Skip to content

Subsample instead of dropping frames with too many star candidates; improve star extraction robustness#918

Draft
Cybis320 wants to merge 16 commits into
prereleasefrom
improve-star-detection
Draft

Subsample instead of dropping frames with too many star candidates; improve star extraction robustness#918
Cybis320 wants to merge 16 commits into
prereleasefrom
improve-star-detection

Conversation

@Cybis320

@Cybis320 Cybis320 commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Motivation

Data from the MQTT health monitor dashboard shows that many stations regularly drop FF files from star extraction. The drops are disproportionately the best FF files of the night, since clear, dark skies produce the most star candidates and trip the overflow limit.

Problem

When the number of star candidates on a frame exceeds max_stars, extractStars() drops the entire frame ("Too many candidate stars to process!") and returns zero stars. This triggers in two very different situations:

  • Genuinely star-rich frames — a sensitive camera under a dark, transparent sky legitimately detects more candidates than the cap. The all-or-nothing gate discards exactly the best frames of the night, and the per-FF star count flips between high values and zero.
  • Bright / noisy frames — the candidate test (local max − min > threshold) passes on steep brightness gradients (moon glare, cloud edges) and on sensor noise, flooding the candidate pool on high-gain cameras and moonlit nights. On real archived data this wipes out large parts of nights: one archived night from a high-gain camera has only 189 FF entries in CALSTARS out of a full night; an adjacent night from the same camera lost essentially everything.

A side effect of the old behavior: intensity_threshold had to be tuned high enough to keep candidate counts under max_stars, or frames would be dropped — so sensitivity and processing cost could not be set independently.

Two related issues:

  • The max_global_intensity gate discards frames silently (no log trace, indistinguishable from a genuinely starless frame), and its default of 150 rejects frames that measurably still hold dozens of stars.
  • In extractStarsImgHandle (video/image-dir processing), a single too-bright chunk aborts all chunks, discarding stars already extracted and disabling meteor detection for the whole file.

Changes

  • Candidate overflow no longer drops the frame. The candidates are subsampled: ranked by white top-hat prominence (image minus its morphological opening — insensitive to gradients, so stars rank above glare noise) and selected on a spatial grid with an equal quota per tile, leftovers filled by global prominence. On star-rich frames this yields a spatially uniform sample across the full FOV (what the distortion fit and recalibration want) rather than a brightest-first, center-biased one; on flooded frames it bounds junk to the affected region's tiles. The PSF fit vets the sample as before. Frames under the limit are untouched — output is bit-identical.
  • The tuning knobs are now independent: intensity_threshold can be set purely for desired sensitivity and max_stars purely as the compute budget — a low threshold no longer risks dropping frames, it just engages the subsampler.
  • max_global_intensity gate is logged at both call sites (median and cutoff values), and the default is raised 150 → 230. The gate operates on the flat-corrected median; frames at median 190–225 were measured to still hold 20–45 stars with 75–95% frame-to-frame repeatability.
  • Too-bright chunks are skipped, not fatal, in extractStarsImgHandle — other chunks keep their stars.
  • SkyFit2 Star Detection tab:
    • New Max Global Intensity slider.
    • The max-stars budget is split into a session-only SkyFit Max Stars (deep detection for initial calibration, never saved) and Config Max Stars (what Save Config writes; 400 recommended for the nightly pipeline).
    • New Reset to Defaults button.
    • Help topic updated.

Note

extractStars() no longer returns False; the callers' status == False branches remain but are now dead paths.

Validation (real FF files, several stations)

