fuelgauge 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. fuelgauge-0.3.0/LICENSE +25 -0
  2. fuelgauge-0.3.0/PKG-INFO +202 -0
  3. fuelgauge-0.3.0/README.md +159 -0
  4. fuelgauge-0.3.0/fuelgauge/__init__.py +17 -0
  5. fuelgauge-0.3.0/fuelgauge/archive.py +100 -0
  6. fuelgauge-0.3.0/fuelgauge/camera.py +83 -0
  7. fuelgauge-0.3.0/fuelgauge/cli.py +151 -0
  8. fuelgauge-0.3.0/fuelgauge/colour.py +64 -0
  9. fuelgauge-0.3.0/fuelgauge/evaluate.py +89 -0
  10. fuelgauge-0.3.0/fuelgauge/measure.py +125 -0
  11. fuelgauge-0.3.0/fuelgauge/quality.py +44 -0
  12. fuelgauge-0.3.0/fuelgauge/rois.py +42 -0
  13. fuelgauge-0.3.0/fuelgauge/segment.py +88 -0
  14. fuelgauge-0.3.0/fuelgauge/sources/__init__.py +1 -0
  15. fuelgauge-0.3.0/fuelgauge/sources/hpwren.py +61 -0
  16. fuelgauge-0.3.0/fuelgauge/sources/lfmc.py +33 -0
  17. fuelgauge-0.3.0/fuelgauge/sources/modis.py +69 -0
  18. fuelgauge-0.3.0/fuelgauge/sources/phenocam.py +53 -0
  19. fuelgauge-0.3.0/fuelgauge/terrain.py +228 -0
  20. fuelgauge-0.3.0/fuelgauge/track.py +224 -0
  21. fuelgauge-0.3.0/fuelgauge/viewshed.py +138 -0
  22. fuelgauge-0.3.0/fuelgauge.egg-info/PKG-INFO +202 -0
  23. fuelgauge-0.3.0/fuelgauge.egg-info/SOURCES.txt +35 -0
  24. fuelgauge-0.3.0/fuelgauge.egg-info/dependency_links.txt +1 -0
  25. fuelgauge-0.3.0/fuelgauge.egg-info/entry_points.txt +2 -0
  26. fuelgauge-0.3.0/fuelgauge.egg-info/requires.txt +21 -0
  27. fuelgauge-0.3.0/fuelgauge.egg-info/top_level.txt +1 -0
  28. fuelgauge-0.3.0/pyproject.toml +54 -0
  29. fuelgauge-0.3.0/setup.cfg +4 -0
  30. fuelgauge-0.3.0/tests/test_camera.py +30 -0
  31. fuelgauge-0.3.0/tests/test_colour.py +51 -0
  32. fuelgauge-0.3.0/tests/test_coverage.py +65 -0
  33. fuelgauge-0.3.0/tests/test_evaluate.py +38 -0
  34. fuelgauge-0.3.0/tests/test_live.py +21 -0
  35. fuelgauge-0.3.0/tests/test_measure.py +35 -0
  36. fuelgauge-0.3.0/tests/test_terrain.py +65 -0
  37. fuelgauge-0.3.0/tests/test_track.py +98 -0
