Converts a single-channel CFA (colour-filter-array) mosaic into a 3-channel RGB image. Supports both standard 2×2 Bayer mosaics (RGGB, BGGR, GRBG, GBRG) via SASpro's Numba-accelerated kernels and Fujifilm X-Trans 6×6 mosaics via rawpy / LibRaw. The dialog auto-detects the CFA family and the Bayer phase from the header, falls back to the camera model, falls back to rawpy's ground truth, and ultimately falls back to a brute-force colour-difference-gradient score if the metadata is silent. ROWORDER handling (bottom-up FITS from NINA and similar) is built in and applied symmetrically so the output orientation always matches the input.
raw_pattern, then falls back to a brute-force scoring auto-detect that debayers under each of the four candidates and picks the one minimising the Sobel gradient energy of the R−G and B−G colour-difference planes (correctly debayered output has smooth colour differences because the three channels are highly correlated at normal pixel scales); the once-only ROWORDER flip invariant that flips the input mosaic to top-down before debayering when ROWORDER = BOTTOM-UP is set and flips the RGB result back after, so the Bayer phase matches the header card but output orientation matches input — and the explicit guarantee from bayer_utils that pattern detection never also bumps yoff by the height parity, so a BOTTOM-UP file never gets its orientation corrected twice; the multi-source RAW path discovery that walks metadata raw_source_path → header cards RAW_PATH, RAWFILE, ORIGFILE, ORIGINAL, ORIGPATH, RAWORIG, SOURCE, SRCFILE → the document's own file_path / path → a sibling-stem RAW scan in the FITS/XISF file's own folder across ten extensions (.raf, .raw, .rw2, .arw, .nef, .cr2, .cr3, .dng, .orf, .pef) so a debayered FITS saved next to its original RAW can still be re-debayered from the X-Trans source without the user re-locating the file; the range-preserving mono normaliser that scales integer dtypes by their native iinfo.max but only rescales genuine >16-bit-ADU floats (max > 256) rather than any float with maximum above 1.0 — so flat-divided linear astronomical data that legitimately runs to 2×–3× the full-scale value doesn't get crushed by an inappropriate / 65535; the QThread-backed worker that runs the Bayer debayer off the UI thread with staged progress reporting; the doc-aware application layer that prefers each document's own apply_edit undoable-commit path when available and falls back to direct mutation with a per-doc view refresh that finds the right subwindow without changing the active one; and the headless debayer_array() function that is the single source of truth for the flip-in / debayer / flip-out orientation handling, shared by the interactive dialog, the Scripts / Presets / Replay runner, and the stacking pipeline, so the same mosaic always produces the same RGB regardless of which entry point was used.
XBAYROFF, YBAYROFF, and ROWORDER from the header) or pick one explicitly. Pick a method — VNG is the default and the usual best choice; Edge-aware trades a bit of smoothness for sharper edges; Bilinear is fastest; Strict CFA splits channels without interpolation (half the resolution per axis, useful as a quick preview). X-Trans data is routed to rawpy automatically; pick AHD or DHT. OK runs the demosaic, pushes the RGB back into the same document (with undo if the doc supports it), and refreshes just that view without switching focus.
The signature move is the auto-detect dispatch chain. For a correctly-tagged file (BAYERPAT = RGGB in the header), opening Debayer and hitting OK just works. For an untagged FITS that came from a bottom-up NINA pipeline, SASpro picks up ROWORDER = BOTTOM-UP, flips the mosaic to top-down, debayers under the header-declared pattern (or scores the four candidates if the header has nothing), flips the RGB back, and lands the result in the right orientation. For a Fujifilm RAF opened and converted to FITS, the dialog spots the X-Trans fingerprint in the metadata, hunts for the original RAF next to the FITS if the stored path is stale, and hands the decode off to LibRaw via rawpy with AHD or DHT — because X-Trans's non-repeating 6×6 pattern can't be handled by the Numba Bayer kernels. One dialog, one OK button, four demosaic algorithms, two CFA families, and the orientation always matches input.
The dialog opens on the active view and requires a single-channel 2D mosaic as input. The early checks reject anything else:
Accepted input dtypes: any integer (uint8, uint16, uint32, …) or any float. Integer inputs are scaled by their dtype's native maximum (iinfo.max) into [0, 1]. Float inputs stay as-is unless they look like unnormalised 16-bit ADU values (max > 256), in which case they're divided by 65535. See the Technical details section on normalisation for why the threshold is 256 and not 1.
The top group box lets you pick which Bayer phase the mosaic uses:
Detected: RGGB (XBAYROFF=0, YBAYROFF=0, ROWORDER=TOP-DOWN). If the header had no pattern, Auto triggers the brute-force scoring fallback on OK.A Bayer phase is just the colour at the top-left of the 2×2 repeating tile. The four patterns correspond to the four possible rotations:
| Pattern | (0, 0) | (0, 1) | (1, 0) | (1, 1) |
|---|---|---|---|---|
| RGGB | R | G | G | B |
| BGGR | B | G | G | R |
| GRBG | G | R | B | G |
| GBRG | G | B | R | G |
The pattern dropdown is disabled when the dialog has detected X-Trans data — X-Trans doesn't have a 2×2 phase; the whole thing is handed off to rawpy.
Four methods for standard Bayer data, all running through SASpro's Numba-accelerated debayer_fits_fast:
Fujifilm's X-Trans CFA is a 6×6 repeating pattern — much more complex than Bayer's 2×2 and specifically designed to break moiré without an optical low-pass filter. The Numba Bayer kernels can't handle it; demosaicing goes to rawpy (the Python wrapper around LibRaw) with two selectable algorithms:
X-Trans demosaicing requires access to the original RAW file, not the derived FITS. The dialog finds it through a multi-source search:
If every source fails, the dialog shows a message explaining that the X-Trans original couldn't be found and suggesting three fixes: open the RAF directly, embed a RAW_PATH card in the header, or place the RAW next to the file.
rawpy runs with use_camera_wb=True (camera's shot white balance), no_auto_bright=True (no auto-brightening), gamma (1.0, 1.0) (linear output), 16-bit output, FBDD noise reduction off, and highlights clipped. The float32 RGB result in [0, 1] is handed back to the apply layer the same way the Numba path's output is.
FITS files have no universal convention for which pixel is first. NINA and some other acquisition tools write ROWORDER = BOTTOM-UP, meaning row 0 of the on-disk array is the bottom of the sensor, not the top. The Bayer pattern header card describes the sensor's actual CFA layout — which is a top-down fact — so a bottom-up file has its mosaic rows in the opposite order from what the Bayer phase declaration implies.
The fix is a once-only flip:
The input flip ensures the Bayer phase matches the on-disk pixel grid; the output flip restores the orientation the user expects to see.
bayer_utils are mutually exclusive — pattern detection does not also bump the Y offset by the height parity, so a BOTTOM-UP file is never corrected twice. If the detector and the flip both tried to compensate for the row order, they'd stack into the wrong pattern (effectively swapping R ↔ B or shifting the phase). The invariant is enforced by the bayer_utils module and must be preserved by anything else that calls into this path.Once the RGB result is ready (from either the Numba worker or the rawpy path), it's pushed back to the source document — never to “the active view” by position. The apply layer walks a two-tier preference:
apply_edit(image, metadata, step_name), call it. This gives the user a proper undoable edit with a named step (Debayer (RGGB), Debayer (X-Trans/AHD), etc.), participates in the undo stack, and triggers any downstream notifications the doc model emits. A one-arg legacy fallback apply_edit(image) is also tried if the full signature isn't accepted.doc.image plus a metadata patch (is_mono = False, bit_depth defaults to 32-bit float), followed by an explicit view refresh.View refresh is done by locating the MDI subwindow bound to the specific document (not the active one) and calling its refresh_pixmap_from_document, refresh_view, or update_from_doc hook if present, falling back to a plain update() on both the view and the subwindow. The user's current window focus doesn't change.
Two headless entry points for scripts, presets, and the Replay Last Action system:
debayer_array(mono, pattern, roworder, method, cfa_drizzle) — pure function. Takes a mosaic array, returns an RGB array. No doc, no Qt, no side effects. This is the single source of truth for the flip-in / debayer / flip-out orientation handling — the interactive dialog, the preset runner, and the stacking pipeline all go through it, so the same mosaic always produces the same RGB regardless of which entry point was used.apply_debayer_preset_to_doc(dm, doc, preset) — doc-aware version. Takes a preset dict ({"pattern": "auto|RGGB|…", "method": "auto|vng|edge|…|AHD|DHT"}), handles the CFA-family dispatch, runs the appropriate engine, applies the result to the given doc, and returns (used_pattern, rgb_array).The command runner run_debayer_via_preset(main, preset, doc=None) wires both of these into SASpro's command system. It registers the invocation with the Replay Last Action buffer so Ctrl+Shift+R can repeat the same debayer with the same settings on the next image. If no explicit doc is passed, the active doc is used.
A typical preset for scripting:
Equations and algorithms, numbered continuing from the usage sections.
Deciding whether a mosaic is Bayer or X-Trans runs a four-tier chain, stopping at the first positive answer:
X-TRANS / XTRANS / FUJIFILM X-TRANS. If any hit, return XTRANS. Otherwise search for BAYER / RGGB / BGGR / GRBG / GBRG; any hit returns BAYER.MAKE contains FUJIFILM and MODEL matches one of 15 known X-Trans bodies (X-T1…X-T5, X-E2 / X-E2S / X-E3, X-Pro1…X-Pro3, X-H1 / X-H2S, X-S10 / X-S20, X100, X70, X30), return XTRANS.None xtrans_pattern → XTRANS; a 2×2 raw_pattern → BAYER.None, which the dialog treats as “Bayer, no header pattern.” The brute-force scorer then runs on OK.The camera-model list is conservative — it only matches bodies where X-Trans is known to be used, not every Fuji camera (the X100 / X70 / X30 are included because specific models in those lines are X-Trans too, but recent entries in those lines might ship a Bayer sensor and would need the rawpy check to confirm).
Within the Bayer family, picking which of the four 2×2 phases to use runs a parallel four-tier chain:
BAYERPAT / XBAYROFF+YBAYROFF / similar card is read and normalised to one of the four canonical tokens (RGGB / BGGR / GRBG / GBRG).raw_pattern — if a RAW path is reachable, rawpy reports the actual sensor CFA.RGGB. (This is the most common pattern on modern CMOS; being wrong just means the user sees R↔B swap or a chequerboard moiré and picks the right one explicitly.)When no header pattern and no rawpy source exist, SASpro picks the Bayer phase by actually debayering under each of the four candidates and scoring the result. The score penalises Sobel gradient energy in the colour-difference planes:
Here Sx and Sy are the standard 3×3 Sobel kernels and I is one channel of the candidate RGB result. The gradient energy on the R−G colour-difference plane is:
and symmetrically for B−G. The total score for a candidate pattern p is the sum:
The winning pattern is the argmin over the four candidates:
The intuition: in natural images (astronomical scenes included), the R, G, and B channels are highly correlated at normal pixel scales — a star is bright in all three; a nebula edge appears in all three. A correctly-debayered image inherits this correlation, so the colour-difference planes R−G and B−G are smooth. An incorrectly-debayered image swaps or shifts colours against the real signal, which introduces zebra artefacts and chequerboard patterns that light up the colour-difference gradient energy. Minimising that energy picks the pattern that recovers the natural correlation.
The score is computed on the first time only; one of the four debayer passes is retained as the output, so there's no wasted work beyond the three losing passes. All four debayers run with cfa_drizzle=False through the standard Numba kernel.
Debayer kernels expect float32 data in [0, 1]. The normaliser handles integer and float inputs separately:
Integer dtypes: scale by the dtype's native maximum.
So uint8 divides by 255, uint16 divides by 65535, etc.
Float dtypes: pass through unless the data looks like unnormalised 16-bit ADU values stored as float.
max > 1.0 is not necessarily unnormalised 16-bit data — it might be perfectly valid linear astro data with a legitimate dynamic range above full scale. Crushing it by / 65535 would move the whole image into the range [0, 3.4e-5] and effectively flatten the signal below floating-point precision at the dark end. The 256 threshold catches genuine 16-bit float storage (where values commonly run to 50000+) while leaving flat-divided linear data alone.The array is also ensured to be C-contiguous via np.ascontiguousarray before being handed to the Numba kernel, which assumes contiguous layout for its stride calculations.
The Bayer path runs the demosaic in a QThread worker (_DebayerWorker) so the UI stays responsive. The worker signals the dialog in three ways:
progress(int, str) — staged progress updates (5% on start, 96% on finalisation). Drives the progress bar and status label.finished(np.ndarray, str) — the RGB result and the pattern that was actually used (for the step name on apply).failed(str) — any exception's string representation. The dialog shows a critical message box and the status reads “Failed.”The X-Trans path doesn't use the worker — rawpy is a C-extension call that holds the GIL for the duration of the decode, so running it in a QThread wouldn't improve responsiveness. The dialog shows a status message, calls rawpy synchronously, and reports completion.
The apply layer _apply_result_to_doc(dm, doc, rgb, step_name) always targets the specific doc passed to it, never “the active doc.” This is important for the headless and script paths, which may apply a debayer to a doc that isn't currently visible.
Preference order:
doc.apply_edit(rgb, metadata, step_name=…) — preferred, participates in undo. Also tries the legacy two-arg and one-arg signatures as fallbacks.doc.image and doc.metadata["is_mono"] = False, followed by an explicit refresh.Refresh is done by _refresh_view_for_doc: it tries the doc manager's refresh_subwindow_for_document API first, falling back to walking the MDI subwindow list for one whose widget().document is identically the passed doc, calling that view's refresh_pixmap_from_document / refresh_view / update_from_doc hook if any exists, and finally calling plain update() on both the view widget and its subwindow. The active subwindow isn't changed; the user stays where they were.
debayer_array() entry point. The underlying demosaic algorithms are classical:
cv2.Sobel) in the scoring auto-detect.debayer_fits_fast) are JIT-compiled with Numba for speed.