Test Result
Regression: 160 archived FFs, old vs. new code Identical star lists on every frame (overflow path never triggers on normal frames)
Recovery on production-dropped flood frames (replayed with the exact station config: mask only, no flat, threshold 18, max_stars 400) 331 PSF-vetted stars in ~1 s where CALSTARS recorded nothing
Star quality, measured offline via repeatability: extraction run independently on consecutive 10 s FFs, stars matched within 2 px (real stars drift <1 px in 10 s and match; noise doesn't — chance rate <1%) Prominence-selected samples repeat at 92–98%, better than the 76–89% of the uncapped pipeline
Stratified vs. pure global ranking, binding cap, no flat (the common network configuration) Retains 60/93 baseline stars vs. 25/93 for global ranking on a gradient-contaminated frame; ties on clean frames
Compute Unchanged on normal frames; overflow frames add one separable max-filter pass plus PSF fits capped at max_stars — same worst case as a legitimately rich frame today

Science impact

Per-star measurements are unchanged (same PSF fit). Junk candidates can't match the star catalog, so recalibration, photometry, and flux cloud detection are structurally unaffected. The net effect is additive: frames that previously contributed zero stars now contribute vetted samples — including the best dark-sky frames on sensitive cameras — so expect a completeness step at deployment (more usable observing time in long time series).

- On candidate overflow, keep a spatially stratified sample of the most
  prominent candidates (white top-hat ranking, equal quota per image tile)
  instead of dropping the whole frame. Frames under the limit are unchanged.
- Log the max_global_intensity gate at both call sites and raise its default
  from 150 to 230 (frames at median ~190-225 still hold dozens of real stars).
- Skip too-bright chunks in extractStarsImgHandle instead of aborting the
  whole image handle and discarding stars from all other chunks.
- SkyFit2 star detection tab: add Max Global Intensity slider, split the max
  stars budget into a session-only SkyFit value and a Config value written by
  Save Config (400 recommended), and add a Reset to Defaults button.
- Update the star detection help topic accordingly.
@Cybis320 Cybis320 marked this pull request as draft July 2, 2026 22:04
Cybis320 added 4 commits July 3, 2026 17:08
- Split the star detection sliders into Station Config (written by Save
  Config) and SkyFit Session Only group boxes
- Move gamma to the Station Config group and have Save Config write it to
  the [Capture] config section; track gamma in the modified-config check
- Tighten group box margins and shorten labels so nothing clips at the
  fixed tab width
- Add a checkable Auto Levels button to the Levels tab, kept in sync with
  the Ctrl+A shortcut via a new sigAutoLevelsToggled signal
- Update the star detection and levels help topics
- Both max stars sliders move in 100-star increments (drag snapping,
  arrow keys step 100, page step 500, minimum raised to 100)
- Wrap the form-style tabs (Fit Parameters, Station, Star Detection,
  Mask, Settings, Debruijn) in scroll areas so short windows scroll the
  content instead of compressing it; Levels and Help stay unwrapped
- Add tabWidgetFor()/tabIndexOf() resolvers and route identity-based tab
  lookups through them
The Help tab rework (7b8da60) collapsed the three-state F1 cycle into a
show/hide toggle and incidentally flipped the panel's startup state back
to visible, reverting 5d7064b. Restore hidden-by-default, but keep the
panel force-shown as the empty-state hint when no data is loaded (and
hide it again once data arrives).
@dvida

dvida commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Thanks Luc — this is a well-motivated and carefully validated PR. I did an in-depth review of the diff plus an independent empirical check of the core algorithm, and audited every downstream CALSTARS consumer. Summary below so you know where to pick things up.

What I verified (independent tests, synthetic star fields, old vs. new code)

  • Bit-identical under the cap — confirmed. 149 candidates, cap 1000: all 8 output arrays identical between old and new code.
  • Flood recovery — confirmed. Moon-glare + heavy noise frame, 990 candidates, cap 200: old code returns False (frame lost), new code recovers 132/134 true stars outside the flooded region, with only ~17 junk detections surviving the PSF fit.
  • Spatial uniformity — confirmed. 600-star clean field, cap 200: 4×4 grid occupancy 9–17 per cell, 200/200 detections match injected truth.
  • Edge cases — no crashes at cap = candidates−1, very small caps (quota-1 path), or a single-region flood.
  • Algorithm internalsmaximum_filter(minimum_filter(img)) is a true grayscale opening so the top-hat prominence is correct; label indexing has no off-by-one; the quota loop preserves prominence order in the leftovers and selects exactly max_star_candidates; the extra maximum_filter pass only runs in the overflow branch, as claimed.

The subsampler itself is sound — I could not break it. The items below are all in the surrounding GUI/config plumbing and in the framing of downstream effects.

Requested changes

1. Reset to Defaults resets camera gamma to 1.0 — can silently corrupt the station config and live photometry.
resetToDefaults() (CustomPyqtgraphClasses.py) loops over all slider defaults, including gamma (100 → 1.0). Gamma is a hardware property (0.45 on many IMX cameras), not a tuning preference. Failure path: user clicks Reset to fix detection sliders → updateGamma(1.0) fires → with override enabled it immediately rewrites platepar.gamma and config.gamma in memory → Save Config now writes [Capture] gamma = 1.00 (new in this PR). Photometry breaks with no warning. Suggested fix: exclude gamma from the reset, or reset it to the station's config value rather than the global default. Arguably Config Max Stars should also reset to the configured value, not 400.

2. The config max-stars slider snaps/clamps, then Save Config writes the altered value.
The slider has range 100–2000 with snap-to-100, and loadFromConfig() seeds it from config.max_stars. A station config with max_stars: 150 shows 200 on open, isConfigModified() returns True with zero user input (Save button enabled at startup), and saving writes 200 back. Values below 100 or above 2000 get clamped the same way. Suggested fix: seed the slider without snapping (snap only user motion), or track a "user touched" flag so the seed value never counts as a modification.

Points for discussion (no code change strictly required)

3. "Science impact: structurally unaffected" holds for catalog-matched paths, but several consumers act on raw CALSTARS counts and do react to junk entries.
Protected, as the PR states: astrometry fit acceptance (matchStarsResiduals, recalibrateFF vetoes) and the flux clear-sky ratio (Utils/Flux.py counts catalog-matched stars) — junk cannot corrupt platepars or cloud detection. Exposed:

  • The meteor-detection gate len(star_list) >= ff_min_stars (DetectStarsAndMeteors.py:235): flooded moonlit frames that used to yield 0 stars now pass, so meteor detection runs on noise frames. Partly the point of the PR, but expect some extra false-positive detections in FTPdetectinfo on bad nights.
  • autoCheckFit picks its NN-alignment seed as the FF with the most raw stars (CheckFit.py:508) — a junk-flooded frame can win and seed the alignment badly. Cheap fix: pick by matched or SNR-filtered count.
  • The flux sensor FWHM is a median over all raw CALSTARS entries (Utils/Flux.py:1821) — in my flood test ~8% of surviving detections were junk, enough to bias the median on bad nights. Cheap fix: SNR-filter before the median.
  • CalibrationReport selects its representative frame by raw count; calstars_min_stars-type total gates inflate similarly. Cosmetic.

4. Default rollout reach. The ConfigReader.py (150→230) and .config template changes only affect installs whose config file lacks max_global_intensity — essentially all deployed stations have it written in their own .config and will keep 140/150 until an operator edits it. So the measured benefit at median 190–225 mostly won't reach the existing network through this PR alone. Worth a note in the PR text on whether that's the intent.

Minor

  • Docstrings at ExtractStars.py:320 and :430 still say "maximum mean intensity" — it's the median now (and the code default is 230).
  • The dead status is False branches are acknowledged in the PR and harmless; fine either way.
  • GUI plumbing all checks out on close read: _original_config_gamma is initialized and None-guarded correctly, every former tab.indexOf() call site was converted to tabIndexOf(), the Auto Levels button and Ctrl+A stay in sync, and the snap handlers' setValue re-entry terminates after one pass.
  • The scroll-area wrappers / Auto Levels button / hidden info panel are unrelated UI improvements riding along in a star-detection PR — fine for prerelease, just makes bisecting a bit harder.

Overall: the core change is a clear improvement and the validation in the PR description reproduces. Once items 1 and 2 are addressed (both small fixes), this is good to go from my side.

Cybis320 added 4 commits July 6, 2026 07:51
Review fixes for #918 (items 1 and 2):

- Reset to Defaults no longer resets gamma to 1.0 - it is a hardware property
  of the camera, and resetting it silently corrupted photometry the moment
  Save Config wrote it to the station config. Gamma and the config max stars
  now return to the loaded station config values; only the tuning sliders
  return to the recommended defaults.
- Programmatic slider seeding bypasses the snap-to-100 handlers, so a config
  value like max_stars=150 loads exactly instead of snapping to 200, showing
  as an unsaved modification with zero user input, and being written back
  snapped. User motion still snaps. Out-of-range config values extend the
  slider range instead of clamping.
Review follow-ups for #918 (item 3 cheap fixes and the docstring note):

- autoCheckFit seeds its NN alignment from the FF file with the most
  SNR-filtered stars instead of the most raw entries, so a junk-flooded
  frame cannot win the seed selection (falls back to the raw count for older
  CALSTARS files without an SNR column).
- The flux sensor FWHM median is computed over SNR-filtered detections for
  the same reason, with the same fallback.
- max_global_intensity docstrings updated: the gate compares the median (not
  mean) image intensity, default 230.
Empirical check of the previous commit's flux change: on a frame that is 94%
junk (866 detections, 54 catalog-matched), the raw FWHM median differs from
the matched-only median by just 0.07 px - junk that survives the PSF fit has
star-like FWHM by construction, and the median is robust to it exactly as
designed. Meanwhile the SNR >= 5 filter shifts the median by +0.43 px on a
perfectly normal night (standard-settings detections have median SNR ~3.5,
so the cut keeps only the brightest third), which would have changed flux
results network-wide.

The SNR-filtered seed selection in autoCheckFit stands - for that consumer
the same measurement confirms it: junk sits at SNR ~1.3, real stars at ~8,
the ranking is unaffected on clean nights and stops junk-flooded frames from
winning on bad ones.
The cloud-detection ratio compares catalog-matched detections against a
predicted star count derived from the photometric zero-point - a model that
does not know the extractor never returns more than config.max_stars
detections. On star-rich nights with sensitive cameras the prediction can
exceed the cap severalfold (at detector LM ~8, a 40x22 deg FOV holds ~900
predicted stars against a 400-star cap), so a perfectly clear sky reads as
cloudy (matched ~ cap, ratio < 0.5). Before the subsampler such frames were
dropped entirely and always read as cloudy; now they deliver a full cap of
matches, so the ratio must compare against what detection is actually
allowed to deliver: min(predicted, max_stars). Also guards a division by
zero when the prediction is empty.
@Cybis320

Cybis320 commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the deep review, Denis, and especially for the independent empirical checks - the flood-recovery and uniformity verifications are exactly the tests I'd have wanted a second pair of eyes on.

Both requested changes are addressed, plus the cheap fixes from the discussion items:

1. Gamma / Reset to Defaults - fixed. Reset to Defaults now returns the station-bound values to the loaded config instead of global defaults: gamma goes back to the station's configured value (it's a hardware property, as you said), and the config max stars likewise. Only the tuning sliders reset to the recommended defaults.

