Pixel Math

An expression language for combining images arithmetically. Write an expression using img (the active view), the identifiers for any other open views, and the built-in function library; the expression is evaluated per-pixel and the result replaces the active image or opens as a new document. Think of it as a scratchpad where every pixel in your image is a variable, and every open view is in scope.

PixelMath is the most flexible tool in SASpro by a wide margin, which is also what makes it the hardest to approach cold. This document lays it all out: the two modes, the variables, the operators and broadcasting rules, every function in the library, the common recipes, and the mechanics of the dialog itself.

1. The two modes

At the top of the editor column, two radio buttons pick the mode:

2. The dialog at a glance

Left column (controls, scrollable):

Right column (preview):

3. Variables

Every open view is in scope. The dialog auto-generates a Python-safe identifier for each one from its window title:

Three other sets of names are always available:

4. Operators

OperatorMeaningExample
+ − * /Standard arithmetic (element-wise) (img + bias) / 2
** or ^Power. Both syntaxes work. img ^ 0.5
~Invert: ~x means 1 − x. ~img (negative)
< <= == != >= > Comparisons. Return boolean arrays; feed them to iff. iff(img < 0.1, 0, img)
view[i] Channel indexing. img[0] is R, [1] is G, [2] is B — each as a 2-D plane. img[0] + img[2]

Mono-friendly indexing. On a mono image, any channel index returns the mono plane itself. So L[2] on a mono L is just L. This means LRGB recipes work regardless of whether L is a mono or RGB document — you can write img * (L[0] / (luma(img) + EPS)) and it does the right thing in both cases.

Caret note. In standard Python, ^ is XOR. PixelMath rebinds it to power for astro-user convenience, so img ^ 0.5 is the square root. Mixing ^ with Python code expectations trips people up; stick with ** if that bothers you.

5. Broadcasting rules

Mono and RGB values mix freely. The usual case:

Reducer functions (med, mean, min, max, std, mad) return per-channel scalars for RGB input, shaped (1, 1, C). These broadcast transparently so img − med(img) subtracts each channel's own median from itself.

PixelMath auto-crops to the common size if two images are mismatched by ≤ 4 px in height or width (common after warps or reprojections that round pixel dimensions differently). Larger mismatches raise a shape error.

6. Preview and auto-stretch

The Preview button runs the current expression and shows the result in the right-hand panel without committing. Everything you see is exactly what Apply will produce.

The Auto-stretch button beside Preview toggles a non-destructive display stretch on the preview panel — it's only for making the preview readable, especially for linear data. It never changes what Apply writes. The dropdown menu offers:

All these preferences persist across sessions via QSettings. If a preview is on screen and you change any auto-stretch setting, the preview is re-rendered automatically.

7. Output routing

The Output group picks what happens when you click OK:

8. Mask integration

If the active document carries a mask, the result of your expression is automatically blended with the untouched source through that mask on Apply — the pixels outside the mask stay untouched, the pixels inside receive the computed result, and feathered mask regions get a proportional blend.

You can also control masking explicitly with the mask() and apply_mask(base, out, m) helpers:

base = img sh = unsharp(img, sigma=1.2, amount=0.8) m = smoothstep(0.1, 0.6, luma(img)) apply_mask(base, sh, m)

This computes a sharpened version, builds a smoothstep weight based on luminance (sharpen only midtones), and blends via apply_mask — which is just base · (1−m) + out · m. This computed-in-expression mask composes with the document's own mask if one exists.

9. The Glossary dialog

The Glossary… button opens a searchable list of every available function with its signature and a one-line description. Type in the search box to filter; double-click an entry to insert its signature at the cursor in whichever editor is active. Covers the entire function library in § 10.

10. Function reference

10.1 Element-wise math

