Wildfire lookout networks have far more camera feeds than anyone can watch. The useful question isn't "is there smoke in this frame?" It's "which of these dozens of tiles should a person open next?", asked without burying them in alerts until they mute the tool.
I built PlumeRank around that question for the OpenCV AI Competition 2026. The most useful thing I learned wasn't in my own pipeline. It was what happened when I gave a baseline the same treatment.
Live, no login: https://live.plumerank.edycu.dev · Code: https://github.com/edycutjong/plumerank
Fix the budget first, then compare
Every system in the comparison gets the same allowance: 2 alerts per camera per 81-minute window, which is the length of one FIgLib sequence. Each one is calibrated to spend that allowance on a fire-free confusion set of 33 windows (low cloud, cloud shadow, overcast, cameras hunting exposure), and only then scored on fire footage.
The allowance is enforced by a small, deliberately strict object:
def spend(self, camera_id: str) -> bool:
"""Spend one alert on this camera. False when its allowance is already gone."""
if camera_id not in self._spent:
raise KeyError(f"{camera_id!r} is not on the roster this budget was built for")
if self._spent[camera_id] >= self._per_camera:
return False
self._spent[camera_id] += 1
return True
An unknown camera raises instead of being auto-registered. A typo that silently created an extra camera would inflate the denominator that the whole comparison rests on.
The threshold isn't chosen by hand. solve_threshold finds the lowest value whose realised alert rate on the confusion set stays within the budget. A system can't look fast by alerting more, because it isn't allowed to.
The result that surprised me: 2 → 18
My first baseline was the obvious one: run the same small CNN on every frame and alert when its probability crosses the solved threshold.
At the fixed budget it detected 2 of 54 held-out fire windows.
The reason is in the table footnote: the solved threshold landed within 1e-6 of 1.0. On a fire-free set full of cloud and haze, the CNN's probabilities saturate. The only way to keep it to 2 alerts per camera-window was to demand near-certainty, and almost no real fire reached that.
PlumeRank doesn't rank a camera by its absolute score. It ranks it by how far the camera has moved from its own recent past:
def rebase(hazard, past, *, warmup=BASELINE_WARMUP_FRAMES,
sd_floor=BASELINE_SD_FLOOR, z_scale=BASELINE_Z_SCALE) -> float:
"""One camera's hazard this minute, relative to the hazards in ``past``.
``past`` is the trailing window and must NOT contain ``hazard`` itself."""
n = len(past)
if n < warmup:
return 0.0
mu = sum(past) / n
var = sum((v - mu) ** 2 for v in past) / n
sd = max(math.sqrt(var), sd_floor)
z = (hazard - mu) / sd
return 1.0 / (1.0 + math.exp(-z / z_scale))
The window is 15 frames (15 minutes) per camera. A camera staring into haze all afternoon competes with its own hazy afternoon, not with a camera under clear sky.
For a fair comparison I put both baselines through that same function. The CNN-on-every-frame went from 2/54 to 18/54. Frame differencing went from 4/54 to 20/54.
If you run any detector on fixed cameras, comparing each camera with its own recent past is probably the cheapest improvement available. It is about twenty lines and needs no training data.
The pipeline (OpenCV 5)
Per camera, per frame:
-
Stabilise: Hanning-windowed
cv2.phaseCorrelatefor the coarse shift, refined withcv2.phaseCorrelateIterative, and anestimateAffine2Dfallback when the phase peak is weak. - Segment: MOG2 background subtraction finds what moved.
- Describe: four cheap plume statistics on the moving region, fused into one hazard.
- Rebase: the function above.
-
Escalate: a small classifier on
cv2.dnn, only where it can change the answer. - Spend: one solved threshold, one budget.
Step 5 is where the cost goes, so the gate is two conditions in one line:
def should_escalate(rank: int, hazard: float) -> bool:
return rank <= ESCALATION_TOP_K and hazard >= ESCALATION_HAZARD_MIN
ESCALATION_TOP_K is 2, so at most two cameras per tick can ever reach the network, whatever the hazards do. The hazard floor is solved on the fire-free set and never tuned against fire windows. In practice the network runs on 1.76% of frames.
Everything runs on one AWS t4g.small (Graviton/ARM), replaying real HPWREN footage through the real pipeline.
The held-out run
The constants were frozen in their own commit and hashed, and the bench refuses to read a held-out frame unless the hash matches. The held-out set was read once.
| System (same budget, same per-camera baseline) | Detected |
|---|---|
| PlumeRank | 27 / 54 |
| Frame differencing | 20 / 54 |
| CNN on every frame | 18 / 54 |
Cost: 1.4 neural-net calls per camera-window for PlumeRank, against 3,645 for the CNN on every frame.
What doesn't hold up
These are on the README's first screen too, because I'd rather say them than have them found:
- The detection lead isn't significant. Paired McNemar p = 0.18 and 0.25 against the two rebased baselines, and approximate, because 22 of the 54 windows come from fires that contribute more than one window. The cost gap is the decisive result; the detection count is a lead.
- Half the held-out fire windows are missed (27 of 54).
- The median is on a knife edge. 38.0 min is a nearest-rank median: the 27th and slowest detection. Shift the ignition clock a few minutes later and there's no median at all. Detected fires themselves are found sooner (median 10.0 min).
- Daylight only. No IR, no night detection.
- The statistic I designed the project around is anti-predictive on its own (AUC 0.44 on development frames). The per-camera baseline carries the ranking, which is part of why the 2 → 18 result matters more to me than my own number.
Try it
-
Live wall: https://live.plumerank.edycu.dev (30-second judge path:
/judge) -
Repo: https://github.com/edycutjong/plumerank. 833 tests;
make verifyre-checks the micro-corpus metrics byte-for-byte in about 3 minutes. - Walkthrough video (3.5 min): https://youtu.be/QqX85JuNfVM
Thanks to HPWREN and the FIgLib authors. Their imagery is what made any of this measurable.
If you've worked with fire cameras, FIgLib or SmokeyNet, I'd like to hear where this reasoning breaks.
Top comments (0)