Skip to content

Commit b462b25

Browse files
committed
Merge branch 'main' into bug/9548-incomplete-filenames-in-otanalytics-if-video-name-has-multiple-dots
# Conflicts: # OTAnalytics/application/datastore.py # OTAnalytics/plugin_parser/track_export.py # OTAnalytics/plugin_ui/cli.py # tests/unit/OTAnalytics/plugin_parser/test_road_user_assignment_export.py # tests/unit/OTAnalytics/plugin_parser/test_track_export.py
2 parents 52f8720 + f039958 commit b462b25

72 files changed

Lines changed: 5162 additions & 706 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

OTAnalytics/application/datastore.py

Lines changed: 1 addition & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from abc import ABC, abstractmethod
2-
from dataclasses import dataclass
32
from datetime import datetime
43
from pathlib import Path
54
from typing import Iterable, Optional, Sequence
65

6+
from OTAnalytics.application.parser.track_parser import TrackParser
77
from OTAnalytics.application.project import Project
88
from OTAnalytics.application.use_cases.export_events import (
99
EventExportSpecification,
@@ -40,49 +40,6 @@
4040
)
4141

4242

43-
@dataclass(frozen=True)
44-
class DetectionMetadata:
45-
detection_classes: frozenset[str]
46-
47-
48-
@dataclass(frozen=True)
49-
class TrackParseResult:
50-
tracks: TrackDataset
51-
detection_metadata: DetectionMetadata
52-
video_metadata: VideoMetadata
53-
54-
55-
@dataclass(frozen=True)
56-
class TracksParseResult:
57-
tracks: TrackDataset
58-
detections_metadata: list[DetectionMetadata]
59-
videos_metadata: list[VideoMetadata]
60-
61-
62-
def combine_track_datasets(results: list[TrackParseResult]) -> TrackDataset:
63-
if not results:
64-
raise ValueError("No results to combine")
65-
tracks = results[0].tracks
66-
for result in results[1:]:
67-
tracks.add_all(result.tracks)
68-
return tracks
69-
70-
71-
class TrackParser(ABC):
72-
def parse_files(self, files: list[Path]) -> TracksParseResult:
73-
if not files:
74-
raise ValueError("No files to parse")
75-
results = [self.parse(file) for file in files]
76-
tracks = combine_track_datasets(results)
77-
detections_metadata = [result.detection_metadata for result in results]
78-
videos_metadata = [result.video_metadata for result in results]
79-
return TracksParseResult(tracks, detections_metadata, videos_metadata)
80-
81-
@abstractmethod
82-
def parse(self, file: Path) -> TrackParseResult:
83-
raise NotImplementedError
84-
85-
8643
class EventListParser(ABC):
8744
@abstractmethod
8845
def serialize(

OTAnalytics/application/eventlist.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,15 @@
1212
from OTAnalytics.domain.geometry import ImageCoordinate, calculate_direction_vector
1313
from OTAnalytics.domain.track_dataset.track_dataset import (
1414
END_FRAME,
15+
END_GEO_X,
16+
END_GEO_Y,
1517
END_OCCURRENCE,
1618
END_VIDEO_NAME,
1719
END_X,
1820
END_Y,
1921
START_FRAME,
22+
START_GEO_X,
23+
START_GEO_Y,
2024
START_OCCURRENCE,
2125
START_VIDEO_NAME,
2226
START_X,
@@ -95,6 +99,8 @@ def _create_enter_scene_event(self, value: dict) -> None:
9599
key_occurrence=START_OCCURRENCE,
96100
key_frame=START_FRAME,
97101
key_video_name=START_VIDEO_NAME,
102+
key_geo_x=START_GEO_X,
103+
key_geo_y=START_GEO_Y,
98104
)
99105
self._events.append(event)
100106

@@ -107,6 +113,8 @@ def _create_leave_scene_event(self, value: dict) -> None:
107113
key_occurrence=END_OCCURRENCE,
108114
key_frame=END_FRAME,
109115
key_video_name=END_VIDEO_NAME,
116+
key_geo_x=END_GEO_X,
117+
key_geo_y=END_GEO_Y,
110118
)
111119
self._events.append(event)
112120

