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
79 changes: 60 additions & 19 deletions imgparse/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from imgparse.types import (
AltitudeSource,
Dimensions,
DistortionParams,
Euler,
PixelCoords,
Version,
Expand Down Expand Up @@ -206,20 +207,26 @@ def pixel_pitch_meters(self) -> float:

return pixel_pitch

def focal_length_meters(self, use_calibrated: bool = False) -> float:
def calibrated_focal_length(self) -> tuple[float, bool]:
"""
Get the focal length (in meters) of the sensor that took the image.
Get the calibrated focal length from xmp data.

:param use_calibrated: enable to use calibrated focal length if available
For Sentera sensors, this focal length is in meters. For DJI, it is in pixels. Returns
a boolean indicating if focal length is in pixels or not.
"""
if use_calibrated:
try:
return float(self.xmp_data[self.xmp_tags.FOCAL_LEN]) / 1000
except KeyError:
logger.warning(
"Calibrated focal length not found in XMP. Defaulting to uncalibrated focal length"
)
try:
fl = float(self.xmp_data[self.xmp_tags.FOCAL_LEN])
if self.make() == "Sentera":
is_in_pixels = False
fl = fl / 1000
else:
is_in_pixels = True
return fl, is_in_pixels
except KeyError:
raise ParsingError("Calibrated focal length not found in XMP")

def focal_length_meters(self) -> float:
"""Get the focal length (in meters) of the sensor that took the image."""
try:
return convert_to_float(self.exif_data["EXIF FocalLength"]) / 1000
except KeyError:
Expand All @@ -229,9 +236,24 @@ def focal_length_meters(self, use_calibrated: bool = False) -> float:

def focal_length_pixels(self, use_calibrated_focal_length: bool = False) -> float:
"""Get the focal length (in pixels) of the sensor that took the image."""
fl = self.focal_length_meters(use_calibrated_focal_length)
pp = self.pixel_pitch_meters()
return fl / pp

def _get_focal_length() -> tuple[float, bool]:
"""Get either the calibrated focal length or the exif focal length."""
if use_calibrated_focal_length:
try:
return self.calibrated_focal_length()
except ParsingError:
logger.warning(
"Couldn't parse calibrated focal length from xmp. Falling back to exif"
)
return self.focal_length_meters(), False

fl, is_in_pixels = _get_focal_length()
if not is_in_pixels:
pp = self.pixel_pitch_meters()
return fl / pp

return fl

def principal_point(self) -> PixelCoords:
"""Get the principal point (x, y) in pixels of the sensor that took the image."""
Expand All @@ -251,15 +273,34 @@ def principal_point(self) -> PixelCoords:
"Couldn't find the principal point tag. Sensor might not be supported"
)

def distortion_parameters(self) -> list[float]:
"""Get the radial distortion parameters of the sensor that took the image."""
def distortion_parameters(self) -> DistortionParams:
"""
Get the radial distortion parameters of the sensor that took the image.

Returns distortion params in [k1, k2, p1, p2, k3] order.
"""
try:
return list(
map(float, str(self.xmp_data[self.xmp_tags.DISTORTION]).split(","))
)
if self.make() == "DJI":
Copy link

Copilot AI Jun 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding more detailed documentation on the expected format of DJI distortion data (e.g., semicolon-separated header and comma-separated numeric values) to aid future maintainers.

Copilot uses AI. Check for mistakes.
distortion_data = str(self.xmp_data[self.xmp_tags.DISTORTION])

parts = distortion_data.split(";")
if len(parts) != 2:
raise ValueError("Invalid dewarp data format: missing semicolon")

values = [float(v) for v in parts[1].split(",")]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is inconsistent with the list(map(float, str().split()) used below. I think your approach works better.

Could you update the Sentera approach in 298 to be consistent with the list comprehension used here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated. Also updated the return type to a NamedTuple in OpenCV order

if len(values) != 9:
raise ValueError("Expected 9 numeric values after semicolon")

k1, k2, p1, p2, k3 = values[4:9]
return DistortionParams(k1, k2, p1, p2, k3)
elif self.make() == "Sentera":
distortion_data = str(self.xmp_data[self.xmp_tags.DISTORTION])
k1, k2, k3, p1, p2 = [float(v) for v in distortion_data.split(",")]
return DistortionParams(k1, k2, p1, p2, k3)
raise ValueError("Sensor isn't supported")
except (KeyError, ValueError):
raise ParsingError(
"Couldn't find the distortion tag. Sensor might not be supported"
"Couldn't parse the distortion parameters. Sensor might not be supported"
)

def location(self) -> WorldCoords:
Expand Down
10 changes: 10 additions & 0 deletions imgparse/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ class Version(NamedTuple):
patch: int


class DistortionParams(NamedTuple):
"""Distortion parameters in OpenCV order."""

k1: float
k2: float
p1: float
p2: float
k3: float


class AltitudeSource(Enum):
"""Altitude source enum."""

Expand Down
1 change: 1 addition & 0 deletions imgparse/xmp_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ class DJITags(XMPTags):
IRRADIANCE = "Camera:Irradiance"
CAPTURE_UUID = "drone-dji:CaptureUUID"
DEWARP_FLAG = "drone-dji:DewarpFlag"
DISTORTION = "drone-dji:DewarpData"


class MicaSenseTags(XMPTags):
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.4"
version = "2.0.5"
description = "Python image-metadata-parser utilities"
authors = []
include = [
Expand Down
11 changes: 6 additions & 5 deletions tests/test_imgparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,23 +132,19 @@ def s3_image_parser() -> MetadataParser:
def test_get_camera_params_dji(dji_parser: MetadataParser) -> None:
pitch1 = dji_parser.pixel_pitch_meters()
focal1 = dji_parser.focal_length_meters()
focal2 = dji_parser.focal_length_meters(use_calibrated=True)
focal_pixels = dji_parser.focal_length_pixels()

assert focal1 == 0.0088
assert pitch1 == 2.41e-06
assert focal2 == pytest.approx(3.666666, abs=1e-06)
assert focal_pixels == pytest.approx(3651.4523, abs=1e-04)


def test_get_camera_params_sentera(sentera_parser: MetadataParser) -> None:
focal1 = sentera_parser.focal_length_meters()
focal2 = sentera_parser.focal_length_meters(use_calibrated=True)
pitch = sentera_parser.pixel_pitch_meters()
focal_pixels = sentera_parser.focal_length_pixels()

assert focal1 == 0.025
assert focal2 == 0.025
assert pitch == pytest.approx(1.55e-06, abs=1e-06)
assert focal_pixels == pytest.approx(16129.032, abs=1e-03)

Expand Down Expand Up @@ -360,7 +356,12 @@ def test_get_principal_point_bad(bad_sentera_parser: MetadataParser) -> None:

def test_get_distortion_params_65r(sentera_65r_parser: MetadataParser) -> None:
params = sentera_65r_parser.distortion_parameters()
assert params == [-0.127, 0.126, 0.097, 0.0, 0.0]
assert params == (-0.127, 0.126, 0.0, 0.0, 0.097)


def test_get_distortion_params_dji(dji_ms_parser: MetadataParser) -> None:
params = dji_ms_parser.distortion_parameters()
assert params == (-0.412558, 0.3754, -2.47e-05, -2.47e-05, -0.457753)


def test_get_distortion_params_bad(bad_sentera_parser: MetadataParser) -> None:
Expand Down