Skip to content
Open
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
86 changes: 86 additions & 0 deletions mapillary_tools/exif_read.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
"x": "adobe:ns:meta/",
"GPano": "http://ns.google.com/photos/1.0/panorama/",
"aux": "http://ns.adobe.com/exif/1.0/aux/",
}
# https://github.com/ianare/exif-py/issues/167
EXIFREAD_LOG = logging.getLogger("exifread")
Expand Down Expand Up @@ -334,6 +335,10 @@ def extract_height(self) -> int | None:
def extract_orientation(self) -> int:
raise NotImplementedError

@abc.abstractmethod
def extract_camera_uuid(self) -> str | None:
raise NotImplementedError


class ExifReadFromXMP(ExifReadABC):
def __init__(self, etree: et.ElementTree):
Expand Down Expand Up @@ -482,6 +487,41 @@ def extract_orientation(self) -> int:
return 1
return orientation

def extract_camera_uuid(self) -> str | None:
"""
Extract camera unique identifier from serial number tags in XMP.
Builds a composite ID from body and lens serial numbers.
"""
body_serial = self._extract_alternative_fields(
[
"exif:SerialNumber",
"exif:BodySerialNumber",
"exif:CameraSerialNumber",
"exifEX:SerialNumber",
"exifEX:BodySerialNumber",
"aux:SerialNumber",
],
str,
)
lens_serial = self._extract_alternative_fields(
[
"exif:LensSerialNumber",
"exifEX:LensSerialNumber",
"aux:LensSerialNumber",
],
str,
)

parts = []
if body_serial:
parts.append(body_serial.strip())
if lens_serial:
parts.append(lens_serial.strip())

if parts:
return "_".join(parts)
return None