@@ -119,6 +127,8 @@ def __create_event(
119127
key_occurrence: str,
120128
key_frame: str,
121129
key_video_name: str,
130+
key_geo_x: str,
131+
key_geo_y: str,
122132
) -> Event:
123133
image_coordinate = ImageCoordinate(value[key_x], value[key_y])
124134
occurrence = value[key_occurrence]
@@ -140,6 +150,8 @@ def __create_event(
140150
video_name=value[key_video_name],
141151
interpolated_occurrence=occurrence,
142152
interpolated_event_coordinate=image_coordinate,
153+
geo_x=value.get(key_geo_x),
154+
geo_y=value.get(key_geo_y),
143155
)
144156

145157

OTAnalytics/application/export_formats/event_list.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
INTERPOLATED_EVENT_COORDINATE = event.INTERPOLATED_EVENT_COORDINATE
2222
INTERPOLATED_EVENT_COORDINATE_X = "interpolated_coordinate_px_x"
2323
INTERPOLATED_EVENT_COORDINATE_Y = "interpolated_coordinate_px_y"
24+
GEO_X: str = event.GEO_X
25+
GEO_Y: str = event.GEO_Y
2426

2527
DATE_FORMAT = "%Y-%m-%d"
2628
TIME_FORMAT = "%H:%M:%S.%f"

OTAnalytics/application/export_formats/road_user_assignments.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ def _prepend_end(key: str) -> str:
5353
END_INTERPOLATED_EVENT_COORD_Y = _prepend_end(
5454
event_list.INTERPOLATED_EVENT_COORDINATE_Y
5555
)
56+
START_GEO_X = _prepend_start(event_list.GEO_X)
57+
START_GEO_Y = _prepend_start(event_list.GEO_Y)
58+
END_GEO_X = _prepend_end(event_list.GEO_X)
59+
END_GEO_Y = _prepend_end(event_list.GEO_Y)
5660
HOSTNAME = event_list.HOSTNAME
5761

5862
DATE_FORMAT = event_list.DATE_FORMAT
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from abc import ABC, abstractmethod
2+
from dataclasses import dataclass
3+
from pathlib import Path
4+
5+
from OTAnalytics.domain.track_dataset.track_dataset import TrackDataset
6+
from OTAnalytics.domain.video import VideoMetadata
7+
8+
9+
@dataclass(frozen=True)
10+
class DetectionMetadata:
11+
detection_classes: frozenset[str]
12+
13+
14+
@dataclass(frozen=True)
15+
class TrackParseResult:
16+
tracks: TrackDataset
17+
detection_metadata: DetectionMetadata
18+
video_metadata: VideoMetadata
19+
20+
21+
@dataclass(frozen=True)
22+
class TracksParseResult:
23+
tracks: TrackDataset
24+
detections_metadata: list[DetectionMetadata]
25+
videos_metadata: list[VideoMetadata]
26+
27+
28+
def combine_track_datasets(results: list[TrackParseResult]) -> TrackDataset:
29+
if not results:
30+
raise ValueError("No results to combine")
31+
tracks = results[0].tracks
32+
for result in results[1:]:
33+
tracks = tracks.add_all(result.tracks)
34+
return tracks
35+
36+
37+
class TrackParser(ABC):
38+
def parse_files(self, files: list[Path]) -> TracksParseResult:
39+
if not files:
40+
raise ValueError("No files to parse")
41+
results = [self.parse(file) for file in files]
42+
tracks = self._combine_track_datasets(results)
43+
detections_metadata = [result.detection_metadata for result in results]
44+
videos_metadata = [result.video_metadata for result in results]
45+
return TracksParseResult(tracks, detections_metadata, videos_metadata)
46+
47+
def _combine_track_datasets(
48+
self, parse_results: list[TrackParseResult]
49+
) -> TrackDataset:
50+
return combine_track_datasets(parse_results)
51+
52+
@abstractmethod
53+
def parse(self, file: Path) -> TrackParseResult:
54+
raise NotImplementedError

OTAnalytics/application/use_cases/load_track_files.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from pathlib import Path
22

3-
from OTAnalytics.application.datastore import TrackParser, VideoParser
3+
from OTAnalytics.application.datastore import VideoParser
44
from OTAnalytics.application.logger import logger
5+
from OTAnalytics.application.parser.track_parser import TrackParser
56
from OTAnalytics.application.state import TracksMetadata, VideosMetadata
67
from OTAnalytics.domain.progress import ProgressbarBuilder
78
from OTAnalytics.domain.track_repository import TrackFileRepository, TrackRepository