2. Slider snap on load - fixed. Programmatic seeding now bypasses the snap-to-100 handlers, so max_stars: 150 loads and displays as exactly 150, is not flagged as an unsaved modification, and Save Config writes it back unchanged. User motion still snaps. Out-of-range config values extend the slider range instead of being clamped.

3. Raw-count consumers - measured both suggestions before/after; one is in, one turned out to be counterproductive:

  • autoCheckFit now seeds its NN alignment from the FF with the most SNR-filtered stars (SNR >= 5), falling back to the raw count for older CALSTARS files that pad the SNR column with -1. Measured on a junk-flooded night: the raw-count winner was a frame where only 6% of detections match the catalog; the SNR-count winner matches 15% with more genuine stars. On clean nights the two rankings pick equivalent frames.
  • The flux FWHM median: I implemented the SNR filter, then measured it - and reverted it. On a frame that is 94% junk the raw median differs from the matched-stars-only median by just 0.07 px: junk that survives the PSF fit has star-like FWHM by construction (it passed the shape checks), so the median is robust to it exactly as designed. The filter, however, biases the median by +0.4 px on perfectly normal nights, because standard-settings detections have median SNR ~3.5 and an SNR >= 5 cut keeps only the brightest third. The cure measurably exceeds the disease, so the median stays unfiltered (with a comment recording the measurement).
  • The meteor-detection gate passing flooded frames is indeed partly the point - those frames can contain real meteors that were previously unfindable. Agreed that some extra false positives on bad nights is the trade-off; they still have to survive the detection pipeline itself, so the exposure is bounded.
  • One addition to your consumer audit: the flux clear-sky ratio is junk-proof as you said, but it has the opposite exposure - its numerator (catalog-matched detections) is bounded by max_stars while the predicted denominator is not. On star-rich nights with sensitive cameras the prediction can exceed the cap severalfold (detector LM ~8 puts ~900 predicted stars in a 40x22 deg FOV against a 400 cap), so a perfectly clear sky would read as cloudy. This predates the PR - such frames used to be dropped outright and always read as cloudy - but now that they deliver a full cap of matches it's worth fixing properly: the prediction is now saturated at min(predicted, config.max_stars), so the ratio compares against what detection is actually allowed to deliver.