def _extract_alternative_fields(
self,
fields: T.Iterable[str],
Expand Down Expand Up @@ -816,6 +856,40 @@ def extract_orientation(self) -> int:
return 1
return orientation

def extract_camera_uuid(self) -> str | None:
"""
Extract camera unique identifier from serial number EXIF tags.
Builds a composite ID from body and lens serial numbers.
"""
body_serial = self._extract_alternative_fields(
[
"EXIF BodySerialNumber",
"EXIF SerialNumber",
"EXIF CameraSerialNumber",
"Image BodySerialNumber",
"MakerNote SerialNumber",
"MakerNote InternalSerialNumber",
],
str,
)
lens_serial = self._extract_alternative_fields(
[
"EXIF LensSerialNumber",
"Image LensSerialNumber",
],
str,
)

parts = []
if body_serial:
parts.append(body_serial.strip())
if lens_serial:
parts.append(lens_serial.strip())

if parts:
return "_".join(parts)
return None

def _extract_alternative_fields(
self,
fields: T.Iterable[str],
Expand Down Expand Up @@ -987,3 +1061,15 @@ def extract_height(self) -> int | None:
if val is not None:
return val
return None

def extract_camera_uuid(self) -> str | None:
val = super().extract_camera_uuid()
if val is not None:
return val
xmp = self._xmp_with_reason("camera_uuid")
if xmp is None:
return None
val = xmp.extract_camera_uuid()
if val is not None:
return val
return None
85 changes: 85 additions & 0 deletions mapillary_tools/exiftool_read.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@
EXIFTOOL_NAMESPACES: dict[str, str] = {
"Adobe": "http://ns.exiftool.org/APP14/Adobe/1.0/",
"Apple": "http://ns.exiftool.org/MakerNotes/Apple/1.0/",
"Canon": "http://ns.exiftool.org/MakerNotes/Canon/1.0/",
"Composite": "http://ns.exiftool.org/Composite/1.0/",
"ExifIFD": "http://ns.exiftool.org/EXIF/ExifIFD/1.0/",
"ExifTool": "http://ns.exiftool.org/ExifTool/1.0/",
"File": "http://ns.exiftool.org/File/1.0/",
"FLIR": "http://ns.exiftool.org/APP1/FLIR/1.0/",
"FujiFilm": "http://ns.exiftool.org/MakerNotes/FujiFilm/1.0/",
"GPS": "http://ns.exiftool.org/EXIF/GPS/1.0/",
"GoPro": "http://ns.exiftool.org/APP6/GoPro/1.0/",
"ICC-chrm": "http://ns.exiftool.org/ICC_Profile/ICC-chrm/1.0/",
Expand All @@ -33,11 +36,20 @@
"IPTC": "http://ns.exiftool.org/IPTC/IPTC/1.0/",
"InteropIFD": "http://ns.exiftool.org/EXIF/InteropIFD/1.0/",
"JFIF": "http://ns.exiftool.org/JFIF/JFIF/1.0/",
"Kodak": "http://ns.exiftool.org/MakerNotes/Kodak/1.0/",
"Leica": "http://ns.exiftool.org/MakerNotes/Leica/1.0/",
"MPF0": "http://ns.exiftool.org/MPF/MPF0/1.0/",
"MPImage1": "http://ns.exiftool.org/MPF/MPImage1/1.0/",
"MPImage2": "http://ns.exiftool.org/MPF/MPImage2/1.0/",
"Nikon": "http://ns.exiftool.org/MakerNotes/Nikon/1.0/",
"Olympus": "http://ns.exiftool.org/MakerNotes/Olympus/1.0/",
"Panasonic": "http://ns.exiftool.org/MakerNotes/Panasonic/1.0/",
"Pentax": "http://ns.exiftool.org/MakerNotes/Pentax/1.0/",
"Photoshop": "http://ns.exiftool.org/Photoshop/Photoshop/1.0/",
"Ricoh": "http://ns.exiftool.org/MakerNotes/Ricoh/1.0/",
"Samsung": "http://ns.exiftool.org/MakerNotes/Samsung/1.0/",
"Sigma": "http://ns.exiftool.org/MakerNotes/Sigma/1.0/",
"Sony": "http://ns.exiftool.org/MakerNotes/Sony/1.0/",
"System": "http://ns.exiftool.org/File/System/1.0/",
"XMP-GAudio": "http://ns.exiftool.org/XMP/XMP-GAudio/1.0/",
"XMP-GImage": "http://ns.exiftool.org/XMP/XMP-GImage/1.0/",
Expand All @@ -53,6 +65,8 @@
"XMP-xmp": "http://ns.exiftool.org/XMP/XMP-xmp/1.0/",
"XMP-xmpMM": "http://ns.exiftool.org/XMP/XMP-xmpMM/1.0/",
"XMP-xmpNote": "http://ns.exiftool.org/XMP/XMP-xmpNote/1.0/",
"XMP-drone-dji": "http://ns.exiftool.org/XMP/XMP-drone-dji/1.0/",
"DJI": "http://ns.exiftool.org/MakerNotes/DJI/1.0/",
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
}

Expand Down Expand Up @@ -426,6 +440,77 @@ def extract_orientation(self) -> int:
return 1
return orientation

def extract_camera_uuid(self) -> str | None:
"""
Extract camera UUID from serial numbers.
Returns a composite ID from body serial and lens serial if available.
"""
# Try body serial number from various sources
body_serial = self._extract_alternative_fields(
[
# Standard EXIF tags (BodySerialNumber has priority over generic SerialNumber)
"ExifIFD:BodySerialNumber",
"ExifIFD:SerialNumber",
"IFD0:CameraSerialNumber",
"IFD0:SerialNumber",
# MakerNotes - camera specific
"Canon:SerialNumber",
"Canon:InternalSerialNumber",
"DJI:SerialNumber",
"XMP-drone-dji:CameraSerialNumber",
"XMP-drone-dji:DroneSerialNumber",
"FLIR:CameraSerialNumber",
"FujiFilm:InternalSerialNumber",
"GoPro:CameraSerialNumber",
"Kodak:SerialNumber",
"Leica:SerialNumber",
"Leica:InternalSerialNumber",
"Nikon:SerialNumber",
"Olympus:SerialNumber",
"Olympus:InternalSerialNumber",
"Panasonic:InternalSerialNumber",
"Pentax:SerialNumber",
"Pentax:InternalSerialNumber",
"Ricoh:SerialNumber",
"Ricoh:InternalSerialNumber",
"Ricoh:BodySerialNumber",
"Sigma:SerialNumber",
"Sony:InternalSerialNumber",
# XMP equivalents
"XMP-exif:SerialNumber",
"XMP-exif:BodySerialNumber",
"XMP-exifEX:SerialNumber",
"XMP-exif:CameraSerialNumber",
"XMP-exifEX:BodySerialNumber",
"XMP-aux:SerialNumber",
],
str,
)

# Try lens serial number
lens_serial = self._extract_alternative_fields(
[
"ExifIFD:LensSerialNumber",
"FLIR:LensSerialNumber",
"Olympus:LensSerialNumber",
"Panasonic:LensSerialNumber",
"Ricoh:LensSerialNumber",
"XMP-exifEX:LensSerialNumber",
"XMP-aux:LensSerialNumber",
],
str,
)

parts = []
if body_serial:
parts.append(body_serial.strip())
if lens_serial:
parts.append(lens_serial.strip())

if parts:
return "_".join(parts)
return None

def _extract_alternative_fields(
self,
fields: T.Sequence[str],
Expand Down
52 changes: 52 additions & 0 deletions mapillary_tools/exiftool_read_video.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,16 @@
EXIFTOOL_NAMESPACES: dict[str, str] = {
"Keys": "http://ns.exiftool.org/QuickTime/Keys/1.0/",
"IFD0": "http://ns.exiftool.org/EXIF/IFD0/1.0/",
"ExifIFD": "http://ns.exiftool.org/EXIF/ExifIFD/1.0/",
"QuickTime": "http://ns.exiftool.org/QuickTime/QuickTime/1.0/",
"UserData": "http://ns.exiftool.org/QuickTime/UserData/1.0/",
"Insta360": "http://ns.exiftool.org/Trailer/Insta360/1.0/",
"GoPro": "http://ns.exiftool.org/QuickTime/GoPro/1.0/",
"Ricoh": "http://ns.exiftool.org/MakerNotes/Ricoh/1.0/",
"XMP-GSpherical": "http://ns.exiftool.org/XMP/XMP-GSpherical/1.0/",
"XMP-aux": "http://ns.exiftool.org/XMP/XMP-aux/1.0/",
"DJI": "http://ns.exiftool.org/MakerNotes/DJI/1.0/",
"XMP-drone-dji": "http://ns.exiftool.org/XMP/XMP-drone-dji/1.0/",
**{
f"Track{track_id}": f"http://ns.exiftool.org/QuickTime/Track{track_id}/1.0/"
for track_id in range(1, MAX_TRACK_ID + 1)
Expand Down Expand Up @@ -408,6 +414,52 @@ def extract_model(self) -> str | None:
_, model = self._extract_make_and_model()
return model

def extract_camera_uuid(self) -> str | None:
"""
Extract camera unique identifier from serial number tags in video metadata.
Builds a composite ID from body and lens serial numbers.
"""
# Try camera-specific serial numbers first
body_serial = self._extract_alternative_fields(
[
# Camera-specific tags
"GoPro:SerialNumber",
"GoPro:CameraSerialNumber",
"Ricoh:SerialNumber",
"XMP-GSpherical:PiDeviceSN", # Labpano cameras
"Insta360:SerialNumber",
"DJI:SerialNumber",
"XMP-drone-dji:CameraSerialNumber",
"XMP-drone-dji:DroneSerialNumber",
# Generic tags
"ExifIFD:BodySerialNumber",
"ExifIFD:SerialNumber",
"IFD0:SerialNumber",
"UserData:SerialNumber",
"XMP-aux:SerialNumber",
"UserData:SerialNumberHash",
],
str,
)
lens_serial = self._extract_alternative_fields(
[
"UserData:LensSerialNumber",
"ExifIFD:LensSerialNumber",
"XMP-aux:LensSerialNumber",
],
str,
)

parts = []
if body_serial:
parts.append(body_serial.strip())
if lens_serial:
parts.append(lens_serial.strip())

if parts:
return "_".join(parts)
return None

def _extract_gps_track_from_track(self) -> list[GPSPoint]:
root = self.etree.getroot()
if root is None:
Expand Down
1 change: 1 addition & 0 deletions mapillary_tools/geotag/geotag_images_from_video.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ def to_description(
if isinstance(metadata, types.ImageMetadata):
metadata.MAPDeviceMake = video_metadata.make
metadata.MAPDeviceModel = video_metadata.model
metadata.MAPCameraUUID = video_metadata.camera_uuid

final_image_metadatas.extend(image_metadatas)

Expand Down
1 change: 1 addition & 0 deletions mapillary_tools/geotag/image_extractors/exif.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ def extract(self) -> types.ImageMetadata:
MAPOrientation=exif.extract_orientation(),
MAPDeviceMake=exif.extract_make(),
MAPDeviceModel=exif.extract_model(),
MAPCameraUUID=exif.extract_camera_uuid(),
)

return image_metadata
2 changes: 2 additions & 0 deletions mapillary_tools/geotag/video_extractors/exiftool.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def extract(self) -> types.VideoMetadata:

make = exif.extract_make()
model = exif.extract_model()
camera_uuid = exif.extract_camera_uuid()

is_gopro = make is not None and make.upper() in ["GOPRO"]

Expand Down Expand Up @@ -70,6 +71,7 @@ def extract(self) -> types.VideoMetadata:
points=points,
make=make,
model=model,
camera_uuid=camera_uuid,
)

return video_metadata
1 change: 1 addition & 0 deletions mapillary_tools/process_sequence_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@ def _group_by_folder_and_camera(
image_metadatas,
lambda metadata: (
str(metadata.filename.parent),
metadata.MAPCameraUUID,
metadata.MAPDeviceMake,
metadata.MAPDeviceModel,
metadata.width,
Expand Down
8 changes: 8 additions & 0 deletions mapillary_tools/serializer/description.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ class VideoDescription(_SharedDescription, total=False):
MAPGPSTrack: Required[list[T.Sequence[float | int | None]]]
MAPDeviceMake: str
MAPDeviceModel: str
MAPCameraUUID: str


class _ErrorObject(TypedDict, total=False):
Expand Down Expand Up @@ -206,6 +207,10 @@ class ErrorDescription(TypedDict, total=False):
"type": "string",
"description": "Device model, e.g. HERO10 Black, DR900S-1CH, Insta360 Titan",
},
"MAPCameraUUID": {
"type": "string",
"description": "Camera unique identifier, typically derived from camera serial number",
},
},
"required": [
"MAPGPSTrack",
Expand Down Expand Up @@ -402,6 +407,8 @@ def _as_video_desc(cls, metadata: VideoMetadata) -> VideoDescription:
desc["MAPDeviceMake"] = metadata.make
if metadata.model:
desc["MAPDeviceModel"] = metadata.model
if metadata.camera_uuid:
desc["MAPCameraUUID"] = metadata.camera_uuid
return desc

@classmethod
Expand Down Expand Up @@ -495,6 +502,7 @@ def _from_video_desc(cls, desc: VideoDescription) -> VideoMetadata:
points=[PointEncoder.decode(entry) for entry in desc["MAPGPSTrack"]],
make=desc.get("MAPDeviceMake"),
model=desc.get("MAPDeviceModel"),
camera_uuid=desc.get("MAPCameraUUID"),
)


Expand Down
1 change: 1 addition & 0 deletions mapillary_tools/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ class VideoMetadata:
make: str | None = None
model: str | None = None
filesize: int | None = None
camera_uuid: str | None = None

def update_md5sum(self) -> None:
if self.md5sum is None:
Expand Down
Loading