Add FireMapper post-processing toolchain
EXIF/GPS embedding, session-wide thermal stretch, and an OpenStreetMap view of trigger points and image footprints, wrapped in a Tkinter GUI. - embed_metadata.py: write JSON sidecars into image EXIF/XMP, with true camera orientation (aircraft attitude + platform roll + mounting offset) - stretch_thermal.py: rescale 16-bit thermal frames to a session-wide window (8-bit palettes), embedding GPS/orientation EXIF in the output - session_map.py: shared camera geometry (orientation + oblique footprints) - firemapper_gui.py: GUI with Embed / Thermal Stretch / Map tabs Includes CLAUDE.md and an end-user manual (manual.html). Capture data and derived outputs (tens of GB) are git-ignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
9629a07399
|
|
@ -0,0 +1,27 @@
|
|||
# --- Capture data & derived outputs (large — never commit) ---
|
||||
Streifen/
|
||||
session_*/
|
||||
*_exif/
|
||||
*_stretched/
|
||||
thermal_palette_sample.png
|
||||
|
||||
# --- exiftool cache ---
|
||||
tools/
|
||||
|
||||
# --- Python ---
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# --- Raw capture / image data (safety net) ---
|
||||
*.tif
|
||||
*.tiff
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.jsonl
|
||||
|
||||
# --- Temp files & logs ---
|
||||
_gui_*.log
|
||||
_diag*
|
||||
_exiftool_args.txt
|
||||
*.log
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
# FireMapper — Session Post-Processing
|
||||
|
||||
Tools that turn raw FireMapper capture sessions into mapping-ready imagery:
|
||||
embed GPS/orientation metadata into the photos, stretch the radiometric thermal
|
||||
frames for viewing, and plot trigger points + image footprints on a map.
|
||||
|
||||
This repo contains **only the Python tools** — the capture data (session folders,
|
||||
`*_exif/`, `*_stretched/`) is large (tens of GB) and is git-ignored.
|
||||
|
||||
## Hardware / data
|
||||
|
||||
FireMapper (GGS Speyer) is an aerial rig:
|
||||
- **RGB cameras** — Allied Vision Alvium *1800 U-1620c*, 5328×3040, saved as TIFF in
|
||||
`cam25/`, `cam45/` (the number is the **off-nadir mounting angle** in degrees).
|
||||
- **Thermal camera** — FLIR **A65**, 640×512, 16-bit **radiometric** PNG in `thermal/`
|
||||
(values are signal counts, *not* calibrated °C). 25° lens → ~25°×20° FOV.
|
||||
- **INS** — VN-200 (fused attitude + position).
|
||||
|
||||
A **capture session** is a folder (typically `session_YYYYMMDD_HHMMSS/`, but any name
|
||||
works) containing:
|
||||
|
||||
```
|
||||
<session>/
|
||||
cam25/ <ts>_step_NN.tiff + <ts>_step_NN.json (same-basename JSON sidecar each)
|
||||
cam45/ …
|
||||
thermal/ <ts>_step_NN.png + <ts>_step_NN.json
|
||||
steps/ step_NN.json (per-step summaries, NOT per-image — ignored by the tools)
|
||||
manifest.json (camera ids / models / serials)
|
||||
imu_log.jsonl
|
||||
```
|
||||
|
||||
Sessions may be grouped under a **parent folder** (e.g. `Streifen/` = flight strips).
|
||||
Session discovery is content-based (a dir that holds `thermal/`, a `cam*/`, or a
|
||||
`manifest.json`) and looks one level into parent folders — so the tools find sessions
|
||||
whether you run from the session's parent or from a grouping folder.
|
||||
|
||||
## The tools
|
||||
|
||||
| File | What it does | Output |
|
||||
|------|--------------|--------|
|
||||
| `embed_metadata.py` | Copies each image and writes its JSON sidecar into the copy's EXIF/XMP (GPS, true camera orientation, UTC time, lens/exposure, full JSON in `UserComment`). | `<session>_exif/` (mirrors layout; originals untouched) |
|
||||
| `stretch_thermal.py` | Rescales all thermal frames to one session-wide brightness window → 8-bit, with a palette (inferno/ironbow/gray). Embeds the sidecar EXIF into the output too. | `<session>/thermal_stretched/` |
|
||||
| `session_map.py` | Shared geometry: trigger points + oblique footprints; also the camera-orientation math used by `embed_metadata`. Not run directly. | — |
|
||||
| `firemapper_gui.py` | Tkinter GUI wrapping all of the above. Tabs: **1. Embed**, **2. Thermal Stretch**, **3. Map**. | — |
|
||||
|
||||
`embed_metadata.py` and `stretch_thermal.py` are also usable as CLIs (see below).
|
||||
`stretch_thermal` imports `embed_metadata` (to reuse exiftool + tag-building);
|
||||
`embed_metadata` and `stretch_thermal` import `session_map` (geometry/discovery).
|
||||
|
||||
## Running
|
||||
|
||||
GUI (recommended for end users):
|
||||
```
|
||||
python firemapper_gui.py
|
||||
```
|
||||
|
||||
CLI:
|
||||
```
|
||||
python embed_metadata.py [SESSION] [--out DIR] [--gps-source position|gps] [--dry-run]
|
||||
python stretch_thermal.py [SESSION] [--colormap inferno|ironbow|gray]
|
||||
[--lo-pct P --hi-pct P | --absolute | --lo N --hi N]
|
||||
[--out DIR] [--no-embed-exif] [--sample]
|
||||
```
|
||||
With no `SESSION` argument they auto-pick the newest session near the script.
|
||||
|
||||
## Camera geometry (important — keep map and EXIF consistent)
|
||||
|
||||
All orientation/footprint math lives in `session_map.py` and is shared by the map and
|
||||
the EXIF writer so they never diverge. The pointing chain is:
|
||||
|
||||
```
|
||||
aircraft attitude (yaw/pitch/roll, body→NED)
|
||||
@ platform roll about the FORWARD axis by platform_angle_deg (cross-track sweep)
|
||||
@ camera mounting offset
|
||||
```
|
||||
|
||||
Mounting (operator-confirmed):
|
||||
- **Thermal** — optical axis straight **down** (nadir). Mounted **vertical/portrait**:
|
||||
sensor *width* runs **along** the flight track.
|
||||
- **RGB** — optical axis tilted toward the **forward** direction by the `cam<NN>`
|
||||
off-nadir angle (cam25 = 25° from straight-down, cam45 = 45°). Mounted **landscape**:
|
||||
sensor *width* runs **across** the flight track.
|
||||
|
||||
Functions:
|
||||
- `camera_orientation(frame)` → `(heading, pitch, roll)` of the true optical axis for
|
||||
EXIF. Roll uses a *canonical landscape* frame, so it is the camera **bank** (≈0 in
|
||||
level flight), not the 90° sensor portrait/landscape rotation.
|
||||
- `camera_axes_ned(frame, sensor=True)` → the sensor-accurate image axes for footprints.
|
||||
|
||||
`embed_metadata.build_tags()` writes the orientation as `GPSImgDirection` +
|
||||
`XMP-GPano:Pose{Heading,Pitch,Roll}Degrees` (the `XMP-Camera:Pose*` namespace is **not**
|
||||
writable in stock exiftool). It uses this geometry — **not** the raw IMU.
|
||||
|
||||
## Conventions & gotchas
|
||||
|
||||
- **exiftool** is required by the embed step. Resolved from PATH, a local
|
||||
`tools/exiftool.exe`, the OliverBetz user install (`%LOCALAPPDATA%\Programs\ExifTool`),
|
||||
else auto-installed via `winget install OliverBetz.ExifTool`. (SourceForge auto-download
|
||||
is unreliable — GDPR/consent wall.)
|
||||
- **GPS source:** cam frames use the fused INS `position` block (falls back to raw `gps`);
|
||||
thermal has only `gps`. UTC time = GPS week/tow − 18 leap seconds.
|
||||
- **Thermal stretch window:** default = pooled 1–99th percentile across the whole session
|
||||
(robust to outliers); configurable. Values are radiometric signal, not °C.
|
||||
- **Footprints** assume **flat ground** at a user-set elevation (default 110 m, the Speyer
|
||||
area). There is no terrain model.
|
||||
- **Sweep side:** if footprints/headings come out mirrored vs a known flight, flip
|
||||
`SCAN_SIGN` at the top of `session_map.py`.
|
||||
- **Tkinter:** never create `ttk.Style()` before the root window — it spawns a stray
|
||||
default root and `StringVar`s bind to the wrong one (entries render blank). The style is
|
||||
created with the app as master inside `__init__`, and all vars are parented to it.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Python 3.13, `Pillow`, `numpy`, `tkintermapview`, `requests` (map tiles need internet),
|
||||
and `exiftool` (auto-installed). Tkinter ships with the standard Windows Python.
|
||||
```
|
||||
pip install Pillow numpy tkintermapview requests
|
||||
```
|
||||
|
|
@ -0,0 +1,327 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
embed_metadata.py - Write FireMapper per-frame JSON sidecars into image EXIF/XMP.
|
||||
|
||||
For every image in a capture session that has a same-basename `.json` sidecar
|
||||
(cam*/<ts>_step_NN.tiff and thermal/<ts>_step_NN.png), this:
|
||||
|
||||
* maps the structured fields to real EXIF/XMP tags so the images are
|
||||
georeferenced and usable in mapping / photogrammetry software:
|
||||
- GPS lat/lon/alt -> EXIF GPS* tags
|
||||
- platform yaw -> EXIF GPSImgDirection + XMP-Camera:PoseHeadingDegrees
|
||||
- pitch / roll -> XMP-Camera:PosePitch/PoseRollDegrees
|
||||
- GPS week/tow -> DateTimeOriginal + GPSDateStamp/GPSTimeStamp (UTC)
|
||||
- lens / exposure / camera -> FocalLength, ExposureTime, Make/Model/Serial
|
||||
* stores the COMPLETE original JSON in EXIF:UserComment so nothing is lost.
|
||||
|
||||
Originals are never modified: tagged copies are written to an output folder
|
||||
that mirrors the session layout.
|
||||
|
||||
Usage:
|
||||
python embed_metadata.py [SESSION_DIR] [options]
|
||||
|
||||
SESSION_DIR session folder (default: the single session_* dir here)
|
||||
--out DIR output folder (default: <session>_exif next to it)
|
||||
--gps-source {position,gps}
|
||||
which block feeds GPS coords for cam frames
|
||||
(default: position = fused INS, falls back to gps)
|
||||
--exiftool PATH path to exiftool.exe (default: auto-detect / download)
|
||||
--dry-run report what would be written, touch nothing
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import session_map as smap # noqa: E402 (shared camera geometry + session discovery)
|
||||
|
||||
IMAGE_EXTS = {".tif", ".tiff", ".png", ".jpg", ".jpeg"}
|
||||
GPS_EPOCH = dt.datetime(1980, 1, 6, tzinfo=dt.timezone.utc)
|
||||
GPS_UTC_LEAP_SECONDS = 18 # GPS-UTC offset as of 2017-2026
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# exiftool acquisition
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _exiftool_candidates(cache_dir: Path):
|
||||
"""Yield likely exiftool locations in priority order."""
|
||||
yield shutil.which("exiftool")
|
||||
yield str(cache_dir / "exiftool.exe")
|
||||
local = os.environ.get("LOCALAPPDATA", "")
|
||||
pf = os.environ.get("ProgramFiles", "")
|
||||
for base in (local and Path(local) / "Programs" / "ExifTool", pf and Path(pf) / "ExifTool"):
|
||||
if base:
|
||||
yield str(base / "ExifTool.exe")
|
||||
yield str(base / "exiftool.exe")
|
||||
|
||||
|
||||
def ensure_exiftool(explicit: str | None, cache_dir: Path) -> str:
|
||||
"""Return a path to a working exiftool, installing it via winget if needed."""
|
||||
if explicit:
|
||||
return explicit
|
||||
for cand in _exiftool_candidates(cache_dir):
|
||||
if cand and Path(cand).exists():
|
||||
return cand
|
||||
|
||||
# not found anywhere - try a one-time install via winget (Windows)
|
||||
if shutil.which("winget"):
|
||||
print("exiftool not found - installing via winget (OliverBetz.ExifTool)...")
|
||||
subprocess.run(
|
||||
["winget", "install", "--id", "OliverBetz.ExifTool",
|
||||
"--accept-package-agreements", "--accept-source-agreements", "--silent"],
|
||||
check=False,
|
||||
)
|
||||
for cand in _exiftool_candidates(cache_dir):
|
||||
if cand and Path(cand).exists():
|
||||
return cand
|
||||
|
||||
sys.exit(
|
||||
"exiftool could not be found or installed automatically.\n"
|
||||
"Install it (e.g. `winget install OliverBetz.ExifTool` or `choco install exiftool`),\n"
|
||||
"or pass its path with --exiftool C:\\path\\to\\exiftool.exe"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# JSON -> tag mapping
|
||||
# --------------------------------------------------------------------------- #
|
||||
def gps_time_to_utc(week, tow) -> dt.datetime | None:
|
||||
if week is None or tow is None:
|
||||
return None
|
||||
return GPS_EPOCH + dt.timedelta(seconds=week * 604800 + tow - GPS_UTC_LEAP_SECONDS)
|
||||
|
||||
|
||||
def signed_ref(value, pos, neg):
|
||||
"""Return (abs_value, ref_letter) for a signed coordinate."""
|
||||
return (abs(value), pos if value >= 0 else neg)
|
||||
|
||||
|
||||
def build_tags(data: dict, manifest_cams: dict, cam_id: str | None, gps_source: str) -> list[str]:
|
||||
"""Return a list of '-Tag=value' exiftool arguments for one frame."""
|
||||
tags: list[str] = []
|
||||
|
||||
# --- coordinates: prefer the requested source, fall back to raw gps ----
|
||||
pos = None
|
||||
if cam_id and gps_source == "position":
|
||||
pos = data.get("position")
|
||||
if not pos:
|
||||
pos = data.get("gps")
|
||||
|
||||
if pos and pos.get("lat") is not None and pos.get("lon") is not None:
|
||||
lat, lat_ref = signed_ref(pos["lat"], "N", "S")
|
||||
lon, lon_ref = signed_ref(pos["lon"], "E", "W")
|
||||
tags += [f"-GPSLatitude={lat}", f"-GPSLatitudeRef={lat_ref}",
|
||||
f"-GPSLongitude={lon}", f"-GPSLongitudeRef={lon_ref}"]
|
||||
if pos.get("alt") is not None:
|
||||
alt = pos["alt"]
|
||||
tags += [f"-GPSAltitude={abs(alt)}", f"-GPSAltitudeRef={0 if alt >= 0 else 1}"]
|
||||
|
||||
# --- orientation: TRUE camera pointing ---------------------------------
|
||||
# aircraft attitude + platform roll (platform_angle_deg) + camera mounting offset,
|
||||
# not the bare IMU. Computed by the shared geometry so EXIF matches the map.
|
||||
imu = data.get("imu") or {}
|
||||
plat = data.get("platform") or {}
|
||||
ori = None
|
||||
if None not in (imu.get("yaw"), imu.get("pitch"), imu.get("roll")):
|
||||
ori = smap.camera_orientation({
|
||||
"kind": "cam" if cam_id else "thermal",
|
||||
"off_nadir": smap.off_nadir_from_name(cam_id) if cam_id else 0.0,
|
||||
"yaw": imu["yaw"], "pitch": imu["pitch"], "roll": imu["roll"],
|
||||
"platform_angle": plat.get("platform_angle_deg"),
|
||||
})
|
||||
if ori:
|
||||
heading, pitch, roll = ori
|
||||
tags += [f"-GPSImgDirection={heading % 360:.4f}", "-GPSImgDirectionRef=T",
|
||||
f"-XMP-GPano:PoseHeadingDegrees={heading % 360:.4f}",
|
||||
f"-XMP-GPano:PosePitchDegrees={pitch:.4f}",
|
||||
f"-XMP-GPano:PoseRollDegrees={roll:.4f}"]
|
||||
|
||||
# --- timestamp (UTC, derived from GPS week/tow) ------------------------
|
||||
g = data.get("gps") or {}
|
||||
when = gps_time_to_utc(g.get("week"), g.get("tow"))
|
||||
if when:
|
||||
tags += [f"-DateTimeOriginal={when:%Y:%m:%d %H:%M:%S}",
|
||||
f"-SubSecTimeOriginal={when.microsecond // 1000:03d}",
|
||||
"-OffsetTimeOriginal=+00:00",
|
||||
f"-GPSDateStamp={when:%Y:%m:%d}",
|
||||
f"-GPSTimeStamp={when:%H:%M:%S}"]
|
||||
|
||||
# --- camera / lens -----------------------------------------------------
|
||||
lens = data.get("lens") or {}
|
||||
if lens.get("focal_length_mm") is not None:
|
||||
tags.append(f"-FocalLength={lens['focal_length_mm']}")
|
||||
if data.get("exposure_time_us") is not None:
|
||||
tags.append(f"-ExposureTime={data['exposure_time_us'] / 1_000_000.0:.9f}")
|
||||
if cam_id and cam_id in manifest_cams:
|
||||
cam = manifest_cams[cam_id]
|
||||
tags += ["-Make=Allied Vision", f"-Model={cam.get('model', cam_id)}",
|
||||
f"-SerialNumber={cam.get('serial', '')}"]
|
||||
|
||||
# --- full fidelity: entire JSON in UserComment + a short description ----
|
||||
compact = json.dumps(data, separators=(",", ":"), sort_keys=True)
|
||||
label_bits = [p for p in (cam_id or ("thermal" if "thermal" in data else None),) if p]
|
||||
if g.get("week") is not None:
|
||||
label_bits.append(f"frame {data.get('frame_id', '?')}")
|
||||
tags += [f"-EXIF:UserComment={compact}",
|
||||
"-Software=FireMapper embed_metadata.py",
|
||||
f"-EXIF:ImageDescription={' '.join(label_bits) or 'FireMapper frame'}"]
|
||||
return tags
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# core (importable - used by both the CLI and the GUI)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def find_session(arg: str | None) -> Path:
|
||||
if arg:
|
||||
return Path(arg).resolve()
|
||||
sessions = smap.list_sessions(Path(__file__).resolve().parent)
|
||||
if not sessions:
|
||||
sys.exit("No session folder found nearby; pass SESSION_DIR explicitly.")
|
||||
return max(sessions, key=lambda p: p.stat().st_mtime)
|
||||
|
||||
|
||||
def collect_pairs(session: Path) -> list[tuple[Path, Path]]:
|
||||
"""Every image under the session that has a same-basename .json sidecar."""
|
||||
pairs = []
|
||||
for img in sorted(session.rglob("*")):
|
||||
if img.suffix.lower() in IMAGE_EXTS and img.with_suffix(".json").exists():
|
||||
pairs.append((img, img.with_suffix(".json")))
|
||||
return pairs
|
||||
|
||||
|
||||
def load_manifest_cams(session: Path) -> dict:
|
||||
"""camera id -> manifest entry (model / serial), if a manifest exists."""
|
||||
cams: dict = {}
|
||||
mpath = session / "manifest.json"
|
||||
if mpath.exists():
|
||||
try:
|
||||
for cam in json.loads(mpath.read_text(encoding="utf-8")).get("cameras", []):
|
||||
cams[cam["id"]] = cam
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return cams
|
||||
|
||||
|
||||
def embed_session(session, out_root=None, gps_source="position", exiftool=None,
|
||||
progress=None, log=None, cancel=None) -> dict:
|
||||
"""
|
||||
Write the JSON sidecars into EXIF/XMP of tagged copies of every image.
|
||||
|
||||
progress(fraction_0_to_1, message) and log(message) are optional callbacks
|
||||
so a GUI (or the CLI) can show what is happening; cancel() may return True
|
||||
to abort. Returns a small summary dict.
|
||||
"""
|
||||
session = Path(session)
|
||||
out_root = Path(out_root) if out_root else session.with_name(session.name + "_exif")
|
||||
log = log or (lambda *_: None)
|
||||
progress = progress or (lambda *_: None)
|
||||
cancel = cancel or (lambda: False)
|
||||
|
||||
pairs = collect_pairs(session)
|
||||
if not pairs:
|
||||
raise RuntimeError(f"No image+json pairs found under {session}")
|
||||
cams = load_manifest_cams(session)
|
||||
et = exiftool or ensure_exiftool(None, Path(__file__).resolve().parent / "tools")
|
||||
total = len(pairs)
|
||||
|
||||
log(f"Session : {session}")
|
||||
log(f"Output : {out_root}")
|
||||
log(f"GPS source : {gps_source} (cam frames; thermal always uses raw gps)")
|
||||
log(f"exiftool : {et}")
|
||||
log(f"Found {total} image + JSON pairs.\nCopying originals into the output folder ...")
|
||||
|
||||
arg_lines: list[str] = []
|
||||
copied = 0
|
||||
for i, (img, sidecar) in enumerate(pairs, 1):
|
||||
if cancel():
|
||||
raise RuntimeError("Cancelled by user.")
|
||||
rel = img.relative_to(session)
|
||||
cam_id = rel.parts[0] if rel.parts[0].startswith("cam") else None
|
||||
try:
|
||||
data = json.loads(sidecar.read_text(encoding="utf-8"))
|
||||
except Exception as e: # noqa: BLE001
|
||||
log(f" ! skip {rel}: bad json ({e})")
|
||||
continue
|
||||
dst = out_root / rel
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(img, dst)
|
||||
copied += 1
|
||||
arg_lines += ["-overwrite_original", *build_tags(data, cams, cam_id, gps_source),
|
||||
str(dst), "-execute"]
|
||||
progress(0.5 * i / total, f"Copying {i}/{total}")
|
||||
|
||||
argfile = out_root / "_exiftool_args.txt"
|
||||
argfile.write_text("\n".join(arg_lines) + "\n", encoding="utf-8")
|
||||
log(f"Copied {copied} images. Writing EXIF/XMP tags with exiftool ...")
|
||||
|
||||
# stream exiftool so progress advances per file
|
||||
proc = subprocess.Popen(
|
||||
[et, "-m", "-charset", "UTF8", "-charset", "filename=UTF8", "-@", str(argfile)],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
)
|
||||
done = updated = 0
|
||||
for line in proc.stdout: # type: ignore[union-attr]
|
||||
line = line.rstrip()
|
||||
if "image files" in line and ("updated" in line or "unchanged" in line):
|
||||
done += 1
|
||||
if "updated" in line:
|
||||
updated += 1
|
||||
progress(0.5 + 0.5 * done / total, f"Tagging {done}/{total}")
|
||||
elif line.strip():
|
||||
log(line)
|
||||
proc.wait()
|
||||
argfile.unlink(missing_ok=True)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"exiftool exited with code {proc.returncode}")
|
||||
|
||||
progress(1.0, "Done")
|
||||
log(f"\nDone. {updated} files tagged.\nOutput: {out_root}")
|
||||
return {"out_root": out_root, "pairs": total, "updated": updated}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CLI
|
||||
# --------------------------------------------------------------------------- #
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Embed FireMapper JSON sidecars into image EXIF/XMP.")
|
||||
ap.add_argument("session", nargs="?", help="session folder (default: newest session_* here)")
|
||||
ap.add_argument("--out", help="output folder (default: <session>_exif)")
|
||||
ap.add_argument("--gps-source", choices=["position", "gps"], default="position",
|
||||
help="GPS source for cam frames (default: position = fused INS)")
|
||||
ap.add_argument("--exiftool", help="path to exiftool (default: auto-detect/install)")
|
||||
ap.add_argument("--dry-run", action="store_true", help="report only, write nothing")
|
||||
args = ap.parse_args()
|
||||
|
||||
session = find_session(args.session)
|
||||
if not session.is_dir():
|
||||
sys.exit(f"Session folder not found: {session}")
|
||||
|
||||
if args.dry_run:
|
||||
pairs = collect_pairs(session)
|
||||
cams = load_manifest_cams(session)
|
||||
if not pairs:
|
||||
sys.exit(f"No image+json pairs found under {session}")
|
||||
img, sidecar = pairs[0]
|
||||
cam_id = img.relative_to(session).parts[0]
|
||||
cam_id = cam_id if cam_id.startswith("cam") else None
|
||||
data = json.loads(sidecar.read_text(encoding="utf-8"))
|
||||
print(f"DRY RUN: {len(pairs)} pairs under {session}")
|
||||
print("\n--- sample tags (first frame) ---")
|
||||
print("\n".join(build_tags(data, cams, cam_id, args.gps_source)))
|
||||
return 0
|
||||
|
||||
embed_session(session, args.out, args.gps_source, args.exiftool,
|
||||
progress=lambda f, m: None, log=print)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,610 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
FireMapper - Session Post-Processing (GUI)
|
||||
==========================================
|
||||
|
||||
A friendly front-end for the two FireMapper post-processing steps:
|
||||
|
||||
1. Embed GPS & metadata - writes each frame's JSON sidecar (GPS, heading,
|
||||
time, lens, ...) into EXIF/XMP of tagged image copies, ready for mapping
|
||||
software. (wraps embed_metadata.py)
|
||||
|
||||
2. Thermal stretch - rescales the whole session's 16-bit radiometric
|
||||
thermal frames into one shared brightness window and saves viewable
|
||||
8-bit images in a heat-map palette. (wraps stretch_thermal.py)
|
||||
|
||||
Just run: python firemapper_gui.py
|
||||
|
||||
No command line needed - pick a session folder, read the on-screen
|
||||
explanation, and click the button. Long jobs run in the background with a
|
||||
progress bar; your original files are never modified.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog, messagebox, scrolledtext, ttk
|
||||
|
||||
from PIL import Image, ImageDraw, ImageTk
|
||||
|
||||
# import the worker modules that live next to this file
|
||||
APP_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(APP_DIR))
|
||||
import embed_metadata as embed # noqa: E402
|
||||
import stretch_thermal as stretch # noqa: E402
|
||||
import session_map as smap # noqa: E402
|
||||
|
||||
try:
|
||||
from tkintermapview import TkinterMapView # noqa: E402
|
||||
except Exception: # noqa: BLE001 (optional dependency - the Map tab degrades gracefully)
|
||||
TkinterMapView = None
|
||||
|
||||
PAD = 10
|
||||
HEADING = ("Segoe UI Semibold", 11)
|
||||
EXPLAIN_WRAP = 720
|
||||
|
||||
|
||||
def autodetect_session() -> str:
|
||||
"""Newest session folder near this script (also one level into parent folders)."""
|
||||
sessions = smap.list_sessions(APP_DIR)
|
||||
return str(max(sessions, key=lambda p: p.stat().st_mtime)) if sessions else ""
|
||||
|
||||
|
||||
def open_in_explorer(path: Path) -> None:
|
||||
if hasattr(os, "startfile"):
|
||||
try:
|
||||
os.startfile(path) # type: ignore[attr-defined]
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
class FireMapperGUI(tk.Tk):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.title("FireMapper - Session Post-Processing")
|
||||
self.geometry("960x780")
|
||||
self.minsize(820, 640)
|
||||
|
||||
# style must be created with THIS root as master - never before it,
|
||||
# or tkinter spins up a second, stray default root and the StringVars
|
||||
# bind to the wrong one (entries then render blank).
|
||||
style = ttk.Style(self)
|
||||
if "vista" in style.theme_names():
|
||||
style.theme_use("vista")
|
||||
style.configure("Card.TFrame", background="#f5f6f8", relief="solid", borderwidth=1)
|
||||
|
||||
self.q: queue.Queue = queue.Queue()
|
||||
self.running = False
|
||||
self.action_buttons: list[ttk.Button] = []
|
||||
self.logs: dict[str, scrolledtext.ScrolledText] = {}
|
||||
self.bars: dict[str, ttk.Progressbar] = {}
|
||||
self.status_vars: dict[str, tk.StringVar] = {}
|
||||
self._preview_imgtk = None # keep a reference so Tk doesn't GC it
|
||||
|
||||
self._build_header()
|
||||
nb = ttk.Notebook(self)
|
||||
nb.pack(fill="both", expand=True, padx=PAD, pady=(0, PAD))
|
||||
self._build_embed_tab(nb)
|
||||
self._build_thermal_tab(nb)
|
||||
self._build_map_tab(nb)
|
||||
|
||||
self.after(100, self._poll_queue)
|
||||
|
||||
# ----- shared UI bits -------------------------------------------------
|
||||
def _build_header(self):
|
||||
head = ttk.Frame(self, padding=(PAD, PAD, PAD, 4))
|
||||
head.pack(fill="x")
|
||||
ttk.Label(head, text="FireMapper - Session Post-Processing",
|
||||
font=("Segoe UI Semibold", 15)).pack(anchor="w")
|
||||
ttk.Label(head, foreground="#555", wraplength=900, justify="left",
|
||||
text="Turn a raw capture session into mapping-ready imagery. "
|
||||
"Pick a session folder, then run either step. Originals are "
|
||||
"never changed - results are written to new folders.").pack(anchor="w")
|
||||
ttk.Separator(self).pack(fill="x", padx=PAD, pady=(6, 8))
|
||||
|
||||
@staticmethod
|
||||
def _explain(parent, text):
|
||||
box = ttk.Frame(parent, padding=8)
|
||||
box.configure(style="Card.TFrame")
|
||||
ttk.Label(box, text=text, wraplength=EXPLAIN_WRAP, justify="left",
|
||||
foreground="#333").pack(anchor="w")
|
||||
return box
|
||||
|
||||
def _folder_row(self, parent, label, var, browse_dir=True):
|
||||
row = ttk.Frame(parent)
|
||||
ttk.Label(row, text=label, width=16).pack(side="left")
|
||||
ttk.Entry(row, textvariable=var).pack(side="left", fill="x", expand=True)
|
||||
|
||||
def browse():
|
||||
if browse_dir:
|
||||
p = filedialog.askdirectory(title=label,
|
||||
initialdir=var.get() or str(APP_DIR))
|
||||
else:
|
||||
p = filedialog.askopenfilename(title=label,
|
||||
initialdir=var.get() or str(APP_DIR))
|
||||
if p:
|
||||
var.set(p)
|
||||
ttk.Button(row, text="Browse...", command=browse).pack(side="left", padx=(6, 0))
|
||||
return row
|
||||
|
||||
def _progress_block(self, parent, which):
|
||||
frame = ttk.Frame(parent)
|
||||
bar = ttk.Progressbar(frame, mode="determinate", maximum=1000)
|
||||
bar.pack(fill="x")
|
||||
sv = tk.StringVar(self, value="Idle.")
|
||||
ttk.Label(frame, textvariable=sv, foreground="#555").pack(anchor="w", pady=(2, 0))
|
||||
log = scrolledtext.ScrolledText(frame, height=11, wrap="word",
|
||||
font=("Consolas", 9))
|
||||
log.pack(fill="both", expand=True, pady=(6, 0))
|
||||
self.bars[which] = bar
|
||||
self.status_vars[which] = sv
|
||||
self.logs[which] = log
|
||||
return frame
|
||||
|
||||
# ----- tab 1: embed ---------------------------------------------------
|
||||
def _build_embed_tab(self, nb):
|
||||
tab = ttk.Frame(nb, padding=PAD)
|
||||
nb.add(tab, text=" 1. Embed GPS & Metadata ")
|
||||
|
||||
ttk.Label(tab, text="Embed GPS & metadata into images", font=HEADING).pack(anchor="w")
|
||||
self._explain(tab,
|
||||
"Copies every image in the session and writes its matching JSON data into "
|
||||
"the copy's EXIF/XMP tags: GPS position, camera heading / pitch / roll, UTC "
|
||||
"capture time, lens & exposure, plus the complete JSON in the comment field. "
|
||||
"The tagged copies drop straight into mapping / photogrammetry tools "
|
||||
"(Pix4D, Metashape, QGIS, ...), which read the embedded GPS to place each "
|
||||
"photo on the map. Requires exiftool - it is found automatically, and "
|
||||
"installed for you the first time if missing."
|
||||
).pack(fill="x", pady=(4, 10))
|
||||
|
||||
self.embed_session = tk.StringVar(self, value=autodetect_session())
|
||||
self.embed_out = tk.StringVar(self, value="")
|
||||
self.embed_gps = tk.StringVar(self, value="position")
|
||||
self.embed_exif = tk.StringVar(self, value="")
|
||||
|
||||
self._folder_row(tab, "Session folder", self.embed_session).pack(fill="x", pady=3)
|
||||
self._folder_row(tab, "Output folder", self.embed_out).pack(fill="x", pady=3)
|
||||
ttk.Label(tab, text="Leave output blank to use <session>_exif next to the session.",
|
||||
foreground="#777").pack(anchor="w", padx=(16, 0))
|
||||
|
||||
gps = ttk.LabelFrame(tab, text="GPS source for camera frames", padding=8)
|
||||
gps.pack(fill="x", pady=(10, 4))
|
||||
ttk.Radiobutton(gps, variable=self.embed_gps, value="position",
|
||||
text="Fused INS (position) - most accurate [recommended]").pack(anchor="w")
|
||||
ttk.Radiobutton(gps, variable=self.embed_gps, value="gps",
|
||||
text="Raw GNSS (gps) - bare satellite fix").pack(anchor="w")
|
||||
ttk.Label(gps, foreground="#777",
|
||||
text="Thermal frames have no INS solution and always use raw GNSS.").pack(anchor="w")
|
||||
|
||||
adv = ttk.Frame(tab)
|
||||
adv.pack(fill="x", pady=(8, 2))
|
||||
ttk.Label(adv, text="exiftool path", width=16).pack(side="left")
|
||||
ttk.Entry(adv, textvariable=self.embed_exif).pack(side="left", fill="x", expand=True)
|
||||
ttk.Label(adv, text="(optional - auto)", foreground="#777").pack(side="left", padx=6)
|
||||
|
||||
btn = ttk.Button(tab, text="Embed metadata", command=self._start_embed)
|
||||
btn.pack(anchor="w", pady=10)
|
||||
self.action_buttons.append(btn)
|
||||
|
||||
self._progress_block(tab, "embed").pack(fill="both", expand=True)
|
||||
|
||||
# ----- tab 2: thermal -------------------------------------------------
|
||||
def _build_thermal_tab(self, nb):
|
||||
tab = ttk.Frame(nb, padding=PAD)
|
||||
nb.add(tab, text=" 2. Thermal Stretch ")
|
||||
|
||||
ttk.Label(tab, text="Stretch thermal frames for viewing", font=HEADING).pack(anchor="w")
|
||||
self._explain(tab,
|
||||
"The thermal camera records 16-bit radiometric frames whose values fill only "
|
||||
"a tiny part of the range, so raw files look almost black and flicker between "
|
||||
"frames. This finds ONE brightness window shared by the whole session and "
|
||||
"stretches every frame into it, saving easy-to-view 8-bit images. Because all "
|
||||
"frames share the window, hot and cold areas stay consistent across the flight. "
|
||||
"Values are radiometric signal (proportional to temperature), not calibrated "
|
||||
"degrees. Tip: click 'Preview palettes' to compare the look before processing."
|
||||
).pack(fill="x", pady=(4, 10))
|
||||
|
||||
self.th_session = tk.StringVar(self, value=autodetect_session())
|
||||
self.th_out = tk.StringVar(self, value="")
|
||||
self.th_cmap = tk.StringVar(self, value="inferno")
|
||||
self.th_lo = tk.DoubleVar(self, value=1.0)
|
||||
self.th_hi = tk.DoubleVar(self, value=99.0)
|
||||
self.th_absolute = tk.BooleanVar(self, value=False)
|
||||
|
||||
self._folder_row(tab, "Session folder", self.th_session).pack(fill="x", pady=3)
|
||||
self._folder_row(tab, "Output folder", self.th_out).pack(fill="x", pady=3)
|
||||
ttk.Label(tab, text="Leave output blank to use <session>/thermal_stretched.",
|
||||
foreground="#777").pack(anchor="w", padx=(16, 0))
|
||||
|
||||
opts = ttk.Frame(tab)
|
||||
opts.pack(fill="x", pady=(10, 4))
|
||||
ttk.Label(opts, text="Palette", width=16).pack(side="left")
|
||||
ttk.Combobox(opts, textvariable=self.th_cmap, width=12, state="readonly",
|
||||
values=["inferno", "ironbow", "gray"]).pack(side="left")
|
||||
ttk.Label(opts, text=" Window low %").pack(side="left")
|
||||
ttk.Spinbox(opts, from_=0, to=49, increment=0.5, width=6,
|
||||
textvariable=self.th_lo).pack(side="left", padx=4)
|
||||
ttk.Label(opts, text="high %").pack(side="left")
|
||||
ttk.Spinbox(opts, from_=51, to=100, increment=0.5, width=6,
|
||||
textvariable=self.th_hi).pack(side="left", padx=4)
|
||||
ttk.Checkbutton(opts, text="Absolute min/max", variable=self.th_absolute).pack(side="left", padx=10)
|
||||
|
||||
self.th_embed = tk.BooleanVar(self, value=True)
|
||||
ttk.Checkbutton(tab, variable=self.th_embed,
|
||||
text="Embed GPS / orientation metadata into the stretched PNGs "
|
||||
"(needs exiftool)").pack(anchor="w", pady=(6, 0))
|
||||
|
||||
bar = ttk.Frame(tab)
|
||||
bar.pack(fill="x", pady=10)
|
||||
pv = ttk.Button(bar, text="Preview palettes", command=self._start_preview)
|
||||
pv.pack(side="left")
|
||||
run = ttk.Button(bar, text="Stretch all frames", command=self._start_stretch)
|
||||
run.pack(side="left", padx=8)
|
||||
self.action_buttons += [pv, run]
|
||||
|
||||
ttk.Label(tab, text="Preview (middle frame) gray | inferno | ironbow",
|
||||
foreground="#777").pack(anchor="w")
|
||||
self.preview_label = ttk.Label(tab, anchor="center")
|
||||
self.preview_label.pack(fill="x", pady=(2, 6))
|
||||
|
||||
self._progress_block(tab, "thermal").pack(fill="both", expand=True)
|
||||
|
||||
# ----- tab 3: map -----------------------------------------------------
|
||||
def _build_map_tab(self, nb):
|
||||
tab = ttk.Frame(nb, padding=PAD)
|
||||
nb.add(tab, text=" 3. Map ")
|
||||
|
||||
if TkinterMapView is None:
|
||||
ttk.Label(tab, foreground="#a00000", wraplength=600, justify="left",
|
||||
text="The map needs the 'tkintermapview' package.\n\n"
|
||||
"Install it from a terminal with:\n"
|
||||
" pip install tkintermapview\n\n"
|
||||
"then reopen this program.").pack(anchor="w", pady=20)
|
||||
return
|
||||
|
||||
ttk.Label(tab, text="Trigger points & image footprints on OpenStreetMap",
|
||||
font=HEADING).pack(anchor="w")
|
||||
self._explain(tab,
|
||||
"Tick the sessions to plot, then click 'Show on map'. Each image becomes a "
|
||||
"trigger point (its GPS position) and, optionally, an oblique footprint - the "
|
||||
"ground patch the photo covers, projected from the camera off-nadir angle "
|
||||
"(cam25 = 25 deg, cam45 = 45 deg), the cross-track scan angle, the aircraft "
|
||||
"attitude and the height above ground. Footprints assume flat ground at the "
|
||||
"elevation set below. Needs an internet connection for the map tiles."
|
||||
).pack(fill="x", pady=(4, 8))
|
||||
|
||||
body = ttk.Frame(tab)
|
||||
body.pack(fill="both", expand=True)
|
||||
left = ttk.Frame(body)
|
||||
left.pack(side="left", fill="y", padx=(0, 8))
|
||||
right = ttk.Frame(body)
|
||||
right.pack(side="left", fill="both", expand=True)
|
||||
|
||||
sess_box = ttk.LabelFrame(left, text="Sessions", padding=6)
|
||||
sess_box.pack(fill="x")
|
||||
self.session_list_frame = ttk.Frame(sess_box)
|
||||
self.session_list_frame.pack(fill="x")
|
||||
self.session_vars: dict[str, tk.BooleanVar] = {}
|
||||
btns = ttk.Frame(sess_box)
|
||||
btns.pack(anchor="w", pady=(4, 0))
|
||||
ttk.Button(btns, text="All", width=5,
|
||||
command=lambda: self._set_all_sessions(True)).pack(side="left")
|
||||
ttk.Button(btns, text="None", width=5,
|
||||
command=lambda: self._set_all_sessions(False)).pack(side="left", padx=4)
|
||||
ttk.Button(btns, text="Refresh", width=8,
|
||||
command=self._refresh_sessions).pack(side="left")
|
||||
|
||||
opt = ttk.LabelFrame(left, text="Options", padding=6)
|
||||
opt.pack(fill="x", pady=(8, 0))
|
||||
self.map_ground = tk.StringVar(self, value="110")
|
||||
self.map_tfov_h = tk.StringVar(self, value=str(smap.THERMAL_FOV_DEFAULT[0])) # FLIR A65 25deg
|
||||
self.map_tfov_v = tk.StringVar(self, value=str(smap.THERMAL_FOV_DEFAULT[1]))
|
||||
self.map_toff = tk.StringVar(self, value="0")
|
||||
self.map_step = tk.StringVar(self, value="auto")
|
||||
self.map_markers = tk.BooleanVar(self, value=True)
|
||||
self.map_foot = tk.BooleanVar(self, value=True)
|
||||
self.map_cam = tk.BooleanVar(self, value=True)
|
||||
self.map_thermal = tk.BooleanVar(self, value=True)
|
||||
|
||||
def field(label, var):
|
||||
r = ttk.Frame(opt)
|
||||
r.pack(fill="x", pady=1)
|
||||
ttk.Label(r, text=label, width=17).pack(side="left")
|
||||
ttk.Entry(r, textvariable=var, width=8).pack(side="left")
|
||||
|
||||
field("Ground elev (m)", self.map_ground)
|
||||
field("Thermal FOV H (deg)", self.map_tfov_h)
|
||||
field("Thermal FOV V (deg)", self.map_tfov_v)
|
||||
field("Thermal off-nadir", self.map_toff)
|
||||
field("Plot every Nth", self.map_step)
|
||||
ttk.Label(opt, foreground="#777",
|
||||
text="Thermal footprints need the FOV above.").pack(anchor="w", pady=(2, 4))
|
||||
ttk.Checkbutton(opt, text="Trigger points", variable=self.map_markers).pack(anchor="w")
|
||||
ttk.Checkbutton(opt, text="Footprints", variable=self.map_foot).pack(anchor="w")
|
||||
ttk.Checkbutton(opt, text="Cameras (blue)", variable=self.map_cam).pack(anchor="w")
|
||||
ttk.Checkbutton(opt, text="Thermal (orange)", variable=self.map_thermal).pack(anchor="w")
|
||||
|
||||
self.map_btn = ttk.Button(left, text="Show on map", command=self._start_plot_map)
|
||||
self.map_btn.pack(fill="x", pady=8)
|
||||
self.action_buttons.append(self.map_btn)
|
||||
self.map_status = tk.StringVar(self, value="Idle.")
|
||||
ttk.Label(left, textvariable=self.map_status, wraplength=230,
|
||||
foreground="#555").pack(anchor="w")
|
||||
|
||||
# small dot icons so hundreds of trigger points stay legible (the default
|
||||
# tkintermapview pins are large and merge into a blob at this density)
|
||||
self._dot_icons = {"cam": self._dot("#1565c0"), "thermal": self._dot("#e65100")}
|
||||
|
||||
self.map_widget = TkinterMapView(right, corner_radius=0)
|
||||
self.map_widget.pack(fill="both", expand=True)
|
||||
self.map_widget.set_tile_server(
|
||||
"https://tile.openstreetmap.org/{z}/{x}/{y}.png", max_zoom=19)
|
||||
self.map_widget.set_position(49.32, 8.43) # Speyer area, until data is plotted
|
||||
self.map_widget.set_zoom(9)
|
||||
|
||||
self._refresh_sessions()
|
||||
|
||||
def _set_all_sessions(self, value: bool):
|
||||
for v in self.session_vars.values():
|
||||
v.set(value)
|
||||
|
||||
def _refresh_sessions(self):
|
||||
for w in self.session_list_frame.winfo_children():
|
||||
w.destroy()
|
||||
self.session_vars = {}
|
||||
sessions = smap.list_sessions(APP_DIR)
|
||||
for s in sessions:
|
||||
v = tk.BooleanVar(self, value=True) # default: show all strips
|
||||
self.session_vars[str(s)] = v
|
||||
ttk.Checkbutton(self.session_list_frame, text=s.name, variable=v).pack(anchor="w")
|
||||
if not sessions:
|
||||
ttk.Label(self.session_list_frame, text="(no sessions found)",
|
||||
foreground="#777").pack(anchor="w")
|
||||
|
||||
def _start_plot_map(self):
|
||||
if self.running:
|
||||
return
|
||||
sel = [s for s, v in self.session_vars.items() if v.get()]
|
||||
if not sel:
|
||||
messagebox.showwarning("No sessions", "Tick at least one session to plot.")
|
||||
return
|
||||
try:
|
||||
ground = float(self.map_ground.get())
|
||||
except ValueError:
|
||||
messagebox.showwarning("Ground elevation", "Ground elevation must be a number (m).")
|
||||
return
|
||||
tfov = None
|
||||
if self.map_tfov_h.get().strip() and self.map_tfov_v.get().strip():
|
||||
try:
|
||||
tfov = (float(self.map_tfov_h.get()), float(self.map_tfov_v.get()))
|
||||
except ValueError:
|
||||
messagebox.showwarning("Thermal FOV", "Thermal FOV must be numbers (degrees).")
|
||||
return
|
||||
try:
|
||||
toff = float(self.map_toff.get() or 0)
|
||||
except ValueError:
|
||||
toff = 0.0
|
||||
opts = dict(sessions=sel, ground=ground, tfov=tfov, toff=toff,
|
||||
step_raw=self.map_step.get().strip().lower(),
|
||||
markers=self.map_markers.get(), foot=self.map_foot.get(),
|
||||
inc_cam=self.map_cam.get(), inc_thermal=self.map_thermal.get())
|
||||
self._set_running(True)
|
||||
self.map_status.set("Reading sessions ...")
|
||||
|
||||
def work():
|
||||
try:
|
||||
frames = []
|
||||
for s in opts["sessions"]:
|
||||
frames += smap.iter_session_frames(
|
||||
s, include_cam=opts["inc_cam"], include_thermal=opts["inc_thermal"],
|
||||
thermal_off_nadir=opts["toff"])
|
||||
if not frames:
|
||||
self.q.put(("maperror", "No frames with GPS in the selected sessions."))
|
||||
return
|
||||
total = len(frames)
|
||||
if opts["step_raw"] in ("", "auto", "0"):
|
||||
step = max(1, math.ceil(total / 600))
|
||||
else:
|
||||
step = max(1, int(float(opts["step_raw"])))
|
||||
use = frames[::step]
|
||||
markers = [(f["lat"], f["lon"], f["kind"]) for f in use] if opts["markers"] else []
|
||||
polys = []
|
||||
if opts["foot"]:
|
||||
for f in use:
|
||||
c = smap.footprint(f, opts["ground"], thermal_fov_deg=opts["tfov"])
|
||||
if c:
|
||||
polys.append((c, f["kind"]))
|
||||
self.q.put(("map", {"markers": markers, "polys": polys,
|
||||
"bounds": smap.bounds(use), "total": total,
|
||||
"shown": len(use), "step": step}))
|
||||
except Exception as e: # noqa: BLE001
|
||||
self.q.put(("maperror", str(e)))
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
|
||||
@staticmethod
|
||||
def _dot(color, d=9):
|
||||
"""A small round marker icon (PhotoImage) of the given colour."""
|
||||
img = Image.new("RGBA", (d, d), (0, 0, 0, 0))
|
||||
ImageDraw.Draw(img).ellipse([0, 0, d - 1, d - 1], fill=color, outline="#ffffff")
|
||||
return ImageTk.PhotoImage(img)
|
||||
|
||||
def _render_map(self, data):
|
||||
self._set_running(False)
|
||||
mw = self.map_widget
|
||||
mw.delete_all_marker()
|
||||
mw.delete_all_polygon()
|
||||
colors = {"cam": "#1565c0", "thermal": "#e65100"}
|
||||
for corners, kind in data["polys"]:
|
||||
mw.set_polygon(corners, outline_color=colors.get(kind, "#333333"), border_width=1)
|
||||
for lat, lon, kind in data["markers"]:
|
||||
mw.set_marker(lat, lon, text="", icon=self._dot_icons.get(kind), icon_anchor="center")
|
||||
mn_lat, mn_lon, mx_lat, mx_lon = data["bounds"]
|
||||
try:
|
||||
if mx_lat > mn_lat and mx_lon > mn_lon:
|
||||
mw.fit_bounding_box((mx_lat, mn_lon), (mn_lat, mx_lon))
|
||||
else:
|
||||
mw.set_position((mn_lat + mx_lat) / 2, (mn_lon + mx_lon) / 2)
|
||||
mw.set_zoom(14)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
self.map_status.set(
|
||||
f"Plotted {data['shown']} of {data['total']} frames (every {data['step']}): "
|
||||
f"{len(data['markers'])} points, {len(data['polys'])} footprints.")
|
||||
|
||||
# ----- run helpers ----------------------------------------------------
|
||||
def _set_running(self, busy: bool):
|
||||
self.running = busy
|
||||
for b in self.action_buttons:
|
||||
b.config(state="disabled" if busy else "normal")
|
||||
|
||||
def _callbacks(self, which):
|
||||
return (lambda frac, msg: self.q.put(("progress", which, frac, msg)),
|
||||
lambda msg: self.q.put(("log", which, msg)))
|
||||
|
||||
def _guard(self, session: str, which: str) -> bool:
|
||||
if self.running:
|
||||
return False
|
||||
if not session or not Path(session).is_dir():
|
||||
messagebox.showwarning("No session", "Please choose a valid session folder.")
|
||||
return False
|
||||
self.logs[which].delete("1.0", "end")
|
||||
self.bars[which]["value"] = 0
|
||||
self._set_running(True)
|
||||
return True
|
||||
|
||||
def _spawn(self, fn):
|
||||
threading.Thread(target=fn, daemon=True).start()
|
||||
|
||||
def _start_embed(self):
|
||||
session = self.embed_session.get().strip()
|
||||
if not self._guard(session, "embed"):
|
||||
return
|
||||
out = self.embed_out.get().strip() or None
|
||||
gps = self.embed_gps.get()
|
||||
et = self.embed_exif.get().strip() or None
|
||||
progress, log = self._callbacks("embed")
|
||||
|
||||
def work():
|
||||
try:
|
||||
res = embed.embed_session(session, out, gps, et, progress=progress, log=log)
|
||||
self.q.put(("done", "embed", res))
|
||||
except Exception as e: # noqa: BLE001
|
||||
self.q.put(("error", "embed", str(e)))
|
||||
self._spawn(work)
|
||||
|
||||
def _read_window_params(self):
|
||||
return dict(lo_pct=float(self.th_lo.get()), hi_pct=float(self.th_hi.get()),
|
||||
absolute=bool(self.th_absolute.get()))
|
||||
|
||||
def _start_preview(self):
|
||||
session = self.th_session.get().strip()
|
||||
if not self._guard(session, "thermal"):
|
||||
return
|
||||
params = self._read_window_params()
|
||||
progress, log = self._callbacks("thermal")
|
||||
|
||||
def work():
|
||||
try:
|
||||
pngs = stretch.list_frames(Path(session))
|
||||
win = stretch.compute_window(pngs, progress=progress, **params)
|
||||
log(f"Frames: {len(pngs)} | absolute signal {win['abs_lo']}-{win['abs_hi']}")
|
||||
log(f"Window: {win['lo']}-{win['hi']} ({win['how']})")
|
||||
img = stretch.make_sample_image(pngs, win["lo"], win["hi"])
|
||||
self.q.put(("preview", img))
|
||||
self.q.put(("done", "thermal", {"kind": "preview", **win}))
|
||||
except Exception as e: # noqa: BLE001
|
||||
self.q.put(("error", "thermal", str(e)))
|
||||
self._spawn(work)
|
||||
|
||||
def _start_stretch(self):
|
||||
session = self.th_session.get().strip()
|
||||
if not self._guard(session, "thermal"):
|
||||
return
|
||||
out = self.th_out.get().strip() or None
|
||||
cmap = self.th_cmap.get()
|
||||
params = self._read_window_params()
|
||||
progress, log = self._callbacks("thermal")
|
||||
|
||||
def work():
|
||||
try:
|
||||
pngs = stretch.list_frames(Path(session))
|
||||
win = stretch.compute_window(pngs, progress=progress, **params)
|
||||
log(f"Window: {win['lo']}-{win['hi']} ({win['how']})")
|
||||
res = stretch.stretch_session(session, out, cmap, win["lo"], win["hi"],
|
||||
embed_exif=self.th_embed.get(),
|
||||
progress=progress, log=log)
|
||||
self.q.put(("done", "thermal", {"kind": "stretch", **res}))
|
||||
except Exception as e: # noqa: BLE001
|
||||
self.q.put(("error", "thermal", str(e)))
|
||||
self._spawn(work)
|
||||
|
||||
# ----- preview rendering ---------------------------------------------
|
||||
def _show_preview(self, img: Image.Image):
|
||||
maxw = max(self.preview_label.winfo_width() - 8, 600)
|
||||
if img.width > maxw:
|
||||
h = round(img.height * maxw / img.width)
|
||||
img = img.resize((maxw, h), Image.LANCZOS)
|
||||
self._preview_imgtk = ImageTk.PhotoImage(img)
|
||||
self.preview_label.config(image=self._preview_imgtk)
|
||||
|
||||
# ----- queue pump (runs on the Tk main thread) ------------------------
|
||||
def _poll_queue(self):
|
||||
try:
|
||||
while True:
|
||||
msg = self.q.get_nowait()
|
||||
kind = msg[0]
|
||||
if kind == "log":
|
||||
_, which, text = msg
|
||||
self.logs[which].insert("end", text + "\n")
|
||||
self.logs[which].see("end")
|
||||
elif kind == "progress":
|
||||
_, which, frac, m = msg
|
||||
self.bars[which]["value"] = max(0, min(1000, int(frac * 1000)))
|
||||
self.status_vars[which].set(m)
|
||||
elif kind == "preview":
|
||||
self._show_preview(msg[1])
|
||||
elif kind == "map":
|
||||
self._render_map(msg[1])
|
||||
elif kind == "maperror":
|
||||
self._set_running(False)
|
||||
self.map_status.set("Error.")
|
||||
messagebox.showerror("FireMapper - map", msg[1])
|
||||
elif kind == "done":
|
||||
self._on_done(msg[1], msg[2])
|
||||
elif kind == "error":
|
||||
_, which, m = msg
|
||||
self._set_running(False)
|
||||
self.status_vars[which].set("Error.")
|
||||
messagebox.showerror("FireMapper - error", m)
|
||||
except queue.Empty:
|
||||
pass
|
||||
self.after(100, self._poll_queue)
|
||||
|
||||
def _on_done(self, which, res):
|
||||
self._set_running(False)
|
||||
if which == "thermal" and res.get("kind") == "preview":
|
||||
self.status_vars["thermal"].set(
|
||||
f"Preview ready - window {res['lo']}-{res['hi']} ({res['how']}).")
|
||||
return
|
||||
self.bars[which]["value"] = 1000
|
||||
self.status_vars[which].set("Done.")
|
||||
if which == "embed":
|
||||
out = Path(res["out_root"])
|
||||
text = f"Tagged {res['updated']} of {res['pairs']} images.\n\nOutput:\n{out}"
|
||||
else:
|
||||
out = Path(res["out_dir"])
|
||||
text = f"Wrote {res['frames']} thermal frames.\n\nOutput:\n{out}"
|
||||
if messagebox.askyesno("FireMapper - done", text + "\n\nOpen the output folder?"):
|
||||
open_in_explorer(out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
FireMapperGUI().mainloop()
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>FireMapper — Session Post-Processing — User Manual</title>
|
||||
<style>
|
||||
:root{
|
||||
--fg:#1c2230; --muted:#5b6473; --bg:#f6f7f9; --card:#ffffff; --line:#e2e6ec;
|
||||
--accent:#1565c0; --accent2:#e65100; --code:#0f172a; --codebg:#f0f2f5;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);
|
||||
font:16px/1.6 "Segoe UI",system-ui,Arial,sans-serif}
|
||||
header{background:linear-gradient(135deg,#1565c0,#e65100);color:#fff;padding:34px 24px}
|
||||
header .wrap{max-width:900px;margin:0 auto}
|
||||
header h1{margin:0 0 6px;font-size:28px}
|
||||
header p{margin:0;opacity:.92}
|
||||
main{max-width:900px;margin:0 auto;padding:24px}
|
||||
nav{background:var(--card);border:1px solid var(--line);border-radius:10px;
|
||||
padding:14px 18px;margin:22px 0}
|
||||
nav b{display:block;color:var(--muted);font-size:13px;text-transform:uppercase;
|
||||
letter-spacing:.04em;margin-bottom:6px}
|
||||
nav a{color:var(--accent);text-decoration:none;margin-right:16px;white-space:nowrap}
|
||||
nav a:hover{text-decoration:underline}
|
||||
section{background:var(--card);border:1px solid var(--line);border-radius:12px;
|
||||
padding:20px 24px;margin:18px 0}
|
||||
h2{margin:.2em 0 .6em;font-size:22px;border-bottom:2px solid var(--line);padding-bottom:8px}
|
||||
h3{margin:1.2em 0 .4em;font-size:17px;color:var(--accent)}
|
||||
code,kbd{background:var(--codebg);border-radius:4px;padding:1px 6px;font-family:Consolas,monospace;font-size:.92em}
|
||||
pre{background:var(--code);color:#e6edf3;border-radius:8px;padding:14px 16px;overflow:auto;font-size:13px}
|
||||
pre code{background:none;color:inherit;padding:0}
|
||||
ol,ul{padding-left:22px}
|
||||
li{margin:4px 0}
|
||||
table{border-collapse:collapse;width:100%;margin:10px 0;font-size:14.5px}
|
||||
th,td{border:1px solid var(--line);padding:8px 10px;text-align:left;vertical-align:top}
|
||||
th{background:var(--codebg)}
|
||||
.pill{display:inline-block;border-radius:999px;padding:1px 10px;font-size:13px;font-weight:600;color:#fff}
|
||||
.blue{background:var(--accent)} .orange{background:var(--accent2)}
|
||||
.note{border-left:4px solid var(--accent);background:#eef4fc;padding:10px 14px;border-radius:6px;margin:12px 0}
|
||||
.warn{border-left:4px solid var(--accent2);background:#fdf0e6;padding:10px 14px;border-radius:6px;margin:12px 0}
|
||||
.muted{color:var(--muted)}
|
||||
footer{max-width:900px;margin:0 auto;padding:10px 24px 40px;color:var(--muted);font-size:13px}
|
||||
.step{counter-reset:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><div class="wrap">
|
||||
<h1>FireMapper — Session Post-Processing</h1>
|
||||
<p>User manual for turning a raw capture session into mapping-ready imagery.</p>
|
||||
</div></header>
|
||||
|
||||
<main>
|
||||
<p>FireMapper records, for every photo, a small <code>.json</code> file next to it
|
||||
containing GPS position, platform/IMU attitude, lens and timing. These tools read those
|
||||
files and:</p>
|
||||
<ul>
|
||||
<li><b>Embed</b> the data into the photos' EXIF/XMP so mapping software can place each
|
||||
image on the map;</li>
|
||||
<li><b>Stretch</b> the dark 16-bit thermal frames into easy-to-view colour images;</li>
|
||||
<li><b>Map</b> the trigger points and image footprints on OpenStreetMap.</li>
|
||||
</ul>
|
||||
<p class="note"><b>Your originals are never changed.</b> Every step writes results into a
|
||||
new folder.</p>
|
||||
|
||||
<nav>
|
||||
<b>Contents</b>
|
||||
<a href="#start">Getting started</a>
|
||||
<a href="#embed">1 · Embed</a>
|
||||
<a href="#thermal">2 · Thermal</a>
|
||||
<a href="#map">3 · Map</a>
|
||||
<a href="#cli">Command line</a>
|
||||
<a href="#trouble">Troubleshooting</a>
|
||||
</nav>
|
||||
|
||||
<section id="start">
|
||||
<h2>Getting started</h2>
|
||||
<ol>
|
||||
<li>Make sure Python 3 is installed, plus the packages:
|
||||
<pre><code>pip install Pillow numpy tkintermapview requests</code></pre></li>
|
||||
<li>Put the four scripts (<code>firemapper_gui.py</code>, <code>embed_metadata.py</code>,
|
||||
<code>stretch_thermal.py</code>, <code>session_map.py</code>) in one folder.
|
||||
Placing them next to your session folders (or next to a parent folder that groups
|
||||
several flight strips) lets the program find your data automatically.</li>
|
||||
<li>Start the program:
|
||||
<pre><code>python firemapper_gui.py</code></pre>
|
||||
A window opens with three tabs. Pick a tab, read the on-screen description, fill in
|
||||
the options, and click the button.</li>
|
||||
</ol>
|
||||
<p class="muted">The program finds session folders automatically — any folder that
|
||||
contains a <code>thermal/</code>, a <code>cam…/</code> folder or a
|
||||
<code>manifest.json</code> counts as a session, and it also looks one level inside
|
||||
grouping folders (e.g. <code>Streifen/</code>).</p>
|
||||
</section>
|
||||
|
||||
<section id="embed">
|
||||
<h2><span class="pill blue">Tab 1</span> Embed GPS & Metadata</h2>
|
||||
<p>Writes each photo's JSON data into a tagged <b>copy</b> of the image: GPS position,
|
||||
true camera pointing direction, capture time (UTC), lens & exposure, and the full
|
||||
JSON in the comment field. The copies drop straight into Pix4D, Metashape, QGIS, etc.</p>
|
||||
<h3>How to use</h3>
|
||||
<ol>
|
||||
<li><b>Session folder</b> — auto-filled with the newest session; or click
|
||||
<kbd>Browse…</kbd>.</li>
|
||||
<li><b>Output folder</b> — leave blank to write to <code><session>_exif</code>
|
||||
next to the session.</li>
|
||||
<li><b>GPS source</b> — <i>Fused INS (position)</i> is the most accurate (recommended);
|
||||
<i>Raw GNSS (gps)</i> uses the bare satellite fix. Thermal frames always use raw GNSS.</li>
|
||||
<li>Click <b>Embed metadata</b>. Progress and a log appear; when done you can open the
|
||||
output folder.</li>
|
||||
</ol>
|
||||
<p class="muted">Requires <b>exiftool</b>. It is found automatically, and installed for
|
||||
you the first time if missing (Windows). Leave the “exiftool path” field blank.</p>
|
||||
</section>
|
||||
|
||||
<section id="thermal">
|
||||
<h2><span class="pill orange">Tab 2</span> Thermal Stretch</h2>
|
||||
<p>The thermal camera records 16-bit radiometric frames whose values fill only a tiny
|
||||
part of the range, so the raw files look almost black and flicker between frames. This
|
||||
finds <b>one brightness window shared by the whole session</b> and stretches every frame
|
||||
into it, saving easy-to-view 8-bit images. Because all frames use the same window, hot
|
||||
and cold areas stay consistent across the flight.</p>
|
||||
<div class="note">Values are radiometric <b>signal</b> (proportional to temperature),
|
||||
not calibrated degrees.</div>
|
||||
<h3>Options</h3>
|
||||
<table>
|
||||
<tr><th>Palette</th><td><b>inferno</b> / <b>ironbow</b> — heat colour maps (dark = cool,
|
||||
bright = hot); <b>gray</b> — plain grayscale.</td></tr>
|
||||
<tr><th>Window low % / high %</th><td>The brightness window, as percentiles pooled over
|
||||
the whole session (default 1–99 %). Lower the high % / raise the low % for more
|
||||
contrast; tick <b>Absolute min/max</b> to use the true extremes.</td></tr>
|
||||
<tr><th>Embed metadata</th><td>When ticked (default), the stretched PNGs also get GPS
|
||||
and orientation EXIF written in, so they are self-contained.</td></tr>
|
||||
</table>
|
||||
<h3>How to use</h3>
|
||||
<ol>
|
||||
<li>Pick the <b>session folder</b> and <b>palette</b>.</li>
|
||||
<li>Click <b>Preview palettes</b> to compare gray / inferno / ironbow on a sample
|
||||
frame before committing.</li>
|
||||
<li>Click <b>Stretch all frames</b>. Output goes to
|
||||
<code><session>/thermal_stretched</code> unless you set an output folder.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section id="map">
|
||||
<h2><span class="pill blue">Tab 3</span> Map</h2>
|
||||
<p>Plots each photo as a <b>trigger point</b> (its GPS position) and, optionally, an
|
||||
<b>oblique footprint</b> — the patch of ground the photo covers — on OpenStreetMap.
|
||||
Cameras are <span class="pill blue">blue</span>, thermal is
|
||||
<span class="pill orange">orange</span>.</p>
|
||||
<h3>How to use</h3>
|
||||
<ol>
|
||||
<li><b>Tick the sessions</b> to plot (use <kbd>All</kbd>/<kbd>None</kbd>; <kbd>Refresh</kbd>
|
||||
rescans the folder). Several flight strips can be shown together.</li>
|
||||
<li>Set the <b>options</b> (see below), then click <b>Show on map</b>. The map zooms to
|
||||
fit the data.</li>
|
||||
</ol>
|
||||
<h3>Options</h3>
|
||||
<table>
|
||||
<tr><th>Ground elev (m)</th><td>Terrain height used to project the footprints (flat-ground
|
||||
assumption). Default 110 m — set this to your site's elevation.</td></tr>
|
||||
<tr><th>Thermal FOV H/V</th><td>Field of view of the thermal camera, needed for its
|
||||
footprints. Pre-filled for the FLIR A65 25° lens (25° × 20°).</td></tr>
|
||||
<tr><th>Thermal off-nadir</th><td>Mounting tilt of the thermal camera (0 = straight down).</td></tr>
|
||||
<tr><th>Plot every Nth</th><td><i>auto</i> thins very large surveys so the map stays
|
||||
responsive; set a number to override.</td></tr>
|
||||
<tr><th>Trigger points / Footprints / Cameras / Thermal</th><td>Toggle what is drawn.</td></tr>
|
||||
</table>
|
||||
<div class="note"><b>Needs an internet connection</b> for the map tiles.</div>
|
||||
<h3>How footprints are computed</h3>
|
||||
<p>The footprint is projected from the true camera pointing direction: the aircraft
|
||||
attitude, plus the platform's left/right swivel (<code>platform_angle_deg</code>), plus
|
||||
each camera's fixed mounting — thermal looks straight down (mounted vertical to the
|
||||
flight line); the RGB cameras look forward-and-down by their off-nadir angle
|
||||
(cam25 = 25°, cam45 = 45°) and are mounted landscape across the flight line.</p>
|
||||
<div class="warn">The footprints assume flat ground at the elevation you set. If the whole
|
||||
swath appears on the <b>wrong side</b> of the flight line compared to reality, ask your
|
||||
developer to flip the <code>SCAN_SIGN</code> setting in <code>session_map.py</code>.</div>
|
||||
</section>
|
||||
|
||||
<section id="cli">
|
||||
<h2>Command line (optional)</h2>
|
||||
<p>The embed and stretch steps also run without the GUI:</p>
|
||||
<pre><code>python embed_metadata.py [SESSION] [--out DIR] [--gps-source position|gps] [--dry-run]
|
||||
|
||||
python stretch_thermal.py [SESSION] [--colormap inferno|ironbow|gray]
|
||||
[--lo-pct 1 --hi-pct 99 | --absolute | --lo N --hi N]
|
||||
[--out DIR] [--no-embed-exif] [--sample]</code></pre>
|
||||
<p class="muted">With no <code>SESSION</code> they use the newest session found nearby.</p>
|
||||
</section>
|
||||
|
||||
<section id="trouble">
|
||||
<h2>Troubleshooting</h2>
|
||||
<table>
|
||||
<tr><th>“exiftool could not be found”</th>
|
||||
<td>Install it once: <code>winget install OliverBetz.ExifTool</code> (or
|
||||
<code>choco install exiftool</code>), then reopen the program.</td></tr>
|
||||
<tr><th>Map is blank / tiles don't load</th>
|
||||
<td>The map needs internet access for OpenStreetMap tiles.</td></tr>
|
||||
<tr><th>No sessions in the Map list</th>
|
||||
<td>Click <kbd>Refresh</kbd>. Make sure the scripts are in (or next to) the folder
|
||||
that holds your session folders.</td></tr>
|
||||
<tr><th>Footprints / headings look mirrored</th>
|
||||
<td>Flip <code>SCAN_SIGN</code> at the top of <code>session_map.py</code>.</td></tr>
|
||||
<tr><th>Thermal footprints missing</th>
|
||||
<td>Fill in the <b>Thermal FOV</b> fields on the Map tab.</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>FireMapper — Session Post-Processing · GGS Speyer. Originals are never modified;
|
||||
all results are written to new folders.</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
session_map.py - geometry for plotting FireMapper sessions on a map.
|
||||
|
||||
Provides trigger points (GPS position of each frame) and oblique image
|
||||
footprints projected onto flat ground.
|
||||
|
||||
Camera model (confirmed with the operator):
|
||||
* Cameras sit on a platform that rolls about the aircraft longitudinal (forward)
|
||||
axis by platform_angle_deg, sweeping every camera cross-track.
|
||||
* Mounting on the platform: thermal looks straight down (nadir); the RGB cameras
|
||||
are tilted toward the forward direction by their cam<NN> off-nadir angle
|
||||
(cam25 -> 25 deg from nadir, cam45 -> 45 deg).
|
||||
* The full chain is: aircraft attitude (yaw/pitch/roll, body->NED) @ platform roll
|
||||
@ camera mounting. The image corners are then ray-cast onto flat ground at a
|
||||
given elevation (footprints); the optical axis gives the true EXIF orientation.
|
||||
|
||||
If footprints / headings come out mirrored, flip SCAN_SIGN below.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
M_PER_DEG_LAT = 111320.0
|
||||
|
||||
SCAN_SIGN = +1.0 # sign of the platform_angle rotation about the aircraft roll (forward) axis
|
||||
|
||||
# Camera mounting on the rotating platform (operator spec):
|
||||
# thermal (FLIR A65): optical axis straight DOWN (nadir); mounted VERTICAL/portrait,
|
||||
# so sensor width runs ALONG the flight track.
|
||||
# RGB cameras: optical axis tilted toward the FORWARD direction by the cam<NN>
|
||||
# off-nadir angle (cam25 = 25 deg from straight-down); mounted
|
||||
# LANDSCAPE, so sensor width runs ACROSS the flight track.
|
||||
# The platform then rolls about the forward axis (platform_angle_deg), sweeping every
|
||||
# camera cross-track, and finally the aircraft attitude (yaw/pitch/roll) is applied.
|
||||
|
||||
# FLIR A65, 640x512, 25 deg lens -> ~25 x 20 deg field of view
|
||||
THERMAL_FOV_DEFAULT = (25.0, 20.0)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# rotations
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _rot_x(a: float) -> np.ndarray:
|
||||
c, s = math.cos(a), math.sin(a)
|
||||
return np.array([[1, 0, 0], [0, c, -s], [0, s, c]])
|
||||
|
||||
|
||||
def _body_to_ned(yaw: float, pitch: float, roll: float) -> np.ndarray:
|
||||
"""Aerospace 3-2-1 (yaw, pitch, roll) body->NED rotation; angles in radians."""
|
||||
cy, sy = math.cos(yaw), math.sin(yaw)
|
||||
cp, sp = math.cos(pitch), math.sin(pitch)
|
||||
cr, sr = math.cos(roll), math.sin(roll)
|
||||
return np.array([
|
||||
[cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr],
|
||||
[sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr],
|
||||
[-sp, cp * sr, cp * cr],
|
||||
])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# sessions & frames
|
||||
# --------------------------------------------------------------------------- #
|
||||
def is_session_dir(p: Path) -> bool:
|
||||
"""A capture session = a folder holding thermal/ or cam*/ or a manifest.json.
|
||||
Name-independent (works whether it's 'session_...' or anything else), and
|
||||
excludes our own derived outputs."""
|
||||
if not p.is_dir() or p.name.endswith(("_exif", "_stretched")) or p.name == "__pycache__":
|
||||
return False
|
||||
return ((p / "manifest.json").exists() or (p / "thermal").is_dir()
|
||||
or any(p.glob("cam*")))
|
||||
|
||||
|
||||
def list_sessions(root) -> list[Path]:
|
||||
"""Session folders directly under root, or one level deeper (e.g. grouped in a
|
||||
parent folder like 'Streifen/'). Sorted by name."""
|
||||
root = Path(root)
|
||||
found: list[Path] = []
|
||||
for p in sorted(root.iterdir()):
|
||||
if not p.is_dir():
|
||||
continue
|
||||
if is_session_dir(p):
|
||||
found.append(p)
|
||||
elif not p.name.endswith(("_exif", "_stretched")) and p.name != "__pycache__":
|
||||
found += [q for q in sorted(p.iterdir()) if is_session_dir(q)]
|
||||
return found
|
||||
|
||||
|
||||
def off_nadir_from_name(folder: str) -> float:
|
||||
digits = "".join(ch for ch in folder if ch.isdigit())
|
||||
return float(digits) if digits else 0.0
|
||||
|
||||
|
||||
def _read_frame(json_path: Path, kind: str, off_nadir: float) -> dict | None:
|
||||
try:
|
||||
j = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
pos = (j.get("gps") if kind == "thermal" else (j.get("position") or j.get("gps"))) or {}
|
||||
if pos.get("lat") is None or pos.get("lon") is None:
|
||||
return None
|
||||
imu = j.get("imu") or {}
|
||||
plat = j.get("platform") or {}
|
||||
img = j.get("image") or {}
|
||||
lens = j.get("lens") or {}
|
||||
return {
|
||||
"kind": kind, "off_nadir": off_nadir, "session": json_path.parent.parent.name,
|
||||
"lat": pos["lat"], "lon": pos["lon"], "alt": pos.get("alt"),
|
||||
"yaw": imu.get("yaw"), "pitch": imu.get("pitch"), "roll": imu.get("roll"),
|
||||
"platform_angle": plat.get("platform_angle_deg"),
|
||||
"width": img.get("width"), "height": img.get("height"),
|
||||
"focal_mm": lens.get("focal_length_mm"), "pixel_um": lens.get("pixel_size_um"),
|
||||
}
|
||||
|
||||
|
||||
def iter_session_frames(session, include_cam=True, include_thermal=True,
|
||||
thermal_off_nadir=0.0) -> list[dict]:
|
||||
"""All plottable frames in a session (those with GPS), tagged by kind."""
|
||||
session = Path(session)
|
||||
frames: list[dict] = []
|
||||
for sub in sorted(p for p in session.iterdir() if p.is_dir()):
|
||||
if sub.name.startswith("cam") and include_cam:
|
||||
off = off_nadir_from_name(sub.name)
|
||||
for jf in sorted(sub.glob("*.json")):
|
||||
fr = _read_frame(jf, "cam", off)
|
||||
if fr:
|
||||
frames.append(fr)
|
||||
elif sub.name == "thermal" and include_thermal:
|
||||
for jf in sorted(sub.glob("*.json")):
|
||||
fr = _read_frame(jf, "thermal", thermal_off_nadir)
|
||||
if fr:
|
||||
frames.append(fr)
|
||||
return frames
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# footprint projection
|
||||
# --------------------------------------------------------------------------- #
|
||||
def lens_fov(focal_mm, pixel_um, width, height):
|
||||
"""(hfov, vfov) in radians from focal length / pixel pitch / pixel counts."""
|
||||
if not (focal_mm and pixel_um and width and height):
|
||||
return None
|
||||
sw = pixel_um / 1000.0 * width # mm
|
||||
sh = pixel_um / 1000.0 * height
|
||||
return (2 * math.atan((sw / 2) / focal_mm), 2 * math.atan((sh / 2) / focal_mm))
|
||||
|
||||
|
||||
def camera_axes_ned(frame: dict, sensor: bool = True):
|
||||
"""
|
||||
Camera frame as NED unit vectors (image_right, image_down, optical_axis) for the
|
||||
TRUE camera pointing, built as:
|
||||
aircraft attitude (body->NED) @ platform roll about forward axis @ mounting
|
||||
|
||||
The optical axis is identical either way. With sensor=True the right/down axes
|
||||
follow the real sensor mounting (RGB width ALONG track = portrait) - used for
|
||||
footprints. With sensor=False a canonical landscape frame is used (right =
|
||||
cross-track) so the orientation 'roll' is the camera bank, not the 90 deg sensor
|
||||
rotation.
|
||||
"""
|
||||
R = _body_to_ned(math.radians(frame["yaw"]), math.radians(frame["pitch"]),
|
||||
math.radians(frame["roll"])) \
|
||||
@ _rot_x(math.radians(SCAN_SIGN * (frame["platform_angle"] or 0.0)))
|
||||
if frame["kind"] == "thermal":
|
||||
# nadir, VERTICAL/portrait mount -> sensor width runs ALONG track
|
||||
optical = np.array([0.0, 0.0, 1.0])
|
||||
if sensor:
|
||||
right = np.array([-1.0, 0.0, 0.0]) # aft (along-track) = width
|
||||
down_im = np.array([0.0, -1.0, 0.0]) # port (cross-track) = height
|
||||
else: # canonical landscape (for bank/roll only)
|
||||
right = np.array([0.0, 1.0, 0.0])
|
||||
down_im = np.array([-1.0, 0.0, 0.0])
|
||||
else:
|
||||
# RGB tilted forward by off-nadir; LANDSCAPE mount -> width ACROSS track.
|
||||
# (landscape == canonical here, so sensor and orientation frames coincide)
|
||||
a = math.radians(frame["off_nadir"])
|
||||
optical = np.array([math.sin(a), 0.0, math.cos(a)]) # forward & down
|
||||
right = np.array([0.0, 1.0, 0.0]) # cross-track = width
|
||||
down_im = np.array([-math.cos(a), 0.0, math.sin(a)]) # along-track = height
|
||||
return R @ right, R @ down_im, R @ optical
|
||||
|
||||
|
||||
def camera_orientation(frame: dict):
|
||||
"""
|
||||
(heading_deg, pitch_deg, roll_deg) of the true camera optical axis, or None.
|
||||
heading: compass azimuth of the optical axis (0-360, from true north)
|
||||
pitch : elevation of the optical axis (0 = horizon, -90 = straight down)
|
||||
roll : bank about the optical axis (0 = image bottom points to ground-down)
|
||||
"""
|
||||
if any(frame.get(k) is None for k in ("yaw", "pitch", "roll")):
|
||||
return None
|
||||
right, down_im, optical = camera_axes_ned(frame, sensor=False) # canonical for bank
|
||||
if math.hypot(optical[0], optical[1]) < 1e-6: # ~nadir: heading from image-up
|
||||
ref = -down_im
|
||||
heading = math.degrees(math.atan2(ref[1], ref[0])) % 360.0
|
||||
else:
|
||||
heading = math.degrees(math.atan2(optical[1], optical[0])) % 360.0
|
||||
pitch = math.degrees(math.asin(max(-1.0, min(1.0, -float(optical[2])))))
|
||||
wd = np.array([0.0, 0.0, 1.0]) # world down
|
||||
roll = math.degrees(math.atan2(float(wd @ right), float(wd @ down_im)))
|
||||
return heading, pitch, roll
|
||||
|
||||
|
||||
def footprint(frame: dict, ground_elev: float,
|
||||
thermal_fov_deg: tuple[float, float] | None = None) -> list[tuple[float, float]] | None:
|
||||
"""Four (lat, lon) ground corners of the image, or None if not projectable."""
|
||||
if any(frame[k] is None for k in ("alt", "yaw", "pitch", "roll")):
|
||||
return None
|
||||
H = frame["alt"] - ground_elev
|
||||
if H <= 1.0:
|
||||
return None
|
||||
|
||||
if frame["kind"] == "thermal":
|
||||
if not thermal_fov_deg:
|
||||
return None
|
||||
hfov, vfov = math.radians(thermal_fov_deg[0]), math.radians(thermal_fov_deg[1])
|
||||
else:
|
||||
fov = lens_fov(frame["focal_mm"], frame["pixel_um"], frame["width"], frame["height"])
|
||||
if not fov:
|
||||
return None
|
||||
hfov, vfov = fov
|
||||
|
||||
right, down_im, optical = camera_axes_ned(frame) # width axis, height axis, boresight
|
||||
th, tv = math.tan(hfov / 2), math.tan(vfov / 2)
|
||||
coslat = math.cos(math.radians(frame["lat"]))
|
||||
corners = []
|
||||
for sx, sy in ((-1, -1), (1, -1), (1, 1), (-1, 1)):
|
||||
d = optical + sx * th * right + sy * tv * down_im # ray toward an image corner
|
||||
if d[2] <= 1e-3: # ray not pointing at the ground
|
||||
return None
|
||||
t = H / d[2]
|
||||
dlat = (t * d[0]) / M_PER_DEG_LAT
|
||||
dlon = (t * d[1]) / (M_PER_DEG_LAT * coslat)
|
||||
corners.append((frame["lat"] + dlat, frame["lon"] + dlon))
|
||||
return corners
|
||||
|
||||
|
||||
def bounds(frames: list[dict]):
|
||||
"""(min_lat, min_lon, max_lat, max_lon) over frame trigger points."""
|
||||
lats = [f["lat"] for f in frames]
|
||||
lons = [f["lon"] for f in frames]
|
||||
return min(lats), min(lons), max(lats), max(lons)
|
||||
|
|
@ -0,0 +1,257 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
stretch_thermal.py - Contrast-stretch a session's radiometric thermal frames
|
||||
to one common signal window, for consistent, comparable visualisation.
|
||||
|
||||
The thermal PNGs are 16-bit radiometric (raw signal counts, monotonic with
|
||||
temperature but not calibrated to degC). Each frame on its own spans only a
|
||||
narrow part of the 16-bit range and uses a different sub-range, so viewing the
|
||||
raw files is near-black and frame-to-frame brightness flickers.
|
||||
|
||||
This computes ONE window [lo, hi] pooled across every frame in the session
|
||||
(default: 1st-99th percentile, robust to outlier pixels), then maps that window
|
||||
to 8-bit and writes the result with a chosen palette. Because all frames share
|
||||
the same window, brightness/colour is directly comparable across the session.
|
||||
|
||||
Usage:
|
||||
python stretch_thermal.py [SESSION_DIR] [options]
|
||||
|
||||
--colormap {inferno,ironbow,gray} palette (default: inferno)
|
||||
--out DIR output folder (default: <session>/thermal_stretched)
|
||||
--lo-pct P low percentile (default: 1.0)
|
||||
--hi-pct P high percentile (default: 99.0)
|
||||
--lo N / --hi N hard-set the signal window, overriding percentiles
|
||||
--absolute use the true session min/max instead of percentiles
|
||||
--sample write one side-by-side comparison of all palettes and exit
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import embed_metadata as embed # noqa: E402 (reuse exiftool + tag building)
|
||||
import session_map as smap # noqa: E402 (session discovery)
|
||||
|
||||
import numpy as np # noqa: E402
|
||||
from PIL import Image # noqa: E402
|
||||
|
||||
U16_MAX = 65536
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# palettes - built-in 256-entry LUTs, no matplotlib dependency
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _lut_from_anchors(anchors) -> np.ndarray:
|
||||
"""Build a 256x3 uint8 LUT by linear interpolation between (pos, r,g,b) anchors."""
|
||||
pos = np.array([a[0] for a in anchors])
|
||||
rgb = np.array([a[1:] for a in anchors], dtype=float)
|
||||
x = np.linspace(0.0, 1.0, 256)
|
||||
lut = np.stack([np.interp(x, pos, rgb[:, c]) for c in range(3)], axis=1)
|
||||
return np.clip(lut, 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
INFERNO = _lut_from_anchors([
|
||||
(0.00, 0, 0, 4), (0.13, 31, 12, 72), (0.25, 85, 15, 109), (0.38, 136, 34, 106),
|
||||
(0.50, 186, 54, 85), (0.63, 227, 89, 51), (0.75, 249, 140, 10),
|
||||
(0.88, 249, 201, 50), (1.00, 252, 255, 164),
|
||||
])
|
||||
IRONBOW = _lut_from_anchors([
|
||||
(0.00, 0, 0, 0), (0.12, 0, 0, 70), (0.25, 60, 0, 130), (0.40, 160, 0, 120),
|
||||
(0.55, 220, 40, 60), (0.70, 250, 110, 0), (0.85, 255, 200, 40), (1.00, 255, 255, 255),
|
||||
])
|
||||
PALETTES = {"inferno": INFERNO, "ironbow": IRONBOW}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
def find_session(arg: str | None) -> Path:
|
||||
if arg:
|
||||
return Path(arg).resolve()
|
||||
sessions = smap.list_sessions(Path(__file__).resolve().parent)
|
||||
if not sessions:
|
||||
sys.exit("No session folder found nearby; pass SESSION_DIR explicitly.")
|
||||
return max(sessions, key=lambda p: p.stat().st_mtime)
|
||||
|
||||
|
||||
def list_frames(session: Path) -> list[Path]:
|
||||
"""Sorted thermal PNG frames in a session (raises if none)."""
|
||||
thermal_dir = Path(session) / "thermal"
|
||||
if not thermal_dir.is_dir():
|
||||
raise RuntimeError(f"No thermal/ folder in {session}")
|
||||
pngs = sorted(thermal_dir.glob("*.png"))
|
||||
if not pngs:
|
||||
raise RuntimeError(f"No PNG frames in {thermal_dir}")
|
||||
return pngs
|
||||
|
||||
|
||||
def session_histogram(pngs, progress=None) -> np.ndarray:
|
||||
"""Pooled value histogram over all frames (exact for integer signal data)."""
|
||||
hist = np.zeros(U16_MAX, dtype=np.int64)
|
||||
n = len(pngs)
|
||||
for i, p in enumerate(pngs, 1):
|
||||
arr = np.asarray(Image.open(p)).astype(np.uint16, copy=False)
|
||||
hist += np.bincount(arr.ravel(), minlength=U16_MAX)
|
||||
if progress:
|
||||
progress(0.5 * i / n, f"Analysing frame {i}/{n}")
|
||||
return hist
|
||||
|
||||
|
||||
def percentile_from_hist(hist: np.ndarray, pct: float) -> int:
|
||||
total = hist.sum()
|
||||
cdf = np.cumsum(hist)
|
||||
return int(np.searchsorted(cdf, total * pct / 100.0))
|
||||
|
||||
|
||||
def compute_window(pngs, lo_pct=1.0, hi_pct=99.0, lo=None, hi=None,
|
||||
absolute=False, progress=None) -> dict:
|
||||
"""Resolve the common [lo, hi] signal window for a set of frames."""
|
||||
hist = session_histogram(pngs, progress=progress)
|
||||
nz = np.nonzero(hist)[0]
|
||||
abs_lo, abs_hi = int(nz[0]), int(nz[-1])
|
||||
if lo is not None or hi is not None:
|
||||
rlo = lo if lo is not None else abs_lo
|
||||
rhi = hi if hi is not None else abs_hi
|
||||
how = "manual"
|
||||
elif absolute:
|
||||
rlo, rhi, how = abs_lo, abs_hi, "absolute min/max"
|
||||
else:
|
||||
rlo = percentile_from_hist(hist, lo_pct)
|
||||
rhi = percentile_from_hist(hist, hi_pct)
|
||||
how = f"{lo_pct:g}-{hi_pct:g} percentile"
|
||||
if rhi <= rlo:
|
||||
rhi = rlo + 1
|
||||
return {"lo": rlo, "hi": rhi, "abs_lo": abs_lo, "abs_hi": abs_hi, "how": how}
|
||||
|
||||
|
||||
def render(arr: np.ndarray, lo: int, hi: int, colormap: str) -> Image.Image:
|
||||
"""Stretch one 16-bit frame through [lo, hi] -> 8-bit, apply palette."""
|
||||
span = max(hi - lo, 1)
|
||||
norm = np.clip((arr.astype(np.float32) - lo) / span, 0.0, 1.0)
|
||||
idx = (norm * 255.0 + 0.5).astype(np.uint8)
|
||||
if colormap == "gray":
|
||||
return Image.fromarray(idx, mode="L")
|
||||
return Image.fromarray(PALETTES[colormap][idx], mode="RGB")
|
||||
|
||||
|
||||
def render_frame(path, lo: int, hi: int, colormap: str) -> Image.Image:
|
||||
"""Open one PNG and render it with the given window/palette (for previews)."""
|
||||
return render(np.asarray(Image.open(path)).astype(np.uint16), lo, hi, colormap)
|
||||
|
||||
|
||||
def make_sample_image(pngs, lo: int, hi: int, gap: int = 8) -> Image.Image:
|
||||
"""Side-by-side comparison of all palettes for the middle frame."""
|
||||
mid = np.asarray(Image.open(pngs[len(pngs) // 2])).astype(np.uint16)
|
||||
h, w = mid.shape
|
||||
tiles = [render(mid, lo, hi, c).convert("RGB") for c in ("gray", "inferno", "ironbow")]
|
||||
canvas = Image.new("RGB", (w * 3 + gap * 2, h), (255, 255, 255))
|
||||
for i, t in enumerate(tiles):
|
||||
canvas.paste(t, (i * (w + gap), 0))
|
||||
return canvas
|
||||
|
||||
|
||||
def _embed_exif(pngs, out_dir: Path, exiftool=None, log=None):
|
||||
"""Write each frame's JSON sidecar into the matching stretched PNG (GPS, true
|
||||
camera orientation, time, full JSON) so the 8-bit outputs are self-contained."""
|
||||
log = log or (lambda *_: None)
|
||||
try:
|
||||
et = exiftool or embed.ensure_exiftool(None, Path(__file__).resolve().parent / "tools")
|
||||
except BaseException as e: # noqa: BLE001 (ensure_exiftool may sys.exit)
|
||||
log(f" ! skipping EXIF embed - exiftool unavailable ({e})")
|
||||
return
|
||||
arg_lines = []
|
||||
for p in pngs:
|
||||
sidecar = p.with_suffix(".json")
|
||||
if not sidecar.exists():
|
||||
continue
|
||||
try:
|
||||
data = json.loads(sidecar.read_text(encoding="utf-8"))
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
dst = out_dir / (p.stem + ".png")
|
||||
arg_lines += ["-overwrite_original", *embed.build_tags(data, {}, None, "gps"),
|
||||
str(dst), "-execute"]
|
||||
if not arg_lines:
|
||||
return
|
||||
argfile = out_dir / "_exif_args.txt"
|
||||
argfile.write_text("\n".join(arg_lines) + "\n", encoding="utf-8")
|
||||
log("Embedding GPS / orientation EXIF into the stretched frames ...")
|
||||
subprocess.run([et, "-m", "-charset", "UTF8", "-charset", "filename=UTF8",
|
||||
"-@", str(argfile)], capture_output=True, text=True)
|
||||
argfile.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def stretch_session(session, out_dir=None, colormap="inferno", lo=None, hi=None,
|
||||
embed_exif=True, exiftool=None, progress=None, log=None) -> dict:
|
||||
"""
|
||||
Stretch every thermal frame of a session through the [lo, hi] window and
|
||||
write 8-bit images with the chosen palette. lo/hi must already be resolved
|
||||
(see compute_window). If embed_exif, the JSON sidecar metadata (GPS + true
|
||||
camera orientation) is written into each output PNG. Callbacks optional.
|
||||
"""
|
||||
session = Path(session)
|
||||
log = log or (lambda *_: None)
|
||||
progress = progress or (lambda *_: None)
|
||||
pngs = list_frames(session)
|
||||
out_dir = Path(out_dir) if out_dir else session / "thermal_stretched"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
n = len(pngs)
|
||||
log(f"Writing {n} {colormap} frames to {out_dir} ...")
|
||||
for i, p in enumerate(pngs, 1):
|
||||
render_frame(p, lo, hi, colormap).save(out_dir / (p.stem + ".png"))
|
||||
progress(0.5 + 0.45 * i / n, f"Rendering {i}/{n}")
|
||||
if embed_exif:
|
||||
_embed_exif(pngs, out_dir, exiftool=exiftool, log=log)
|
||||
progress(1.0, "Done")
|
||||
log(f"Done. {n} frames written.\nOutput: {out_dir}")
|
||||
return {"out_dir": out_dir, "frames": n}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CLI
|
||||
# --------------------------------------------------------------------------- #
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Stretch session thermal frames to a common window.")
|
||||
ap.add_argument("session", nargs="?", help="session folder (default: newest session_* here)")
|
||||
ap.add_argument("--colormap", choices=["inferno", "ironbow", "gray"], default="inferno")
|
||||
ap.add_argument("--out", help="output folder (default: <session>/thermal_stretched)")
|
||||
ap.add_argument("--lo-pct", type=float, default=1.0, help="low percentile (default 1.0)")
|
||||
ap.add_argument("--hi-pct", type=float, default=99.0, help="high percentile (default 99.0)")
|
||||
ap.add_argument("--lo", type=int, help="hard low signal value (overrides percentile)")
|
||||
ap.add_argument("--hi", type=int, help="hard high signal value (overrides percentile)")
|
||||
ap.add_argument("--absolute", action="store_true", help="use true session min/max")
|
||||
ap.add_argument("--no-embed-exif", action="store_true",
|
||||
help="do not write GPS/orientation EXIF into the stretched PNGs")
|
||||
ap.add_argument("--sample", action="store_true",
|
||||
help="write one side-by-side palette comparison and exit")
|
||||
args = ap.parse_args()
|
||||
|
||||
session = find_session(args.session)
|
||||
pngs = list_frames(session)
|
||||
win = compute_window(pngs, args.lo_pct, args.hi_pct, args.lo, args.hi, args.absolute)
|
||||
lo, hi = win["lo"], win["hi"]
|
||||
print(f"Session : {session}")
|
||||
print(f"Frames : {len(pngs)} | absolute signal range {win['abs_lo']}-{win['abs_hi']}")
|
||||
print(f"Window : {lo}-{hi} ({win['how']})")
|
||||
|
||||
if args.sample:
|
||||
out = session / "thermal_palette_sample.png"
|
||||
make_sample_image(pngs, lo, hi).save(out)
|
||||
print(f"\nSample written (left->right: gray | inferno | ironbow):\n {out}")
|
||||
return 0
|
||||
|
||||
out_dir = Path(args.out).resolve() if args.out else None
|
||||
res = stretch_session(session, out_dir, args.colormap, lo, hi,
|
||||
embed_exif=not args.no_embed_exif, log=print)
|
||||
print(f"\nWrote {res['frames']} {args.colormap} frames to:\n {res['out_dir']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Reference in New Issue