4. Rollout reach - correct. Deployed stations keep their configured max_global_intensity until an operator changes it; this PR raises the default for new installs and gives operators the recommendation plus the SkyFit UI to apply it deliberately. It's the usual catch 22 with our config scheme - we could retire and rename the config field if we later think it's needed?

Docstrings at ExtractStars.py fixed (median, default 230).

Pushed as four commits on top of the branch (including the measured revert of the flux FWHM filter and the clear-sky prediction saturation).

Cybis320 added 7 commits July 6, 2026 15:03
Since the subsampler, exceeding max_stars keeps the best-distributed most
prominent subset instead of dropping the frame - so neither max-stars value
is a detection-quality knob anymore, and tuning or seeding them from sky
content no longer makes sense:

- The SkyFit session budget defaults to 2000 and is no longer seeded from
  the station config, whose value is the Pi's pipeline budget - seeding
  dragged the deep calibration default down on every folder load.
- Tune no longer sets max_stars from the candidate count and the seasonal
  star variation sweep (both fossils from when overflowing the cap lost the
  frame); it tunes what remains quality-relevant - intensity threshold and
  segment radius. The now-unused seasonal variation helper is removed.
- Reset to Defaults returns the session budget to 2000 and the config
  budget to the station's configured value, as before.
The clear-sky ratio saturates the prediction at max_stars (the extractor
never returns more), but the plot still showed the raw prediction with a
'Matched/Predicted stars' ratio above it - on star-rich nights the two
panels disagreed (e.g. ratio 0.80 plotted while the panels showed 320/870).
Plot the capped prediction the ratio actually uses (only when the cap binds)
and note the capping in the ratio axis label.
…ames