OTAnalytics/application/use_cases/road_user_assignment_export.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@ def __create(self, assignment: RoadUserAssignment) -> dict:
9797
ras.START_INTERPOLATED_EVENT_COORD_Y: start.interpolated_event_coordinate.y,
9898
ras.END_INTERPOLATED_EVENT_COORD_X: end.interpolated_event_coordinate.x,
9999
ras.END_INTERPOLATED_EVENT_COORD_Y: end.interpolated_event_coordinate.y,
100+
ras.START_GEO_X: start.geo_x,
101+
ras.START_GEO_Y: start.geo_y,
102+
ras.END_GEO_X: end.geo_x,
103+
ras.END_GEO_Y: end.geo_y,
100104
}
101105

102106
def reset(self) -> None:
@@ -140,6 +144,10 @@ def reset(self) -> None:
140144
ras.START_INTERPOLATED_EVENT_COORD_Y,
141145
ras.END_INTERPOLATED_EVENT_COORD_X,
142146
ras.END_INTERPOLATED_EVENT_COORD_Y,
147+
ras.START_GEO_X,
148+
ras.START_GEO_Y,
149+
ras.END_GEO_X,
150+
ras.END_GEO_Y,
143151
]
144152

145153

OTAnalytics/domain/event.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
VIDEO_NAME = "video_name"
2727
INTERPOLATED_OCCURRENCE = "interpolated_occurrence"
2828
INTERPOLATED_EVENT_COORDINATE = "interpolated_event_coordinate"
29+
GEO_X: str = "geo_x"
30+
GEO_Y: str = "geo_y"
2931

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

8589
road_user_id: str
@@ -94,6 +98,8 @@ class Event(DataclassValidation):
9498
video_name: str
9599
interpolated_occurrence: datetime
96100
interpolated_event_coordinate: ImageCoordinate
101+
geo_x: float | None = None
102+
geo_y: float | None = None
97103

98104
def _validate(self) -> None:
99105
self._validate_frame_number_greater_equal_one()
@@ -129,6 +135,8 @@ def to_dict(self) -> dict:
129135
VIDEO_NAME: self.video_name,
130136
INTERPOLATED_OCCURRENCE: self.interpolated_occurrence.strftime(DATE_FORMAT),
131137
INTERPOLATED_EVENT_COORDINATE: self.interpolated_event_coordinate.to_list(),
138+
GEO_X: self.geo_x,
139+
GEO_Y: self.geo_y,
132140
}
133141

134142
def to_typed_dict(self) -> dict:
@@ -151,6 +159,8 @@ def to_typed_dict(self) -> dict:
151159
VIDEO_NAME: self.video_name,
152160
INTERPOLATED_OCCURRENCE: self.interpolated_occurrence,
153161
INTERPOLATED_EVENT_COORDINATE: self.interpolated_event_coordinate.to_list(),
162+
GEO_X: self.geo_x,
163+
GEO_Y: self.geo_y,
154164
}
155165

156166
def _serialized_section_id(self) -> Optional[str]:
@@ -172,6 +182,8 @@ def __init__(self) -> None:
172182
self.section_id: Optional[SectionId] = None
173183
self.interpolated_occurrence: Optional[datetime] = None
174184
self.interpolated_event_coordinate: Optional[ImageCoordinate] = None
185+
self.geo_x: float | None = None
186+
self.geo_y: float | None = None
175187

176188
@abstractmethod
177189
def create_event(self, detection: Detection) -> Event:
@@ -255,6 +267,16 @@ def add_interpolated_occurrence(self, occurrence: datetime) -> None:
255267
def add_interpolated_event_coordinate(self, x: float, y: float) -> None:
256268
self.interpolated_event_coordinate = ImageCoordinate(x, y)
257269

270+
def add_geo_coordinate(self, geo_x: float | None, geo_y: float | None) -> None:
271+
"""Add geo coordinates to the event to be built.
272+
273+
Args:
274+
geo_x: the geo x coordinate, or None if unavailable.
275+
geo_y: the geo y coordinate, or None if unavailable.
276+
"""
277+
self.geo_x = geo_x
278+
self.geo_y = geo_y
279+
258280

