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:
@@ -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]
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user