Where is the fire, how fast can we say so, and can the model run on the camera?
The 2024 Kitchen fire (20240701_Kitchenfire), seen from four HPWREN towers, a frame a minute
from t = −120 s to +2400 s and re-solved from each camera's best detection so far. Each camera's
most confident smoke detection (right) casts a bearing from its tower; the bearings accumulate
into a likelihood field whose peak (✗) is the estimate; the open circle is the official ignition
coordinate. One ray has no depth; by +240 s three sites cross 1.22 km out. From +600 s
lp-e-mobo-c's best box is a bird (apparently with very smoke-like feet), and its ray, 38° off,
drags the estimate to 1.6–1.7 km until a stronger box on the plume replaces it at +1380 s. Four
sites then hold near 1.3 km until the last minutes' boxes close them to 0.14 km.
Wildfire smoke detection is usually reported as mAP on a held-out split. That number does not tell an operator anything they can act on. This project measures three things that it does: how far off the located ignition point is, in kilometers, against official coordinates; how many seconds until a real fire is called, at a false-alarm rate a dispatcher could absorb; and what the detector costs in milliseconds and watts on an ARM64 device with an NPU.
Built on HPWREN's Fire Ignition images Library — 189 sequences, 14,827 frames, 505 known camera positions — with ignition coordinates from the interagency WFIGS/IRWIN incident feed.
Bearings from two or more sites, accumulated into a likelihood field over the ground, scored in kilometers against the official ignition coordinate. The estimate is the likelihood peak, re-found at 10 m around the best cell of a 0.4 km grid. Medians in these tables are upper medians: with an even count, the higher of the two middle values.
| set | n | upper median error | max | ≤1 km | ≤2 km | ≤5 km |
|---|---|---|---|---|---|---|
| all scoring fires | 26 | 2.80 km | 63.21 km | 6 | 12 | 19 |
| name-confirmed truth | 10 | 1.87 km | 3.74 km | |||
| probable truth | 16 | 4.45 km | 63.21 km |
Each dot is one fire's estimate, displaced east/north from its own official coordinate. Left: the published camera table, full extent. The confirmed tier (blue) clusters at the centre and the probable tier carries the whole tail. Right: the inner 5 km under the star-measured lens (below), with a thin line back to where the published table put each estimate.
How to read the triangulation stills below: as in the animation at the top, each camera's most confident detection (right) casts a bearing (matching color) from its tower and the bearings are accumulated into the likelihood field, whose peak is the estimate (✗), whose falloff is the 95% contour, and where the open circle is the official coordinate. The inset appears only where the credible region is too small to see at the main scale.
The geometry holds up in conditions that are not benign. 20201202_WillowFire is a night
ignition seen against continuous city light from three sites, and lands 0.21 km from the
assigned coordinate (probable-tier truth).
The split between the tiers is the finding. Every confirmed-tier fire lands within 3.74 km; the probable tier carries the entire tail. Where a probable-tier fire fails, the failure has a shape worth reading: bearings agreeing with each other to within a few km² while sitting 20–60 km from the assigned incident. That shape has two possible causes — a wrong record, or a weak crossing angle — and a second official source tells them apart.
20171010_FIRE → PORTOLA: three bearings from two sites close on an 8 km² region, with the
WFIGS coordinate 23.86 km away and no ray passing near it. CAL FIRE records the same fire at
33.50488, −117.02132 — Riverside County, De Portola Road east of Pauba Road, 23 acres —
1.24 km from the estimate, inside the 95% region. The WFIGS point falls outside it
entirely, at zero relative posterior density; it reads 33°18′00″ 116°59′59″, rounded to the
arcminute, and is filed in the wrong county.
python -m src.figlib.calfire runs that cross-check over the corpus, picking each CAL FIRE
candidate by the WFIGS name and never by the estimate, so the comparison cannot borrow
credibility from the geometry it tests. On the 110 fires where both sources name the same
incident they agree to a median of 0.65 km, and 72 agree within 1 km. The official
records are usually right, and PORTOLA is an outlier rather than a tier-wide failure — it is
also the only one of the four disagreements over 5 km that has a solve to arbitrate it.
The inverse case is the guard against reading too much into a large error. 20171207_FIRE.2
→ LIBERTY has both sources agreeing to within 1.71 km while the estimate sits 63 km out, and
there the geometry really is what is wrong: two cameras 13° apart, a 154 km² credible region
smeared along the line of sight. A large error indicts the record only when the credible
region is small.
The published camera table is a nameplate: azimuths rounded to a compass quadrant, a nominal field of view, no lens model (see Caveats). Stars fix that without a site visit. A point that drifts at the sidereal rate is a star, and matching a night of such tracks to a star catalog under one shared pose measures a camera's azimuth, pitch, roll and lens together.
Green: a star's track over the night. Magenta: that catalog star under the solved pose, running inside the track. Orange: the same star under the published pose; the yellow arrows run from one to the other.
- 86 solves on 52 cameras in the pose ledger, at a median residual of 0.9 px and ~25 stars per solve. Each is solved through its unit's own optical centre, which on 34 of the cameras sits a median 29 px off the frame's middle (star-calibration v0.3.0 for the CDN nights; fitted from the FIgLib nights themselves for those). A second method adds five more (below).
- 34 of the 52 cameras point more than 1° from their published azimuth, mlo-s-mobo-c by 23°. Cameras also get re-aimed (Toro Peak West moved 7.4° between 2021 and 2026), so corrections are kept per camera and date, in a ledger.
- Not the lens the pipeline assumed. The solves put the focal scale at 0.883–0.894 of nameplate: an equidistant fisheye spanning about ±55°, not a rectilinear ±45°. Near the frame edge that was worth up to 8° of bearing.
docs/star-calibration.md has the rest: a sea-horizon check that agrees with the star pose, a closed-form pose from the star trails that names no star and found a second lens group, moonlit nights solving as well as dark ones, night-to-night agreement as the acceptance test, and prior work and other uses of the same sky.
Priced in kilometers, with the same detections and solver, on the name-confirmed fires:
| camera model | upper median error | ≤2 km |
|---|---|---|
| published azimuth, rectilinear lens | 1.87 km | 7 of 10 |
| star-measured fisheye lens | 1.68 km | 6 of 10 |
| + per-camera star azimuth from the ledger | 2.03 km | 5 of 10 |
| + each camera's own solved lens, pitch and roll | 2.03 km | 5 of 10 |
The ≤2 km counts are the fragile number: Clubfire (2.07 km), Roundfire (2.03) and Creelman
(2.11) all sit within 0.1 km of the line, so a tenth of a kilometre moves the count by three.
The last row is the calibrated model (FIGLIB_PROFILE=calibrated). On these fires it moves no
estimate by more than 0.03 km; it matters for the few cameras whose lens differs from the rest
(NOTES.md, 2026-09-25).
The lens is the clear gain in bearing: detections in the outer half of the frame go from a median miss of 4.4° to 3.5° (every bearing), and the median falls to 1.68 km. ResortFire.2 (1.87 → 0.70 km) and Roundfire (3.39 → 2.05) gain most; Clubfire (1.76 → 2.07) crosses the 2 km line the other way. The azimuth ledger lands where detections are right: ScissorsFire goes from 0.62 km to 0.07 km. It also makes JunctionFire worse, and that is informative. Correcting vo-n-mobo-c by 11.4° puts the ignition point outside that camera's field of view, and its low-confidence detection turns out to be a cumulus cloud at the frame edge. With the cameras calibrated, detection selection (edge-clipped boxes, best-confidence picking the wrong object) is what limits kilometers now. The full account is in NOTES.md.
A bearing gives a direction, and normally only a second site gives the distance. With the pose measured, the DEM can supply part of it. At every distance along the ray, terrain fixes the lowest image row that smoke rising from there can show. That row is the source itself where it is visible, or the crest in front of it where it is not. Usually it is not: most official ignitions sit behind a nearer crest, and the first detections sit on that crest.
Top: first detections (green) sit on the crest (white bar) that hides the official ignition (white cross). The lines are DEM ridges projected through the star-solved pose, with no pixels fitted. Bottom: a skyline predicted from the DEM and the star pose alone lands on the image edge. On the solves from before precession was modelled it sat 6 px low (the terrain check).
So the bottom of the earliest boxes must sit on the terrain line at the fire's distance. It can't be below the line, because smoke behind a crest can't be seen. It can't be far above it either, because a young plume's foot is on the terrain it rises from. Each camera with a star pose therefore gets one interval of distance along its bearing.
- The interval contains the fire. On 74 bearings within 5° of the official point, it contains the official distance on 61, with a median length of 0.91× that distance. At the official distance, the terrain row sits a median 1 px from the box bottom.
- One camera still says something. Each of the 118 confirmed and probable fires seen from a single site gets its own figure: bearing, miss across the ray (a median of 0.79 km on confirmed fires), and the interval. The interval contains the official point on 30 of 37 confirmed bearings with a star pose.
The interval rarely moves a two-site estimate, but it can say how far along a shared line
the fire is. 20260722_RainbowFire is seen by two cameras looking straight at each other, so
their bearings nearly coincide and give almost no distance:
- Bearings alone: 0.99 km, with a 95% region 25.8 km² long along the shared line. The two bearings are within 1° of collinear, so the peak is a long flat ridge, and a tenth of a degree slides it a kilometre: solving through each camera's optical centre moved the two bearings by 0.06° and 0.09°, and the estimate from 2.17 km to 0.99 km. It was 5.82 km before the ledger was re-solved with precession (and bearings made exact on WGS84), and 17 km before the fresh star solves of 2026-09-12 to -14, because Boucher Hill West's published azimuth was 1.16° off.
- With terrain, 30 px band: still 0.99 km, and the region shrinks to 5.0 km² (8.3 km² at 60 px, 21.0 at 100).
Why it is an opt-in term, not the default. On the 17 name-confirmed two-site fires across
all of FIgLib, the median doesn't move at 100 px (1.87 km), and a 30 px band makes it worse
(2.03 km) while dropping 4 of the 74 validated truths. On the poses from before precession was
modelled, the 100 px term moved 20250107_PalisadesFire from 1.82 to 0.32 km. With the
precessed ledger and the refined peak, its bearings alone put it at 1.08 km, and the term
changes nothing at any band. The interval is also only
as good as the pose under it. Toro Peak West was re-aimed between star solves: matching
skylines puts it 2.4° from today's pose at 20240724_GroveFire, and the interval drawn with
the wrong pose excludes the fire (14.9–29.0 km against 31.9 km).
The 40 minutes of pre-ignition frames in every sequence are negatives from the same camera under the same light — the same haze, cumulus and glint the positives sit in.
| τ | FA/camera-day | fires alerted | median s to alert |
|---|---|---|---|
| 0.25 | 25.8 | 98% | 180 |
| 0.40 | 8.0 | 94% | 240 |
| 0.50 | 3.6 | 90% | 360 |
| 0.70 | 0.40 | 59% | 660 |
The gap between what this detector does and what a network needs is three orders of magnitude. At τ=0.25 — an unremarkable default — 25.8 false alarms per camera-day is 13,000 alerts a day across 505 cameras. One per camera-week is 0.14/day, reached only past τ=0.7, where 41% of fires are missed. That is what per-frame operation costs at scale, and it is the argument for spending effort on temporal and cross-camera structure rather than another point of AP.
Two results worth stating because they were not what was expected:
- Persistence is cheap and it works. 2-of-3 frames over threshold cuts the rate about a quarter at matched recall, for one extra minute of latency.
- Cross-site agreement does not beat thresholding. Requiring two sites to agree geometrically halves the false-alarm rate — but so does raising the threshold, more cheaply, and the any-camera curve dominates at every operating point measured. What survives is narrower and real: geometry buys latency, not recall (240 s vs ~426 s at a matched 0.81 FA/camera-day). An earlier anecdote suggested otherwise; the measurement overruled it.
Full write-up in docs/edge-m3.md. A fanless M3 MacBook Air is a
defensible stand-in for a fielded camera: real ARM64, a real NPU, per-unit wattage from
powermetrics, and — being fanless — a genuine sustained-throughput curve rather than a burst
number.
| variant | unit | model latency | throughput | frames/joule |
|---|---|---|---|---|
| FP32 | ANE (requested) | 97.3 ms | 10.3 fps | — |
| FP16 | CPU | 48.6 ms | 20.6 fps | 2.3 |
| FP16 | GPU | 24.0 ms | 41.6 fps | 5.6 |
| FP16 | ANE | 11.0 ms | 91.0 fps | 12.0 |
| INT8-weight | ANE | 10.4 ms | 96.7 fps | 12.8 |
- FP32 silently does not use the NPU. The compute plan places 0 of 241 ops on the ANE, and requesting it returns the CPU at 97 ms and 0 mW ANE — with no error and no warning. Verify dispatch; never trust the requested setting.
- INT8 buys size, not speed, on M3. 10.4 ms against 11.0, and 7,574 mW against 7,566 — the same within run-to-run noise — for 9.3 MB against 18.2 MB. Through the M3 generation the ANE supports weight-only INT8; full INT8 activation compute arrived with A17 Pro and M4. Stating that distinction is the point.
- Preprocessing is half the budget. End-to-end is 20.2 ms against 11.0 model-only, so JPEG decode and letterbox cost ~9.2 ms — 45%.
- Thermal throttle is real but mild. 94 fps sustained for ~8 minutes, then a 9.8% step down; frames/joule improves (12.0 → 12.4) as clocks drop.
Priced downstream in kilometers: FP16 is free (2.28 km, identical recall). INT8-weight is not measurably worse: 2.67 km against 2.28 at 40 minutes, but 3.57 km against 3.93 at 3 minutes, with 9 fires inside 2 km against 6.
python -m venv .venv && .venv/bin/pip install -r requirements.txt
./data/fetch.sh # ~13 GB of FIgLib archives, idempotent
./data/fetch_dem.sh # optional: ~435 MB of DEM tiles, for the terrain modules onlyThen, roughly in pipeline order:
python -m src.figlib.ingest # parse the filename clock, join cameras
python -m src.figlib.fires # cluster event labels into real ignitions
python -m src.figlib.truth # WFIGS/IRWIN join -- no credentials needed
python -m src.figlib.resolve # disambiguate by bearing
./run_detect.sh # pyronear yolo11s over 189 sequences, 4 workers
python -m src.figlib.geolocate # kilometers against official coordinates
python -m src.figlib.falsealarm # the seconds-to-alert sweepStar calibration, optionally. The solver is
star-calibration, installed by
requirements.txt; CDN night frames go to its shared cache ($HPWREN_CACHE, default
~/.cache/hpwren):
python -m star_calibration.hpwren.nights 20260911 @cams.txt # night frames from HPWREN's public CDN
python -m src.figlib.stars.run_nights # track, star-solve, rebuild data/meta/pose_ledger.json
FIGLIB_PROFILE=calibrated python -m src.figlib.geolocate # star lens and whole solved camera
python -m src.figlib.compare_geolocation # baseline vs calibrated, fire by fireMore star commands are in docs/star-calibration.md.
Terrain ranges along a bearing, optionally (needs fetch_dem.sh, the calibrated poses, and
the full corpus):
FIGLIB_CORPUS=all python -m src.figlib.terrain_range validate # does the range contain the official point?
FIGLIB_CORPUS=all python -m src.figlib.fig_bearing # one figure per single-site fire
FIGLIB_CORPUS=all python -m src.figlib.terrain_range figure 20260722_RainbowFire 30Every number in this README comes from one pass of ./rerun_results.sh, which runs each
stage above in order from a committed tree and logs it (see Map of the code).
The Core ML work is macOS-only and installs separately (requirements-edge.txt); see
docs/edge-m3.md.
The full run needs ~13 GB of FIgLib archives, but the geometry does not. The camera table,
the sequence index, and the resolved ground truth are committed under data/meta/, and
tests/fixtures/ carries one fire's real detections — enough to run bearings, the
likelihood field, and evidence accumulation end to end:
pip install -r requirements-dev.txt
pytest # ~6 s; geolocates 20240701_Kitchenfire to 0.33 km (calibrated)CI runs this on every push. tests/test_geom.py pins the bearing and projection maths
against hand-checkable cases; test_geolocation.py and test_accumulate.py are the
end-to-end smoke tests, including that a confident wrong bearing perturbs the estimate
rather than capturing it.
Modules live in src/figlib/, with star calibration in src/figlib/stars/, and run as python -m src.figlib.<name>.
| stage | modules |
|---|---|
| Ingest & ground truth | ingest fires truth resolve wind · recent (cameras FIgLib didn't annotate, from the CDN) |
| Detection | detect_yolo (ONNX) · detect_coreml (Apple) · detect_diff (training-free floor) |
| Geometry | geom geolocate accumulate |
| Plume masks (tested, lost) | masks plumefit — segmentation-based bearings, see NOTES.md · open_vocab sam2_track (exploratory, with viewers) |
| Terrain (pose audit) | terrain calibrate pose_validate — see NOTES.md · terrain_range (how far along one bearing?) · ridge_feet (terrain under the star pose; hidden ignitions) |
| Star calibration | the star-calibration library (solver, lens, catalog, HPWREN camera table and CDN nights) · stars.solve stars.tracks (FIgLib and CDN sequences by name) stars.skyline_check stars.agree · pose_ledger frame_sizes compare_geolocation |
| Evaluation | falsealarm quantization evolve · coverage bias (is the 95% region honest?) |
| Edge | bench_edge power |
| Figures | viz viz_map viz_terrain animate animate_triangulate fig_peaks fig_pose fig_triangulate fig_bearing fig_offsets · stars.fig_solve_process stars.fig_moon |
Environment variables let a whole pipeline be re-scored against different inputs without
editing anything. All of them, with their defaults, are in
settings.py (python -m src.figlib.settings lists them as resolved).
FIGLIB_DETS points at an alternative detection directory (this is how quantized variants are
priced in kilometers) and FIGLIB_CAMS at an alternative camera table. FIGLIB_LENS=fisheye,
FIGLIB_POSE_LEDGER=1 and FIGLIB_POSE_FULL=1 switch on the star-measured lens, the
per-camera star azimuths, and each camera's whole solved camera; geolocate writes those
variants beside the baseline, never over it. A named profile in configs/ sets
several at once (FIGLIB_PROFILE=calibrated), and the environment overrides it. Two more
select the data: FIGLIB_CORPUS scores separately fetched FIgLib archives in their own
directories, and FIGLIB_TIER restricts scoring to sequences the detector cannot have trained
on. Archive hashes and a per-run log, which records every setting as resolved, live in
data/meta/ — see data/README.md.
NOTES.md is the lab notebook — running state, findings, and the reasoning behind each decision, including the predictions that were refuted and the claims that had to be corrected. It is the honest record, not a summary.
- The detector was trained on FIgLib. pyronear's
yolo11s_rapid-raccoon_v8.1.0listsFIGLIB_ANNOTATED_RESIZEDas a training source, so detection and timing figures measure memorisation as well as detection. They are reported as a labeled reference point, not a generalisation claim. Geolocation is unaffected — a memorised detection still yields a valid bearing, and kilometer error tests geometry. - The corpus cannot resolve the rate that matters. 40 minutes of negatives per sequence is 4.99 camera-days in total. Rates below ~1 FA/camera-day rest on zero to three events, and one false alarm per camera-week is not measurable on FIgLib at all. A Poisson upper bound is carried in the JSON for every row so the ratios are never read as more than they are.
- The camera metadata is a nameplate, not a calibration. Across all 505 cameras,
azis exactly 0/90/180/270 on 482 andfovexactly 90 or 60 on 483;pitch,rollandyaware non-zero on only 9, 14 and 3, and are literal0.0placeholders everywhere else. There is no focal length, principal point or distortion coefficient anywhere. Bearings are therefore only as good as a compass heading rounded to a quadrant, which is worth knowing before reading a kilometer figure.NOTES.mdrecords the attempt to refine it against terrain, and why that failed. Star tracks succeed where terrain did not; see Camera calibration from the night sky. - "Minutes of warning gained" is not a claim this data supports. On the core corpus,
official
FireDiscoveryDateTimeminus annotated plume appearance has a median of +1.0 min — humans reported 7 of the 10 name-confirmed fires before the plume was annotated visible. What the WFIGS join buys is evidence that the FIgLib clock is a validated proxy for when a human knew.
- FIgLib / HPWREN — conceived, created and maintained by Hans-Werner Braun for HPWREN at UC San Diego. Use of this data requires a credit reference to https://www.hpwren.ucsd.edu/.
- pyronear —
yolo11s_rapid-raccoon_v8.1.0, Apache 2.0. - WFIGS / IRWIN — interagency wildland fire incident locations, NIFC open data.
- Copernicus DEM GLO-30 — ESA, via the AWS Open Data registry.
- Star calibration of HPWREN cameras — R. Quimby, Using the Stars for Altitude-Azimuth Calibration of HPWREN Cameras, HPWREN, 20 September 2024.
- HYG Database — star catalog, astronexus/HYG-Database, CC BY-SA 4.0, through star-calibration, which ships the filtered (mag ≤ 4) derivative.
Code is MIT (LICENSE). The data and model weights above are used under their own terms and are not redistributed here.









