Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions imgparse/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,17 @@ 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} is not supported."
)

return pixel_pitch

def calibrated_focal_length(self) -> tuple[float, bool]:
Expand Down
19 changes: 15 additions & 4 deletions imgparse/pixel_pitches.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,23 +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": {
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,
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "imgparse"
version = "2.0.12"
version = "2.0.13"
description = "Python image-metadata-parser utilities"
authors = []
include = [
Expand Down
50 changes: 50 additions & 0 deletions tests/test_imgparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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()
Expand Down
Loading