From b3587bee518d96f4572cd8dce7585e46048a8af5 Mon Sep 17 00:00:00 2001 From: nq Date: Sun, 3 May 2026 02:00:14 -0700 Subject: [PATCH] stage-3-roads - NON WORKING --- .../garmin_img_roads.cpython-314.pyc | Bin 0 -> 31738 bytes stage-3-parsing-roads/garmin_img_roads.py | 386 +++++++ stage-3-parsing-roads/readme.md | 144 +++ .../roads_stage2_configurable.py | 969 ++++++++++++++++++ stage-3-parsing-roads/run.py | 202 ++++ 5 files changed, 1701 insertions(+) create mode 100644 stage-3-parsing-roads/__pycache__/garmin_img_roads.cpython-314.pyc create mode 100644 stage-3-parsing-roads/garmin_img_roads.py create mode 100644 stage-3-parsing-roads/readme.md create mode 100644 stage-3-parsing-roads/roads_stage2_configurable.py create mode 100644 stage-3-parsing-roads/run.py diff --git a/stage-3-parsing-roads/__pycache__/garmin_img_roads.cpython-314.pyc b/stage-3-parsing-roads/__pycache__/garmin_img_roads.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c023295f40ae0f7a437bc0d9b89fe2abc3bc1e55 GIT binary patch literal 31738 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 WIoD6V?v2?>B)$al1ipy%#Qy=+5!lE8 literal 0 HcmV?d00001 diff --git a/stage-3-parsing-roads/garmin_img_roads.py b/stage-3-parsing-roads/garmin_img_roads.py new file mode 100644 index 0000000..775caff --- /dev/null +++ b/stage-3-parsing-roads/garmin_img_roads.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import csv +import gzip +import hashlib +import importlib.util +import json +import math +import sys +from collections import Counter +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +EARTH_M_PER_DEG_LAT = 111_320.0 + + +def info(msg: str) -> None: + print(f"[info] {msg}", file=sys.stderr) + + +def warn(msg: str) -> None: + print(f"[warn] {msg}", file=sys.stderr) + + +def load_stage1_module(path: Optional[Path] = None): + path = path or (Path(__file__).with_name('garmin_img_to_osmand_v6.py')) + spec = importlib.util.spec_from_file_location('garmin_img_stage1_v6', path) + if spec is None or spec.loader is None: + raise RuntimeError(f'cannot load stage1 module from {path}') + mod = importlib.util.module_from_spec(spec) + sys.modules['garmin_img_stage1_v6'] = mod + spec.loader.exec_module(mod) + return mod + + +def open_text(path: Path, mode: str): + if str(path).lower().endswith('.gz'): + return gzip.open(path, mode + 't', encoding='utf-8', newline='') + return open(path, mode, encoding='utf-8', newline='') + + +def meters_per_deg_lon(lat_deg: float) -> float: + return EARTH_M_PER_DEG_LAT * max(0.01, math.cos(math.radians(lat_deg))) + + +def line_length_m(coords: List[List[float]]) -> float: + total = 0.0 + for (ax, ay), (bx, by) in zip(coords, coords[1:]): + mean_lat = (ay + by) / 2.0 + dx = (bx - ax) * meters_per_deg_lon(mean_lat) + dy = (by - ay) * EARTH_M_PER_DEG_LAT + total += math.hypot(dx, dy) + return total + + +def line_bbox(coords: List[List[float]]) -> Tuple[float, float, float, float]: + xs = [p[0] for p in coords] + ys = [p[1] for p in coords] + return min(xs), min(ys), max(xs), max(ys) + + +def line_centroid(coords: List[List[float]]) -> Tuple[float, float]: + n = max(1, len(coords)) + return sum(p[0] for p in coords) / n, sum(p[1] for p in coords) / n + + +def line_endpoints(coords: List[List[float]]) -> Tuple[float, float, float, float]: + a = coords[0] + b = coords[-1] + return a[0], a[1], b[0], b[1] + + +def is_closed(coords: List[List[float]], tol_m: float = 5.0) -> bool: + if len(coords) < 3: + return False + a = coords[0] + b = coords[-1] + mean_lat = (a[1] + b[1]) / 2.0 + dx = (b[0] - a[0]) * meters_per_deg_lon(mean_lat) + dy = (b[1] - a[1]) * EARTH_M_PER_DEG_LAT + return math.hypot(dx, dy) <= tol_m + + +def stable_line_hash(coords: List[List[float]], garmin_kind: str, garmin_type: str, garmin_subtype: str, *, grid_m: float = 12.0) -> str: + if not coords: + seed = f'{garmin_kind}|{garmin_type}|{garmin_subtype}|empty' + return hashlib.blake2b(seed.encode('utf-8'), digest_size=8).hexdigest().upper()[:12] + lon0, lat0 = line_centroid(coords) + pts = [] + for lon, lat in coords: + qx = round((lon - lon0) * meters_per_deg_lon(lat0) / grid_m) + qy = round((lat - lat0) * EARTH_M_PER_DEG_LAT / grid_m) + pts.append(f'{qx}:{qy}') + a = '|'.join(pts) + b = '|'.join(reversed(pts)) + canon = min(a, b) + seed = f'{garmin_kind}|{garmin_type}|{garmin_subtype}|{len(coords)}|{canon}' + return hashlib.blake2b(seed.encode('utf-8'), digest_size=8).hexdigest().upper()[:12] + + +def endpoint_hash(coords: List[List[float]], *, grid_m: float = 8.0) -> str: + if not coords: + return '' + ax, ay = coords[0] + bx, by = coords[-1] + lat0 = (ay + by) / 2.0 + p1 = (round(ax * meters_per_deg_lon(lat0) / grid_m), round(ay * EARTH_M_PER_DEG_LAT / grid_m)) + p2 = (round(bx * meters_per_deg_lon(lat0) / grid_m), round(by * EARTH_M_PER_DEG_LAT / grid_m)) + a, b = sorted([p1, p2]) + raw = f'{a[0]}:{a[1]}|{b[0]}:{b[1]}' + return hashlib.blake2b(raw.encode('utf-8'), digest_size=6).hexdigest().upper()[:10] + + +def parse_near(text: Optional[str]) -> Optional[Tuple[float, float, float]]: + if not text: + return None + parts = [p.strip() for p in text.split(',')] + if len(parts) not in (2, 3): + raise SystemExit('--near must be lon,lat[,radius_m]') + lon = float(parts[0]); lat = float(parts[1]); radius = float(parts[2]) if len(parts) == 3 else 50.0 + return lon, lat, radius + + +def line_intersects_near(coords: List[List[float]], near: Tuple[float, float, float]) -> bool: + lon, lat, radius_m = near + for x, y in coords: + mean_lat = (lat + y) / 2.0 + dx = (x - lon) * meters_per_deg_lon(mean_lat) + dy = (y - lat) * EARTH_M_PER_DEG_LAT + if math.hypot(dx, dy) <= radius_m: + return True + return False + + +def parse_tag_filters(values: Optional[List[str]]) -> List[Tuple[str, str]]: + out = [] + for v in values or []: + if '=' not in v: + raise SystemExit(f'invalid --filter-tag value {v!r}, expected key=value') + k, val = v.split('=', 1) + out.append((k.strip(), val.strip())) + return out + + +def infer_line_class(sem: Dict[str, str], gpxsee_classes: List[str]) -> str: + if sem.get('highway'): + return f'highway:{sem["highway"]}' + if sem.get('railway'): + return f'railway:{sem["railway"]}' + if sem.get('route'): + return f'route:{sem["route"]}' + if sem.get('waterway'): + return f'waterway:{sem["waterway"]}' + if sem.get('natural') == 'coastline': + return 'natural:coastline' + classes = {c.lower() for c in gpxsee_classes} + if 'contour_line' in classes: + return 'contour:elevation' + if 'cartographic_line' in classes: + return 'cartographic:line' + if 'styled_line' in classes: + return 'styled:line' + return 'raw' + + +def default_group_key(sem: Dict[str, str], gpxsee_classes: List[str], garmin_kind: str, garmin_type: str, garmin_subtype: str) -> str: + line_class = infer_line_class(sem, gpxsee_classes) + if line_class != 'raw': + return line_class + return f'raw:{garmin_kind}:{garmin_type}:{garmin_subtype}' + + +def road_interest_score(sem: Dict[str, str], gpxsee_classes: List[str], length_m: float) -> int: + score = 0 + if sem.get('highway'): + score += 60 + if sem.get('route') == 'ferry' or sem.get('railway'): + score += 40 + if sem.get('highway') in {'motorway', 'primary', 'secondary', 'tertiary'}: + score += 20 + if sem.get('highway') in {'track', 'path', 'cycleway', 'bridleway'}: + score += 10 + if 'contour_line' in {c.lower() for c in gpxsee_classes}: + score -= 40 + if sem.get('waterway'): + score -= 20 + score += min(20, int(length_m // 500)) + return score + + +def profile_accepts(sem: Dict[str, str], gpxsee_classes: List[str], profile: str) -> bool: + classes = {c.lower() for c in gpxsee_classes} + if profile == 'all_lines': + return True + if profile == 'roads': + return 'highway' in sem + if profile == 'roads_paths': + return 'highway' in sem or sem.get('route') == 'ferry' + if profile == 'roads_strict': + return sem.get('highway') in {'motorway','primary','secondary','tertiary','unclassified','residential','service','road'} + if profile == 'transport': + return any(k in sem for k in ('highway', 'railway', 'route', 'aerialway', 'aeroway')) + if profile == 'hydro': + return 'waterway' in sem or sem.get('natural') == 'coastline' + if profile == 'contours': + return 'contour_line' in classes or sem.get('contour') == 'elevation' + if profile == 'cartographic': + return 'cartographic_line' in classes or 'styled_line' in classes + if profile == 'nonroads': + return ('highway' not in sem) and (profile_accepts(sem, gpxsee_classes, 'all_lines')) + raise ValueError(f'unknown profile {profile!r}') + + +def normalize_row(f: Any, sem: Dict[str, str], gpxsee_classes: List[str], coords: List[List[float]]) -> Dict[str, Any]: + min_lon, min_lat, max_lon, max_lat = line_bbox(coords) + start_lon, start_lat, end_lon, end_lat = line_endpoints(coords) + length_m = line_length_m(coords) + feature_id = stable_line_hash(coords, str(f.props.get('garmin_kind') or ''), str(f.props.get('garmin_type') or ''), str(f.props.get('garmin_subtype') or '')) + centroid_lon, centroid_lat = line_centroid(coords) + group_key = default_group_key(sem, gpxsee_classes, str(f.props.get('garmin_kind') or ''), str(f.props.get('garmin_type') or ''), str(f.props.get('garmin_subtype') or '')) + row = { + 'mapset': str(f.props.get('mapset') or ''), + 'feature_id': feature_id, + 'endpoint_hash': endpoint_hash(coords), + 'name': str(sem.get('name') or f.props.get('name') or ''), + 'garmin_kind': str(f.props.get('garmin_kind') or ''), + 'garmin_type': str(f.props.get('garmin_type') or ''), + 'garmin_subtype': str(f.props.get('garmin_subtype') or ''), + 'line_class': infer_line_class(sem, gpxsee_classes), + 'road_group_key': group_key, + 'highway': sem.get('highway', ''), + 'railway': sem.get('railway', ''), + 'route': sem.get('route', ''), + 'waterway': sem.get('waterway', ''), + 'aerialway': sem.get('aerialway', ''), + 'aeroway': sem.get('aeroway', ''), + 'natural': sem.get('natural', ''), + 'surface': sem.get('surface', ''), + 'tracktype': sem.get('tracktype', ''), + 'junction': sem.get('junction', ''), + 'oneway': 'yes' if f.props.get('garmin_direction') else '', + 'closed_loop': 'yes' if is_closed(coords) else '', + 'point_count': len(coords), + 'length_m': round(length_m, 1), + 'road_interest_score': road_interest_score(sem, gpxsee_classes, length_m), + 'preview_lon': f'{centroid_lon:.8f}', + 'preview_lat': f'{centroid_lat:.8f}', + 'start_lon': f'{start_lon:.8f}', 'start_lat': f'{start_lat:.8f}', + 'end_lon': f'{end_lon:.8f}', 'end_lat': f'{end_lat:.8f}', + 'min_lon': f'{min_lon:.8f}', 'min_lat': f'{min_lat:.8f}', + 'max_lon': f'{max_lon:.8f}', 'max_lat': f'{max_lat:.8f}', + 'bbox_json': json.dumps({'west': min_lon, 'south': min_lat, 'east': max_lon, 'north': max_lat}, ensure_ascii=False, separators=(',', ':')), + 'gpxsee_classes_json': json.dumps(gpxsee_classes, ensure_ascii=False), + 'semantic_tags_json': json.dumps(sem, ensure_ascii=False, sort_keys=True), + 'coords_json': json.dumps(coords, ensure_ascii=False, separators=(',', ':')), + } + return row + + +def matches_filters(row: Dict[str, Any], tag_filters: List[Tuple[str, str]], line_classes: Optional[List[str]], group_keys: Optional[List[str]]) -> bool: + if line_classes and row['line_class'] not in set(line_classes): + return False + if group_keys and row['road_group_key'] not in set(group_keys): + return False + sem = json.loads(row['semantic_tags_json']) if row.get('semantic_tags_json') else {} + for k, v in tag_filters: + if str(sem.get(k, '')) != v: + return False + return True + + +def extract_rows(mod, img: Path, *, mapsets: Optional[List[str]], bbox: Optional[str], profile: str, near: Optional[Tuple[float, float, float]], min_length_m: float, tag_filters: List[Tuple[str, str]], line_classes: Optional[List[str]], group_keys: Optional[List[str]], max_rows: int) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: + bbox_parsed = mod._parse_bbox(bbox) if bbox else None + features, meta = mod.load_features_from_img(img, mapsets=mapsets, bbox=bbox_parsed) + rows: List[Dict[str, Any]] = [] + group_counter = Counter() + type_counter = Counter() + class_counter = Counter() + dropped = Counter() + for f in features: + if f.geom_type != 'LineString': + continue + sem = mod.semantic_tags_for_feature(f) + gpxsee_classes = mod.gpxsee_classes_for_feature(f) + if not profile_accepts(sem, gpxsee_classes, profile): + dropped['profile'] += 1 + continue + coords = f.coords + if len(coords) < 2: + dropped['too_short_geom'] += 1 + continue + if near and not line_intersects_near(coords, near): + dropped['near'] += 1 + continue + length_m = line_length_m(coords) + if length_m < min_length_m: + dropped['length'] += 1 + continue + row = normalize_row(f, sem, gpxsee_classes, coords) + if not matches_filters(row, tag_filters, line_classes, group_keys): + dropped['filters'] += 1 + continue + rows.append(row) + group_counter[row['road_group_key']] += 1 + type_counter[f"{row['garmin_type']}:{row['garmin_subtype']}"] += 1 + class_counter[row['line_class']] += 1 + if max_rows and len(rows) >= max_rows: + break + meta2 = dict(meta) + meta2.update({ + 'road_profile': profile, + 'road_count': len(rows), + 'road_groups': dict(group_counter.most_common()), + 'line_classes': dict(class_counter.most_common()), + 'raw_type_counts': dict(type_counter.most_common()), + 'dropped_counts': dict(dropped), + }) + return rows, meta2 + + +def write_roads_csv(rows: List[Dict[str, Any]], path: Path) -> None: + fields = [ + 'mapset','feature_id','endpoint_hash','name','garmin_kind','garmin_type','garmin_subtype','line_class','road_group_key', + 'highway','railway','route','waterway','aerialway','aeroway','natural','surface','tracktype','junction','oneway','closed_loop', + 'point_count','length_m','road_interest_score','preview_lon','preview_lat','start_lon','start_lat','end_lon','end_lat','min_lon','min_lat','max_lon','max_lat', + 'bbox_json','gpxsee_classes_json','semantic_tags_json','coords_json' + ] + with open_text(path, 'w') as f: + w = csv.DictWriter(f, fieldnames=fields) + w.writeheader() + for row in rows: + w.writerow({k: row.get(k, '') for k in fields}) + + +def print_groups(rows: List[Dict[str, Any]]) -> None: + cnt = Counter(r['road_group_key'] for r in rows) + print('road_group_key\tcount\tsample_line_class\tsample_name') + sample = {} + for r in rows: + sample.setdefault(r['road_group_key'], r) + for key, value in cnt.most_common(): + s = sample[key] + print(f"{key}\t{value}\t{s.get('line_class','')}\t{s.get('name','')}") + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description='Stage 1 road extractor from Garmin IMG. Extracts LineString features into road-focused CSV for stage 2 packaging.') + ap.add_argument('img', type=Path) + ap.add_argument('--stage1-module', type=Path, help='Path to garmin_img_to_osmand_v6.py') + ap.add_argument('--mapset', action='append') + ap.add_argument('--bbox') + ap.add_argument('--near') + ap.add_argument('--roads-csv', type=Path) + ap.add_argument('--summary-json', type=Path) + ap.add_argument('--list-road-groups', action='store_true') + ap.add_argument('--road-profile', choices=['roads','roads_paths','roads_strict','transport','hydro','contours','cartographic','nonroads','all_lines'], default='roads_paths') + ap.add_argument('--min-length-m', type=float, default=0.0) + ap.add_argument('--filter-tag', action='append') + ap.add_argument('--filter-line-class', action='append') + ap.add_argument('--filter-group-key', action='append') + ap.add_argument('--max-rows', type=int, default=0) + args = ap.parse_args(argv) + + if not args.roads_csv and not args.summary_json and not args.list_road_groups: + ap.error('provide at least one of --roads-csv, --summary-json, --list-road-groups') + + mod = load_stage1_module(args.stage1_module) + near = parse_near(args.near) + tag_filters = parse_tag_filters(args.filter_tag) + rows, meta = extract_rows(mod, args.img, mapsets=args.mapset, bbox=args.bbox, profile=args.road_profile, near=near, min_length_m=args.min_length_m, tag_filters=tag_filters, line_classes=args.filter_line_class, group_keys=args.filter_group_key, max_rows=args.max_rows) + info(f'extracted {len(rows)} line features for profile={args.road_profile}') + if args.list_road_groups: + print_groups(rows) + if args.roads_csv: + write_roads_csv(rows, args.roads_csv) + info(f'wrote roads CSV: {args.roads_csv}') + if args.summary_json: + args.summary_json.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding='utf-8') + info(f'wrote summary JSON: {args.summary_json}') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/stage-3-parsing-roads/readme.md b/stage-3-parsing-roads/readme.md new file mode 100644 index 0000000..2759a75 --- /dev/null +++ b/stage-3-parsing-roads/readme.md @@ -0,0 +1,144 @@ +I reworked the road path instead of just cloning the landmark flow. + +The old strategy was decent, but it had three weak spots: + +* extraction was line-focused, but not **verification-focused** +* stage 2 could group and package, but it did not have a proper **review loop for ambiguous roads** +* there was no persistent **feature-level override layer** driven by an external visualizer + +That mattered because the base IMG extractor already gives enough line semantics to separate roads, ferries, rail, water lines, contours, and raw leftovers through `LINE_TAGS` and the line-class logic, so the next bottleneck is not decoding lines, but **reviewing and correcting them efficiently**. + +Also, the landmark stage-2 script already had the right pattern for external visualization hooks and interactive config editing, including `--run-cmd-before-eval` placeholders such as `{lat}`, `{lon}`, `{z}`, `{mapset}`, `{group_id}`, `{group_label}`, `{raw_type}`, and `{name}`. I used that as the model for the road verifier rather than pretending roads need a completely different interaction style. + +New files: + +* [garmin_img_roads_v3.py](sandbox:/mnt/data/garmin_img_roads_v3.py) +* [roads_stage2_configurable_v3.py](sandbox:/mnt/data/roads_stage2_configurable_v3.py) + +Tested artifacts: + +* [sample road CSV](sandbox:/mnt/data/sample_roads_v3_02235051.csv) +* [road extraction summary](sandbox:/mnt/data/sample_roads_v3_02235051_summary.json) +* [road stage-2 config](sandbox:/mnt/data/sample_roads_stage2_v3_config_02235051.json) +* [road stage-2 config summary](sandbox:/mnt/data/sample_roads_stage2_v3_config_02235051_summary.json) +* [resolved road CSV](sandbox:/mnt/data/sample_roads_stage2_v3_resolved_02235051.csv) +* [resolved road OSM](sandbox:/mnt/data/sample_roads_stage2_v3_resolved_02235051.osm) +* [resolved road summary](sandbox:/mnt/data/sample_roads_stage2_v3_resolved_02235051_summary.json) +* [unmatched road CSV](sandbox:/mnt/data/sample_roads_stage2_v3_unmatched_02235051.csv) + +What changed + +`garmin_img_roads_v3.py` + +* still builds on the IMG extractor logic you uploaded and the current Garmin stage-1 parser +* adds: + + * `preview_lon` / `preview_lat` + * `endpoint_hash` + * `bbox_json` + * `road_interest_score` + * filtering by: + + * `--filter-line-class` + * `--filter-group-key` + * `--filter-tag` + * extra profile: + + * `roads_strict` + +`roads_stage2_configurable_v3.py` + +* keeps the analyze/build config workflow from the earlier road packager +* adds a real `verify` subcommand +* stores **feature-level overrides** in the config under: + + * `overrides.features.` +* verifier supports: + + * forcing a feature into a specific group + * disabling a feature + * adding/removing tags on a single feature + * renaming a single feature + * editing the assigned group on the fly +* supports external preview command placeholders: + + * `{lat}` + * `{lon}` + * `{z}` + * `{mapset}` + * `{group_id}` + * `{group_label}` + * `{raw_type}` + * `{line_class}` + * `{feature_id}` + * `{endpoint_hash}` + * `{name}` + +So now the road config is not just “analyze once, build once.” It becomes: + +1. extract roads +2. analyze into config +3. verify visually against the real IMG in GPXSee or another viewer +4. save corrections back into the same config +5. build final OSM + +Commands + +Stage 1 extract: + +```bash +python garmin_img_roads_v3.py gmapsupp.img --mapset 02235051 --road-profile roads_paths --roads-csv roads.csv --summary-json roads_summary.json +``` + +Stage 2 analyze: + +```bash +python roads_stage2_configurable_v3.py analyze roads.csv --config-out roads_config.json --summary-json roads_config_summary.json +``` + +Verifier with external viewer: + +```bash +python roads_stage2_configurable_v3.py verify roads.csv --config roads_config.json --run-cmd-before-eval ".\GPXSee.exe D:\maps-bg\gmapsupp\gmapsupp.img \"geo:{lat},{lon};z={z}\"" --run-cmd-zoom 16 +``` + +Build final OSM: + +```bash +python roads_stage2_configurable_v3.py build roads.csv --config roads_config.json --resolved-csv roads_final.csv --osm roads_final.osm --summary-json roads_final_summary.json --unmatched-csv roads_unmatched.csv +``` + +Or review during build: + +```bash +python roads_stage2_configurable_v3.py build roads.csv --config roads_config.json --interactive-verify --run-cmd-before-eval ".\GPXSee.exe D:\maps-bg\gmapsupp\gmapsupp.img \"geo:{lat},{lon};z={z}\"" --osm roads_final.osm +``` + +What I tested + +* extractor v3 successfully ran on your uploaded `gmapsupp.img` for mapset `02235051` +* it produced `13,918` road/path line features +* analyze ran successfully +* build ran successfully and produced OSM/CSV/summary +* I did **not** execute the interactive verifier loop end-to-end here because that needs a live TTY and an external GUI program + +Strategy evaluation, after iteration + +What is now good: + +* road extraction is no longer blind bulk export +* stage 2 has a proper verification layer +* config is now the single source of truth for: + + * group rules + * group styling + * postprocess tags + * per-feature corrections + +What is still weak: + +* road grouping is still mostly based on semantic class and raw fallback, not on **network topology** +* the verifier is feature-by-feature, not yet **intersection-aware** +* no automatic “likely duplicate road geometry” reconciliation across mapsets yet + +The next best iteration is a **connectivity-aware verifier**, where roads sharing endpoint hashes or intersecting near the same nodes can be reviewed as a bundle instead of individually. That would be the real “beast mode” next step for road quality. diff --git a/stage-3-parsing-roads/roads_stage2_configurable.py b/stage-3-parsing-roads/roads_stage2_configurable.py new file mode 100644 index 0000000..882df2f --- /dev/null +++ b/stage-3-parsing-roads/roads_stage2_configurable.py @@ -0,0 +1,969 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import csv +import gzip +import hashlib +import json +import re +import subprocess +import sys +import xml.etree.ElementTree as ET +from collections import Counter, defaultdict +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional + +OSMAND_NS = 'https://osmand.net' +GPX_NS = 'http://www.topografix.com/GPX/1/1' +ET.register_namespace('osmand', OSMAND_NS) +_XML_INVALID_RE = re.compile(r'[\x00-\x08\x0B\x0C\x0E-\x1F\uD800-\uDFFF\uFFFE\uFFFF]') + +DEFAULT_STYLES = { + 'highway:motorway': {'label': 'Motorways', 'color': '#E53935', 'width': 4, 'icon': 'road'}, + 'highway:primary': {'label': 'Primary roads', 'color': '#FB8C00', 'width': 4, 'icon': 'road'}, + 'highway:secondary': {'label': 'Secondary roads', 'color': '#FDD835', 'width': 3, 'icon': 'road'}, + 'highway:tertiary': {'label': 'Tertiary roads', 'color': '#C0CA33', 'width': 3, 'icon': 'road'}, + 'highway:residential': {'label': 'Residential roads', 'color': '#9CCC65', 'width': 2, 'icon': 'road'}, + 'highway:service': {'label': 'Service roads', 'color': '#AED581', 'width': 2, 'icon': 'road'}, + 'highway:unclassified': {'label': 'Unclassified roads', 'color': '#AED581', 'width': 2, 'icon': 'road'}, + 'highway:road': {'label': 'Generic roads', 'color': '#FFB74D', 'width': 2, 'icon': 'road'}, + 'highway:track': {'label': 'Tracks', 'color': '#8D6E63', 'width': 2, 'icon': 'road'}, + 'highway:path': {'label': 'Paths', 'color': '#43A047', 'width': 2, 'icon': 'trekking'}, + 'highway:cycleway': {'label': 'Cycleways', 'color': '#00ACC1', 'width': 2, 'icon': 'trekking'}, + 'route:ferry': {'label': 'Ferries', 'color': '#1E88E5', 'width': 3, 'icon': 'water_drop'}, + 'railway:rail': {'label': 'Railways', 'color': '#616161', 'width': 3, 'icon': 'railway'}, + 'waterway:river': {'label': 'Rivers', 'color': '#1E88E5', 'width': 2, 'icon': 'water_drop'}, + 'waterway:stream': {'label': 'Streams', 'color': '#42A5F5', 'width': 1, 'icon': 'water_drop'}, + 'contour:elevation': {'label': 'Contours', 'color': '#BCAAA4', 'width': 1, 'icon': 'marker'}, + 'raw': {'label': 'Raw lines', 'color': '#5E35B1', 'width': 2, 'icon': 'road'}, +} + + +@dataclass +class Road: + mapset: str + feature_id: str + endpoint_hash: str + name: str + garmin_kind: str + garmin_type: str + garmin_subtype: str + line_class: str + road_group_key: str + semantic_tags: Dict[str, str] + gpxsee_classes: List[str] + coords: List[List[float]] + preview_lon: float = 0.0 + preview_lat: float = 0.0 + source_files: List[str] = field(default_factory=list) + + def raw_type_key(self) -> str: + return f'{self.garmin_type}:{self.garmin_subtype}' + + def clone(self) -> 'Road': + return Road( + mapset=self.mapset, + feature_id=self.feature_id, + endpoint_hash=self.endpoint_hash, + name=self.name, + garmin_kind=self.garmin_kind, + garmin_type=self.garmin_type, + garmin_subtype=self.garmin_subtype, + line_class=self.line_class, + road_group_key=self.road_group_key, + semantic_tags=dict(self.semantic_tags), + gpxsee_classes=list(self.gpxsee_classes), + coords=[list(p) for p in self.coords], + preview_lon=self.preview_lon, + preview_lat=self.preview_lat, + source_files=list(self.source_files), + ) + + +def sanitize_text(value: Any) -> str: + if value is None: + return '' + return _XML_INVALID_RE.sub('', str(value).replace('\r\n', '\n').replace('\r', '\n')).replace('\x00', '').strip() + + +def open_text(path: Path, mode: str): + if str(path).lower().endswith('.gz'): + return gzip.open(path, mode + 't', encoding='utf-8', newline='') + return open(path, mode, encoding='utf-8', newline='') + + + + +def init_csv_field_limit() -> int: + """Set csv field size limit as high as the host Python build allows. + + CSV rows can contain very large coords_json payloads for long roads. The stdlib + default limit (often 128 KiB) is too small for this workload. + """ + limit = getattr(sys, 'maxsize', 2**31 - 1) + while True: + try: + csv.field_size_limit(limit) + return limit + except OverflowError: + limit //= 10 + if limit <= 0: + csv.field_size_limit(2**31 - 1) + return 2**31 - 1 + +def safe_json_loads(s: str, default): + try: + return json.loads(s) if s else default + except Exception: + return default + + +def load_roads(paths: Iterable[Path]) -> List[Road]: + init_csv_field_limit() + items: List[Road] = [] + for path in paths: + print(f"Parsing - {path}") + with open_text(path, 'r') as f: + try: + reader = csv.DictReader(f) + for row_idx, row in enumerate(reader, start=2): + try: + coords = safe_json_loads(row.get('coords_json') or '[]', []) + if not isinstance(coords, list) or len(coords) < 2: + continue + try: + preview_lon = float(row.get('preview_lon') or row.get('centroid_lon') or coords[len(coords)//2][0]) + preview_lat = float(row.get('preview_lat') or row.get('centroid_lat') or coords[len(coords)//2][1]) + except Exception: + preview_lon, preview_lat = coords[len(coords)//2] + sem = safe_json_loads(row.get('semantic_tags_json') or '{}', {}) + gpx = safe_json_loads(row.get('gpxsee_classes_json') or '[]', []) + if not isinstance(sem, dict): + sem = {} + if not isinstance(gpx, list): + gpx = [] + items.append(Road( + mapset=sanitize_text(row.get('mapset')), + feature_id=sanitize_text(row.get('feature_id')), + endpoint_hash=sanitize_text(row.get('endpoint_hash')), + name=sanitize_text(row.get('name')), + garmin_kind=sanitize_text(row.get('garmin_kind')), + garmin_type=sanitize_text(row.get('garmin_type')), + garmin_subtype=sanitize_text(row.get('garmin_subtype')), + line_class=sanitize_text(row.get('line_class')), + road_group_key=sanitize_text(row.get('road_group_key')), + semantic_tags={sanitize_text(k): sanitize_text(v) for k, v in sem.items() if sanitize_text(k) and sanitize_text(v)}, + gpxsee_classes=[sanitize_text(x) for x in gpx if sanitize_text(x)], + coords=coords, + preview_lon=preview_lon, + preview_lat=preview_lat, + source_files=[path.name], + )) + except Exception as e: + print(f'[warn] skipping bad row in {path} at CSV row {row_idx}: {e}', file=sys.stderr) + continue + except csv.Error as e: + raise RuntimeError(f'CSV parse failed for {path}: {e}. This usually means an oversized coords_json field or malformed quoting.') from e + return items + + +def dedupe_roads(items: List[Road]) -> List[Road]: + seen: Dict[str, Road] = {} + for it in items: + key = it.feature_id or it.endpoint_hash or f'{it.raw_type_key()}:{it.preview_lon:.7f}:{it.preview_lat:.7f}' + cur = seen.get(key) + if cur is None: + seen[key] = it.clone() + continue + if not cur.name and it.name: + cur.name = it.name + cur.source_files = sorted(set(cur.source_files + it.source_files)) + return list(seen.values()) + + +def infer_style(group_key: str) -> Dict[str, Any]: + if group_key in DEFAULT_STYLES: + return dict(DEFAULT_STYLES[group_key]) + for prefix, style in DEFAULT_STYLES.items(): + if group_key.startswith(prefix): + return dict(style) + return {'label': group_key.replace(':', ' ').replace('_', ' ').title(), 'color': '#5E35B1', 'width': 2, 'icon': 'road'} + + +def spread_examples(items: List[Road], n: int) -> List[Dict[str, Any]]: + ordered = sorted(items, key=lambda r: (r.mapset, r.feature_id or r.endpoint_hash, r.preview_lat, r.preview_lon)) + if not ordered or n <= 0: + return [] + if len(ordered) <= n: + chosen = ordered + elif n == 1: + chosen = [ordered[len(ordered)//2]] + else: + idxs = sorted(set(round(i * (len(ordered)-1) / (n-1)) for i in range(n))) + chosen = [ordered[int(i)] for i in idxs] + out = [] + for it in chosen: + out.append({ + 'feature_id': it.feature_id, + 'endpoint_hash': it.endpoint_hash, + 'name': it.name, + 'mapset': it.mapset, + 'group_key': it.road_group_key, + 'lat': round(it.preview_lat, 8), + 'lon': round(it.preview_lon, 8), + 'raw_type': it.raw_type_key(), + 'line_class': it.line_class, + 'semantic_tags': dict(it.semantic_tags), + }) + return out + + +def parse_tag_exprs(values: List[str] | None) -> Dict[str, str]: + out: Dict[str, str] = {} + for expr in values or []: + if '=' not in expr: + raise ValueError(f'invalid tag expression {expr!r}; expected key=value') + k, v = expr.split('=', 1) + k = sanitize_text(k) + v = sanitize_text(v) + if k and v: + out[k] = v + return out + + +def merge_postprocess_dicts(*dicts: Dict[str, str]) -> Dict[str, str]: + out: Dict[str, str] = {} + for d in dicts: + for k, v in (d or {}).items(): + k2 = sanitize_text(k) + v2 = sanitize_text(v) + if k2 and v2: + out[k2] = v2 + return out + + +def merge_remove_tags(*lists: List[str]) -> List[str]: + out: List[str] = [] + seen = set() + for lst in lists: + for item in lst or []: + v = sanitize_text(item) + if v and v not in seen: + seen.add(v) + out.append(v) + return out + + +def analyze_to_config(items: List[Road], *, example_count: int, default_postprocess_add: Optional[Dict[str, str]] = None) -> Dict[str, Any]: + grouped = defaultdict(list) + for it in items: + grouped[it.road_group_key].append(it) + groups = [] + for idx, (gkey, members) in enumerate(sorted(grouped.items(), key=lambda kv: (-len(kv[1]), kv[0]))): + style = infer_style(gkey) + group_id = re.sub(r'[^a-zA-Z0-9_]+', '_', gkey).strip('_').lower() or f'road_group_{idx+1}' + raw_types = Counter(m.raw_type_key() for m in members) + sem_pairs = Counter() + for m in members: + for k, v in m.semantic_tags.items(): + if v: + sem_pairs[(k, v)] += 1 + member_count = max(1, len(members)) + semantic_required_any = [] + semantic_preferred_any = [] + for (k, v), c in sem_pairs.most_common(8): + pair = {k: v} + if c / member_count >= 0.80: + semantic_required_any.append(pair) + else: + semantic_preferred_any.append(pair) + groups.append({ + 'id': group_id, + 'enabled': True, + 'priority': 1000 - idx, + 'match': { + 'road_group_keys': [gkey], + 'line_classes': sorted({m.line_class for m in members if m.line_class}), + 'raw_types': [rt for rt, _ in raw_types.most_common(8)], + 'semantic_required_any': semantic_required_any, + 'semantic_preferred_any': semantic_preferred_any, + 'has_name': None, + }, + 'display': style, + 'naming': { + 'mode': 'preserve_or_template_if_missing', + 'base_name': style['label'], + 'template': '{existing_name_or_base} - {hash}', + 'preserve_existing_names': True, + }, + 'postprocess': { + 'add_tags': dict(default_postprocess_add or {}), + 'remove_tags': [], + }, + 'stats': { + 'count': len(members), + 'named_count': sum(1 for m in members if m.name), + 'raw_type_counts': dict(raw_types.most_common(12)), + 'semantic_tag_counts': {f'{k}={v}': c for (k, v), c in sem_pairs.most_common(12)}, + }, + 'examples': spread_examples(members, example_count), + }) + return { + 'kind': 'roads_stage2', + 'version': 3, + 'defaults': { + 'postprocess': {'add_tags': dict(default_postprocess_add or {}), 'remove_tags': []}, + 'verifier': {'run_cmd_before_eval': '', 'run_cmd_zoom': 16}, + }, + 'groups': groups, + 'overrides': {'features': {}}, + } + + +def save_json(path: Path, data: Any): + with open_text(path, 'w') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def load_json(path: Path) -> Any: + with open_text(path, 'r') as f: + return json.load(f) + + +def migrate_config(config: dict[str, Any]) -> dict[str, Any]: + cfg = json.loads(json.dumps(config, ensure_ascii=False)) + defaults = cfg.setdefault('defaults', {}) + defaults.setdefault('postprocess', {'add_tags': {}, 'remove_tags': []}) + defaults.setdefault('verifier', {'run_cmd_before_eval': '', 'run_cmd_zoom': 16}) + overrides = cfg.setdefault('overrides', {}) + overrides.setdefault('features', {}) + for g in cfg.get('groups', []): + g.setdefault('enabled', True) + g.setdefault('priority', 0) + g.setdefault('match', {}) + g.setdefault('display', {'label': g.get('id', 'Roads'), 'color': '#5E35B1', 'width': 2, 'icon': 'road'}) + g.setdefault('naming', {'mode': 'preserve_or_template_if_missing', 'base_name': g.get('display', {}).get('label', 'Roads'), 'template': '{existing_name_or_base} - {hash}', 'preserve_existing_names': True}) + pp = g.setdefault('postprocess', {}) + if not isinstance(pp.get('add_tags'), dict): + pp['add_tags'] = {} + if not isinstance(pp.get('remove_tags'), list): + pp['remove_tags'] = [] + return cfg + + +def match_group(it: Road, group: Dict[str, Any]) -> bool: + if not group.get('enabled', True): + return False + m = group.get('match', {}) + if m.get('road_group_keys') and it.road_group_key not in set(m['road_group_keys']): + return False + if m.get('line_classes') and it.line_class not in set(m['line_classes']): + return False + raw_type = it.raw_type_key() + if m.get('raw_types') and raw_type not in set(m['raw_types']): + return False + req = m.get('semantic_required_any') or [] + if req and not any(all(it.semantic_tags.get(k) == v for k, v in cond.items()) for cond in req): + return False + has_name = m.get('has_name') + if has_name is True and not it.name: + return False + if has_name is False and it.name: + return False + return True + + +def get_group_by_id(config: Dict[str, Any], group_id: str) -> Optional[Dict[str, Any]]: + for g in config.get('groups', []): + if g.get('id') == group_id: + return g + return None + + +def assign_group(it: Road, config: Dict[str, Any]) -> Optional[Dict[str, Any]]: + overrides = ((config.get('overrides') or {}).get('features') or {}).get(it.feature_id) or {} + if overrides.get('disabled'): + return None + forced = overrides.get('force_group_id') + if forced: + return get_group_by_id(config, forced) + groups = sorted(config.get('groups', []), key=lambda g: (-int(g.get('priority', 0)), g.get('id', ''))) + for group in groups: + if match_group(it, group): + return group + return None + + +def compute_hash(it: Road) -> str: + if it.feature_id: + return it.feature_id[-6:] + raw = json.dumps(it.coords, ensure_ascii=False, separators=(',', ':')).encode('utf-8') + return hashlib.blake2b(raw, digest_size=6).hexdigest().upper()[:6] + + +def render_template_value(template: Any, *, base_name: str, existing_name: str, hash_value: str, group_id: str) -> str: + if template is None: + return '' + return sanitize_text(str(template).format( + base_name=base_name, + existing_name=existing_name, + existing_name_or_base=existing_name or base_name, + hash=hash_value, + group_id=group_id, + )) + + +def render_name(it: Road, group: Dict[str, Any], config: Dict[str, Any]) -> str: + naming = group.get('naming', {}) + override = (((config.get('overrides') or {}).get('features') or {}).get(it.feature_id) or {}) + if override.get('name'): + return sanitize_text(override['name']) + mode = naming.get('mode', 'preserve_or_template_if_missing') + base_name = sanitize_text(naming.get('base_name') or group.get('display', {}).get('label') or 'Road') + existing = sanitize_text(it.name) + h = compute_hash(it) + template = naming.get('template') or '{existing_name_or_base}' + if mode == 'preserve': + return existing + if mode == 'template_always': + return render_template_value(template, base_name=base_name, existing_name=existing, hash_value=h, group_id=group.get('id', '')) + return existing or render_template_value(template, base_name=base_name, existing_name=existing, hash_value=h, group_id=group.get('id', '')) + + +def apply_postprocess(props: Dict[str, str], group: Dict[str, Any], config: Dict[str, Any], it: Road, *, base_name: str, existing_name: str, hash_value: str) -> Dict[str, str]: + out = dict(props) + defaults = ((config.get('defaults') or {}).get('postprocess') or {}) + grp = group.get('postprocess') or {} + override = (((config.get('overrides') or {}).get('features') or {}).get(it.feature_id) or {}) + add = merge_postprocess_dicts(defaults.get('add_tags') or {}, grp.get('add_tags') or {}, override.get('add_tags') or {}) + for k, v in add.items(): + out[sanitize_text(k)] = render_template_value(v, base_name=base_name, existing_name=existing_name, hash_value=hash_value, group_id=group.get('id', '')) + remove = merge_remove_tags(defaults.get('remove_tags') or [], grp.get('remove_tags') or [], override.get('remove_tags') or []) + for k in remove: + out.pop(k, None) + return {sanitize_text(k): sanitize_text(v) for k, v in out.items() if sanitize_text(k) and sanitize_text(v)} + + +def build_records(items: List[Road], config: Dict[str, Any]) -> tuple[List[Dict[str, Any]], List[Road], List[Road]]: + records: List[Dict[str, Any]] = [] + unmatched: List[Road] = [] + disabled: List[Road] = [] + for it in items: + override = (((config.get('overrides') or {}).get('features') or {}).get(it.feature_id) or {}) + if override.get('disabled'): + disabled.append(it) + continue + group = assign_group(it, config) + if not group: + unmatched.append(it) + continue + h = compute_hash(it) + name = render_name(it, group, config) + props = dict(it.semantic_tags) + if name: + props['name'] = name + props['garmin:kind'] = it.garmin_kind + if it.garmin_type: + props['garmin:type'] = it.garmin_type + if it.garmin_subtype: + props['garmin:subtype'] = it.garmin_subtype + props['source:road_group'] = sanitize_text(group.get('id') or '') + props['source:road_group_key'] = it.road_group_key + props['source:stable_hash'] = h + props['source:feature_id'] = it.feature_id + props['source:mapset'] = it.mapset + display = group.get('display', {}) + if display.get('color'): + props['osmand:color'] = sanitize_text(display['color']) + if display.get('icon'): + props['osmand:icon'] = sanitize_text(display['icon']) + props = apply_postprocess(props, group, config, it, base_name=sanitize_text(display.get('label') or 'Road'), existing_name=sanitize_text(it.name), hash_value=h) + records.append({ + 'feature_id': it.feature_id, + 'group_id': sanitize_text(group.get('id') or ''), + 'group_label': sanitize_text(display.get('label') or ''), + 'name': name, + 'icon': sanitize_text(display.get('icon') or ''), + 'color': sanitize_text(display.get('color') or ''), + 'width': int(display.get('width', 2) or 2), + 'mapset': it.mapset, + 'preview_lon': it.preview_lon, + 'preview_lat': it.preview_lat, + 'coords': [list(p) for p in it.coords], + 'props': props, + 'source_files': list(it.source_files), + }) + return records, unmatched, disabled + + +def write_resolved_csv(records: List[Dict[str, Any]], path: Path): + fields = ['group_id','group_label','feature_id','mapset','name','icon','color','width','preview_lon','preview_lat','source_files_json','props_json','coords_json'] + with open_text(path, 'w') as f: + w = csv.DictWriter(f, fieldnames=fields) + w.writeheader() + for r in records: + w.writerow({ + 'group_id': r['group_id'], + 'group_label': r['group_label'], + 'feature_id': r['feature_id'], + 'mapset': r['mapset'], + 'name': sanitize_text(r['name']), + 'icon': r['icon'], + 'color': r['color'], + 'width': r['width'], + 'preview_lon': f"{r['preview_lon']:.8f}", + 'preview_lat': f"{r['preview_lat']:.8f}", + 'source_files_json': json.dumps(r['source_files'], ensure_ascii=False), + 'props_json': json.dumps(r['props'], ensure_ascii=False, sort_keys=True), + 'coords_json': json.dumps(r['coords'], ensure_ascii=False, separators=(',', ':')), + }) + + +def write_osm(records: List[Dict[str, Any]], path: Path): + osm = ET.Element('osm', {'version': '0.6', 'generator': 'roads_stage2_configurable_v3.py'}) + node_id = -1 + way_id = -1 + for r in records: + node_refs = [] + for lon, lat in r['coords']: + node = ET.SubElement(osm, 'node', {'id': str(node_id), 'lat': f'{lat:.8f}', 'lon': f'{lon:.8f}'}) + node_refs.append(node_id) + node_id -= 1 + way = ET.SubElement(osm, 'way', {'id': str(way_id)}) + way_id -= 1 + for nid in node_refs: + ET.SubElement(way, 'nd', {'ref': str(nid)}) + for k, v in sorted(r['props'].items()): + k2 = sanitize_text(k); v2 = sanitize_text(v) + if k2 and v2: + ET.SubElement(way, 'tag', {'k': k2, 'v': v2}) + tree = ET.ElementTree(osm) + try: + ET.indent(tree, space=' ') + except Exception: + pass + if str(path).lower().endswith('.gz'): + with gzip.open(path, 'wb') as f: + tree.write(f, encoding='utf-8', xml_declaration=True) + else: + with path.open('wb') as f: + tree.write(f, encoding='utf-8', xml_declaration=True) + + +def write_gpx(records: List[Dict[str, Any]], path: Path): + ET.register_namespace('', GPX_NS) + gpx = ET.Element('gpx', {'version': '1.1', 'creator': 'roads_stage2_configurable_v3.py', 'xmlns': GPX_NS}) + ext_root = ET.SubElement(gpx, 'extensions') + groups_el = ET.SubElement(ext_root, f'{{{OSMAND_NS}}}points_groups') + seen = set() + for r in records: + if r['group_id'] in seen: + continue + seen.add(r['group_id']) + ET.SubElement(groups_el, f'{{{OSMAND_NS}}}group', {'name': r['group_label'], 'color': r['color'], 'icon': r['icon'], 'background': 'circle'}) + for r in records: + trk = ET.SubElement(gpx, 'trk') + ET.SubElement(trk, 'name').text = sanitize_text(r['name'] or r['feature_id']) + ET.SubElement(trk, 'type').text = sanitize_text(r['group_label']) + ext = ET.SubElement(trk, 'extensions') + ET.SubElement(ext, f'{{{OSMAND_NS}}}icon').text = sanitize_text(r['icon']) + ET.SubElement(ext, f'{{{OSMAND_NS}}}color').text = sanitize_text(r['color']) + seg = ET.SubElement(trk, 'trkseg') + for lon, lat in r['coords']: + ET.SubElement(seg, 'trkpt', {'lat': f'{lat:.8f}', 'lon': f'{lon:.8f}'}) + tree = ET.ElementTree(gpx) + try: + ET.indent(tree, space=' ') + except Exception: + pass + if str(path).lower().endswith('.gz'): + with gzip.open(path, 'wb') as f: + tree.write(f, encoding='utf-8', xml_declaration=True) + else: + with path.open('wb') as f: + tree.write(f, encoding='utf-8', xml_declaration=True) + + +def write_unmatched_csv(items: List[Road], path: Path): + fields = ['mapset','feature_id','endpoint_hash','name','garmin_kind','garmin_type','garmin_subtype','line_class','road_group_key','preview_lon','preview_lat','semantic_tags_json','gpxsee_classes_json','coords_json'] + with open_text(path, 'w') as f: + w = csv.DictWriter(f, fieldnames=fields) + w.writeheader() + for it in items: + w.writerow({ + 'mapset': it.mapset, + 'feature_id': it.feature_id, + 'endpoint_hash': it.endpoint_hash, + 'name': sanitize_text(it.name), + 'garmin_kind': it.garmin_kind, + 'garmin_type': it.garmin_type, + 'garmin_subtype': it.garmin_subtype, + 'line_class': it.line_class, + 'road_group_key': it.road_group_key, + 'preview_lon': f'{it.preview_lon:.8f}', + 'preview_lat': f'{it.preview_lat:.8f}', + 'semantic_tags_json': json.dumps(it.semantic_tags, ensure_ascii=False, sort_keys=True), + 'gpxsee_classes_json': json.dumps(it.gpxsee_classes, ensure_ascii=False), + 'coords_json': json.dumps(it.coords, ensure_ascii=False, separators=(',', ':')), + }) + + +def run_eval_command(template: str, example: dict[str, Any], *, group: dict[str, Any], z: int = 16) -> None: + if not template: + return + fmt = { + 'lon': example.get('lon', ''), 'lat': example.get('lat', ''), 'z': z, + 'name': sanitize_text(example.get('name', '')), + 'mapset': sanitize_text(example.get('mapset', '')), + 'group_id': sanitize_text(group.get('id', '')), + 'group_label': sanitize_text((group.get('display') or {}).get('label', '')), + 'raw_type': sanitize_text(example.get('raw_type', '')), + 'line_class': sanitize_text(example.get('line_class', '')), + 'feature_id': sanitize_text(example.get('feature_id', '')), + 'endpoint_hash': sanitize_text(example.get('endpoint_hash', '')), + } + cmd = str(template).format(**fmt) + try: + subprocess.Popen(cmd, shell=True) + except Exception as e: + print(f'[warn] failed to run command: {e}', file=sys.stderr) + + +def feature_examples(it: Road, assigned_group: Optional[Dict[str, Any]] = None) -> dict[str, Any]: + return { + 'feature_id': it.feature_id, + 'endpoint_hash': it.endpoint_hash, + 'name': it.name, + 'mapset': it.mapset, + 'group_key': it.road_group_key, + 'lat': round(it.preview_lat, 8), + 'lon': round(it.preview_lon, 8), + 'raw_type': it.raw_type_key(), + 'line_class': it.line_class, + 'semantic_tags': dict(it.semantic_tags), + 'assigned_group_id': assigned_group.get('id') if assigned_group else '', + 'assigned_group_label': (assigned_group.get('display') or {}).get('label', '') if assigned_group else '', + } + + +def interactive_edit_group(g: Dict[str, Any]) -> None: + display = g.setdefault('display', {}) + naming = g.setdefault('naming', {}) + pp = g.setdefault('postprocess', {}) + pp.setdefault('add_tags', {}) + pp.setdefault('remove_tags', []) + new_label = input(f"label [{display.get('label','')}]: ").strip() + if new_label: + display['label'] = sanitize_text(new_label) + new_color = input(f"color [{display.get('color','')}]: ").strip() + if new_color: + display['color'] = sanitize_text(new_color) + new_width = input(f"width [{display.get('width',2)}]: ").strip() + if new_width: + try: + display['width'] = int(new_width) + except Exception: + pass + new_icon = input(f"icon [{display.get('icon','')}]: ").strip() + if new_icon: + display['icon'] = sanitize_text(new_icon) + new_base = input(f"base_name [{naming.get('base_name','')}]: ").strip() + if new_base: + naming['base_name'] = sanitize_text(new_base) + new_tpl = input(f"template [{naming.get('template','')}]: ").strip() + if new_tpl: + naming['template'] = sanitize_text(new_tpl) + print('add_tags current =', pp.get('add_tags', {}), file=sys.stderr) + while True: + expr = input('add/replace tag key=value (blank to stop): ').strip() + if not expr: + break + if '=' not in expr: + print('expected key=value', file=sys.stderr) + continue + k, v = expr.split('=', 1) + pp['add_tags'][sanitize_text(k)] = sanitize_text(v) + print('remove_tags current =', pp.get('remove_tags', []), file=sys.stderr) + while True: + expr = input('append remove-tag key (blank to stop): ').strip() + if not expr: + break + pp['remove_tags'].append(sanitize_text(expr)) + + +def interactive_edit_config(cfg: Dict[str, Any], *, save_path: Optional[Path] = None, run_cmd_before_eval: Optional[str] = None, run_cmd_zoom: int = 16, auto_open_example: bool = True) -> Dict[str, Any]: + if not sys.stdin.isatty(): + print('[warn] interactive config editing requested, but stdin is not interactive; keeping config unchanged', file=sys.stderr) + return cfg + groups = cfg.get('groups') or [] + for g in groups: + examples = list(g.get('examples') or []) + idx = 0 + while True: + ex = examples[idx] if examples else {} + print('\n=== ROAD GROUP ===', file=sys.stderr) + print(f"id: {g.get('id')} label: {(g.get('display') or {}).get('label','')} count: {(g.get('stats') or {}).get('count','?')}", file=sys.stderr) + if ex: + print(f"example[{idx+1}/{len(examples)}]: name={ex.get('name','')} mapset={ex.get('mapset','')} at {ex.get('lat')},{ex.get('lon')} raw={ex.get('raw_type','')} class={ex.get('line_class','')}", file=sys.stderr) + if run_cmd_before_eval and ex and auto_open_example: + run_eval_command(run_cmd_before_eval, ex, group=g, z=run_cmd_zoom) + reply = input('Preview command [Enter/e edit, n next, p prev, o open, s skip, ! stop]: ').strip().lower() + auto_open_example = False + if reply in ('', 'e'): + break + if reply == 'n' and examples: + idx = (idx + 1) % len(examples) + continue + if reply == 'p' and examples: + idx = (idx - 1) % len(examples) + continue + if reply == 'o' and run_cmd_before_eval and ex: + run_eval_command(run_cmd_before_eval, ex, group=g, z=run_cmd_zoom) + continue + if reply == 's': + break + if reply == '!': + if save_path: + save_json(save_path, cfg) + return cfg + if reply == 's': + continue + interactive_edit_group(g) + if save_path: + save_json(save_path, cfg) + return cfg + + +def interactive_verify(cfg: Dict[str, Any], items: List[Road], *, scope: str, group_id: Optional[str], save_path: Optional[Path], run_cmd_before_eval: Optional[str], run_cmd_zoom: int, auto_open_example: bool = True) -> Dict[str, Any]: + if not sys.stdin.isatty(): + print('[warn] interactive verifier requested, but stdin is not interactive; keeping config unchanged', file=sys.stderr) + return cfg + cfg = migrate_config(cfg) + records, unmatched, disabled = build_records(items, cfg) + assigned_map = {r['feature_id']: r['group_id'] for r in records} + record_items = [] + for it in items: + assigned_group = get_group_by_id(cfg, assigned_map.get(it.feature_id, '')) if assigned_map.get(it.feature_id) else None + if scope == 'matched' and not assigned_group: + continue + if scope == 'unmatched' and assigned_group: + continue + if scope == 'group' and group_id and (not assigned_group or assigned_group.get('id') != group_id): + continue + record_items.append((it, assigned_group)) + + groups_by_id = {g.get('id'): g for g in cfg.get('groups', [])} + idx = 0 + while idx < len(record_items): + it, assigned_group = record_items[idx] + override = (((cfg.get('overrides') or {}).get('features') or {}).get(it.feature_id) or {}) + ex = feature_examples(it, assigned_group) + print('\n=== ROAD VERIFY ===', file=sys.stderr) + print(f"[{idx+1}/{len(record_items)}] feature_id={it.feature_id} endpoint_hash={it.endpoint_hash}", file=sys.stderr) + print(f" mapset={it.mapset} at {it.preview_lat:.8f},{it.preview_lon:.8f}", file=sys.stderr) + print(f" raw={it.raw_type_key()} class={it.line_class} name={it.name or ''}", file=sys.stderr) + print(f" road_group_key={it.road_group_key}", file=sys.stderr) + print(f" assigned_group={(assigned_group.get('id') if assigned_group else '')} label={((assigned_group.get('display') or {}).get('label') if assigned_group else '')}", file=sys.stderr) + if it.semantic_tags: + print(f" semantic={json.dumps(it.semantic_tags, ensure_ascii=False, sort_keys=True)}", file=sys.stderr) + if override: + print(f" override={json.dumps(override, ensure_ascii=False, sort_keys=True)}", file=sys.stderr) + if run_cmd_before_eval and auto_open_example: + run_eval_command(run_cmd_before_eval, ex, group=assigned_group or {'id':'','display':{}}, z=run_cmd_zoom) + auto_open_example = False + cmd = input('Command [Enter/n next, p prev, o open, g edit-group, f force-group, d disable, u clear-override, t add-tag, r remove-tag, m rename, ! stop]: ').strip().lower() + if cmd in ('', 'n'): + idx += 1 + continue + if cmd == 'p': + idx = max(0, idx - 1) + continue + if cmd == 'o' and run_cmd_before_eval: + run_eval_command(run_cmd_before_eval, ex, group=assigned_group or {'id':'','display':{}}, z=run_cmd_zoom) + continue + features_over = cfg.setdefault('overrides', {}).setdefault('features', {}) + ov = features_over.setdefault(it.feature_id, {}) + if cmd == 'g': + target = assigned_group + if not target: + gid = input('group id to edit: ').strip() + target = groups_by_id.get(gid) + if target: + interactive_edit_group(target) + else: + print('[warn] group not found', file=sys.stderr) + continue + if cmd == 'f': + gid = input('force group id: ').strip() + if gid in groups_by_id: + ov['force_group_id'] = gid + ov.pop('disabled', None) + else: + print('[warn] group id not found', file=sys.stderr) + idx += 1 + continue + if cmd == 'd': + ov['disabled'] = True + ov.pop('force_group_id', None) + idx += 1 + continue + if cmd == 'u': + features_over.pop(it.feature_id, None) + idx += 1 + continue + if cmd == 't': + add = ov.setdefault('add_tags', {}) + while True: + expr = input('feature add-tag key=value (blank to stop): ').strip() + if not expr: + break + if '=' not in expr: + print('expected key=value', file=sys.stderr) + continue + k, v = expr.split('=', 1) + add[sanitize_text(k)] = sanitize_text(v) + idx += 1 + continue + if cmd == 'r': + rem = ov.setdefault('remove_tags', []) + while True: + expr = input('feature remove-tag key (blank to stop): ').strip() + if not expr: + break + rem.append(sanitize_text(expr)) + idx += 1 + continue + if cmd == 'm': + val = input(f'rename [{ov.get("name", it.name)}]: ').strip() + if val: + ov['name'] = sanitize_text(val) + idx += 1 + continue + if cmd == '!': + if save_path: + save_json(save_path, cfg) + return cfg + print('[warn] unknown command', file=sys.stderr) + if save_path: + save_json(save_path, cfg) + return cfg + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description='Road packager + verifier: analyze road CSVs into config, interactively edit or verify with external visualizer, then build OSM/GPX/CSV.') + sub = ap.add_subparsers(dest='cmd', required=True) + + a = sub.add_parser('analyze') + a.add_argument('csv', nargs='+', type=Path) + a.add_argument('--config-out', required=True, type=Path) + a.add_argument('--summary-json', type=Path) + a.add_argument('--group-examples', type=int, default=4) + a.add_argument('--default-postprocess-tag', action='append', default=[]) + + e = sub.add_parser('edit-config') + e.add_argument('--config', required=True, type=Path) + e.add_argument('--output', type=Path) + e.add_argument('--run-cmd-before-eval') + e.add_argument('--run-cmd-zoom', type=int, default=16) + e.add_argument('--default-postprocess-tag', action='append', default=[]) + + v = sub.add_parser('verify') + v.add_argument('csv', nargs='+', type=Path) + v.add_argument('--config', required=True, type=Path) + v.add_argument('--output', type=Path, help='Write reviewed config here; defaults to in-place update') + v.add_argument('--scope', choices=['all', 'matched', 'unmatched', 'group'], default='unmatched') + v.add_argument('--group-id', help='Required when --scope group') + v.add_argument('--run-cmd-before-eval') + v.add_argument('--run-cmd-zoom', type=int, default=16) + + b = sub.add_parser('build') + b.add_argument('csv', nargs='+', type=Path) + b.add_argument('--config', required=True, type=Path) + b.add_argument('--resolved-csv', type=Path) + b.add_argument('--osm', type=Path) + b.add_argument('--gpx', type=Path) + b.add_argument('--summary-json', type=Path) + b.add_argument('--unmatched-csv', type=Path) + b.add_argument('--interactive-edit-config', action='store_true') + b.add_argument('--interactive-verify', action='store_true') + b.add_argument('--verify-scope', choices=['all', 'matched', 'unmatched', 'group'], default='unmatched') + b.add_argument('--verify-group-id') + b.add_argument('--edited-config-out', type=Path) + b.add_argument('--run-cmd-before-eval') + b.add_argument('--run-cmd-zoom', type=int, default=16) + + args = ap.parse_args(argv) + + if args.cmd == 'edit-config': + cfg = migrate_config(load_json(args.config)) + if args.default_postprocess_tag: + defaults = cfg.setdefault('defaults', {}) + pp = defaults.setdefault('postprocess', {'add_tags': {}, 'remove_tags': []}) + pp['add_tags'] = merge_postprocess_dicts(pp.get('add_tags') or {}, parse_tag_exprs(args.default_postprocess_tag)) + if args.run_cmd_before_eval: + cfg.setdefault('defaults', {}).setdefault('verifier', {})['run_cmd_before_eval'] = args.run_cmd_before_eval + cfg.setdefault('defaults', {}).setdefault('verifier', {})['run_cmd_zoom'] = args.run_cmd_zoom + output = args.output or args.config + interactive_edit_config(cfg, save_path=output, run_cmd_before_eval=args.run_cmd_before_eval, run_cmd_zoom=args.run_cmd_zoom) + print(f'[info] wrote config: {output}') + return 0 + + items = dedupe_roads(load_roads(args.csv if args.cmd != 'edit-config' else [])) if args.cmd != 'edit-config' else [] + if args.cmd == 'analyze': + cfg = analyze_to_config(items, example_count=args.group_examples, default_postprocess_add=parse_tag_exprs(args.default_postprocess_tag)) + save_json(args.config_out, cfg) + if args.summary_json: + save_json(args.summary_json, { + 'item_count': len(items), + 'group_count': len(cfg['groups']), + 'top_groups': [{'id': g['id'], 'count': g['stats']['count'], 'label': g['display']['label']} for g in cfg['groups'][:50]], + }) + print(f'[info] analyzed {len(items)} roads') + print(f'[info] wrote config: {args.config_out}') + if args.summary_json: + print(f'[info] wrote summary: {args.summary_json}') + return 0 + + cfg = migrate_config(load_json(args.config)) + verifier_defaults = (cfg.get('defaults') or {}).get('verifier') or {} + run_cmd = args.run_cmd_before_eval or verifier_defaults.get('run_cmd_before_eval') or None + run_zoom = args.run_cmd_zoom or verifier_defaults.get('run_cmd_zoom', 16) + + if args.cmd == 'verify': + output = args.output or args.config + cfg = interactive_verify(cfg, items, scope=args.scope, group_id=args.group_id, save_path=output, run_cmd_before_eval=run_cmd, run_cmd_zoom=run_zoom) + print(f'[info] wrote config: {output}') + return 0 + + if args.interactive_edit_config: + cfg = interactive_edit_config(cfg, save_path=args.edited_config_out, run_cmd_before_eval=run_cmd, run_cmd_zoom=run_zoom) + if args.interactive_verify: + cfg = interactive_verify(cfg, items, scope=args.verify_scope, group_id=args.verify_group_id, save_path=args.edited_config_out or args.config, run_cmd_before_eval=run_cmd, run_cmd_zoom=run_zoom) + records, unmatched, disabled = build_records(items, cfg) + if not any([args.resolved_csv, args.osm, args.gpx, args.summary_json, args.unmatched_csv]): + print(f'[info] built {len(records)} road records') + print(f'[info] unmatched {len(unmatched)} roads; disabled {len(disabled)} roads') + return 0 + if args.resolved_csv: + write_resolved_csv(records, args.resolved_csv) + print(f'[info] wrote resolved CSV: {args.resolved_csv}') + if args.osm: + write_osm(records, args.osm) + print(f'[info] wrote OSM: {args.osm}') + if args.gpx: + write_gpx(records, args.gpx) + print(f'[info] wrote GPX: {args.gpx}') + if args.unmatched_csv: + write_unmatched_csv(unmatched, args.unmatched_csv) + print(f'[info] wrote unmatched CSV: {args.unmatched_csv}') + if args.summary_json: + c = Counter(r['group_id'] for r in records) + save_json(args.summary_json, {'record_count': len(records), 'unmatched_count': len(unmatched), 'disabled_count': len(disabled), 'groups': dict(c.most_common())}) + print(f'[info] wrote summary: {args.summary_json}') + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/stage-3-parsing-roads/run.py b/stage-3-parsing-roads/run.py new file mode 100644 index 0000000..f480f64 --- /dev/null +++ b/stage-3-parsing-roads/run.py @@ -0,0 +1,202 @@ +#take from previous output +temp = """00234008 21.467285,43.637695,22.038574,44.033203 +00234009 21.972656,43.637695,22.543945,44.033203 +00234010 22.456055,43.637695,23.027344,44.033203 +00234011 22.961426,43.637695,23.532715,44.033203 +00234012 23.466797,43.637695,24.038086,44.033203 +00234020 21.467285,43.308105,22.038574,43.703613 +00234021 21.972656,43.308105,22.543945,43.703613 +00234022 22.456055,43.308105,23.027344,43.703613 +00234023 22.961426,43.308105,23.532715,43.703613 +00234024 23.466797,43.308105,24.038086,43.703613 +00234032 21.467285,42.978516,22.038574,43.374023 +00234033 21.972656,42.956543,22.543945,43.395996 +00234034 22.456055,42.978516,23.027344,43.374023 +00234035 22.961426,42.978516,23.532715,43.374023 +00234036 23.466797,42.978516,24.038086,43.374023 +00234044 21.467285,42.604980,22.038574,43.044434 +00234045 21.972656,42.626953,22.543945,43.022461 +00234046 22.456055,42.626953,23.027344,43.022461 +00234047 22.961426,42.626953,23.532715,43.022461 +00234048 23.466797,42.604980,24.038086,43.044434 +00234056 21.467285,42.297363,22.038574,42.692871 +00234057 21.972656,42.297363,22.543945,42.692871 +00234058 22.456055,42.297363,23.027344,42.692871 +00234059 22.961426,42.297363,23.532715,42.692871 +00234060 23.466797,42.297363,24.038086,42.692871 +00234068 21.467285,41.967773,22.038574,42.363281 +00234069 21.972656,41.967773,22.543945,42.363281 +00234070 22.456055,41.967773,23.027344,42.363281 +00234071 22.961426,41.967773,23.532715,42.363281 +00234072 23.466797,41.967773,24.038086,42.363281 +00234080 21.467285,41.638184,22.038574,42.033691 +00234081 21.972656,41.638184,22.543945,42.033691 +00234082 22.456055,41.638184,23.027344,42.033691 +00234083 22.961426,41.638184,23.532715,42.033691 +00234084 23.466797,41.638184,24.038086,42.033691 +00234092 21.467285,41.308594,22.038574,41.704102 +00234093 21.972656,41.308594,22.543945,41.704102 +00234094 22.456055,41.308594,23.027344,41.704102 +00234095 22.961426,41.308594,23.532715,41.704102 +00234096 23.466797,41.308594,24.038086,41.704102 +00234104 21.467285,40.979004,22.038574,41.374512 +00234105 21.972656,40.957031,22.543945,41.396484 +00234106 22.456055,40.979004,23.027344,41.374512 +00234107 22.961426,40.979004,23.532715,41.374512 +00234108 23.466797,40.979004,24.038086,41.374512 +00234116 21.467285,40.605469,22.038574,41.044922 +00234117 21.972656,40.627441,22.543945,41.022949 +00234118 22.456055,40.605469,23.027344,41.044922 +00234119 22.961426,40.627441,23.532715,41.066895 +00234120 23.466797,40.627441,24.038086,41.022949 +00235001 23.972168,43.637695,24.543457,44.033203 +00235002 24.455566,43.637695,25.026855,44.033203 +00235003 24.960938,43.637695,25.532227,44.033203 +00235004 25.466309,43.637695,26.037598,44.033203 +00235005 25.971680,43.637695,26.542969,44.033203 +00235006 26.455078,43.637695,27.026367,44.033203 +00235007 26.960449,43.637695,27.531738,44.033203 +00235008 27.465820,43.637695,28.037109,44.033203 +00235009 27.971191,43.637695,28.542480,44.033203 +00235010 28.454590,43.637695,29.025879,44.033203 +00235013 23.972168,43.308105,24.543457,43.703613 +00235014 24.455566,43.308105,25.026855,43.703613 +00235015 24.960938,43.308105,25.532227,43.703613 +00235016 25.466309,43.308105,26.037598,43.703613 +00235017 25.971680,43.308105,26.542969,43.703613 +00235018 26.455078,43.308105,27.026367,43.703613 +00235019 26.960449,43.308105,27.531738,43.703613 +00235020 27.465820,43.308105,28.037109,43.703613 +00235021 27.971191,43.308105,28.542480,43.703613 +00235022 28.454590,43.308105,29.025879,43.703613 +00235025 23.972168,42.956543,24.543457,43.395996 +00235026 24.455566,42.978516,25.026855,43.374023 +00235027 24.960938,42.978516,25.532227,43.374023 +00235028 25.466309,42.956543,26.037598,43.395996 +00235029 25.971680,42.978516,26.542969,43.374023 +00235030 26.455078,42.978516,27.026367,43.374023 +00235031 26.960449,42.978516,27.531738,43.374023 +00235032 27.465820,42.978516,28.037109,43.374023 +00235033 27.971191,42.978516,28.542480,43.374023 +00235034 28.454590,42.978516,29.025879,43.374023 +00235037 23.972168,42.626953,24.543457,43.022461 +00235038 24.455566,42.626953,25.026855,43.066406 +00235039 24.960938,42.604980,25.532227,43.044434 +00235040 25.466309,42.604980,26.037598,43.044434 +00235041 25.971680,42.626953,26.542969,43.022461 +00235042 26.455078,42.626953,27.026367,43.022461 +00235043 26.960449,42.626953,27.531738,43.022461 +00235044 27.465820,42.604980,28.037109,43.044434 +00235045 27.971191,42.626953,28.542480,43.022461 +00235046 28.454590,42.626953,29.025879,43.022461 +00235049 23.972168,42.297363,24.543457,42.692871 +00235050 24.455566,42.297363,25.026855,42.692871 +00235051 24.960938,42.297363,25.532227,42.692871 +00235052 25.466309,42.297363,26.037598,42.692871 +00235053 25.971680,42.297363,26.542969,42.692871 +00235054 26.455078,42.297363,27.026367,42.692871 +00235055 26.960449,42.297363,27.531738,42.692871 +00235056 27.465820,42.297363,28.037109,42.692871 +00235057 27.971191,42.297363,28.542480,42.692871 +00235058 28.454590,42.297363,29.025879,42.692871 +00235061 23.972168,41.967773,24.543457,42.363281 +00235062 24.455566,41.967773,25.026855,42.363281 +00235063 24.960938,41.967773,25.532227,42.363281 +00235064 25.466309,41.967773,26.037598,42.363281 +00235065 25.971680,41.967773,26.542969,42.363281 +00235066 26.455078,41.967773,27.026367,42.363281 +00235067 26.960449,41.967773,27.531738,42.363281 +00235068 27.465820,41.967773,28.037109,42.363281 +00235069 27.971191,41.967773,28.542480,42.363281 +00235070 28.454590,41.967773,29.025879,42.363281 +00235073 23.928223,41.638184,24.543457,42.033691 +00235074 24.455566,41.638184,25.026855,42.033691 +00235075 24.960938,41.638184,25.532227,42.033691 +00235076 25.466309,41.638184,26.037598,42.033691 +00235077 25.971680,41.638184,26.542969,42.033691 +00235078 26.455078,41.638184,27.026367,42.033691 +00235079 26.960449,41.638184,27.531738,42.033691 +00235080 27.465820,41.638184,28.037109,42.033691 +00235081 27.971191,41.638184,28.542480,42.033691 +00235082 28.454590,41.638184,29.025879,42.033691 +00235085 23.972168,41.308594,24.543457,41.704102 +00235086 24.455566,41.308594,25.026855,41.704102 +00235087 24.960938,41.308594,25.532227,41.704102 +00235088 25.466309,41.308594,26.037598,41.704102 +00235089 25.971680,41.308594,26.542969,41.704102 +00235090 26.455078,41.308594,27.026367,41.704102 +00235091 26.960449,41.308594,27.531738,41.704102 +00235092 27.465820,41.308594,28.037109,41.704102 +00235093 27.971191,41.308594,28.542480,41.704102 +00235094 28.454590,41.308594,29.025879,41.704102 +00235097 23.972168,40.979004,24.543457,41.374512 +00235098 24.455566,40.979004,25.026855,41.374512 +00235099 24.960938,40.979004,25.532227,41.374512 +00235100 25.466309,40.979004,26.037598,41.374512 +00235101 25.971680,40.957031,26.542969,41.396484 +00235102 26.455078,40.979004,27.026367,41.374512 +00235103 26.960449,40.979004,27.531738,41.374512 +00235104 27.465820,40.979004,28.037109,41.374512 +00235105 27.971191,40.957031,28.542480,41.396484 +00235106 28.454590,40.979004,29.025879,41.374512 +00235109 23.972168,40.605469,24.543457,41.044922 +00235110 24.455566,40.627441,25.026855,41.066895 +00235111 24.960938,40.627441,25.532227,41.066895 +00235112 25.466309,40.605469,26.037598,41.044922 +00235113 25.971680,40.627441,26.542969,41.022949 +00235114 26.455078,40.627441,27.026367,41.022949 +00235115 26.960449,40.627441,27.531738,41.022949 +00235116 27.465820,40.627441,28.037109,41.022949 +00235117 27.971191,40.605469,28.542480,41.044922 +00235118 28.454590,40.627441,29.025879,41.022949 +00334140 21.467285,43.967285,22.038574,44.362793 +00334141 21.972656,43.967285,22.543945,44.362793 +00334142 22.456055,43.967285,23.027344,44.362793 +00334143 22.961426,43.967285,23.532715,44.362793 +00334144 23.466797,43.967285,24.038086,44.362793 +00335133 23.972168,43.967285,24.543457,44.362793 +00335134 24.455566,43.967285,25.026855,44.362793 +00335135 24.960938,43.967285,25.532227,44.362793 +00335136 25.466309,43.967285,26.037598,44.362793 +00335137 25.971680,43.967285,26.542969,44.362793 +00335138 26.455078,43.967285,27.026367,44.362793 +00335139 26.960449,43.967285,27.531738,44.362793 +00335140 27.465820,43.967285,28.037109,44.362793 +00335141 27.971191,43.967285,28.542480,44.362793 +00335142 28.454590,43.967285,29.025879,44.362793 +""" +map_segments = [] +for line in temp.split("\n"): + map_segments.append(line.split(" ")[0]) + + +import os,sys,garmin_img_roads + +print(len(map_segments)) +filepath = r"D:\\maps-bg\\gmapsupp\\gmapsupp.img" + +if input(f'Running against {len(map_segments)} and {filepath} [!=y break]')!='y': + exit() + +counter = 0 + +# map_segments = map_segments[50:52] + +for msegment in map_segments: + # sys.argv = ['garmin_img_to_osmand_v4.py', filepath, '--mapset',msegment,"--landmarks-csv",f"export_landmarks/landmarks-{msegment}.csv"] + # garmin_img_to_osmand_v4.main() + # print("Parsed ",msegment) + # sys.argv = ['garmin_img_to_osmand.py', filepath, '--mapset',msegment,'--landmarks-csv', f"..\parsed-landmarks\csv-from-stage-1\export-v6-{msegment}.csv", '--include-unnamed','--point-profile','all'] + # garmin_img_to_osmand.main() + #python stage-1-read-garmin-img/garmin_img_to_osmand.py 'd:/maps-bg/gmapsupp/gmapsupp.img' --point-profile all --include-unnamed --point-group-by raw_type + + sys.argv = ['garmin_img_to_osmand.py', filepath, + '--stage1-module','../stage-1-read-garmin-img/garmin_img_to_osmand.py', + '--mapset', msegment, + '--roads-csv', f"../parsed-landmarks/roads-from-stage-3/export-roads-v3-t1-{msegment}.csv", + '--road-profile', 'all_lines' + ] + + garmin_img_roads.main() + print(f"[{counter}/{len(map_segments)}] - Parsed {msegment}") + counter = counter + 1 \ No newline at end of file