Files
garmin-img-format-parsing/garmin_img_to_osmand_v2.py
2026-04-14 14:48:54 -07:00

1440 lines
56 KiB
Python

#!/usr/bin/env python3
"""
Prototype Garmin IMG vector extractor -> GeoJSON / OSM XML.
What it does well:
- Reads classic Garmin IMG container FAT and extracts subfiles.
- Supports classic top-level TRE/RGN/LBL maps and many GMP/NT-style maps where
TRE/RGN/LBL offsets are stored inside the .GMP container.
- Parses TRE levels/subdivisions.
- Parses LBL labels (coding 6, 9, 10) with common codepage handling.
- Parses standard points, extended points, standard polylines/polygons, and
extended polylines/polygons from RGN.
- Exports GeoJSON and/or OSM XML.
What it does NOT promise:
- Full Garmin NT routing/address semantics.
- Locked/compressed/vendor-obfuscated maps.
- Perfect type-to-OSM semantic translation. The exporter preserves Garmin type
codes as tags instead of inventing OSM semantics.
This is a practical reverse-engineering tool, not a complete implementation of
all Garmin IMG variants.
"""
from __future__ import annotations
import argparse
import io
import json
import math
import sys
import gzip
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, Iterable, Iterator, List, Optional, Tuple
from xml.sax.saxutils import escape as xml_escape
# -------------------------
# Low-level helpers
# -------------------------
COORD_FACTOR = 360.0 / (1 << 24)
FAT_BLOCK_SIZE = 0x200
FAT_ENTRY_SIZE = 0x200
MAX_FAT_BLOCKLIST = 240
SEG_POINT = 0
SEG_IPOINT = 1
SEG_POLYLINE = 2
SEG_POLYGON = 3
SEG_EXTPOLYGON = 4
SEG_EXTPOLYLINE = 5
SEG_EXTPOINT = 6
OBJ_POINT = 0x10
OBJ_INDEXED_POINT = 0x20
OBJ_POLYLINE = 0x40
OBJ_POLYGON = 0x80
OBJ_EXT_POLYGON = 0x100
OBJ_EXT_POLYLINE = 0x200
OBJ_EXT_POINT = 0x400
def warn(msg: str) -> None:
print(f"[warn] {msg}", file=sys.stderr)
def info(msg: str) -> None:
print(f"[info] {msg}", file=sys.stderr)
def read_u16le(buf: bytes, off: int) -> int:
return int.from_bytes(buf[off:off + 2], "little", signed=False)
def read_s16le(buf: bytes, off: int) -> int:
return int.from_bytes(buf[off:off + 2], "little", signed=True)
def read_u24le(buf: bytes, off: int) -> int:
return int.from_bytes(buf[off:off + 3], "little", signed=False)
def read_s24le(buf: bytes, off: int) -> int:
raw = read_u24le(buf, off)
if raw & 0x800000:
raw -= 1 << 24
return raw
def read_u32le(buf: bytes, off: int) -> int:
return int.from_bytes(buf[off:off + 4], "little", signed=False)
def to_deg(coord: int) -> float:
return coord * COORD_FACTOR
def decode_ascii_z(data: bytes) -> str:
return data.split(b"\x00", 1)[0].decode("ascii", errors="replace").strip()
# -------------------------
# Container extraction
# -------------------------
@dataclass
class FatRecord:
filename: str
ext: str
size: int
blocks: List[int]
offset_in_fat: int
class ImgContainer:
def __init__(self, raw: bytes):
self.raw = raw
# Some IMG files are XOR'd by a single byte stored at byte 0.
xor_byte = raw[0]
if xor_byte not in (0x00,):
maybe = bytes(b ^ xor_byte for b in raw)
sig = maybe[0x10:0x17]
ident = maybe[0x41:0x48]
if sig.startswith(b"DSKIMG") or ident.startswith(b"GARMIN"):
info(f"applied XOR decode with byte 0x{xor_byte:02x}")
self.raw = maybe
self.block_size = self._read_block_size()
self.fat_start = self._read_fat_start()
self.files = self._extract_subfiles()
def _read_block_size(self) -> int:
e1 = self.raw[0x61]
e2 = self.raw[0x62]
return 1 << (e1 + e2)
def _read_fat_start(self) -> int:
fat_phys_block = self.raw[0x40]
return fat_phys_block * FAT_BLOCK_SIZE + FAT_BLOCK_SIZE
def _parse_fat_chain(self) -> List[FatRecord]:
records: List[FatRecord] = []
off = self.fat_start
seen_offsets = set()
while off + FAT_ENTRY_SIZE <= len(self.raw):
if off in seen_offsets:
break
seen_offsets.add(off)
first = self.raw[off]
if first != 0x01:
break
name = self.raw[off + 1:off + 9].decode("ascii", errors="replace").rstrip(" \x00")
ext = self.raw[off + 9:off + 12].decode("ascii", errors="replace").rstrip(" \x00")
size = read_u32le(self.raw, off + 12)
next_fat = read_u16le(self.raw, off + 16)
blocks = []
boff = off + 0x20
for i in range(MAX_FAT_BLOCKLIST):
blk = read_u16le(self.raw, boff + i * 2)
if blk == 0xFFFF:
break
blocks.append(blk)
if next_fat == 0:
records.append(FatRecord(name, ext, size, blocks, off))
off += FAT_ENTRY_SIZE
return records
def _collect_blocks(self, start_record: FatRecord) -> bytes:
data = bytearray()
blocks = list(start_record.blocks)
current_offset = start_record.offset_in_fat
# Follow FAT continuation blocks when next_fat is used.
while True:
next_fat = read_u16le(self.raw, current_offset + 16)
if next_fat == 0:
break
current_offset += FAT_ENTRY_SIZE
if current_offset + FAT_ENTRY_SIZE > len(self.raw):
break
boff = current_offset + 0x20
for i in range(MAX_FAT_BLOCKLIST):
blk = read_u16le(self.raw, boff + i * 2)
if blk == 0xFFFF:
break
blocks.append(blk)
for blk in blocks:
start = blk * self.block_size
end = start + self.block_size
if end > len(self.raw):
break
data.extend(self.raw[start:end])
return bytes(data[:start_record.size])
def _extract_subfiles(self) -> Dict[str, bytes]:
out: Dict[str, bytes] = {}
for rec in self._parse_fat_chain():
key = f"{rec.filename}.{rec.ext}".upper()
out[key] = self._collect_blocks(rec)
return out
# -------------------------
# Core format structures
# -------------------------
@dataclass
class LevelInfo:
level: int
bits_per_coord: int
inherited: bool
present: bool = True
@dataclass
class Subdivision:
index: int
level: int
data_offset: int
object_types: int
lon_center: int
lat_center: int
width: int
height: int
index_next_level: int = 0
last: bool = False
data_end: int = 0
data_ext_polygon_offset: int = 0
data_ext_polygon_end: int = 0
data_ext_polyline_offset: int = 0
data_ext_polyline_end: int = 0
data_ext_poi_offset: int = 0
data_ext_poi_end: int = 0
children: List["Subdivision"] = field(default_factory=list)
def nb_object_types(self) -> int:
count = 0
cur = 0x10
for _ in range(4):
if self.object_types & cur:
count += 1
cur <<= 1
return count
@dataclass
class Feature:
geom_type: str # Point | LineString | Polygon
coords: object
props: Dict[str, object]
# -------------------------
# LBL parser
# -------------------------
class LBL:
NORMAL_CHARS = [' ', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R',
'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '~', '~', '~', '~', '~', '0', '1', '2', '3', '4', '5',
'6', '7', '8', '9', '~', '~', '~', '~', '~', '~']
SYMBOL_CHARS = ['@', '!', '"', '#', '$', '%', '&', "'", '(', ')', '*', '+', ',', '-', '.', '/', '~', '~', '~',
'~', '~', '~', '~', '~', '~', '~', ':', ';', '<', '=', '>', '?', '~', '~', '~', '~', '~', '~',
'~', '~', '~', '~', '~', '[', '\\', ']', '^', '_']
SPECIAL_CHARS = ['`', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r',
's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '~', '~', '~', '~', '~', '0', '1', '2', '3', '4', '5',
'6', '7', '8', '9', '~', '~', '~', '~', '~', '~']
def __init__(self, data: Optional[bytes]):
self.data = data or b""
self.ok = bool(data)
self.data_offset = 0
self.data_length = 0
self.data_offset_multiplier = 1
self.label_coding = 6
self.codepage = 1252
if self.ok:
self._parse_header()
def _parse_header(self) -> None:
header_length = read_u16le(self.data, 0)
self.data_offset = read_u32le(self.data, 0x15)
self.data_length = read_u32le(self.data, 0x19)
self.data_offset_multiplier = 1 << self.data[0x1D]
self.label_coding = self.data[0x1E]
if len(self.data) >= 0xAC:
self.codepage = read_u16le(self.data, 0xAA)
def get_label(self, offset: int) -> str:
if not self.ok or offset == 0:
return ""
actual = self.data_offset + offset * self.data_offset_multiplier
if actual < 0 or actual >= len(self.data):
return ""
if self.label_coding == 6:
return self._get_label6(actual)
return self._get_label8_10(actual)
def _get_label8_10(self, off: int) -> str:
end = off
while end < len(self.data) and self.data[end] != 0:
end += 1
raw = self.data[off:end]
enc = None
cp = self.codepage
if cp in (0, 850):
enc = "cp1252"
elif cp == 65001:
enc = "utf-8"
elif cp == 932:
enc = "cp932"
elif cp == 950:
enc = "big5"
else:
enc = f"cp{cp}"
try:
return raw.decode(enc, errors="replace")
except Exception:
return raw.decode("latin1", errors="replace")
def _get_label6(self, off: int) -> str:
out: List[str] = []
charset = "NORMAL"
pos = off
while pos + 3 <= len(self.data):
b1, b2, b3 = self.data[pos], self.data[pos + 1], self.data[pos + 2]
pos += 3
codes = [
b1 >> 2,
((b1 & 0x3) << 4) | (b2 >> 4),
((b2 & 0xF) << 2) | (b3 >> 6),
b3 & 0x3F,
]
for c in codes:
if c > 0x2F:
return "".join(out).strip()
if charset == "NORMAL":
if c == 0x1C:
charset = "SYMBOL"
elif c == 0x1B:
charset = "SPECIAL"
elif c == 0x1D:
out.append("|")
elif c in (0x1E, 0x1F):
out.append(" ")
else:
out.append(self.NORMAL_CHARS[c])
elif charset == "SYMBOL":
out.append(self.SYMBOL_CHARS[c])
charset = "NORMAL"
else:
out.append(self.SPECIAL_CHARS[c])
charset = "NORMAL"
return "".join(out).strip()
# -------------------------
# TRE parser
# -------------------------
class TRE:
def __init__(self, data: bytes):
self.data = data
self.header_length = read_u16le(data, 0)
self.north = read_s24le(data, 0x15)
self.east = read_s24le(data, 0x18)
self.south = read_s24le(data, 0x1B)
self.west = read_s24le(data, 0x1E)
self.levels: Dict[int, LevelInfo] = {}
self.max_level = 0
self.min_level = 15
self.extended_types = False
self.extended_types_offset = 0
self.extended_types_length = 0
self.extended_types_size = 0
self.extended_types_number = 0
self.decalaje_extended_types = 0
self.subdivisions_count = 1
self.root_subdivisions: List[Subdivision] = []
self.subdivisions_by_index: Dict[int, Subdivision] = {}
self._parse()
def _parse(self) -> None:
self._parse_levels()
self._parse_tre7()
self._parse_subdivisions()
def _parse_tre7(self) -> None:
if self.header_length >= 0x7C + 10:
self.extended_types_offset = read_u32le(self.data, 0x7C)
self.extended_types_length = read_u32le(self.data, 0x80)
self.extended_types_size = read_u16le(self.data, 0x84)
if self.extended_types_size > 0:
self.extended_types_number = self.extended_types_length // self.extended_types_size
self.extended_types = self.extended_types_length > 0
self.decalaje_extended_types = self.subdivisions_count - self.extended_types_number
def _parse_levels(self) -> None:
levels_offset = read_u32le(self.data, 0x21)
levels_length = read_u32le(self.data, 0x25)
pos = levels_offset
end = levels_offset + levels_length
while pos + 4 <= end and pos + 4 <= len(self.data):
zoom = self.data[pos]
bits = self.data[pos + 1]
count = read_u16le(self.data, pos + 2)
_ = count
level = zoom & 0xF
inherited = bool(zoom & 0x80)
self.levels[level] = LevelInfo(level=level, bits_per_coord=bits, inherited=inherited)
self.max_level = max(self.max_level, level)
self.min_level = min(self.min_level, level)
self.subdivisions_count += count
pos += 4
def get_resolution(self, level: int) -> int:
return self.levels[level].bits_per_coord
def convert_map_units(self, level: int, value: int, additional_accuracy: int) -> int:
shift = 24 - self.get_resolution(level) - additional_accuracy
if shift >= 0:
return value << shift
return value >> (-shift)
def _parse_subdiv_record(self, pos: int, level: int, record_size: int, index: int) -> Tuple[Subdivision, int]:
data_offset = read_u24le(self.data, pos)
object_types = self.data[pos + 3]
if object_types & 0x0F:
data_offset += (object_types & 0x0F) * (1 << 24)
lon_center = read_s24le(self.data, pos + 4)
lat_center = read_s24le(self.data, pos + 7)
width = read_u16le(self.data, pos + 10)
last = False
if width & 0x8000:
width &= 0x7FFF
last = True
height = read_u16le(self.data, pos + 12)
index_next = read_u16le(self.data, pos + 14) if record_size >= 16 else 0
sub = Subdivision(index=index, level=level, data_offset=data_offset, object_types=object_types,
lon_center=lon_center, lat_center=lat_center, width=width, height=height,
index_next_level=index_next, last=last)
# Extended offsets per subdivision, if present.
if self.extended_types:
indice = index - self.decalaje_extended_types
if indice > 0 and self.extended_types_size >= 8:
p = self.extended_types_offset + (indice - 1) * self.extended_types_size
if p + self.extended_types_size <= len(self.data):
sub.data_ext_polygon_offset = read_u32le(self.data, p)
if self.extended_types_size >= 8:
sub.data_ext_polyline_offset = read_u32le(self.data, p + 4)
if self.extended_types_size >= 12:
sub.data_ext_poi_offset = read_u32le(self.data, p + 8)
return sub, pos + record_size
def _parse_subdivisions(self) -> None:
sub_offset = read_u32le(self.data, 0x29)
sub_length = read_u32le(self.data, 0x2D)
end = sub_offset + sub_length
if end > len(self.data):
end = len(self.data)
present_levels = sorted(self.levels.keys(), reverse=True)
if not present_levels:
return
current_root_level = present_levels[0]
index = 1
pos = sub_offset
roots: List[Subdivision] = []
# Parse all 16-byte records first until last root.
while pos + 16 <= end:
sub, pos = self._parse_subdiv_record(pos, current_root_level, 16, index)
roots.append(sub)
self.subdivisions_by_index[index] = sub
index += 1
if sub.last:
break
self.root_subdivisions = roots
# Recursively parse children using the index_next_level scheme.
self._parse_children(self.root_subdivisions, current_root_level - 1, sub_offset, end, index)
# Compute data ends by sorted data offsets.
ordered = sorted(self.subdivisions_by_index.values(), key=lambda s: (s.data_offset, s.index))
for i, sub in enumerate(ordered):
if i + 1 < len(ordered):
sub.data_end = ordered[i + 1].data_offset
else:
sub.data_end = 0
# Extended segment ends.
for attr_start, attr_end in [
("data_ext_polygon_offset", "data_ext_polygon_end"),
("data_ext_polyline_offset", "data_ext_polyline_end"),
("data_ext_poi_offset", "data_ext_poi_end"),
]:
items = sorted((s for s in self.subdivisions_by_index.values() if getattr(s, attr_start, 0)),
key=lambda s: getattr(s, attr_start))
for i, sub in enumerate(items):
if i + 1 < len(items):
setattr(sub, attr_end, getattr(items[i + 1], attr_start))
else:
setattr(sub, attr_end, 0)
def _next_present_level(self, level: int) -> int:
while level > 0 and level not in self.levels:
level -= 1
return level
def _parse_children(self, parents: List[Subdivision], level: int, sub_offset: int, end: int, next_index_hint: int) -> None:
level = self._next_present_level(level)
if level <= 0:
return
for parent in parents:
if parent.index_next_level <= 0:
continue
idx = parent.index_next_level
if idx <= 0:
continue
# Heuristic matching JGarminImgParser: 16-byte records for non-leaf levels, 14-byte for last level.
record_size = 14 if level == self.min_level else 16
pos = sub_offset + (idx - 1) * 16 if record_size == 16 else sub_offset + (idx - 1) * 14
# Fallback for mixed record layout: compute small-record area start after all 16-byte records already parsed.
if record_size == 14 and pos + 14 > end:
pos = min(end, sub_offset + len(self.root_subdivisions) * 16)
children: List[Subdivision] = []
while pos + record_size <= end:
try:
sub, pos = self._parse_subdiv_record(pos, level, record_size, idx)
except Exception:
break
children.append(sub)
self.subdivisions_by_index[idx] = sub
idx += 1
if sub.last:
break
parent.children = children
child_level = self._next_present_level(level - 1)
if child_level > 0 and children:
self._parse_children(children, child_level, sub_offset, end, idx)
# -------------------------
# RGN parser
# -------------------------
class BitStreamReader:
def __init__(self, data: bytes, start: int, length_bytes: int):
self.data = data
self.pos = start
self.remaining_bytes = length_bytes
self.remaining_bits = 0
self.cur_byte = 0
def has_next(self, nbits: int) -> bool:
return self.remaining_bytes * 8 + self.remaining_bits >= nbits
def finish(self) -> int:
self.pos += self.remaining_bytes
self.remaining_bytes = 0
self.remaining_bits = 0
return self.pos
def _get_if_needed(self) -> None:
if self.remaining_bits == 0:
if self.remaining_bytes <= 0:
raise EOFError
self.cur_byte = self.data[self.pos]
self.pos += 1
self.remaining_bytes -= 1
self.remaining_bits = 8
def read_next_bits(self, to_get: int) -> int:
cur_pos = 0
result = 0
while cur_pos < to_get:
self._get_if_needed()
remaining_to_get = to_get - cur_pos
if remaining_to_get >= self.remaining_bits:
result |= self.cur_byte << cur_pos
cur_pos += self.remaining_bits
self.remaining_bits = 0
else:
mask = (1 << remaining_to_get) - 1
result |= (self.cur_byte & mask) << cur_pos
self.cur_byte >>= remaining_to_get
self.remaining_bits -= remaining_to_get
return result
return result
def read_coord_offset(self, nbits: int, sign: int, extra_bit: int) -> int:
if sign == 0:
value = self.read_next_bits(nbits)
sign_mask = 1 << (nbits - 1)
if value & sign_mask:
comp = value ^ sign_mask
if extra_bit == 0:
if comp != 0:
return comp - sign_mask
other = self.read_coord_offset(nbits, sign, extra_bit)
if other < 0:
return 1 - value + other
return value - 1 + other
else:
if comp & 0xFFFFFE:
return (comp & 0xFFFFFE) - sign_mask
other = self.read_coord_offset(nbits - 1, sign, 0)
if other < 0:
return 1 - sign_mask + 1 + (other << 1)
return sign_mask - 1 - 1 + (other << 1)
else:
if extra_bit > 0:
return value & 0xFFFFFE
return value
else:
value = self.read_next_bits(nbits)
if extra_bit > 0:
return (((value >> 1) * sign) << 1)
return value * sign
class RGN:
def __init__(self, data: bytes, tre: TRE, lbl: Optional[LBL]):
self.data = data
self.tre = tre
self.lbl = lbl or LBL(None)
self.header_length = read_u16le(data, 0)
self.data_offset = read_u32le(data, 0x15) if len(data) >= 0x1D else 0
self.data_length = read_u32le(data, 0x19) if len(data) >= 0x1D else 0
self.ext_poly_offset = read_u32le(data, 0x1D) if len(data) >= 0x25 else 0
self.ext_poly_length = read_u32le(data, 0x21) if len(data) >= 0x25 else 0
self.ext_line_offset = read_u32le(data, 0x39) if len(data) >= 0x41 else 0
self.ext_line_length = read_u32le(data, 0x3D) if len(data) >= 0x41 else 0
self.ext_poi_offset = read_u32le(data, 0x55) if len(data) >= 0x5D else 0
self.ext_poi_length = read_u32le(data, 0x59) if len(data) >= 0x5D else 0
def data_end(self) -> int:
return self.data_length
def ext_polygon_end(self) -> int:
return self.ext_poly_length
def ext_polyline_end(self) -> int:
return self.ext_line_length
def ext_poi_end(self) -> int:
return self.ext_poi_length
@staticmethod
def _convert_coord_length(i: int, sign: int, extra_bit: int) -> int:
add = 0
if sign == 0:
add += 1
add += extra_bit
if i <= 9:
return i + 2 + add
return 2 * i - 9 + 2 + add
def _subdiv_lon(self, sub: Subdivision, delta: int, add_acc: int) -> int:
return sub.lon_center + self.tre.convert_map_units(sub.level, delta, add_acc)
def _subdiv_lat(self, sub: Subdivision, delta: int, add_acc: int) -> int:
return sub.lat_center + self.tre.convert_map_units(sub.level, delta, add_acc)
def _segments(self, sub: Subdivision) -> List[Optional[Tuple[int, int]]]:
result: List[Optional[Tuple[int, int]]] = [None] * 7
offset = sub.data_offset + self.data_offset
end = (sub.data_end if sub.data_end else self.data_length) + self.data_offset
if sub.object_types == 0:
return result
if sub.data_end and end > len(self.data):
end = len(self.data)
if sub.data_end and end > offset and sub.nb_object_types() > 0:
if sub.object_types & OBJ_POINT:
result[SEG_POINT] = (0, 0)
if sub.object_types & OBJ_INDEXED_POINT:
result[SEG_IPOINT] = (0, 0)
if sub.object_types & OBJ_POLYLINE:
result[SEG_POLYLINE] = (0, 0)
if sub.object_types & OBJ_POLYGON:
result[SEG_POLYGON] = (0, 0)
order = [SEG_POINT, SEG_IPOINT, SEG_POLYLINE, SEG_POLYGON]
nb_pointers = sub.nb_object_types() - 1
if offset + nb_pointers * 2 <= len(self.data):
segment_start = offset + nb_pointers * 2
cur_idx = 0
p = offset
for _ in range(nb_pointers):
while cur_idx < 4 and result[order[cur_idx]] is None:
cur_idx += 1
if cur_idx >= 4:
break
segment_end = read_u16le(self.data, p) + offset
p += 2
if segment_end > end or segment_end <= segment_start:
result[order[cur_idx]] = None
else:
result[order[cur_idx]] = (segment_start, segment_end)
segment_start = segment_end
cur_idx += 1
while cur_idx < 4 and result[order[cur_idx]] is None:
cur_idx += 1
if cur_idx < 4 and result[order[cur_idx]] is not None and segment_start < end:
result[order[cur_idx]] = (segment_start, end)
if sub.data_ext_polygon_offset:
s = self.ext_poly_offset + sub.data_ext_polygon_offset
e = self.ext_poly_offset + (sub.data_ext_polygon_end or self.ext_poly_length)
if e > s:
result[SEG_EXTPOLYGON] = (s, e)
if sub.data_ext_polyline_offset:
s = self.ext_line_offset + sub.data_ext_polyline_offset
e = self.ext_line_offset + (sub.data_ext_polyline_end or self.ext_line_length)
if e > s:
result[SEG_EXTPOLYLINE] = (s, e)
if sub.data_ext_poi_offset:
s = self.ext_poi_offset + sub.data_ext_poi_offset
e = self.ext_poi_offset + (sub.data_ext_poi_end or self.ext_poi_length)
if e > s:
result[SEG_EXTPOINT] = (s, e)
return result
def parse_features(self) -> List[Feature]:
# Finalize subdivision end markers using RGN section lengths.
ordered = sorted(self.tre.subdivisions_by_index.values(), key=lambda s: (s.data_offset, s.index))
for i, sub in enumerate(ordered):
if sub.data_end == 0:
sub.data_end = self.data_length if i + 1 == len(ordered) else ordered[i + 1].data_offset
for attr_start, final_end in [
("data_ext_polygon_offset", self.ext_poly_length),
("data_ext_polyline_offset", self.ext_line_length),
("data_ext_poi_offset", self.ext_poi_length),
]:
items = sorted((s for s in self.tre.subdivisions_by_index.values() if getattr(s, attr_start, 0)),
key=lambda s: getattr(s, attr_start))
for i, sub in enumerate(items):
if attr_start == "data_ext_polygon_offset":
setattr(sub, "data_ext_polygon_end", final_end if i + 1 == len(items) else getattr(items[i + 1], attr_start))
elif attr_start == "data_ext_polyline_offset":
setattr(sub, "data_ext_polyline_end", final_end if i + 1 == len(items) else getattr(items[i + 1], attr_start))
else:
setattr(sub, "data_ext_poi_end", final_end if i + 1 == len(items) else getattr(items[i + 1], attr_start))
feats: List[Feature] = []
for sub in sorted(self.tre.subdivisions_by_index.values(), key=lambda s: s.index):
segs = self._segments(sub)
if segs[SEG_POINT]:
feats.extend(self._parse_points(sub, segs[SEG_POINT], indexed=False))
if segs[SEG_IPOINT]:
feats.extend(self._parse_points(sub, segs[SEG_IPOINT], indexed=True))
if segs[SEG_EXTPOINT]:
feats.extend(self._parse_ext_points(sub, segs[SEG_EXTPOINT]))
if segs[SEG_POLYLINE]:
feats.extend(self._parse_poly(sub, segs[SEG_POLYLINE], line=True, extended=False))
if segs[SEG_POLYGON]:
feats.extend(self._parse_poly(sub, segs[SEG_POLYGON], line=False, extended=False))
if segs[SEG_EXTPOLYLINE]:
feats.extend(self._parse_poly(sub, segs[SEG_EXTPOLYLINE], line=True, extended=True))
if segs[SEG_EXTPOLYGON]:
feats.extend(self._parse_poly(sub, segs[SEG_EXTPOLYGON], line=False, extended=True))
return feats
def _parse_points(self, sub: Subdivision, seg: Tuple[int, int], indexed: bool) -> List[Feature]:
feats: List[Feature] = []
pos, end = seg
while pos < end and pos + 8 <= len(self.data):
typ = self.data[pos]
info24 = read_u24le(self.data, pos + 1)
has_subtype = bool(info24 & 0x800000)
is_poi = bool(info24 & 0x400000)
lbl_off = info24 & 0x3FFFFF
lon_delta = read_s16le(self.data, pos + 4)
lat_delta = read_s16le(self.data, pos + 6)
pos += 8
subtype = 0
if has_subtype and pos < end:
subtype = self.data[pos]
pos += 1
name = self.lbl.get_label(lbl_off) if lbl_off else ""
lon = to_deg(self._subdiv_lon(sub, lon_delta, 0))
lat = to_deg(self._subdiv_lat(sub, lat_delta, 0))
feats.append(Feature(
geom_type="Point",
coords=[lon, lat],
props={
"garmin_kind": "indexed_point" if indexed else "point",
"garmin_type": f"0x{typ:02x}",
"garmin_subtype": f"0x{subtype:02x}",
"garmin_is_poi": is_poi,
"name": name,
},
))
return feats
def _parse_ext_points(self, sub: Subdivision, seg: Tuple[int, int]) -> List[Feature]:
feats: List[Feature] = []
pos, end = seg
while pos < end and pos + 6 <= len(self.data):
typ = self.data[pos]
subtype_raw = self.data[pos + 1]
has_lbl = bool(subtype_raw & 0x20)
subtype = subtype_raw % 32
full_type = ((typ + 0x100) << 8) + subtype
lon_delta = read_s16le(self.data, pos + 2)
lat_delta = read_s16le(self.data, pos + 4)
pos += 6
lbl_off = read_u24le(self.data, pos) if has_lbl and pos + 3 <= end else 0
if has_lbl:
pos += 3
name = self.lbl.get_label(lbl_off) if lbl_off else ""
lon = to_deg(self._subdiv_lon(sub, lon_delta, 0))
lat = to_deg(self._subdiv_lat(sub, lat_delta, 0))
feats.append(Feature(
geom_type="Point",
coords=[lon, lat],
props={
"garmin_kind": "extended_point",
"garmin_type": f"0x{full_type:04x}",
"name": name,
},
))
return feats
def _parse_poly(self, sub: Subdivision, seg: Tuple[int, int], line: bool, extended: bool) -> List[Feature]:
feats: List[Feature] = []
pos, end = seg
while pos < end:
try:
if not extended:
if pos + 10 > end:
break
info1 = self.data[pos]
pos += 1
if line:
typ = info1 & 0x3F
direction = bool(info1 & 0x40)
else:
typ = info1 & 0x7F
direction = False
two_byte_len = bool(info1 & 0x80)
info24 = read_u24le(self.data, pos)
pos += 3
lbl_off = info24 & 0x3FFFFF
extra_bit = 1 if (info24 & 0x400000) else 0
data_in_net = bool(info24 & 0x800000)
lon_delta = read_s16le(self.data, pos)
lat_delta = read_s16le(self.data, pos + 2)
pos += 4
bitstream_len = read_u16le(self.data, pos) if two_byte_len else self.data[pos]
pos += 2 if two_byte_len else 1
bitstream_info = self.data[pos]
pos += 1
long_sign = 0
lat_sign = 0
long_extra_bit = extra_bit
lat_extra_bit = 0
full_type = typ
else:
if pos + 8 > end:
break
typ = self.data[pos]
subtype_raw = self.data[pos + 1]
has_lbl = bool(subtype_raw & 0x20)
subtype = subtype_raw % 32
full_type = ((typ + 0x100) << 8) + subtype
lon_delta = read_s16le(self.data, pos + 2)
lat_delta = read_s16le(self.data, pos + 4)
pos += 6
bitstream_len_byte = self.data[pos]
pos += 1
if bitstream_len_byte % 2 == 0:
if pos >= end:
break
bitstream_len = (bitstream_len_byte + self.data[pos] * 256) // 4 - 1
pos += 1
else:
bitstream_len = bitstream_len_byte // 2 - 1
bitstream_info = self.data[pos]
pos += 1
direction = False
data_in_net = False
long_sign = 0
lat_sign = 0
long_extra_bit = 0
lat_extra_bit = 0
reader = BitStreamReader(self.data, pos, bitstream_len)
if reader.read_next_bits(1) != 0:
long_sign = +1 if reader.read_next_bits(1) == 0 else -1
if reader.read_next_bits(1) != 0:
lat_sign = +1 if reader.read_next_bits(1) == 0 else -1
if extended:
long_extra_bit = reader.read_next_bits(1)
long_bits = self._convert_coord_length(bitstream_info & 0xF, long_sign, long_extra_bit)
lat_bits = self._convert_coord_length(bitstream_info >> 4, lat_sign, lat_extra_bit)
cur_lon = lon_delta
cur_lat = lat_delta
pts = [[to_deg(self._subdiv_lon(sub, cur_lon, 0)), to_deg(self._subdiv_lat(sub, cur_lat, 0))]]
cur_lon <<= long_extra_bit
cur_lat <<= lat_extra_bit
while reader.has_next(long_bits + lat_bits):
dlon = reader.read_coord_offset(long_bits, long_sign, long_extra_bit)
dlat = reader.read_coord_offset(lat_bits, lat_sign, lat_extra_bit)
cur_lon += dlon
cur_lat += dlat
pts.append([
to_deg(self._subdiv_lon(sub, cur_lon, long_extra_bit)),
to_deg(self._subdiv_lat(sub, cur_lat, lat_extra_bit)),
])
pos = reader.finish()
lbl_off = 0 if extended else lbl_off
if extended:
lbl_off = read_u24le(self.data, pos) if has_lbl and pos + 3 <= end else 0
if has_lbl:
pos += 3
name = self.lbl.get_label(lbl_off) if lbl_off else ""
if not line:
if pts and pts[0] != pts[-1]:
pts.append(pts[0])
feats.append(Feature(
geom_type="Polygon",
coords=[pts],
props={
"garmin_kind": "extended_polygon" if extended else "polygon",
"garmin_type": f"0x{full_type:04x}" if extended else f"0x{typ:02x}",
"garmin_direction": direction,
"garmin_data_in_net": data_in_net,
"name": name,
},
))
else:
feats.append(Feature(
geom_type="LineString",
coords=pts,
props={
"garmin_kind": "extended_polyline" if extended else "polyline",
"garmin_type": f"0x{full_type:04x}" if extended else f"0x{typ:02x}",
"garmin_direction": direction,
"garmin_data_in_net": data_in_net,
"name": name,
},
))
except Exception:
# Stop current segment on malformed data instead of crashing the whole file.
break
return feats
# -------------------------
# Output writers and semantic mapping
# -------------------------
def feature_to_geojson(f: Feature) -> Dict[str, object]:
props = {k: v for k, v in f.props.items() if v not in (None, "", [], {})}
return {
"type": "Feature",
"geometry": {"type": f.geom_type, "coordinates": f.coords},
"properties": props,
}
def _osm_escape(v: object) -> str:
return xml_escape(str(v), {'"': '&quot;'})
def _maybe_open_text(path: Path):
if str(path).lower().endswith('.gz'):
return gzip.open(path, 'wt', encoding='utf-8', newline='\n')
return open(path, 'w', encoding='utf-8', newline='\n')
def _parse_bbox(text: Optional[str]) -> Optional[Tuple[float, float, float, float]]:
if not text:
return None
parts = [p.strip() for p in text.split(',')]
if len(parts) != 4:
raise ValueError('bbox must be west,south,east,north')
west, south, east, north = map(float, parts)
if west > east or south > north:
raise ValueError('invalid bbox ordering')
return west, south, east, north
def _feature_bounds(f: Feature) -> Tuple[float, float, float, float]:
if f.geom_type == 'Point':
lon, lat = f.coords
return lon, lat, lon, lat
if f.geom_type == 'LineString':
pts = f.coords
else:
pts = f.coords[0]
xs = [p[0] for p in pts]
ys = [p[1] for p in pts]
return min(xs), min(ys), max(xs), max(ys)
def _intersects_bbox(f: Feature, bbox: Optional[Tuple[float, float, float, float]]) -> bool:
if bbox is None:
return True
west, south, east, north = bbox
a_w, a_s, a_e, a_n = _feature_bounds(f)
return not (a_w > east or a_e < west or a_s > north or a_n < south)
def _all_mapsets(files: Dict[str, bytes]) -> Dict[str, Dict[str, bytes]]:
groups: Dict[str, Dict[str, bytes]] = defaultdict(dict)
for key, data in files.items():
if '.' not in key:
continue
base, ext = key.rsplit('.', 1)
groups[base.upper()][ext.upper()] = data
out: Dict[str, Dict[str, bytes]] = {}
for base, subs in groups.items():
if 'TRE' in subs and 'RGN' in subs:
out[base] = subs
return dict(sorted(out.items()))
# Default semantic mapping. These are based on common Garmin/mkgmap conventions,
# plus a few heuristics for map labels commonly found in topographic IMG files.
LINE_TAGS: Dict[str, Dict[str, str]] = {
'0x01': {'highway': 'motorway'},
'0x02': {'highway': 'primary'},
'0x03': {'highway': 'secondary'},
'0x04': {'highway': 'tertiary'},
'0x05': {'highway': 'unclassified'},
'0x06': {'highway': 'residential'},
'0x07': {'highway': 'service'},
'0x08': {'highway': 'construction'},
'0x09': {'highway': 'road'},
'0x0a': {'highway': 'track', 'surface': 'unpaved'},
'0x0c': {'highway': 'road', 'junction': 'roundabout'},
'0x0d': {'highway': 'path'},
'0x0e': {'highway': 'track', 'tracktype': 'grade1'},
'0x0f': {'highway': 'track', 'tracktype': 'grade2'},
'0x10': {'highway': 'track', 'tracktype': 'grade3'},
'0x11': {'highway': 'track', 'tracktype': 'grade4'},
'0x12': {'highway': 'track', 'tracktype': 'grade5'},
'0x13': {'highway': 'steps'},
'0x14': {'railway': 'rail'},
'0x15': {'natural': 'coastline'},
'0x16': {'highway': 'cycleway'},
'0x17': {'highway': 'bridleway'},
'0x18': {'waterway': 'stream'},
'0x1a': {'route': 'ferry'},
'0x1f': {'waterway': 'river'},
'0x27': {'aeroway': 'runway'},
'0x28': {'man_made': 'pipeline'},
'0x29': {'power': 'line'},
'0x31': {'natural': 'cliff'},
'0x32': {'barrier': 'wall'},
'0x33': {'barrier': 'fence'},
'0x34': {'barrier': 'hedge'},
'0x38': {'aerialway': 'cable_car'},
'0x39': {'railway': 'tram'},
}
POLYGON_TAGS: Dict[str, Dict[str, str]] = {
'0x03': {'landuse': 'residential'},
'0x05': {'amenity': 'parking'},
'0x09': {'leisure': 'marina'},
'0x0b': {'amenity': 'hospital'},
'0x0c': {'landuse': 'industrial'},
'0x14': {'natural': 'heath'},
'0x15': {'natural': 'wood'},
'0x16': {'leisure': 'nature_reserve'},
'0x17': {'leisure': 'park'},
'0x18': {'leisure': 'golf_course'},
'0x19': {'leisure': 'sports_centre'},
'0x1a': {'landuse': 'cemetery'},
'0x2a': {'landuse': 'farmland'},
'0x2b': {'landuse': 'farmyard'},
'0x2c': {'landuse': 'vineyard'},
'0x2d': {'landuse': 'quarry'},
'0x2e': {'tourism': 'camp_site'},
'0x32': {'natural': 'water', 'water': 'sea'},
'0x35': {'landuse': 'meadow'},
'0x3c': {'natural': 'water'},
'0x3d': {'natural': 'beach'},
'0x3e': {'natural': 'water'},
'0x3f': {'landuse': 'reservoir'},
'0x40': {'natural': 'water'},
'0x41': {'natural': 'water'},
'0x46': {'waterway': 'riverbank'},
'0x4c': {'natural': 'water', 'intermittent': 'yes'},
'0x4d': {'natural': 'glacier'},
'0x4e': {'landuse': 'orchard'},
'0x4f': {'natural': 'scrub'},
'0x50': {'natural': 'wood'},
'0x51': {'natural': 'wetland'},
'0x52': {'natural': 'heath'}, # heuristic: Garmin default "Tundra"
'0x53': {'natural': 'bare_rock'}, # heuristic: Garmin default "Flat"
}
POINT_TAGS: Dict[Tuple[str, Optional[str]], Dict[str, str]] = {
('0x04', '0x00'): {'place': 'city'},
('0x08', '0x00'): {'place': 'town'},
('0x0a', '0x00'): {'place': 'suburb'},
('0x0b', '0x00'): {'place': 'village'},
('0x0d', '0x00'): {'place': 'village'}, # heuristic for this sample topo IMG
('0x11', '0x00'): {'place': 'hamlet'},
('0x28', '0x00'): {'place': 'locality'}, # heuristic: local named spot labels in sample
('0x64', '0x03'): {'amenity': 'grave_yard'},
('0x64', '0x06'): {'highway': 'crossing'},
('0x64', '0x11'): {'man_made': 'tower'},
('0x64', '0x14'): {'amenity': 'drinking_water'},
('0x64', '0x17'): {'amenity': 'hunting_stand'},
('0x64', '0x18'): {'amenity': 'grit_bin'},
('0x65', '0x0a'): {'natural': 'glacier'},
('0x65', '0x0c'): {'place': 'island'},
('0x65', '0x11'): {'natural': 'spring'},
('0x66', '0x04'): {'natural': 'beach'},
('0x66', '0x07'): {'natural': 'cliff'},
('0x66', '0x0e'): {'natural': 'volcano'},
('0x66', '0x16'): {'natural': 'peak'},
('0x66', '0x19'): {'natural': 'cave_entrance'},
}
def _parse_ele_from_name(name: str) -> Optional[str]:
if not name:
return None
t = name.strip().replace(',', '.')
if not t:
return None
try:
v = float(t)
except ValueError:
return None
if abs(v) < 20000:
if v.is_integer():
return str(int(v))
return str(v)
return None
def semantic_tags_for_feature(f: Feature) -> Dict[str, str]:
kind = f.props.get('garmin_kind', '')
gtype = f.props.get('garmin_type')
subtype = f.props.get('garmin_subtype')
name = f.props.get('name') or ''
sem: Dict[str, str] = {}
if kind in ('polyline', 'extended_polyline'):
if gtype in ('0x20', '0x21', '0x22'):
sem['contour'] = 'elevation'
sem['contour_ext'] = {
'0x20': 'elevation_minor',
'0x21': 'elevation_medium',
'0x22': 'elevation_major',
}[gtype]
ele = _parse_ele_from_name(name)
if ele is not None:
sem['ele'] = ele
elif gtype in LINE_TAGS:
sem.update(LINE_TAGS[gtype])
elif kind == 'extended_polyline':
# Fallback heuristic for common topo extended trail/path style objects.
if gtype in ('0x10e11', '0x10e12', '0x10e13', '0x10e14', '0x10e1c', '0x10e1d', '0x10e1f',
'0x10f12', '0x10f14', '0x10f16'):
sem['highway'] = 'path'
elif kind in ('polygon', 'extended_polygon'):
if gtype in POLYGON_TAGS:
sem.update(POLYGON_TAGS[gtype])
elif kind in ('point', 'indexed_point', 'extended_point'):
key = (gtype, subtype)
if key in POINT_TAGS:
sem.update(POINT_TAGS[key])
elif gtype == '0x66' and subtype == '0x18':
sem['natural'] = 'hill' # heuristic fallback
elif gtype == '0x65' and subtype == '0x00' and name:
sem['place'] = 'locality'
elif gtype == '0x66' and name:
sem['place'] = 'locality'
if name:
sem['name'] = name
return sem
def tags_for_feature(f: Feature, semantic: bool = True) -> Dict[str, str]:
tags: Dict[str, str] = {}
if semantic:
tags.update(semantic_tags_for_feature(f))
kind = f.props.get('garmin_kind')
gtype = f.props.get('garmin_type')
if kind:
tags['garmin:kind'] = str(kind)
if gtype:
tags['garmin:type'] = str(gtype)
if f.props.get('garmin_subtype'):
tags['garmin:subtype'] = str(f.props['garmin_subtype'])
if f.props.get('garmin_is_poi'):
tags['garmin:is_poi'] = 'yes'
return tags
def _is_useful_feature(tags: Dict[str, str]) -> bool:
# Keep only features with at least one semantic tag or a name.
for k in tags:
if not k.startswith('garmin:'):
return True
return 'name' in tags
def _node_key(lon: float, lat: float) -> Tuple[int, int]:
# Quantized key for shared way node reuse.
return (int(round(lon * 1e7)), int(round(lat * 1e7)))
def parse_mapset_features(mapset_name: str, subfiles: Dict[str, bytes]) -> Tuple[List[Feature], Dict[str, object]]:
tre = TRE(subfiles['TRE'])
lbl = LBL(subfiles.get('LBL'))
rgn = RGN(subfiles['RGN'], tre=tre, lbl=lbl)
features = rgn.parse_features()
meta = {
'mapset': mapset_name,
'bounds_wgs84': {
'north': to_deg(tre.north),
'east': to_deg(tre.east),
'south': to_deg(tre.south),
'west': to_deg(tre.west),
},
'feature_count': len(features),
'levels': {lvl: {'bits_per_coord': li.bits_per_coord, 'inherited': li.inherited} for lvl, li in tre.levels.items()},
}
return features, meta
def collect_type_stats(features: Iterable[Feature]) -> Dict[str, object]:
by_kind = Counter()
by_type = Counter()
by_type_sub = Counter()
for f in features:
kind = f.props.get('garmin_kind') or 'unknown'
typ = f.props.get('garmin_type') or 'unknown'
sub = f.props.get('garmin_subtype') or ''
by_kind[kind] += 1
by_type[f'{kind}:{typ}'] += 1
if sub:
by_type_sub[f'{kind}:{typ}:{sub}'] += 1
return {
'by_kind': dict(by_kind.most_common()),
'by_type': dict(by_type.most_common()),
'by_type_subtype': dict(by_type_sub.most_common()),
}
def write_geojson(features: List[Feature], path: Path) -> None:
if str(path).lower().endswith('.gz'):
with gzip.open(path, 'wt', encoding='utf-8', newline='\n') as fh:
json.dump({
'type': 'FeatureCollection',
'features': [feature_to_geojson(f) for f in features],
}, fh, ensure_ascii=False)
else:
path.write_text(json.dumps({
'type': 'FeatureCollection',
'features': [feature_to_geojson(f) for f in features],
}, ensure_ascii=False, indent=2), encoding='utf-8')
def _serialize_osm_chunk(fh, features: List[Feature], node_id: int, way_id: int, semantic: bool = True) -> Tuple[int, int]:
line_nodes: Dict[Tuple[int, int], int] = {}
plain_nodes: Dict[int, Tuple[float, float]] = {}
point_nodes: List[str] = []
ways: List[Tuple[int, List[int], Dict[str, str]]] = []
def alloc_node(lon: float, lat: float) -> int:
nonlocal node_id
key = _node_key(lon, lat)
if key in line_nodes:
return line_nodes[key]
nid = node_id
node_id -= 1
line_nodes[key] = nid
plain_nodes[nid] = (lon, lat)
return nid
for f in features:
tags = tags_for_feature(f, semantic=semantic)
if not _is_useful_feature(tags):
continue
if f.geom_type == 'Point':
lon, lat = f.coords
nid = node_id
node_id -= 1
node_lines = [f' <node id="{nid}" lat="{lat:.8f}" lon="{lon:.8f}">']
for k, v in tags.items():
node_lines.append(f' <tag k="{_osm_escape(k)}" v="{_osm_escape(v)}"/>')
node_lines.append(' </node>')
point_nodes.append('\n'.join(node_lines))
else:
coords = f.coords if f.geom_type == 'LineString' else f.coords[0]
node_ids = [alloc_node(lon, lat) for lon, lat in coords]
if len(node_ids) < 2:
continue
wid = way_id
way_id -= 1
if f.geom_type == 'Polygon':
tags['area'] = 'yes'
ways.append((wid, node_ids, tags))
for nid in sorted(plain_nodes.keys(), reverse=True):
lon, lat = plain_nodes[nid]
fh.write(f' <node id="{nid}" lat="{lat:.8f}" lon="{lon:.8f}"/>\n')
for chunk in point_nodes:
fh.write(chunk)
fh.write('\n')
for wid, node_ids, tags in ways:
fh.write(f' <way id="{wid}">\n')
for nid in node_ids:
fh.write(f' <nd ref="{nid}"/>\n')
for k, v in tags.items():
fh.write(f' <tag k="{_osm_escape(k)}" v="{_osm_escape(v)}"/>\n')
fh.write(' </way>\n')
return node_id, way_id
def write_osm(features: List[Feature], path: Path, semantic: bool = True) -> None:
with _maybe_open_text(path) as fh:
fh.write('<?xml version="1.0" encoding="UTF-8"?>\n')
fh.write('<osm version="0.6" generator="garmin_img_to_osmand_v2">\n')
_serialize_osm_chunk(fh, features, node_id=-1, way_id=-1, semantic=semantic)
fh.write('</osm>\n')
def write_osm_from_img(img_path: Path, path: Path, mapsets: Optional[List[str]] = None,
bbox: Optional[Tuple[float, float, float, float]] = None,
semantic: bool = True) -> Dict[str, object]:
raw = img_path.read_bytes()
container = ImgContainer(raw)
all_sets = _all_mapsets(container.files)
selected = set(s.upper() for s in mapsets) if mapsets else None
total_kind_counter = Counter()
total_features = 0
mapset_meta: List[Dict[str, object]] = []
node_id = -1
way_id = -1
with _maybe_open_text(path) as fh:
fh.write('<?xml version="1.0" encoding="UTF-8"?>\n')
fh.write('<osm version="0.6" generator="garmin_img_to_osmand_v2">\n')
for name, subs in all_sets.items():
if selected and name.upper() not in selected:
continue
feats, meta = parse_mapset_features(name, subs)
if bbox is not None:
feats = [f for f in feats if _intersects_bbox(f, bbox)]
meta['feature_count_after_bbox'] = len(feats)
total_features += len(feats)
for f in feats:
total_kind_counter[f.props.get('garmin_kind') or 'unknown'] += 1
node_id, way_id = _serialize_osm_chunk(fh, feats, node_id=node_id, way_id=way_id, semantic=semantic)
mapset_meta.append(meta)
fh.write('</osm>\n')
return {
'img_file': str(img_path),
'block_size': container.block_size,
'mapset_count': len(all_sets),
'selected_mapsets': mapsets or sorted(all_sets.keys()),
'mapsets': mapset_meta,
'feature_count': total_features,
'kind_counts': dict(total_kind_counter),
}
def load_features_from_img(
img_path: Path,
mapsets: Optional[List[str]] = None,
bbox: Optional[Tuple[float, float, float, float]] = None,
) -> Tuple[List[Feature], Dict[str, object]]:
raw = img_path.read_bytes()
container = ImgContainer(raw)
all_sets = _all_mapsets(container.files)
selected = set(s.upper() for s in mapsets) if mapsets else None
features: List[Feature] = []
mapset_meta: List[Dict[str, object]] = []
for name, subs in all_sets.items():
if selected and name.upper() not in selected:
continue
feats, meta = parse_mapset_features(name, subs)
if bbox is not None:
feats = [f for f in feats if _intersects_bbox(f, bbox)]
meta['feature_count_after_bbox'] = len(feats)
features.extend(feats)
mapset_meta.append(meta)
meta = {
'img_file': str(img_path),
'block_size': container.block_size,
'mapset_count': len(all_sets),
'selected_mapsets': mapsets or sorted(all_sets.keys()),
'mapsets': mapset_meta,
'feature_count': len(features),
'type_stats': collect_type_stats(features),
}
return features, meta
def main() -> int:
ap = argparse.ArgumentParser(description='Extract vector features from a Garmin IMG and export GeoJSON / OSM XML suitable for further conversion to OsmAnd .obf.')
ap.add_argument('img', type=Path, help='Input Garmin .img file')
ap.add_argument('--geojson', type=Path, help='Write GeoJSON or .geojson.gz output')
ap.add_argument('--osm', type=Path, help='Write OSM XML or .osm.gz output')
ap.add_argument('--meta-json', type=Path, help='Write parse metadata JSON')
ap.add_argument('--mapset', action='append', help='Process only this TRE/RGN family id (repeatable), e.g. 02234008')
ap.add_argument('--bbox', help='Clip by WGS84 bbox: west,south,east,north')
ap.add_argument('--list-mapsets', action='store_true', help='List available mapsets and exit')
ap.add_argument('--raw-only', action='store_true', help='Do not add semantic OSM tags; only preserve raw garmin:* tags')
args = ap.parse_args()
if args.list_mapsets:
container = ImgContainer(args.img.read_bytes())
for name, subs in _all_mapsets(container.files).items():
tre = TRE(subs['TRE'])
print(f'{name}\t{to_deg(tre.west):.6f},{to_deg(tre.south):.6f},{to_deg(tre.east):.6f},{to_deg(tre.north):.6f}')
return 0
if not args.geojson and not args.osm and not args.meta_json:
ap.error('provide at least one of --geojson, --osm, --meta-json or use --list-mapsets')
bbox = _parse_bbox(args.bbox)
if args.osm and not args.geojson:
meta = write_osm_from_img(args.img, args.osm, mapsets=args.mapset, bbox=bbox, semantic=not args.raw_only)
info(f'parsed {meta.get("feature_count", 0)} features from {len(meta.get("mapsets", []))} mapsets')
info(f'wrote OSM XML: {args.osm}')
if args.meta_json:
args.meta_json.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding='utf-8')
info(f'wrote metadata: {args.meta_json}')
return 0
features, meta = load_features_from_img(args.img, mapsets=args.mapset, bbox=bbox)
info(f'parsed {len(features)} features from {len(meta.get("mapsets", []))} mapsets')
if args.geojson:
write_geojson(features, args.geojson)
info(f'wrote GeoJSON: {args.geojson}')
if args.osm:
write_osm(features, args.osm, semantic=not args.raw_only)
info(f'wrote OSM XML: {args.osm}')
if args.meta_json:
args.meta_json.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding='utf-8')
info(f'wrote metadata: {args.meta_json}')
return 0
if __name__ == '__main__':
raise SystemExit(main())