@@ -0,0 +1,25 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shourya Mehta
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ Camera images, field samples and satellite data used by this project belong to
24
+ their providers (PhenoCam Network, HPWREN, Globe-LFMC contributors, NASA, ESA,
25
+ Copernicus) and are covered by their own terms; see README.md.
@@ -0,0 +1,202 @@
1
+ Metadata-Version: 2.4
2
+ Name: fuelgauge
3
+ Version: 0.3.0
4
+ Summary: Turn any fixed camera into a calibrated sensor: pose from the skyline, decades of footage on one view, every pixel mapped to the ground.
5
+ Author: Shourya Mehta
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://shourya0mehta.github.io/fuel-gauge/
8
+ Project-URL: Documentation, https://shourya0mehta.github.io/fuel-gauge/docs.html
9
+ Project-URL: Source, https://github.com/shourya0mehta/fuel-gauge
10
+ Project-URL: Research, https://shourya0mehta.github.io/fuel-gauge/research.html
11
+ Keywords: computer-vision,camera-calibration,image-registration,wildfire,remote-sensing,viewshed,phenocam,geospatial
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Scientific/Engineering :: Image Recognition
20
+ Classifier: Topic :: Scientific/Engineering :: GIS
21
+ Classifier: Topic :: Scientific/Engineering :: Atmospheric Science
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: numpy>=1.24
26
+ Requires-Dist: scipy>=1.10
27
+ Requires-Dist: pandas>=2.0
28
+ Requires-Dist: opencv-python-headless>=4.8
29
+ Requires-Dist: pillow>=10
30
+ Provides-Extra: geo
31
+ Requires-Dist: rasterio>=1.3; extra == "geo"
32
+ Requires-Dist: pystac-client>=0.7; extra == "geo"
33
+ Requires-Dist: planetary-computer>=1.0; extra == "geo"
34
+ Provides-Extra: seg
35
+ Requires-Dist: onnxruntime>=1.16; extra == "seg"
36
+ Provides-Extra: eval
37
+ Requires-Dist: scikit-learn>=1.3; extra == "eval"
38
+ Requires-Dist: pyarrow>=14; extra == "eval"
39
+ Requires-Dist: openpyxl>=3.1; extra == "eval"
40
+ Provides-Extra: dev
41
+ Requires-Dist: pytest>=7; extra == "dev"
42
+ Dynamic: license-file
43
+
44
+ <p align="center">
45
+ <a href="https://shourya0mehta.github.io/fuel-gauge/"><img src="https://raw.githubusercontent.com/shourya0mehta/fuel-gauge/main/docs/assets/readme/banner.png" alt="fuelgauge: turn any fixed camera into a calibrated sensor" width="100%"></a>
46
+ </p>
47
+
48
+ <p align="center">
49
+ <a href="https://github.com/shourya0mehta/fuel-gauge/actions/workflows/tests.yml"><img src="https://github.com/shourya0mehta/fuel-gauge/actions/workflows/tests.yml/badge.svg" alt="Tests"></a>
50
+ <a href="https://github.com/shourya0mehta/fuel-gauge/actions/workflows/site.yml"><img src="https://img.shields.io/badge/live_network-updated_daily-FF5B1F" alt="Live network updated daily"></a>
51
+ <img src="https://img.shields.io/badge/python-3.10%2B-0E0E0E" alt="Python 3.10+">
52
+ <img src="https://img.shields.io/badge/runs_on-CPU-0E0E0E" alt="Runs on CPU">
53
+ <a href="https://github.com/shourya0mehta/fuel-gauge/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-0E0E0E" alt="MIT licence"></a>
54
+ </p>
55
+
56
+ <p align="center">
57
+ <a href="https://shourya0mehta.github.io/fuel-gauge/"><b>Website</b></a> &nbsp;|&nbsp;
58
+ <a href="https://shourya0mehta.github.io/fuel-gauge/docs.html"><b>Docs</b></a> &nbsp;|&nbsp;
59
+ <a href="https://shourya0mehta.github.io/fuel-gauge/research.html"><b>Research</b></a> &nbsp;|&nbsp;
60
+ <a href="https://shourya0mehta.github.io/fuel-gauge/#live"><b>Live network</b></a>
61
+ </p>
62
+
63
+ **Fuel Gauge is open-source computer vision that tells a camera exactly where it is looking.** It solves a camera's pose from the skyline alone, holds decades of footage on one fixed view, maps every pixel to a spot on the ground, and measures how the land changes. No survey, no ground control points, no hand-drawn masks, and it runs on a laptop.
64
+
65
+ Thousands of cameras already watch wildland for fire. California alone runs 1,309 of them, each posting a frame a minute. To the software behind them a pixel is only a colour, with no distance and no place on a map. Fuel Gauge turns that colour into a measurement.
66
+
67
+ <p align="center"><img src="https://raw.githubusercontent.com/shourya0mehta/fuel-gauge/main/docs/assets/readme/console.gif" alt="A live HPWREN fire camera calibrated by Fuel Gauge: the skyline found in the photo locks onto the skyline the terrain predicts, then every point on the hillside reads out its distance, bearing, land cover and coordinates" width="90%"></p>
68
+ <p align="center"><sub>A live HPWREN fire camera after calibration. Orange: the skyline Fuel Gauge found in the photo. Dashed: the skyline a 30 m terrain model predicts from the solved pose, 0.04° apart.</sub></p>
69
+
70
+ ## Highlights
71
+
72
+ <table>
73
+ <tr>
74
+ <td align="center" width="33%"><h3>0.04°</h3><sub>median skyline error after solving a fire camera's pose from one photo</sub></td>
75
+ <td align="center" width="33%"><h3>29,047</h3><sub>photos from 21 cameras and 20 years locked onto fixed views</sub></td>
76
+ <td align="center" width="33%"><h3>1,309</h3><sub>California fire cameras audited for line of sight in one run</sub></td>
77
+ </tr>
78
+ <tr>
79
+ <td align="center" width="33%"><h3>1.4M acres</h3><sub>burned since 2020 by fires that started where no camera could see the smoke</sub></td>
80
+ <td align="center" width="33%"><h3>88%</h3><sub>of all burn area out of every camera's view that ten new cameras from the siting search would have seen</sub></td>
81
+ <td align="center" width="33%"><h3>8 cameras</h3><sub>calibrated, live, and measured every morning by GitHub Actions</sub></td>
82
+ </tr>
83
+ </table>
84
+
85
+ ## Quickstart
86
+
87
+ ```bash
88
+ pip install "fuelgauge[geo,seg]"
89
+
90
+ # years of photos from one camera -> aligned views -> daily vegetation record
91
+ fuelgauge run photos/ out/ridge
92
+
93
+ # one frame + a location -> camera pose + pixel-to-ground lookup
94
+ fuelgauge calibrate ridge frame.jpg --lat 33.4008 --lon -117.1905 --elev 483 --yaw 90
95
+
96
+ # a list of camera sites -> what they can see, and where to add the next ones
97
+ fuelgauge viewshed cameras.csv coverage.tif
98
+ fuelgauge site cameras.csv --k 10 > new_sites.csv
99
+ ```
100
+
101
+ Photo times are read from file names (ISO dates, PhenoCam and HPWREN naming) or EXIF, so most archives work as they are. Every step is also a Python function:
102
+
103
+ ```python
104
+ from fuelgauge.archive import process
105
+ from fuelgauge.measure import measure
106
+ from fuelgauge import terrain, viewshed
107
+
108
+ blocks = process(paths, times, "out/ridge") # align
109
+ daily, regions, info = measure(blocks, "out/ridge") # measure
110
+ dem = terrain.load_planetary("cop-dem-glo-90", "data", 33.4, -117.2, 40000)
111
+ picks = viewshed.site(dem, existing=[(33.40, -117.19)], k=5) # site new cameras
112
+ ```
113
+
114
+ Out come a daily GRVI/GCC record per view (`_daily.csv`), an image of the regions it measured (`_regions.png`), per-photo QA (`_qa.json`), a pixel-to-ground lookup (`_lookup.npz`) and a coverage GeoTIFF any GIS opens. Full guide and API reference: **[docs](https://shourya0mehta.github.io/fuel-gauge/docs.html)**.
115
+
116
+ ## How it works
117
+
118
+ Five steps from raw footage to a calibrated sensor.
119
+
120
+ | Step | What it solves | Under the hood |
121
+ | --- | --- | --- |
122
+ | **1. Align** <br><sub>`track`, `archive`</sub> | Cameras drift, get bumped, re-aimed and moved over the years | Frame screening (brightness, Laplacian sharpness, contrast, clipping); SIFT on CLAHE-equalised greyscale with Lowe's ratio test; 4-DOF similarity by RANSAC; keyframe tracking with relocalisation; loop closure through a maximum-inlier spanning tree; relocated cameras kept as second views; bump and re-aim detection |
123
+ | **2. Calibrate** <br><sub>`terrain`, `camera`</sub> | Public cameras come with a location and nothing else | SegFormer-B2 sky segmentation (ONNX Runtime, CPU) to an edge-snapped skyline; terrain panorama ray-marched from Copernicus 30 m elevation with earth curvature and refraction; equidistant fisheye with one radial term; heading, tilt and roll by bounded Powell search on a trimmed loss; one lens fitted jointly across a network |
124
+ | **3. Map** <br><sub>`terrain.backproject`</sub> | A measurement needs a place on the ground | Every pixel block's ray traced to its first terrain hit: distance, latitude, longitude, slope, aspect and ESA WorldCover land cover |
125
+ | **4. Measure** <br><sub>`measure`, `quality`, `rois`, `colour`</sub> | Raw colour is mostly haze, weather and sensor drift | Haze scoring by Sobel edge correlation against a trailing norm; automatic regions from segmentation plus seasonal amplitude; drift-tracking von Kries white balance; GRVI, GCC and camera NDVI from near-infrared twins; causal smoothing that is safe to nowcast with |
126
+ | **5. Scale** <br><sub>`viewshed`</sub> | Which ground a whole network can see, and where one more camera helps most | Radial viewsheds with curvature and refraction; smoke-column line of sight; greedy maximum-coverage siting over hilltop candidates |
127
+ | **Evaluate** <br><sub>`evaluate`</sub> | Whether a camera signal knows anything the calendar does not | Leave-one-year-out ridge regression on a per-site, per-species seasonal baseline; held-out predictors clipped to the training range; RMSE, anomaly correlation and years beating season |
128
+
129
+ 27 tests run in CI on Python 3.10 and 3.12, covering synthetic drift, re-aims, relocations, terrain, viewsheds, siting, colour drift and leakage in the evaluation.
130
+
131
+ ## What we found with it
132
+
133
+ Every result below was produced end to end by the pipeline on public data, and each has an interactive page on the **[research site](https://shourya0mehta.github.io/fuel-gauge/research.html)**.
134
+
135
+ **A line-of-sight audit of California's fire cameras.** Fuel Gauge traced terrain line of sight from all 1,309 ALERTCalifornia cameras across the state and checked it against every wildfire from 2020 to 2025.
136
+
137
+ - **46%** of California's wildland is in view of at least one camera, and **20%** of two, the overlap needed to triangulate smoke.
138
+ - **21%** of the 277 fires over 1,000 acres started where no camera could see even a 300 m smoke column. Those fires, including the SCU Lightning Complex and the Claremont Fire, burned **1.4 million acres**.
139
+ - A siting search over 11,847 hilltops finds ten new sites that add **9,332 km²** of watched wildland. Looking back, a different ten would have seen **1.31 million of the 1.48 million acres** that burned out of view.
140
+ - The biggest blind spots: the Klamath Mountains, Yosemite's high country, the Modoc Plateau and the Diablo Range.
141
+
142
+ <p align="center"><img src="https://raw.githubusercontent.com/shourya0mehta/fuel-gauge/main/docs/assets/coverage/ca_light.png" alt="California shaded by how many fire cameras can see each patch of wildland, with large fires since 2020 marked" width="60%"></p>
143
+
144
+ **Twenty years of footage on one view.** 29,047 daily photos from 21 PhenoCam cameras in 6 states were locked onto fixed views, including six cameras that were moved to a new mast mid-record. Automatic regions beat the network's own hand-drawn masks at 12 of 20 cameras, and drift-tracking white balance lifts the year-to-year signal by 68%.
145
+
146
+ <p align="center"><img src="https://raw.githubusercontent.com/shourya0mehta/fuel-gauge/main/docs/assets/readme/hillside.gif" alt="Fifteen years of one hillside in the San Bernardino National Forest, every April and September, aligned onto one view" width="70%"></p>
147
+
148
+ **A benchmark for camera fuel moisture.** The cameras were scored against 3,832 field samples of live fuel moisture, one held-out year at a time. Satellite shortwave infrared leads (anomaly correlation 0.31, ahead of season at 15 of 21 cameras), camera colour matches the seasonal baseline, and near-infrared camera NDVI adds little. That gives the field a clear target, and the harness ships with the package so the next signal can be scored the same way.
149
+
150
+ **A live, calibrated fire-camera network.** Eight HPWREN cameras in Southern California, calibrated from their skylines with one shared lens (median error 0.04° to 0.17°). Every morning a GitHub Action pulls the latest frames, measures every ground block and publishes which slopes are drying fastest.
151
+
152
+ ## Scope and what's next
153
+
154
+ - **The coverage audit shows the network at full reach.** Each site is treated as panning a full circle out to 30 km, so the gaps it finds are the floor: haze, night and where a camera happens to point only widen them. Next, per-camera aim from the live calibration.
155
+ - **The fuel moisture benchmark is scored the hard way.** Field plots sit up to 25 km from each camera, often on another slope, and are sampled every two to four weeks. Any signal that holds up here is robust.
156
+ - **Colour cameras see greenness; leaf water shows in shortwave infrared.** That is why satellite infrared leads, and why pairing camera timing with satellite infrared is the next step. The harness already scores combinations.
157
+ - **The live network reports relative drying today** and is ready to score fuel moisture as soon as field plots sit close enough to its cameras.
158
+
159
+ ## Repository
160
+
161
+ | Path | What it is |
162
+ | --- | --- |
163
+ | [`fuelgauge/`](https://github.com/shourya0mehta/fuel-gauge/tree/main/fuelgauge) | The package and the `fuelgauge` command |
164
+ | [`docs/`](https://github.com/shourya0mehta/fuel-gauge/tree/main/docs) | The website: product page, docs (generated by `scripts/build_docs.py`), research pages and live data |
165
+ | [`scripts/`](https://github.com/shourya0mehta/fuel-gauge/tree/main/scripts) | The studies end to end: download, align, score, map, siting and site data |
166
+ | [`live/`](https://github.com/shourya0mehta/fuel-gauge/tree/main/live) | The daily updater for the calibrated HPWREN cameras, run by GitHub Actions |
167
+ | [`study/`](https://github.com/shourya0mehta/fuel-gauge/tree/main/study) | Camera list, per-camera results and coverage statistics |
168
+ | [`tests/`](https://github.com/shourya0mehta/fuel-gauge/tree/main/tests) | Synthetic cameras and terrain |
169
+
170
+ <details>
171
+ <summary><b>Reproduce the studies</b></summary>
172
+
173
+ ```bash
174
+ pip install -e ".[geo,seg,eval,dev]"
175
+ export DATA=data
176
+ python scripts/fetch_lfmc.py # Globe-LFMC 2.0 field samples
177
+ python scripts/fetch_phenocam.py # midday photos for the cameras in study/cameras.json
178
+ python scripts/fetch_modis.py # MODIS MCD43A4 at each camera view and sampling site
179
+ python scripts/register_phenocam.py <camera> # once per camera (STRIDE=2 keeps every other day)
180
+ python scripts/fetch_nir.py <cam,cam,...> # near-infrared twins + exposures for IR-capable cameras
181
+ python scripts/nir.py <cam> ... # camera NDVI on the registered regions
182
+ python scripts/study.py && python scripts/timing.py && python scripts/summary.py
183
+ python scripts/fetch_fires.py # NIFC WFIGS California wildfire ignitions
184
+ python scripts/coverage.py && python scripts/siting.py && python scripts/coverage_report.py # viewsheds, siting, stats, maps
185
+ python scripts/build_site_data.py # docs/data/ for the site
186
+ ```
187
+
188
+ The full run downloads about 60,000 photos and takes a few hours on two CPU cores.
189
+ </details>
190
+
191
+ ## Data and credits
192
+
193
+ - **PhenoCam Network** (phenocam.nau.edu). Richardson et al. (2018), *Tracking vegetation phenology across diverse North American biomes using PhenoCam imagery*, Scientific Data 5:180028. Per-camera acknowledgements are in [study/ACKNOWLEDGEMENTS.md](https://github.com/shourya0mehta/fuel-gauge/blob/main/study/ACKNOWLEDGEMENTS.md).
194
+ - **HPWREN** camera network, University of California San Diego (hpwren.ucsd.edu). Images courtesy of HPWREN.
195
+ - **ALERTCalifornia** camera network (UC San Diego, with CAL FIRE): camera positions from the public camera map.
196
+ - **Globe-LFMC 2.0**. Yebra et al. (2024), Scientific Data 11:332, compiled in part from the US National Fuel Moisture Database.
197
+ - **MODIS MCD43A4 v6.1** (NASA LP DAAC), **Copernicus DEM GLO-30** (ESA) and **ESA WorldCover 2021**, through Microsoft Planetary Computer.
198
+ - **SegFormer-B2** fine-tuned on ADE20K (NVIDIA), ONNX export by Xenova on Hugging Face.
199
+ - **NIFC WFIGS** wildland fire incident locations (National Interagency Fire Center).
200
+ - Camera NDVI method: Petach et al. (2014), *Monitoring vegetation phenology using an infrared-enabled security camera*, Agricultural and Forest Meteorology 195-196:143-151.
201
+
202
+ Built by Shourya Mehta. To cite, see [CITATION.cff](https://github.com/shourya0mehta/fuel-gauge/blob/main/CITATION.cff).
@@ -0,0 +1,159 @@
1
+ <p align="center">
2
+ <a href="https://shourya0mehta.github.io/fuel-gauge/"><img src="https://raw.githubusercontent.com/shourya0mehta/fuel-gauge/main/docs/assets/readme/banner.png" alt="fuelgauge: turn any fixed camera into a calibrated sensor" width="100%"></a>
3
+ </p>
4
+
5
+ <p align="center">
6
+ <a href="https://github.com/shourya0mehta/fuel-gauge/actions/workflows/tests.yml"><img src="https://github.com/shourya0mehta/fuel-gauge/actions/workflows/tests.yml/badge.svg" alt="Tests"></a>
7
+ <a href="https://github.com/shourya0mehta/fuel-gauge/actions/workflows/site.yml"><img src="https://img.shields.io/badge/live_network-updated_daily-FF5B1F" alt="Live network updated daily"></a>
8
+ <img src="https://img.shields.io/badge/python-3.10%2B-0E0E0E" alt="Python 3.10+">
9
+ <img src="https://img.shields.io/badge/runs_on-CPU-0E0E0E" alt="Runs on CPU">
10
+ <a href="https://github.com/shourya0mehta/fuel-gauge/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-0E0E0E" alt="MIT licence"></a>
11
+ </p>
12
+
13
+ <p align="center">
14
+ <a href="https://shourya0mehta.github.io/fuel-gauge/"><b>Website</b></a> &nbsp;|&nbsp;
15
+ <a href="https://shourya0mehta.github.io/fuel-gauge/docs.html"><b>Docs</b></a> &nbsp;|&nbsp;
16
+ <a href="https://shourya0mehta.github.io/fuel-gauge/research.html"><b>Research</b></a> &nbsp;|&nbsp;
17
+ <a href="https://shourya0mehta.github.io/fuel-gauge/#live"><b>Live network</b></a>
18
+ </p>
19
+
20
+ **Fuel Gauge is open-source computer vision that tells a camera exactly where it is looking.** It solves a camera's pose from the skyline alone, holds decades of footage on one fixed view, maps every pixel to a spot on the ground, and measures how the land changes. No survey, no ground control points, no hand-drawn masks, and it runs on a laptop.
21
+
22
+ Thousands of cameras already watch wildland for fire. California alone runs 1,309 of them, each posting a frame a minute. To the software behind them a pixel is only a colour, with no distance and no place on a map. Fuel Gauge turns that colour into a measurement.
23
+
24
+ <p align="center"><img src="https://raw.githubusercontent.com/shourya0mehta/fuel-gauge/main/docs/assets/readme/console.gif" alt="A live HPWREN fire camera calibrated by Fuel Gauge: the skyline found in the photo locks onto the skyline the terrain predicts, then every point on the hillside reads out its distance, bearing, land cover and coordinates" width="90%"></p>
25
+ <p align="center"><sub>A live HPWREN fire camera after calibration. Orange: the skyline Fuel Gauge found in the photo. Dashed: the skyline a 30 m terrain model predicts from the solved pose, 0.04° apart.</sub></p>
26
+
27
+ ## Highlights
28
+
29
+ <table>
30
+ <tr>
31
+ <td align="center" width="33%"><h3>0.04°</h3><sub>median skyline error after solving a fire camera's pose from one photo</sub></td>
32
+ <td align="center" width="33%"><h3>29,047</h3><sub>photos from 21 cameras and 20 years locked onto fixed views</sub></td>
33
+ <td align="center" width="33%"><h3>1,309</h3><sub>California fire cameras audited for line of sight in one run</sub></td>
34
+ </tr>
35
+ <tr>
36
+ <td align="center" width="33%"><h3>1.4M acres</h3><sub>burned since 2020 by fires that started where no camera could see the smoke</sub></td>
37
+ <td align="center" width="33%"><h3>88%</h3><sub>of all burn area out of every camera's view that ten new cameras from the siting search would have seen</sub></td>
38
+ <td align="center" width="33%"><h3>8 cameras</h3><sub>calibrated, live, and measured every morning by GitHub Actions</sub></td>
39
+ </tr>
40
+ </table>
41
+
42
+ ## Quickstart
43
+
44
+ ```bash
45
+ pip install "fuelgauge[geo,seg]"
46
+
47
+ # years of photos from one camera -> aligned views -> daily vegetation record
48
+ fuelgauge run photos/ out/ridge
49
+
50
+ # one frame + a location -> camera pose + pixel-to-ground lookup
51
+ fuelgauge calibrate ridge frame.jpg --lat 33.4008 --lon -117.1905 --elev 483 --yaw 90
52
+
53
+ # a list of camera sites -> what they can see, and where to add the next ones
54
+ fuelgauge viewshed cameras.csv coverage.tif
55
+ fuelgauge site cameras.csv --k 10 > new_sites.csv
56
+ ```
57
+
58
+ Photo times are read from file names (ISO dates, PhenoCam and HPWREN naming) or EXIF, so most archives work as they are. Every step is also a Python function:
59
+
60
+ ```python
61
+ from fuelgauge.archive import process
62
+ from fuelgauge.measure import measure
63
+ from fuelgauge import terrain, viewshed
64
+
65
+ blocks = process(paths, times, "out/ridge") # align
66
+ daily, regions, info = measure(blocks, "out/ridge") # measure
67
+ dem = terrain.load_planetary("cop-dem-glo-90", "data", 33.4, -117.2, 40000)
68
+ picks = viewshed.site(dem, existing=[(33.40, -117.19)], k=5) # site new cameras
69
+ ```
70
+
71
+ Out come a daily GRVI/GCC record per view (`_daily.csv`), an image of the regions it measured (`_regions.png`), per-photo QA (`_qa.json`), a pixel-to-ground lookup (`_lookup.npz`) and a coverage GeoTIFF any GIS opens. Full guide and API reference: **[docs](https://shourya0mehta.github.io/fuel-gauge/docs.html)**.
72
+
73
+ ## How it works
74
+
75
+ Five steps from raw footage to a calibrated sensor.
76
+
77
+ | Step | What it solves | Under the hood |
78
+ | --- | --- | --- |
79
+ | **1. Align** <br><sub>`track`, `archive`</sub> | Cameras drift, get bumped, re-aimed and moved over the years | Frame screening (brightness, Laplacian sharpness, contrast, clipping); SIFT on CLAHE-equalised greyscale with Lowe's ratio test; 4-DOF similarity by RANSAC; keyframe tracking with relocalisation; loop closure through a maximum-inlier spanning tree; relocated cameras kept as second views; bump and re-aim detection |
80
+ | **2. Calibrate** <br><sub>`terrain`, `camera`</sub> | Public cameras come with a location and nothing else | SegFormer-B2 sky segmentation (ONNX Runtime, CPU) to an edge-snapped skyline; terrain panorama ray-marched from Copernicus 30 m elevation with earth curvature and refraction; equidistant fisheye with one radial term; heading, tilt and roll by bounded Powell search on a trimmed loss; one lens fitted jointly across a network |
81
+ | **3. Map** <br><sub>`terrain.backproject`</sub> | A measurement needs a place on the ground | Every pixel block's ray traced to its first terrain hit: distance, latitude, longitude, slope, aspect and ESA WorldCover land cover |
82
+ | **4. Measure** <br><sub>`measure`, `quality`, `rois`, `colour`</sub> | Raw colour is mostly haze, weather and sensor drift | Haze scoring by Sobel edge correlation against a trailing norm; automatic regions from segmentation plus seasonal amplitude; drift-tracking von Kries white balance; GRVI, GCC and camera NDVI from near-infrared twins; causal smoothing that is safe to nowcast with |
83
+ | **5. Scale** <br><sub>`viewshed`</sub> | Which ground a whole network can see, and where one more camera helps most | Radial viewsheds with curvature and refraction; smoke-column line of sight; greedy maximum-coverage siting over hilltop candidates |
84
+ | **Evaluate** <br><sub>`evaluate`</sub> | Whether a camera signal knows anything the calendar does not | Leave-one-year-out ridge regression on a per-site, per-species seasonal baseline; held-out predictors clipped to the training range; RMSE, anomaly correlation and years beating season |
85
+
86
+ 27 tests run in CI on Python 3.10 and 3.12, covering synthetic drift, re-aims, relocations, terrain, viewsheds, siting, colour drift and leakage in the evaluation.
87
+
88
+ ## What we found with it
89
+
90
+ Every result below was produced end to end by the pipeline on public data, and each has an interactive page on the **[research site](https://shourya0mehta.github.io/fuel-gauge/research.html)**.
91
+
92
+ **A line-of-sight audit of California's fire cameras.** Fuel Gauge traced terrain line of sight from all 1,309 ALERTCalifornia cameras across the state and checked it against every wildfire from 2020 to 2025.
93
+
94
+ - **46%** of California's wildland is in view of at least one camera, and **20%** of two, the overlap needed to triangulate smoke.
95
+ - **21%** of the 277 fires over 1,000 acres started where no camera could see even a 300 m smoke column. Those fires, including the SCU Lightning Complex and the Claremont Fire, burned **1.4 million acres**.
96
+ - A siting search over 11,847 hilltops finds ten new sites that add **9,332 km²** of watched wildland. Looking back, a different ten would have seen **1.31 million of the 1.48 million acres** that burned out of view.
97
+ - The biggest blind spots: the Klamath Mountains, Yosemite's high country, the Modoc Plateau and the Diablo Range.
98
+
99
+ <p align="center"><img src="https://raw.githubusercontent.com/shourya0mehta/fuel-gauge/main/docs/assets/coverage/ca_light.png" alt="California shaded by how many fire cameras can see each patch of wildland, with large fires since 2020 marked" width="60%"></p>
100
+
101
+ **Twenty years of footage on one view.** 29,047 daily photos from 21 PhenoCam cameras in 6 states were locked onto fixed views, including six cameras that were moved to a new mast mid-record. Automatic regions beat the network's own hand-drawn masks at 12 of 20 cameras, and drift-tracking white balance lifts the year-to-year signal by 68%.
102
+
103
+ <p align="center"><img src="https://raw.githubusercontent.com/shourya0mehta/fuel-gauge/main/docs/assets/readme/hillside.gif" alt="Fifteen years of one hillside in the San Bernardino National Forest, every April and September, aligned onto one view" width="70%"></p>
104
+
105
+ **A benchmark for camera fuel moisture.** The cameras were scored against 3,832 field samples of live fuel moisture, one held-out year at a time. Satellite shortwave infrared leads (anomaly correlation 0.31, ahead of season at 15 of 21 cameras), camera colour matches the seasonal baseline, and near-infrared camera NDVI adds little. That gives the field a clear target, and the harness ships with the package so the next signal can be scored the same way.
106
+
107
+ **A live, calibrated fire-camera network.** Eight HPWREN cameras in Southern California, calibrated from their skylines with one shared lens (median error 0.04° to 0.17°). Every morning a GitHub Action pulls the latest frames, measures every ground block and publishes which slopes are drying fastest.
108
+
109
+ ## Scope and what's next
110
+
111
+ - **The coverage audit shows the network at full reach.** Each site is treated as panning a full circle out to 30 km, so the gaps it finds are the floor: haze, night and where a camera happens to point only widen them. Next, per-camera aim from the live calibration.
112
+ - **The fuel moisture benchmark is scored the hard way.** Field plots sit up to 25 km from each camera, often on another slope, and are sampled every two to four weeks. Any signal that holds up here is robust.
113
+ - **Colour cameras see greenness; leaf water shows in shortwave infrared.** That is why satellite infrared leads, and why pairing camera timing with satellite infrared is the next step. The harness already scores combinations.
114
+ - **The live network reports relative drying today** and is ready to score fuel moisture as soon as field plots sit close enough to its cameras.
115
+
116
+ ## Repository
117
+
118
+ | Path | What it is |
119
+ | --- | --- |
120
+ | [`fuelgauge/`](https://github.com/shourya0mehta/fuel-gauge/tree/main/fuelgauge) | The package and the `fuelgauge` command |
121
+ | [`docs/`](https://github.com/shourya0mehta/fuel-gauge/tree/main/docs) | The website: product page, docs (generated by `scripts/build_docs.py`), research pages and live data |
122
+ | [`scripts/`](https://github.com/shourya0mehta/fuel-gauge/tree/main/scripts) | The studies end to end: download, align, score, map, siting and site data |
123
+ | [`live/`](https://github.com/shourya0mehta/fuel-gauge/tree/main/live) | The daily updater for the calibrated HPWREN cameras, run by GitHub Actions |
124
+ | [`study/`](https://github.com/shourya0mehta/fuel-gauge/tree/main/study) | Camera list, per-camera results and coverage statistics |
125
+ | [`tests/`](https://github.com/shourya0mehta/fuel-gauge/tree/main/tests) | Synthetic cameras and terrain |
126
+
127
+ <details>
128
+ <summary><b>Reproduce the studies</b></summary>
129
+
130
+ ```bash
131
+ pip install -e ".[geo,seg,eval,dev]"
132
+ export DATA=data
133
+ python scripts/fetch_lfmc.py # Globe-LFMC 2.0 field samples
134
+ python scripts/fetch_phenocam.py # midday photos for the cameras in study/cameras.json
135
+ python scripts/fetch_modis.py # MODIS MCD43A4 at each camera view and sampling site
136
+ python scripts/register_phenocam.py <camera> # once per camera (STRIDE=2 keeps every other day)
137
+ python scripts/fetch_nir.py <cam,cam,...> # near-infrared twins + exposures for IR-capable cameras
138
+ python scripts/nir.py <cam> ... # camera NDVI on the registered regions
139
+ python scripts/study.py && python scripts/timing.py && python scripts/summary.py
140
+ python scripts/fetch_fires.py # NIFC WFIGS California wildfire ignitions
141
+ python scripts/coverage.py && python scripts/siting.py && python scripts/coverage_report.py # viewsheds, siting, stats, maps
142
+ python scripts/build_site_data.py # docs/data/ for the site
143
+ ```
144
+
145
+ The full run downloads about 60,000 photos and takes a few hours on two CPU cores.
146
+ </details>
147
+
148
+ ## Data and credits
149
+
150
+ - **PhenoCam Network** (phenocam.nau.edu). Richardson et al. (2018), *Tracking vegetation phenology across diverse North American biomes using PhenoCam imagery*, Scientific Data 5:180028. Per-camera acknowledgements are in [study/ACKNOWLEDGEMENTS.md](https://github.com/shourya0mehta/fuel-gauge/blob/main/study/ACKNOWLEDGEMENTS.md).
151
+ - **HPWREN** camera network, University of California San Diego (hpwren.ucsd.edu). Images courtesy of HPWREN.
152
+ - **ALERTCalifornia** camera network (UC San Diego, with CAL FIRE): camera positions from the public camera map.
153
+ - **Globe-LFMC 2.0**. Yebra et al. (2024), Scientific Data 11:332, compiled in part from the US National Fuel Moisture Database.
154
+ - **MODIS MCD43A4 v6.1** (NASA LP DAAC), **Copernicus DEM GLO-30** (ESA) and **ESA WorldCover 2021**, through Microsoft Planetary Computer.
155
+ - **SegFormer-B2** fine-tuned on ADE20K (NVIDIA), ONNX export by Xenova on Hugging Face.
156
+ - **NIFC WFIGS** wildland fire incident locations (National Interagency Fire Center).
157
+ - Camera NDVI method: Petach et al. (2014), *Monitoring vegetation phenology using an infrared-enabled security camera*, Agricultural and Forest Meteorology 195-196:143-151.
158
+
159
+ Built by Shourya Mehta. To cite, see [CITATION.cff](https://github.com/shourya0mehta/fuel-gauge/blob/main/CITATION.cff).
@@ -0,0 +1,17 @@
1
+ """fuelgauge: turn fixed outdoor cameras into georeferenced vegetation sensors.
2
+
3
+ Modules
4
+ camera fisheye camera model (pixel <-> azimuth/elevation)
5
+ terrain DEM panoramas, skyline pose fitting, shared-lens network calibration, pixel -> ground
6
+ track multi-year registration: keyframe tracking, segment linking (loop closure)
7
+ quality frame screening and haze scoring
8
+ colour block colours, GCC / GRVI, white balance, causal smoothing
9
+ rois automatic measurement regions (no hand-drawn masks)
10
+ segment SegFormer-B2 (ADE20K) sky / ground segmentation via ONNX Runtime
11
+ archive end-to-end registration of a camera archive (photos -> registered block colours)
12
+ measure registered blocks -> daily vegetation series: haze, automatic regions, white balance, smoothing
13
+ evaluate leave-one-year-out comparison against field fuel moisture
14
+ viewshed terrain line of sight, smoke-column visibility and greedy camera siting
15
+ sources PhenoCam, HPWREN, Globe-LFMC, MODIS
16
+ """
17
+ __version__ = "0.3.0"
@@ -0,0 +1,100 @@
1
+ """Process a camera archive end to end: screen -> track -> link -> warp into each view -> block colours.
2
+
3
+ A camera that was moved to a new spot gets a second view (its own anchor frame) instead of losing those years.
4
+ Featureless scenes (open-grass close-ups) fragment into short stretches that cannot be joined; when fewer than
5
+ half of the usable frames land in a view, every usable frame is measured in place, the way fixed PhenoCam
6
+ regions are.
7
+
8
+ from fuelgauge.archive import process
9
+ out = process(sorted(glob('frames/*.jpg')), times, 'out/mycam')
10
+
11
+ Writes <out>_blocks.npz (dates, per-block mean RGB, validity, haze score, segment) and <out>_qa.json.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+
18
+ import numpy as np
19
+
20
+ from . import quality, track as T
21
+ from .colour import block_means
22
+
23
+ W, H = 768, 576
24
+ BLOCK = 24
25
+
26
+
27
+ def _load(path, size=(W, H)):
28
+ import cv2
29
+ im = cv2.imread(path)
30
+ return None if im is None else cv2.resize(im, size, interpolation=cv2.INTER_AREA)
31
+
32
+
33
+ def process(paths, times, out_prefix, log=print):
34
+ import cv2
35
+ matcher = T.Matcher()
36
+ sx = W / matcher.size[0]
37
+ recs = []
38
+
39
+ def frames():
40
+ for p, t in zip(paths, times):
41
+ img = _load(p)
42
+ if img is None:
43
+ recs.append(dict(file=os.path.basename(p), time=str(t), ok=False)); yield None, 0; continue
44
+ q = quality.screen(img)
45
+ recs.append(dict(file=os.path.basename(p), time=str(t), **q))
46
+ yield (img if q['ok'] else None), q['clarity']
47
+
48
+ res = T.track(frames(), matcher, progress=lambda k, s: log(f'track {k}/{len(paths)} segments {s}'))
49
+ import pandas as pd
50
+ n_ok = sum(1 for r in recs if r.get('ok'))
51
+ tt = pd.to_datetime(pd.Series(list(times)))
52
+ span_all = (tt.max() - tt.min()).days
53
+ # thresholds scale down for short archives
54
+ views = T.group_views(res, times, matcher, min_size=max(3, min(20, n_ok // 20)), min_frames=max(5, min(60, n_ok // 10)),
55
+ min_span_days=min(300, span_all // 2))
56
+ n_view = sum(v['frames'] for v in views)
57
+ mode = 'registered'
58
+ if n_view < 0.5 * n_ok:
59
+ mode = 'in_place'
60
+ ok_idx = [k for k, r in enumerate(recs) if r.get('ok')]
61
+ anchor = max(ok_idx, key=lambda k: recs[k]['clarity'])
62
+ per_frame = [(0, T.I2) if k in set(ok_idx) else (None, None) for k in range(len(paths))]
63
+ anchors = [anchor]
64
+ log(f'only {n_view} of {n_ok} usable frames in a joined view: measuring in place')
65
+ else:
66
+ per_frame = []
67
+ for s_k, T_k in zip(res.seg, res.T):
68
+ v = next((i for i, vw in enumerate(views) if s_k in vw['to_view']), None)
69
+ per_frame.append((v, T.compose(T_k, views[v]['to_view'][s_k])) if (v is not None and T_k is not None) else (None, None))
70
+ anchors = [res.segments[v['anchor']].ref for v in views]
71
+ log(f'segments {len(res.segments)}, views {[sorted(v["to_view"]) for v in views]}')
72
+ view_imgs = [_load(paths[a]) for a in anchors]
73
+ view_edges = [quality.edge_map(v) for v in view_imgs]
74
+ dates, rgb, valid, haze, seg, view = [], [], [], [], [], []
75
+ for k, (p, (v, Tk)) in enumerate(zip(paths, per_frame)):
76
+ recs[k]['reg'] = Tk is not None
77
+ if Tk is None:
78
+ continue
79
+ Mf = Tk.copy(); Mf[:, 2] *= sx
80
+ img = _load(p)
81
+ warped = cv2.warpAffine(img.astype(np.float32), Mf, (W, H), flags=cv2.INTER_LINEAR, borderValue=(-1, -1, -1))
82
+ cover = warped[..., 0] >= 0
83
+ corr = quality.edge_agreement(np.clip(warped, 0, 255).astype(np.uint8), view_edges[v], cover)
84
+ mean, ok = block_means(np.where(cover[..., None], warped, 0)[..., ::-1], cover, BLOCK)
85
+ recs[k].update(edge_corr=corr, M=Mf.tolist(), tx=float(Mf[0, 2]), ty=float(Mf[1, 2]),
86
+ scale=float(np.hypot(Mf[0, 0], Mf[1, 0])), seg=int(res.seg[k]), view=int(v))
87
+ dates.append(str(times[k])); rgb.append(mean.astype(np.float32)); valid.append(ok); haze.append(corr)
88
+ seg.append(int(res.seg[k])); view.append(int(v))
89
+ master, master_idx = view_imgs[0], anchors[0]
90
+ np.savez_compressed(f'{out_prefix}_blocks.npz', dates=np.array(dates), rgb=np.array(rgb), valid=np.array(valid),
91
+ edge_corr=np.array(haze), seg=np.array(seg), view=np.array(view),
92
+ masters=np.array([v[..., ::-1] for v in view_imgs]),
93
+ master=master[..., ::-1], master_file=os.path.basename(paths[master_idx]))
94
+ sizes = np.bincount(res.seg[res.seg >= 0], minlength=len(res.segments)).tolist()
95
+ json.dump(dict(recs=recs, sizes=sizes, mode=mode,
96
+ views=[] if mode == 'in_place' else [dict(anchor=int(v['anchor']), segments=sorted(int(s) for s in v['to_view']),
97
+ links=[(int(a), int(b), int(n)) for a, b, n in v['links']]) for v in views]),
98
+ open(f'{out_prefix}_qa.json', 'w'))
99
+ log(f'{len(paths)} frames, {sum(r.get("ok", False) for r in recs)} pass checks, {len(dates)} registered')
100
+ return f'{out_prefix}_blocks.npz'
@@ -0,0 +1,83 @@
1
+ """Camera model for fixed outdoor cameras: equidistant fisheye with one radial term.
2
+
3
+ A pixel (u, v) maps to a viewing direction (azimuth, elevation) given
4
+ yaw - azimuth of the optical axis, degrees clockwise from north
5
+ pitch - tilt of the optical axis above the horizon, degrees
6
+ roll - rotation about the optical axis, degrees
7
+ hfov - angle spanned by the image half-width times two, in the undistorted model, degrees
8
+ k1 - radial term: off-axis angle = (r / f) * (1 + k1 * (r / f)^2)
9
+
10
+ Wildfire cameras (Mobotix, Axis) are strongly barrel-distorted, so a pinhole model does not fit them.
11
+ With k1 = 0 and a narrow field of view this reduces to a close approximation of a pinhole camera.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, asdict
16
+ import math
17
+ import numpy as np
18
+
19
+
20
+ @dataclass
21
+ class Camera:
22
+ width: int
23
+ height: int
24
+ yaw: float = 0.0
25
+ pitch: float = 0.0
26
+ roll: float = 0.0
27
+ hfov: float = 100.0
28
+ k1: float = 0.0
29
+
30
+ @property
31
+ def focal(self) -> float:
32
+ """Pixels per radian of the undistorted angle."""
33
+ return (self.width / 2) / math.radians(self.hfov / 2)
34
+
35
+ def params(self) -> list[float]:
36
+ return [self.yaw, self.pitch, self.roll, self.hfov, self.k1]
37
+
38
+ def with_params(self, p) -> "Camera":
39
+ return Camera(self.width, self.height, *[float(x) for x in p])
40
+
41
+ def to_dict(self) -> dict:
42
+ return asdict(self)
43
+
44
+ # ------------------------------------------------------------------ pixel -> direction
45
+ def pixel_to_dir(self, u, v):
46
+ """Pixels -> (azimuth deg, elevation deg)."""
47
+ u = np.asarray(u, float); v = np.asarray(v, float)
48
+ dx = u - self.width / 2; dy = -(v - self.height / 2)
49
+ r = np.hypot(dx, dy) / self.focal
50
+ theta = r * (1 + self.k1 * r * r)
51
+ phi = np.arctan2(dy, dx)
52
+ x = np.sin(theta) * np.cos(phi); y = np.sin(theta) * np.sin(phi); z = np.cos(theta)
53
+ cr, sr = math.cos(math.radians(self.roll)), math.sin(math.radians(self.roll))
54
+ xr = cr * x - sr * y; yr = sr * x + cr * y
55
+ cp, sp = math.cos(math.radians(self.pitch)), math.sin(math.radians(self.pitch))
56
+ E = xr; N = z * cp - yr * sp; U = z * sp + yr * cp
57
+ az = (np.degrees(np.arctan2(E, N)) + self.yaw) % 360.0
58
+ el = np.degrees(np.arctan2(U, np.hypot(E, N)))
59
+ return az, el
60
+
61
+ # ------------------------------------------------------------------ direction -> pixel
62
+ def dir_to_pixel(self, az, el):
63
+ """(azimuth deg, elevation deg) -> pixels. Inverse of pixel_to_dir (Newton on the radial term)."""
64
+ az = np.radians(np.asarray(az, float) - self.yaw); el = np.radians(np.asarray(el, float))
65
+ E = np.cos(el) * np.sin(az); N = np.cos(el) * np.cos(az); U = np.sin(el)
66
+ cp, sp = math.cos(math.radians(self.pitch)), math.sin(math.radians(self.pitch))
67
+ z = N * cp + U * sp; yr = -N * sp + U * cp; xr = E
68
+ cr, sr = math.cos(math.radians(self.roll)), math.sin(math.radians(self.roll))
69
+ x = cr * xr + sr * yr; y = -sr * xr + cr * yr
70
+ theta = np.arccos(np.clip(z, -1, 1))
71
+ phi = np.arctan2(y, x)
72
+ r = theta.copy()
73
+ for _ in range(8): # solve r (1 + k1 r^2) = theta
74
+ f = r * (1 + self.k1 * r * r) - theta
75
+ r = r - f / (1 + 3 * self.k1 * r * r)
76
+ rad = r * self.focal
77
+ u = self.width / 2 + rad * np.cos(phi); v = self.height / 2 - rad * np.sin(phi)
78
+ return u, v
79
+
80
+ def effective_hfov(self) -> float:
81
+ """Real angular width across the image after distortion, degrees."""
82
+ r = math.radians(self.hfov / 2)
83
+ return 2 * math.degrees(r * (1 + self.k1 * r * r))