The clear-sky ratio saturated the prediction at max_stars, but the cap
counts candidates while the numerator counts catalog-matched survivors of
the PSF fit - a clear sky on a cap-bound night plateaued at ~0.75 rather
than 1, eating into the threshold margin on cameras with weaker matching.

Since observing periods are computed after the night, the deficit is
measurable from the night itself: the median matched/predicted over frames
where the cap does not bind and the sky is already clearly clear. The
expectation becomes min(predicted, deficit*max_stars) - continuous in the
prediction, exact historical semantics below the effective ceiling, and a
clear capped sky now reads ~1.0 (measured on a real cap-bound starry night:
0.75-0.80 -> ~1.0, cloudy frames unaffected at ~0.2). Clamped to [0.5, 1]
so a contaminated sample cannot lift cloudy frames past the threshold, and
inert (previous behavior) on nights with no usable calibration sample. The
observing-periods plot shows the expectation series it actually uses.
The ratio is now 'fraction of the clear-sky expectation' rather than a raw
matched/predicted fraction, and can legitimately sit slightly above 1 on
cap-bound frames (the deficit is calibrated on uncapped frames, whose
fainter stars underperform the bright capped subset). A dotted reference at
1.0 makes the reading direct: near the green line = clear, under the red
line = cloudy.
Legend and labels stick to the three nouns - matched, predicted, expected
stars - with the 1.0 reference reading 'Matched = Expected'. The cap and
calibrated deficit stay in the log, not the legend.
A horizontal reference at 1 asserts a calibrated expectation that the
prediction model does not deliver: on a second live station the uncapped
matched/predicted runs 1.2-1.5 early night and drifts to 0.9 - the
limiting-magnitude prediction is biased low for that camera, and
time-varyingly so. The deficit calibration correctly refuses to inflate
expectations (clamped at 1), so measurements legitimately sit far above the
line and it reads as broken. The threshold line is the decision boundary
and the bottom panel carries the counts; the reference line only misleads.
predictStarNumberInFOV filters predicted stars through the mask, but only
the nightly pipeline caller passed one - the manual Flux CLI and FluxBatch
ran the prediction unmasked, counting stars behind obstructions that
matched stars can never contain. On heavily masked cameras this biases the
clear-sky ratio low in batch flux runs, rejecting intervals that the same
night passes on-station.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants