#!/usr/bin/env python3 """One-command BGMountains IMG -> reviewed OSM -> custom indexed OsmAnd OBF. Only Python 3 stdlib needed for orchestration; CMake/C++/OsmAndMapCreator are external. """ from __future__ import annotations import argparse import csv import datetime as dt import hashlib import json import os from pathlib import Path import shutil import subprocess import sys import urllib.request import xml.etree.ElementTree as ET ROOT = Path(__file__).resolve().parents[1] UPSTREAM = { 'poi': 'https://raw.githubusercontent.com/osmandapp/OsmAnd-resources/master/poi/poi_types.xml', 'rendering': 'https://raw.githubusercontent.com/osmandapp/OsmAnd-resources/master/obf_creation/rendering_types.xml' } def call(argv, cwd=None, log=None, env=None): argv = [str(s) for s in argv] print('+', ' '.join(shlex_quote(s) for s in argv), flush=True) if log: with Path(log).open('w', encoding='utf-8') as output: r = subprocess.run(argv, cwd=cwd, env=env, stdout=output, stderr=subprocess.STDOUT) if r.returncode: print('Command failed; last log lines:', file=sys.stderr) print('\n'.join(Path(log).read_text(errors='replace').splitlines()[-35:]), file=sys.stderr) raise SystemExit(r.returncode) else: subprocess.run(argv, cwd=cwd, env=env, check=True) def shlex_quote(s): import shlex return shlex.quote(s) def base_xml(provided, creator, key, cache, offline): if provided: p = Path(provided).expanduser().resolve() if not p.is_file(): raise SystemExit('Upstream XML not found: '+str(p)) return p names = ['poi_types.xml'] if key == 'poi' else ['rendering_types.xml'] if creator: for prefix in [creator, creator/'resources', creator/'config', creator/'poi', creator/'obf_creation']: for name in names: candidate = prefix/name if candidate.is_file(): return candidate.resolve() target = cache/names[0] if target.is_file(): return target if offline: raise SystemExit('Missing upstream '+names[0]+': pass --'+key+'-base FILE or allow download') target.parent.mkdir(parents=True,exist_ok=True) print('Fetching official OsmAnd '+names[0]+' (first run only) ...', flush=True) try: with urllib.request.urlopen(UPSTREAM[key], timeout=50) as u, target.open('wb') as out: shutil.copyfileobj(u,out) except Exception as ex: target.unlink(missing_ok=True) raise SystemExit('Cannot fetch official OsmAnd '+names[0]+': '+str(ex)+'\nProvide --'+key+'-base FILE manually.') return target def catalog_rows(p): with Path(p).open('r',encoding='utf-8-sig',newline='') as f: rows=list(csv.DictReader(f)) if not rows or not {'code','count','has_typ_icon'}.issubset(rows[0]): raise SystemExit('Invalid POI catalog (expected code,count,has_typ_icon): '+str(p)) out=[] seen=set() for r in rows: code='0x'+format(int(r['code'],0),'x') if code in seen:raise SystemExit('Duplicate code in catalog: '+code) seen.add(code) out.append(dict(r,code=code,count=int(r['count']))) return sorted(out,key=lambda r:(-r['count'],int(r['code'],0))) def write_xml(tree,path): ET.indent(tree,space=' ') tree.write(path,encoding='utf-8',xml_declaration=True) ET.parse(path) def make_configs(poi_base,render_base,catalog,folder,threshold,frequent_zoom=15,rare_zoom=17,route_styles=()): # Keep entire upstream definitions! Replacing with a standalone tiny XML is # precisely what used to make ordinary semantic POIs disappear. poi=ET.parse(poi_base); proot=poi.getroot() if proot.tag!='poi_types':raise SystemExit('Not an OsmAnd poi_types.xml: '+str(poi_base)) if any(c.get('name')=='bgmountains_raw' for c in proot.findall('poi_category')): raise SystemExit('Input poi_types.xml already has a bgmountains_raw category') cat=ET.SubElement(proot,'poi_category',name='bgmountains_raw', default_tag='bgm_poi', no_edit='true') for r in catalog: code=r['code'] ET.SubElement(cat,'poi_type',name='bgm_'+code.replace('0x',''),tag='bgm_poi',value='type_'+code,no_edit='true') pa=folder/'poi_types.bgmountains.xml' write_xml(poi,pa) rendering=ET.parse(render_base); rroot=rendering.getroot() if rroot.tag != 'osmand_types': # OsmAnd resources use the tag 'rendering_types'; fail instead of dropping all built-in types. raise SystemExit('Unexpected OsmAnd rendering_types.xml root: '+rroot.tag) cats=[c for c in rroot.iter('category') if c.get('name')=='user_defined'] target=cats[0] if cats else ET.SubElement(rroot,'category',name='user_defined') for r in catalog: code=r['code'] ET.SubElement(target,'type',tag='bgm_poi',value='type_'+code, minzoom=str(frequent_zoom) if r['count']>=threshold else str(rare_zoom)) # Route overlays have their OWN map-index main types, not only an ignored metadata tag. # Do not map decorative overlays to 'highway=path': that would create bogus routing. for route in route_styles: if route.get('show','yes').lower() not in ('yes','1','true'): continue ET.SubElement(target,'type',tag='bgm_route',value='type_'+route['code'],minzoom=route['display_minzoom']) rp=folder/'rendering_types.bgmountains.xml' write_xml(rendering,rp) summary={ 'catalog_types':len(catalog), 'route_overlay_types':len(route_styles), 'poi_count_in_catalog':sum(r.get('sample_count',r['count']) for r in catalog), 'types_at_or_above_threshold':sum(r['count']>=threshold for r in catalog), 'frequent_index_minzoom':frequent_zoom, 'rare_index_minzoom':rare_zoom, 'items_at_or_above_threshold':sum(r.get('sample_count',r['count']) for r in catalog if r['count']>=threshold), 'types_with_extracted_icon':sum(r['has_typ_icon']=='yes' for r in catalog), 'poi_base_sha256':hashlib.sha256(Path(poi_base).read_bytes()).hexdigest(), 'rendering_base_sha256':hashlib.sha256(Path(render_base).read_bytes()).hexdigest(), 'note':'Local-only compat data; synthetic information icons are not truth about an informational board. All Garmin hex POI types indexed; high-frequency raw types inserted into OBF map index from selected frequent zoom; low-frequency from selected rare zoom. Rendering exact Garmin icon PNGs requires installing compatible icons/style in OsmAnd, not merely compiling an OBF.' } (folder/'type-index-summary.json').write_text(json.dumps(summary,ensure_ascii=False,indent=2)+'\n',encoding='utf-8') return pa,rp,summary def read_route_styles(path): with Path(path).open(encoding='utf-8-sig',newline='') as f: rows=list(csv.DictReader(f)) if not rows or not {'code','color','show','display_minzoom','width'}.issubset(rows[0]): raise SystemExit('Invalid trail-styles.csv: '+str(path)) seen=set() for r in rows: code='0x'+format(int(r['code'],0),'x') if code in seen: raise SystemExit('Duplicate route style type: '+code) seen.add(code);r['code']=code if not (len(r['color'])==7 and r['color'][0]=='#' and all(c in '0123456789abcdefABCDEF' for c in r['color'][1:])): raise SystemExit('Invalid CSS color in route type '+code) if not (0.2<=float(r['width'])<=15 and 1<=int(r['display_minzoom'])<=21): raise SystemExit('Invalid route width/zoom '+code) return rows def make_style(icon_csv,folder,catalog,route_styles=()): """Selectable OsmAnd map style that maps BGM indexes to built-in icons. The .obf itself only embeds geometry/tags. Android requires installing and selecting this renderer; stock app ignores bgm_poi and bgm_route styling. """ known={r['code'] for r in catalog} with Path(icon_csv).open(encoding='utf-8-sig',newline='') as f: rows=list(csv.DictReader(f)) visible=[] for r in rows: code='0x'+format(int(r['code'],0),'x') if code not in known or r['show_in_custom_style'].strip().lower() not in ('yes','true','1'): continue name=r['osmand_builtin_icon'].strip() if not name or not name.replace('_','').replace('-','').isalnum(): raise SystemExit('Invalid OsmAnd icon: '+repr(name)+' code='+code) zoom=int(r['minzoom']) if not 1<=zoom<=21: raise SystemExit('Bad icon zoom: '+code) visible.append((code,name,zoom)) root=ET.Element('renderingStyle',name='BGMountains outdoor v0.9', depends='default',defaultColor='#fff8ee',version='1') order=ET.SubElement(root,'order');points=ET.SubElement(root,'point') lines=ET.SubElement(root,'line') for code,name,zoom in visible: ET.SubElement(order,'case',tag='bgm_poi',value='type_'+code, minzoom=str(zoom),point='true',objectType='1',order='120') ET.SubElement(points,'case',tag='bgm_poi',value='type_'+code,minzoom=str(zoom), icon=name,iconOrder='11',iconVisibleSize='16',intersectionSizeFactor='0.7') route_count=0 for route in route_styles: if route.get('show','yes').lower() not in ('yes','true','1'):continue route_count+=1 code=route['code'];zoom=route['display_minzoom'] ET.SubElement(order,'case',tag='bgm_route',value='type_'+code, minzoom=zoom,objectType='2',order='125') # Color and width faithfully recover TYP base line color, but NOT its bitmap # segment pattern. A precise pattern renderer requires a custom app build. ET.SubElement(lines,'case',tag='bgm_route',value='type_'+code, minzoom=zoom,color=route['color'],strokeWidth=route['width'],cap='ROUND') path=folder/'BGMountains-outdoor.render.xml' write_xml(ET.ElementTree(root),path) return path,len(visible),route_count def obtain_creator(cache, offline=False): """Opt-in download from official OsmAnd nightly build (large file).""" import zipfile link='https://download.osmand.net/latest-night-build/OsmAndMapCreator-main.zip' target=cache/'OsmAndMapCreator-main.zip' extracted=cache/'unpacked' found=list(extracted.rglob('utilities.sh')) if extracted.is_dir() else [] if found:return found[0].parent if offline:raise SystemExit('Cannot download MapCreator with --offline; pass --creator') cache.mkdir(parents=True,exist_ok=True) if not target.is_file(): print('Downloading official OsmAndMapCreator (~150 MB) ...',flush=True) try: with urllib.request.urlopen(link,timeout=180) as src,target.open('wb') as dest: shutil.copyfileobj(src,dest,length=2**20) except Exception as ex: target.unlink(missing_ok=True) raise SystemExit('Cannot download OsmAndMapCreator: '+str(ex)+'\nDownload manually: '+link) print('Extracting MapCreator...',flush=True) extracted.mkdir(parents=True,exist_ok=True) with zipfile.ZipFile(target) as z: for m in z.infolist(): rel=Path(m.filename) if rel.is_absolute() or '..' in rel.parts: raise SystemExit('Unsafe file in MapCreator archive: '+m.filename) z.extract(m,extracted) found=list(extracted.rglob('utilities.sh')) if not found:raise SystemExit('MapCreator ZIP extracted but utilities.sh not found') for name in ('utilities.sh','inspector.sh'): for f in extracted.rglob(name):f.chmod(f.stat().st_mode|0o111) return found[0].parent def resolve_creator(path): name = path or os.environ.get('OSMAND_MAPCREATOR') if not name:return None creator=Path(name).expanduser().resolve() if creator.is_file() and creator.name=='utilities.sh':creator=creator.parent if not (creator/'utilities.sh').is_file(): raise SystemExit('Cannot find utilities.sh under '+str(creator)) return creator def main(): p=argparse.ArgumentParser(description=__doc__,formatter_class=argparse.ArgumentDefaultsHelpFormatter) inp=p.add_mutually_exclusive_group(required=True) inp.add_argument('--img',help='Original Garmin IMG; converter is compiled and executed') inp.add_argument('--osm',help='Already converted OSM XML; must contain bgm_poi=type_0x... tags (v0.7+)') p.add_argument('--creator',help='OsmAndMapCreator directory containing utilities.sh (or OSMAND_MAPCREATOR env)') p.add_argument('--download-creator',action='store_true',help='Download official OsmAndMapCreator ZIP automatically (~150 MB) when no --creator is set') p.add_argument('--out',default='build/bgmountains-output',help='Output folder') p.add_argument('--jobs',type=int,default=1,help='C++ IMG reader threads (opt-in)') p.add_argument('--bbox',help='Optional W,S,E,N bounds for small-area test') p.add_argument('--threshold',type=int,default=100,help='Frequent-type occurrence threshold') p.add_argument('--frequent-zoom',type=int,default=15,help='Store frequent raw map icons at zoom N and above') p.add_argument('--rare-zoom',type=int,default=17,help='Store rare raw map icons at zoom N and above') p.add_argument('--icons-config',default=str(ROOT/'config/osmand-icons.csv'),help='OsmAnd icon mappings') p.add_argument('--trail-styles',default=str(ROOT/'config/trail-styles.csv'),help='Garmin TYP coloured route overlay styles') p.add_argument('--poi-overrides',default=str(ROOT/'config/poi-overrides-all-types.csv')) p.add_argument('--poi-name-fixes',default=str(ROOT/'config/poi-name-fixes.csv')) p.add_argument('--poi-labels',default=str(ROOT/'config/poi-labels-bg.csv'),help='Bulgarian prefixes for stable generated names on nameless Garmin POIs') p.add_argument('--poi-additions',default=str(ROOT/'config/poi-additions.csv')) p.add_argument('--poi-base',help='Official upstream poi_types.xml; if omitted use cache/download') p.add_argument('--rendering-base',help='Official upstream rendering_types.xml; if omitted use cache/download') p.add_argument('--offline',action='store_true',help='Never download upstream XML') p.add_argument('--prepare-only',action='store_true',help='Do not run OsmAndMapCreator, but generate OSM and XMLs') p.add_argument('--skip-qa',action='store_true',help='Skip OSM comparison for faster repeat runs') p.add_argument('--java-opts',default='-Xms512M -Xmx8192M',help='JAVA_OPTS for OsmAndMapCreator') p.add_argument('--skip-cpp-build',action='store_true') p.add_argument('--catalog',help='Preexisting POI catalog when supplying --osm (otherwise use packaged nationwide catalog)') p.add_argument('--frequency-catalog',default=str(ROOT/'data/BGMountains-unique-poi-types.csv'),help='Reference nationwide counts for map index zoom decisions even in --bbox builds') args=p.parse_args() if args.jobs<1 or args.jobs>64 or args.threshold<1 or not 1<=args.frequent_zoom<=21 or not 1<=args.rare_zoom<=21: p.error('--jobs 1..64; threshold >=1; zoom 1..21') dest=Path(args.out).expanduser().resolve();dest.mkdir(parents=True,exist_ok=True) creator=resolve_creator(args.creator) if not creator and args.download_creator and not args.prepare_only: creator=obtain_creator(ROOT/'build'/'mapcreator-download',args.offline) if not creator and not args.prepare_only:p.error('Supply --creator /path/to/OsmAndMapCreator (or OSMAND_MAPCREATOR env) or use --prepare-only') if args.img: if not Path(args.img).is_file():p.error('IMG file missing: '+args.img) binary=ROOT/'build'/'garmin2osm' if not args.skip_cpp_build or not binary.is_file(): call(['cmake','-S',ROOT,'-B',ROOT/'build','-DCMAKE_BUILD_TYPE=Release']) call(['cmake','--build',ROOT/'build','--config','Release','-j','2']) osm=dest/'BGMountains.osm' cmd=[binary,'--input',Path(args.img).resolve(),'--output',osm,'--poi-overrides',Path(args.poi_overrides).resolve(), '--poi-name-fixes',Path(args.poi_name_fixes).resolve(),'--poi-labels',Path(args.poi_labels).resolve(),'--poi-additions',Path(args.poi_additions).resolve(), '--poi-manifest',dest/'source-pois.csv','--poi-types-out',dest/'poi-types.csv','--jobs',args.jobs] if args.bbox:cmd+=['--bbox',args.bbox] call(cmd,log=dest/'converter.log') catalog=dest/'poi-types.csv' if not args.skip_qa: call([sys.executable,ROOT/'tools/validate_pois.py','compare','--source',dest/'source-pois.csv','--osm',osm, '--rules',Path(args.poi_overrides).resolve(),'--report',dest/'validation.json','--mismatches',dest/'mismatches.csv'], log=dest/'validator.log') else: osm=Path(args.osm).expanduser().resolve() if not osm.is_file():p.error('OSM file missing: '+str(osm)) catalog=Path(args.catalog).expanduser().resolve() if args.catalog else ROOT/'data'/'BGMountains-unique-poi-types.csv' call([sys.executable,ROOT/'tools/audit_icon_coverage.py', '--osm',osm, '--catalog',catalog, '--csv',dest/'poi-coverage.csv','--json',dest/'poi-coverage.json'], log=dest/'poi-coverage.log') rows=catalog_rows(catalog) nationwide={r['code']:r['count'] for r in catalog_rows(args.frequency_catalog)} if args.frequency_catalog else {} for row in rows: row['sample_count']=row['count'] row['count']=nationwide.get(row['code'],row['count']) # A separately supplied legacy OSM might lack our new classifier. # For --img, the v0.7 converter above just generated it by construction. if args.osm: import gzip opener=gzip.open if osm.suffix=='.gz' else open with opener(osm,'rt',encoding='utf-8',errors='replace') as f: found=any('k="bgm_poi"' in line for line in f) if not found: raise SystemExit('Existing OSM lacks bgm_poi; re-extract with v0.7 C++ converter.') folder=dest/'osmand-config';folder.mkdir(exist_ok=True) poi_base=base_xml(args.poi_base,creator,'poi',dest/'upstream',args.offline) rendering_base=base_xml(args.rendering_base,creator,'rendering',dest/'upstream',args.offline) route_styles=read_route_styles(args.trail_styles) poi_cfg,render_cfg,summary=make_configs(poi_base,rendering_base,rows,folder,args.threshold,args.frequent_zoom,args.rare_zoom,route_styles) style,nicons,nlines=make_style(args.icons_config,folder,rows,route_styles) print('Indexed POI types:',summary['catalog_types'],'frequent:',summary['types_at_or_above_threshold'],'custom icons:',nicons,'colored route types:',nlines,flush=True) if args.prepare_only: print('PREPARED (no OBF generated). Files at:',dest) return 0 before={str(i.resolve()) for i in dest.glob('*.obf')} # Save Java logs, and preserve OsmAndMapCreator temp data inside user-selected output dir. env=os.environ.copy();env['JAVA_OPTS']=args.java_opts program=creator/'utilities.sh' cmd=[str(program),'generate-obf-no-address',str(osm), '--poi-types='+str(poi_cfg),'--rendering-types='+str(render_cfg)] call(cmd,cwd=dest,log=dest/'mapcreator.log',env=env) after=[i for i in dest.glob('*.obf') if str(i.resolve()) not in before] if not after: # Creator sometimes writes next to the .osm source, even with cwd changed. alt=list(osm.parent.glob('*.obf')) after=[i for i in alt if i.is_file()] if not after:raise SystemExit('MapCreator returned success but no .obf was found; see '+str(dest/'mapcreator.log')) built=max(after,key=lambda p:p.stat().st_mtime) print('OBF created:',built,'bytes:',built.stat().st_size) if (creator/'inspector.sh').is_file(): call([str(creator/'inspector.sh'),'-vpoi',str(built)],cwd=dest,log=dest/'obf-poi-index.txt',env=env) (dest/'build-manifest.json').write_text(json.dumps({ 'created_at_utc':dt.datetime.now(dt.timezone.utc).isoformat(), 'obf':str(built),'obf_bytes':built.stat().st_size, 'input_osm':str(osm),'poi_types':str(poi_cfg),'rendering_types':str(render_cfg), 'custom_style':str(style), 'custom_style_icon_rules':nicons, 'custom_route_rules':nlines, 'summary':summary },ensure_ascii=False,indent=2)+'\n',encoding='utf-8') return 0 if __name__=='__main__': sys.exit(main())