EXIF extraction fails on every TIFF, and the obvious fix silently loses DateTimeOriginal #145
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: HIGH — an entire evidence source is missing for an archival scan format, silently
Found while building the container images for #50. Confirmed on the host as well, so it is not container-induced.
The bug
backend/app/services/image_sandbox.py:125calls a private Pillow API:_getexifexists onJpegImageFileand does not exist onTiffImageFile. Verified against the pinned Pillow 12.3.0:So every TIFF ingested raises
AttributeErrorinside the EXIF sandbox, andexifevidence (#74) — trust-weighted by tag, with the wholeexif_trustmachinery behind it — is never recorded for any of them.TIFF is not an edge case here. It is in
ALLOWED_EXTENSIONSand iningest_batch.ACCEPTED_SUFFIXES, it is the archival format a flatbed scanner is usually configured to produce, anddocs/circa-spec.mdtreats scanned prints as the whole subject of the application.Why nothing caught it
Every EXIF test builds a JPEG.
tests/test_exif_trust.pysaves withformat="JPEG"and there is no TIFF anywhere in the EXIF tests. So the suite exercises the one type that happens to have the private method, and the format most likely to arrive from a real scanner is untested.The failure is also recorded but invisible.
ingest_photoputsexif_statusandexif_errorin the audit event — #65 added exactly that so "EXIF could not be read" stays distinguishable from "there was none". The distinction is in the ledger and nothing surfaces it, so an operator sees no error and simply gets no EXIF evidence, forever.app/services/exif_extractor.py:130already carries a comment noting_getexifis private API.The fix is NOT a one-line swap — this is the important half
img.getexif()is the public replacement, but it does not return the same thing.DateTimeOriginal(tag 36867) lives in the Exif sub-IFD at 0x8769._getexif()merged that in;getexif()does not. Measured:DateTimeOriginalis the tag this application cares about most —exif_trustrates it the strongest EXIF signal precisely because it means "when the shutter opened" rather than "when the file was written". So a naive_getexif()→getexif()swap would fix the loud TIFF failure and introduce a quiet wrong answer on every JPEG, which is strictly worse: an absent date becomes an absent date nobody can tell was ever there.The fix must merge the top-level IFD with
get_ifd(0x8769), and the tests must pin that it does.Scope
_getexif()with a public-API read that merges the Exif sub-IFDDateTimeOriginalsurvives the change, so the sub-IFD merge cannot be dropped laterWorth checking at the same time
Whether anything else in the image path reaches for a private Pillow attribute, since the same class of breakage would be equally silent.
Timing
This is worth doing before a real collection is ingested, not after. EXIF evidence is recorded at ingest, so every TIFF that goes in before the fix carries no
exifevidence — and re-deriving it later means re-reading every original file rather than a database pass.app/cli/reassess_exif.pyexists and may cover that, which is worth confirming as part of this.References
backend/app/services/image_sandbox.py:125backend/app/services/exif_extractor.py(the_getexifis-private comment)backend/tests/test_exif_trust.py(JPEG only)