Skip to content

Improve fireball detector recall with stdpixel decontamination - #866

Open
alextudorica wants to merge 3 commits into
CroatianMeteorNetwork:prereleasefrom
alextudorica:fireball-stdpixel-decontamination
Open

Improve fireball detector recall with stdpixel decontamination#866
alextudorica wants to merge 3 commits into
CroatianMeteorNetwork:prereleasefrom
alextudorica:fireball-stdpixel-decontamination

Conversation

@alextudorica

@alextudorica alextudorica commented May 19, 2026

Copy link
Copy Markdown

Summary

The fireball detector misses events when a bright fireball inflates stdpixel along its
trail within the 256-frame FF block. The threshold avepixel + k1*stdpixel + j1 then
exceeds the uint8 range and is clipped. thresholdAndSubsample keeps a pixel when
maxpixel >= threshold, so a fully-saturated pixel (maxpixel == 255) still passes — but
the unsaturated trail pixels, whose true value sits below the clipped threshold, are
dropped. The trail loses its fainter edges exactly where recall matters.

The line finder (find3DLines) is not the problem — it has zero failures when given a
clean point cloud. The bug is entirely upstream in the threshold stage.

Changes

VideoExtraction.py — stdpixel decontamination (main fix)

decontaminateStdpixel() replaces stdpixel at contaminated pixels with the background
median before thresholdAndSubsample:

  1. Background reference: std_ref = median(stdpixel) over background (non-bright,
    mask-valid) pixels.
  2. Contamination mask: maxpixel in the top decile AND stdpixel > 3 * std_ref.
  3. Replace: stdpixel[contaminated] = max(1, round(std_ref)).

Pure numpy, Pi/Py2-compatible. The 4-plane compressed array is copied only when
contamination is present, so the no-fireball path is zero-copy. Mask handling is
shape-guarded (mirrors maskImage()), so it is a no-op — not a crash — on stations with
detection_binning_factor > 1.

Grouping3Dcy.pyx — threshold clip 254

Clip the threshold to 254 so maxpixel == 254 pixels can also pass. This is a minor extra
rescue on top of the decontamination (which does the real work): saturated pixels already
passed under the old 255 clip.

Grouping3D.py / .config / ConfigReader.py — FOV-aware velocity cap

findCoefficients converts fireball_max_ang_vel (deg/s, default 60) into a per-sensor
px/frame cap using fps and the nominal [Capture] FOV, replacing the hardcoded total > 2.
Falls back to the legacy 2.0 px/frame when no config is passed. On the fleet's cameras
(~87×45° / 720p) this is ~2.3 px/frame — slightly wider than legacy.

Recall benchmark (129-event v2 reference dataset, real per-station configs)

Each station's shipped .config (real FOV / k1 / j1 / fps), stock vs this PR.

FR-positive recall (676 FFs where stock produced an FR file):

Stock This PR
Recall 666/676 = 98.5% 668/676 = 98.8%
Regressions (stock detected, PR lost) 0

Missed-station rescue (100 stations stock produced no FR for):

Stock This PR
Stations rescued 30/100 41/100 (+11)

0 regressions, +11 stations rescued, +2 FR-positive gains.

False-positive control (full ordinary nights, no fireballs)

Full nights of real FF files through stock vs this PR — the concern being that the lower
threshold and wider cap could re-admit planes / headlights / clouds:

