Spectral Flux Color Calibration (SFCC)

Physics-grounded colour calibration. SASpro detects stars in the plate-solved frame, matches each one to a Gaia DR3 XP calibrated spectrum (or a Pickles library template as fallback), integrates its spectrum through the telescope's filter/sensor/light-pollution curves to predict the expected R/G, B/G ratios, measures them in the image, fits a correction model, and applies it.

1. Using the tool

The dialog is a two-step workflow with a shared panel of filter/sensor selections between them. Both steps run against the active image window; switching the active window between runs is fine.

Step 1 — Fetch stars & spectra

The White Reference dropdown picks the SED that defines “neutral grey” (default G2V; Vega / A0V is the usual alternative). It only affects the reference integrals S_ref_c, nothing else at this stage.

Click Step 1: Fetch Stars & Spectra from Current View and the pipeline runs:

The result is a summary plot in two panels. The status line below shows the raw counts; the plot shows the quality of what you got.

The shared panel — filter/sensor selections

Pick the actual optical chain: R Filter, G Filter, B Filter, Sensor (QE) and up to two LP/Cut Filter curves (e.g. an IR-cut and a light-pollution filter). (None) is a flat-unity curve. These are read from the shipped sasp_data.fits plus a per-user usercustomcurves.fits. Choices persist via QSettings.

The SASP Viewer — see your optical chain against any star

Click Open SASP Viewer (next to the Step 1 button) to launch a standalone window that plots any stellar SED through any R/G/B filter set, any sensor QE, and up to two LP/cut filters. It doesn't touch the image — it's a free-form exploration of what your optical chain actually sees, and it's where you get a feel for why one filter choice gives you deeper red stars than another, or why that bargain CMOS sensor can't tell a late K from an M.

The dropdowns across the top are the same vocabulary as the main dialog: Star Template (any Pickles SED from O supergiants through late M dwarfs), R / G / B Filter, two optional LP/Cut Filter slots, and Sensor (QE). Change any dropdown and press Plot to redraw.

What the plot shows

One figure, two y-axes:

Things worth doing with it

Step 2 — Run color calibration

With Step 1 done and the chain picked, click Step 2: Run Color Calibration. SASpro re-detects stars on the live frame, matches them within 3 px to the Step-1 star list, caps the sample to the brightest 500 by SEP flux, performs annulus-background-subtracted aperture photometry per channel, computes the model (§2.4) and applies it (§2.5).

Reading the before/after residuals plot

Two side-by-side panels, both plotting the fractional residual (measured_ratio / expected_ratio) − 1 on the y-axis against the expected ratio on the x-axis. Red circles are the R/G channel, blue squares are B/G. The shaded bands behind each colour are the Q1–Q3 interquartile range — the central half of the scatter.

Gradient extraction (Beta, hidden)

A separate Run Gradient Extraction button and method combo (poly2, poly3, rbf) are hidden by default but available on code paths that enable them. It uses the same spectra and photometry to measure a per-channel spectrophotometric gradient and remove it — see §2.7.

The result pane shows three side-by-side panels, one per channel, each a divergent red/blue heatmap of the fitted gradient surface in magnitudes. Panel titles report the peak magnitude for that channel so you can tell at a glance how aggressive the correction was — a 0.03 mag peak is a cosmetic tweak, a 0.3 mag peak was clamped (§2.7) because the fit tried to go further. Yellow dots overlay the star positions used for that channel's fit after sigma-clipping; sparse corners mean the surface is extrapolating there and should be treated with caution. The three channels usually show similar shape (same corner darker, same edge brighter) with different amplitudes — that's a real optical gradient. If the three channels show unrelated patterns, the fit is chasing noise and poly2 is probably the right method to retry with.

2. Technical details

RGB only; no narrowband. The core idea: use each star's real spectrum as a photometric standard. SIMBAD + Pickles can approximate the spectrum from a type label; Gaia XP gives you the actual observed spectrum of that star. SFCC uses XP where it has it and falls back to a hierarchical Pickles match where it doesn't.

