diff --git a/CLAUDE.md b/CLAUDE.md index a1abb0b..76e8e2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ whether you run from the session's parent or from a grouping folder. | 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`). | `_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. | `/thermal_stretched/` | +| `stretch_thermal.py` | Rescales all thermal frames to one session-wide brightness window → 8-bit, with a palette (inferno/ironbow/gray). Carries metadata into each output: from the JSON sidecar if present, otherwise copied from the source image's own EXIF/XMP (so stretching already-embedded PNGs keeps GPS/orientation). | `/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**. | — | diff --git a/stretch_thermal.py b/stretch_thermal.py index c4a0e32..9df73a3 100644 --- a/stretch_thermal.py +++ b/stretch_thermal.py @@ -164,33 +164,43 @@ def _embed_exif(pngs, out_dir: Path, exiftool=None, log=None): except BaseException as e: # noqa: BLE001 (ensure_exiftool may sys.exit) log(f" ! skipping EXIF embed - exiftool unavailable ({e})") return - arg_lines, dsts = [], [] + arg_lines, dsts, srcs = [], [], [] + have_sidecars = False 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") + # 1) copy any metadata already on the SOURCE image (covers stretching + # already-embedded PNGs that have no JSON sidecar next to them); + # 2) overlay/refresh from the JSON sidecar when it exists (raw session). + seg = ["-overwrite_original", "-tagsFromFile", str(p), "-EXIF:all", "-XMP:all"] + sidecar = p.with_suffix(".json") + if sidecar.exists(): + try: + data = json.loads(sidecar.read_text(encoding="utf-8")) + seg += embed.build_tags(data, {}, None, "gps") + have_sidecars = True + except Exception: # noqa: BLE001 + pass + seg += [str(dst), "-execute"] + arg_lines += seg dsts.append(dst) - arg_lines += ["-overwrite_original", *embed.build_tags(data, {}, None, "gps"), - str(dst), "-execute"] + srcs.append(p) if not arg_lines: return argfile = out_dir / "_exif_args.txt" argfile.write_text("\n".join(arg_lines) + "\n", encoding="utf-8") cmd = [et, "-m", "-charset", "UTF8", "-charset", "filename=UTF8", "-@", str(argfile)] - samples = {dsts[0], dsts[len(dsts) // 2], dsts[-1]} # spot-check first/mid/last + idx = sorted({0, len(dsts) // 2, len(dsts) - 1}) # spot-check first/mid/last - def gps_written(): - return all(subprocess.run([et, "-s", "-s", "-s", "-GPSLatitude", str(s)], - capture_output=True, text=True).stdout.strip() for s in samples) + def has_gps(path): + return bool(subprocess.run([et, "-s", "-s", "-s", "-GPSLatitude", str(path)], + capture_output=True, text=True).stdout.strip()) + + # only expect GPS in the output if there was any to embed (sidecar or source EXIF) + expect_gps = have_sidecars or any(has_gps(srcs[i]) for i in idx) log("Embedding GPS / orientation EXIF into the stretched frames ...") proc = subprocess.run(cmd, capture_output=True, text=True) - if proc.returncode == 0 and not gps_written(): + if expect_gps and proc.returncode == 0 and not all(has_gps(dsts[i]) for i in idx): log(" ! GPS not present after first pass - retrying once ...") proc = subprocess.run(cmd, capture_output=True, text=True) argfile.unlink(missing_ok=True) @@ -198,13 +208,18 @@ def _embed_exif(pngs, out_dir: Path, exiftool=None, log=None): err = (proc.stderr or "").strip() if err: log(err) - if proc.returncode != 0 or not gps_written(): + if proc.returncode != 0 or (expect_gps and not all(has_gps(dsts[i]) for i in idx)): raise RuntimeError( "Could not embed GPS/EXIF into the stretched PNGs (exiftool exit " f"{proc.returncode}). The usual cause is a FULL DISK - free up space and re-run. " "The stretched images were written; only the embedded metadata is missing." + (f"\nexiftool: {err}" if err else "")) - log(f"EXIF embedded into {len(dsts)} stretched frames.") + if not expect_gps: + log(" note: no JSON sidecars and the source images carry no GPS - the stretched " + "PNGs have no location metadata. Stretch the raw session (with .json sidecars), " + "or run Embed first.") + else: + log(f"EXIF embedded into {len(dsts)} stretched frames.") def stretch_session(session, out_dir=None, colormap="inferno", lo=None, hi=None,