From d554ef257d2d494c66daaf928114c8097fb8fb91 Mon Sep 17 00:00:00 2001 From: Peter Corke Date: Sun, 16 Aug 2026 08:36:03 +1000 Subject: [PATCH] fix: Image.metadata() missing camera-specific Exif sub-IFD tags Image.getexif() (the modern public Pillow API) only returns the top-level IFD0 tags (Make, Model, Orientation, ...). Camera-specific tags -- FocalLength, ExposureTime, FNumber, ISOSpeedRatings, LensModel, etc. -- live in a separate "Exif" sub-IFD that getexif() does not flatten in. metadata() never fetched that sub-IFD, so img.metadata("FocalLength") always raised KeyError even though the tag is genuinely present in the file (confirmed via raw PIL: it's under Exif.get_ifd(0x8769), the fixed EXIF-spec tag id for the Exif IFD pointer). This is a Pillow API evolution, not a data problem: the old private Image._getexif() used to flatten everything into one dict; the public getexif() introduced later deliberately does not, requiring an explicit get_ifd() call for the sub-IFD. Co-Authored-By: Claude Sonnet 5 --- src/machinevisiontoolbox/ImageIO.py | 8 ++++++++ tests/test_image_io.py | 21 +++++++++++++++------ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/machinevisiontoolbox/ImageIO.py b/src/machinevisiontoolbox/ImageIO.py index eef14dad..f39b8f09 100644 --- a/src/machinevisiontoolbox/ImageIO.py +++ b/src/machinevisiontoolbox/ImageIO.py @@ -267,6 +267,14 @@ def metadata(self, key: str | None = None) -> Any: # map tag number to tag name exif[TAGS[tag]] = value + # Camera-specific tags (FocalLength, ExposureTime, FNumber, + # ISOSpeedRatings, LensModel, ...) live in a separate "Exif" sub-IFD + # that Image.getexif() does not flatten into the top-level dict -- + # 0x8769 is the fixed EXIF-spec tag id for the pointer to it. + for tag, value in meta.get_ifd(0x8769).items(): + if tag in TAGS: + exif[TAGS[tag]] = value + if key is None: return exif else: diff --git a/tests/test_image_io.py b/tests/test_image_io.py index 75e30e39..6008d018 100644 --- a/tests/test_image_io.py +++ b/tests/test_image_io.py @@ -190,14 +190,23 @@ def test_disp(self): # im.disp(block=False) pass - # new test def test_metadata(self): """Test reading image metadata""" - # TODO: Test with image file that has metadata - # im = Image.Read('test_image.jpg') - # metadata = im.metadata() - # self.assertIsNotNone(metadata) - pass + im = Image.Read("walls-l.png") + metadata = im.metadata() + self.assertIsNotNone(metadata) + + # top-level IFD0 tags + self.assertEqual(metadata["Make"], "Apple") + self.assertEqual(metadata["Model"], "iPhone 5s") + self.assertEqual(im.metadata("Make"), "Apple") + + # camera-specific tags live in a separate "Exif" sub-IFD that + # Image.getexif() doesn't flatten into the top-level dict -- + # regression test for that sub-IFD merge + self.assertIn("FocalLength", metadata) + self.assertAlmostEqual(im.metadata("FocalLength"), 4.15) + self.assertAlmostEqual(metadata["FNumber"], 2.2) # new test def test_showpixels(self):