SignatureDescription
clamp(x, lo=0, hi=1) Limit values to [lo..hi] (NumPy clip).
rescale(x, a, b, lo=0, hi=1) Linear map from input range [a..b] to output range [lo..hi]. Values outside [a..b] are extrapolated — wrap in clamp if you want hard bounds.
gamma(x, g) Gamma curve: clip(x, 0, 1) ** g. Values below 1 brighten midtones; above 1 darken.
pow_safe(x, p) Power with an EPS floor on the base, so pow_safe(0, 0.5) doesn't blow up.
abs(x), absf(x)Absolute value.
exp(x), expf(x)Exponential.
sqrt(x), sqrtf(x) Square root; input clipped to ≥ 0 first.
arcsin(x) Inverse sine (radians), input clipped to [−1, 1].

10.2 Logs and hyperbolics

SignatureDescription
log(x), ln(x) Natural log (base e) with EPS floor so log(0) is a large negative, not −Inf.
log10(x)Base-10 log with EPS floor.
log2(x)Base-2 log with EPS floor.
asinh(x) Inverse hyperbolic sine. Useful for astro stretches — preserves sign, behaves like log for large positive values and like x near zero.
sinh(x), cosh(x), tanh(x) Hyperbolic sine, cosine, tangent.

10.3 Reducers (per-channel on RGB)

SignatureDescription
med(x) Median. Returns a scalar for mono, per-channel (1, 1, C) for RGB (so arithmetic broadcasts).
mean(x)Arithmetic mean (per-channel on RGB).
std(x)Standard deviation (per-channel on RGB).
mad(x) Median absolute deviation (per-channel on RGB). Robust noise estimator.
percentile(x, p) Per-channel p-th percentile, returned as a full-size constant image for easy use in subsequent arithmetic.
avg(a, b, …) Element-wise mean of any number of images or scalars (variadic). Not a reducer in the above sense — this averages between multiple arguments, not within one image.

10.4 Element-wise min / max

SignatureDescription
min(x) Reduce: scalar for mono, per-channel for RGB.
min(a, b) Element-wise minimum of two values (broadcasts).
max(x), max(a, b) Same, maximum.
minimum(a, b), maximum(a, b) Explicit element-wise names — use these if you want unambiguity.

10.5 Smooth curves

SignatureDescription
sigmoid(x, k=10, mid=0.5) S-curve centred at mid with steepness k. See § 14.3.
smoothstep(e0, e1, x) Cubic ramp from 0 at x = e0 to 1 at x = e1, with zero derivative at both endpoints. Classical shader primitive.
lerp(a, b, t), mix(a, b, t) Linear interpolation: (1−t)·a + t·b. Both names work.

10.6 Statistics / normalisation

SignatureDescription
normalize01(x) Per-channel rescale to [0, 1]: (x − min) / (max − min).
zscore(x) Per-channel standardisation: (x − mean) / std. Not clipped.

10.7 Channels and colour

SignatureDescription
ch(x, i) Extract channel i (0 = R, 1 = G, 2 = B) as a 2-D plane. x[i] syntax does the same.
luma(x) Rec. 709 luminance as 2-D plane: 0.2126R + 0.7152G + 0.0722B. Mono input passes through.
compose(R, G, B) Stack three 2-D planes into an RGB array.
lumarecom(rgb, L, clip=True) Rec. 709 LRGB recombination: rescale each channel of rgb so the result's luminance equals L, preserving hue. See § 14.4.

10.8 Masks

SignatureDescription
mask() Returns the document's active mask as a 2-D array in [0, 1], resized to the current image. All zeros if there is no active mask.
apply_mask(base, out, m) Standard blend: base · (1−m) + out · m. Accepts 2-D or 3-D masks.

10.9 Filters

SignatureDescription
boxblur(x, k=3) Box blur with kernel size k. OpenCV when available.
gauss(x, sigma=1.0) Gaussian blur with the given sigma.
median(x, k=3) Median filter; k rounded up to odd.
unsharp(x, sigma=1.5, amount=1.0) Classic unsharp mask: x + amount·(x − gauss(x, sigma)).

10.10 Astro-specific

SignatureDescription
mtf(x, m) Midtones Transfer Function (same curve as in Levels / Curves). See § 14.2.

10.11 Conditional

