irn-engine 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- irn/__init__.py +3 -0
- irn/cli.py +406 -0
- irn/common/crs.py +148 -0
- irn/common/io.py +40 -0
- irn/common/logger.py +58 -0
- irn/common/metrics.py +84 -0
- irn/m1_fetcher/__init__.py +6 -0
- irn/m1_fetcher/cleaner.py +230 -0
- irn/m1_fetcher/osm_fetcher.py +271 -0
- irn/m2_cv_interface/__init__.py +20 -0
- irn/m2_cv_interface/detector.py +196 -0
- irn/m2_cv_interface/idd_adapter.py +306 -0
- irn/m2_cv_interface/schema.py +177 -0
- irn/m3_fusion/__init__.py +44 -0
- irn/m3_fusion/camera_model.py +246 -0
- irn/m3_fusion/deduplication.py +247 -0
- irn/m3_fusion/linear_ref.py +99 -0
- irn/m3_fusion/map_matcher.py +327 -0
- irn/m3_fusion/pipeline.py +167 -0
- irn/m3_fusion/storage.py +232 -0
- irn/m3_fusion/sync.py +144 -0
- irn/m3_fusion/width_fusion.py +83 -0
- irn/m4_compiler/__init__.py +19 -0
- irn/m4_compiler/anomalies_generator.py +66 -0
- irn/m4_compiler/compiler.py +148 -0
- irn/m4_compiler/netconvert_runner.py +112 -0
- irn/m4_compiler/sumo_config_generator.py +67 -0
- irn/m4_compiler/sumo_plain_xml.py +127 -0
- irn/m4_compiler/validator.py +104 -0
- irn/m4_compiler/vtypes_generator.py +134 -0
- irn/m4_compiler/xodr_builder.py +139 -0
- irn/orchestrator.py +480 -0
- irn_engine-0.1.0.dist-info/METADATA +375 -0
- irn_engine-0.1.0.dist-info/RECORD +37 -0
- irn_engine-0.1.0.dist-info/WHEEL +5 -0
- irn_engine-0.1.0.dist-info/entry_points.txt +2 -0
- irn_engine-0.1.0.dist-info/top_level.txt +1 -0
irn/__init__.py
ADDED
irn/cli.py
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""IRN Command-Line Interface (CLI) orchestrated via Typer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from irn.common.logger import log_event, logger
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(
|
|
12
|
+
name="irn",
|
|
13
|
+
help="IRN: Automated Road Network Modeling Engine for Lane-Free Indian Traffic Simulations",
|
|
14
|
+
add_completion=False
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.command()
|
|
19
|
+
def info() -> None:
|
|
20
|
+
"""Display engine environment, dependencies, and CRS projection info."""
|
|
21
|
+
import osmnx as ox
|
|
22
|
+
import pyarrow as pa
|
|
23
|
+
import shapely
|
|
24
|
+
|
|
25
|
+
typer.echo("=================================================================")
|
|
26
|
+
typer.echo("IRN: Road Network Modeling Engine (SVIIT Indore Major Project)")
|
|
27
|
+
typer.echo("=================================================================")
|
|
28
|
+
typer.echo(f"OSMnx Version: {ox.__version__}")
|
|
29
|
+
typer.echo(f"Shapely Version: {shapely.__version__}")
|
|
30
|
+
typer.echo(f"PyArrow Version: {pa.__version__}")
|
|
31
|
+
typer.echo("Traffic Rule: Strict Left-Hand Traffic (LHT)")
|
|
32
|
+
typer.echo("Coordinate Grid: WGS84 Storage (EPSG:4326) / Local Projected UTM")
|
|
33
|
+
typer.echo("=================================================================")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command()
|
|
37
|
+
def run(
|
|
38
|
+
config: Path = typer.Option(
|
|
39
|
+
Path("configs/run_config.yaml"),
|
|
40
|
+
"--config",
|
|
41
|
+
help="Path to pipeline run_config.yaml"
|
|
42
|
+
),
|
|
43
|
+
out_dir: Path = typer.Option(
|
|
44
|
+
Path("output_scenario"),
|
|
45
|
+
"--out",
|
|
46
|
+
help="Destination directory for scenario bundle"
|
|
47
|
+
),
|
|
48
|
+
offline: bool = typer.Option(
|
|
49
|
+
True,
|
|
50
|
+
"--offline/--live",
|
|
51
|
+
help="Whether to use calibrated offline network fallback or query live Overpass API"
|
|
52
|
+
),
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Execute end-to-end IRN pipeline (M1 -> M2 -> M3 -> M4 -> Manifest)."""
|
|
55
|
+
from irn.orchestrator import PipelineOrchestrator
|
|
56
|
+
|
|
57
|
+
typer.secho("Starting End-to-End IRN Simulation Synthesis...", fg=typer.colors.CYAN)
|
|
58
|
+
orch = PipelineOrchestrator(config_path=config)
|
|
59
|
+
res = orch.run_end_to_end(output_dir=out_dir, use_offline_network=offline)
|
|
60
|
+
|
|
61
|
+
typer.secho(f"Scenario successfully synthesized at {out_dir}!", fg=typer.colors.GREEN)
|
|
62
|
+
report_text = orch.generate_report(out_dir)
|
|
63
|
+
typer.echo(report_text)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.command()
|
|
67
|
+
def report(
|
|
68
|
+
scenario: Path = typer.Option(
|
|
69
|
+
Path("output_scenario"),
|
|
70
|
+
"--scenario",
|
|
71
|
+
help="Path to compiled scenario directory"
|
|
72
|
+
),
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Display execution statistics and validation report for a scenario bundle."""
|
|
75
|
+
from irn.orchestrator import PipelineOrchestrator
|
|
76
|
+
|
|
77
|
+
orch = PipelineOrchestrator()
|
|
78
|
+
report_text = orch.generate_report(scenario)
|
|
79
|
+
typer.echo(report_text)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command()
|
|
83
|
+
def fetch(
|
|
84
|
+
bbox: Optional[str] = typer.Option(
|
|
85
|
+
None,
|
|
86
|
+
"--bbox",
|
|
87
|
+
help="Bounding box as 'west,south,east,north' in WGS84 degrees (EPSG:4326)"
|
|
88
|
+
),
|
|
89
|
+
place: Optional[str] = typer.Option(
|
|
90
|
+
None,
|
|
91
|
+
"--place",
|
|
92
|
+
help="OSM place query string (e.g. 'Gachibowli, Hyderabad, India')"
|
|
93
|
+
),
|
|
94
|
+
out_dir: Path = typer.Option(
|
|
95
|
+
Path("data/d1_graph"),
|
|
96
|
+
"--out",
|
|
97
|
+
help="Destination directory for D1 artifacts (edges.gpkg, nodes.gpkg, osm_snapshot.json)"
|
|
98
|
+
),
|
|
99
|
+
timeout: int = typer.Option(10, "--timeout", help="Overpass request timeout in seconds"),
|
|
100
|
+
offline: bool = typer.Option(False, "--offline", help="Generate calibrated offline network without external Overpass API request"),
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Fetch drivable road network from OSM, project to local UTM, clean, and export Contract D1."""
|
|
103
|
+
from irn.m1_fetcher.osm_fetcher import OSMFetcher
|
|
104
|
+
|
|
105
|
+
fetcher = OSMFetcher(timeout=timeout)
|
|
106
|
+
|
|
107
|
+
if bbox:
|
|
108
|
+
try:
|
|
109
|
+
parts = [float(x.strip()) for x in bbox.split(",")]
|
|
110
|
+
if len(parts) != 4:
|
|
111
|
+
raise ValueError("Must provide 4 float values: west,south,east,north")
|
|
112
|
+
bbox_tuple = (parts[0], parts[1], parts[2], parts[3])
|
|
113
|
+
except Exception as e:
|
|
114
|
+
typer.secho(f"Invalid bbox parameter: {e}", fg=typer.colors.RED)
|
|
115
|
+
raise typer.Exit(code=1)
|
|
116
|
+
|
|
117
|
+
typer.secho(f"Acquiring network for bbox: {bbox_tuple}...", fg=typer.colors.CYAN)
|
|
118
|
+
if offline:
|
|
119
|
+
G = fetcher.generate_offline_grid(bbox_tuple)
|
|
120
|
+
else:
|
|
121
|
+
G = fetcher.fetch_by_bbox(bbox_tuple)
|
|
122
|
+
query_meta = {"bbox": list(bbox_tuple), "offline": offline}
|
|
123
|
+
elif place:
|
|
124
|
+
typer.secho(f"Fetching network for place: '{place}'...", fg=typer.colors.CYAN)
|
|
125
|
+
G = fetcher.fetch_by_place(place)
|
|
126
|
+
query_meta = {"place": place}
|
|
127
|
+
else:
|
|
128
|
+
# Default bounding box: Gachibowli / IIIT-H corridor
|
|
129
|
+
bbox_tuple = (78.345, 17.440, 78.360, 17.452)
|
|
130
|
+
typer.secho(f"No area specified. Using default IIIT-H bbox: {bbox_tuple}...", fg=typer.colors.YELLOW)
|
|
131
|
+
if offline:
|
|
132
|
+
G = fetcher.generate_offline_grid(bbox_tuple)
|
|
133
|
+
else:
|
|
134
|
+
G = fetcher.fetch_by_bbox(bbox_tuple)
|
|
135
|
+
query_meta = {"bbox": list(bbox_tuple), "default": True, "offline": offline}
|
|
136
|
+
|
|
137
|
+
res = fetcher.process_and_export(G, out_dir, query_metadata=query_meta)
|
|
138
|
+
typer.secho(f"D1 Road Graph Exported Successfully to {out_dir}", fg=typer.colors.GREEN)
|
|
139
|
+
typer.echo(f" - Edges: {res['edges_gpkg']}")
|
|
140
|
+
typer.echo(f" - Nodes: {res['nodes_gpkg']}")
|
|
141
|
+
typer.echo(f" - Snapshot: {res['osm_snapshot_json']}")
|
|
142
|
+
typer.echo(f" - Projected UTM: EPSG:{res['utm_epsg']}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@app.command()
|
|
146
|
+
def fuse(
|
|
147
|
+
graph: Path = typer.Option(
|
|
148
|
+
Path("data/d1_graph/edges.gpkg"),
|
|
149
|
+
"--graph",
|
|
150
|
+
help="Path to D1 edges.gpkg"
|
|
151
|
+
),
|
|
152
|
+
detections: Path = typer.Option(
|
|
153
|
+
Path("data/d2_detections/detections.parquet"),
|
|
154
|
+
"--detections",
|
|
155
|
+
help="Path to D2 detections.parquet"
|
|
156
|
+
),
|
|
157
|
+
gps: Path = typer.Option(
|
|
158
|
+
Path("data/synthetic/d2_data/gps.csv"),
|
|
159
|
+
"--gps",
|
|
160
|
+
help="Path to GPS log CSV (gps.csv)"
|
|
161
|
+
),
|
|
162
|
+
rig: Path = typer.Option(
|
|
163
|
+
Path("configs/rig.yaml"),
|
|
164
|
+
"--rig",
|
|
165
|
+
help="Path to rig calibration YAML"
|
|
166
|
+
),
|
|
167
|
+
out_d3: Path = typer.Option(
|
|
168
|
+
Path("data/d3_store/anomalies.db"),
|
|
169
|
+
"--out-d3",
|
|
170
|
+
help="Path to SQLite ledger output (Contract D3)"
|
|
171
|
+
),
|
|
172
|
+
out_d4: Path = typer.Option(
|
|
173
|
+
Path("data/d4_enriched"),
|
|
174
|
+
"--out-d4",
|
|
175
|
+
help="Output directory for enriched GeoParquet (Contract D4)"
|
|
176
|
+
),
|
|
177
|
+
utm_zone: int = typer.Option(44, "--utm-zone", help="UTM zone number (default 44 for Hyderabad)"),
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Execute M3 Geospatial Fusion pipeline: ray projection, HMM map matching, (s,t) referencing, and de-duplication."""
|
|
180
|
+
from irn.m3_fusion.pipeline import run_geospatial_fusion
|
|
181
|
+
|
|
182
|
+
typer.secho("Launching M3 Geospatial Fusion Engine...", fg=typer.colors.CYAN)
|
|
183
|
+
res = run_geospatial_fusion(
|
|
184
|
+
edges_gpkg_path=graph,
|
|
185
|
+
detections_parquet_path=detections,
|
|
186
|
+
gps_csv_path=gps,
|
|
187
|
+
rig_config_path=rig,
|
|
188
|
+
utm_zone=utm_zone,
|
|
189
|
+
output_d3_db=out_d3,
|
|
190
|
+
output_d4_dir=out_d4,
|
|
191
|
+
)
|
|
192
|
+
typer.secho("M3 Fusion Complete!", fg=typer.colors.GREEN)
|
|
193
|
+
typer.echo(f" - D3 Ledger (SQLite): {res['d3_db']}")
|
|
194
|
+
typer.echo(f" - D4 Enriched Edges: {res['d4_edges_parquet']}")
|
|
195
|
+
typer.echo(f" - D4 Anomalies Parquet: {res['d4_anomalies_parquet']}")
|
|
196
|
+
typer.echo(f" - Total Observations: {res['num_raw_observations']}")
|
|
197
|
+
typer.echo(f" - Canonical Anomalies: {res['num_canonical_anomalies']} ({res['num_confirmed']} confirmed)")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@app.command()
|
|
201
|
+
def compile(
|
|
202
|
+
enriched: Path = typer.Option(
|
|
203
|
+
Path("data/d4_enriched/enriched_edges.parquet"),
|
|
204
|
+
"--enriched",
|
|
205
|
+
help="Path to D4 enriched_edges.parquet"
|
|
206
|
+
),
|
|
207
|
+
anomalies: Path = typer.Option(
|
|
208
|
+
Path("data/d4_enriched/anomalies.parquet"),
|
|
209
|
+
"--anomalies",
|
|
210
|
+
help="Path to D4 anomalies.parquet"
|
|
211
|
+
),
|
|
212
|
+
nodes: Path = typer.Option(
|
|
213
|
+
Path("data/synthetic/d1_graph/nodes.gpkg"),
|
|
214
|
+
"--nodes",
|
|
215
|
+
help="Path to D1 nodes.gpkg"
|
|
216
|
+
),
|
|
217
|
+
out_dir: Path = typer.Option(
|
|
218
|
+
Path("output_scenario"),
|
|
219
|
+
"--out",
|
|
220
|
+
help="Destination directory for scenario bundle"
|
|
221
|
+
),
|
|
222
|
+
lanefree_mode: str = typer.Option(
|
|
223
|
+
"collapse",
|
|
224
|
+
"--lanefree-mode",
|
|
225
|
+
help="Sublane mode: 'collapse' for continuous width or 'lanes'"
|
|
226
|
+
),
|
|
227
|
+
utm_zone: int = typer.Option(44, "--utm-zone", help="UTM zone number"),
|
|
228
|
+
) -> None:
|
|
229
|
+
"""Compile enriched network into Eclipse SUMO and ASAM OpenDRIVE 1.6.1 scenario."""
|
|
230
|
+
from irn.m4_compiler.compiler import compile_simulation_scenario
|
|
231
|
+
|
|
232
|
+
typer.secho("Compiling simulation scenario bundle...", fg=typer.colors.CYAN)
|
|
233
|
+
res = compile_simulation_scenario(
|
|
234
|
+
enriched_edges_path=enriched,
|
|
235
|
+
anomalies_parquet_path=anomalies,
|
|
236
|
+
nodes_gpkg_path=nodes,
|
|
237
|
+
out_dir=out_dir,
|
|
238
|
+
lanefree_mode=lanefree_mode,
|
|
239
|
+
utm_zone=utm_zone
|
|
240
|
+
)
|
|
241
|
+
typer.secho(f"Scenario compiled successfully in {out_dir}", fg=typer.colors.GREEN)
|
|
242
|
+
typer.echo(f" - SUMO Net: {res['network_net_xml']}")
|
|
243
|
+
typer.echo(f" - OpenDRIVE: {res['network_xodr']}")
|
|
244
|
+
typer.echo(f" - Fleet Profiles: {res['vtypes_add_xml']}")
|
|
245
|
+
typer.echo(f" - Anomalies: {res['anomalies_add_xml']}")
|
|
246
|
+
typer.echo(f" - SUMO Config: {res['scenario_sumocfg']}")
|
|
247
|
+
typer.echo(f" - Preview: {res['preview_geojson']}")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@app.command()
|
|
251
|
+
def detect(
|
|
252
|
+
sequence_id: str = typer.Option("seq_demo_01", "--seq", help="Identifier for IDD drive sequence"),
|
|
253
|
+
out_file: Path = typer.Option(
|
|
254
|
+
Path("data/d2_detections/detections.parquet"),
|
|
255
|
+
"--out",
|
|
256
|
+
help="Destination path for Contract D2 detections.parquet"
|
|
257
|
+
),
|
|
258
|
+
frames: int = typer.Option(60, "--frames", help="Number of frames to generate in mock mode"),
|
|
259
|
+
) -> None:
|
|
260
|
+
"""Run detection engine (or mock generator) and export Contract D2 detections.parquet."""
|
|
261
|
+
from irn.m2_cv_interface.detector import MockDetector
|
|
262
|
+
|
|
263
|
+
typer.secho(f"Running detection ingestion for sequence '{sequence_id}'...", fg=typer.colors.CYAN)
|
|
264
|
+
detector = MockDetector(sequence_id=sequence_id)
|
|
265
|
+
records = detector.generate_synthetic_drive_detections(num_frames=frames)
|
|
266
|
+
|
|
267
|
+
from irn.m2_cv_interface.schema import save_detections_parquet
|
|
268
|
+
out_path = save_detections_parquet(records, out_file)
|
|
269
|
+
typer.secho(f"D2 Detections Exported Successfully ({len(records)} items) to {out_path}", fg=typer.colors.GREEN)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@app.command("ingest-idd")
|
|
273
|
+
def ingest_idd(
|
|
274
|
+
seq_dir: Path = typer.Option(
|
|
275
|
+
Path(r"D:\final\DATASET\Primary\idd_multimodal\primary\d0"),
|
|
276
|
+
"--seq-dir",
|
|
277
|
+
help="Path to IDD Multimodal sequence directory (e.g. primary/d0)"
|
|
278
|
+
),
|
|
279
|
+
out_dir: Path = typer.Option(
|
|
280
|
+
Path("data/idd_d0"),
|
|
281
|
+
"--out",
|
|
282
|
+
help="Destination directory for normalized GPS and D2 Parquet"
|
|
283
|
+
),
|
|
284
|
+
pothole_model: Optional[Path] = typer.Option(
|
|
285
|
+
Path(r"D:\Major_2\roadscope\backend\models\potholes.pt") if Path(r"D:\Major_2\roadscope\backend\models\potholes.pt").exists() else None,
|
|
286
|
+
"--pothole-model",
|
|
287
|
+
help="Path to trained YOLO pothole detector weights (.pt)"
|
|
288
|
+
),
|
|
289
|
+
vehicle_model: Optional[Path] = typer.Option(
|
|
290
|
+
Path(r"D:\Major_2\roadscope\backend\yolo11n.pt") if Path(r"D:\Major_2\roadscope\backend\yolo11n.pt").exists() else None,
|
|
291
|
+
"--vehicle-model",
|
|
292
|
+
help="Path to YOLO vehicle detector weights (.pt)"
|
|
293
|
+
),
|
|
294
|
+
sample_step: int = typer.Option(10, "--sample-step", help="Subsample video frames by this factor"),
|
|
295
|
+
max_frames: Optional[int] = typer.Option(150, "--max-frames", help="Maximum frames to run inference on"),
|
|
296
|
+
seq_id: str = typer.Option("idd_d0", "--seq-id", help="Sequence ID identifier"),
|
|
297
|
+
) -> None:
|
|
298
|
+
"""Ingest real IDD Multimodal driving sequence, run YOLO inference, and export D2 parquet + normalized GPS."""
|
|
299
|
+
from irn.m2_cv_interface.idd_adapter import IDDAdapter
|
|
300
|
+
|
|
301
|
+
typer.secho(f"Ingesting IDD Multimodal sequence from: {seq_dir}", fg=typer.colors.CYAN)
|
|
302
|
+
adapter = IDDAdapter(sequence_id=seq_id)
|
|
303
|
+
res = adapter.process_sequence(
|
|
304
|
+
sequence_dir=seq_dir,
|
|
305
|
+
out_dir=out_dir,
|
|
306
|
+
pothole_weights=pothole_model,
|
|
307
|
+
vehicle_weights=vehicle_model,
|
|
308
|
+
sample_step=sample_step,
|
|
309
|
+
max_frames=max_frames,
|
|
310
|
+
)
|
|
311
|
+
typer.secho("IDD Multimodal Ingestion Successful!", fg=typer.colors.GREEN)
|
|
312
|
+
typer.echo(f" - Sequence ID: {res['sequence_id']}")
|
|
313
|
+
typer.echo(f" - Trajectory Bbox: {res['bbox']}")
|
|
314
|
+
typer.echo(f" - Normalized GPS: {res['gps_csv']} ({res['num_gps_fixes']} fixes)")
|
|
315
|
+
typer.echo(f" - D2 Detections: {res['detections_parquet']} ({res['num_detections']} records)")
|
|
316
|
+
typer.secho(f"\nNext Steps to run full pipeline on this sequence:", fg=typer.colors.YELLOW)
|
|
317
|
+
bbox_str = f"{res['bbox'][0]},{res['bbox'][1]},{res['bbox'][2]},{res['bbox'][3]}"
|
|
318
|
+
typer.echo(f" 1. Fetch OSM Graph: irn fetch --bbox \"{bbox_str}\" --out {out_dir}/d1_graph")
|
|
319
|
+
typer.echo(f" 2. Geospatial Fusion: irn fuse --graph {out_dir}/d1_graph/edges.gpkg --detections {res['detections_parquet']} --gps {res['gps_csv']} --out-d3 {out_dir}/d3_store/anomalies.db --out-d4 {out_dir}/d4_enriched --utm-zone 44")
|
|
320
|
+
typer.echo(f" 3. Compile Scenario: irn compile --enriched {out_dir}/d4_enriched/enriched_edges.parquet --anomalies {out_dir}/d4_enriched/anomalies.parquet --nodes {out_dir}/d1_graph/nodes.gpkg --out output_{seq_id} --utm-zone 44")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@app.command("batch-idd")
|
|
324
|
+
def batch_idd(
|
|
325
|
+
dataset_dir: Path = typer.Option(
|
|
326
|
+
Path(r"D:\final\DATASET\Primary\idd_multimodal\primary"),
|
|
327
|
+
"--dataset-dir",
|
|
328
|
+
help="Root path containing IDD sequence directories (d0, d1, d2...)"
|
|
329
|
+
),
|
|
330
|
+
out_dir: Path = typer.Option(
|
|
331
|
+
Path("scenarios"),
|
|
332
|
+
"--out",
|
|
333
|
+
help="Base directory for output scenarios and multi-pass corridor bundle"
|
|
334
|
+
),
|
|
335
|
+
sequences: Optional[str] = typer.Option(
|
|
336
|
+
None,
|
|
337
|
+
"--sequences",
|
|
338
|
+
help="Comma-separated sequence names to process (e.g. 'd0,d1,d2'). Default: all found"
|
|
339
|
+
),
|
|
340
|
+
pothole_model: Optional[Path] = typer.Option(
|
|
341
|
+
Path(r"D:\Major_2\roadscope\backend\models\potholes.pt") if Path(r"D:\Major_2\roadscope\backend\models\potholes.pt").exists() else None,
|
|
342
|
+
"--pothole-model",
|
|
343
|
+
help="Path to trained YOLO pothole detector weights (.pt)"
|
|
344
|
+
),
|
|
345
|
+
vehicle_model: Optional[Path] = typer.Option(
|
|
346
|
+
Path(r"D:\Major_2\roadscope\backend\yolo11n.pt") if Path(r"D:\Major_2\roadscope\backend\yolo11n.pt").exists() else None,
|
|
347
|
+
"--vehicle-model",
|
|
348
|
+
help="Path to YOLO vehicle detector weights (.pt)"
|
|
349
|
+
),
|
|
350
|
+
sample_step: int = typer.Option(10, "--sample-step", help="Subsample video frames by this factor"),
|
|
351
|
+
max_frames: Optional[int] = typer.Option(100, "--max-frames", help="Maximum frames per sequence to run inference on"),
|
|
352
|
+
unified: bool = typer.Option(True, "--unified/--isolated", help="Enable multi-pass Hungarian cross-pass fusion ledger"),
|
|
353
|
+
) -> None:
|
|
354
|
+
"""Run end-to-end IRN batch processing across all IDD multimodal sequences with multi-pass fusion."""
|
|
355
|
+
from irn.orchestrator import PipelineOrchestrator
|
|
356
|
+
|
|
357
|
+
seq_list = [s.strip() for s in sequences.split(",")] if sequences else None
|
|
358
|
+
orch = PipelineOrchestrator()
|
|
359
|
+
typer.secho(f"Starting IDD Batch Pipeline for dataset: {dataset_dir}", fg=typer.colors.CYAN)
|
|
360
|
+
|
|
361
|
+
batch_res = orch.run_idd_batch(
|
|
362
|
+
dataset_dir=dataset_dir,
|
|
363
|
+
output_base_dir=out_dir,
|
|
364
|
+
sequences=seq_list,
|
|
365
|
+
pothole_weights=pothole_model,
|
|
366
|
+
vehicle_weights=vehicle_model,
|
|
367
|
+
sample_step=sample_step,
|
|
368
|
+
max_frames=max_frames,
|
|
369
|
+
unified_multi_pass=unified,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
typer.secho("\n==================================================", fg=typer.colors.GREEN)
|
|
373
|
+
typer.secho("IDD MULTI-SEQUENCE BATCH PROCESSING COMPLETE!", fg=typer.colors.GREEN)
|
|
374
|
+
typer.secho("==================================================", fg=typer.colors.GREEN)
|
|
375
|
+
for seq, res in batch_res["sequence_results"].items():
|
|
376
|
+
st = res["manifest"]["statistics"]
|
|
377
|
+
typer.echo(f" * Sequence {seq}: {st['num_detections_ingested']} dets -> {st['num_canonical_anomalies']} anomalies mapped -> {res['output_dir']}")
|
|
378
|
+
|
|
379
|
+
if batch_res.get("corridor_scenario"):
|
|
380
|
+
typer.secho(f"\nUnified Multi-Pass Corridor Scenario compiled in: {out_dir}/corridor_multi_pass", fg=typer.colors.YELLOW)
|
|
381
|
+
typer.echo(f" - OpenDRIVE: {out_dir}/corridor_multi_pass/network.xodr")
|
|
382
|
+
typer.echo(f" - SUMO Config: {out_dir}/corridor_multi_pass/scenario.sumocfg")
|
|
383
|
+
typer.echo(f" - Preview: {out_dir}/corridor_multi_pass/preview.geojson")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@app.command()
|
|
387
|
+
def synth(
|
|
388
|
+
out_dir: Path = typer.Option(Path("data/synthetic"), "--out", help="Output directory for synthetic world"),
|
|
389
|
+
length: float = typer.Option(100.0, "--length", help="Length of road segment in meters"),
|
|
390
|
+
) -> None:
|
|
391
|
+
"""Generate mathematically verified synthetic ground truth world (NFR-QA-01)."""
|
|
392
|
+
from tests.fixtures.synthetic_world import SyntheticWorld
|
|
393
|
+
|
|
394
|
+
typer.secho(f"Building synthetic world of length {length}m...", fg=typer.colors.CYAN)
|
|
395
|
+
world = SyntheticWorld(road_length_m=length)
|
|
396
|
+
d1_res = world.build_synthetic_graph(out_dir / "d1_graph")
|
|
397
|
+
gps_path, d2_path = world.generate_trajectory_and_detections(out_dir / "d2_data")
|
|
398
|
+
|
|
399
|
+
typer.secho(f"Synthetic World generated at {out_dir}", fg=typer.colors.GREEN)
|
|
400
|
+
typer.echo(f" - Graph Edges: {d1_res['edges_gpkg']}")
|
|
401
|
+
typer.echo(f" - Detections: {d2_path}")
|
|
402
|
+
typer.echo(f" - Vehicle GPS: {gps_path}")
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
if __name__ == "__main__":
|
|
406
|
+
app()
|
irn/common/crs.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Coordinate Reference System (CRS) transformations and UTM utilities.
|
|
2
|
+
|
|
3
|
+
Strictly enforces Rule G-1:
|
|
4
|
+
- Stored geometry is always WGS84 (EPSG:4326).
|
|
5
|
+
- All metric spatial calculations use local projected UTM based on the area centroid.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import math
|
|
10
|
+
from typing import Tuple, Union
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
# WGS84 Ellipsoid constants
|
|
14
|
+
WGS84_A = 6378137.0 # semi-major axis (meters)
|
|
15
|
+
WGS84_F = 1.0 / 298.257223563 # flattening
|
|
16
|
+
WGS84_B = WGS84_A * (1.0 - WGS84_F) # semi-minor axis
|
|
17
|
+
WGS84_E2 = 2.0 * WGS84_F - WGS84_F**2 # first eccentricity squared
|
|
18
|
+
WGS84_E_PRIME2 = WGS84_E2 / (1.0 - WGS84_E2) # second eccentricity squared
|
|
19
|
+
UTM_K0 = 0.9996 # central scale factor
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def utm_zone_from_lon_lat(lon: float, lat: float) -> Tuple[int, bool]:
|
|
23
|
+
"""Calculate UTM zone number and hemisphere (True for North, False for South) from lon/lat."""
|
|
24
|
+
zone_number = int((lon + 180.0) / 6.0) + 1
|
|
25
|
+
if zone_number < 1:
|
|
26
|
+
zone_number = 1
|
|
27
|
+
elif zone_number > 60:
|
|
28
|
+
zone_number = 60
|
|
29
|
+
is_northern = lat >= 0.0
|
|
30
|
+
return zone_number, is_northern
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def epsg_from_utm_zone(zone_number: int, is_northern: bool = True) -> int:
|
|
34
|
+
"""Return EPSG code for a given UTM zone (e.g. 32643 for UTM 43N, 32644 for UTM 44N)."""
|
|
35
|
+
return (32600 if is_northern else 32700) + zone_number
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def optimal_utm_epsg(lon: float, lat: float) -> int:
|
|
39
|
+
"""Find the optimal UTM EPSG code for a given centroid (Rule G-1)."""
|
|
40
|
+
zone, north = utm_zone_from_lon_lat(lon, lat)
|
|
41
|
+
return epsg_from_utm_zone(zone, north)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def wgs84_to_utm(
|
|
45
|
+
lon: Union[float, np.ndarray],
|
|
46
|
+
lat: Union[float, np.ndarray],
|
|
47
|
+
zone_number: int,
|
|
48
|
+
is_northern: bool = True
|
|
49
|
+
) -> Tuple[Union[float, np.ndarray], Union[float, np.ndarray]]:
|
|
50
|
+
"""Vectorized conversion of (longitude, latitude) in degrees to UTM (easting, northing) in meters.
|
|
51
|
+
|
|
52
|
+
Uses high-precision Karney/Snyder Transverse Mercator formulation.
|
|
53
|
+
"""
|
|
54
|
+
lon_rad = np.radians(lon)
|
|
55
|
+
lat_rad = np.radians(lat)
|
|
56
|
+
|
|
57
|
+
lon_origin = (zone_number - 1) * 6.0 - 180.0 + 3.0
|
|
58
|
+
lon_origin_rad = np.radians(lon_origin)
|
|
59
|
+
|
|
60
|
+
sin_lat = np.sin(lat_rad)
|
|
61
|
+
cos_lat = np.cos(lat_rad)
|
|
62
|
+
tan_lat = np.tan(lat_rad)
|
|
63
|
+
|
|
64
|
+
N = WGS84_A / np.sqrt(1.0 - WGS84_E2 * sin_lat**2)
|
|
65
|
+
T = tan_lat**2
|
|
66
|
+
C = WGS84_E_PRIME2 * cos_lat**2
|
|
67
|
+
A = cos_lat * (lon_rad - lon_origin_rad)
|
|
68
|
+
|
|
69
|
+
# Meridional distance M
|
|
70
|
+
M = WGS84_A * (
|
|
71
|
+
(1.0 - WGS84_E2 / 4.0 - 3.0 * WGS84_E2**2 / 64.0 - 5.0 * WGS84_E2**3 / 256.0) * lat_rad
|
|
72
|
+
- (3.0 * WGS84_E2 / 8.0 + 3.0 * WGS84_E2**2 / 32.0 + 45.0 * WGS84_E2**3 / 1024.0) * np.sin(2.0 * lat_rad)
|
|
73
|
+
+ (15.0 * WGS84_E2**2 / 256.0 + 45.0 * WGS84_E2**3 / 1024.0) * np.sin(4.0 * lat_rad)
|
|
74
|
+
- (35.0 * WGS84_E2**3 / 3072.0) * np.sin(6.0 * lat_rad)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
easting = UTM_K0 * N * (
|
|
78
|
+
A
|
|
79
|
+
+ (1.0 - T + C) * A**3 / 6.0
|
|
80
|
+
+ (5.0 - 18.0 * T + T**2 + 72.0 * C - 58.0 * WGS84_E_PRIME2) * A**5 / 120.0
|
|
81
|
+
) + 500000.0
|
|
82
|
+
|
|
83
|
+
northing = UTM_K0 * (
|
|
84
|
+
M
|
|
85
|
+
+ N * tan_lat * (
|
|
86
|
+
A**2 / 2.0
|
|
87
|
+
+ (5.0 - T + 9.0 * C + 4.0 * C**2) * A**4 / 24.0
|
|
88
|
+
+ (61.0 - 58.0 * T + T**2 + 600.0 * C - 330.0 * WGS84_E_PRIME2) * A**6 / 720.0
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if not is_northern:
|
|
93
|
+
northing += 10000000.0
|
|
94
|
+
|
|
95
|
+
return easting, northing
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def utm_to_wgs84(
|
|
99
|
+
easting: Union[float, np.ndarray],
|
|
100
|
+
northing: Union[float, np.ndarray],
|
|
101
|
+
zone_number: int,
|
|
102
|
+
is_northern: bool = True
|
|
103
|
+
) -> Tuple[Union[float, np.ndarray], Union[float, np.ndarray]]:
|
|
104
|
+
"""Vectorized conversion of UTM (easting, northing) in meters to (longitude, latitude) in degrees."""
|
|
105
|
+
x = np.asarray(easting, dtype=float) - 500000.0
|
|
106
|
+
y = np.asarray(northing, dtype=float)
|
|
107
|
+
if not is_northern:
|
|
108
|
+
y -= 10000000.0
|
|
109
|
+
|
|
110
|
+
lon_origin = (zone_number - 1) * 6.0 - 180.0 + 3.0
|
|
111
|
+
|
|
112
|
+
M = y / UTM_K0
|
|
113
|
+
mu = M / (WGS84_A * (1.0 - WGS84_E2 / 4.0 - 3.0 * WGS84_E2**2 / 64.0 - 5.0 * WGS84_E2**3 / 256.0))
|
|
114
|
+
|
|
115
|
+
e1 = (1.0 - np.sqrt(1.0 - WGS84_E2)) / (1.0 + np.sqrt(1.0 - WGS84_E2))
|
|
116
|
+
|
|
117
|
+
phi1 = (
|
|
118
|
+
mu
|
|
119
|
+
+ (3.0 * e1 / 2.0 - 27.0 * e1**3 / 32.0) * np.sin(2.0 * mu)
|
|
120
|
+
+ (21.0 * e1**2 / 16.0 - 55.0 * e1**4 / 32.0) * np.sin(4.0 * mu)
|
|
121
|
+
+ (151.0 * e1**3 / 96.0) * np.sin(6.0 * mu)
|
|
122
|
+
+ (1097.0 * e1**4 / 512.0) * np.sin(8.0 * mu)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
sin_phi1 = np.sin(phi1)
|
|
126
|
+
cos_phi1 = np.cos(phi1)
|
|
127
|
+
tan_phi1 = np.tan(phi1)
|
|
128
|
+
|
|
129
|
+
N1 = WGS84_A / np.sqrt(1.0 - WGS84_E2 * sin_phi1**2)
|
|
130
|
+
R1 = WGS84_A * (1.0 - WGS84_E2) / ((1.0 - WGS84_E2 * sin_phi1**2) ** 1.5)
|
|
131
|
+
D = x / (N1 * UTM_K0)
|
|
132
|
+
|
|
133
|
+
T1 = tan_phi1**2
|
|
134
|
+
C1 = WGS84_E_PRIME2 * cos_phi1**2
|
|
135
|
+
|
|
136
|
+
lat_rad = phi1 - (N1 * tan_phi1 / R1) * (
|
|
137
|
+
D**2 / 2.0
|
|
138
|
+
- (5.0 + 3.0 * T1 + 10.0 * C1 - 4.0 * C1**2 - 9.0 * WGS84_E_PRIME2) * D**4 / 24.0
|
|
139
|
+
+ (61.0 + 90.0 * T1 + 298.0 * C1 + 45.0 * T1**2 - 252.0 * WGS84_E_PRIME2 - 3.0 * C1**2) * D**6 / 720.0
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
lon_rad = np.radians(lon_origin) + (
|
|
143
|
+
D
|
|
144
|
+
- (1.0 + 2.0 * T1 + C1) * D**3 / 6.0
|
|
145
|
+
+ (5.0 - 2.0 * C1 + 28.0 * T1 - 3.0 * C1**2 + 8.0 * WGS84_E_PRIME2 + 24.0 * T1**2) * D**5 / 120.0
|
|
146
|
+
) / cos_phi1
|
|
147
|
+
|
|
148
|
+
return np.degrees(lon_rad), np.degrees(lat_rad)
|
irn/common/io.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Atomic file I/O utilities enforcing Constraint C-7."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, Union
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def atomic_write_text(filepath: Union[str, Path], content: str, encoding: str = "utf-8") -> Path:
|
|
12
|
+
"""Atomically write text to filepath via a temporary sibling file."""
|
|
13
|
+
path = Path(filepath).resolve()
|
|
14
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
|
|
16
|
+
with tempfile.NamedTemporaryFile("w", dir=path.parent, delete=False, encoding=encoding) as tmp:
|
|
17
|
+
tmp.write(content)
|
|
18
|
+
tmp_name = tmp.name
|
|
19
|
+
|
|
20
|
+
os.replace(tmp_name, path)
|
|
21
|
+
return path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def atomic_write_json(filepath: Union[str, Path], data: Any, indent: int = 2) -> Path:
|
|
25
|
+
"""Atomically write Python object to JSON file."""
|
|
26
|
+
content = json.dumps(data, indent=indent, default=str)
|
|
27
|
+
return atomic_write_text(filepath, content)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def atomic_write_bytes(filepath: Union[str, Path], data: bytes) -> Path:
|
|
31
|
+
"""Atomically write raw bytes to filepath."""
|
|
32
|
+
path = Path(filepath).resolve()
|
|
33
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
|
|
35
|
+
with tempfile.NamedTemporaryFile("wb", dir=path.parent, delete=False) as tmp:
|
|
36
|
+
tmp.write(data)
|
|
37
|
+
tmp_name = tmp.name
|
|
38
|
+
|
|
39
|
+
os.replace(tmp_name, path)
|
|
40
|
+
return path
|
irn/common/logger.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Structured logging system with reason codes and standardized formatting."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
# ANSI Colors
|
|
8
|
+
CYAN = "\033[96m"
|
|
9
|
+
GREEN = "\033[92m"
|
|
10
|
+
YELLOW = "\033[93m"
|
|
11
|
+
RED = "\033[91m"
|
|
12
|
+
BOLD = "\033[1m"
|
|
13
|
+
RESET = "\033[0m"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ReasonCodeFormatter(logging.Formatter):
|
|
17
|
+
"""Formats logs with timestamp, module, reason code, and message."""
|
|
18
|
+
|
|
19
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
20
|
+
reason_code = getattr(record, "reason_code", "GEN-000")
|
|
21
|
+
color = ""
|
|
22
|
+
if record.levelno >= logging.ERROR:
|
|
23
|
+
color = RED
|
|
24
|
+
elif record.levelno >= logging.WARNING:
|
|
25
|
+
color = YELLOW
|
|
26
|
+
elif record.levelno >= logging.INFO:
|
|
27
|
+
color = CYAN
|
|
28
|
+
|
|
29
|
+
timestamp = self.formatTime(record, "%Y-%m-%d %H:%M:%S")
|
|
30
|
+
prefix = f"{color}[{timestamp}] [{record.levelname:<7}] [{reason_code}]{RESET}"
|
|
31
|
+
return f"{prefix} {record.getMessage()}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def setup_logger(name: str = "irn", level: int = logging.INFO) -> logging.Logger:
|
|
35
|
+
"""Configure and return the root IRN logger."""
|
|
36
|
+
logger = logging.getLogger(name)
|
|
37
|
+
logger.setLevel(level)
|
|
38
|
+
|
|
39
|
+
if not logger.handlers:
|
|
40
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
41
|
+
handler.setLevel(level)
|
|
42
|
+
handler.setFormatter(ReasonCodeFormatter())
|
|
43
|
+
logger.addHandler(handler)
|
|
44
|
+
|
|
45
|
+
return logger
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
logger = setup_logger("irn")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def log_event(
|
|
52
|
+
msg: str,
|
|
53
|
+
reason_code: str = "INFO-001",
|
|
54
|
+
level: int = logging.INFO,
|
|
55
|
+
**kwargs
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Helper to log structured events with explicit reason codes."""
|
|
58
|
+
logger.log(level, msg, extra={"reason_code": reason_code}, **kwargs)
|