image-editor β Implementation Plan
Companion to analysis.md. Turns the research into a concrete, phased build. Reuses
image_selector's conventions: PySide6/Qt UI, Qt-decoupled pure-array ops, atomic Unicode-safe full-resolution save.Date: 2026-09-11 Β· Status: planning
Guiding constraints (from analysis Β§0)
- Full-resolution, non-destructive β preview on a proxy, always render the final at native resolution with format/quality control.
- Photoshop-capable, phone-app-simple β power (masks/layers/inpainting) lives under a direct-on-image UI. Every feature ships an algorithm and a simple interaction, together.
- Models are optional β phases 1β5 run with zero ML deps; ML degrades gracefully when a model/GPU is absent.
Target architecture
MVC like image_selector, but the "model" grows from a flat EditState into a
Document (layer stack). The core is Qt-free so it can back an MCP server later.
image-editor/
βββ main.py # entry point β boots Qt, opens Document, wires window
βββ config.py # JSON config (~/.config/image-editor/)
βββ img_io.py # COPIED from image_selector β Unicode-safe imread/imwrite
βββ core/ # zero Qt imports β pure numpy/opencv
β βββ document.py # Document, layer stack, render(), history
β βββ layers.py # Layer types (Adjustment, Heal, Pixel, ...)
β βββ mask.py # Mask type + sources (shapes, brush, parametric, ML)
β βββ ops.py # ported edit_ops.py β brightness/contrast/curves/...
β βββ film_luts.py # COPIED from image_selector (film sims as ops)
β βββ curves.py # monotone-spline β 256-LUT
β βββ blend.py # alpha composite, blend modes, seamlessClone, colour-match
β βββ heal.py # inpaint dispatch: telea | patchmatch | lama
β βββ render.py # proxy vs full-res rendering, export (quality/format)
β βββ backends/ # ML wrappers, lazy-imported, optional
β βββ segment.py # SAM 2 + rembg (returns a Mask)
β βββ inpaint_lama.py # LaMa erase (+ optional SD)
βββ widgets/ # Qt view layer
β βββ main_window.py # window, toolbar, keyboard, open/save
β βββ canvas.py # zoom/pan preview (port preview_widget.py) + gestures
β βββ tool_overlay.py # on-canvas brush/shape/handles (port crop overlay)
β βββ tools_panel.py # the simple tool rail (Erase, Adjust, Curves, Crop...)
β βββ adjust_panel.py # slider/curve UI for the active tool
βββ app_controller.py # actions β Document β widgets
βββ requirements.txt
βββ docs/{analysis.md, plan.md, changelog.md, versioning/...}
Core data model
# core/mask.py
@dataclass
class Mask:
data: np.ndarray # float32 HxW in [0,1], image-resolution (or None = all-ones)
invert: bool = False
feather_px: float = 0.0 # gaussian fallback
edge_aware: bool = False # guided-filter refine against image
def resolve(self, img) -> np.ndarray: ... # -> concrete float32 HxW in [0,1]
# core/layers.py
@dataclass
class Layer:
kind: str # "adjust" | "heal" | "pixel"
params: dict # op name + values, or heal engine, or rgba+transform
mask: Mask | None = None
blend_mode: str = "normal"
opacity: float = 1.0
visible: bool = True
# core/document.py
@dataclass
class Document:
base: np.ndarray # immutable original BGR (full res)
layers: list[Layer]
crop_rect: tuple | None = None
rotation: int = 0
def render(self, scale: float = 1.0) -> np.ndarray: ... # proxy or full-res
render() = start from (rotated, cropped) base, apply each visible layer
top-to-bottom via blend.composite(base, effect, mask, mode, opacity). The
same method renders the proxy (scale<1, for live preview) and the full-res
export (scale=1) β guaranteeing what you see is what you save.
Phase 0 β Project scaffold (0.5 day)
main.py,config.py,img_io.py(copy),requirements.txt(PySide6,opencv-contrib-python,numpy,Pillow,send2trash).widgets/main_window.py+widgets/canvas.py: open an image, zoom/pan/fit (portpreview_widget.py), Save As with format + JPEG-quality control (atomic tempfile βshutil.moveβos.utime, ported fromimage_selector).- Milestone: open a 24 MP photo, zoom/pan smoothly, save a full-res copy at chosen quality. This alone already beats the phone-app resolution loss.
Phase 1 β Document, layers, compositor, masks (3β4 days) β the spine
core/document.py,core/layers.py,core/blend.py(alpha composite + core blend modes: normal/multiply/screen/overlay).core/mask.pywith sources: brush, circle/ellipse, linear/radial gradient, parametric/luminosity (mask from luminance sliders).- Feathering:
cv2.GaussianBlur(cheap) + edge-aware viacv2.ximgproc.guidedFilterusing the image as guide. widgets/tool_overlay.py: paint/drag masks directly on the canvas; live proxy preview with the 50 ms debounce pattern fromimage_selector.- Undo/redo stack over layer operations.
- Milestone: brush a region, see a visible mask, feather it edge-aware.
Phase 2 β Local adjustments (2 days)
- Port
edit_ops.pyβcore/ops.pyandfilm_luts.py(unchanged math). - Wrap each op as an AdjustmentLayer; global = all-ones mask (one code path).
widgets/adjust_panel.py: sliders for exposure/contrast/saturation/shadows/ highlights + film-sim picker.- Simple UX: "drag on the sky β it gets a darkened-sky adjust layer with an auto luminosity+gradient mask" β no layer jargon exposed.
- Milestone: apply contrast to only the shadows; brighten only a brushed area.
Phase 3 β Curves (1β2 days)
core/curves.py: control points β monotone cubic spline β 256-LUT (hand-rolled PCHIP to avoid the SciPy dep) βcv2.LUT.- Master RGB + per-channel R/G/B via a single
(256,1,3)LUT; Lab L/a/b mode later. - Curve editor widget (draggable points on a histogram backdrop); curves are just another op β automatically maskable + layerable.
- Milestone: S-curve contrast; per-channel colour grade; masked curve.
Phase 4 β Classical heal / erase (1β2 days)
core/heal.py: dispatch by mask size βcv2.inpaint(Telea) for tiny defects, PatchMatch for textured self-similar backgrounds.- HealLayer stores the mask + engine; result cached, re-render aware.
- Milestone: remove dust/blemishes/small distractions cleanly, CPU-only.
Phase 5 β Seamless paste + colour match (2 days)
core/blend.py:cv2.seamlessClone(NORMAL/MIXED) for pasted regions; Lab mean/std colour transfer +skimage.exposure.match_histograms(addscikit-image) to make a filled/pasted region match its surroundings.- Auto "match colour to surroundings" runs after any heal/paste, then feather.
- Milestone: paste an object from another photo with an invisible seam; colour-match a patch.
Phases 0β5 ship a genuinely useful editor with zero ML dependencies. Everything below is additive and optional.
Phase 6 β AI segmentation (2β3 days)
core/backends/segment.pybehind a lazy import + capability check: SAM 2 (click/box β precise mask) and rembg/UΒ²-Net (one-click subject or background cutout). Output is a plainMaskβ flows into the existing engine.- Run via ONNX Runtime (CPU default; GPU if available). Missing model/dep β feature hidden, manual brush still works.
- UX: tap the person β mask appears; adjust with brush.
- Milestone: one tap selects a person accurately.
Phase 7 β LaMa erase (2β3 days) β the headline feature
core/backends/inpaint_lama.py(iopaint/simple-lama-inpainting, ONNX/PyTorch).- Wire "Erase" to auto-pick engine: tiny mask β
cv2.inpaint; person/object mask β LaMa; then auto colour-match + feather (Phase 5). - Graceful fallback to PatchMatch when LaMa unavailable.
- Milestone: tap a person β Erase β they're gone, background plausibly filled, saved at full resolution.
Phase 8 β Optional extras (as desired)
- Stable-Diffusion inpaint/outpaint ("erase and replace withβ¦", extend canvas) β GPU-gated.
- Port
ai_edit.pyClaude/Ollama bridge for text-prompt global looks. - RAW (
rawpy) / HEIC (pillow-heif) input; batch apply a recipe.
Phase 9 β Pro-grade capabilities (the "professional look")
From the professional-editing research in analysis.md Β§11. Ordered by leverage on a professional result Γ· effort. Items 9.1β9.6 are pure OpenCV/NumPy (no new deps) and slot into the existing layer/mask/curve engine β do these first. 9.7β9.10 follow the optional-backend pattern proven with LaMa/SAM.
No new dependencies (highest leverage first)
- 9.1 β Colour grading (HSL + colour balance) β per-hue saturation/luminance
targeting, and shadow/midtone/highlight colour wheels (split-tone). The biggest
"pro colour" lever we lack (Capture One's headline). New
adjustops; maskable like the rest. (2β3 days) - 9.2 β White-balance eyedropper + Kelvin temp β click a neutral pixel to set WB; temperature in approximate Kelvin. (1 day)
- 9.3 β Clarity / Texture / Dehaze β midtone local-contrast (unsharp on a large radius), fine-detail texture, and dark-channel-prior haze removal. Maskable. (2 days)
- 9.4 β Sharpening (capture + output) β unsharp / high-pass sharpen, plus an output-sharpening step at export sized for screen vs print. (1β2 days)
- 9.5 β Dodge & burn tool β a dedicated brush that paints local exposure up/down; mostly reuses the masked-exposure layer path. (1 day)
- 9.6 β Frequency separation (skin) β split low (tone) / high (texture) frequency so skin can be smoothed while keeping pores; a portrait staple. Maskable. (1β2 days)
- 9.7 β Finishing: vignette + film grain β creative edge darkening and grain to finish a graded look. (1 day)
- 9.8 β Presets + basic batch β save an adjustment recipe (an
.ieditwithout the pixel layers) and apply it to any image or a folder. Consistency = professionalism. Reusescore/project.py. (1β2 days)
Optional backends (bigger, higher image-quality tier)
- 9.9 β RAW input (
rawpy/ libraw) β the biggest image-quality gap: true highlight recovery and white balance come from RAW, not JPEG. Also HEIC (pillow-heif). (2β3 days) - 9.10 β Denoise β classical
cv2.fastNlMeansDenoisingColorednow; an AI denoise model later (the DxO DeepPRIME niche) for high-ISO. (classical 1 day; ML more) - 9.11 β Lens & perspective correction β distortion / vignetting / chromatic-aberration
and keystone / horizon straighten via OpenCV
undistort+ perspective transform; manual sliders first, lens profiles later. (2β3 days) - 9.12 β AI super-resolution / upscale β Real-ESRGAN via ONNX Runtime (reuses the
lazy
core/backends/pattern) to enlarge for print without softening; optional face recovery. β οΈ check model licence. (2β3 days)
Suggested order: 9.2 β 9.4 β 9.3 β 9.1 β 9.5 β 9.8 β 9.6 β 9.7, then the backend tier 9.9 β 9.10 β 9.12 β 9.11. Until RAW/denoise/upscale land, pair image-editor with a free RAW processor (Darktable / RawTherapee) for those steps.
Cross-cutting concerns
- Performance: proxy preview at longest-side β€ ~2000 px; full-res only on
export. Cache each layer's rendered output; invalidate on param/mask change.
Heavy ops (heal/ML) run in a
QThread(port_AiWorker) so the UI never blocks. - Non-destructive project files: optional
.ieditsidecar (JSON of the layer stack) so an edit can be reopened and revised; the pixel export stays separate. - Licensing: pin exact model versions; record each weight's licence before shipping (some are non-commercial) β see analysis Β§8.
- Testing: pytest over
core/(pure arrays, no Qt) β golden-image tests for ops/curves/blend; mask math unit tests; a tiny fixture image set. - Docs & versioning: mirror
image_selectorβdocs/versioning/vX.Y.Z/withplan.md/changelog.md, and keepdocs/analysis.mdcurrent.
Rough sequencing
| Milestone | Phases | Outcome |
|---|---|---|
| M1 β "better than the phone" | 0β3 | full-res open/edit/save, masks, local adjust, curves β no ML |
| M2 β classical retouch | 4β5 | heal small stuff, seamless paste, colour match |
| M3 β "erase anyone" | 6β7 | SAM tap-select + LaMa erase, the headline feature |
| M4 β creative/optional | 8 | SD replace/outpaint, AI looks, RAW/HEIC, batch |
| M5 β pro-grade look | 9 | colour grading/HSL, WB picker, clarity/dehaze, sharpening, dodge & burn, frequency separation, presets/batch, then RAW Β· denoise Β· upscale Β· lens correction |