Estimates the GPS coordinates of a forest fire from a single fixed PTZ camera whose position is known but whose pan angle (heading) is not.
| Camera | Best result | Range | Method |
|---|---|---|---|
| Oghi (frame4) | 91 m error | 1.84 km | Direct terrain intersection, SRTM DEM |
| Danna Top (frame5) | ~596 m error | 8.74 km | Ridge fallback + range refinement (step 5) |
| Danna Top (frame5) | ~1,037 m error | 9.35 km | Ridge fallback alone (before step 5) |
- A synthetic horizon profile is computed from a Digital Elevation Model (DEM), what the ridge silhouette should look like from the camera's GPS position in every direction.
- The observed ridge silhouette in a real camera frame is matched against the synthetic profile to find the camera's heading, tilt, and HFOV. This is skyline matching.
- Once the camera is calibrated, fire pixel positions are back-projected through the calibration and intersected with the DEM to get GPS coordinates.
- If the ray does not intersect terrain (fire is hidden behind a ridge), a ridge fallback reports the closest-approach point as an estimated location.
| Session | Approach | Error vs NASA FIRMS |
|---|---|---|
| Baseline (manual landmark clicking) | Pixel offset to yaw to position | ~800-900 m |
| Previous session | Skyline matching, single heading | ~461 m |
| Current best | Calibrate on fire frame + heading_from_fire | ~91 m |
- Ground truth: NASA FIRMS, 34.53385, 73.02354
- Fire range: 1.84 km
- DEM used: SRTM (
dem/oghi_dem.tif) - All three annotated frames converged within 2 m of each other.
| Approach | Error vs NASA FIRMS |
|---|---|
| SRTM DEM | Ray misses all terrain, no result |
| Copernicus DEM + ridge fallback | ~1,037 m |
| + step 5 range refinement (physics) | ~596 m |
- Ground truth: NASA FIRMS, 34.51177, 73.32583
- Fire range: ~8.3 km (ridge fallback range: 9.35 km; refined: 8.74 / 8.91 km)
- DEM used: Copernicus GLO-30 (
dem/copernicus_dem.tif) - Both annotated frames produce identical predicted coordinates before refinement.
- The true range falls inside the refiner's 80% credible interval for both frames.
- Residual ~280 m is the 1.9° bearing error; range refinement cannot fix bearing.
The ~1,037 m error on frame5 is not a calibration failure; it is a fundamental geometric limit of single-camera observation when the fire is hidden behind a ridge.
What is happening:
- The fire is at ~8.3 km, bearing ~345.8° from Danna Top.
- A mountain ridge sits between the camera and the fire.
- Only smoke that has risen above the ridge is visible in the image (pixel y ≈ 82, top of frame).
- The ray at that elevation angle (+5.98°) flies over all terrain, reaching ~2,378 m altitude at 9.35 km. The fire terrain is ~1,400-2,000 m. The smoke visible to the camera has already risen 400-1,000 m above the fire base before cresting the ridge.
- The ridge fallback reports the closest-approach point (9.35 km) as the best estimate, but the fire is actually ~1.1 km closer (8.28 km).
Error decomposition:
| Source | Contribution |
|---|---|
| Range overshoot (ridge fallback vs fire range) | ~1,102 m |
| Bearing error (1.92° from calibration limit) | ~280 m lateral |
| Total | ~1,037 m |
Why the bearing cannot be improved further: The skyline geometry looking NNW from Danna Top has a nearly flat cost landscape between 344° and 346°. Every calibration attempt (single-frame, multi-frame averaged, constrained search, all 15 frames) converges to ~344.5-344.7°. The true bearing is 345.82°. The residual 1.9° is irreducible with skyline matching for this camera/scene.
What would actually fix it:
- A second camera at a different location, triangulating two bearing lines eliminates the range ambiguity entirely and would reduce error to <200 m.
- The fire becoming directly visible (clearing smoke/ridge), would give a clean terrain intersection instead of a ridge fallback.
What we did instead (step 5): with only one camera, recover the range from the plume rather than the ray. See "Ridge Range Refinement" below; this takes frame5 from 1,037 m to ~600 m without a second camera. It does not replace triangulation; it narrows the gap until a second camera exists.
When the ray misses terrain, the bearing is still good to a fraction of a degree; only the range is unknown. Step 5 estimates that range from the smoke plume and the terrain profile along the bearing, then places the fire on terrain at that range.
For every candidate ground range s along the bearing, the fire would sit on terrain at
altitude z(s). For its plume top to reach the angle we observe above the skyline, that
plume would have to be H_implied(s) tall:
H_implied(s) = s * tan(theta_top) + cam_alt + drop(s) - z(s)
H_implied grows roughly linearly with s. A fire 20 km back would need a 1 km smoke
column to peek over the ridge. Combined with atmospheric extinction (distant smoke is
washed out, contrast ∝ exp(-beta*s)), this breaks the range degeneracy.
beta is measured per-image, for free: the skyline is terrain at a range the DEM knows
exactly, and its contrast against the sky inverts Beer-Lambert directly.
1. Occlusion is a running-max property, not an argmax property. The intuitive hypothesis space, "the fire is beyond the skyline crest", is wrong, and it silently excludes the truth. A point is hidden iff it falls below the running maximum elevation angle of all terrain nearer than it:
theta_horizon(s) = max over s' < s of elev_terrain(s')
hidden(s) <=> elev_terrain(s) < theta_horizon(s)
On the real frame5 bearing the fire is at 8.29 km, occluded by terrain right in front of it, while the global skyline crest is at 9.30 km, a kilometre further out. Anchoring the hypothesis space on the crest cannot contain the answer.
2. Occlusion prunes almost nothing. ~90% of the profile beyond a few hundred metres is hidden. Geometry supplies the hypothesis space and the on-terrain constraint; it does not supply the answer. The discrimination comes from the soft plume-height prior and the contrast decay.
ridge_geometry.py lays down 96 log-spaced range hypotheses along the bearing (300 m to 35 km)
and keeps the ones that are physically admissible: on DEM terrain, occluded by nearer terrain,
and implying a plausible plume height. physics_posterior() then scores each survivor in
closed form:
log p(s) = log P_lognormal(H_implied(s)) plume-height prior
+ log N(ln C_obs ; ln C0 - beta*s, sig) atmospheric extinction (soft)
The plume-height prior is the workhorse; the haze term only nudges it, because the intrinsic
smoke contrast C0 is genuinely uncertain (see "Why not a learned model?"). The output is a
full distribution, so we report p10/p50/p90 rather than a false point estimate. For a
ridge-occluded fire the honest answer is "5 to 9 km, 80% confident", not "8.74 km". It uses no
learning, no torch, and no checkpoint.
| Range | Error vs FIRMS | |
|---|---|---|
| ridge fallback (before) | 9.35 km | 1,039 m |
| physics refiner | 8.74 / 8.91 km | 596 m |
The true range falls inside the 80% credible interval on both frame5 frames. This is a real fire with NASA FIRMS ground truth, and the only ridge-occluded event we can check.
n = 1 event. One event is not a validation set.
eval_frame5.pyexists so the claim stays falsifiable; treat the 596 m as a smoke test with a number attached, not evidence of generalisation. About 280 m of the residual is the 1.9° bearing error, which no range refiner can touch; a second camera remains the real fix.
An earlier version fused smoke and terrain tokens with a small transformer (a pointer head over
the terrain tokens, trained on physics-simulated fires over the real DEM). It was 3x better
than the closed form on synthetic data (427 m) and catastrophically worse on the one real
fire, and confidently so, with the truth outside its 80% interval. It has since been removed.
The full post-mortem is in new_refinements; the short version:
- It was faithful to its training data, not broken. The nearest synthetic fires to frame5 in feature space (not conditioned on the answer) have a true-range median of ~2.5 km, and the model said 1.76 km. The simulator is what disagreed with reality.
- The haze cue is structurally fragile.
haze_range = -ln(C/C₀)/β, so its error isln(C₀_assumed/C₀_true)/β. Dividing by β amplifies any error in the assumed intrinsic smoke contrast (at β=5e-5, C₀=0.35 vs 0.65 gives about +12 km). The simulator's honestC₀ ~ U(0.35, 0.90)prior makes the cue genuinely unreliable; the model correctly learned to discount it, which is exactly wrong on a real frame where C₀ happens to sit near the assumed value. - The closed form wins partly by luck. It uses a fixed wide-tolerance likelihood instead of a learned discount, so it lets the haze evidence pull the estimate. On frame5 that paid off. With n=1 we cannot distinguish that from skill.
The lesson, kept: never promote a learned component on synthetic validation alone. Synthetic validation said 427 m moments before the model said 0.69 km for a fire at 8.29 km.
Step 5 runs automatically inside pipeline.py whenever a ray misses terrain. It needs
neither torch nor a checkpoint.
# Standalone refiner
python refine_ridge_range.py \
--locations output/fire_locations_frame5.csv \
--calibration calibration_frame5.json \
--camera-lat 34.439466 --camera-lon 73.347998 --tower-height 10 \
--dem dem/copernicus_dem.tif
# Check it against ground truth
python eval_frame5.pyThree bugs that only appear on real frames, all found on frame5 and all fixed in
fire_features.py:
- The LUMS detector's blue overlay box (BGR ~160,4,0) sits directly on the plume and
wrecks every contrast measurement. We inpaint it. The mask must be strict: the loose
test in
calibrate_camera.preprocess_night(B>80, B-G>40, B-R>40) also matches hazy blue-grey mountains, selecting ~7,000 terrain pixels on a frame with no box at all. - A global sky reference is contaminated by haze gradients and by the plume itself. Take it from columns local to the plume, above the skyline.
- The skyline is not horizontal. Sampling a fixed row across the full image width
averages sky and terrain together, collapsing contrast to ~0 and pinning
betaat its clamp. Find the boundary per column by peak luminance gradient.
Before these fixes, the same fire measured theta_vis = 1.449° in one frame and 3.686° in
the next. After: 1.503° and 1.541°.
Also note theta_vis is measured against the DEM-predicted skyline row, not
detect_skyline(). On frame5 detect_skyline() latches onto a hazy valley boundary 110 px
below the true silhouette; the DEM prediction lands on it.
The curvature correction is applied with the wrong sign.
ENU is a plane tangent at the camera. The ellipsoid curves away below it, so a ray's true
altitude is higher than flat-ENU suggests, not lower. Degenerate check: a camera on the
surface (alt0=0) looking exactly horizontally over a flat ellipsoid must never hit ground.
alt_ray_corrected = alt_ray - s²/(2R) -> -s²/(2R) <= 0 ray sinks into the ground
alt_ray_corrected = alt_ray + s²/(2R) -> +s²/(2R) > 0 ray correctly escapes
The docstring's claim that this "fixes systematic range overestimate at >5 km" is also backwards: curvature makes distant terrain sink below the horizon, so a flat-earth march underestimates range.
Measured impact is small, the sign flip costs 2*s²/(2R) of ray height (0.6 m at 2 km,
15.7 m at 10 km):
| Case | current (-) |
correct (+) |
shift |
|---|---|---|---|
| frame4 (1.84 km) | 1842.4 m | 1844.0 m | 1.6 m |
| frame5 (9.35 km, ridge fallback) | 9350 m | 9350 m | 0 m |
So this does not explain the 91 m or 1,037 m errors, and it is left unchanged so as not
to perturb validated results. It would matter beyond ~20 km and for grazing rays near a
crest, where a few metres of vertical shift can flip between intersection and ridge_fallback.
ridge_geometry.py uses the correct sign, with k = 1.16 for optical refraction.
The default --max-shift 80 is too small when the camera has panned more than ~3° from the
reference frame. The cross-correlation finds a spurious local peak and reports a wrong
heading. Always estimate the expected pixel shift (pan_degrees / HFOV * image_width) and
set --max-shift to at least that value.
For frame5, the camera panned ~7.3° between frames (199 px at HFOV=23.97°) but
--max-shift 80 caused track_heading to report only +0.3 px. Fix: use
--max-shift 250 or larger, or use the fire-pixel consistency method below.
Workaround for large pans: infer the heading from fire-pixel consistency:
# Same fire visible in two frames, so fire bearing must be identical
# heading_B = fire_bearing_from_A - pixel_offset_B
import math
HFOV, W = 23.97, 640
fx = (W/2) / math.tan(math.radians(HFOV/2))
offset_A = math.degrees(math.atan((x_A - W/2) / fx))
fire_bearing = heading_A + offset_A
offset_B = math.degrees(math.atan((x_B - W/2) / fx))
heading_B = fire_bearing - offset_B| Scenario | Recommended DEM |
|---|---|
| Short range (<5 km), clean terrain intersection | SRTM (dem/oghi_dem.tif) |
| Long range, fire near/behind ridgeline | Copernicus (dem/copernicus_dem.tif) |
Ray gives method=ridge_fallback |
Copernicus (ridgeline height accuracy matters) |
Ray gives method=intersection |
Either; prefer whichever gives lower multi-frame spread |
Copernicus GLO-30 uses TanDEM-X interferometric SAR and more accurately captures sharp mountain peaks vs SRTM which systematically underestimates ridgelines due to radar penetration and canopy averaging.
Important: switching DEM also changes the horizon profile and calibration. Always
rebuild horizon_profile_*.csv and re-run calibrate_camera.py when changing DEMs.
Tested on frame4 (Oghi): Copernicus made results worse (130 m avg vs 91 m with SRTM)
because at 1.84 km the calibration drifted with the different horizon shape. SRTM is
sufficient for short-range direct intersections.
Multi-frame spread as calibration quality gate: after localizing, if predicted fire
locations across frames spread > 150 m, the calibration landed in a wrong local minimum.
Re-run calibrate_camera.py with tighter --heading-min/max bounds.
python download_copernicus_dem.py --out dem/copernicus_dem.tifDownloads tiles N34E072 and N34E073 from the public AWS S3 bucket (no account required), merges them into a single GeoTIFF. Output: 3600x7200 px, bounds 72-74°E 34-35°N, ~74 MB.
Skyline matching requires a visible sky/terrain boundary. At night the sky/terrain contrast is much lower than daytime, and camera systems (e.g. LUMS) draw blue bounding boxes directly onto saved frames, these are not real scene content and confuse the gradient detector.
Both issues are solved by the --night flag in calibrate_camera.py, which:
- Erases blue overlay pixels (B channel >> R and G) before detection.
- Applies CLAHE to amplify the faint sky/terrain boundary.
The rest of the pipeline (detect, grid-search, Nelder-Mead) is unchanged.
Night calibration for frame3 (Danna Top):
python build_horizon.py \
--camera-lat 34.439466 \
--camera-lon 73.347998 \
--tower-height 17.5 \
--dem dem/oghi_dem.tif \
--out horizon_profile_danna.csv
python calibrate_camera.py \
--frame frames/frame3_frame_0008.png \
--horizon horizon_profile_danna.csv \
--night \
--sky-frac 0.45 \
--heading-min 150 --heading-max 270 \
--hfov-min 20 --hfov-max 60 \
--out calibration_danna.jsonFull nighttime pipeline for frame3:
python annotate_fire.py \
--frames frames/frame3_frame_0008.png frames/frame3_frame_0009.png \
--out fire_pixels_danna.csv
python heading_from_fire.py \
--fire fire_pixels_danna.csv \
--calibration calibration_danna.json \
--anchor-frame frames/frame3_frame_0008.png \
--out frame_headings_danna.csv
python localize_fire.py \
--camera-lat 34.439466 \
--camera-lon 73.347998 \
--tower-height 17.5 \
--calibration calibration_danna.json \
--fire fire_pixels_danna.csv \
--dem dem/oghi_dem.tif \
--frame-headings frame_headings_danna.csv \
--out output/fire_locations_danna.csvforest-fire-localization-ptz/
│
├── data/
│ ├── frame3.gif Raw GIF feeds
│ ├── frame4.gif
│ └── frame5.gif
│
├── dem/
│ ├── oghi_dem.tif SRTM DEM, Oghi/Danna Top area
│ ├── copernicus_dem.tif Copernicus GLO-30 DEM (same coverage, higher accuracy at peaks)
│ └── cache/ Raw tile cache for download scripts
│
├── frames/ PNG frames extracted from GIFs
│ └── *.png
│
├── output/ Localization results
│ ├── fire_locations.csv Frame4 (Oghi)
│ ├── fire_locations_frame5.csv Frame5 (Danna Top)
│ └── fire_locations_danna.csv Frame3 (Danna Top, nighttime)
│
├── extract_frames.py Step 1: Extract PNG frames from GIFs
├── build_horizon.py Step 2: Build synthetic horizon from DEM
├── calibrate_camera.py Step 3: Match observed skyline to synthetic horizon
├── track_heading.py Step 3b: Track heading change via skyline cross-correlation
├── heading_from_fire.py Step 3c: Derive per-frame heading from fire pixel position
├── annotate_fire.py Step 4: Click to mark fire/smoke pixel (only manual step)
├── localize_fire.py Step 5: Compute fire GPS coordinates (with ridge fallback)
├── download_copernicus_dem.py Utility, Download Copernicus GLO-30 tiles from AWS S3
│
│ # Ridge range refinement (step 5 of pipeline.py), see "Ridge Range Refinement"
├── ridge_geometry.py Range hypothesis space + closed-form physics posterior
├── fire_features.py Plume feature extraction from a real frame
├── refine_ridge_range.py Rewrites ridge_fallback rows with the refined estimate
├── eval_frame5.py Ground-truth check against NASA FIRMS (n=1)
│
├── calibration.json Oghi camera calibration (SRTM)
├── calibration_frame5.json Danna Top calibration (Copernicus)
├── calibration_danna.json Danna Top calibration (nighttime, SRTM)
├── horizon_profile.csv Oghi horizon (SRTM)
├── horizon_profile_danna_cop.csv Danna Top horizon (Copernicus)
├── horizon_profile_danna.csv Danna Top horizon (SRTM, nighttime)
├── fire_pixels.csv Frame4 fire annotations
├── fire_pixels_frame5.csv Frame5 smoke annotations
├── fire_pixels_danna.csv Frame3 fire annotations (night)
├── frame_headings.csv Frame4 per-frame headings
├── frame_headings_frame5.csv Frame5 per-frame headings
├── frame_headings_danna.csv Frame3 per-frame headings (night)
├── locations.txt Camera GPS coordinates reference
└── requirements.txt
pip install -r requirements.txtRequires: numpy, opencv-python, Pillow, scipy, rasterio, requests
python extract_frames.py --input data/frame4.gif --out frames/Output: frames/frame4_frame_0001.png … frame4_frame_0015.png
Compute the expected ridge elevation angle at every azimuth from the camera position. Run once per camera location per DEM. Reuse for all future events.
# Oghi camera: SRTM
python build_horizon.py \
--camera-lat 34.534508 --camera-lon 73.003801 \
--tower-height 17.5 \
--dem dem/oghi_dem.tif \
--out horizon_profile.csv
# Danna Top camera: Copernicus
python build_horizon.py \
--camera-lat 34.439466 --camera-lon 73.347998 \
--tower-height 10 \
--dem dem/copernicus_dem.tif \
--out horizon_profile_danna_cop.csv| Argument | Description |
|---|---|
--camera-lat / --camera-lon |
Camera GPS coordinates |
--tower-height |
Height of the camera mast in metres |
--dem |
DEM GeoTIFF, must cover at least 40 km radius |
--az-step |
Azimuth resolution in degrees (default: 0.1°, gives 3600 rows) |
--max-range |
Maximum ray-cast range in metres (default: 40000) |
--out |
Output CSV path |
Determine heading, tilt, and HFOV by matching the observed ridge silhouette against the synthetic horizon. Now supports multiple frames, their skylines are averaged before optimisation to reduce per-frame noise.
# Single frame (standard)
python calibrate_camera.py \
--frame frames/frame4_frame_0009.png \
--horizon horizon_profile.csv \
--hfov-min 16 --hfov-max 26 \
--heading-min 75 --heading-max 110 \
--out calibration.json
# Multiple frames averaged (reduces noise; use only frames at the same pan position)
python calibrate_camera.py \
--frame frames/frame5_frame_0004.png frames/frame5_frame_0006.png \
--horizon horizon_profile_danna_cop.csv \
--sky-frac 0.25 \
--heading-min 330 --heading-max 360 \
--hfov-min 18 --hfov-max 30 \
--out calibration_frame5.json| Argument | Description |
|---|---|
--frame |
One or more PNG frames. Multiple frames are averaged before optimisation, use only frames at the same camera pan position |
--horizon |
horizon_profile.csv from Step 2 |
--hfov-min / --hfov-max |
Search range for HFOV (default: 20-90°). Narrow this if zoom is known |
--tilt-min / --tilt-max |
Search range for camera tilt (default: -20 to +20°) |
--heading-min / --heading-max |
Always narrow to ±40° around known camera direction to avoid locking onto a wrong ridge |
--sky-frac |
Fraction of image height to search for skyline from the top (default: 0.5). Use 0.25 if foreground trees contaminate the detection |
--night |
Erase blue overlay boxes and apply CLAHE before detection |
--out |
Output JSON path |
Quality check, cost_px:
| Value | Meaning |
|---|---|
| < 5 px | Excellent |
| 5-10 px | Good |
| 10-15 px | Acceptable |
| > 15 px | Poor, try clearer frame or narrower search |
Note: low
cost_pxdoes not guarantee correct heading. The optimizer can find a visually similar ridge at the wrong azimuth. Always constrain--heading-min/max.
For panning cameras: estimate heading change between a reference frame and fire frames via horizontal skyline cross-correlation.
python track_heading.py \
--reference frames/frame4_frame_0001.png \
--frames frames/frame4_frame_0009.png frames/frame4_frame_0010.png \
--calibration calibration.json \
--sky-frac 0.35 \
--max-shift 250 \
--out frame_headings.csv| Argument | Description |
|---|---|
--reference |
Clear reference frame at the calibration pan position |
--frames |
Fire frames to compute headings for |
--sky-frac |
Top image fraction to use (default: 0.35) |
--max-shift |
Maximum pixel shift to search. Default 80 is too small for large pans. Estimate: pan_degrees / HFOV * image_width. Use 250+ for safety |
--out |
Output CSV: frame, heading_deg, pixel_shift, confidence |
Check the confidence column, values below 0.3 are unreliable. For large pans or heavy
smoke, use Step 3c instead.
Most accurate for multi-frame panning sequences. The fire is a fixed world point, so its bearing from one anchor frame constrains the heading for every other frame.
python heading_from_fire.py \
--fire fire_pixels.csv \
--calibration calibration.json \
--anchor-frame frames/frame4_frame_0009.png \
--out frame_headings.csv| Argument | Description |
|---|---|
--anchor-frame |
Frame whose heading is treated as ground truth, use the calibration frame |
--anchor-heading |
Override anchor heading in degrees if you have a better estimate |
Output: frame, heading_deg, fire_x, offset_deg
Click the base of the visible smoke/fire plume in each fire frame.
python annotate_fire.py \
--frames frames/frame5_frame_0003.png frames/frame5_frame_0005.png \
--out fire_pixels_frame5.csv| Key | Action |
|---|---|
| Left-click | Place / move the marker |
| S | Save and move to next frame |
| D | Skip frame |
| Q | Quit |
For ridge-occluded fires, click the lowest visible smoke pixel, the point where smoke first becomes visible above the ridgeline. This gives the smallest elevation angle and the closest ridge-fallback range estimate.
Cast a ray from the camera through the annotated pixel and intersect with the DEM. If the
ray does not hit terrain (ridge occlusion), the ridge_fallback method reports the point
of closest approach to terrain.
# Frame4 (Oghi, direct intersection)
python localize_fire.py \
--camera-lat 34.534508 --camera-lon 73.003801 \
--tower-height 17.5 \
--calibration calibration.json \
--fire fire_pixels.csv \
--dem dem/oghi_dem.tif \
--frame-headings frame_headings.csv \
--out output/fire_locations.csv
# Frame5 (Danna Top, ridge fallback)
python localize_fire.py \
--camera-lat 34.439466 --camera-lon 73.347998 \
--tower-height 10 \
--calibration calibration_frame5.json \
--fire fire_pixels_frame5.csv \
--dem dem/copernicus_dem.tif \
--frame-headings frame_headings_frame5.csv \
--out output/fire_locations_frame5.csv| Argument | Description |
|---|---|
--dem |
DEM GeoTIFF for ray-terrain intersection |
--ridge-gap-limit |
Maximum ray-terrain gap (m) to trigger ridge fallback (default: 200 m) |
--max-range |
Maximum ray range in metres (default: 40000) |
--frame-headings |
Per-frame headings from Step 3b or 3c. Required for panning cameras |
Output columns: frame, fire_x, fire_y, bearing_deg, elev_deg, range_m, lat, lon, terrain_alt_m, method
The method column is either intersection (ray hit terrain) or ridge_fallback
(ray missed terrain; closest-approach point reported).
| Source of error | Typical impact |
|---|---|
| Skyline calibration heading | ±0.5-2° gives ±90-350 m at 10 km |
| DEM resolution (SRTM 30 m) | ±50-150 m at terrain intersection |
| Fire pixel annotation jitter | ±1-3 px gives ±30-100 m at range |
| Smoke obscuring ridge during calibration | 5-20° heading error |
| Ridge occlusion (smoke only visible) | 500 m to 2 km range overshoot |
Practical expected accuracy:
- Direct terrain intersection: 100-400 m at 2-10 km
- Ridge fallback: 500-1,500 m depending on how far the fire is behind the ridge
- Use a frame with a clean ridge and no smoke near the horizon
- Narrow
--heading-min/maxto ±40° around the known camera direction - Try
--sky-frac 0.25to exclude foreground trees - Try
--hfov-min/maxbounds if zoom level is known
- Most common cause:
--max-shiftis too small for the actual pan. Estimate the expected shift:pan_deg / HFOV * Wand set--max-shiftto at least that value. Default 80 fails for pans larger than ~3°. - Check the
confidencecolumn, below 0.3 is unreliable - For panning cameras with fire visible, use
heading_from_fire.pyinstead
- Fire may be hidden behind a ridge, this is expected for long-range fires
- Switch to Copernicus DEM which better captures sharp ridgeline heights
- The fallback reports the best single-camera estimate; a second camera is needed for accurate range estimation in this scenario
- Only average frames at the same camera pan position. Frames from different pan positions produce a blurred skyline that forces HFOV to be over-estimated and degrades localization on off-center frames.
- Add
--demto the localize command - Check DEM covers both camera location and fire area
- Store daytime
calibration.jsonand reuse it: skip Step 3 at night - Go directly to Step 4, then Step 3c, then Step 5
- Delete the partial
.tiffile fromdem/cache/before re-running - The download script tries a fallback AWS region automatically