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.
