Layered is an open-source image and game-asset editor built in Python with PyQt6, inspired by Paint.NET. It delivers a familiar non-destructive workflow — draw, stack layers, blend, export — without ever leaving your Python toolchain.
Built for game developers. Export every layer as its own PNG alongside a
manifest.jsoncarrying offsets, blend modes, and visibility — so your engine can reassemble the scene at runtime.
🎨 Drawing Toolkit
Brush · Eraser · Fill Bucket · Line · Rectangle · Ellipse · Color Picker · Text
Paint assets from scratch or retouch imports with a full suite of drawing primitives.
🗂 Non-Destructive Layers
- Per-layer opacity and visibility toggle
- 12 blend modes — Normal, Multiply, Screen, Overlay, Soft Light, Darken, Lighten, Add, Subtract, Difference, Color, Saturation
- Reorder, rename, duplicate, and group layers
- Original pixel data is never destroyed — every operation is fully reversible
↶ Full Undo / History
Every brush stroke, filter, and layer operation is tracked. Browse the history panel and jump to any prior state instantly.
📦 Export Formats
| Format | Description |
|---|---|
| PNG / JPEG / WEBP | Flattened composite export |
Per-layer PNG + manifest.json |
Offsets, blend modes, visibility, opacity — game-engine ready |
| Multi-tab Projects | Work on several files simultaneously |
🔌 Plugin System
Drop a .py file into Plugins/ and it's live. Plugins can register tools, filters, or menu actions, declare typed settings (auto-generated dialog), and run fully sandboxed — a crashing plugin gets logged and isolated while the editor keeps running.
📋 Logging & Diagnostics
logs/layered.log— full session activitylogs/errors/— per-crash reports with stack trace + context- In-app Console panel mirrors log output live
# 1. Clone the repo
git clone https://github.com/NightHawkHSI/Layered.git
cd Layered
# 2. Install dependencies
pip install -r requirements.txt
# 3. Launch
python main.pyRequirements: Python 3.9+ · PyQt6 >= 6.6 · Pillow >= 10.0 · numpy >= 1.26
💡 No Python? Grab the prebuilt Windows binary from the Releases page — no setup needed.
Layered ships with 17+ working plugins in Plugins/ — ready to use or read as templates.
| Plugin | Type | Description |
|---|---|---|
grayscale |
🔵 Filter | Desaturate to grayscale |
invert |
🔵 Filter | Invert RGB / per-channel |
brightness_contrast |
🔵 Filter | Brightness + contrast sliders |
sharpen |
🔵 Filter | Unsharp mask sharpening |
posterize |
🔵 Filter | Reduce color levels |
gradient_map |
🔵 Filter | Remap luminance to a gradient |
color_replace |
🔵 Filter | Swap one color for another |
outline_filter |
🔵 Filter | Edge outline effect |
glow_filter |
🔵 Filter | Soft outer glow |
drop_shadow |
🔵 Filter | Drop shadow with offset & blur |
normal_map |
🔵 Filter | Generate normal map from height |
background_remove |
🔵 Filter | Knock out flat / chroma background |
tile_fix |
🔵 Filter | Make textures seamless |
pixel_art_resize |
🔵 Filter | Nearest-neighbor upscale |
crop_tool |
🟠 Action | Crop canvas to selection |
flip_tool |
🟠 Action | Flip horizontal / vertical |
grid_overlay |
🟠 Action | Toggle grid overlay |
Layered/
├── 📄 main.py # Entry point
├── 📄 requirements.txt
├── 📄 build.bat # PyInstaller one-file build (Windows)
├── 🖼 Icon.png / Icon.ico
│
├── 📁 app/
│ ├── main_window.py # Menus, docks, plugin wiring
│ ├── 📁 core/ # Domain model
│ │ ├── layer.py # Layer + LayerStack
│ │ ├── project.py # .layered project document
│ │ ├── history.py # Undo / redo stack
│ │ ├── blending.py # Blend-mode math (NumPy / numba)
│ │ ├── image_ops.py # Pixel ops (fill, transforms, etc.)
│ │ └── adjustments.py # Adjustment-layer math
│ ├── 📁 render/ # Compositing surfaces
│ │ ├── canvas.py # Interactive canvas widget
│ │ ├── gpu_renderer.py # moderngl GPU compositor (opt-in)
│ │ └── tile_renderer.py # Tiled CPU compositor
│ ├── 📁 io/ # Persistence
│ │ ├── export.py # Composite + per-layer export
│ │ ├── project_io.py # .layered file save / load
│ │ ├── session.py # Multi-document session state
│ │ └── brush_loader.py # Brush-preset discovery from Brushes/
│ ├── 📁 plugins/ # Plugin system
│ │ ├── plugin_api.py # Public plugin API
│ │ ├── plugin_loader.py # Plugin discovery + sandbox
│ │ ├── tool_loader.py # Tool discovery from Plugins/Brushes/
│ │ └── tools.py # Tool base class + ToolContext + helpers
│ ├── 📁 app_ui/ # App-shell support
│ │ ├── theme.py # Dark / light theme engine
│ │ ├── preferences.py # User preferences (prefs.json)
│ │ └── logger.py # Logging + crash reporter
│ ├── 📁 controllers/ # History / paste / selection controllers
│ └── 📁 ui/ # Qt panels (layers, tools, color, history,
│ # text, console, project tabs, dialogs)
│
├── 📁 Plugins/ # ← Drop your plugins here
│ ├── Brushes/ # Tool plugins, grouped by folder
│ │ ├── _shared.py # One-stop import for every brush
│ │ └── <Category>/<Tool>/ # Each tool is a folder
│ │ ├── tool.py # Required — defines TOOL_CLASS = MyTool
│ │ └── tool.json # Optional — display name, id, icon, category override
│ └── *.py # Filter / action plugins (flat .py files)
│
├── 📁 Brushes/ # ← Brush presets (size/hardness/opacity/...)
├── 📁 docs/
│ ├── PLUGIN_API.md # Full plugin API reference
│ └── build_brush.md # How to build a brush (folder layout, lifecycle, helpers)
└── 📁 logs/ # Generated at runtime
Drop a .py file in Plugins/ and subclass Plugin — that's it.
# Plugins/my_filter.py
from PIL import Image, ImageOps
from app.plugins.plugin_api import Plugin, PluginContext
class GrayscalePlugin(Plugin):
name = "Grayscale"
version = "1.0.0"
def register(self, ctx: PluginContext) -> None:
ctx.register_filter("Grayscale", self.apply)
@staticmethod
def apply(image: Image.Image) -> Image.Image:
return ImageOps.grayscale(image.convert("RGB")).convert("RGBA")| Kind | Where it appears | Method |
|---|---|---|
| Tool | Toolbox panel | ctx.register_tool(name, Tool) |
| Filter | Filters menu |
ctx.register_filter(name, fn, settings=...) |
| Action | Plugins menu |
ctx.register_action(name, fn, settings=...) |
Filters and actions accept typed Setting specs — int, float, bool, choice, color, string — and the host auto-generates the settings dialog, passing values as keyword arguments.
📘 See docs/PLUGIN_API.md for the full API surface and invert.py for a complete settings example.
Tools and brush presets live in two separate trees:
| Folder | Drives | Layout |
|---|---|---|
Plugins/Brushes/ |
The Tools dock — every group folder becomes a split-button with its sub-tools in a dropdown | <Group>/<Tool>/tool.py |
Brushes/ |
The brush preset picker — preset JSON files per category | <Category>/<preset>.json |
// Brushes/Inking/04_marker.json
{ "name": "Marker", "icon": "🖊", "size": 20, "hardness": 0.95, "opacity": 1.0, "spacing": 0.05 }Every brush is a folder under Plugins/Brushes/<Category>/<ToolName>/ with a
tool.py (and optional tool.json). Each Tool subclass declares its own
icon, shortcut, and build_ui() — settings render in the per-tool
settings toolbar at the top of the window.
# Plugins/Brushes/Basic/Brush/tool.py
import importlib.util as _iu, sys as _sys
from pathlib import Path as _P
_KEY = "_layered_brushes_shared"
if _KEY not in _sys.modules:
_spec = _iu.spec_from_file_location(_KEY, _P(__file__).resolve().parents[2] / "_shared.py")
_mod = _iu.module_from_spec(_spec); _sys.modules[_KEY] = _mod; _spec.loader.exec_module(_mod)
_sh = _sys.modules[_KEY]
Tool = _sh.Tool; Layer = _sh.Layer
build_brush_settings_ui = _sh.build_brush_settings_ui
class BrushTool(Tool):
name, tool_id = "Brush", "brush"
icon, shortcut = "🖌", "B"
is_default = True
def __init__(self, ctx=None):
super().__init__(ctx)
self.brush_size, self.brush_hardness, self.brush_opacity = 20, 0.8, 1.0
def build_ui(self, parent, ctx):
return build_brush_settings_ui(self, parent,
fields=("size", "hardness", "opacity"))
def press(self, layer, x, y): ...
def move(self, layer, x, y): ...
def release(self, layer, x, y): self._last_pt = None
TOOL_CLASS = BrushToolPlugins/Brushes/_shared.py re-exports stdlib, PIL, PyQt6, painting helpers,
SliderField, build_brush_settings_ui, and the Tool/Layer/ToolPhase
bases — one import covers every brush.
📘 Full guide with class-attr reference, lifecycle methods, painting helpers,
and shape/selection bases: docs/build_brush.md.
| Mode | Effect | Best For |
|---|---|---|
| Normal | Standard alpha compositing | Everything |
| Multiply | Darkens — multiplies values | Shadows, tinting |
| Screen | Lightens — inverts multiply | Glows, highlights |
| Overlay | Contrast boost (multiply + screen) | Detail enhancement |
| Soft Light | Gentle dodge / burn driven by the top layer | Subtle shading |
| Darken | Keeps the darker pixel | Soft shadows |
| Lighten | Keeps the lighter pixel | Soft highlights |
| Add | Brightens additively (linear dodge) | Bloom, fire, neon |
| Subtract | Darkens subtractively | Dark burn effects |
| Difference | Highlights where layers differ | Masking, debug |
| Color | Hue + saturation of top, luma of base | Recoloring, tinting |
| Saturation | Saturation of top, hue + luma of base | Vibrance tweaks |
All modes operate on premultiplied RGBA via NumPy in
app/core/blending.py(numba-accelerated when available).
| Location | Contents |
|---|---|
logs/layered.log |
Full session activity, INFO+ |
logs/errors/<timestamp>.txt |
Stack trace + context per crash |
| In-app Console panel | Live mirror of the log stream |
Plugins get their own sandboxed logger (layered.plugin.<name>) — use ctx.logger instead of print so output lands in both the log file and the console panel.
Windows one-file build via PyInstaller:
build.batOutput drops in GitHub/Release/. The bundled Plugins/, Brushes/, and Icon.ico folders are picked up automatically.
- Fork the repo and create a branch:
git checkout -b feature/my-thing - Make changes — keep functions small, prefer Pillow / NumPy over hand-rolled loops
- Test — run the app and verify nothing regressed
- Open a PR with a clear description of what changed and why
Bug reports and feature requests live in Issues. All contributions are welcome!
Distributed under the terms described in LICENSE.
