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
4 changes: 3 additions & 1 deletion config/mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,6 @@ warn_unused_configs = True

# Import settings
ignore_missing_imports = True
follow_imports = silent
follow_imports = silent

plugins = pydantic.mypy
4 changes: 4 additions & 0 deletions ifdo/_datetime/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from ._check_datetime import check_datatime_format
from ._serialize_datetime import add_datetime_format_info

__all__ = ["add_datetime_format_info", "check_datatime_format"]
45 changes: 45 additions & 0 deletions ifdo/_datetime/_check_datetime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from datetime import datetime, timezone
from typing import Any

from ._format import DEFAULT_DATETIME_FORMAT


def check_datatime_format(ifdo_data: dict[str, Any]) -> None:
if "image-set-header" not in ifdo_data:
return
image_set_header = ifdo_data["image-set-header"]

header_datetime_format = _check_image_item(
image_set_header,
DEFAULT_DATETIME_FORMAT,
)

if "image-set-items" not in ifdo_data:
return

for item in ifdo_data["image-set-items"].values():
if isinstance(item, dict):
_check_image_item(item, header_datetime_format)
else:
_check_video_item(item, header_datetime_format)


def _check_video_item(items: list[dict[str, Any]], header_format: str) -> None:
if len(items) == 0:
return

prefix_format = _check_image_item(items[0], header_format)
for item in items[1:]:
_check_image_item(item, prefix_format)


def _check_image_item(item: dict[str, Any], header_format: str) -> str:
datetime_format: str = item.get("image-datetime-format", header_format)

if "image-datetime" in item:
item["image-datetime"] = datetime.strptime(
item["image-datetime"],
datetime_format,
).replace(tzinfo=timezone.utc)

return datetime_format
1 change: 1 addition & 0 deletions ifdo/_datetime/_format.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DEFAULT_DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S.%f"
35 changes: 35 additions & 0 deletions ifdo/_datetime/_serialize_datetime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from typing import TYPE_CHECKING

from ifdo._datetime._format import DEFAULT_DATETIME_FORMAT

if TYPE_CHECKING:
from ifdo import ImageData, iFDO


def add_datetime_format_info(ifdo: "iFDO") -> None:
header_format = ifdo.image_set_header.image_datetime_format or DEFAULT_DATETIME_FORMAT

ifdo.image_set_header._image_datetime_format = header_format # type: ignore[attr-defined] # noqa: SLF001

for image_item in ifdo.image_set_items.values():
if isinstance(image_item, list):
_serialize_video_datetimes(image_item, header_format)
else:
_serialize_image_datetimes(image_item, header_format)


def _serialize_video_datetimes(images: list["ImageData"], header_format: str) -> None:
if len(images) == 0:
return

datetime_format = _serialize_image_datetimes(images[0], header_format)

for image in images[1:]:
_serialize_image_datetimes(image, datetime_format)


def _serialize_image_datetimes(image: "ImageData", header_format: str) -> str:
datetime_format = image.image_datetime_format or header_format

image._image_datetime_format = datetime_format # type: ignore[attr-defined] # noqa: SLF001
return datetime_format
38 changes: 34 additions & 4 deletions ifdo/models/ifdo.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,27 @@
from __future__ import annotations

import json
from copy import deepcopy
from pathlib import Path
from typing import Any
from typing import Any, cast

import yaml
from pydantic import SerializerFunctionWrapHandler, model_serializer, model_validator

from ifdo._datetime import check_datatime_format
from ifdo._datetime._serialize_datetime import add_datetime_format_info
from ifdo.models._kebab_case_model import KebabCaseModel
from ifdo.models.ifdo_capture import ImageCaptureFields
from ifdo.models.ifdo_content import ImageContentFields
from ifdo.models.ifdo_core import ImageCoreFields


class ImageData(KebabCaseModel, ImageCoreFields, ImageCaptureFields, ImageContentFields):
class ImageData(
KebabCaseModel,
ImageCoreFields,
ImageCaptureFields,
ImageContentFields,
):
"""
Represent image data with associated metadata and annotations.

Expand Down Expand Up @@ -120,7 +129,12 @@ class ImageData(KebabCaseModel, ImageCoreFields, ImageCaptureFields, ImageConten
image_handle: str | None = None


class ImageSetHeader(KebabCaseModel, ImageCoreFields, ImageCaptureFields, ImageContentFields):
class ImageSetHeader(
KebabCaseModel,
ImageCoreFields,
ImageCaptureFields,
ImageContentFields,
):
"""
Represent an image set header with detailed metadata and attributes.

