#!/usr/bin/env python3 """v0.93: streaming IMG/OSM native-OsmAnd translation and honest QA. Only user-editable controls: config/poi-mapping.csv and config/line-mapping.csv. """ import argparse,base64,collections,csv,hashlib,json,pathlib,re,sqlite3,sys,xml.etree.ElementTree as ET from xml.sax.saxutils import escape,quoteattr def top_level_elements(source): """Bounded-memory XML pull iterator. ElementTree .clear() alone leaves one empty Element per processed object in the root; clearing the root prevents nationwide Garmin files from exhausting RAM during multi-pass conversion. Callers must fully process each element before requesting the next. """ context=ET.iterparse(source,events=('start','end')) event, root=next(context) if event != 'start' or root.tag != 'osm': raise ValueError('Expected an OSM XML root element') depth=1 for event,elem in context: if event=='start':depth+=1;continue if depth==2 and elem.tag in ('node','way','relation'): yield elem root.clear() depth-=1 def tagdict(elem):return {x.get('k'):x.get('v','') for x in elem.findall('tag')} def set_tags(elem,d): for child in list(elem): if child.tag=='tag':elem.remove(child) for k,v in d.items(): if v is not None and v!='':ET.SubElement(elem,'tag',k=k,v=str(v)) def read_csv(p): with open(p,encoding='utf-8-sig',newline='') as f: lines=(line for line in f if not line.startswith('#')) yield from csv.DictReader(lines) def code(value):return hex(int(value,0)).lower() def mapping(path): ret={} for row in read_csv(path): try:c=code(row['code']) except Exception as ex:raise ValueError('Invalid code in '+str(path)+': '+str(row)) from ex if c in ret:raise ValueError(f'Duplicate code {c} in {path}') ret[c]=row return ret def tags_from_string(v): d={} for x in v.split(';'): if not x:continue if '=' not in x:raise ValueError(f'Invalid OSM tag {x!r}') k,val=x.split('=',1) if not k or not val:raise ValueError(f'Invalid OSM tag {x!r}') if k in ('name','garmin:type','garmin:feature','garmin:mapset','bgm:generated_name'): raise ValueError('Reserved mapping key: '+k) d[k]=val return d # User-requested PRIVATE-map presentation fallback. A locality is a safe stock-map # display tag, but this is synthetically applied to unknown source types and is # therefore NOT suitable for public OSM imports. Elevation annotations are excluded. SEMANTIC_KEYS = frozenset(('amenity','tourism','emergency','natural','man_made','place', 'historic','shop','leisure','building','waterway','railway','power','aeroway', 'highway','hazard','ford','water','water_source','landuse','barrier', 'aerialway','craft','military','geological','public_transport','sport')) GENERATED_SUFFIX = re.compile(r' - ([A-Z0-9]{7,16})$') def stable_unknown_hash(elem,tags): name=tags.get('name','') m=GENERATED_SUFFIX.search(name) if tags.get('bgm:generated_name') == 'yes' else None if m:return m.group(1) # preserve previously generated stable identity seed='|'.join((tags.get('garmin:mapset',''),tags.get('garmin:type',''), elem.get('lat',''),elem.get('lon',''))) return base64.b32encode(hashlib.blake2s(seed.encode('utf-8'),digest_size=7).digest()).decode().rstrip('=')[:11] def add_native_unknown_fallback(elem,tags,rule): """Only unclassified POIs. Named POIs retain their genuine source names.""" if any(k in tags for k in SEMANTIC_KEYS):return False if tags.get('garmin:type') == '0x6600' or 'ele' in tags: return False # spot heights and elevation annotations are not unknown places if rule and rule.get('action','').strip().lower()=='drop':return False tags['place']='locality' tags['bgm:synthetic_fallback']='place_locality' tags['bgm:classification']='unverified' name=tags.get('name','').strip() if not name or tags.get('bgm:generated_name')=='yes' or name.startswith('Garmin обект '): tags['name']='Неуточнено - '+stable_unknown_hash(elem,tags) tags['bgm:generated_name']='yes' return True def make_cpp_rules(pois,out,labels): """C++ v0.9 uses these internal stage files; they are NOT user control files.""" with open(out,'w',encoding='utf-8',newline='') as f,open(labels,'w',encoding='utf-8',newline='') as g: w=csv.writer(f);w.writerow(['code','name_contains','mode','tags','note']) l=csv.writer(g);l.writerow(['code','label_bg']) for c,row in sorted(pois.items(),key=lambda t:int(t[0],0)): action=row.get('action','replace').strip().lower() if action not in ('replace','append','raw','drop'):raise ValueError('Invalid action for '+c) vals=tags_from_string(row.get('osm_tags','')) if action=='raw':action='replace';vals={} if action=='drop':vals={} w.writerow([c,'',action,';'.join(k+'='+v for k,v in vals.items()),'v0.92 compiled from poi-mapping.csv']) if row.get('label_bg'):l.writerow([c,row['label_bg']]) def inspect_counts(osm,pois,lines,out,strict=False): seen=collections.Counter();unresolved=collections.Counter();annotations=collections.Counter();fallback=collections.Counter();bad=collections.Counter();routes=collections.Counter();examples={};category=collections.Counter();n=0 with open(osm,'rb') as src: for el in top_level_elements(src): if el.tag=='node': t=tagdict(el) if t.get('garmin:feature')=='poi': n+=1;c=t.get('garmin:type','');seen[c]+=1 if c not in pois:bad[c]+=1 if t.get('bgm:synthetic_fallback')=='place_locality':fallback[c]+=1 if c=='0x6600' and 'ele' in t: annotations['spot_height']+=1 elif not any(k in t for k in ('amenity','tourism','emergency','natural','man_made','place','historic','shop','leisure','building','waterway','railway','power','aeroway','highway','ele','hazard','ford','water','water_source','landuse','barrier','aerialway','craft','geological')): unresolved[c]+=1 if len(examples.get(c,[]))<5:examples.setdefault(c,[]).append([el.get('lat'),el.get('lon'),t.get('name','')]) for k in ('amenity','tourism','emergency','natural','man_made','building','historic','shop','leisure'): if k in t:category[f'{k}={t[k]}']+=1 elif el.tag=='relation': t=tagdict(el) if t.get('route')=='hiking':routes[t.get('garmin:type','unknown')]+=1 el.clear() summary={'status':'FAIL' if strict and (bad or unresolved) else ('WARN' if bad or unresolved else 'PASS'), 'total_pois':n,'mapped_types':len(pois),'observed_types':len(seen), 'source_codes_not_in_config':dict(bad),'unclassified_counts':dict(unresolved), 'unclassified_total':sum(unresolved.values()),'synthetic_locality_fallback_total':sum(fallback.values()),'synthetic_locality_counts':dict(fallback),'annotations_not_missing_pois':dict(annotations),'route_relation_counts':dict(routes), 'native_semantic_categories':dict(category.most_common(100)), 'sample_coordinates':examples, 'warning':'OSM audit only; stock icons/search not guaranteed. Synthetic localities are private-map fallback, not valid public OSM classification. Elevation-only objects deliberately have no artificial POI icon.'} pathlib.Path(out).write_text(json.dumps(summary,ensure_ascii=False,indent=2)+'\n',encoding='utf-8') return summary def transform(source,dest,pois,lines,report,strict=False): """Streaming write; route metadata held in SQLite so nationwide builds do not need RAM for all nodes/ways. Native route relations are built only from selected Garmin colored overlay ways, NOT from invented highway=path edges. """ dbpath=pathlib.Path(dest).with_suffix('.edges.sqlite') if dbpath.exists():raise FileExistsError(dbpath) db=sqlite3.connect(dbpath) db.execute('PRAGMA synchronous=OFF');db.execute('PRAGMA journal_mode=MEMORY') db.execute('CREATE TABLE edges (code TEXT, way INTEGER, a INTEGER, b INTEGER)') poi_count=0; ways=0; candidate_edges=0; emitted=0; synthetic=0; kinds=collections.Counter();seen=collections.Counter();gaps=collections.Counter() with open(source,'rb') as inp,open(dest,'w',encoding='utf-8') as out: out.write('\n\n') for el in top_level_elements(inp): if el.tag=='node': tags=tagdict(el) if tags.get('garmin:feature')=='poi': poi_count+=1;c=tags.get('garmin:type','') if c in pois: row=pois[c];tags['bgm:legend_bg']=row.get('legend_bg','') or row.get('label_bg','') tags['bgm:priority']=row.get('priority','normal') # only provenance; no false POI semantic category inserted here tags.pop('bgm_poi',None);tags.pop('bgm:synthetic_icon',None) # Unclassified Garmin POIs intentionally remain raw: stock OsmAnd # cannot visually draw a unique icon for each without installed resources. else:gaps[c]+=1;tags.pop('bgm_poi',None) if add_native_unknown_fallback(el,tags,pois.get(c)): synthetic+=1 set_tags(el,tags);seen[c]+=1 out.write(ET.tostring(el,encoding='unicode')+'\n');emitted+=1 elif el.tag=='way': ways+=1;tags=tagdict(el) if tags.get('garmin:feature')=='extended_polyline' or 'bgm_route' in tags: c=tags.get('garmin:type','') r=lines.get(c) tags.pop('bgm_route',None) # Raw geometry and colour may still be needed for diagnosis; # no duplicate road geometry or misleading water/terrain categories. if r: tags['bgm:line_legend']=r.get('legend_bg','') tags['bgm:translation_status']=r.get('route_confidence','unknown') tags.update(tags_from_string(r.get('osm_way_tags',''))) if r.get('route_mode','').lower()=='relation': nref=[int(x.attrib['ref']) for x in el.findall('nd')] if len(nref)>=2: db.execute('INSERT INTO edges VALUES (?,?,?,?)',(c,int(el.get('id')),nref[0],nref[-1]));candidate_edges+=1 else:gaps[c]+=1 set_tags(el,tags) out.write(ET.tostring(el,encoding='unicode')+'\n');emitted+=1 elif el.tag=='relation': out.write(ET.tostring(el,encoding='unicode')+'\n');emitted+=1 el.clear() db.commit() # Use topological unions on endpoint node refs to avoid one nationwide route per color. grouped=collections.defaultdict(list) for c,w,a,b in db.execute('SELECT code,way,a,b FROM edges ORDER BY code,way'): grouped[c].append((w,a,b)) relations=0;components=0;largest=0 for c,edges in sorted(grouped.items()): parent=list(range(len(edges)));rank=[0]*len(edges) def root(i): while parent[i]!=i: parent[i]=parent[parent[i]];i=parent[i] return i def union(a,b): ra,rb=root(a),root(b) if ra==rb:return if rank[ra]\n') db.close();dbpath.unlink(missing_ok=True) result={'pois':poi_count,'synthetic_locality_fallbacks':synthetic,'ways':ways,'route_candidate_ways':candidate_edges, 'route_relations':relations,'route_components':components,'largest_component_ways':largest, 'source_types_missing_mapping':dict(gaps),'notes':['Synthetic place=locality on unknown features is PRIVATE-map-only' ,'Native OSM route relations; no custom phone-side stylesheet','Display zoom remains controlled by stock OsmAnd, not config zoom_hint','Route symbol derived from Garmin color; color_only mappings require review']} pathlib.Path(report).write_text(json.dumps(result,ensure_ascii=False,indent=2)+'\n',encoding='utf-8') return result def template(catalog,poi_file,line_file,osm=None): """Future map: source-driven, abstains from guessing semantic identities.""" if pathlib.Path(poi_file).exists() or pathlib.Path(line_file).exists():raise FileExistsError('Refusing to overwrite mapping templates') pr=[] for row in read_csv(catalog): pr.append({'code':row['code'],'source_count':row['count'],'legend_bg':'','legend_en':'', 'meaning_confidence':'unknown','osm_tags':'','label_bg':f'Garmin обект {row["code"]}', 'priority':'normal','zoom_hint':'17','action':'raw', 'note':f'SAMPLE {row.get("example1_lat","")}, {row.get("example1_lon","")}, {row.get("example1_name","")}'}) lc=collections.Counter() if osm: with open(osm,'rb') as inp: for el in top_level_elements(inp): if el.tag=='way': t=tagdict(el) if t.get('garmin:feature')=='extended_polyline':lc[t.get('garmin:type','')]+=1 el.clear() lr=[{'code':k,'source_count':v,'legend_bg':'','legacy_color':'', 'osm_way_tags':'', 'route_mode':'preserve','osmc_symbol':'','network':'lwn','route_confidence':'unknown', 'priority':'normal','note':'Unmapped type: check Garmin legend and geometry before enabling'} for k,v in sorted(lc.items())] with open(poi_file,'w',encoding='utf-8',newline='') as f: w=csv.DictWriter(f,fieldnames=list(pr[0]) if pr else ['code','source_count','legend_bg','legend_en','meaning_confidence','osm_tags','label_bg','priority','zoom_hint','action','note']);w.writeheader();w.writerows(pr) with open(line_file,'w',encoding='utf-8',newline='') as f: w=csv.DictWriter(f,fieldnames=list(lr[0]) if lr else ['code','source_count','legend_bg','legacy_color','osm_way_tags','route_mode','osmc_symbol','network','route_confidence','priority','note']);w.writeheader();w.writerows(lr) return {'poi_types':len(pr),'line_types':len(lr)} def main(): p=argparse.ArgumentParser(description=__doc__);sub=p.add_subparsers(dest='mode',required=True) for cmd in ['prepare','transform','audit','template']: s=sub.add_parser(cmd);s.add_argument('--poi',required=True);s.add_argument('--lines',required=True) if cmd=='prepare':s.add_argument('--out',required=True) elif cmd=='transform':s.add_argument('--osm',required=True);s.add_argument('--out',required=True);s.add_argument('--report',required=True) elif cmd=='audit':s.add_argument('--osm',required=True);s.add_argument('--out',required=True);s.add_argument('--strict',action='store_true') else:s.add_argument('--catalog',required=True);s.add_argument('--osm') a=p.parse_args() if a.mode=='template':res=template(a.catalog,a.poi,a.lines,a.osm) else: ps=mapping(a.poi);ls=mapping(a.lines) if a.mode=='prepare': out=pathlib.Path(a.out);out.mkdir(parents=True,exist_ok=True) make_cpp_rules(ps,out/'cpp-poi-override.csv',out/'cpp-name-labels.csv');res={'poi_types':len(ps),'line_types':len(ls)} elif a.mode=='transform':res=transform(a.osm,a.out,ps,ls,a.report) else:res=inspect_counts(a.osm,ps,ls,a.out,a.strict) print(json.dumps(res,ensure_ascii=False,indent=2));return 0 if __name__=='__main__':sys.exit(main())