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.
- fuelgauge-0.3.0/LICENSE +25 -0
- fuelgauge-0.3.0/PKG-INFO +202 -0
- fuelgauge-0.3.0/README.md +159 -0
- fuelgauge-0.3.0/fuelgauge/__init__.py +17 -0
- fuelgauge-0.3.0/fuelgauge/archive.py +100 -0
- fuelgauge-0.3.0/fuelgauge/camera.py +83 -0
- fuelgauge-0.3.0/fuelgauge/cli.py +151 -0
- fuelgauge-0.3.0/fuelgauge/colour.py +64 -0
- fuelgauge-0.3.0/fuelgauge/evaluate.py +89 -0
- fuelgauge-0.3.0/fuelgauge/measure.py +125 -0
- fuelgauge-0.3.0/fuelgauge/quality.py +44 -0
- fuelgauge-0.3.0/fuelgauge/rois.py +42 -0
- fuelgauge-0.3.0/fuelgauge/segment.py +88 -0
- fuelgauge-0.3.0/fuelgauge/sources/__init__.py +1 -0
- fuelgauge-0.3.0/fuelgauge/sources/hpwren.py +61 -0
- fuelgauge-0.3.0/fuelgauge/sources/lfmc.py +33 -0
- fuelgauge-0.3.0/fuelgauge/sources/modis.py +69 -0
- fuelgauge-0.3.0/fuelgauge/sources/phenocam.py +53 -0
- fuelgauge-0.3.0/fuelgauge/terrain.py +228 -0
- fuelgauge-0.3.0/fuelgauge/track.py +224 -0
- fuelgauge-0.3.0/fuelgauge/viewshed.py +138 -0
- fuelgauge-0.3.0/fuelgauge.egg-info/PKG-INFO +202 -0
- fuelgauge-0.3.0/fuelgauge.egg-info/SOURCES.txt +35 -0
- fuelgauge-0.3.0/fuelgauge.egg-info/dependency_links.txt +1 -0
- fuelgauge-0.3.0/fuelgauge.egg-info/entry_points.txt +2 -0
- fuelgauge-0.3.0/fuelgauge.egg-info/requires.txt +21 -0
- fuelgauge-0.3.0/fuelgauge.egg-info/top_level.txt +1 -0
- fuelgauge-0.3.0/pyproject.toml +54 -0
- fuelgauge-0.3.0/setup.cfg +4 -0
- fuelgauge-0.3.0/tests/test_camera.py +30 -0
- fuelgauge-0.3.0/tests/test_colour.py +51 -0
- fuelgauge-0.3.0/tests/test_coverage.py +65 -0
- fuelgauge-0.3.0/tests/test_evaluate.py +38 -0
- fuelgauge-0.3.0/tests/test_live.py +21 -0
- fuelgauge-0.3.0/tests/test_measure.py +35 -0
- fuelgauge-0.3.0/tests/test_terrain.py +65 -0
- fuelgauge-0.3.0/tests/test_track.py +98 -0
fuelgauge-0.3.0/LICENSE
ADDED
|
@@ -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.
|
fuelgauge-0.3.0/PKG-INFO
ADDED
|
@@ -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> |
|
|
58
|
+
<a href="https://shourya0mehta.github.io/fuel-gauge/docs.html"><b>Docs</b></a> |
|
|
59
|
+
<a href="https://shourya0mehta.github.io/fuel-gauge/research.html"><b>Research</b></a> |
|
|
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> |
|
|
15
|
+
<a href="https://shourya0mehta.github.io/fuel-gauge/docs.html"><b>Docs</b></a> |
|
|
16
|
+
<a href="https://shourya0mehta.github.io/fuel-gauge/research.html"><b>Research</b></a> |
|
|
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))
|