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.
At the top of the editor column, two radio buttons pick the mode:
(H, W, 3), the result is RGB. SASpro infers
mono vs RGB automatically. Most work happens here.Left column (controls, scrollable):
Ctrl + wheel for standard text
scrolling.Right column (preview):
Ctrl + wheel cursor-anchored
zoom.Every open view is in scope. The dialog auto-generates a Python-safe identifier for each one from its window title:
img — always refers to the active view, regardless
of its title.andromeda,
andromeda_2).andromeda.png works even though the sanitised name is
andromeda_png.Three other sets of names are always available:
H, W,
C, and shape (a tuple).X and Y,
both (H, W) arrays ramping linearly from 0 to 1
across the frame. Use these for position-dependent effects
(gradients, radial masks, horizontal fades).pi, e,
EPS (= 10⁻⁸).| Operator | Meaning | Example |
|---|---|---|
+ − * / | 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.
Mono and RGB values mix freely. The usual case:
(H, W) and an RGB value
(H, W, 3) combine with the mono broadcast to
all three channels. So rgb * mono_mask dims
every channel of rgb by the same per-pixel factor.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.
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.
The Output group picks what happens when you click OK:
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:
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.
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.
| Signature | Description |
|---|---|
| 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]. |
| Signature | Description |
|---|---|
| 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. |
| Signature | Description |
|---|---|
| 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. |
| Signature | Description |
|---|---|
| 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. |
| Signature | Description |
|---|---|
| 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. |
| Signature | Description |
|---|---|
| normalize01(x) | Per-channel rescale to [0, 1]:
(x − min) / (max − min). |
| zscore(x) | Per-channel standardisation:
(x − mean) / std.
Not clipped. |
| Signature | Description |
|---|---|
| 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. |
| Signature | Description |
|---|---|
| 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. |
| Signature | Description |
|---|---|
| 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)). |
| Signature | Description |
|---|---|
| mtf(x, m) | Midtones Transfer Function (same curve as in Levels / Curves). See § 14.2. |
| Signature | Description |
|---|---|
| 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. |
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.
(img + other) / 2. The naive stack of two
aligned frames.img - other.
Useful for frame-to-frame comparison, dark-frame subtraction,
spotting what changed.~img (equivalent to
1 - img).img - med(img). Zero-centres the median; useful before
operations that assume zero-mean data.img - mean(img).
Same idea with mean instead of median; less robust to outliers but
preserves additive constants.min(img) + max(img).
Simple statistical constant image, used mostly for diagnostics.log(img + 1e-6).
Compresses dynamic range; classical for displaying linear data.mtf(img, 0.25).
Shift midtones down (brighten); the lower m, the more
aggressive the midpoint shift.gamma(img, 0.6). Brightens
midtones with a power curve.sigmoid(img, k=12, mid=0.4). Add contrast around the
midpoint 0.4 with slope 12. Steeper k gives sharper
transition.normalize01(img).
Each channel's min becomes 0, max becomes 1. Can clip faint signal
if your image has hot pixels — use the percentile variant
instead.iff(img < med(img), 0, 1). Simple threshold mask.lerp(img, other, X). The frame transitions from
img on the left to other on the right
because X ramps 0 to 1 across the width.apply_mask(img, other, mask()). The document's active
mask drives the blend.img * (med(other) / med(img)). Multiplicative rescale
so img's median equals other's.img / maximum(other, 1e-6). Avoids division by zero in
the denominator view.normalize01 that ignores
extreme-tail pixels.(Single Expression mode — channel indexing with [i].)
compose(luma(img), luma(img), luma(img)) or in Separate
mode luma(img) in each tab.img[2] / img[1] / img[0].a[0] / b[1] / c[2]. Takes the
red channel from one view, green from another, blue from a third.gauss(img, sigma=2.0).median(img, k=3).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:
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.
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).
Both Single and Separate modes let you write multi-line expressions with intermediate variables:
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.
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.
Every image variable is wrapped in a PixelImage object
that:
__add__, __sub__,
__mul__, __truediv__, __pow__
to coerce shapes before the NumPy operation (so mono + RGB works
without surprise).__invert__ to compute
1 − x instead of bitwise NOT.__xor__ to delegate to __pow__
so ^ means power. (NumPy bitwise XOR is still
available through np.bitwise_xor.)__getitem__ for the channel-indexing syntax
with the mono-friendly fallback (§ 4).__array_priority__ = 10 000 so NumPy defers
to the wrapper's dunder methods when the two interoperate.The classical PixInsight MTF, usable both as a tonal transform and as the engine behind Levels and Curves' midtones:
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.
Standard tone-shaping primitives:
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.
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:
then rescales every channel uniformly by the ratio of target to source luminance:
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.
Reducers return the smallest broadcastable representation of their result:
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.
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].
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.
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).
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.
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]).
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.