Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
109 commits
Select commit Hold shift + click to select a range
fae5599
OP#9528: Add design spec for optional geo coordinates support
randy-seng Apr 3, 2026
875f7c0
OP#9528:Add implementation plan for geo coordinates feature
randy-seng Apr 3, 2026
9d71e75
feat: add GEO_X/GEO_Y constants and abstract properties to Detection
randy-seng Apr 3, 2026
f430469
feat: add optional geo_x/geo_y fields to Event and EventBuilder
randy-seng Apr 3, 2026
b014019
refactor: fix test quality issues in TestEventGeoCoordinates
randy-seng Apr 3, 2026
60038d3
feat: add geo_x/geo_y fields to TrackBuilder and EventBuilder test he…
randy-seng Apr 3, 2026
bbb7f18
feat: add optional geo_x/geo_y fields to PythonDetection
randy-seng Apr 3, 2026
c88d7a7
feat: add optional geo_x/geo_y to PandasDetection with index-check fa…
randy-seng Apr 3, 2026
ea8acab
feat: add optional geo_x/geo_y to PolarsDetection using dict.get() fa…
randy-seng Apr 3, 2026
0af7e7c
feat: map geo_x/geo_y columns in PandasDetectionParser when present i…
randy-seng Apr 3, 2026
bc84cae
feat: propagate START/END_GEO_X/Y in create_track_segments when geo c…
randy-seng Apr 3, 2026
2a1c8ca
feat: pass through START/END_GEO_X/Y in find_line_intersections output
randy-seng Apr 3, 2026
78e852c
feat: linearly interpolate geo coordinates at track-section intersect…
randy-seng Apr 3, 2026
2a14af9
feat: propagate geo coordinates into events in create_events() and Po…
randy-seng Apr 3, 2026
6795698
refactor: use named constants and add docstrings in create_events geo…
randy-seng Apr 3, 2026
7ddc538
feat: include geo_x/geo_y columns in event list CSV export when present
randy-seng Apr 3, 2026
e93f61d
refactor: fix import style, remove duplicate test, remove no-op setup…
randy-seng Apr 3, 2026
e586ef7
fix: preserve geo columns in PolarsTrackDataset.add_all when merging …
randy-seng Apr 9, 2026
e390feb
docs: document why offset has no effect for fusion point-detection tr…
randy-seng Apr 13, 2026
4cd0e35
docs: add design spec for geo-coordinate-based intersection events
randy-seng Apr 13, 2026
88a6db7
docs: add implementation plan for geo-coordinate-based intersection e…
randy-seng Apr 13, 2026
46739c7
feat: add OtfusionMetadata domain class and pixel_to_geo conversion
randy-seng Apr 13, 2026
c73ede3
feat: parse OtfusionMetadata from ottrk and propagate through TrackPa…
randy-seng Apr 13, 2026
7658132
feat: thread OtfusionMetadata from TrackRepository into PolarsTrackDa…
randy-seng Apr 13, 2026
c69b202
feat: add use_geo flag to find_line_intersections for geo-space inter…
randy-seng Apr 13, 2026
792f2d1
feat: use geo coordinates for intersection detection when OtfusionMet…
randy-seng Apr 13, 2026
75672c1
test: add fallback regression test for non-fusion pixel-space interse…
randy-seng Apr 13, 2026
1fcf3ed
feat: log coordinate space used for intersection event creation
randy-seng Apr 14, 2026
583a52c
docs: add implementation plan for geo coordinates in scene events
randy-seng Apr 14, 2026
abd244c
feat: include geo columns in PolarsTrackDataset.__create_segments()
randy-seng Apr 14, 2026
3038f54
refactor: fix test structure for TestGetSegmentsPreservesGeoColumns
randy-seng Apr 14, 2026
b9d7740
feat: propagate geo coordinates to enter-scene and leave-scene events
randy-seng Apr 14, 2026
8510379
refactor: clarify geo null handling and deduplicate test dataset builder
randy-seng Apr 14, 2026
73b9c07
docs: add georeference metadata format update design spec
randy-seng Apr 14, 2026
8d2375c
docs: add georeference metadata format update implementation plan
randy-seng Apr 14, 2026
87ce1bd
feat: replace OtfusionMetadata with GeoreferenceMetadata domain model
randy-seng Apr 14, 2026
e6928d3
feat: update ottrk_dataformat constants for georeference block
randy-seng Apr 14, 2026
c7c1344
feat: rename OtfusionMetadata to GeoreferenceMetadata across all layers
randy-seng Apr 14, 2026
7609cb2
fix: update stale otfusion docstring in geometry store test
randy-seng Apr 15, 2026
a20ad9b
fix: remove stale OTFusion language from docstrings
randy-seng Apr 15, 2026
c23e0ed
fix: propagate georeference metadata through feathers parser pipeline
randy-seng Apr 16, 2026
599063b
fix: preserve georeference metadata through dataset split and filter …
randy-seng Apr 28, 2026
e6b3f85
OP#9528: replace hardcoded geo constants with variables in geometry s…
randy-seng May 12, 2026
5d591b9
OP#9528: replace hardcoded geo constants with variables in datastore …
randy-seng May 12, 2026
54a032e
OP#9528: refactor datastore test to replace hardcoded geo constants w…
randy-seng May 12, 2026
54df32a
OP#9528: extract reusable geo constants in datastore test
randy-seng May 12, 2026
8d81154
OP#9528: decouple parser initialization from geo parser tests
randy-seng May 12, 2026
62b7cb5
OP#9528: replace hardcoded geo constants with reusable variables in p…
randy-seng May 12, 2026
6d025ff
OP#9528: add stricter column checks in polars geometry store and upda…
randy-seng May 12, 2026
7ce87bc
OP#9528: replace hardcoded geo constants with reusable variables in e…
randy-seng May 12, 2026
ee18e41
OP#9528: replace hardcoded geo constants with reusable variables in e…
randy-seng May 12, 2026
0b91ce9
OP#9528: rename `bev_width` and `bev_height` to `birds_eye_view_width…
randy-seng May 13, 2026
11638f5
OP#9528: extract line_dx/line_dy locals in calculate_intersection_points
randy-seng May 14, 2026
c8bed27
OP#9528: add equivalence test for find_line_intersections pixel vs ge…
randy-seng May 14, 2026
5534921
OP#9528: fix mislabeled case descriptions in equivalence test factory
randy-seng May 14, 2026
1835258
OP#9528: unify pixel and geo intersection math via column-name parame…
randy-seng May 14, 2026
c8e39b1
OP#9528: unify pixel and geo relative-position computation via column…
randy-seng May 14, 2026
6390607
OP#9528: document W=H=0 invariant inline in relative-position formulas
randy-seng May 14, 2026
2ddd8e8
OP#9528: Enhance docstrings
randy-seng May 15, 2026
10a7380
OP#9528: Enhance docstrings
randy-seng May 15, 2026
d991680
OP#9528: replace hardcoded geo constants with reusable variables in t…
randy-seng May 15, 2026
920f939
OP#9528: add test for applying georeference metadata during track fil…
randy-seng May 15, 2026
b6f8813
OP#9528: Extract `TrackParser` and related classes into a new `parser…
randy-seng May 15, 2026
2374883
OP#9528: add tests for georeference metadata propagation in TrackPars…
randy-seng May 15, 2026
2193c11
OP#9528: replace hardcoded georeference metadata with `GIVEN_METADATA…
randy-seng May 15, 2026
0d588d9
OP#9528: replace repeated row data with `SINGLE_ROW` and update tests…
randy-seng May 15, 2026
e1eaa9a
OP#9528: refactor otvision parser tests to reuse `SAMPLE_GEOREFERENCE…
randy-seng May 18, 2026
b631934
OP#9528: add design doc for georeference metadata containment
randy-seng May 18, 2026
e7bc9da
OP#9528: add implementation plan for georeference metadata containment
randy-seng May 18, 2026
468c416
OP#9528: add georeference defaults and incompatibility error to Track…
randy-seng May 18, 2026
e70c977
OP#9528: simplify _MinimalTrackDataset using __abstractmethods__ over…
randy-seng May 18, 2026
2b9b7e7
OP#9528: validate georeference metadata when merging PolarsTrackDatasets
randy-seng May 18, 2026
6c8b57f
OP#9528: document add_all metadata strictness and empty-incoming no-op
randy-seng May 18, 2026
2fc1b27
OP#9528: preserve georeference metadata across PolarsTrackDataset ope…
randy-seng May 18, 2026
18a7f4f
OP#9528: embed georeference metadata in OttrkParser parse result
randy-seng May 18, 2026
4ac3b5f
OP#9528: read expected_metadata from Given in OttrkParser test
randy-seng May 18, 2026
06338db
OP#9528: embed georeference metadata in FeathersParser; inherit parse…
randy-seng May 18, 2026
21a5e69
OP#9528: tighten FeathersParser column-order test; remove dead use_fe…
randy-seng May 18, 2026
868d0b1
OP#9528: fix combine_track_datasets dropping merged tracks from later…
randy-seng May 18, 2026
832910a
OP#9528: drop georeference_metadata field from parser result dataclasses
randy-seng May 18, 2026
5c16207
OP#9528: read georeference metadata from tracks in convert_ottrk_to_f…
randy-seng May 18, 2026
ef77fe7
OP#9528: remove apply_georeference_metadata; metadata flows via add_all
randy-seng May 18, 2026
f5e8fd4
OP#9528: integration test for mismatched georeference across loads
randy-seng May 18, 2026
7651e37
OP#9528: lock in non-Polars backends' default georeference behavior
randy-seng May 18, 2026
3aeb6cd
OP#9528: ensure georeference columns exist before processing intersec…
randy-seng May 18, 2026
ac2340b
OP#9528: implement with_georeference_metadata on PandasTrackDataset a…
randy-seng May 18, 2026
3857677
OP#9528: fix aliasing and add non-empty/null tests for PandasTrackDat…
randy-seng May 18, 2026
0131f3a
OP#9528: add lifecycle metadata preservation tests for split_finished…
randy-seng May 18, 2026
ceb9b6f
OP#9528: fix split_finished variable names and revert_cuts_for code p…
randy-seng May 18, 2026
f66f2b5
OP#9528: fix from_dataframe empty-input metadata drop and add_all scr…
randy-seng May 18, 2026
ac6e108
OP#9528: remove redundant inline polars import from test_from_datafra…
randy-seng May 18, 2026
7f17733
OP#9528: remove obsolete georeference metadata tests
randy-seng May 19, 2026
f91e796
OP#9528: remove unnecessary string annotations from georeference meta…
randy-seng May 19, 2026
94bbaa1
docs: add design spec for PolarsTrackDataset.merge_all bulk concat
randy-seng May 19, 2026
0e8d751
docs: add implementation plan for PolarsTrackDataset.merge_all bulk c…
randy-seng May 19, 2026
ef5148f
feat(OP#9528): add PolarsTrackDataset.merge_all for efficient bulk co…
randy-seng May 19, 2026
96c748e
test(OP#9528): add partial-empty dataset test for merge_all
randy-seng May 19, 2026
89650e1
refactor(OP#9528): simplify merge_all concat and add geo column tests
randy-seng May 19, 2026
f7691e0
feat(OP#9528): override FeathersParser._combine_track_datasets to use…
randy-seng May 19, 2026
327045d
OP#9528: refactor for better code readability
randy-seng May 20, 2026
e1e6f49
OP#9528: Remove unused method
randy-seng May 20, 2026
1a5737a
feat(OP#9528): null-pad geo columns in merge_all/add_all instead of d…
randy-seng May 20, 2026
11127bc
fix(OP#9528): use drop_nulls subset in create_track_segments to prese…
randy-seng May 20, 2026
a8e825a
feat(OP#9528): log coordinate space used for intersection and event c…
randy-seng May 21, 2026
f27c96b
refactor(OP#9528): extract CsvTrackExport dataset writing into writer…
randy-seng May 21, 2026
15429d7
feat(OP#9528): include geo coordinates in road user assignment CSV ex…
randy-seng May 21, 2026
21a0011
test(OP#9528): Refactor tests to own module
randy-seng May 21, 2026
ac1f2e1
Merge branch 'main' into feature/9528-extend-sections-and-tracks-with…
randy-seng May 21, 2026
f3e97c4
chore(OP#9528): remove geo coordinates implementation plan doc
randy-seng May 21, 2026
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
45 changes: 1 addition & 44 deletions OTAnalytics/application/datastore.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Iterable, Optional, Sequence

from OTAnalytics.application.export_formats.export_mode import OVERWRITE
from OTAnalytics.application.parser.track_parser import TrackParser
from OTAnalytics.application.project import Project
from OTAnalytics.application.use_cases.export_events import (
EventExportSpecification,
Expand Down Expand Up @@ -41,49 +41,6 @@
)


@dataclass(frozen=True)
class DetectionMetadata:
detection_classes: frozenset[str]


@dataclass(frozen=True)
class TrackParseResult:
tracks: TrackDataset
detection_metadata: DetectionMetadata
video_metadata: VideoMetadata


@dataclass(frozen=True)
class TracksParseResult:
tracks: TrackDataset
detections_metadata: list[DetectionMetadata]
videos_metadata: list[VideoMetadata]


def combine_track_datasets(results: list[TrackParseResult]) -> TrackDataset:
if not results:
raise ValueError("No results to combine")
tracks = results[0].tracks
for result in results[1:]:
tracks.add_all(result.tracks)
return tracks


class TrackParser(ABC):
def parse_files(self, files: list[Path]) -> TracksParseResult:
if not files:
raise ValueError("No files to parse")
results = [self.parse(file) for file in files]
tracks = combine_track_datasets(results)
detections_metadata = [result.detection_metadata for result in results]
videos_metadata = [result.video_metadata for result in results]
return TracksParseResult(tracks, detections_metadata, videos_metadata)

@abstractmethod
def parse(self, file: Path) -> TrackParseResult:
raise NotImplementedError


class EventListParser(ABC):
@abstractmethod
def serialize(
Expand Down
12 changes: 12 additions & 0 deletions OTAnalytics/application/eventlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,15 @@
from OTAnalytics.domain.geometry import ImageCoordinate, calculate_direction_vector
from OTAnalytics.domain.track_dataset.track_dataset import (
END_FRAME,
END_GEO_X,
END_GEO_Y,
END_OCCURRENCE,
END_VIDEO_NAME,
END_X,
END_Y,
START_FRAME,
START_GEO_X,
START_GEO_Y,
START_OCCURRENCE,
START_VIDEO_NAME,
START_X,
Expand Down Expand Up @@ -95,6 +99,8 @@ def _create_enter_scene_event(self, value: dict) -> None:
key_occurrence=START_OCCURRENCE,
key_frame=START_FRAME,
key_video_name=START_VIDEO_NAME,
key_geo_x=START_GEO_X,
key_geo_y=START_GEO_Y,
)
self._events.append(event)

Expand All @@ -107,6 +113,8 @@ def _create_leave_scene_event(self, value: dict) -> None:
key_occurrence=END_OCCURRENCE,
key_frame=END_FRAME,
key_video_name=END_VIDEO_NAME,
key_geo_x=END_GEO_X,
key_geo_y=END_GEO_Y,
)
self._events.append(event)

Expand All @@ -119,6 +127,8 @@ def __create_event(
key_occurrence: str,
key_frame: str,
key_video_name: str,
key_geo_x: str,
key_geo_y: str,
) -> Event:
image_coordinate = ImageCoordinate(value[key_x], value[key_y])
occurrence = value[key_occurrence]
Expand All @@ -140,6 +150,8 @@ def __create_event(
video_name=value[key_video_name],
interpolated_occurrence=occurrence,
interpolated_event_coordinate=image_coordinate,
geo_x=value.get(key_geo_x),
geo_y=value.get(key_geo_y),
)


Expand Down
2 changes: 2 additions & 0 deletions OTAnalytics/application/export_formats/event_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
INTERPOLATED_EVENT_COORDINATE = event.INTERPOLATED_EVENT_COORDINATE
INTERPOLATED_EVENT_COORDINATE_X = "interpolated_coordinate_px_x"
INTERPOLATED_EVENT_COORDINATE_Y = "interpolated_coordinate_px_y"
GEO_X: str = event.GEO_X
GEO_Y: str = event.GEO_Y

DATE_FORMAT = "%Y-%m-%d"
TIME_FORMAT = "%H:%M:%S.%f"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ def _prepend_end(key: str) -> str:
END_INTERPOLATED_EVENT_COORD_Y = _prepend_end(
event_list.INTERPOLATED_EVENT_COORDINATE_Y
)
START_GEO_X = _prepend_start(event_list.GEO_X)
START_GEO_Y = _prepend_start(event_list.GEO_Y)
END_GEO_X = _prepend_end(event_list.GEO_X)
END_GEO_Y = _prepend_end(event_list.GEO_Y)
HOSTNAME = event_list.HOSTNAME

DATE_FORMAT = event_list.DATE_FORMAT
Expand Down
54 changes: 54 additions & 0 deletions OTAnalytics/application/parser/track_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path

from OTAnalytics.domain.track_dataset.track_dataset import TrackDataset
from OTAnalytics.domain.video import VideoMetadata


@dataclass(frozen=True)
class DetectionMetadata:
detection_classes: frozenset[str]


@dataclass(frozen=True)
class TrackParseResult:
tracks: TrackDataset
detection_metadata: DetectionMetadata
video_metadata: VideoMetadata


@dataclass(frozen=True)
class TracksParseResult:
tracks: TrackDataset
detections_metadata: list[DetectionMetadata]
videos_metadata: list[VideoMetadata]


def combine_track_datasets(results: list[TrackParseResult]) -> TrackDataset:
if not results:
raise ValueError("No results to combine")
tracks = results[0].tracks
for result in results[1:]:
tracks = tracks.add_all(result.tracks)
return tracks


class TrackParser(ABC):
def parse_files(self, files: list[Path]) -> TracksParseResult:
if not files:
raise ValueError("No files to parse")
results = [self.parse(file) for file in files]
tracks = self._combine_track_datasets(results)
detections_metadata = [result.detection_metadata for result in results]
videos_metadata = [result.video_metadata for result in results]
return TracksParseResult(tracks, detections_metadata, videos_metadata)

def _combine_track_datasets(
self, parse_results: list[TrackParseResult]
) -> TrackDataset:
return combine_track_datasets(parse_results)

@abstractmethod
def parse(self, file: Path) -> TrackParseResult:
raise NotImplementedError
3 changes: 2 additions & 1 deletion OTAnalytics/application/use_cases/load_track_files.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from pathlib import Path

from OTAnalytics.application.datastore import TrackParser, VideoParser
from OTAnalytics.application.datastore import VideoParser
from OTAnalytics.application.logger import logger
from OTAnalytics.application.parser.track_parser import TrackParser
from OTAnalytics.application.state import TracksMetadata, VideosMetadata
from OTAnalytics.domain.progress import ProgressbarBuilder
from OTAnalytics.domain.track_repository import TrackFileRepository, TrackRepository
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ def __create(self, assignment: RoadUserAssignment) -> dict:
ras.START_INTERPOLATED_EVENT_COORD_Y: start.interpolated_event_coordinate.y,
ras.END_INTERPOLATED_EVENT_COORD_X: end.interpolated_event_coordinate.x,
ras.END_INTERPOLATED_EVENT_COORD_Y: end.interpolated_event_coordinate.y,
ras.START_GEO_X: start.geo_x,
ras.START_GEO_Y: start.geo_y,
ras.END_GEO_X: end.geo_x,
ras.END_GEO_Y: end.geo_y,
}

def reset(self) -> None:
Expand Down Expand Up @@ -139,6 +143,10 @@ def reset(self) -> None:
ras.START_INTERPOLATED_EVENT_COORD_Y,
ras.END_INTERPOLATED_EVENT_COORD_X,
ras.END_INTERPOLATED_EVENT_COORD_Y,
ras.START_GEO_X,
ras.START_GEO_Y,
ras.END_GEO_X,
ras.END_GEO_Y,
]


Expand Down
26 changes: 26 additions & 0 deletions OTAnalytics/domain/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
VIDEO_NAME = "video_name"
INTERPOLATED_OCCURRENCE = "interpolated_occurrence"
INTERPOLATED_EVENT_COORDINATE = "interpolated_event_coordinate"
GEO_X: str = "geo_x"
GEO_Y: str = "geo_y"

DATE_FORMAT: str = "%Y-%m-%d %H:%M:%S.%f"
FILE_NAME_PATTERN = r"(?P<hostname>[A-Za-z0-9]+)_.*\..*"
Expand Down Expand Up @@ -80,6 +82,8 @@ class Event(DataclassValidation):
event occurred.
interpolated_event_coordinate (ImageCoordinate): interpolated event
coordinate between two detections.
geo_x (float | None): optional geo x coordinate. Defaults to None.
geo_y (float | None): optional geo y coordinate. Defaults to None.
"""

road_user_id: str
Expand All @@ -94,6 +98,8 @@ class Event(DataclassValidation):
video_name: str
interpolated_occurrence: datetime
interpolated_event_coordinate: ImageCoordinate
geo_x: float | None = None
geo_y: float | None = None

def _validate(self) -> None:
self._validate_frame_number_greater_equal_one()
Expand Down Expand Up @@ -129,6 +135,8 @@ def to_dict(self) -> dict:
VIDEO_NAME: self.video_name,
INTERPOLATED_OCCURRENCE: self.interpolated_occurrence.strftime(DATE_FORMAT),
INTERPOLATED_EVENT_COORDINATE: self.interpolated_event_coordinate.to_list(),
GEO_X: self.geo_x,
GEO_Y: self.geo_y,
}

def to_typed_dict(self) -> dict:
Expand All @@ -151,6 +159,8 @@ def to_typed_dict(self) -> dict:
VIDEO_NAME: self.video_name,
INTERPOLATED_OCCURRENCE: self.interpolated_occurrence,
INTERPOLATED_EVENT_COORDINATE: self.interpolated_event_coordinate.to_list(),
GEO_X: self.geo_x,
GEO_Y: self.geo_y,
}

def _serialized_section_id(self) -> Optional[str]:
Expand All @@ -172,6 +182,8 @@ def __init__(self) -> None:
self.section_id: Optional[SectionId] = None
self.interpolated_occurrence: Optional[datetime] = None
self.interpolated_event_coordinate: Optional[ImageCoordinate] = None
self.geo_x: float | None = None
self.geo_y: float | None = None

@abstractmethod
def create_event(self, detection: Detection) -> Event:
Expand Down Expand Up @@ -255,6 +267,16 @@ def add_interpolated_occurrence(self, occurrence: datetime) -> None:
def add_interpolated_event_coordinate(self, x: float, y: float) -> None:
self.interpolated_event_coordinate = ImageCoordinate(x, y)

def add_geo_coordinate(self, geo_x: float | None, geo_y: float | None) -> None:
"""Add geo coordinates to the event to be built.

Args:
geo_x: the geo x coordinate, or None if unavailable.
geo_y: the geo y coordinate, or None if unavailable.
"""
self.geo_x = geo_x
self.geo_y = geo_y


class SectionEventBuilder(EventBuilder):
"""A builder to build section events."""
Expand Down Expand Up @@ -320,6 +342,8 @@ def create_event(self, detection: Detection) -> Event:
video_name=detection.video_name,
interpolated_occurrence=self.interpolated_occurrence,
interpolated_event_coordinate=self.interpolated_event_coordinate,
geo_x=self.geo_x,
geo_y=self.geo_y,
)


Expand Down Expand Up @@ -369,6 +393,8 @@ def create_event(self, detection: Detection) -> Event:
video_name=detection.video_name,
interpolated_occurrence=detection.occurrence,
interpolated_event_coordinate=self.event_coordinate,
geo_x=self.geo_x,
geo_y=self.geo_y,
)


Expand Down
56 changes: 56 additions & 0 deletions OTAnalytics/domain/georeference.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Domain model for georeference Birds-Eye-View coordinate metadata."""

from dataclasses import dataclass


@dataclass(frozen=True)
class GeoreferenceMetadata:
"""Geo-referencing metadata from a georeferenced ottrk file.

Describes the affine mapping between Birds-Eye-View pixel coordinates and UTM
geo coordinates for a single ottrk output file.

Attributes:
geo_min_x (float): West boundary in UTM easting (metres).
geo_min_y (float): South boundary in UTM northing (metres).
geo_max_x (float): East boundary in UTM easting (metres).
geo_max_y (float): North boundary in UTM northing (metres).
birds_eye_view_width (int): Width of the BEV image in pixels.
birds_eye_view_height (int): Height of the BEV image in pixels.
padding (int): Pixel padding applied to all edges of the BEV image.
crs (str): Coordinate reference system as a WKT or authority string.
"""

geo_min_x: float
geo_min_y: float
geo_max_x: float
geo_max_y: float
birds_eye_view_width: int
birds_eye_view_height: int
padding: int
crs: str


def pixel_to_geo(
x: float, y: float, metadata: GeoreferenceMetadata
) -> tuple[float, float]:
"""Convert a Birds-Eye-View pixel coordinate to UTM geo coordinate.

Args:
x (float): Pixel x coordinate (column, increases rightward).
y (float): Pixel y coordinate (row, increases downward).
metadata (GeoreferenceMetadata): Metadata containing geo bounds and image size.

Returns:
Tuple[float, float] in the same UTM coordinate system as the per-detection
geo_x/geo_y fields.
"""
scale_x = (metadata.geo_max_x - metadata.geo_min_x) / (
metadata.birds_eye_view_width - 2 * metadata.padding
)
scale_y = (metadata.geo_max_y - metadata.geo_min_y) / (
metadata.birds_eye_view_height - 2 * metadata.padding
)
geo_x = metadata.geo_min_x + (x - metadata.padding) * scale_x
geo_y = metadata.geo_max_y - (y - metadata.padding) * scale_y
return geo_x, geo_y
12 changes: 12 additions & 0 deletions OTAnalytics/domain/track.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
ORIGINAL_TRACK_ID: str = "original_track_id"
VIDEO_NAME: str = "video_name"
INPUT_FILE: str = "input_file"
GEO_X: str = "geo_x"
GEO_Y: str = "geo_y"


@dataclass(frozen=True, order=True)
Expand Down Expand Up @@ -114,6 +116,16 @@ def video_name(self) -> str:
def input_file(self) -> str:
raise NotImplementedError

@property
@abstractmethod
def geo_x(self) -> float | None:
raise NotImplementedError

@property
@abstractmethod
def geo_y(self) -> float | None:
raise NotImplementedError

def to_dict(self) -> dict:
return {
CLASSIFICATION: self.classification,
Expand Down
Loading
Loading