Third-party data used. SFCC relies on Gaia DR3 XP calibrated spectra (ESA/DPAC) via gaiaxpy, the Pickles 1998 stellar flux library, SIMBAD (CDS Strasbourg), and SEP (Source Extractor as a Python library). The calibration pipeline, the three-model fit, the pivot-at-median application, and all SASpro-specific heuristics below are implemented in SASpro.

2.1 Star detection — Auto-σ waterfall

SEP runs on the mean of the three channels. Rather than fixing a detection threshold:

\sigma\in\{100,50,25,12,8,5,3\}\,,\;\text{stop at first}\;N\geq 1200

If no rung reaches 1200 the lowest σ is kept so there's still something to work with. Detections are filtered by flux-radius to (0.2, 10] px to drop galaxies/plate flaws. Only sep.extract is retried per rung — the one-off background estimation runs once.

2.2 System throughput & expected integrals

The optical chain combines multiplicatively on a shared 3000–11000 Å 1-Å grid:

T^{c}_{\mathrm{sys}}(\lambda)=T^{c}_{\mathrm{filter}}(\lambda)\cdot QE(\lambda)\cdot T_{\mathrm{LP}_1}(\lambda)\cdot T_{\mathrm{LP}_2}(\lambda)

For each star, the expected integrated flux through channel c is

S^{c}_{\mathrm{star}}=\int f_{\mathrm{star}}(\lambda)\,T^{c}_{\mathrm{sys}}(\lambda)\,d\lambda

where f_star(λ) is either the Gaia XP calibrated spectrum (interpolated onto the grid) or the matched Pickles SED. Integrals are cached per (source_id, system_key) in an SQLite store so repeat runs are instant — system_key is an MD5 over the wavelength grid and the three throughputs so any filter change triggers a recompute. Reference integrals S_ref_c are the same formula applied to the chosen white reference (default G2V).

2.3 Spectral-type → Pickles template

SIMBAD spectral type (e.g. K2III) is matched against the available Pickles extensions by letter class, subclass, and luminosity — exact match first, then same letter + same luminosity + nearest subclass, then same letter + any luminosity + nearest subclass, finally all templates in that letter. When SIMBAD gives no type but SIMBAD or Gaia-synthetic Johnson magnitudes are available, the letter is inferred from B−V:

letter = B if B-V < 0.00 A if 0.00 ≤ B-V < 0.30 F if 0.30 ≤ B-V < 0.58 G if 0.58 ≤ B-V < 0.81 K if 0.81 ≤ B-V < 1.40 M if B-V ≥ 1.40

and the match runs again on the letter alone (so a B−V-only star picks, say, the main-sequence K5V rather than K5III).

2.4 Photometry & the three-model fit

Step-1 catalogue stars are re-matched to Step-2 SEP detections within 3 px and capped to the brightest 500. For each kept star the aperture radii scale with the SEP semi-major axis a:

r=\mathrm{clip}(2.5\,a,\,2,\,12)\,,\;r_{\mathrm{in}}=\mathrm{clip}(3r,6,40)\,,\;r_{\mathrm{out}}=\mathrm{clip}(5r,r_{\mathrm{in}}{+}2,60)

Each channel's background-subtracted flux comes from an annulus-estimated pedestal:

F_c=\sum_{\mathrm{ap}}I_c-\mu^{c}_{\mathrm{bg}}\cdot A_{\mathrm{ap}}\,,\qquad \mu^{c}_{\mathrm{bg}}=\frac{\sum_{\mathrm{ann}}I_c}{A_{\mathrm{ann}}}

Photometry runs in parallel across up to 8 threads; stars with any non-positive channel are dropped. The result is the two measured ratios per star compared against the predicted ratios from §2.2:

\mathrm{meas}_{R/G}=\frac{F_R}{F_G}\,,\quad \mathrm{exp}_{R/G}=\frac{S^{R}_{\mathrm{star}}}{S^{G}_{\mathrm{star}}}

SASpro then fits three models independently for the R and B channels and keeps the best per channel. The slope-only solution is closed-form:

m=\frac{\sum_i\mathrm{meas}_i\,\mathrm{exp}_i}{\sum_i\mathrm{meas}_i^{2}}

and affine / quadratic are standard least-squares and polynomial fits:

