The Layers Dock is built around one rule: the base view's pixels are never changed by the layer stack. You can add a dozen layers, tweak their blend modes and opacities, add masks, scale and rotate them — and the view you're working on still contains the original image, untouched, with full undo intact. The composite you see lives entirely in the floating preview window. Only three operations ever write back:
Drag another open view's title bar (or thumbnail) into the Layers dock. A new layer appears at the top of the stack, with that view as its live source — if you later run a tool on the source view that changes its pixels, the composite re-renders automatically to pick up the new data. The layer name shows the source view's title and updates when you rename the view.
You can also drop the same view multiple times for different roles (one copy as a blend layer, another as a mask), or drop a document that isn't open as a subwindow — SASpro resolves it by document UID, pointer, or file path depending on what the drop payload carries.
Each layer row has four sub-rows:
Pick any open view from the Mask dropdown to use its luminance as this layer's mask. Invert flips the mask. Clear removes the mask assignment. Alternatively, drop a view directly onto an existing layer row — SASpro recognizes the mask MIME type and assigns it as this layer's mask automatically, including any invert and feather settings carried in the drop payload.
Each layer has its own levels adjustment that applies before the blend mode runs. Enable the checkbox, set black point, midtones, and white point (0 to 1 for all three). Uses the same PixInsight-style midtones transfer function as SASpro's main Levels tool, so adjustments translate predictably if you later bake the layer. Midtones = 0.5 is neutral; lower brightens, higher darkens.
The base view row (greyed at the bottom of the list) also has a levels block that applies to the base image before any layers are composited on top. Useful for setting a stretch on the base that you don't want to commit until you merge.
When the blend mode is set to Sigmoid, two extra controls appear: Center (0 to 1, default 0.5) and Strength (0.1 to 50, default 10). These control the shape of the luma-dependent blend curve — see §6 under Sigmoid.
Click 👁 Preview to open the floating preview window. It's non-modal, so you can keep it visible while you tweak the dock. Three controls at the top:
30 modes in six categories. All operate in float32 [0,1] space with 3-channel inputs; monochrome layers are promoted to RGB first.
| Mode | Formula (base a, source s) | Use for |
|---|---|---|
| Multiply | a·s | Darkening overlays, star-mask integration |
| Darken | min(a, s) | Per-channel darker of the two |
| Darker Color | s if Y_s ≤ Y_a else a | Pick the darker whole pixel by luminance |
| Color Burn | 1 - (1-a)/s | Deepen dark areas, enhance shadow contrast |
| Mode | Formula | Use for |
|---|---|---|
| Screen | 1 - (1-a)(1-s) | Classic stacking of light layers, nebula overlays |
| Lighten | max(a, s) | Per-channel brighter of the two |
| Lighter Color | s if Y_s ≥ Y_a else a | Pick the brighter whole pixel by luminance |
| Color Dodge | a/(1-s) | Boost highlights, HDR-ish bloom |
| Add | clip(a + s) | Linear stacking (handle with care; clips) |
| Relativistic Addition | (a+s)/(1+a·s) | Non-clipping addition — bounded in [0,1] by construction |
| Mode | Formula | Use for |
|---|---|---|
| Overlay | Multiply where a ≤ 0.5, Screen above | Classic contrast boost based on base luminance |
| Soft Light | (1-2s)·a² + 2sa (W3C) | Gentler contrast; good for texture layers |
| Hard Light | Multiply/Screen conditional on s | Overlay's cousin, conditioned on source instead |
| Pin Light | max(a, 2s-1) if s>0.5, else min(a, 2s) | Selective shadow/highlight replacement |
| Mode | Formula | Use for |
|---|---|---|
| Difference | |a - s| | Alignment check, gradient reveal |
| Difference (Squared) | (a - s)² | Weighted difference emphasizing larger offsets |
| Exclusion | a + s - 2as | Softer Difference, less contrasty midtones |
| Negation | 1 - |1 - a - s| | "Bright difference" — equal pixels go white |
| Subtract | clip(a - s) | Gradient / pedestal / synthetic-flat removal |
| Divide | a/s | Flat-fielding, illumination correction |
| Grain Extract | a - s + 0.5 | Pull a gradient out as a mid-gray map for inspection |
| Grain Merge | a + s - 0.5 | Re-add an extracted gradient layer |
| Mode | Formula | Use for |
|---|---|---|
| Average | (a + s)/2 | Simple 2-image mean (fixed opacity) |
| Geometric Mean | √(a·s) | Sits between Multiply and Average; preserves zeros |
| Mode | Behavior | Use for |
|---|---|---|
| Luminosity | Keep base's color, use source's luminance | LRGB combination: swap in a sharper L |
| Color | Keep base's luminance, use source's color | Chromatic overlays: paint color onto a mono master |
| Sigmoid | Luma-weighted blend; w = 1/(1+exp(-k(Y-c))) | Blend only where base is bright (bright-area overlays) |
The Luminosity mode uses the full luminancerecombine pipeline (same as SASpro's Luminance Recombine tool) so highlight soft-knee and other refinements are available. The Color mode is its inverse: it rescales the source channels by the ratio of base luma to source luma, preserving the base's brightness distribution while replacing its hue and saturation.
Sigmoid is unique: the blend weight is a logistic function of the base's luminance, with user-controlled center and strength. At center = 0.5, strength = 10 (defaults), dark base pixels keep mostly the base, bright base pixels transition smoothly to the source. Great for pasting bright stars from a short-exposure frame onto a long-exposure stack — the sharp stars replace the bloated ones only where the base is bright.
The Transform… button on each layer row opens a dialog with five numeric inputs:
Changes apply live with a 120 ms debounce — tweak a slider and the preview catches up without a tool call per keystroke. Hitting Reset zeros everything. Hitting Cancel restores the transform that was active before you opened the dialog. Hitting OK commits.
The transform applies to both the layer pixels and the layer's mask (if any), so a rotated galaxy stays masked correctly. The identity transform (no translation, no rotation, scale = 1,1) is detected automatically and skipped entirely — no warp pass runs when the transform is a no-op, which is the common case for most layers and keeps composites fast.
WIMI composites run in two tiers (both debounced to coalesce rapid changes):
Both tiers coalesce: dragging an opacity slider across its full range in half a second produces exactly one fast render per 80-ms window and one full-res render per 350-ms window — not one per slider tick. A generation counter on the worker queue silently discards any stale full-res result that lands after you've moved on.
While you drag transform parameters or adjust levels, you're interacting with the fast preview. When your mouse stops, the full-res catches up. If you push a long-running tool on a source view (say a 30-second deconvolution), the preview auto-refreshes once the tool completes — the dock listens to each source document's changed signal.
Flattens the full-resolution composite and replaces the current view's image as a standard SASpro undoable edit (step name: "Layers Merge"). The layer stack is cleared from the view. If you don't like the result, Ctrl+Z gets your original back.
Flattens the composite into a brand-new document. The source view is left completely untouched and its layer stack remains active. The new document's title appends "_merged" to the source view's title. Useful when you want to keep iterating on the layers while also saving a snapshot of the current state.
Select two or more layer rows (Ctrl+click or Shift+click), then click this. SASpro composites just those layers against whatever's below them, bakes the result as a new raster ImageLayer (no source document, just pixels), and replaces the selected range with that one baked layer. Layers above and below the selection are preserved exactly. Useful for consolidating a sub-stack you've finished tweaking so later changes to other layers don't trigger expensive recomposites of the frozen sub-group.
Removes every layer from the current view's stack. The base view returns to its untouched pixels. Doesn't delete any source documents — you can drop them back in to rebuild.
M(x; m) = ((m-1)x) / ((2m-1)x - m) with the boundary cases M(0) = 0, M(m) = 0.5, M(1) = 1.cv2.resize INTER_NEAREST) and affine warping (cv2.warpAffine). Falls back to NumPy mgrid/indexing when OpenCV isn't available. Transform math built from standard 3×3 affine composition.MIME_VIEWSTATE (adds a layer from a view) and MIME_MASK (assigns a view as a mask on the hovered layer) are defined in setiastro.saspro.dnd_mime and shared with the SASpro view manager so any subwindow drag source works.
SASpro uses the PixInsight MTF for all levels adjustments (base and per-layer):
with boundary conditions enforced explicitly: M(0) = 0, M(m) = 0.5, M(1) = 1. Valid for midtone m in (0, 1); SASpro clamps to [1e-6, 1 - 1e-6] to avoid the singularity at m = 0.5 where the denominator would vanish for x = m. Vectorized across all channels in one pass.
Per channel, in float32 [0, 1]:
Black point is subtracted first, white point normalizes the result to [0, 1] with clipping, then the midtones curve applies. If the user sets wp ≤ bp (an impossibility that would blow up the normalization), SASpro silently widens wp to bp + 1e-8 so the pipeline never crashes.
All luminance-aware operations (Lighter Color, Darker Color, Sigmoid, Luminosity, Color, mask luminance extraction) use Rec. 709 primaries:
Clamped to [0, 1] after weighting. Chosen because it matches the sRGB gamut primaries most users' calibrated monitors are displaying, so the perceived luma matches what you see on screen.
Each mode operates per-channel on base a and source s, both in [0, 1]. The exact implementations:
All division operations use ε = 1e-6 in the denominator to guard against division by zero. All results are clipped to [0, 1] where the formula could otherwise overshoot (Color Dodge/Burn, Add, Linear Burn, Linear Light, Vivid Light, Divide). Difference and Subtract naturally stay in range; Multiply, Screen, Soft Light, and Relativistic Addition are provably bounded in [0, 1] for inputs in [0, 1] and don't need explicit clipping.
Unique among the modes because the blend weight depends on the base's luminance:
Y is the Rec. 709 luminance of the base, c is the center (default 0.5), k is the strength (default 10). At the default, dark base pixels (Y < 0.3) stay essentially 100% base; bright base pixels (Y > 0.7) transition to essentially 100% source. The strength parameter controls how sharp the transition band is — small k gives a wide ramp, large k gives a nearly-binary mask.
Luminosity delegates to the full SASpro luminancerecombine pipeline (compute Y_src with Rec. 709, then recombine_luminance_linear_scale keeps the base's chrominance). Color is the mirror operation implemented inline:
This rescales every source channel by the ratio of base luma to source luma, which preserves the base's brightness distribution while replacing its hue and saturation with the source's.
After the blend mode produces a candidate pixel, SASpro combines it with the base using layer opacity (and mask if present):
With no mask, M = 1 everywhere and alpha_map degenerates to the scalar opacity. When Invert is on, M becomes 1 − M before the opacity multiplication.
The 3×3 affine matrix is built from the user's parameters as a composition of translate, pivot-translate, rotate, scale, and inverse-pivot-translate:
where T_t is translation by (t_x, t_y), T_p is translation by the pivot (default = image center), R is a 2D rotation by θ, S is diagonal scale with s_x and s_y, and T_{-p} is translation by the negative pivot. The composition reads right-to-left: translate to origin, scale, rotate, translate back to the pivot, then apply the user's translation.
For sampling, we invert and apply dest → source:
Nearest-neighbor lookup fills the destination grid. OpenCV's cv2.warpAffine provides a fast C path when available; otherwise SASpro uses a vectorized NumPy implementation with explicit valid-region masking. Singular matrices (e.g. s_x = 0) are caught and return the fill color.
Two debounced timers drive the preview:
composite_stack with max_dim set to the preview canvas longest side (minimum 512 px). The whole composite runs at preview resolution.QThread + _CompositeWorker that runs the composite at native base resolution. On completion, the worker emits a done(generation, result) signal.A generation counter on LayersDock increments every time the composite is scheduled. When a worker emits its result, SASpro compares the emitted generation to the current one — if it's older, the result is silently discarded. This cleanly handles the case where you drag an opacity slider rapidly: workers for intermediate values still finish (they can't be interrupted mid-render) but their results don't flicker on-screen because they're stale by the time they arrive.
The preview downscale is a straightforward ratio against the longest edge:
Transform parameters that are specified in pixels (translate, pivot) are scaled by this factor so geometry stays correct:
Rotation and scale factors don't need adjustment — they're angles and ratios. The transform is applied to the already-resized layer canvas, not the full-res one, so a translate-200-rotate-15 transform at full-res becomes translate-50-rotate-15 at 4× downscale, producing a geometrically identical preview at ¼ the cost.
Most layers have no transform active most of the time. The composite engine checks each layer's transform against a small tolerance (1e-6 on each parameter) and if everything is identity, skips the warp step entirely. This is a significant optimization: a 10-layer stack with no transforms runs ~10× faster than one with transforms on every layer, because warps on full-res 4K+ images dominate composite time.
Converting the raw base image (which can be mono, uint8, uint16, float, 1-channel, 3-channel) to the composite engine's canonical form (float32, 3-channel, [0, 1]) is non-trivial. The dock caches this conversion keyed by id(base_doc.image) — Python's object identity — and only rebuilds when the base document actually produces a different array object (which happens when the base is edited through SASpro's normal pipeline). Base levels adjustments apply on top of the cached conversion, so moving the base black-point slider doesn't re-convert the whole image.
While the preview window is open, the dock listens to two signals on the current view:
document.changed — fires when the base document's pixels change (someone pushed an edit).layer_sources_changed — fires when any source document assigned to a layer (or its mask) changes.Either signal triggers _force_refresh_preview, which drops the cached before-image identity (so the cached conversion rebuilds) and schedules a new composite via the standard debounced timers. This means: run Curves on a view you're using as a layer, and the composite in the preview updates on its own. Switch to a different base view and the dock rewires its watchers automatically.
Three design choices keep the layer stack cheap at preview time:
Full-resolution composites, triggered by the 350 ms timer, do run at native resolution — but on a worker thread, in the background, where even a multi-second render doesn't block the UI.