259281
class SectionEventBuilder(EventBuilder):
260282
"""A builder to build section events."""
@@ -320,6 +342,8 @@ def create_event(self, detection: Detection) -> Event:
320342
video_name=detection.video_name,
321343
interpolated_occurrence=self.interpolated_occurrence,
322344
interpolated_event_coordinate=self.interpolated_event_coordinate,
345+
geo_x=self.geo_x,
346+
geo_y=self.geo_y,
323347
)
324348

325349

@@ -369,6 +393,8 @@ def create_event(self, detection: Detection) -> Event:
369393
video_name=detection.video_name,
370394
interpolated_occurrence=detection.occurrence,
371395
interpolated_event_coordinate=self.event_coordinate,
396+
geo_x=self.geo_x,
397+
geo_y=self.geo_y,
372398
)
373399

374400

OTAnalytics/domain/georeference.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Domain model for georeference Birds-Eye-View coordinate metadata."""
2+
3+
from dataclasses import dataclass
4+
5+
6+
@dataclass(frozen=True)
7+
class GeoreferenceMetadata:
8+
"""Geo-referencing metadata from a georeferenced ottrk file.
9+
10+
Describes the affine mapping between Birds-Eye-View pixel coordinates and UTM
11+
geo coordinates for a single ottrk output file.
12+
13+
Attributes:
14+
geo_min_x (float): West boundary in UTM easting (metres).
15+
geo_min_y (float): South boundary in UTM northing (metres).
16+
geo_max_x (float): East boundary in UTM easting (metres).
17+
geo_max_y (float): North boundary in UTM northing (metres).
18+
birds_eye_view_width (int): Width of the BEV image in pixels.
19+
birds_eye_view_height (int): Height of the BEV image in pixels.
20+
padding (int): Pixel padding applied to all edges of the BEV image.
21+
crs (str): Coordinate reference system as a WKT or authority string.
22+
"""
23+
24+
geo_min_x: float
25+
geo_min_y: float
26+
geo_max_x: float
27+
geo_max_y: float
28+
birds_eye_view_width: int
29+
birds_eye_view_height: int
30+
padding: int
31+
crs: str
32+
33+
34+
def pixel_to_geo(
35+
x: float, y: float, metadata: GeoreferenceMetadata
36+
) -> tuple[float, float]:
37+
"""Convert a Birds-Eye-View pixel coordinate to UTM geo coordinate.
38+
39+
Args:
40+
x (float): Pixel x coordinate (column, increases rightward).
41+
y (float): Pixel y coordinate (row, increases downward).
42+
metadata (GeoreferenceMetadata): Metadata containing geo bounds and image size.
43+
44+
Returns:
45+
Tuple[float, float] in the same UTM coordinate system as the per-detection
46+
geo_x/geo_y fields.
47+
"""
48+
scale_x = (metadata.geo_max_x - metadata.geo_min_x) / (
49+
metadata.birds_eye_view_width - 2 * metadata.padding
50+
)
51+
scale_y = (metadata.geo_max_y - metadata.geo_min_y) / (
52+
metadata.birds_eye_view_height - 2 * metadata.padding
53+
)
54+
geo_x = metadata.geo_min_x + (x - metadata.padding) * scale_x
55+
geo_y = metadata.geo_max_y - (y - metadata.padding) * scale_y
56+
return geo_x, geo_y

OTAnalytics/domain/track.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
ORIGINAL_TRACK_ID: str = "original_track_id"
2121
VIDEO_NAME: str = "video_name"
2222
INPUT_FILE: str = "input_file"
23+
GEO_X: str = "geo_x"
24+
GEO_Y: str = "geo_y"
2325

2426

2527
@dataclass(frozen=True, order=True)
@@ -114,6 +116,16 @@ def video_name(self) -> str:
114116
def input_file(self) -> str:
115117
raise NotImplementedError
116118

119+
@property
120+
@abstractmethod
121+
def geo_x(self) -> float | None:
122+
raise NotImplementedError
123+
124+
@property
125+
@abstractmethod
126+
def geo_y(self) -> float | None:
127+
raise NotImplementedError
128+
117129
def to_dict(self) -> dict:
118130
return {
119131
CLASSIFICATION: self.classification,

0 commit comments

Comments
 (0)