SignatureDescription
iff(cond, a, b) Element-wise where: pick a where cond is true, b otherwise. Accepts scalar or array a/b and broadcasts.
iif(cond, a, b) Alias for iff — both spellings supported because nobody remembers which one it is.

11. Example recipes

The Examples dropdown in the dialog has these pre-filled recipes ready to insert. Here they are with brief explanations — a mental model of what each one does helps when you want to adapt them.

11.1 Basic combinations

11.2 Transforms

11.3 Thresholding and masks

11.4 Multi-view calibration

11.5 Per-channel operations

(Single Expression mode — channel indexing with [i].)

11.6 Small filters

11.7 LRGB recombination

The classical LRGB workflow: pair a stretched luminance (shot through a clear or L filter) with a stretched colour image (RGB) by using the L for structure and the RGB for hue. The lumarecom helper does exactly this:

lumarecom(rgb_view, L_view)

which rescales each channel of rgb_view so the result's Rec. 709 luminance equals L_view, preserving the R/G and B/G hue ratios. See § 14.4 for the math.

12. Favorites

The Save as Favorite button saves whatever is in the current editor to the Favorites dropdown. Favorites persist across sessions (stored in QSettings), so recipes you use often don't need to be rebuilt or remembered. In Separate mode the three channel expressions are stored together with [R], [G], [B] tags; selecting a saved Separate recipe repopulates all three tabs. Right-click any favorite to delete it (also available via the Delete Favorite button).

13. Multiline expressions

Both Single and Separate modes let you write multi-line expressions with intermediate variables:

A = med(img) B = img - A B * 2.0

Every line except the last is executed for its side effects (variable assignment). The last line is the returned result and must be an expression, not an assignment. Writing C = B * 2.0 as the last line returns nothing — the error message is a cue that you need to drop the C = part. (Or add a bare C as the final line.)

The namespace used inside multi-line expressions is a safe subset — no file I/O, no imports, no builtins. Only the functions in § 10 and the variables in § 3 are available.

14. Technical details

All processing on float32 in [0, 1]. The expression is parsed and evaluated by Python's exec / eval in a sandboxed namespace with no built-ins. Operator overloading happens through a lightweight PixelImage wrapper that routes arithmetic to NumPy with the broadcasting / coercion rules described in § 5.

14.1 The PixelImage wrapper

Every image variable is wrapped in a PixelImage object that:

14.2 Midtones Transfer Function

The classical PixInsight MTF, usable both as a tonal transform and as the engine behind Levels and Curves' midtones:

\mathrm{mtf}(x,\,m) \;=\; \frac{(m - 1)\,x}{(2m - 1)\,x \;-\; m}

with m = 0.5 giving the identity, lower values brightening midtones (shifting the mid-level up), higher values darkening them. Fixed points: mtf(0, m) = 0 and mtf(1, m) = 1.

14.3 Sigmoid, smoothstep, lerp

Standard tone-shaping primitives:

\mathrm{sigmoid}(x,\,k,\,m) \;=\; \frac{1}{1 \;+\; \exp\!\left(-k\,(x - m)\right)}
\mathrm{smoothstep}(e_0,\,e_1,\,x) \;=\; t^2(3 - 2t)\,,\qquad t = \mathrm{clip}\!\left(\frac{x - e_0}{e_1 - e_0},\;0,\;1\right)
\mathrm{lerp}(a,\,b,\,t) \;=\; (1 - t)\,a \;+\; t\,b

Sigmoid is the logistic function: smooth S-curve with centre m and slope k at the centre. Smoothstep is a cubic ramp with zero derivatives at both endpoints — the standard no-visible-transition weight function. Lerp is just the linear blend.

14.4 Rec. 709 LRGB recombination

Classical LRGB combines a sharp mono L with a soft RGB C so the result has L's luminance and C's chroma. The implementation computes Rec. 709 luma of the current RGB:

Y(\mathrm{rgb}) \;=\; 0.2126\,R \;+\; 0.7152\,G \;+\; 0.0722\,B

then rescales every channel uniformly by the ratio of target to source luminance:

