Add EXIF writing and machine metadata support

This commit is contained in:
2026-04-24 18:21:37 -04:00
parent f2193011ca
commit e8d3bf7180
11 changed files with 577 additions and 12 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 631 B

+138
View File
@@ -0,0 +1,138 @@
"""Tests for spruce.exif — mosaic EXIF injection."""
import shutil
from pathlib import Path
import piexif
import pytest
from piexif import ExifIFD, GPSIFD, ImageIFD
from spruce.exif import USER_COMMENT_ASCII, write_mosaic_exif
FIXTURES = Path(__file__).parent / "fixtures"
BLANK_JPEG = FIXTURES / "blank.jpg"
def _dms_pair_to_float(num: int, den: int) -> float:
return num / den if den else 0.0
def _dms_to_deg(
ref: bytes, dms: list[tuple[tuple[int, int], tuple[int, int], tuple[int, int]]]
) -> float:
d0, m0, s0 = dms
deg = (
_dms_pair_to_float(d0[0], d0[1])
+ _dms_pair_to_float(m0[0], m0[1]) / 60.0
+ _dms_pair_to_float(s0[0], s0[1]) / 3600.0
)
if ref in (b"S", b"W"):
return -deg
return deg
@pytest.fixture
def tmp_jpeg(tmp_path: Path) -> Path:
dest = tmp_path / "mosaic.jpg"
shutil.copy(BLANK_JPEG, dest)
return dest
@pytest.fixture
def scan_meta() -> dict:
return {
"scan_id": 157743,
"name": "Plot 7 AMR18 Full tube scan",
"scan_time": "2024-06-28 11:00:00",
"user": "Joanne",
"nx": 103,
"ny": 328,
"dx": 3.01,
"dy": 2.26,
"end_x": 310.0,
"end_y": 740.0,
}
@pytest.fixture
def machine() -> dict:
return {"label": "BW1-7 [AMR-18]", "version": "3.0.0.18", "machine_id": "7"}
def test_write_mosaic_exif_round_trip(tmp_jpeg: Path, scan_meta: dict, machine: dict):
assert write_mosaic_exif(
tmp_jpeg,
scan_meta,
machine,
157743,
None,
)
exif = piexif.load(str(tmp_jpeg))
assert exif["Exif"][ExifIFD.DateTimeOriginal] == b"2024:06:28 11:00:00"
assert (
exif["0th"][ImageIFD.ImageDescription]
== b"BW1-7 [AMR-18] scan 157743 (Plot 7 AMR18 Full tube scan)"
)
assert exif["0th"][ImageIFD.Artist] == b"Joanne"
assert exif["0th"][ImageIFD.Software] == b"RootView 3.0.0.18"
assert exif["0th"][ImageIFD.ProcessingSoftware] == b"spruce-scraper/1.0"
uc = exif["Exif"][ExifIFD.UserComment]
assert uc.startswith(USER_COMMENT_ASCII)
tail = uc[len(USER_COMMENT_ASCII) :]
assert b"SPRUCE scan 157743" in tail
assert b"103x328" in tail
assert b"see metadata.json" in tail
assert b"plot_number" not in tail
assert ImageIFD.XPKeywords not in exif["0th"]
assert "GPS" not in exif or not exif["GPS"]
def test_gps_decode(tmp_jpeg: Path, scan_meta: dict, machine: dict):
mmeta = {
"latitude_wgs_84": 47.5047,
"longitude_wgs_84": -93.4530,
"elevation_masl": 418.0,
}
assert write_mosaic_exif(tmp_jpeg, scan_meta, machine, 157743, mmeta)
exif = piexif.load(str(tmp_jpeg))
gps = exif["GPS"]
lat = _dms_to_deg(gps[GPSIFD.GPSLatitudeRef], gps[GPSIFD.GPSLatitude])
lon = _dms_to_deg(gps[GPSIFD.GPSLongitudeRef], gps[GPSIFD.GPSLongitude])
assert abs(lat - 47.5047) < 1e-5
assert abs(lon - (-93.4530)) < 1e-5
alt = gps[GPSIFD.GPSAltitude]
assert alt[0] / alt[1] == pytest.approx(418.0)
def test_no_gps_no_keywords_when_meta_none(
tmp_jpeg: Path, scan_meta: dict, machine: dict
):
assert write_mosaic_exif(tmp_jpeg, scan_meta, machine, 157743, None)
exif = piexif.load(str(tmp_jpeg))
assert ImageIFD.XPKeywords not in exif["0th"]
assert "GPS" not in exif or not exif["GPS"]
def test_xp_keywords_treatment(tmp_jpeg: Path, scan_meta: dict, machine: dict):
mmeta = {
"plot_number": 7,
"enclosure": False,
"temp_treatment": 2.25,
"co2_treatment": "ambient",
}
assert write_mosaic_exif(tmp_jpeg, scan_meta, machine, 157743, mmeta)
exif = piexif.load(str(tmp_jpeg))
raw = exif["0th"][ImageIFD.XPKeywords]
text = bytes(raw).decode("utf-16le").rstrip("\x00")
assert "SPRUCE" in text
assert "plot 7" in text
assert "no enclosure" in text
assert "temp" in text
assert "2.25" in text
assert "aCO2" in text
uc = exif["Exif"][ExifIFD.UserComment]
uct = uc[len(USER_COMMENT_ASCII) :].decode("ascii", errors="replace")
assert "plot_number 7" in uct
assert "enclosure no" in uct
assert "temp_treatment 2.25" in uct
assert "co2_treatment ambient" in uct
+5 -1
View File
@@ -143,7 +143,11 @@ def test_recheck_archive_skips_mosaic_urls(tmp_path):
mosaic_url = "http://192.0.2.1:8011/RootView_Database/158374/mosaic.jpg"
p.mark_done(mosaic_url)
p.save()
# recheck_verifies a non-zero mosaic exists under */*/<scan_id>/mosaic.jpg
mpath = tmp_path / "M" / "2020-01-01" / "158374" / "mosaic.jpg"
mpath.parent.mkdir(parents=True)
mpath.write_bytes(b"\xff\xd8\xff\xd9") # minimal JPEG soff + eoi
removed = recheck_archive(tmp_path, p)
assert removed == 0
assert p.is_done(mosaic_url) # mosaics are never touched
assert p.is_done(mosaic_url)
+2
View File
@@ -60,6 +60,8 @@ def test_load_config_defaults(tmp_path):
assert cfg["timeout"] == 60
assert cfg["request_delay"] == 0.5
assert cfg["output_dir"] == "archives"
assert cfg["write_exif"] is True
assert cfg["machine_metadata"] == {}
def test_load_config_overrides(tmp_path):