Add offline mosaic EXIF tagging (stitch --write-exif, tag_mosaic_exif CLI)

- spruce.exif: tag_mosaic_jpeg_for_scan_dir, resolve_machine_label_for_scan_dir; ProcessingSoftware for tile-stitched mosaics
- spruce.settings: load_config(require_credentials=False) for config without login
- scripts/tag_mosaic_exif.py and tests; stitch script --write-exif path
This commit is contained in:
2026-04-26 20:47:23 -04:00
parent 314b68322c
commit 08a29d124a
6 changed files with 424 additions and 5 deletions
+164
View File
@@ -0,0 +1,164 @@
"""Tests for offline mosaic EXIF tagging (tag_mosaic_jpeg_for_scan_dir, resolver)."""
from __future__ import annotations
import json
import logging
import os
import shutil
import subprocess
import sys
from pathlib import Path
import piexif
import pytest
from piexif import ImageIFD
from spruce.exif import (
DEFAULT_PROCESSING_SOFTWARE_MOSAIC_FROM_TILES,
resolve_machine_label_for_scan_dir,
tag_mosaic_jpeg_for_scan_dir,
)
from spruce.paths import machine_dir_name
FIXTURES = Path(__file__).parent / "fixtures"
BLANK_JPEG = FIXTURES / "blank.jpg"
ROOT = Path(__file__).resolve().parents[1]
TAG_SCRIPT = ROOT / "scripts" / "tag_mosaic_exif.py"
def _scan_tree(tmp_path: Path, *, machine_label: str, scan_id: int) -> Path:
slug = machine_dir_name({"label": machine_label})
scan_dir = tmp_path / "archives" / slug / "2024-01-01" / str(scan_id)
scan_dir.mkdir(parents=True)
meta = {
"scan_id": scan_id,
"name": "Test scan",
"scan_time": "2024-06-28 11:00:00",
"user": "Tester",
"nx": 2,
"ny": 2,
"dx": 1.0,
"dy": 1.0,
"end_x": 10.0,
"end_y": 10.0,
}
(scan_dir / "metadata.json").write_text(json.dumps(meta), encoding="utf-8")
return scan_dir
def test_resolve_machine_label_from_slug_unique(tmp_path: Path) -> None:
label = "BW1-7 [AMR-18]"
scan_dir = _scan_tree(tmp_path, machine_label=label, scan_id=42)
cfg = {"machine_metadata": {label: {"plot_number": 7}}}
assert resolve_machine_label_for_scan_dir(scan_dir, cfg, None) == label
def test_resolve_machine_label_explicit_overrides_slug(tmp_path: Path) -> None:
scan_dir = _scan_tree(tmp_path, machine_label="BW1-7 [AMR-18]", scan_id=1)
cfg = {"machine_metadata": {"Other [X]": {}}}
assert (
resolve_machine_label_for_scan_dir(scan_dir, cfg, "Custom [Label]")
== "Custom [Label]"
)
def test_resolve_machine_label_no_match_raises(tmp_path: Path) -> None:
scan_dir = _scan_tree(tmp_path, machine_label="BW1-7 [AMR-18]", scan_id=1)
cfg = {"machine_metadata": {}}
with pytest.raises(ValueError, match="Could not map"):
resolve_machine_label_for_scan_dir(scan_dir, cfg, None)
def test_tag_mosaic_jpeg_for_scan_dir_writes_exif(tmp_path: Path) -> None:
label = "BW1-7 [AMR-18]"
scan_dir = _scan_tree(tmp_path, machine_label=label, scan_id=99)
jpeg = tmp_path / "out.jpg"
shutil.copy(BLANK_JPEG, jpeg)
cfg = {"write_exif": True, "machine_metadata": {label: {}}}
assert tag_mosaic_jpeg_for_scan_dir(scan_dir, jpeg, cfg, force=True)
exif = piexif.load(str(jpeg))
assert (
exif["0th"][ImageIFD.ProcessingSoftware]
== DEFAULT_PROCESSING_SOFTWARE_MOSAIC_FROM_TILES.encode("ascii")
)
assert b"SPRUCE scan 99" in exif["Exif"][piexif.ExifIFD.UserComment]
def test_tag_mosaic_skipped_when_write_exif_false_without_force(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
label = "BW1-7 [AMR-18]"
scan_dir = _scan_tree(tmp_path, machine_label=label, scan_id=1)
jpeg = tmp_path / "out.jpg"
shutil.copy(BLANK_JPEG, jpeg)
cfg = {"write_exif": False, "machine_metadata": {label: {}}}
with caplog.at_level(logging.WARNING):
ok = tag_mosaic_jpeg_for_scan_dir(scan_dir, jpeg, cfg, force=False)
assert not ok
assert "Skipping EXIF" in caplog.text
def test_tag_mosaic_scan_dir_name_mismatch_raises(tmp_path: Path) -> None:
label = "BW1-7 [AMR-18]"
scan_dir = _scan_tree(tmp_path, machine_label=label, scan_id=100)
bad = scan_dir.parent / "wrong_name"
shutil.move(str(scan_dir), str(bad))
jpeg = tmp_path / "out.jpg"
shutil.copy(BLANK_JPEG, jpeg)
cfg = {"machine_metadata": {label: {}}}
with pytest.raises(ValueError, match="scan_dir must be the scan id folder"):
tag_mosaic_jpeg_for_scan_dir(bad, jpeg, cfg, force=True)
def test_tag_mosaic_non_jpeg_raises(tmp_path: Path) -> None:
label = "BW1-7 [AMR-18]"
scan_dir = _scan_tree(tmp_path, machine_label=label, scan_id=1)
png = tmp_path / "out.png"
png.write_bytes(b"not really png")
cfg = {"machine_metadata": {label: {}}}
with pytest.raises(ValueError, match="only supports JPEG"):
tag_mosaic_jpeg_for_scan_dir(scan_dir, png, cfg, force=True)
def test_tag_mosaic_second_run_replaces_exif(tmp_path: Path) -> None:
label = "BW1-7 [AMR-18]"
scan_dir = _scan_tree(tmp_path, machine_label=label, scan_id=5)
jpeg = tmp_path / "out.jpg"
shutil.copy(BLANK_JPEG, jpeg)
cfg = {"machine_metadata": {label: {}}}
assert tag_mosaic_jpeg_for_scan_dir(scan_dir, jpeg, cfg, force=True)
assert tag_mosaic_jpeg_for_scan_dir(
scan_dir, jpeg, cfg, force=True, processing_software="custom/2.0"
)
exif = piexif.load(str(jpeg))
assert exif["0th"][ImageIFD.ProcessingSoftware] == b"custom/2.0"
def test_tag_mosaic_exif_script_cli(tmp_path: Path) -> None:
label = "BW1-6 [AMR-19]"
scan_dir = _scan_tree(tmp_path, machine_label=label, scan_id=156875)
jpeg = scan_dir / "mosaic_reconstructed.jpg"
shutil.copy(BLANK_JPEG, jpeg)
cfg = tmp_path / "mini.yaml"
cfg.write_text(
f"write_exif: true\nmachine_metadata:\n {label!r}: {{}}\n",
encoding="utf-8",
)
env = {**os.environ, "PYTHONPATH": str(ROOT)}
r = subprocess.run(
[
sys.executable,
str(TAG_SCRIPT),
str(scan_dir),
"--config",
str(cfg),
],
cwd=str(ROOT),
capture_output=True,
text=True,
env=env,
)
assert r.returncode == 0, r.stderr + r.stdout
exif = piexif.load(str(jpeg))
assert b"SPRUCE scan 156875" in exif["Exif"][piexif.ExifIFD.UserComment]
+10
View File
@@ -95,6 +95,16 @@ def test_load_config_missing_password_exits(tmp_path):
load_config(str(path))
def test_load_config_optional_credentials(tmp_path):
path = tmp_path / "config.yaml"
path.write_text(
yaml.dump({"machine_metadata": {"A [B]": {"plot_number": 2}}})
)
cfg = load_config(str(path), require_credentials=False)
assert cfg["machine_metadata"]["A [B]"]["plot_number"] == 2
assert cfg["write_exif"] is True
# ---------------------------------------------------------------------------
# CSV schemas (failure columns)
# ---------------------------------------------------------------------------