\mathrm{rgb}' \;=\; \mathrm{rgb} \,\cdot\, \frac{L_{\mathrm{new}}}{Y(\mathrm{rgb}) + \varepsilon}

which preserves the R:G:B ratios of each pixel (so hue and saturation are unchanged) and only shifts overall brightness. The ε floor avoids division by zero on pure-black pixels. The result is optionally clipped to [0, 1] (parameter clip=True by default); turn clipping off if you want to preserve above-unity excess for further processing. Alpha channels pass through untouched.

14.5 Reducer broadcast shapes

Reducers return the smallest broadcastable representation of their result:

\mathrm{med}(X) \;:\; (H, W, C) \;\longmapsto\; (1, 1, C)

which still broadcasts identically to the full-size tiled output you would get from np.full_like(x, med(x)), but a 4000×6000 RGB median drops from ~288 MB of memory to 12 bytes. For mono input, the result is a plain Python float.

14.6 Unsharp mask

\mathrm{unsharp}(x,\,\sigma,\,a) \;=\; x \;+\; a\,(x - G_{\sigma}(x))

Classical form: subtract the Gaussian-blurred image from itself to isolate detail above spatial scale σ, add it back scaled by a. The result is clipped to [0, 1].

14.7 Normalisation primitives

\mathrm{normalize01}(x) \;=\; \frac{x - \min_c(x)}{\max_c(x) - \min_c(x)}
\mathrm{zscore}(x) \;=\; \frac{x - \mu_c(x)}{\sigma_c(x)}

Both operate per-channel on RGB inputs (minc, μc, etc. means per-channel). Not clipped by default — zscore in particular will produce values outside [0, 1] and typically needs sigmoid or clamp downstream.

14.8 Rescale

\mathrm{rescale}(x,\,a,\,b,\,l,\,h) \;=\; l \;+\; (h - l)\,\frac{x - a}{b - a}

Linear re-mapping. Extrapolates outside [a, b]; wrap in clamp for hard bounds. Useful for display calibration (rescale(x, 0.1, 0.9, 0, 1) stretches the 0.1–0.9 range of the input to full dynamic range).

14.9 Expression safety

User expressions run in a namespace with __builtins__ explicitly set to None, so open, __import__, exec, and the rest of the standard library are not reachable. Only the functions in § 10, the variables in § 3, and the mapped view identifiers are available. This is a soft sandbox — sufficient to prevent accidental damage, not a hard security boundary — so don't paste arbitrary expressions from untrusted sources.

14.10 Mono vs RGB result handling

In Single mode on a mono source, if your expression returns an RGB array whose three channels are identical (to float32 tolerance), the result is collapsed back to mono. If the three channels differ (you explicitly built an RGB from a mono source), the mono source gets promoted to RGB and the output is RGB — so compose(L, L, L) + small_perturbation on a mono L correctly produces an RGB output.

In Separate mode on any source, each per-channel expression must produce a 2-D plane. If an expression returns an RGB array, the matching channel is auto-selected (the R tab takes [0], G takes [1], B takes [2]).

Credits. The Midtones Transfer Function is Pleiades Astrophoto's PixInsight formulation; the family of Möbius-style tonal curves it belongs to is classical. Rec. 709 luminance coefficients are from ITU-R Recommendation BT.709. Sigmoid, smoothstep, and lerp are standard shader-language primitives (OpenGL / GLSL / HLSL). Unsharp mask is a classical darkroom photographic technique formalised digitally in the 1980s. The Gaussian, box, and median blur kernels come from OpenCV when available, with pure-NumPy fallbacks. SASpro's own contributions are the PixelImage wrapper with its mono/RGB broadcasting rules, the mono-friendly channel-indexing fallback, the per-channel reducer broadcast shapes, the Rec. 709 lumarecom helper with its hue-preserving scale factor, the active-view / open-views variable namespace with title-to-identifier rewriting, the multiline expression evaluator, the non-destructive auto-stretch preview, the examples / favorites / glossary dialogs, and the per-document mask integration.