From 9b3b0004b38d0065815bd6f8682fe9e65a2bd66f Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Tue, 21 Jul 2026 14:16:06 -0500 Subject: [PATCH 1/3] add m4e pixel patch --- imgparse/pixel_pitches.py | 1 + pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/imgparse/pixel_pitches.py b/imgparse/pixel_pitches.py index 15fc404..2b1d6e8 100644 --- a/imgparse/pixel_pitches.py +++ b/imgparse/pixel_pitches.py @@ -24,6 +24,7 @@ "ZenmuseP1": 4.27e-06, # Zemmuse P1 (M300) (24mm, 35mm, 50mm) "FC3170": 8e-07, # Mavic Air 2 "M3E": 3.28e-06, # Mavic 3 Enterprise + "M4E": 3.28e-06, # Matrice 4E (wide camera: 4/3" CMOS, 5280x3956) "FC6360": 3.0e-06, # Phantom 4 Multispectral "FC6310R": 2.41e-06, # Phatom 4 Pro RTK "M3M": 3.28e-06, # Mavic 3 Multispectral diff --git a/pyproject.toml b/pyproject.toml index e4f9561..a5f6874 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "imgparse" -version = "2.0.12" +version = "2.0.13" description = "Python image-metadata-parser utilities" authors = [] include = [ From ac4b1c510694d16ed742a9d63582ed3c848cb1ad Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Tue, 21 Jul 2026 15:32:37 -0500 Subject: [PATCH 2/3] raise error on unsupported m4e --- imgparse/parser.py | 13 ++++++++++ imgparse/pixel_pitches.py | 20 ++++++++++++---- tests/test_imgparse.py | 50 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 5 deletions(-) diff --git a/imgparse/parser.py b/imgparse/parser.py index 49e3796..ed6cb67 100644 --- a/imgparse/parser.py +++ b/imgparse/parser.py @@ -206,6 +206,19 @@ def pixel_pitch_meters(self) -> float: "Couldn't parse pixel pitch. Sensor might not be supported" ) + # Some models (e.g. Matrice 4E) have multiple cameras that share the same + # model string; the pixel pitch is stored per image width to identify the lens. + if isinstance(pixel_pitch, dict): + width = self.dimensions().width + try: + pixel_pitch = pixel_pitch[width] + except KeyError: + raise ParsingError( + f"Pixel pitch for {self.model()} at image width {width} isn't " + "supported yet. Only the Wide camera is currently supported for " + "this sensor." + ) + return pixel_pitch def calibrated_focal_length(self) -> tuple[float, bool]: diff --git a/imgparse/pixel_pitches.py b/imgparse/pixel_pitches.py index 2b1d6e8..d4b7fad 100644 --- a/imgparse/pixel_pitches.py +++ b/imgparse/pixel_pitches.py @@ -7,9 +7,17 @@ to ``PIXEL_PITCHES``, indexed by camera make. To add a new supported camera model, simply append a new model/pixel pitch pair to the existing camera make dictionary. + +Some DJI models (e.g. the Matrice 4E) expose multiple cameras that all report the same ``Image Model``. For those, +the value is a dictionary keyed by image width (in pixels), which uniquely identifies the lens/sensor. See +``MetadataParser.pixel_pitch_meters`` for how these are resolved. """ -DJI_PIXEL_PITCH = { +# A pixel pitch is either a single value (meters) or, for sensors whose cameras +# share an ``Image Model``, a mapping of image width (pixels) -> pixel pitch. +PixelPitch = float | dict[int, float] + +DJI_PIXEL_PITCH: dict[str, PixelPitch] = { "FC6310": 2.41e-06, # Phantom 4 Pro "FC6310S": 2.41e-06, # Phantom 4 Pro V2 "FC220": 1.55e-06, # Phantom 2 Vision @@ -24,24 +32,26 @@ "ZenmuseP1": 4.27e-06, # Zemmuse P1 (M300) (24mm, 35mm, 50mm) "FC3170": 8e-07, # Mavic Air 2 "M3E": 3.28e-06, # Mavic 3 Enterprise - "M4E": 3.28e-06, # Matrice 4E (wide camera: 4/3" CMOS, 5280x3956) + "M4E": { + 5280: 3.28e-06, # Wide camera (4/3" CMOS, 5280x3956) + }, "FC6360": 3.0e-06, # Phantom 4 Multispectral "FC6310R": 2.41e-06, # Phatom 4 Pro RTK "M3M": 3.28e-06, # Mavic 3 Multispectral } -HASSELBLAD_PIXEL_PITCH = { +HASSELBLAD_PIXEL_PITCH: dict[str, PixelPitch] = { "L1D-20c": 2.4e-06, # Mavic 2 Pro "L2D-20c": 3.28e-06, # Mavic 3 Classic } -SONY_PIXEL_PITCH = { +SONY_PIXEL_PITCH: dict[str, PixelPitch] = { "DSC-RX1RM2": 4.5e-06, # Sony Cyber-shot RX1R II (42.4MP) "DSC-RX100M2": 2.41e-06, # Sony Cyber-shot DSC-RX100 II (20.2MP) "ILCE-7RM4A": 3.76e-06, # Sony A7R IV (60.2MP) } -PIXEL_PITCHES = { +PIXEL_PITCHES: dict[str, dict[str, PixelPitch]] = { "DJI": DJI_PIXEL_PITCH, "Hasselblad": HASSELBLAD_PIXEL_PITCH, "SONY": SONY_PIXEL_PITCH, diff --git a/tests/test_imgparse.py b/tests/test_imgparse.py index 9441726..8dfdf9d 100644 --- a/tests/test_imgparse.py +++ b/tests/test_imgparse.py @@ -19,6 +19,14 @@ def __init__(self, values: Any): self.values = values +class Ratio: + """Minimal stand-in for exifread's Ratio (has ``num`` / ``den``).""" + + def __init__(self, num: int, den: int): + self.num = num + self.den = den + + @pytest.fixture def bad_data_parser() -> MetadataParser: parser = MetadataParser(base_path / "BAD_IMG.jpg") @@ -77,6 +85,34 @@ def dji_parser() -> MetadataParser: return MetadataParser(base_path / "DJI_normal.JPG") +@pytest.fixture +def m4e_wide_parser() -> MetadataParser: + parser = MetadataParser(base_path / "M4E_wide.JPG") + parser._exif_data = { + "Image Make": Tag("DJI"), + "Image Model": Tag("M4E"), + "EXIF FocalLength": Tag([Ratio(1229, 100)]), + "EXIF ExifImageWidth": Tag([5280]), + "EXIF ExifImageLength": Tag([3956]), + } + parser._xmp_data = {} + return parser + + +@pytest.fixture +def m4e_tele_parser() -> MetadataParser: + parser = MetadataParser(base_path / "M4E_tele.JPG") + parser._exif_data = { + "Image Make": Tag("DJI"), + "Image Model": Tag("M4E"), + "EXIF FocalLength": Tag([Ratio(1229, 100)]), + "EXIF ExifImageWidth": Tag([8064]), + "EXIF ExifImageLength": Tag([6048]), + } + parser._xmp_data = {} + return parser + + @pytest.fixture def dji_homepoint_parser() -> MetadataParser: return MetadataParser(base_path / "DJI_home_point.jpg") @@ -144,6 +180,20 @@ def test_get_camera_params_dji(dji_parser: MetadataParser) -> None: assert focal_pixels == pytest.approx(3651.4523, abs=1e-04) +def test_get_camera_params_m4e_wide(m4e_wide_parser: MetadataParser) -> None: + assert m4e_wide_parser.pixel_pitch_meters() == 3.28e-06 + assert m4e_wide_parser.focal_length_pixels() == pytest.approx(3746.9512, abs=1e-04) + + +def test_get_camera_params_m4e_non_wide_unsupported( + m4e_tele_parser: MetadataParser, +) -> None: + # Non-Wide M4E lenses (e.g. width 8064) aren't supported yet and must raise + # rather than silently reuse the Wide pixel pitch. + with pytest.raises(ParsingError): + m4e_tele_parser.pixel_pitch_meters() + + def test_get_camera_params_sentera(sentera_parser: MetadataParser) -> None: focal1 = sentera_parser.focal_length_meters() pitch = sentera_parser.pixel_pitch_meters() From 1884cbb58e4c9d4a0e687d60327c784baf02f87d Mon Sep 17 00:00:00 2001 From: TrevorBurgoyne Date: Tue, 21 Jul 2026 15:37:14 -0500 Subject: [PATCH 3/3] less specific error message --- imgparse/parser.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/imgparse/parser.py b/imgparse/parser.py index ed6cb67..620194f 100644 --- a/imgparse/parser.py +++ b/imgparse/parser.py @@ -214,9 +214,7 @@ def pixel_pitch_meters(self) -> float: pixel_pitch = pixel_pitch[width] except KeyError: raise ParsingError( - f"Pixel pitch for {self.model()} at image width {width} isn't " - "supported yet. Only the Wide camera is currently supported for " - "this sensor." + f"Pixel pitch for {self.model()} at image width {width} is not supported." ) return pixel_pitch