Camera-night Stock FR PR FR Δ
Bucharest RO000W (urban, clear) — 2620 FF 16 16 +0
Vaslui RO000M (cloudy) — 2485 FF 10 16 +6
  • Clear night: 0 extra FR files — both modes produce the identical 16 (planes / bright
    objects stock already extracts).
  • Cloudy night: +6 FR files, hand-verified as moonlit-cloud-edge artifacts — marginal
    (they flip with the point-subsample RNG seed), not planes/satellites, not a new failure
    mode (stock's own FRs already include a plane and clouds). At ~0.66 MB/FR that is a few
    MB per cloudy camera-night.
  • Extractor CPU: +5.6–8.8 ms per FF for the decontamination (partition + median), run once
    per ~10 s FF block → <0.1% duty cycle.

Known limitation: for 180° all-sky fisheye cameras the linear deg/px conversion gives a
very tight cap (~0.8 px/frame). A fisheye's scale is not uniform across the frame, so the
cap is not meaningful there — those are the only configs where it is tighter than legacy.

Tests

Tests/test_fireball_decontamination.py: decontamination (clean no-op, contaminated trail,
binned-mask shape guard, mask exclusion, replacement clamp) and the deg/s → px/frame
velocity conversion.

@alextudorica
alextudorica force-pushed the fireball-stdpixel-decontamination branch from 7ac217a to 2f18ff2 Compare May 21, 2026 12:56
@alextudorica
alextudorica changed the base branch from master to prerelease May 21, 2026 12:57
@alextudorica
alextudorica force-pushed the fireball-stdpixel-decontamination branch 3 times, most recently from f88aa48 to b714310 Compare May 27, 2026 22:33
@alextudorica
alextudorica force-pushed the fireball-stdpixel-decontamination branch from b714310 to 0c788ea Compare May 29, 2026 15:11
A bright fireball inflates stdpixel along its trail within the 256-frame
FF block, pushing the threshold (avepixel + k1*stdpixel + j1) above 255.
The uint8 clip kills near-saturated trail pixels (250-254).

Fix: before thresholding, replace stdpixel at contaminated pixels
(maxpixel >= P90 AND stdpixel > 3x background median) with the
background median. Pure numpy, no Cython changes needed.

Also: clip threshold to 254 (not 255) so near-saturated pixels can pass,
and replace the hard-coded 2 px/frame fireball velocity cap with a
configurable angular velocity (fireball_max_ang_vel, default 60 deg/s)
converted to a per-sensor px/frame limit at runtime using fps and the
platepar-derived deg/pixel scale.

Tested on 129-event reference dataset (1755 FF files, 743 stations):
FR-positive recall 99.0% (679/686), missed station rescue 74.0%
(77/104), overall station recall 86.0% -> 96.4% (716/743). Zero
threshold failures on real data. Line finder has zero failures in all
686 tests -- the problem was entirely upstream in the threshold stage.
- Exclude masked pixels from contamination set, not just background sample
- Use O(n) np.partition instead of O(n log n) np.percentile for P90
- Copy only stdpixel layer instead of full compressed array (~2.8 MB saved)
- Report which specific config attributes are missing/zero in warning
- Fix Cython comment: only max_val=254 is newly enabled, not 250-254
- Document fallback velocity cap (~39 deg/s) vs production default (60 deg/s)
- Add from __future__ import division for Python 2 safety

@dvida dvida left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks -- the diagnosis that the recall loss lives in the threshold stage (stdpixel contamination pushing ave + k1*std + j1 past the uint8 ceiling) is convincing, the decontamination approach is sound, and the benchmark results are impressive. I verified the parts that can be checked statically:

  • Velocity conversion math is correct: deg/s / (deg/px) / fps / f correctly lands in subsampled px/frame (matches thresholdAndSubsample's x//f, y//f, per-frame z). The fallback 2.0 px/frame reproduces legacy behavior, and the ~39.5 deg/s equivalence claimed in the comment checks out at 720p/25fps/f=16.
  • No side effects on saved FF files: self.compressed is never mutated (decontamination writes to a copy), and detectionCutOut only reads the avepixel plane, so passing it the original array is consistent.
  • Config plumbing (.config -> parseFireballDetection -> findCoefficients) is correct and backward compatible (config=None keeps legacy behavior for external callers).

One blocking issue (inline): the mask handling crashes the extractor process on every FF block for stations with detection_binning_factor > 1 -- reproduced, details and a one-line fix in the inline comment.

One request before merge: a false-positive control benchmark. The decontamination strictly lowers thresholds -- it re-enables exactly the bright slow objects the old clipping accidentally suppressed (planes, car headlights, lightning), and the velocity cap simultaneously widens from an effective ~2.0 to ~3.0 subsampled px/frame at default config (+52%). The benchmark covers only fireball-positive FF files, so the FP cost of both changes is unmeasured. Please run a few full ordinary nights (no fireballs) stock vs. this PR and report the FR-file count and extractor CPU-time deltas. If plane/headlight FRs go up noticeably, that's a disk/bandwidth cost for the whole network.

Non-blocking notes:

  • The magic constants (P90 bright cut, 3*std_ref contamination factor, 100-px minimum background sample) would be easier to tune later as named module-level constants, even if not config options.
  • More threshold passers means the max_points (500) random subsample can dilute fireball points on noisy frames -- benchmark shows a net win, just flagging the mechanism.
  • A small synthetic unit test for the decontamination block (clean image no-op / contaminated trail gets replaced / binned-mask shape case) and the deg/s conversion would protect this logic; nothing in Tests/ covers this pipeline today.

Comment thread RMS/VideoExtraction.py Outdated
# Exclude masked-out pixels (camera borders/obstructions) from the background sample
if self.mask is not None:
mask_valid = self.mask.img > 0
bg_mask = bg_mask & mask_valid

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: this crashes the extractor for every station with detection_binning_factor > 1.

Extractor.__init__ bins the mask via binImageCalibration() (using config.detection_binning_factor), but the extractor operates on capture-resolution frames (Compression.py passes the full-res compressed array). With binning factor 2, self.mask.img is e.g. 360x640 while compressed[0] is 720x1280. The existing maskImage() tolerates this with a silent shape check (MaskImage.py), but this new code doesn't:

ValueError: operands could not be broadcast together with shapes (720,1280) (360,640)

(Reproduced with a synthetic 720p compressed array + a 2x-binned mask, running this block verbatim.) Since findPoints() runs inside the Extractor process, the exception kills the process on every FF block -- fireball detection is completely disabled for any station with detection binning enabled.

Suggested fix -- mirror the shape guard from maskImage():

if self.mask is not None and self.mask.img.shape == maxpix.shape:
    mask_valid = self.mask.img > 0
    bg_mask = bg_mask & mask_valid
else:
    mask_valid = None

Comment thread RMS/VideoExtraction.py Outdated
maxpix = self.compressed[0]
stdpix = self.compressed[3]
# O(n) partition-based percentile instead of O(n log n) full sort
bright_threshold = np.partition(maxpix.ravel(), -maxpix.size // 10)[-maxpix.size // 10]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: by this point executeAll() has already run maskImage() on maxpixel/avepixel, which fills masked pixels with the image mean -- and those filled pixels are included in this percentile. On heavily-masked stations this skews bright_threshold. Once the shape guard (see comment below) is in place, consider computing the percentile over mask-valid pixels only.

Comment thread RMS/VideoExtraction.py Outdated
contaminated = contaminated & mask_valid
if np.any(contaminated):
compressed = np.array(self.compressed)
compressed[3] = self.compressed[3].copy()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

np.array(self.compressed) already deep-copies all 4 planes, so this line copies plane 3 a second time -- it can be deleted. The comment above ("Only the stdpixel layer is copied") also doesn't match the code: the whole 4-plane array is copied when contamination is present (~3.7 MB at 720p, ~8 MB at 1080p -- acceptable, but the comment should say what actually happens). Making the copy truly std-only would require passing planes separately to the Cython function, which isn't worth it -- just fix the comment and drop this line.

Comment thread RMS/Routines/Grouping3Dcy.pyx Outdated
# Make sure the threshold limit is not above the maximum possible value
if avg_std > 255:
avg_std = 255
# Clip threshold to 254 so that pixels at exactly max_val=254 can still pass

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old code was not actually blind to saturated pixels: the pass condition below is max_val >= avg_std, so with the old clip at 255 a saturated pixel (255 >= 255) always passed. What actually failed were the unsaturated bright trail pixels (200-254) whose threshold got clipped to 255 -- and those are fixed by the stdpixel decontamination, not by this clip.

This change only additionally rescues pixels with max_val == 254, so it's a near-no-op (harmless, fine to keep). But please update this comment and the PR description ("maxpixel > 255 is always false for uint8", "the fireball becomes invisible at the very pixels where it is brightest") -- the stated mechanism is wrong and will mislead future readers about why the decontamination was needed.

Comment thread .config Outdated
max_lines: 5

; Maximum angular velocity for fireball candidates in deg/s. Converted to a per-sensor
; px/frame cap at runtime using fps and the platepar-derived deg/pixel scale, so the same

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"platepar-derived" is inaccurate -- the scale comes from the nominal fov_w/fov_h values in the [Capture] section of the config file, not from the platepar. Worth stating precisely, because users need to know which value has to be kept correct for this cap to be meaningful (nominal config FOV can be stale or wrong even when the platepar is good). Same wording appears in the ConfigReader.py comment above self.fireball_max_ang_vel.

- Fix extractor crash on stations with detection_binning_factor > 1: the binned
  mask no longer matches the capture-resolution frames, so guard the mask by shape
  (mirrors maskImage()) instead of raising a broadcast error in findPoints().
- Extract the decontamination into a testable decontaminateStdpixel() helper; compute
  the brightness percentile over mask-valid pixels only (executeAll fills masked-out
  maxpixel with the image mean, which otherwise skews it on heavily-masked cameras).
- Drop the redundant second copy of the stdpixel plane and correct the comment
  (np.array() already deep-copies all 4 planes).
- Name the tuning constants (BRIGHT_PERCENTILE, CONTAMINATION_STD_FACTOR,
  MIN_BACKGROUND_PIXELS) at module level.
- Correct the clip-254 comment and the .config/ConfigReader wording: the recall loss
  was unsaturated trail pixels clipped to 255 (fixed by decontamination), not saturated
  pixels; the velocity scale comes from the nominal [Capture] FOV, not the platepar.
- Add Tests/test_fireball_decontamination.py: decontamination (clean no-op, contaminated
  trail, binned-mask shape guard, mask exclusion, replacement clamp) and the deg/s ->
  px/frame velocity conversion.
@alextudorica

Copy link
Copy Markdown
Author

Thanks for the thorough review — all points addressed in 2d8994f.

Blocking (binning crash): fixed exactly as you suggested. The decontamination now
applies the mask only when mask.img.shape == maxpix.shape, otherwise skips it (mirroring
maskImage()), so stations with detection_binning_factor > 1 no longer hit the broadcast
ValueError. Reproduced your crash first, then confirmed the fix; there's a regression test
for the binned-mask case.

FP control benchmark (your merge gate): ran full ordinary nights (no fireballs) through
stock vs this PR, using each station's real .config + mask, counting FR files and
extractor CPU:

Camera-night Stock FR PR FR Δ
Bucharest RO000W (urban, clear) — 2620 FF 16 16 +0
Vaslui RO000M (cloudy) — 2485 FF 10 16 +6

On the clear night the two modes produce the identical 16 FRs (planes / bright objects
stock already extracts). On the cloudy night the PR adds 6 — I pulled and eyeballed each:
all are moonlit-cloud-edge artifacts, marginal (they flip with the point-subsample RNG
seed), none are planes/satellites, and stock's own FRs already include a plane and clouds.
So it's a small, night-dependent amplification of the existing bright-moving-object
sensitivity, not a new failure mode — a few ~0.66 MB FRs per cloudy camera-night. Extractor
CPU: +5.6–8.8 ms/FF for the decontamination's partition+median, run once per ~10 s FF block
(<0.1% duty cycle; a Pi3 will be slower in absolute ms but still negligible per block).

Non-blocking, all done:

  • Dropped the redundant compressed[3].copy(); comment corrected (the whole 4-plane array
    is copied when contamination is present).
  • Percentile now computed over mask-valid pixels only — avoids the mean-fill skew you
    flagged on heavily-masked cameras.
  • Tuning constants (BRIGHT_PERCENTILE, CONTAMINATION_STD_FACTOR,
    MIN_BACKGROUND_PIXELS) named at module level.
  • Clip-254 comment + PR description corrected: you're right the old code wasn't blind to
    maxpixel == 255; the recall loss was the unsaturated trail pixels clipped to 255, which
    the decontamination fixes.
  • .config / ConfigReader wording fixed to "nominal [Capture] FOV", not platepar.
  • Added Tests/test_fireball_decontamination.py (decontamination clean / contaminated /
    binned-mask / mask-exclusion / clamp + the deg/s conversion).

One correction to my own numbers while re-verifying: my original recall table came from a
benchmark whose make_config faked the FOV proportional to resolution, producing a bogus
1.24 px/frame velocity cap (tighter than legacy). Re-run with each station's real shipped
.config (median cap 2.30): FR-positive recall 666/676 → 668/676 (98.5% → 98.8%),
0 regressions, and missed-station rescue 30/100 → 41/100 (+11 stations). PR
description updated. Known limitation worth flagging: for 180° all-sky fisheye cams the
linear deg/px cap is meaningless (~0.8 px/frame) — those are the only configs where the FOV
cap comes out tighter than legacy.

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