\mathrm{exp}=m\cdot\mathrm{meas}+b\quad(\text{least-squares})
\mathrm{exp}=a\cdot\mathrm{meas}^{2}+b\cdot\mathrm{meas}+c

Each is scored by the fractional-residual RMS:

\mathrm{RMS}_{\mathrm{frac}}=\sqrt{\frac{1}{N}\sum_i\!\left(\frac{\mathrm{pred}_i}{\mathrm{exp}_i}-1\right)^{\!2}}

and the winner minimises the sum over both channels:

\min_{M\in\{\mathrm{slope},\mathrm{affine},\mathrm{quad}\}}\;\left(\mathrm{RMS}^{R}_{\mathrm{frac}}+\mathrm{RMS}^{B}_{\mathrm{frac}}\right)

A fractional loss is used, not absolute, because stars span orders of magnitude in flux and residuals should be measured relative to the expected value. Quadratic is only entered when there are ≥ 6 valid stars.

2.5 Applying the correction — pivot at median

With the winning per-channel polynomials, the per-pixel correction factors are the polynomials evaluated at the measured pixel ratio (x_R = I_R/I_G, x_B = I_B/I_G) and clipped to a safe dynamic range:

m_c(x)=\mathrm{clip}\!\left(a_c x^{2}+b_c x+c_c,\;0.25,\;4.0\right)

And applied with a pivot at the channel median rather than at zero, so the overall channel brightness is preserved and only the colour balance moves:

\mathrm{out}_c=p_c+(I_c-p_c)\cdot m_c(x_c)\,,\quad p_c=\mathrm{median}(I_c)

Green is the reference channel and is left unchanged. The clip at [0.25, 4] prevents extreme ratios on bright or dark pixels from driving runaway gains (e.g. a saturated red core where G→0).

2.6 Optional 3×3 color matrix

Off by default and hidden in the current UI. Rarely needed once per-channel scaling is applied; mostly useful for sensors with heavy cross-channel bleed. Not recommended for narrowband.

In ratio space, with G normalised to 1, each star gives a measurement vector and a target vector:

M ยท [F_R/F_G, 1, F_B/F_G]^T ≈ [S_R/S_G, 1, S_B/S_G]^T

SASpro solves this by least-squares row-by-row (per output channel), then forces the G row to [0, 1, 0] so green maps to itself exactly and the matrix stays near-identity. 3σ-MAD clipping on residuals removes outliers, and the solution is rejected if any of:

Applied per pixel in ratio space with G held fixed, then a per-pixel negative fix (shift up by min(channels)) and a luminance-pivoted ceiling compression keep the output in gamut without hard-clipping any one channel.

2.7 Gradient extraction (Beta)

Removes a spatially-varying colour gradient per channel using the same star catalogue. For each star and channel the differential magnitude is computed (no annulus — raw aperture only, since the gradient is the pedestal we're after), then field-median-subtracted so only the spatial variation remains:

\Delta m_c(x,y)=-2.5\log_{10}\!\left(\frac{F_c}{S^{c}_{\mathrm{star}}}\right)\;-\;\mathrm{median}(\Delta m_c)

A 2.5σ-MAD clip removes outliers, then a 2D surface (poly2, poly3, or RBF with a thin-plate-spline kernel) is fit per channel, re-centred, and clamped to ±0.3 mag so a bad fit can't blow up the image. The correction is multiplicative in flux:

\mathrm{scale}_c(x,y)=10^{-0.4\,\Delta m_c(x,y)}\,,\quad \mathrm{out}_c=I_c/\mathrm{scale}_c

At least 10 stars are required post-clipping, and at least 5 per channel to proceed with the fit.

2.8 DR4 roadmap (not shipped)

The current pipeline trusts the manufacturer's published QE curve. That curve is a room-temperature bare-die measurement; a real system's response is the product of sensor QE, cover glass, AR coatings, telescope throughput, atmosphere and airmass. With Gaia DR4's ~10× denser XP coverage, the plan is to drop the QE combobox entirely and solve the effective system response R(λ) directly from the stellar population — the same approach Gaia uses internally for its own photometric calibration (Carrasco et al. 2021, Riello et al. 2021). Filters stay as inputs; QE becomes a solved output, with the matrix of star / channel observations providing the overconstrained system.