Skip to content

Commit d107760

Browse files
authored
Merge pull request #686 from OpenTrafficCam/feature/7883-add-count-plotters
Feature/7883 add count plotters
2 parents d11ea72 + 0664455 commit d107760

14 files changed

Lines changed: 1016 additions & 82 deletions

File tree

OTAnalytics/adapter_ui/dummy_viewmodel.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1292,6 +1292,10 @@ def create_events(self) -> None:
12921292
initial_position=self.get_position(),
12931293
)
12941294
self._application.create_events()
1295+
1296+
# TODO find appropriate place to trigger update of plots
1297+
# self._application.update_count_plots()
1298+
12951299
self.notify_flows(self.get_all_flow_ids())
12961300
start_msg_popup.update_message(message="Creating events completed")
12971301
sleep(1)

OTAnalytics/application/analysis/traffic_counting.py

Lines changed: 113 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1+
import re
2+
import unicodedata
13
from abc import ABC, abstractmethod
24
from collections import defaultdict
35
from dataclasses import dataclass
46
from datetime import datetime, timedelta, timezone
7+
from pathlib import Path
58
from typing import Callable, Iterable, Optional
69

10+
from PIL.Image import Image
11+
712
from OTAnalytics.application.analysis.traffic_counting_specification import (
813
CountingSpecificationDto,
914
ExportCounts,
@@ -19,6 +24,8 @@
1924
from OTAnalytics.domain.section import Section, SectionId
2025
from OTAnalytics.domain.types import EventType
2126

27+
DATETIME_FORMAT = "%Y-%m-%d_%H-%M-%S"
28+
2229
LEVEL_FROM_SECTION = "from section"
2330
LEVEL_TO_SECTION = "to section"
2431
LEVEL_FLOW = "flow"
@@ -995,9 +1002,9 @@ def create_export_specification(
9951002
return ExportSpecificationDto(counting_specification, flow_dtos)
9961003

9971004

998-
class ExportTrafficCounting(ExportCounts):
1005+
class TrafficCounting:
9991006
"""
1000-
Use case to export traffic counting.
1007+
Use case to produce traffic counts.
10011008
"""
10021009

10031010
def __init__(
@@ -1008,36 +1015,82 @@ def __init__(
10081015
create_events: CreateEvents,
10091016
assigner: RoadUserAssigner,
10101017
tagger_factory: TaggerFactory,
1011-
exporter_factory: ExporterFactory,
1012-
) -> None:
1018+
):
10131019
self._event_repository = event_repository
10141020
self._flow_repository = flow_repository
10151021
self._get_sections_by_id = get_sections_by_id
10161022
self._create_events = create_events
10171023
self._assigner = assigner
10181024
self._tagger_factory = tagger_factory
1019-
self._exporter_factory = exporter_factory
10201025

1021-
def export(self, specification: CountingSpecificationDto) -> None:
1026+
def count(self, specification: CountingSpecificationDto) -> Count:
10221027
"""
1023-
Export the traffic countings based on the currently available events and flows.
1028+
Produce traffic counts based on the currently available events and flows.
10241029
10251030
Args:
10261031
specification (CountingSpecificationDto): specification of the export
10271032
"""
10281033
if self._event_repository.is_empty():
10291034
self._create_events()
1030-
events = self._event_repository.get(
1031-
start_date=specification.start,
1032-
end_date=specification.end,
1033-
)
1034-
flows = self._flow_repository.get_all()
1035+
1036+
if specification.count_all_events:
1037+
events = self._event_repository.get_all()
1038+
else:
1039+
events = self._event_repository.get(
1040+
start_date=specification.start,
1041+
end_date=specification.end,
1042+
)
1043+
1044+
flows = self.get_flows()
10351045
assigned_flows = self._assigner.assign(events, flows)
10361046
tagger = self._tagger_factory.create_tagger(specification)
10371047
tagged_assignments = assigned_flows.tag(tagger)
1038-
counts = tagged_assignments.count(flows)
1048+
return tagged_assignments.count(flows)
1049+
1050+
def get_flows(self) -> list[Flow]:
1051+
return self._flow_repository.get_all()
1052+
1053+
def with_tagger_factory(
1054+
self, new_tagger_factory: TaggerFactory
1055+
) -> "TrafficCounting":
1056+
return TrafficCounting(
1057+
self._event_repository,
1058+
self._flow_repository,
1059+
self._get_sections_by_id,
1060+
self._create_events,
1061+
self._assigner,
1062+
new_tagger_factory,
1063+
)
1064+
1065+
def provide_get_sections_by_id(self) -> GetSectionsById:
1066+
return self._get_sections_by_id
1067+
1068+
1069+
class ExportTrafficCounting(ExportCounts):
1070+
"""
1071+
Use case to export traffic counting.
1072+
"""
1073+
1074+
def __init__(
1075+
self,
1076+
traffic_counting: TrafficCounting,
1077+
exporter_factory: ExporterFactory,
1078+
) -> None:
1079+
self._traffic_counting = traffic_counting
1080+
self._exporter_factory = exporter_factory
1081+
1082+
def export(self, specification: CountingSpecificationDto) -> None:
1083+
"""
1084+
Export the traffic countings based on the currently available events and flows.
1085+
1086+
Args:
1087+
specification (CountingSpecificationDto): specification of the export
1088+
"""
1089+
counts = self._traffic_counting.count(specification)
1090+
1091+
flows = self._traffic_counting.get_flows()
10391092
export_specification = create_export_specification(
1040-
flows, specification, self._get_sections_by_id
1093+
flows, specification, self._traffic_counting.provide_get_sections_by_id()
10411094
)
10421095
exporter = self._exporter_factory.create_exporter(export_specification)
10431096
exporter.export(counts, specification.export_mode)
@@ -1050,3 +1103,49 @@ def get_supported_formats(self) -> Iterable[ExportFormat]:
10501103
Iterable[ExportFormat]: supported export formats
10511104
"""
10521105
return self._exporter_factory.get_supported_formats()
1106+
1107+
1108+
@dataclass(frozen=True)
1109+
class CountImage:
1110+
"""
1111+
Represents an image with a counts plot.
1112+
"""
1113+
1114+
image: Image
1115+
width: int
1116+
height: int
1117+
name: str
1118+
timestamp: datetime
1119+
1120+
def save(self, save_dir: Path) -> None:
1121+
time = self.timestamp.strftime(DATETIME_FORMAT)
1122+
1123+
save_dir.mkdir(parents=True, exist_ok=True)
1124+
1125+
self.image.save(save_dir / f"counts_plot_{self.safe_filename()}_{time}.png")
1126+
1127+
def safe_filename(self, max_length: int = 255, replacement: str = "_") -> str:
1128+
"""Create a string from the image name that can be used as the filename.
1129+
1130+
Use ascii only and replace illegal characters (/:*?"<>|),
1131+
whitespaces and linebreaks.
1132+
Remove sequences of replacement char and truncate to max_length.
1133+
1134+
Args:
1135+
max_length (int, optional): length for truncating the resulting file name.
1136+
Defaults to 255.
1137+
replacement (str, optional): character to replace illegal characters
1138+
in image name. Defaults to "_".
1139+
1140+
Returns:
1141+
str: string safe to use as filename
1142+
"""
1143+
safe = (
1144+
unicodedata.normalize("NFKD", self.name).encode("ascii", "ignore").decode()
1145+
)
1146+
safe = re.sub(r'[<>:"/\\|?*\n\r\t]', replacement, safe)
1147+
safe = re.sub(r"\s+", replacement, safe)
1148+
safe = re.sub(rf"{re.escape(replacement)}+", replacement, safe)
1149+
safe = safe.strip(replacement)
1150+
# Truncate to max length (preserving file extension if needed)
1151+
return safe[:max_length]

OTAnalytics/application/analysis/traffic_counting_specification.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class CountingSpecificationDto:
2525
output_format: str
2626
output_file: str
2727
export_mode: ExportMode
28+
count_all_events: bool = False
2829

2930

3031
@dataclass(frozen=True)

OTAnalytics/application/application.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@
7171
ExportTrackStatistics,
7272
TrackStatisticsExportSpecification,
7373
)
74+
from OTAnalytics.application.use_cases.update_count_plots import CountPlotsUpdater
7475
from OTAnalytics.application.use_cases.update_project import ProjectUpdater
7576
from OTAnalytics.domain.date import DateRange
7677
from OTAnalytics.domain.filter import FilterElement, FilterElementSettingRestorer
@@ -147,6 +148,7 @@ def __init__(
147148
number_of_tracks_assigned_to_each_flow: NumberOfTracksAssignedToEachFlow,
148149
export_track_statistics: ExportTrackStatistics,
149150
get_current_remark: GetCurrentRemark,
151+
update_count_plots: CountPlotsUpdater,
150152
) -> None:
151153
self._datastore: Datastore = datastore
152154
self.track_state: TrackState = track_state
@@ -193,6 +195,7 @@ def __init__(
193195
)
194196
self._export_track_statistics = export_track_statistics
195197
self._get_current_remark = get_current_remark
198+
self._update_count_plots = update_count_plots
196199

197200
def connect_observers(self) -> None:
198201
"""
@@ -708,6 +711,9 @@ def get_track_statistics_export_formats(
708711
) -> Iterable[ExportFormat]:
709712
return self._export_track_statistics.get_supported_formats()
710713

714+
def update_count_plots(self) -> None:
715+
self._update_count_plots.update()
716+
711717

712718
class MissingTracksError(Exception):
713719
pass

OTAnalytics/application/state.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from pathlib import Path
66
from typing import Callable, Generic, Optional
77

8+
from OTAnalytics.application.analysis.traffic_counting import CountImage
89
from OTAnalytics.application.config import DEFAULT_TRACK_OFFSET
910
from OTAnalytics.application.datastore import Datastore
1011
from OTAnalytics.application.playback import SkipTime
@@ -206,6 +207,8 @@ def __init__(self) -> None:
206207
](default=[])
207208
self.skip_time = ObservableProperty[SkipTime](DEFAULT_SKIP_TIME)
208209

210+
self.count_plots = ObservableProperty[list[CountImage]](default=[])
211+
209212
def reset(self) -> None:
210213
"""Reset to default settings."""
211214
self.selected_videos.set([])
@@ -217,6 +220,8 @@ def reset(self) -> None:
217220
self.track_offset.set(DEFAULT_TRACK_OFFSET)
218221
self.skip_time.set(DEFAULT_SKIP_TIME)
219222

223+
self.count_plots.set([])
224+
220225

221226
class LiveImage:
222227
"""
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from pathlib import Path
2+
3+
from OTAnalytics.application.analysis.traffic_counting import CountImage
4+
from OTAnalytics.application.state import TrackViewState
5+
from OTAnalytics.plugin_ui.visualization.counts.counts_plotter import CountPlotter
6+
7+
8+
class CountPlotsUpdater:
9+
10+
def __init__(self, state: TrackViewState, plotter: CountPlotter) -> None:
11+
self._state = state
12+
self._plotter = plotter
13+
14+
def __call__(self) -> None:
15+
self.update()
16+
17+
def update(self) -> None:
18+
width = self._state.view_width.get()
19+
height = self._state.view_height.get()
20+
plots = self._plotter.plot(width, height)
21+
22+
self._state.count_plots.set(plots)
23+
24+
25+
class CountPlotSaver:
26+
27+
def __init__(self, path: Path):
28+
self._path = path
29+
30+
def __call__(self, plots: list[CountImage]) -> None:
31+
self.save(plots)
32+
33+
def save(self, plots: list[CountImage]) -> None:
34+
for plot in plots:
35+
plot.save(self._path)

0 commit comments

Comments
 (0)