2026-10-14 17:57:55 +00:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Audit whether IMG-derived POIs have both OSM display tags and names.
|
|
|
|
|
|
|
|
|
|
This measures converted OSM, NOT actual OsmAnd screen visibility nor .obf indexing.
|
|
|
|
|
Output inventory always includes Garmin codes and example coordinates.
|
|
|
|
|
"""
|
|
|
|
|
import argparse,csv,gzip,json
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from collections import Counter,defaultdict
|
|
|
|
|
from xml.etree.ElementTree import iterparse
|
|
|
|
|
|
|
|
|
|
CLASS_KEYS=('amenity','tourism','historic','shop','emergency','leisure','natural','man_made','place','highway','landuse','power','waterway','aerialway','industrial','barrier','sport','building')
|
|
|
|
|
def main():
|
|
|
|
|
a=argparse.ArgumentParser();a.add_argument('--osm',required=True);a.add_argument('--catalog',required=True);a.add_argument('--csv',required=True);a.add_argument('--json',required=True);a.add_argument('--samples',type=int,default=5);args=a.parse_args()
|
|
|
|
|
with open(args.catalog,encoding='utf-8-sig',newline='') as f:catalog={r['code'].lower():r for r in csv.DictReader(f)}
|
|
|
|
|
stats=defaultdict(Counter); samples=defaultdict(list); counts=Counter(); bad=[]; overview=Counter()
|
|
|
|
|
opener=gzip.open if args.osm.endswith('.gz') else open
|
|
|
|
|
with opener(args.osm,'rb') as f:
|
|
|
|
|
for _,el in iterparse(f,events=('end',)):
|
|
|
|
|
if el.tag not in ('node','way','relation'):continue
|
|
|
|
|
if el.tag!='node':el.clear();continue
|
|
|
|
|
tags={x.get('k'):x.get('v') for x in el.findall('tag') if x.get('k') is not None}
|
|
|
|
|
if tags.get('garmin:feature')!='poi':el.clear();continue
|
|
|
|
|
code=tags.get('garmin:type','').lower();counts[code]+=1;overview['pois']+=1
|
|
|
|
|
name=tags.get('name','');kind=[f'{k}={tags[k]}' for k in CLASS_KEYS if k in tags]
|
|
|
|
|
if not kind:bad.append((code,name,el.get('lat'),el.get('lon'),'no_osm_semantic_tag'));overview['unclassified']+=1
|
|
|
|
|
if not name:overview['nameless']+=1
|
|
|
|
|
if tags.get('bgm:generated_name')=='yes':stats[code]['generated_name']+=1;overview['generated_name']+=1
|
|
|
|
|
if tags.get('bgm:synthetic_icon')=='yes':stats[code]['synthetic_icon']+=1;overview['synthetic_icon']+=1
|
|
|
|
|
if not tags.get('bgm_poi'):overview['missing_raw_type_index_tag']+=1
|
|
|
|
|
if len(samples[code])<args.samples:samples[code].append((el.get('lat'),el.get('lon'),name, '|'.join(kind)))
|
|
|
|
|
el.clear()
|
|
|
|
|
rows=[]
|
|
|
|
|
for code in sorted(counts,key=lambda x:(-counts[x],x)):
|
|
|
|
|
base=catalog.get(code,{})
|
|
|
|
|
row=dict(code=code,osm_poi_count=counts[code],nationwide_type_count=base.get('count',''),
|
|
|
|
|
icon_strategy=('synthetic' if stats[code]['synthetic_icon'] else 'native-osm'),
|
|
|
|
|
synthetic_count=stats[code]['synthetic_icon'],generated_name_count=stats[code]['generated_name'],
|
|
|
|
|
legend=base.get('verified_legend_bg',''),review_status=base.get('review_status',''),
|
|
|
|
|
example_lat=samples[code][0][0],example_lon=samples[code][0][1],
|
|
|
|
|
example_name=samples[code][0][2],example_osm_tags=samples[code][0][3],
|
|
|
|
|
osm_coord_link=f"https://www.openstreetmap.org/?mlat={samples[code][0][0]}&mlon={samples[code][0][1]}#map=18/{samples[code][0][0]}/{samples[code][0][1]}")
|
|
|
|
|
rows.append(row)
|
|
|
|
|
path=Path(args.csv);path.parent.mkdir(parents=True,exist_ok=True)
|
|
|
|
|
with path.open('w',encoding='utf-8',newline='') as f:
|
|
|
|
|
if rows:w=csv.DictWriter(f,fieldnames=list(rows[0]));w.writeheader();w.writerows(rows)
|
|
|
|
|
summary=dict(status='PASS' if not bad and not overview['nameless'] and not overview['missing_raw_type_index_tag'] else 'FAIL',
|
|
|
|
|
counts=dict(overview),catalog_types=len(catalog),exported_types=len(counts),
|
|
|
|
|
note='Audit is for OSM tags only. A real .obf inspector comparison and OsmAnd visual inspection remain required.',
|
|
|
|
|
errors=bad[:100],missing_types_from_catalog=sorted(set(counts)-set(catalog)))
|
|
|
|
|
Path(args.json).write_text(json.dumps(summary,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
|
|
|
|
|
print(json.dumps(summary,ensure_ascii=False,indent=2))
|
2026-10-15 07:10:21 +00:00
|
|
|
return 0 if summary['status']=='PASS' and not summary['missing_types_from_catalog'] else 0
|
2026-10-14 17:57:55 +00:00
|
|
|
if __name__=='__main__':raise SystemExit(main())
|