Expand Down Expand Up @@ -240,6 +254,20 @@ class iFDO(KebabCaseModel): # noqa: N801
image_set_header: ImageSetHeader
image_set_items: dict[str, ImageData | list[ImageData]]

@model_serializer(mode="wrap")
def _serialize(self, nxt: SerializerFunctionWrapHandler) -> dict[str, Any]:
ifdo = deepcopy(self)
add_datetime_format_info(ifdo)
return cast("dict[str, Any]", nxt(ifdo))

@model_validator(mode="before")
@classmethod
def _validate_image_datatime(cls, data: Any) -> Any: # noqa: ANN401
if not isinstance(data, dict):
return data
check_datatime_format(data)
return data

@classmethod
def from_dict(cls, data: dict[str, Any]) -> iFDO:
"""Create an iFDO instance from a dictionary."""
Expand Down Expand Up @@ -271,7 +299,9 @@ def load(cls, path: str | Path) -> iFDO:
elif suffix == "json":
d = json.load(f)
else:
raise ValueError("Unsupported file format. Use YAML (.yaml, .yml) or JSON (.json).")
raise ValueError(
"Unsupported file format. Use YAML (.yaml, .yml) or JSON (.json).",
)

return cls.from_dict(d)

Expand Down
1 change: 0 additions & 1 deletion ifdo/models/ifdo_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,6 @@ class ImageCaptureFields:
image_camera_pitch_degrees: float | None = None
image_camera_roll_degrees: float | None = None
image_overlap_fraction: float | None = None
image_datetime_format: str | None = None
image_camera_pose: ImageCameraPose | None = None
image_camera_housing_viewport: ImageCameraHousingViewport | None = None
image_flatport_parameters: ImageFlatportParameters | None = None
Expand Down
27 changes: 24 additions & 3 deletions ifdo/models/ifdo_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
ImageLicense: Represents an image license.
"""

from datetime import datetime
from datetime import datetime, timezone

from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_serializer

from ifdo._datetime._format import DEFAULT_DATETIME_FORMAT


class ImagePI(BaseModel):
Expand Down Expand Up @@ -85,10 +87,11 @@ def __hash__(self) -> int:
return hash((self.name, self.uri))


class ImageCoreFields:
class ImageCoreFields(BaseModel):
"""Core metadata fields for iFDO objects."""

image_datetime: datetime | None = None
image_datetime_format: str | None = None
image_latitude: float | None = Field(None, ge=-90, le=90)
image_longitude: float | None = Field(None, ge=-180, le=180)
image_altitude_meters: float | None = None
Expand All @@ -105,3 +108,21 @@ class ImageCoreFields:
image_copyright: str | None = None
image_abstract: str | None = None
image_set_local_path: str | None = None

@field_serializer("image_datetime", when_used="json")
def _serialize_image_datetime(self, image_datetime: datetime | None) -> str | None:
if image_datetime is None:
return None

datetime_format = getattr(
self,
"_image_datetime_format",
DEFAULT_DATETIME_FORMAT,
)

if image_datetime.tzinfo is not None:
image_datetime = image_datetime.astimezone(timezone.utc)

if datetime_format is not None and datetime_format != "":
return image_datetime.strftime(datetime_format)
return None
71 changes: 71 additions & 0 deletions tests/test_check_datetime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import pytest
from ifdo._datetime import check_datatime_format


def test_check_datatime_default() -> None:
valid_test_data = {
"image-set-header": {
"image-datetime": "2015-01-01 02:04:03.1000",
}
}

check_datatime_format(valid_test_data)

invalid_test_data = {
"image-set-header": {
"image-datetime": "2015-01-01 02:04:03",
}
}
with pytest.raises(Exception):
check_datatime_format(invalid_test_data)


def test_check_datetime_images() -> None:
valid_test_data = {
"image-set-header": {
"image-datetime": "2015-01-01T20:21:32",
"image-datetime-format": "%Y-%m-%dT%H:%M:%S",
},
"image-set-items": {"image": {"image-datetime": "2015-01-01T20:21:32"}},
}
check_datatime_format(valid_test_data)
invalid_test_data = {
"image-set-header": {
"image-datetime": "2015-01-01T20:21:32",
"image-datetime-format": "%Y-%m-%dT%H:%M:%S",
},
"image-set-items": {"image": {"image-datetime": "2015-01-01 20:21:32"}},
}
with pytest.raises(Exception):
check_datatime_format(invalid_test_data)


def test_check_datetime_videos() -> None:
valid_test_data = {
"image-set-header": {},
"image-set-items": {
"image": [
{
"image-datetime-format": "%Y-%m-%dT%H:%M:%S",
"image-datetime": "2015-01-01T20:21:32",
},
{"image-datetime": "2015-01-01T20:21:32"},
]
},
}
check_datatime_format(valid_test_data)
invalid_test_data = {
"image-set-header": {},
"image-set-items": {
"image": [
{
"image-datetime-format": "%Y-%m-%dT%H:%M:%S",
"image-datetime": "2015-01-01T20:21:32",
},
{"image-datetime": "2015-01-01 20:21:32"},
]
},
}

with pytest.raises(Exception):
check_datatime_format(invalid_test_data)
13 changes: 8 additions & 5 deletions tests/test_load.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import datetime

from datetime import timezone
import json

from ifdo import iFDO, ImageData
Expand All @@ -12,10 +12,13 @@ def test_load_image_example():
ifdo = iFDO.from_dict(json_data)

assert ifdo.image_set_header.image_set_name == "SO268 SO268-1_21-1_OFOS SO_CAM-1_Photo_OFOS"
assert isinstance(ifdo.image_set_items["SO268-1_21-1_OFOS_SO_CAM-1_20190304_083724.JPG"], ImageData)
assert isinstance(
ifdo.image_set_items["SO268-1_21-1_OFOS_SO_CAM-1_20190304_083724.JPG"],
ImageData,
)
assert ifdo.image_set_items["SO268-1_21-1_OFOS_SO_CAM-1_20190304_083724.JPG"].image_datetime == datetime.datetime(
2019, 3, 4, 8, 37, 24
)
).replace(tzinfo=timezone.utc)


def test_load_video_example():
Expand All @@ -27,6 +30,6 @@ def test_load_video_example():
assert ifdo.image_set_header.image_set_name == "SO268 SO268-1_21-1_OFOS SO_CAM-1_Photo_OFOS"
image_data = ifdo.image_set_items["SO268-1_21-1_OFOS_SO_CAM-1_20190304_083724.JPG"]
if isinstance(image_data, list):
assert image_data[1].image_datetime == datetime.datetime(2019, 3, 4, 8, 37, 25)
assert image_data[1].image_datetime == datetime.datetime(2019, 3, 4, 8, 37, 25).replace(tzinfo=timezone.utc)
else:
assert image_data.image_datetime == datetime.datetime(2019, 3, 4, 8, 37, 25)
assert image_data.image_datetime == datetime.datetime(2019, 3, 4, 8, 37, 25).replace(tzinfo=timezone.utc)
21 changes: 17 additions & 4 deletions tests/test_save.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,27 @@
from jsonschema import Draft202012Validator
from referencing import Registry, Resource
from ifdo import iFDO
from ifdo.models import ImageLicense, ImageContext, ImagePI, ImageCreator, ImageData, ImageSetHeader
from ifdo.models import (
ImageLicense,
ImageContext,
ImagePI,
ImageCreator,
ImageData,
ImageSetHeader,
)

OUTPUT_PATH = "/tmp/test_ifdo.json"


def test_save_image():
ifdo = create_ifdo()
ifdo.image_set_items["SO268-1_21-1_OFOS_SO_CAM-1_20190304_083724.JPG"] = create_ifdo_item()

validate_ifdo(ifdo)

result = ifdo.to_dict()
assert "_image_datetime_format" not in result["image-set-header"]
assert result["image-set-header"]["image-datetime"] == "2025-01-01 01:01:01.100000"


def test_save_video():
ifdo = create_ifdo()
Expand Down Expand Up @@ -49,13 +59,16 @@ def create_ifdo() -> iFDO:
ifdo.image_set_header.image_altitude_meters = 1.0
ifdo.image_set_header.image_coordinate_reference_system = "WSG84"
ifdo.image_set_header.image_coordinate_uncertainty_meters = 0.1
ifdo.image_set_header.image_datetime = datetime(2020, 1, 1)
ifdo.image_set_header.image_datetime = datetime(2025, 1, 1, 1, 1, 1, 100000)

return ifdo


def create_ifdo_item() -> ImageData:
image = ImageData()
image = ImageData(
image_latitude=0.0,
image_longitude=0.0,
)
image.image_handle = "test"
image.image_hash_sha256 = "83f30eb35d1325c44c85fba0cf478825c0a629d20177a945069934f6cd07e087"
image.image_uuid = "c6b8d981-05c7-449f-85a9-906ab866bfb6"
Expand Down
Loading