diff --git a/imgparse/parser.py b/imgparse/parser.py index f96a530..a372827 100644 --- a/imgparse/parser.py +++ b/imgparse/parser.py @@ -15,6 +15,7 @@ from imgparse.types import ( AltitudeSource, Dimensions, + DistortionParams, Euler, PixelCoords, Version, @@ -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: @@ -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.""" @@ -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": + 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(",")] + 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: diff --git a/imgparse/types.py b/imgparse/types.py index 3d6994f..6fe8532 100644 --- a/imgparse/types.py +++ b/imgparse/types.py @@ -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.""" diff --git a/imgparse/xmp_tags.py b/imgparse/xmp_tags.py index eeea7bf..14a404c 100644 --- a/imgparse/xmp_tags.py +++ b/imgparse/xmp_tags.py @@ -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): diff --git a/pyproject.toml b/pyproject.toml index c72e360..1c56b78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "imgparse" -version = "2.0.4" +version = "2.0.5" description = "Python image-metadata-parser utilities" authors = [] include = [ diff --git a/tests/test_imgparse.py b/tests/test_imgparse.py index ede9ca8..6d90b22 100644 --- a/tests/test_imgparse.py +++ b/tests/test_imgparse.py @@ -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) @@ -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: