From 0929b148d3229ce1041bafc002c06d226e831047 Mon Sep 17 00:00:00 2001 From: jgauchia Date: Thu, 1 Jan 2026 11:29:27 +0100 Subject: [PATCH 01/16] feat(tile-generator): Add FlatGeobuf converter with R-Tree spatial indexing --- .gitignore | 4 + README.md | 522 ++++-------- docs/bin_tile_format.md | 353 -------- docs/features_json_format.md | 161 ---- docs/tile_viewer.md | 345 -------- features.json | 336 +++----- fgb_viewer.py | 715 ++++++++++++++++ generate_tiles.sh | 299 ------- pbf_to_fgb.py | 614 ++++++++++++++ tile_generator.py | 1323 ------------------------------ tile_viewer.py | 1490 ---------------------------------- 11 files changed, 1578 insertions(+), 4584 deletions(-) delete mode 100644 docs/bin_tile_format.md delete mode 100644 docs/features_json_format.md delete mode 100644 docs/tile_viewer.md create mode 100644 fgb_viewer.py delete mode 100755 generate_tiles.sh create mode 100644 pbf_to_fgb.py delete mode 100644 tile_generator.py delete mode 100644 tile_viewer.py diff --git a/.gitignore b/.gitignore index c35fba4..eee53e6 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ competitive_analysis_vector_tiles.md rsync_copy.sh MAP/ VECTMAP/ +migracion.md +venv/ +CHANGELOG_FGB_MIGRATION.md + diff --git a/README.md b/README.md index 1e2687e..a627a63 100644 --- a/README.md +++ b/README.md @@ -1,441 +1,199 @@ -# OSM Vector Tile Generator +# OSM to FlatGeobuf Tile Generator -Highly optimized Python script for generating vector map tiles from OpenStreetMap (OSM) data using a custom binary format. +Converts OpenStreetMap PBF files to FlatGeobuf (.fgb) format with R-Tree spatial indexing for [IceNav](https://github.com/jgauchia/IceNav-v3) ESP32-based GPS navigator. -The generated tiles are extremely compact and optimized for fast rendering in custom map applications, featuring advanced compression techniques, dynamic color palette optimization, and intelligent line width handling. +## Features ---- +- **Direct PBF processing** - No intermediate formats (GOL, Docker, etc.) +- **R-Tree spatial index** - Fast bounding box queries +- **Zoom-level organization** - Separate files per zoom level for optimal file size +- **Feature filtering** - Configurable via `features.json` +- **ESP32 optimized** - Small files, efficient for SD card access -## What Does the Script Do? +## Requirements -- **GOL format support** using gol CLI for efficient OSM data processing -- **Streaming GeoJSON processing** with incremental JSON parsing via ijson -- **Dynamic resource allocation** based on available system memory -- **Generates compact binary tiles** with efficient coordinate encoding -- **Global color palette optimization** with compact indices -- **Hybrid line width handling**: OSM tags + CartoDB-style zoom-based defaults -- **Geometry smoothing** at high zoom levels (≥16) for smooth curves -- **Geometry clipping** to tile boundaries using Cohen-Sutherland and Sutherland-Hodgman algorithms -- **Layer-based priority system** for correct rendering order -- **Adaptive batch sizing** based on zoom level and available memory -- **Real-time progress tracking** with performance metrics +- Python 3.8+ +- Virtual environment (recommended) ---- +## Installation -## Drawing Command Set - -The script implements these drawing commands for tile generation: - -### Geometry Commands -| Command | Code | Purpose | Data Format | -|---------|------|---------|-------------| -| `POLYLINE` | 2 (0x02) | Multi-point line with width | varint(width) + varint(num_points) + coordinates | -| `STROKE_POLYGON` | 3 (0x03) | Polygon outline with width | varint(width) + varint(num_points) + coordinates | - -### State Management Commands -| Command | Code | Purpose | Data Format | -|---------|------|---------|-------------| -| `SET_COLOR` | 128 (0x80) | Set RGB332 color directly | uint8(rgb332) | -| `SET_COLOR_INDEX` | 129 (0x81) | Set color from palette | varint(palette_index) | - -**Note**: All commands are defined in the `DRAW_COMMANDS` dictionary in the script. Only these 4 commands are actively used in tile generation. - ---- - -## Line Width System - -The script uses a sophisticated hybrid approach for determining line widths: - -### 1. Physical Width from OSM Tags -When available, the script uses physical width tags from OSM data: -- `width`: Explicit width measurement -- `maxwidth`: Maximum width -- `est_width`: Estimated width -- `diameter`: For circular features -- `gauge`: For railway tracks - -Physical widths are converted from various units (meters, feet, inches) to pixels based on zoom level and latitude, then clamped to reasonable min/max constraints. - -### 2. CartoDB-Style Zoom Defaults -When no physical tags exist, the script applies zoom-based styling optimized for small screens: - -**Major Roads** (visible but not overwhelming at low zoom): -- Motorway: 1px@z6 → 16px@z18 -- Trunk: 1px@z6 → 14px@z18 -- Primary: 1px@z8 → 14px@z18 - -**Connecting Roads** (thin until zoomed in): -- Secondary: 1px@z11 → 12px@z18 -- Tertiary: 1px@z12 → 10px@z18 - -**Minor Roads** (hairline until very close): -- Residential: 0.5px@z13 → 8px@z18 -- Service: 1px@z15 → 6px@z18 - -**Waterways** (drastically reduced): -- River: 1px@z8 → 10px@z18 -- Stream: 1px@z14 → 3px@z18 - -**Transport**: -- Railway: 1px@z10 → 5px@z18 -- Runway: 1px@z10 → 24px@z18 - -### 3. Width Constraints -All calculated widths are clamped with strict min/max values: -- Low zoom (≤10): Maximum 3px for any feature -- Medium zoom (≤12): Maximum 4px for any feature -- High zoom: Feature-specific maximums (e.g., motorway max 18px) - -This ensures clean rendering on small screens without features blocking each other. - ---- - -## Geometry Processing Pipeline +```bash +# Clone repository +git clone https://github.com/jgauchia/Tile-Generator.git +cd Tile-Generator -### 1. Smoothing (Zoom ≥ 16) -At high zoom levels, geometries are smoothed by interpolating additional points: -- Roundabouts get extra smoothing -- Maximum segment distance decreases with zoom -- Preserves closed rings for polygons +# Create virtual environment +python3 -m venv venv +source venv/bin/activate -### 2. Clipping -Geometries are clipped to tile boundaries using industry-standard algorithms: -- **Lines**: Cohen-Sutherland line clipping -- **Polygons**: Sutherland-Hodgman polygon clipping -- Tolerance increased to 1e-5 for better continuity across tile edges +# Install dependencies +pip install geopandas pyogrio shapely pygame osmium +``` -### 3. Coordinate Conversion -Geographic coordinates (lat/lon) are converted to tile pixel coordinates: -- Float pixels (0-256) scaled to uint16 (0-65536) for precision -- Delta encoding for compact storage -- Zigzag encoding for signed deltas +## Usage ---- +### 1. Convert PBF to FlatGeobuf -## Performance Optimizations +```bash +source venv/bin/activate +python pbf_to_fgb.py features.json [--zoom 6-17] +``` -### Memory Management -- **Adaptive batch sizes**: Automatically adjusted based on zoom level and available memory -- **Dynamic worker allocation**: Based on CPU cores and available RAM -- **Garbage collection**: Triggered after processing batches -- **Memory monitoring**: System memory detected and utilized efficiently +**Arguments:** -### Processing Speed -- **Streaming JSON parsing**: Uses ijson to avoid loading entire dataset -- **Batch processing**: Features processed in configurable batch sizes -- **Thread pool execution**: Parallel tile writing with optimal worker count -- **Progress tracking**: Real-time feedback with features/second metrics +| Argument | Description | Default | +|----------|-------------|---------| +| `input.pbf` | OpenStreetMap PBF file | Required | +| `output_dir` | Output directory | Required | +| `features.json` | Feature configuration | Required | +| `--zoom` | Zoom range (e.g., `6-17`, `10-14`, `12`) | `6-17` | -### Storage Efficiency -- **Variable-length encoding**: Varint and zigzag encoding for compact coordinates -- **Global color palette**: Colors indexed once, referenced by index -- **Delta encoding**: Coordinate differences stored instead of absolutes -- **RGB332 format**: 8-bit color (3R, 3G, 2B) reduces palette storage +**Example:** ---- +```bash +python pbf_to_fgb.py catalonia.osm.pbf ./fgb_output features.json --zoom 6-17 +``` -## Usage +### 2. View Generated Files -### Basic Usage ```bash -python tile_generator.py input.gol output_dir features.json --zoom 6-17 +python fgb_viewer.py --lat --lon [--zoom ] ``` -### Arguments +**Arguments:** + | Argument | Description | Default | |----------|-------------|---------| -| `gol_file` | Path to .gol file | Required | -| `output_dir` | Output directory for tiles | Required | -| `config_file` | Features JSON configuration | Required | -| `--zoom` | Zoom level(s) (e.g. `12` or `6-17`) | `6-17` | -| `--max-file-size` | Max tile size in KB | 128 | -| `--batch-size` | Base batch size (auto-adjusted) | 10000 | +| `fgb_dir` | Directory with FGB files | Required | +| `--lat` | Center latitude | Required | +| `--lon` | Center longitude | Required | +| `--zoom` | Zoom level (1-18) | `14` | -### Examples +**Example:** -**Process specific zoom level:** ```bash -python tile_generator.py london.gol tiles/ features.json --zoom 12 +python fgb_viewer.py ./fgb_output --lat 41.3851 --lon 2.1734 --zoom 14 ``` -**Process zoom range with custom batch size:** -```bash -python tile_generator.py region.gol tiles/ features.json --zoom 10-15 --batch-size 5000 -``` - -**Large region with smaller tile size:** -```bash -python tile_generator.py planet.gol tiles/ features.json --zoom 6-17 --max-file-size 64 -``` +**Viewer Controls:** ---- +| Key | Action | +|-----|--------| +| Arrow keys | Pan map | +| Mouse drag | Pan map | +| `[` / `]` or Mouse wheel | Zoom in/out | +| `B` | Toggle background (white/black) | +| `F` | Toggle polygon fill | +| `G` | Toggle tile grid | +| `Q` / `ESC` | Quit | -## Configuration +## Output Structure -### Features JSON Format -The script uses a JSON configuration file to define feature styling: +``` +fgb_output/ +├── 6/ +│ ├── water.fgb +│ ├── roads.fgb +│ └── landuse.fgb +├── 7/ +│ └── ... +├── 10/ +│ ├── roads.fgb (+ secondary roads) +│ └── railways.fgb +├── 14/ +│ ├── roads.fgb (+ residential) +│ ├── buildings.fgb +│ └── ... +└── 17/ + └── ... (all features) +``` + +Each zoom level directory contains only features visible at that zoom level, resulting in smaller files optimized for R-Tree queries. + +### Layer Files + +| Layer | Contents | +|-------|----------| +| `water.fgb` | Coastlines, water bodies, rivers | +| `landuse.fgb` | Forests, parks, residential areas | +| `roads.fgb` | All road types | +| `railways.fgb` | Rail lines | +| `buildings.fgb` | Building footprints | +| `amenities.fgb` | Hospitals, schools, parking | +| `infrastructure.fgb` | Bridges, tunnels, airports | +| `terrain.fgb` | Peaks, cliffs | +| `places.fgb` | Towns, villages | + +## Feature Configuration + +The `features.json` file defines which OSM features to include: ```json { "highway=motorway": { - "color": "#E892A2", - "priority": 12, - "zoom": 6 + "zoom": 6, + "color": "#ff9999", + "priority": 60 }, "highway=primary": { - "color": "#FCD6A4", - "priority": 10, - "zoom": 8 + "zoom": 6, + "color": "#ffcc99", + "priority": 62 }, "building": { - "color": "#D9D0C9", - "priority": 14, - "zoom": 13 - }, - "waterway=river": { - "color": "#A0C8F0", - "priority": 3, - "zoom": 8 + "zoom": 15, + "color": "#dddddd", + "priority": 80 } } ``` -**Configuration Features:** -- **Tag matching**: Supports both `key=value` and `key` only patterns -- **Color**: Hex color code (converted to RGB332 internally) -- **Priority**: Rendering order (higher = drawn later/on top) -- **Zoom**: Minimum zoom level for feature visibility -- **Automatic palette**: Colors automatically indexed into optimal palette - -### Priority System -The script uses a sophisticated priority calculation: -1. **Base priority** from configuration -2. **Layer priority** from OSM `layer` tag (×1000 multiplier) -3. **Feature type priorities**: - - Water features: 100-300 - - Land use: 200 - - Underground (tunnels): 500 - - Railways: 600 - - Roads: 700-1200 (by importance) - - Bridges: 1300 - - Buildings: 1400 - - Amenities: 1500 - ---- - -## Binary Tile Format - -### File Structure -``` -[varint: num_commands] -[command_1] -[command_2] -... -[command_n] -``` - -### Command Structure - -**SET_COLOR (0x80)** -``` -[0x80][rgb332_byte] -``` - -**SET_COLOR_INDEX (0x81)** -``` -[0x81][varint: palette_index] -``` - -**POLYLINE (0x02)** -``` -[0x02][varint: width][varint: num_points] -[zigzag: x1][zigzag: y1] -[zigzag: dx2][zigzag: dy2] -... -``` - -**STROKE_POLYGON (0x03)** -``` -[0x03][varint: width][varint: num_points] -[zigzag: x1][zigzag: y1] -[zigzag: dx2][zigzag: dy2] -... -``` - -### Palette File Format -``` -palette.bin: -[uint32: num_colors] -[r1][g1][b1] // RGB888 for color 0 -[r2][g2][b2] // RGB888 for color 1 -... -``` - ---- - -## Output Structure - -``` -output_dir/ -├── palette.bin # Global color palette (RGB888) -├── 6/ # Zoom level 6 -│ └── 32/ -│ └── 21.bin # Tile (x=32, y=21, z=6) -├── 12/ # Zoom level 12 -│ ├── 2048/ -│ │ ├── 1536.bin -│ │ └── 1537.bin -│ └── 2049/ -└── 17/ # Zoom level 17 - └── ... -``` - ---- - -## System Requirements - -### Required -- **Python 3.7+** -- **gol CLI** ([download](https://www.geodesk.com/download/)) -- **Python packages**: `ijson` - -### Recommended -- **Multi-core CPU**: Script utilizes all available cores -- **4-8GB RAM**: For processing large regions -- **SSD storage**: For better I/O performance - -### Installation -```bash -# Install Python dependencies -pip install ijson - -# Install gol CLI -# Download from: https://www.geodesk.com/download/ -``` - ---- - -## Performance Statistics +**Fields:** -The script provides comprehensive processing reports: +| Field | Description | +|-------|-------------| +| `zoom` | Minimum zoom level for feature visibility | +| `color` | Hex color (converted to RGB332 internally) | +| `priority` | Render order (lower = background, higher = foreground) | -``` -System: 8 CPU cores, 16384MB RAM -> Using 6 workers -Resource settings: 6 workers, base batch size: 10000 -Building color palette... -Palette: 39 colors -Palette written - -Processing zoom 12 (batch size: 4000, workers: 6)... -Zoom 12: 54482 features processed, 139 tiles written -Zoom 12 completed in 2m 45.32s - -Total tiles written: 139 -Total size: 1.2MB -Average tile size: 8847 bytes -Palette file: tiles/palette.bin -Total processing time: 2m 45.32s -``` - ---- - -## Advanced Usage - -### Processing Large Regions -For planet-scale or large extracts, process in smaller zoom ranges: -```bash -# Low zoom levels (broader features) -python tile_generator.py planet.gol tiles/ features.json --zoom 6-10 - -# Medium zoom levels -python tile_generator.py planet.gol tiles/ features.json --zoom 11-14 - -# High zoom levels (detailed features) -python tile_generator.py planet.gol tiles/ features.json --zoom 15-17 -``` - -### Memory-Constrained Systems -Reduce batch size for systems with limited RAM: -```bash -python tile_generator.py input.gol tiles/ features.json --batch-size 2000 -``` +## FlatGeobuf Properties -### Custom Tile Size Limits -Adjust maximum tile file size: -```bash -# Smaller tiles (64KB) for bandwidth-constrained environments -python tile_generator.py input.gol tiles/ features.json --max-file-size 64 - -# Larger tiles (512KB) for high-detail regions -python tile_generator.py input.gol tiles/ features.json --max-file-size 512 -``` - ---- - -## Troubleshooting - -### Common Issues +Each feature in the FGB files contains: -**gol CLI not found:** -```bash -# Download and install gol from https://www.geodesk.com/download/ -# Add to PATH or use absolute path -``` - -**Memory issues:** -- Script automatically adjusts based on available memory -- Reduce `--batch-size` for very constrained systems -- Process zoom ranges separately +| Property | Type | Description | +|----------|------|-------------| +| `color_rgb332` | int | 8-bit color (RGB332 format) | +| `min_zoom` | int | Minimum zoom level | +| `priority` | int | Rendering priority | +| `feature_type` | string | OSM tag (e.g., `highway=primary`) | +| `osm_id` | int | Original OSM ID | +| `layer` | string | Layer name | -**Slow processing:** -- Verify GOL file is not corrupted -- Check system resources (CPU, RAM, disk I/O) -- Use SSD for better performance -- Script automatically optimizes worker count +## ESP32 Implementation -**Missing features in tiles:** -- Check `zoom` setting in features.json -- Verify feature tags match configuration -- Review gol query output for filtering issues +The generated FGB files are optimized for ESP32: ---- +1. **Directory selection**: Use current zoom to select folder +2. **R-Tree query**: Read index, seek to matching features +3. **Partial reads**: Use `fseek()`/`fread()` for bbox queries +4. **Properties**: Read `color_rgb332`, `priority` for rendering -## Technical Details +### Advantages over tile-based formats -### Coordinate Precision -- **Input**: Geographic coordinates (lat/lon, decimal degrees) -- **Internal**: Float pixels (0-256 per tile) -- **Storage**: Uint16 (0-65536) for sub-pixel precision -- **Delta encoding**: Reduces storage by ~60-70% +- No tile coordinate calculations +- Query any bounding box directly +- Smaller total file size +- Efficient SD card access with R-Tree seeks -### Color System -- **Input**: Hex colors (#RRGGBB) -- **Conversion**: RGB888 → RGB332 (8-bit) -- **Storage**: 3 bytes per palette color (RGB888) -- **Usage**: 1 byte per palette index in commands +## Download PBF Files -### Width Calculation -1. Check OSM tags for physical width -2. Convert to pixels using zoom and latitude -3. Apply feature-specific constraints (min/max) -4. Fall back to CartoDB-style defaults if no tags -5. Interpolate between zoom breakpoints - -### Clipping Algorithms -- **Cohen-Sutherland**: Fast line segment clipping with outcodes -- **Sutherland-Hodgman**: Polygon clipping against rectangular viewport -- **Tolerance**: 1e-5 for continuity across tile edges - ---- +Get OSM extracts from [Geofabrik](https://download.geofabrik.de/) ## License -This project is open source and available under the MIT License. - ---- +MIT License -## Acknowledgments +## Related Projects -- **OpenStreetMap**: For providing open map data -- **Geodesk**: For GOL format and gol CLI tool -- **ijson**: For incremental JSON parsing \ No newline at end of file +- [IceNav](https://github.com/jgauchia/IceNav-v3) - ESP32-based GPS navigator +- [FlatGeobuf](https://flatgeobuf.org/) - Geospatial format specification diff --git a/docs/bin_tile_format.md b/docs/bin_tile_format.md deleted file mode 100644 index 94de5d4..0000000 --- a/docs/bin_tile_format.md +++ /dev/null @@ -1,353 +0,0 @@ -# Tile Binary Format Specification - -This document describes the binary format produced by the tile generation script (`tile_generator.py`) for vector map tiles. The format is intended for efficient rendering and compact storage of map data at various zoom levels. Applications and libraries can use this specification to parse and render the resulting `.bin` files. - ---- - -## File Overview - -Each file represents a single tile for a given zoom level (`z`), x coordinate (`x`), and y coordinate (`y`). -The filename and directory structure is: -``` -{output_dir}/{z}/{x}/{y}.bin -``` - -The file contains a sequence of drawing commands encoded in a compact binary format. -All coordinates are encoded as `uint16` values (range 0–65535) relative to the tile. - -The format uses **state commands** (SET_COLOR, SET_COLOR_INDEX) to eliminate color redundancy and achieve maximum compression through a dynamic palette system. - ---- - -## File Structure - -The file is a single binary blob with the structure: - -| Field | Type | Description | -|----------------|-----------|-------------------------------------------| -| num_commands | varint | Number of drawing commands in the tile | -| commands[] | variable | Sequence of drawing commands | - -- All variable-length integers use [protobuf varint encoding](https://developers.google.com/protocol-buffers/docs/encoding#varints). -- All signed integers are encoded with [zigzag encoding](https://developers.google.com/protocol-buffers/docs/encoding#signed-integers). - ---- - -## Drawing Command Structure - -Commands are separated into **state commands** and **geometry commands**: - -### State Commands (Set Current Color) -| Field | Type | Description | -|--------------|-----------|---------------------------------------------| -| type | varint | State command type (0x80 or 0x81) | -| color_data | variable | Color information (see below) | - -### Geometry Commands (Use Current Color) -| Field | Type | Description | -|--------------|-----------|---------------------------------------------| -| type | varint | Drawing command type (see command types) | -| parameters | variable | Command-specific data (no color field) | - ---- - -## Command Types - -### Basic Geometry Commands -| Name | Value | Description | -|---------------------|-------|------------------------------------------------------| -| LINE | 0x01 | Single line (from x1,y1 to x2,y2) | -| POLYLINE | 0x02 | Polyline (sequence of points) | -| STROKE_POLYGON | 0x03 | Closed polygon outline (sequence of points) | - -### State Commands -| Name | Value | Description | -|---------------------|-------|------------------------------------------------------| -| SET_COLOR | 0x80 | Set current color using RGB332 direct value (fallback) | -| SET_COLOR_INDEX | 0x81 | Set current color using dynamic palette index | - ---- - -## State Command Parameters - -### SET_COLOR (type 0x80) -Sets the current color for subsequent geometry commands using direct RGB332 value. Used as fallback when color is not in the dynamic palette. - -| Field | Type | Description | -|-------------|---------|--------------------------------| -| color | uint8 | RGB332 color value (0-255) | - -### SET_COLOR_INDEX (type 0x81) -Sets the current color using a dynamic palette index. This is the primary method for color assignment. - -| Field | Type | Description | -|-------------|---------|------------------------------------------| -| color_index | varint | Index into dynamic color palette (0-N) | - ---- - -## Geometry Command Parameters - -**Important**: Geometry commands do **NOT** include color fields. The current color is set by the most recent SET_COLOR or SET_COLOR_INDEX command. - -All geometry commands now include a **width parameter** before the coordinate data. - -### LINE (type 0x01) -| Field | Type | Description | -|-------------|---------|-------------------------| -| width | varint | Line width in pixels | -| x1, y1 | int32 | Starting coordinates | -| x2, y2 | int32 | Ending coordinates | - -**Encoding**: varint(width), zigzag(x1), zigzag(y1), zigzag(x2 - x1), zigzag(y2 - y1) - -### POLYLINE (type 0x02) -| Field | Type | Description | -|-------------|---------|----------------------------------------| -| width | varint | Line width in pixels | -| num_points | varint | Number of points | -| points[] | int32 | Sequence of (x, y) | - -**Encoding**: -- varint(width) -- varint(num_points) -- zigzag(x0), zigzag(y0) – first point absolute -- zigzag(x1 - x0), zigzag(y1 - y0) – delta encoding for subsequent points - -### STROKE_POLYGON (type 0x03) -| Field | Type | Description | -|-------------|---------|----------------------------------------| -| width | varint | Line width in pixels | -| num_points | varint | Number of points | -| points[] | int32 | Sequence of (x, y) | - -**Encoding**: -- varint(width) -- varint(num_points) -- zigzag(x0), zigzag(y0) – first point absolute -- zigzag(x1 - x0), zigzag(y1 - y0) – delta encoding for subsequent points - ---- - -## Line Width Calculation - -The tile generator includes intelligent line width calculation based on OSM tags and zoom level: - -### Width from 'width' Tag (Highest Priority) -If a feature has a `width` tag (in meters), it's converted to pixels using Mercator projection: -- Considers the feature's latitude for accurate meter-to-pixel conversion -- Applies zoom-based scaling factors -- Clamps to minimum (0.5px) and maximum (20px) values - -### Default Widths by Feature Type -When no `width` tag is present, default widths are calculated based on feature type and zoom level: - -**Highways:** -- motorway, trunk: `max(2, int(4 * zoom/18.0))` -- primary, motorway_link, trunk_link: `max(1, int(3 * zoom/18.0))` -- secondary, primary_link: `max(1, int(2.5 * zoom/18.0))` -- tertiary, secondary_link: `max(1, int(2 * zoom/18.0))` -- residential, unclassified, road: `max(1, int(1.5 * zoom/18.0))` -- service, track: `1` -- Other (footway, path, etc.): `1` - -**Railways:** -- All types: `max(1, int(2 * zoom/18.0))` - -**Waterways:** -- river, canal: `max(1, int(3 * zoom/18.0))` -- stream, drain, ditch: `max(1, int(1.5 * zoom/18.0))` -- Other: `1` - -**Aeroways:** -- runway, taxiway: `max(1, int(4 * zoom/18.0))` - -**Power lines:** -- power=line: `max(1, int(1.5 * zoom/18.0))` - -**Barriers:** -- fence, wall, retaining_wall: `max(1, int(1 * zoom/18.0))` - -**Man-made:** -- pipeline, embankment: `max(1, int(2 * zoom/18.0))` - -**Natural:** -- cliff, ridge, arete: `max(1, int(2 * zoom/18.0))` - ---- - -## Color Encoding - -### RGB332 Format -- `color` is stored as a single byte (`uint8`) in [RGB332 format](https://en.wikipedia.org/wiki/List_of_monochrome_and_RGB_palettes#RGB332). -- **Bit layout**: `RRRGGGBB` (3 bits red, 3 bits green, 2 bits blue) - -### Dynamic Palette -- **Automatic generation**: Palette is built from unique colors in the configuration file -- **Index mapping**: Each hex color (e.g., `#ff0000`) receives a unique index (0-N) -- **Alphabetical ordering**: Colors are sorted alphabetically for consistency -- **Efficient encoding**: Indices use varint encoding (smaller for low indices) -- **Primary method**: Most colors use SET_COLOR_INDEX for optimal compression - -### Palette File -The generator creates a `palette.bin` file in the output directory: - -| Field | Type | Description | -|----------------|-----------|-------------------------------------------| -| num_colors | uint32 | Number of colors in palette | -| colors[] | RGB888 | Array of 24-bit RGB colors (R, G, B) | - -Each color is stored as 3 bytes (R, G, B) in the range 0-255, expanded from RGB332 format. - ---- - -## Coordinate System - -- All coordinates are relative to the tile, with the top-left of the tile as (0,0) and bottom-right as (65535,65535). -- This allows for sub-pixel precision and scalable rendering at different resolutions. -- **Delta encoding**: Most commands use coordinate differences for better compression. -- **Zigzag encoding**: Signed coordinate differences are encoded efficiently. - ---- - -## Layer Priority System - -Features are rendered in priority order determined by the `get_layer_priority()` function: - -| Priority | Feature Type | -|----------|------------------------------------------------------| -| 100 | Water features (natural=water/coastline/bay, waterway=riverbank/dock/boatyard) | -| 200 | Land use and natural areas (landuse, natural=wood/forest/scrub/heath/grassland/beach/sand/wetland, leisure=park/nature_reserve/garden) | -| 300 | Waterways (waterway=river/stream/canal) | -| 400 | Natural terrain features (natural=peak/ridge/volcano/cliff) | -| 500 | Tunnels (tunnel=yes) | -| 600 | Railways | -| 700 | Pedestrian ways (highway=path/footway/cycleway/steps/pedestrian/track) | -| 800 | Tertiary roads (highway=tertiary/tertiary_link) | -| 900 | Secondary roads (highway=secondary/secondary_link) | -| 1000 | Primary roads (highway=primary/primary_link) | -| 1100 | Trunk roads (highway=trunk/trunk_link) | -| 1200 | Motorways (highway=motorway/motorway_link) | -| 1300 | Bridges and aeroways (bridge=yes, aeroway) | -| 1400 | Buildings | -| 1500 | Amenities | - -The OSM `layer` tag can add ±1000 * layer_value to the base priority. - ---- - -## Geometry Simplification - -The generator includes Douglas-Peucker simplification for zoom levels ≥ 16: - -### Simplification Tolerance by Zoom Level -- Zoom 16: 0.000015 -- Zoom 17: 0.000012 -- Zoom 18: 0.00001 -- Zoom 19: 0.000008 -- Zoom ≥20: 0.000005 - -### Simplification Rules -- Only applied to features with ≥50 vertices -- LineStrings with <10 points are not simplified -- Polygons maintain their closed ring structure -- Can be disabled with `--no-simplify` flag - ---- - -## Optimization Benefits - -### Dynamic Palette System -- **Primary optimization**: Replaces RGB332 values with compact indices -- **Automatic generation**: Palette built from configuration file -- **Maximum compression**: Frequently used colors get low indices (smaller varint) -- **Benefit**: 25-40% file size reduction compared to embedded colors - -### State Command Architecture -- Eliminates redundant color fields in geometry commands -- One color command can apply to multiple geometry commands -- **Benefit**: Additional 15-25% file size reduction in dense tiles - -### Width-Based Rendering -- Intelligent width calculation based on OSM tags -- Zoom-level adaptive scaling -- Meter-to-pixel conversion with Mercator projection -- **Benefit**: Realistic and consistent feature rendering across zoom levels - -### File Size Reduction -- **Dynamic palette**: 25-40% reduction compared to embedded colors -- **State commands**: Additional 15-25% reduction in dense tiles -- **Delta encoding**: Efficient coordinate compression -- **Geometry simplification**: 30-50% reduction for high-zoom complex features -- **Total improvement**: Up to 60% smaller than unoptimized format - ---- - -## Performance Considerations - -### Rendering Performance -- **Fewer state changes**: Grouped commands reduce GPU state changes -- **Efficient primitives**: Basic line and polygon rendering -- **Width-aware rendering**: Single pass with variable line widths -- **Cache efficiency**: Sequential commands improve cache hits - -### Memory Usage -- **Palette storage**: Minimal overhead (typically <200 bytes for palette.bin) -- **Parser state**: Single color variable -- **Coordinate buffers**: Small buffers for delta decoding - ---- - -## Compatibility and Forward Compatibility - -### Version Compatibility -- **Basic parsers**: Must implement commands 0x01-0x03, 0x80-0x81 -- **Width parameter**: All geometry commands now include width (as of current version) -- **Fallback rendering**: Unknown commands can be skipped -- **State preservation**: Color state persists across unknown commands - -### Implementation Guidelines -- Always implement basic commands (0x01-0x03, 0x80-0x81) -- Load and use the palette.bin file for color interpretation -- Support width parameters for all geometry commands -- Unknown command types should be skipped gracefully -- Maintain color state across all commands - ---- - -## Usage - -### Basic Implementation Requirements -1. Read the file and parse the initial varint (`num_commands`) -2. Initialize `current_color = 0xFF` -3. Load dynamic palette from `palette.bin` -4. Implement state command handlers (SET_COLOR, SET_COLOR_INDEX) -5. Implement basic geometry commands (LINE, POLYLINE, STROKE_POLYGON) -6. Support width parameter in all geometry commands - -### Command-Line Usage -```bash -python tile_generator.py input.gol output_dir config.json --zoom 6-17 [options] - -Options: - --max-file-size KB Maximum tile file size (default: 128KB) - --batch-size N Base batch size, auto-adjusted per zoom (default: 10000) - --no-simplify Disable Douglas-Peucker simplification -``` - -### Error Handling -- Invalid command types should be skipped gracefully -- Out-of-bounds palette indices should use default color (0xFF) -- Missing palette should fall back to direct RGB332 interpretation -- Corrupted files should fail safely without crashing -- Coordinate overflow should clamp to tile boundaries (0-65535) - ---- - -## References - -- [Protocol Buffers Varint Encoding](https://developers.google.com/protocol-buffers/docs/encoding#varints) -- [Zigzag Encoding](https://developers.google.com/protocol-buffers/docs/encoding#signed-integers) -- [RGB332 Color Format](https://en.wikipedia.org/wiki/List_of_monochrome_and_RGB_palettes#RGB332) - ---- \ No newline at end of file diff --git a/docs/features_json_format.md b/docs/features_json_format.md deleted file mode 100644 index db572c3..0000000 --- a/docs/features_json_format.md +++ /dev/null @@ -1,161 +0,0 @@ -# Features JSON Format Specification - -This document describes the structure and usage of the `features.json` configuration file for vector tile generation. -The file defines feature styling, priority, and minimum zoom level for OpenStreetMap (OSM) tags to be rendered in the generated tiles. - ---- - -## File Overview - -- The file is a JSON object containing **feature definitions** for OpenStreetMap data styling and filtering. -- **Feature definitions** specify styling, priority, and minimum zoom level for OSM tags. - ---- - -## Feature Definitions - -Each feature definition is a key-value pair where: -- The key is a **feature selector** for OSM data, matching tags in the form `key=value` or just `key`. -- The value is an object specifying options for rendering that feature. - ---- - -## Feature Selector - -- The key is either: - - `"key=value"`: Matches OSM features where the given tag equals the specified value. - - `"key"`: Matches any OSM feature with the given key present (any value). - -**Examples:** -```json -"natural=coastline": {...} -"building": {...} -``` - ---- - -## Feature Parameters - -Each feature definition object can include: - -| Parameter | Type | Description | -|--------------|----------|---------------------------------------------------------------------------------------------------| -| zoom | integer | Minimum zoom level at which the feature is rendered. | -| color | string | Fill/stroke color in HTML hexadecimal format (`#rrggbb`). | -| description | string | Human-readable description of the feature. | -| priority | integer | Rendering priority (lower numbers are rendered first/underneath; higher numbers are on top). | - ---- - -### Parameter Details - -- **zoom** - - Specifies the lowest zoom at which the feature will be rendered. - - Features are omitted from tiles with a zoom less than this value. - -- **color** - - Specifies the color to render the feature. - - Uses standard 6-character hex notation (e.g., `#ffbe00` for yellow). - - This is mapped to a compact color encoding (RGB332) in the binary tiles. - -- **description** - - Provides a short, human-readable label for the feature type. - -- **priority** - - Controls draw order. - - Lower values are rendered below higher values in the tile. - ---- - -## Example - -```json -{ - "natural=coastline": { - "zoom": 6, - "color": "#0077FF", - "description": "Coastlines", - "priority": 1 - }, - "natural=water": { - "zoom": 12, - "color": "#3399FF", - "description": "Water bodies", - "priority": 1 - }, - "building": { - "zoom": 15, - "color": "#BBBBBB", - "description": "Buildings", - "priority": 9 - }, - "highway=motorway": { - "zoom": 6, - "color": "#FFFFFF", - "description": "Motorways", - "priority": 10 - }, - "highway=primary": { - "zoom": 6, - "color": "#FFA500", - "description": "Primary roads", - "priority": 12 - }, - "amenity=hospital": { - "zoom": 15, - "color": "#FF0000", - "description": "Hospitals", - "priority": 8 - } -} -``` - -- **Feature definitions**: Examples showing different feature types and their properties -- **Complete configuration**: The actual `features.json` contains 497 feature definitions covering all major OSM categories - ---- - -## How Matching Works - -- During tile generation, each OSM feature is checked against the keys in the JSON: - - If the feature has a matching tag (`key=value`), the corresponding entry is used for rendering. - - If only the key matches (e.g., `"building"`), the entry applies for any value of that key. - -- If a feature matches multiple entries, the most specific (key=value) takes precedence over generic key matches. - -- Features are filtered by zoom level: only features with `zoom` parameter less than or equal to the current tile zoom level are included. - -- Features are rendered in priority order: lower priority numbers are drawn first (underneath), higher priority numbers are drawn on top. - ---- - -## Polygon Rendering - -Polygons can be rendered in two modes (controlled by the tile viewer application): - -- **Filled mode**: Polygons are filled with their specified color and have outlines: - - **Interior segments**: Outlines are 40% darker than the fill color - - **Border segments**: Outlines use the original polygon color -- **Outline mode**: Polygons show only outlines: - - **Interior segments**: Outlines use the original polygon color - - **Border segments**: Outlines use the background color (making them invisible) - -This creates a visual distinction between polygon interiors and tile borders. - -## Usage Notes - -- You can extend the file with new feature selectors and styling as needed. -- All parameters are optional, but `zoom` and `color` are recommended for correct rendering. -- This file drives both filtering (which features are extracted from the OSM source) and styling (how they appear in the output tiles). -- Priority values range from 1-35 in the current configuration, with natural features having the lowest priorities and place labels having the highest. -- Zoom levels range from 6-17, with major features (coastlines, motorways) appearing at low zoom levels and detailed features (individual trees, street lamps) appearing at high zoom levels. -- Color values use standard HTML hex notation and are automatically converted to RGB332 format for efficient storage in binary tiles. - ---- - -## References - -- [OpenStreetMap Tagging](https://wiki.openstreetmap.org/wiki/Tags) -- [Hex Color Codes](https://www.w3schools.com/colors/colors_picker.asp) - ---- \ No newline at end of file diff --git a/docs/tile_viewer.md b/docs/tile_viewer.md deleted file mode 100644 index f8649fc..0000000 --- a/docs/tile_viewer.md +++ /dev/null @@ -1,345 +0,0 @@ -# Tile Viewer - -`tile_viewer.py` is an advanced Python application for visualizing map tiles, supporting both custom vector tiles in `.bin` format and raster tiles in `.png` format. -It provides an interactive map viewer with mouse and keyboard controls, allowing exploration of tiles rendered in either format. - -## Features - -- **High Performance**: LRU cache system with configurable memory limits -- **Multi-threading**: Persistent thread pool for efficient tile loading -- **Error Recovery**: Graceful handling of missing or corrupted tiles -- **Lazy Loading**: Only loads visible tiles to optimize memory usage -- **Advanced Rendering**: Supports advanced compression commands (GRID_PATTERN, CIRCLE, PREDICTED_LINE, DASHED_LINE, DOTTED_LINE) -- **Dynamic Palette**: Loads color palettes from binary files (`palette.bin`) or configuration files (`features.json`) -- **Modern UI**: Beautiful icons and improved button text rendering -- **Layer Support**: Renders tiles in correct layer order (TERRAIN, WATER, BUILDINGS, OUTLINES, ROADS, LABELS) -- **Polygon Filling**: Toggle between filled and outlined polygon rendering - ---- - -## What Does This Script Do? - -- Loads and renders tiles from a directory containing tiles in `.bin` (vector) **or** `.png` (raster) format -- Supports multiple zoom levels and smooth panning -- Displays tile boundaries, coordinates, and labels -- Offers interactive controls via mouse and keyboard -- Can show GPS cursor coordinates and fill polygons in vector tiles -- Implements intelligent caching and memory management -- Provides detailed logging and error reporting -- Loads dynamic color palettes for accurate tile rendering - -**Note:** If both `.bin` and `.png` files are present for a tile, `.bin` is preferred and rendered as vector graphics. -This script is useful for visualizing both the output of the vector tile generator and other map tiles in PNG format. - ---- - -## Directory Structure - -The tile viewer expects the following directory structure: - -``` -TILES_DIRECTORY/ -├── 6/ # Zoom level 6 -│ ├── 0/ # Tile X coordinate -│ │ ├── 0.bin # Vector tile (preferred) -│ │ └── 0.png # Raster tile (fallback) -│ ├── 1/ -│ │ ├── 0.bin -│ │ └── 0.png -│ └── ... -├── 7/ # Zoom level 7 -│ ├── 0/ -│ │ ├── 0.bin -│ │ └── 0.png -│ └── ... -├── 8/ # Zoom level 8 -├── ... -└── palette.bin # Binary color palette (optional, preferred) - -features.json # Feature configuration (optional, fallback) - in script directory -``` - -### Tile File Formats - -- **`.bin` files**: Custom vector tile format with compressed drawing commands -- **`.png` files**: Standard raster tile format (fallback) -- **`palette.bin`**: Binary color palette file generated by `tile_generator.py` (in tile directory) -- **`features.json`**: JSON configuration file with feature definitions and colors (in script directory) - ---- - -## Dependencies - -### Python Packages - -You need the following Python packages: - -- `pygame` - Graphics and window management -- `struct` - Binary data handling (built-in) -- `threading` - Multi-threading support (built-in) -- `concurrent.futures` - Thread pool management (built-in) -- `collections` - Data structures (built-in) -- `functools` - Function utilities (built-in) - -### System Dependencies - -For optimal performance, you may also need: - -- `python3-dev` - Python development headers -- `libsdl2-dev` - SDL2 development libraries (for pygame) - -### Installation - -#### Using pip (Python packages): - -```sh -pip install pygame -``` - -#### Using apt (System packages): - -```sh -# Ubuntu/Debian -sudo apt update -sudo apt install python3-pygame python3-dev libsdl2-dev - -# Or install pygame system-wide -sudo apt install python3-pygame -``` - -#### Complete installation script: - -```sh -# Update package list -sudo apt update - -# Install system dependencies -sudo apt install python3 python3-pip python3-dev libsdl2-dev - -# Install Python packages -pip3 install pygame - -# Verify installation -python3 -c "import pygame; print('Pygame version:', pygame.version.ver)" -``` - ---- - -## How It Works - -### Controls - -- **Mouse Drag:** Pan the viewport across the map -- **Mouse Wheel / `[ ]` keys:** Zoom in and out between available zoom levels -- **Arrow Keys:** Move viewport left, right, up, or down -- **Buttons (on the right toolbar):** - - **Background:** Toggle background color (black/white) - - **Tile Labels:** Show/hide tile coordinates and file names - - **GPS Cursor:** Show/hide cursor latitude/longitude (in decimal and GMS) - - **Fill Polygons:** Toggle filled polygons rendering (for vector `.bin` tiles) -- **`l` key:** Toggle tile labels -- **`f` key:** Toggle polygon filling -- **Toolbar buttons:** Click to activate features with improved multi-line text rendering - -### Status Bar - -- Shows the current zoom level and progress bars for indexing/loading tiles -- Displays cache statistics and performance metrics -- Real-time logging of operations and errors - -### Startup - -Start the script with the tile directory as argument: - -```sh -python3 tile_viewer.py TILES_DIRECTORY -``` - ---- - -## Configuration - -The viewer uses hardcoded default configuration values. All viewer-specific settings are built into the application: - -### Default Configuration - -```python -DEFAULT_CONFIG = { - 'tile_size': 256, - 'viewport_size': 768, - 'toolbar_width': 160, - 'statusbar_height': 40, - 'max_cache_size': 1000, - 'thread_pool_size': 4, - 'background_colors': [(0, 0, 0), (255, 255, 255)], - 'log_level': 'INFO', - 'config_file': 'features.json', - 'fps_limit': 30, - 'fill_polygons': False -} -``` - -### Configuration Options - -- `tile_size`: Size of individual tiles in pixels (default: 256) -- `viewport_size`: Size of the viewport window (default: 768) -- `toolbar_width`: Width of the right toolbar in pixels (default: 160) -- `statusbar_height`: Height of the status bar in pixels (default: 40) -- `max_cache_size`: Maximum number of tiles to cache (default: 1000) -- `thread_pool_size`: Number of worker threads for tile loading (default: 4) -- `background_colors`: Array of background colors for toggle (default: black and white) -- `log_level`: Logging level (default: "INFO") -- `fps_limit`: Maximum frames per second (default: 30) -- `fill_polygons`: Whether to fill polygons by default (default: False) - -**Note:** All configuration values are hardcoded for optimal performance and consistency. - ---- - -## Palette Loading - -The viewer supports two methods for loading color palettes: - -### 1. Binary Palette (Preferred) - -The viewer automatically looks for `palette.bin` in the tile directory: - -```sh -# Generated by tile_generator.py in the tile directory -TILES_DIRECTORY/ -├── 6/ -├── 7/ -├── ... -└── palette.bin -``` - -**Format:** 4-byte header (number of colors) + RGB888 color data (3 bytes per color) - -### 2. JSON Configuration (Fallback) - -If `palette.bin` is not found, the viewer falls back to `features.json` for color extraction: - -The viewer extracts unique colors from feature definitions in `features.json` to build the palette dynamically. - ---- - -## Performance Features - -- **LRU Cache**: Intelligent memory management with configurable limits -- **Thread Pool**: Persistent worker threads for efficient tile loading -- **Lazy Loading**: Only loads visible tiles to optimize memory usage -- **Coordinate Caching**: Cached coordinate conversions for better performance -- **Error Recovery**: Graceful handling of missing or corrupted tiles -- **Memory Pool**: Reusable objects to reduce garbage collection -- **Streaming**: Processes tiles in batches to reduce memory footprint - ---- - -## Supported Drawing Commands - -The viewer supports a wide range of drawing commands for vector tiles: - -### Basic Commands -- `LINE` (1): Draw a line between two points -- `POLYLINE` (2): Draw a polyline with multiple points -- `STROKE_POLYGON` (3): Draw a polygon outline -- `HORIZONTAL_LINE` (5): Draw a horizontal line -- `VERTICAL_LINE` (6): Draw a vertical line - -### Advanced Commands -- `RECTANGLE` (0x82): Draw a rectangle -- `STRAIGHT_LINE` (0x83): Draw a straight line -- `HIGHWAY_SEGMENT` (0x84): Draw highway segments -- `GRID_PATTERN` (0x85): Draw grid patterns -- `BLOCK_PATTERN` (0x86): Draw city block patterns -- `CIRCLE` (0x87): Draw circles -- `PREDICTED_LINE` (0x8A): Draw predicted lines -- `COMPRESSED_POLYLINE` (0x8B): Draw compressed polylines -- `OPTIMIZED_POLYGON` (0x8C): Draw optimized polygons -- `HOLLOW_POLYGON` (0x8D): Draw hollow polygons -- `OPTIMIZED_TRIANGLE` (0x8E): Draw optimized triangles -- `OPTIMIZED_RECTANGLE` (0x8F): Draw optimized rectangles -- `OPTIMIZED_CIRCLE` (0x90): Draw optimized circles -- `SIMPLE_RECTANGLE` (0x96): Draw simple rectangles -- `SIMPLE_CIRCLE` (0x97): Draw simple circles -- `SIMPLE_TRIANGLE` (0x98): Draw simple triangles -- `DASHED_LINE` (0x99): Draw dashed lines -- `DOTTED_LINE` (0x9A): Draw dotted lines - -### State Commands -- `SET_COLOR` (0x80): Set direct RGB332 color -- `SET_COLOR_INDEX` (0x81): Set color from palette index -- `SET_LAYER` (0x88): Set rendering layer -- `RELATIVE_MOVE` (0x89): Move cursor relatively - ---- - -## Rendering Layers - -Tiles are rendered in the following layer order (from bottom to top): - -1. **TERRAIN** (0): Background terrain, water bodies -2. **WATER** (1): Rivers, lakes, oceans -3. **BUILDINGS** (2): Buildings, structures -4. **OUTLINES** (3): Polygon outlines, borders -5. **ROADS** (4): Roads, highways, paths -6. **LABELS** (5): Text labels, symbols - ---- - -## Troubleshooting - -### Common Issues - -1. **"No zoom level directories found"** - - Ensure your tile directory contains subdirectories named with numbers (6, 7, 8, etc.) - - Check that the directory path is correct - -2. **"Could not load palette from palette.bin"** - - The viewer will fall back to `features.json` - - Ensure `palette.bin` was generated by `tile_generator.py` - -3. **"Tile file does not exist"** - - Check that tile files exist in the expected `{zoom}/{x}/{y}.bin` or `.png` format - - Verify file permissions - -4. **Performance Issues** - - Reduce `max_cache_size` in configuration - - Lower `thread_pool_size` for systems with limited cores - - Check available system memory - -### Debug Mode - -Enable debug logging by modifying the configuration: - -```python -# In tile_viewer.py, change: -logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s') -``` - ---- - -## Notes - -- If the tile directory contains both `.bin` and `.png` files, `.bin` is preferred for vector rendering -- The viewer supports multiple zoom levels, switching seamlessly with mouse wheel or bracket keys -- The GPS tooltip shows coordinates at the mouse position, both in decimal degrees and degrees/minutes/seconds (GMS) -- You can visualize both vector tiles (`.bin`) and raster tiles (`.png`) in the same directory -- The application includes comprehensive logging for debugging and monitoring -- Modern UI with beautiful icons and improved text rendering -- Polygon filling can be toggled on/off for different visualization needs -- The viewer automatically handles tile borders and interior segments differently for better visual quality - ---- - -## Integration with Tile Generator - -This viewer is designed to work seamlessly with `tile_generator.py`: - -1. **Generate tiles**: Run `tile_generator.py` to create `.bin` tiles and `palette.bin` -2. **View tiles**: Run `tile_viewer.py` to visualize the generated tiles -3. **Automatic palette**: The viewer automatically loads the correct color palette from `palette.bin` -4. **Layer support**: All rendering layers are properly supported -5. **Command compatibility**: All drawing commands are fully supported - -The viewer provides an excellent way to preview and debug tiles generated by the tile generator. \ No newline at end of file diff --git a/features.json b/features.json index cf2f773..111af95 100644 --- a/features.json +++ b/features.json @@ -1,482 +1,356 @@ { - "tile_size": 256, - "viewport_size": 768, - "toolbar_width": 160, - "statusbar_height": 40, - "max_cache_size": 1000, - "thread_pool_size": 4, - "background_colors": [ - [0, 0, 0], - [255, 255, 255] - ], - "log_level": "INFO", - "config_file": "features.json", - "fps_limit": 30, - "fill_polygons": true, - "natural=coastline": { "zoom": 6, "color": "#88c9fa", - "priority": 100 + "priority": 10 }, "natural=water": { "zoom": 12, "color": "#88c9fa", - "priority": 100 + "priority": 10 }, "natural=bay": { "zoom": 12, "color": "#88c9fa", - "priority": 100 + "priority": 10 }, "waterway=riverbank": { "zoom": 12, "color": "#88c9fa", - "priority": 100 + "priority": 10 }, "waterway=dock": { "zoom": 14, "color": "#88c9fa", - "priority": 100 - }, - "waterway=boatyard": { - "zoom": 14, - "color": "#88c9fa", - "priority": 100 + "priority": 10 }, "waterway=river": { "zoom": 8, "color": "#88c9fa", - "priority": 70 + "priority": 15 }, "waterway=stream": { - "zoom": 8, + "zoom": 12, "color": "#88c9fa", - "priority": 70 + "priority": 15 }, "waterway=canal": { "zoom": 12, "color": "#88c9fa", - "priority": 70 - }, - "natural=spring": { - "zoom": 17, - "color": "#88c9fa", - "priority": 26 + "priority": 15 }, "natural=beach": { "zoom": 12, "color": "#f7e9c9", - "priority": 90 + "priority": 20 }, "natural=sand": { "zoom": 12, "color": "#f7e9c9", - "priority": 90 + "priority": 20 }, "natural=wetland": { "zoom": 12, "color": "#c2e0e4", - "priority": 90 + "priority": 20 }, "natural=wood": { "zoom": 12, "color": "#9bc184", - "priority": 90 + "priority": 25 }, "landuse=forest": { "zoom": 12, "color": "#9bc184", - "priority": 90 - }, - "natural=forest": { - "zoom": 12, - "color": "#9bc184", - "priority": 90 + "priority": 25 }, "natural=scrub": { - "zoom": 12, + "zoom": 14, "color": "#b5d0a3", - "priority": 90 + "priority": 25 }, "natural=heath": { - "zoom": 12, + "zoom": 14, "color": "#d4d8a3", - "priority": 90 + "priority": 25 }, "natural=grassland": { - "zoom": 12, - "color": "#c8e6a9", - "priority": 90 - }, - "landuse=meadow": { - "zoom": 14, - "color": "#c8e6a9", - "priority": 90 - }, - "landuse=grass": { - "zoom": 14, - "color": "#c8e6a9", - "priority": 90 - }, - "landuse=orchard": { "zoom": 14, "color": "#c8e6a9", - "priority": 90 - }, - "landuse=vineyard": { - "zoom": 14, - "color": "#c8e6a9", - "priority": 90 + "priority": 25 }, "landuse=farmland": { "zoom": 12, "color": "#e8e4b5", - "priority": 90 - }, - "natural=tree_row": { - "zoom": 16, - "color": "#9bc184", - "priority": 29 - }, - "natural=tree": { - "zoom": 17, - "color": "#9bc184", - "priority": 28 + "priority": 25 }, "landuse=park": { "zoom": 12, "color": "#b5e3b5", - "priority": 85 + "priority": 30 }, "leisure=park": { "zoom": 12, "color": "#b5e3b5", - "priority": 85 + "priority": 30 }, "leisure=nature_reserve": { "zoom": 12, "color": "#b5e3b5", - "priority": 85 - }, - "leisure=garden": { - "zoom": 14, - "color": "#b5e3b5", - "priority": 85 - }, - "leisure=pitch": { - "zoom": 12, - "color": "#b5e3b5", - "priority": 85 + "priority": 30 }, "leisure=golf_course": { - "zoom": 12, + "zoom": 14, "color": "#b5e3b5", - "priority": 85 + "priority": 30 }, "landuse=residential": { "zoom": 12, "color": "#e8e6e3", - "priority": 80 - }, - "place=suburb": { - "zoom": 14, - "color": "#e8e6e3", - "priority": 80 + "priority": 35 }, "landuse=commercial": { "zoom": 14, "color": "#f0e0d0", - "priority": 80 + "priority": 35 }, "landuse=retail": { "zoom": 14, "color": "#f0e0d0", - "priority": 80 + "priority": 35 }, "landuse=industrial": { "zoom": 12, "color": "#d8d8d8", - "priority": 80 - }, - "landuse=construction": { - "zoom": 14, - "color": "#e8d8c8", - "priority": 80 + "priority": 35 }, "landuse=cemetery": { "zoom": 14, "color": "#c0c0c0", - "priority": 85 - }, - "landuse=allotments": { - "zoom": 14, - "color": "#d8e8d8", - "priority": 85 - }, - - "leisure=stadium": { - "zoom": 12, - "color": "#d0d0d0", - "priority": 85 - }, - "leisure=sports_centre": { - "zoom": 12, - "color": "#d0d0d0", - "priority": 85 - }, - "leisure=playground": { - "zoom": 12, - "color": "#e8d8a8", - "priority": 85 + "priority": 35 }, "natural=peak": { - "zoom": 15, - "color": "#8b7355", - "priority": 30 - }, - "natural=ridge": { - "zoom": 15, + "zoom": 14, "color": "#8b7355", - "priority": 30 + "priority": 40 }, "natural=volcano": { - "zoom": 15, + "zoom": 14, "color": "#d84200", - "priority": 30 + "priority": 40 }, "natural=cliff": { "zoom": 15, "color": "#8b7355", - "priority": 30 - }, - - "tunnel=yes": { - "zoom": 12, - "color": "#a0a0a0", "priority": 40 }, "railway=rail": { "zoom": 8, "color": "#808080", - "priority": 65 + "priority": 50 }, "railway=subway": { - "zoom": 12, + "zoom": 14, "color": "#808080", - "priority": 65 + "priority": 50 }, - "railway=tram": { - "zoom": 12, - "color": "#808080", - "priority": 65 + + "tunnel=yes": { + "zoom": 14, + "color": "#a0a0a0", + "priority": 55 }, "highway=motorway": { "zoom": 6, "color": "#ff9999", - "priority": 50 + "priority": 60 }, "highway=motorway_link": { - "zoom": 6, + "zoom": 8, "color": "#ff9999", - "priority": 50 + "priority": 60 }, "highway=trunk": { "zoom": 6, "color": "#ffbbbb", - "priority": 49 + "priority": 61 }, "highway=trunk_link": { - "zoom": 6, + "zoom": 8, "color": "#ffbbbb", - "priority": 49 + "priority": 61 }, "highway=primary": { "zoom": 6, "color": "#ffcc99", - "priority": 48 + "priority": 62 }, "highway=primary_link": { - "zoom": 6, + "zoom": 10, "color": "#ffcc99", - "priority": 48 + "priority": 62 }, "highway=secondary": { - "zoom": 12, + "zoom": 10, "color": "#ffff99", - "priority": 47 + "priority": 63 }, "highway=secondary_link": { "zoom": 12, "color": "#ffff99", - "priority": 47 + "priority": 63 }, "highway=tertiary": { "zoom": 12, "color": "#ffffcc", - "priority": 46 + "priority": 64 }, "highway=tertiary_link": { "zoom": 12, "color": "#ffffcc", - "priority": 46 + "priority": 64 }, "highway=residential": { "zoom": 14, - "color": "#f8f8f8", - "priority": 45 + "color": "#ffffff", + "priority": 65 }, "highway=living_street": { "zoom": 14, - "color": "#f8f8f8", - "priority": 44 + "color": "#ffffff", + "priority": 65 }, "highway=unclassified": { "zoom": 12, - "color": "#f8f8f8", - "priority": 34 + "color": "#ffffff", + "priority": 65 }, "highway=service": { - "zoom": 14, - "color": "#f0f0f0", - "priority": 43 - }, - "highway=pedestrian": { "zoom": 15, - "color": "#f8f8f8", - "priority": 42 + "color": "#f0f0f0", + "priority": 66 }, "highway=track": { - "zoom": 15, + "zoom": 14, "color": "#e8d8b0", - "priority": 41 + "priority": 67 }, "highway=path": { "zoom": 15, "color": "#e8d8b0", - "priority": 40 + "priority": 68 }, "highway=footway": { - "zoom": 15, + "zoom": 16, "color": "#f0e8d8", - "priority": 39 + "priority": 68 }, "highway=cycleway": { - "zoom": 15, + "zoom": 14, "color": "#a0c8f0", - "priority": 38 + "priority": 68 }, "highway=steps": { "zoom": 16, "color": "#f0e8d8", - "priority": 37 - }, - "highway=crossing": { - "zoom": 16, - "color": "#ffff00", - "priority": 36 - }, - "highway=bus_stop": { - "zoom": 17, - "color": "#4682b4", - "priority": 35 - }, - "highway=street_lamp": { - "zoom": 17, - "color": "#ffffcc", - "priority": 27 + "priority": 68 }, "bridge=yes": { "zoom": 12, "color": "#c0c0c0", - "priority": 60 + "priority": 70 }, "man_made=bridge": { "zoom": 12, "color": "#c0c0c0", - "priority": 60 + "priority": 70 }, "aeroway=runway": { "zoom": 12, "color": "#e8e8e8", - "priority": 55 + "priority": 75 }, "aeroway=taxiway": { "zoom": 14, "color": "#e0e0e0", - "priority": 55 + "priority": 75 }, "aeroway=apron": { "zoom": 14, "color": "#d8d8d8", - "priority": 55 + "priority": 75 }, "building": { "zoom": 15, "color": "#dddddd", - "priority": 60 + "priority": 80 }, - "man_made=tower": { + "leisure=stadium": { + "zoom": 14, + "color": "#dddddd", + "priority": 80 + }, + "leisure=sports_centre": { "zoom": 15, - "color": "#cccccc", - "priority": 55 + "color": "#dddddd", + "priority": 80 + }, + "leisure=pitch": { + "zoom": 15, + "color": "#c8e6a9", + "priority": 80 }, "amenity=parking": { "zoom": 15, "color": "#e8e8e8", - "priority": 75 + "priority": 85 }, "amenity=hospital": { - "zoom": 15, + "zoom": 14, "color": "#ffcccc", - "priority": 55 + "priority": 85 }, "amenity=school": { "zoom": 15, "color": "#fff8dc", - "priority": 55 + "priority": 85 }, "amenity=university": { - "zoom": 15, - "color": "#fff8dc", - "priority": 55 - }, - "amenity=place_of_worship": { - "zoom": 15, + "zoom": 14, "color": "#fff8dc", - "priority": 55 + "priority": 85 }, "place=state": { - "zoom": 8, + "zoom": 6, "color": "#4a5c6a", - "priority": 25 + "priority": 90 }, "place=town": { - "zoom": 12, + "zoom": 10, "color": "#5a6c7a", - "priority": 24 + "priority": 91 }, "place=village": { "zoom": 12, "color": "#6a7c8a", - "priority": 23 + "priority": 92 }, "place=hamlet": { - "zoom": 15, + "zoom": 14, "color": "#7a8c9a", - "priority": 22 + "priority": 93 } -} \ No newline at end of file +} diff --git a/fgb_viewer.py b/fgb_viewer.py new file mode 100644 index 0000000..27faa30 --- /dev/null +++ b/fgb_viewer.py @@ -0,0 +1,715 @@ +#!/usr/bin/env python3 +""" +FlatGeobuf Viewer - ESP32 Map Simulator + +Simulates the ESP32 map rendering behavior using FlatGeobuf files. +Displays a 768x768 viewport (3x3 tiles of 256px) centered on given coordinates. + +Usage: + python fgb_viewer.py fgb_dir --lat 42.5063 --lon 1.5218 [--zoom 14] +""" + +import os +import sys +import math +import argparse +import logging +from typing import Dict, List, Tuple, Optional +import json + +try: + import pygame + PYGAME_AVAILABLE = True +except ImportError: + PYGAME_AVAILABLE = False + print("Warning: pygame not found. Install with: pip install pygame") + +try: + import geopandas as gpd + from shapely.geometry import box, Point, LineString, Polygon + from shapely.ops import transform + GEOPANDAS_AVAILABLE = True +except ImportError: + GEOPANDAS_AVAILABLE = False + print("Warning: geopandas not found. Install with: pip install geopandas pyogrio") + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +# Constants matching ESP32 implementation +TILE_SIZE = 256 +VIEWPORT_SIZE = 768 # 3x3 tiles +TOOLBAR_WIDTH = 200 +STATUSBAR_HEIGHT = 60 +WINDOW_WIDTH = VIEWPORT_SIZE + TOOLBAR_WIDTH +WINDOW_HEIGHT = VIEWPORT_SIZE + STATUSBAR_HEIGHT + +# Layer rendering order (lower = rendered first = behind) +LAYER_ORDER = [ + 'water', + 'landuse', + 'terrain', + 'railways', + 'roads', + 'infrastructure', + 'buildings', + 'amenities', + 'places' +] + + +def deg2num(lat_deg: float, lon_deg: float, zoom: int) -> Tuple[float, float]: + """Convert lat/lon to tile numbers (floating point for sub-tile precision).""" + lat_rad = math.radians(lat_deg) + n = 2.0 ** zoom + xtile = (lon_deg + 180.0) / 360.0 * n + ytile = (1.0 - math.log(math.tan(lat_rad) + 1.0 / math.cos(lat_rad)) / math.pi) / 2.0 * n + return xtile, ytile + + +def num2deg(xtile: float, ytile: float, zoom: int) -> Tuple[float, float]: + """Convert tile numbers to lat/lon.""" + n = 2.0 ** zoom + lon_deg = xtile / n * 360.0 - 180.0 + lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * ytile / n))) + lat_deg = math.degrees(lat_rad) + return lat_deg, lon_deg + + +def get_bbox_for_viewport(center_lat: float, center_lon: float, zoom: int) -> Tuple[float, float, float, float]: + """ + Calculate bounding box for 768x768 viewport centered on coordinates. + Returns (min_lon, min_lat, max_lon, max_lat) + """ + # Get tile coordinates for center + center_x, center_y = deg2num(center_lat, center_lon, zoom) + + # Calculate extent in tiles (768px = 3 tiles of 256px) + tiles_extent = VIEWPORT_SIZE / TILE_SIZE / 2.0 # 1.5 tiles from center + + # Get corner tile coordinates + min_tile_x = center_x - tiles_extent + max_tile_x = center_x + tiles_extent + min_tile_y = center_y - tiles_extent + max_tile_y = center_y + tiles_extent + + # Convert back to lat/lon + max_lat, min_lon = num2deg(min_tile_x, min_tile_y, zoom) + min_lat, max_lon = num2deg(max_tile_x, max_tile_y, zoom) + + return (min_lon, min_lat, max_lon, max_lat) + + +def latlon_to_pixel(lat: float, lon: float, bbox: Tuple[float, float, float, float]) -> Tuple[int, int]: + """Convert lat/lon to pixel coordinates within viewport.""" + min_lon, min_lat, max_lon, max_lat = bbox + + # Normalize to 0-1 + x_norm = (lon - min_lon) / (max_lon - min_lon) + y_norm = (max_lat - lat) / (max_lat - min_lat) # Y is inverted + + # Scale to viewport + px = int(x_norm * VIEWPORT_SIZE) + py = int(y_norm * VIEWPORT_SIZE) + + return px, py + + +def pixel_to_latlon(px: int, py: int, bbox: Tuple[float, float, float, float]) -> Tuple[float, float]: + """Convert pixel coordinates to lat/lon.""" + min_lon, min_lat, max_lon, max_lat = bbox + + x_norm = px / VIEWPORT_SIZE + y_norm = py / VIEWPORT_SIZE + + lon = min_lon + x_norm * (max_lon - min_lon) + lat = max_lat - y_norm * (max_lat - min_lat) + + return lat, lon + + +def hex_to_rgb(hex_color: str) -> Tuple[int, int, int]: + """Convert hex color to RGB tuple.""" + try: + if hex_color.startswith('#'): + hex_color = hex_color[1:] + return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) + except: + return (255, 255, 255) + + +def rgb332_to_rgb888(c: int) -> Tuple[int, int, int]: + """Convert RGB332 to RGB888.""" + r = (c & 0xE0) + g = (c & 0x1C) << 3 + b = (c & 0x03) << 6 + return (r, g, b) + + +def darken_color(rgb: Tuple[int, int, int], amount: float = 0.3) -> Tuple[int, int, int]: + """Darken RGB color by specified amount.""" + return tuple(max(0, int(v * (1 - amount))) for v in rgb) + + +class FGBViewer: + """FlatGeobuf map viewer simulating ESP32 behavior.""" + + def __init__(self, fgb_dir: str, config_file: str = None): + self.fgb_dir = fgb_dir + self.config = {} + self.layer_files: Dict[str, str] = {} # layer_name -> filepath (flat structure fallback) + self.zoom_dirs: Dict[int, str] = {} # zoom_level -> directory path (new structure) + + # Load config if provided + if config_file and os.path.exists(config_file): + with open(config_file, 'r') as f: + self.config = json.load(f) + logger.info(f"Loaded config from {config_file}") + + # Index FGB files (but don't load them) + self._index_layers() + + # Viewport state + self.center_lat = 0.0 + self.center_lon = 0.0 + self.zoom = 14 + self.bbox = None + + # Display options + self.background_color = (255, 255, 255) # White default + self.fill_polygons = True + self.show_tile_grid = False + + # Stats + self.last_query_stats = {} + + def _index_layers(self): + """Index available zoom levels with FlatGeobuf files.""" + if not os.path.isdir(self.fgb_dir): + logger.error(f"Directory not found: {self.fgb_dir}") + return + + # Check for zoom-level directory structure (new format) + self.zoom_dirs = {} + for item in os.listdir(self.fgb_dir): + item_path = os.path.join(self.fgb_dir, item) + if os.path.isdir(item_path) and item.isdigit(): + zoom_level = int(item) + self.zoom_dirs[zoom_level] = item_path + # Count FGB files in this zoom directory + fgb_count = len([f for f in os.listdir(item_path) if f.endswith('.fgb')]) + logger.info(f"Indexed zoom {zoom_level}: {fgb_count} layers") + + if self.zoom_dirs: + logger.info(f"Zoom levels available: {sorted(self.zoom_dirs.keys())}") + else: + # Fallback to flat structure (old format) + for filename in os.listdir(self.fgb_dir): + if filename.endswith('.fgb'): + layer_name = filename[:-4] + filepath = os.path.join(self.fgb_dir, filename) + self.layer_files[layer_name] = filepath + file_size = os.path.getsize(filepath) / (1024 * 1024) + logger.info(f"Indexed layer: {layer_name} ({file_size:.1f} MB)") + logger.info(f"Total layers indexed: {len(self.layer_files)} (flat structure)") + + def set_center(self, lat: float, lon: float, zoom: int = None): + """Set viewport center and optionally zoom.""" + self.center_lat = lat + self.center_lon = lon + if zoom is not None: + self.zoom = zoom + self.bbox = get_bbox_for_viewport(self.center_lat, self.center_lon, self.zoom) + logger.info(f"Viewport centered at ({lat:.6f}, {lon:.6f}) zoom {self.zoom}") + logger.info(f"Bounding box: {self.bbox}") + + def _get_zoom_dir(self) -> Optional[str]: + """Get the directory for the current zoom level.""" + if not self.zoom_dirs: + return None + + # Find the best matching zoom directory + available_zooms = sorted(self.zoom_dirs.keys()) + best_zoom = available_zooms[0] # Default to lowest + + for z in available_zooms: + if z <= self.zoom: + best_zoom = z + else: + break + + return self.zoom_dirs.get(best_zoom) + + def query_features(self, layer_name: str) -> Optional[gpd.GeoDataFrame]: + """Query features from layer within current bbox using R-Tree spatial index. + + This simulates ESP32 behavior - reads only features within bbox directly + from the FGB file using its built-in R-Tree index. + """ + if self.bbox is None: + return None + + # Determine file path based on structure + if self.zoom_dirs: + # New zoom-based structure + zoom_dir = self._get_zoom_dir() + if zoom_dir is None: + return None + filepath = os.path.join(zoom_dir, f"{layer_name}.fgb") + if not os.path.exists(filepath): + return None + else: + # Flat structure fallback + if layer_name not in self.layer_files: + return None + filepath = self.layer_files[layer_name] + + # Query directly from file using bbox (uses FGB R-Tree index) + import time + start = time.time() + + try: + # gpd.read_file with bbox parameter uses R-Tree index for FlatGeobuf + result = gpd.read_file(filepath, bbox=self.bbox) + + elapsed = (time.time() - start) * 1000 # ms + + # Calculate bytes read (memory usage of loaded data) + bytes_read = result.memory_usage(deep=True).sum() if len(result) > 0 else 0 + + self.last_query_stats[layer_name] = { + 'features': len(result), + 'time_ms': elapsed, + 'read_kb': bytes_read / 1024 + } + + return result + except Exception as e: + logger.debug(f"Spatial query error for {layer_name}: {e}") + return None + + def render_to_surface(self, surface: pygame.Surface): + """Render all visible features to pygame surface.""" + if self.bbox is None: + return + + # Clear with background color + surface.fill(self.background_color) + + # Render layers in order + for layer_name in LAYER_ORDER: + # Check if layer exists (either in zoom dirs or flat structure) + if self.zoom_dirs: + zoom_dir = self._get_zoom_dir() + if zoom_dir and os.path.exists(os.path.join(zoom_dir, f"{layer_name}.fgb")): + self._render_layer(surface, layer_name) + elif layer_name in self.layer_files: + self._render_layer(surface, layer_name) + + # Draw tile grid if enabled + if self.show_tile_grid: + self._draw_tile_grid(surface) + + def _render_layer(self, surface: pygame.Surface, layer_name: str): + """Render a single layer.""" + features = self.query_features(layer_name) + if features is None or len(features) == 0: + return + + # Sort by priority if available + if 'priority' in features.columns: + features = features.sort_values('priority') + + for idx, row in features.iterrows(): + self._render_feature(surface, row, layer_name) + + def _render_feature(self, surface: pygame.Surface, feature, layer_name: str): + """Render a single feature.""" + geom = feature.geometry + if geom is None or geom.is_empty: + return + + # Get color from properties or default + if 'color_rgb332' in feature.index and feature['color_rgb332']: + color = rgb332_to_rgb888(int(feature['color_rgb332'])) + else: + color = self._get_layer_default_color(layer_name) + + geom_type = geom.geom_type + + if geom_type == 'Point': + self._render_point(surface, geom, color) + elif geom_type == 'LineString': + self._render_linestring(surface, geom, color) + elif geom_type == 'Polygon': + self._render_polygon(surface, geom, color) + elif geom_type == 'MultiLineString': + for line in geom.geoms: + self._render_linestring(surface, line, color) + elif geom_type == 'MultiPolygon': + for poly in geom.geoms: + self._render_polygon(surface, poly, color) + + def _render_point(self, surface: pygame.Surface, point, color: Tuple[int, int, int]): + """Render a point feature.""" + px, py = latlon_to_pixel(point.y, point.x, self.bbox) + if 0 <= px < VIEWPORT_SIZE and 0 <= py < VIEWPORT_SIZE: + pygame.draw.circle(surface, color, (px, py), 3) + + def _render_linestring(self, surface: pygame.Surface, line, color: Tuple[int, int, int], width: int = 1): + """Render a linestring feature.""" + points = [] + for coord in line.coords: + px, py = latlon_to_pixel(coord[1], coord[0], self.bbox) + points.append((px, py)) + + if len(points) >= 2: + # Clip to viewport (simple) + pygame.draw.lines(surface, color, False, points, width) + + def _render_polygon(self, surface: pygame.Surface, polygon, color: Tuple[int, int, int]): + """Render a polygon feature.""" + if polygon.is_empty: + return + + # Get exterior ring + exterior = polygon.exterior + points = [] + for coord in exterior.coords: + px, py = latlon_to_pixel(coord[1], coord[0], self.bbox) + points.append((px, py)) + + if len(points) >= 3: + if self.fill_polygons: + pygame.draw.polygon(surface, color, points) + # Draw border + border_color = darken_color(color, 0.4) + pygame.draw.polygon(surface, border_color, points, 1) + else: + pygame.draw.polygon(surface, color, points, 1) + + def _get_layer_default_color(self, layer_name: str) -> Tuple[int, int, int]: + """Get default color for layer.""" + defaults = { + 'water': (136, 201, 250), # Light blue + 'landuse': (200, 230, 200), # Light green + 'roads': (255, 255, 255), # White + 'railways': (128, 128, 128), # Gray + 'buildings': (221, 221, 221), # Light gray + 'amenities': (255, 200, 200), # Light red + 'infrastructure': (200, 200, 200), + 'terrain': (139, 115, 85), # Brown + 'places': (100, 100, 100) + } + return defaults.get(layer_name, (200, 200, 200)) + + def _draw_tile_grid(self, surface: pygame.Surface): + """Draw tile grid overlay.""" + grid_color = (100, 100, 100) + + # Draw vertical lines + for i in range(4): + x = i * TILE_SIZE + pygame.draw.line(surface, grid_color, (x, 0), (x, VIEWPORT_SIZE), 1) + + # Draw horizontal lines + for i in range(4): + y = i * TILE_SIZE + pygame.draw.line(surface, grid_color, (0, y), (VIEWPORT_SIZE, y), 1) + + +def draw_button(surface, text, rect, bg_color, fg_color, border_color, font, pressed=False): + """Draw a button.""" + radius = 8 + pygame.draw.rect(surface, bg_color, rect, border_radius=radius) + pygame.draw.rect(surface, border_color, rect, 2, border_radius=radius) + if pressed: + pygame.draw.rect(surface, border_color, rect, 4, border_radius=radius) + + label = font.render(text, True, fg_color) + text_rect = label.get_rect(center=rect.center) + surface.blit(label, text_rect) + + +def format_coord(decimal: float, is_latitude: bool = True) -> str: + """Format decimal coordinate as degrees/minutes/seconds.""" + sign = "" + if is_latitude: + sign = "N" if decimal >= 0 else "S" + else: + sign = "E" if decimal >= 0 else "W" + + decimal = abs(decimal) + degrees = int(decimal) + minutes_full = (decimal - degrees) * 60 + minutes = int(minutes_full) + seconds = (minutes_full - minutes) * 60 + + return f"{degrees}°{minutes}'{seconds:.1f}\"{sign}" + + +def main(): + parser = argparse.ArgumentParser( + description='FlatGeobuf Map Viewer - ESP32 Simulator', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python fgb_viewer.py ./output --lat 42.5063 --lon 1.5218 + python fgb_viewer.py ./output --lat 42.5063 --lon 1.5218 --zoom 16 + python fgb_viewer.py ./output --lat 41.3851 --lon 2.1734 --zoom 14 --config features.json + +Controls: + Arrow keys / Mouse drag: Pan map + Mouse wheel / [ ] keys: Zoom in/out + B: Toggle background color + F: Toggle polygon fill + G: Toggle tile grid + Q / ESC: Quit + """ + ) + + parser.add_argument('fgb_dir', help='Directory containing FGB files') + parser.add_argument('--lat', type=float, required=True, help='Center latitude') + parser.add_argument('--lon', type=float, required=True, help='Center longitude') + parser.add_argument('--zoom', type=int, default=14, help='Zoom level (default: 14)') + parser.add_argument('--config', help='Features configuration JSON file') + + args = parser.parse_args() + + if not PYGAME_AVAILABLE: + logger.error("pygame is required. Install with: pip install pygame") + sys.exit(1) + + if not GEOPANDAS_AVAILABLE: + logger.error("geopandas is required. Install with: pip install geopandas pyogrio") + sys.exit(1) + + if not os.path.isdir(args.fgb_dir): + logger.error(f"Directory not found: {args.fgb_dir}") + sys.exit(1) + + # Initialize viewer + viewer = FGBViewer(args.fgb_dir, args.config) + viewer.set_center(args.lat, args.lon, args.zoom) + + # Initialize pygame + pygame.init() + screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT)) + pygame.display.set_caption(f"FGB Viewer - {os.path.basename(args.fgb_dir)}") + + font = pygame.font.SysFont(None, 18) + font_small = pygame.font.SysFont(None, 14) + clock = pygame.time.Clock() + + # Create viewport surface + viewport_surface = pygame.Surface((VIEWPORT_SIZE, VIEWPORT_SIZE)) + + # Button definitions + button_margin = 10 + button_height = 35 + button_width = TOOLBAR_WIDTH - 20 + + bg_button_rect = pygame.Rect(VIEWPORT_SIZE + 10, button_margin, button_width, button_height) + fill_button_rect = pygame.Rect(VIEWPORT_SIZE + 10, button_margin * 2 + button_height, button_width, button_height) + grid_button_rect = pygame.Rect(VIEWPORT_SIZE + 10, button_margin * 3 + button_height * 2, button_width, button_height) + + # State + dragging = False + drag_start = None + drag_center_start = None + need_redraw = True + running = True + + # Pan speed (degrees per key press) + pan_speed_base = 0.01 + + while running: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + + elif event.type == pygame.KEYDOWN: + # Calculate pan speed based on zoom + pan_speed = pan_speed_base / (2 ** (viewer.zoom - 10)) + + if event.key == pygame.K_LEFT: + viewer.set_center(viewer.center_lat, viewer.center_lon - pan_speed) + need_redraw = True + elif event.key == pygame.K_RIGHT: + viewer.set_center(viewer.center_lat, viewer.center_lon + pan_speed) + need_redraw = True + elif event.key == pygame.K_UP: + viewer.set_center(viewer.center_lat + pan_speed, viewer.center_lon) + need_redraw = True + elif event.key == pygame.K_DOWN: + viewer.set_center(viewer.center_lat - pan_speed, viewer.center_lon) + need_redraw = True + elif event.key == pygame.K_LEFTBRACKET: + if viewer.zoom > 1: + viewer.set_center(viewer.center_lat, viewer.center_lon, viewer.zoom - 1) + need_redraw = True + elif event.key == pygame.K_RIGHTBRACKET: + if viewer.zoom < 19: + viewer.set_center(viewer.center_lat, viewer.center_lon, viewer.zoom + 1) + need_redraw = True + elif event.key == pygame.K_b: + if viewer.background_color == (255, 255, 255): + viewer.background_color = (0, 0, 0) + else: + viewer.background_color = (255, 255, 255) + need_redraw = True + elif event.key == pygame.K_f: + viewer.fill_polygons = not viewer.fill_polygons + need_redraw = True + elif event.key == pygame.K_g: + viewer.show_tile_grid = not viewer.show_tile_grid + need_redraw = True + elif event.key in (pygame.K_q, pygame.K_ESCAPE): + running = False + + elif event.type == pygame.MOUSEBUTTONDOWN: + mx, my = event.pos + + if event.button == 1: # Left click + if bg_button_rect.collidepoint(mx, my): + if viewer.background_color == (255, 255, 255): + viewer.background_color = (0, 0, 0) + else: + viewer.background_color = (255, 255, 255) + need_redraw = True + elif fill_button_rect.collidepoint(mx, my): + viewer.fill_polygons = not viewer.fill_polygons + need_redraw = True + elif grid_button_rect.collidepoint(mx, my): + viewer.show_tile_grid = not viewer.show_tile_grid + need_redraw = True + elif mx < VIEWPORT_SIZE and my < VIEWPORT_SIZE: + dragging = True + drag_start = (mx, my) + drag_center_start = (viewer.center_lat, viewer.center_lon) + + elif event.button == 4: # Scroll up - zoom in + if viewer.zoom < 19: + viewer.set_center(viewer.center_lat, viewer.center_lon, viewer.zoom + 1) + need_redraw = True + elif event.button == 5: # Scroll down - zoom out + if viewer.zoom > 1: + viewer.set_center(viewer.center_lat, viewer.center_lon, viewer.zoom - 1) + need_redraw = True + + elif event.type == pygame.MOUSEBUTTONUP: + if event.button == 1: + dragging = False + + elif event.type == pygame.MOUSEMOTION: + if dragging and drag_start and drag_center_start: + dx = event.pos[0] - drag_start[0] + dy = event.pos[1] - drag_start[1] + + # Convert pixel delta to lat/lon delta + if viewer.bbox: + min_lon, min_lat, max_lon, max_lat = viewer.bbox + lon_per_pixel = (max_lon - min_lon) / VIEWPORT_SIZE + lat_per_pixel = (max_lat - min_lat) / VIEWPORT_SIZE + + new_lon = drag_center_start[1] - dx * lon_per_pixel + new_lat = drag_center_start[0] + dy * lat_per_pixel + + viewer.set_center(new_lat, new_lon) + need_redraw = True + + # Render + if need_redraw: + # Render map to viewport + viewer.render_to_surface(viewport_surface) + + # Clear screen + screen.fill((50, 50, 50)) + + # Draw viewport + screen.blit(viewport_surface, (0, 0)) + + # Draw toolbar background + pygame.draw.rect(screen, (30, 30, 30), (VIEWPORT_SIZE, 0, TOOLBAR_WIDTH, VIEWPORT_SIZE)) + pygame.draw.line(screen, (100, 100, 100), (VIEWPORT_SIZE, 0), (VIEWPORT_SIZE, VIEWPORT_SIZE)) + + # Draw buttons + button_bg = (50, 50, 50) + button_fg = (255, 255, 255) + button_border = (100, 100, 100) + + bg_text = "Background: White" if viewer.background_color == (255, 255, 255) else "Background: Black" + draw_button(screen, bg_text, bg_button_rect, button_bg, button_fg, button_border, font_small) + + fill_text = "Fill: ON" if viewer.fill_polygons else "Fill: OFF" + draw_button(screen, fill_text, fill_button_rect, button_bg, button_fg, button_border, font_small) + + grid_text = "Grid: ON" if viewer.show_tile_grid else "Grid: OFF" + draw_button(screen, grid_text, grid_button_rect, button_bg, button_fg, button_border, font_small) + + # Draw info in toolbar + info_y = button_margin * 4 + button_height * 3 + 20 + info_color = (200, 200, 200) + + # Coordinates + lat_text = f"Lat: {viewer.center_lat:.6f}" + lon_text = f"Lon: {viewer.center_lon:.6f}" + zoom_text = f"Zoom: {viewer.zoom}" + + screen.blit(font_small.render(lat_text, True, info_color), (VIEWPORT_SIZE + 10, info_y)) + screen.blit(font_small.render(lon_text, True, info_color), (VIEWPORT_SIZE + 10, info_y + 18)) + screen.blit(font_small.render(zoom_text, True, info_color), (VIEWPORT_SIZE + 10, info_y + 36)) + + # DMS format + lat_dms = format_coord(viewer.center_lat, True) + lon_dms = format_coord(viewer.center_lon, False) + screen.blit(font_small.render(lat_dms, True, info_color), (VIEWPORT_SIZE + 10, info_y + 60)) + screen.blit(font_small.render(lon_dms, True, info_color), (VIEWPORT_SIZE + 10, info_y + 78)) + + # Layer query stats (R-Tree queries) + layer_y = info_y + 110 + screen.blit(font_small.render("R-Tree Queries:", True, info_color), (VIEWPORT_SIZE + 10, layer_y)) + total_features = 0 + total_time = 0 + total_size_kb = 0 + for i, layer_name in enumerate(LAYER_ORDER): + if layer_name in viewer.last_query_stats: + stats = viewer.last_query_stats[layer_name] + read_kb = stats.get('read_kb', 0) + text = f" {layer_name}: {stats['features']} ({stats['time_ms']:.0f}ms) [{read_kb:.0f}KB]" + total_features += stats['features'] + total_time += stats['time_ms'] + total_size_kb += read_kb + screen.blit(font_small.render(text, True, (150, 150, 150)), (VIEWPORT_SIZE + 10, layer_y + 18 + i * 14)) + + # Total stats + summary_y = layer_y + 18 + len(LAYER_ORDER) * 14 + 10 + screen.blit(font_small.render(f"Total: {total_features} features", True, (100, 200, 100)), (VIEWPORT_SIZE + 10, summary_y)) + screen.blit(font_small.render(f"Query time: {total_time:.0f}ms", True, (100, 200, 100)), (VIEWPORT_SIZE + 10, summary_y + 16)) + screen.blit(font_small.render(f"Data read: {total_size_kb:.0f}KB", True, (100, 200, 100)), (VIEWPORT_SIZE + 10, summary_y + 32)) + + # Draw status bar + pygame.draw.rect(screen, (30, 30, 30), (0, VIEWPORT_SIZE, WINDOW_WIDTH, STATUSBAR_HEIGHT)) + pygame.draw.line(screen, (100, 100, 100), (0, VIEWPORT_SIZE), (WINDOW_WIDTH, VIEWPORT_SIZE)) + + # Status text + if viewer.bbox: + min_lon, min_lat, max_lon, max_lat = viewer.bbox + bbox_text = f"BBox: ({min_lat:.4f}, {min_lon:.4f}) to ({max_lat:.4f}, {max_lon:.4f})" + screen.blit(font_small.render(bbox_text, True, (200, 200, 200)), (10, VIEWPORT_SIZE + 10)) + + # Resolution info + meters_per_pixel = 156543.03392 * math.cos(math.radians(viewer.center_lat)) / (2 ** viewer.zoom) + res_text = f"Resolution: {meters_per_pixel:.2f} m/px | Viewport: {VIEWPORT_SIZE}x{VIEWPORT_SIZE}px" + screen.blit(font_small.render(res_text, True, (200, 200, 200)), (10, VIEWPORT_SIZE + 30)) + + pygame.display.flip() + need_redraw = False + + clock.tick(30) + + pygame.quit() + + +if __name__ == '__main__': + main() diff --git a/generate_tiles.sh b/generate_tiles.sh deleted file mode 100755 index 16aa8c0..0000000 --- a/generate_tiles.sh +++ /dev/null @@ -1,299 +0,0 @@ -#!/bin/bash - -# Script to generate tiles from PBF using Docker -# Converts PBF -> GOL -> Tiles automatically -# Optimized version with minimal dependencies - -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Banner -echo -e "${BLUE}" -echo "╔════════════════════════════════════════╗" -echo "║ PBF → GOL → Tiles Generator ║" -echo "╚════════════════════════════════════════╝" -echo -e "${NC}" - -# Initialize variables -FORCE_CLEAN=false -ZOOM_ARGS="" - -# Parse all arguments -while [[ $# -gt 0 ]]; do - case $1 in - --clean-docker) - FORCE_CLEAN=true - shift - ;; - --zoom) - ZOOM_ARGS="$ZOOM_ARGS --zoom $2" - shift 2 - ;; - --max-file-size) - ZOOM_ARGS="$ZOOM_ARGS --max-file-size $2" - shift 2 - ;; - *) - # Positional arguments - if [ -z "$INPUT_PBF" ]; then - INPUT_PBF=$1 - elif [ -z "$OUTPUT_DIR" ]; then - OUTPUT_DIR=$1 - elif [ -z "$CONFIG_FILE" ]; then - CONFIG_FILE=$1 - else - echo "Unknown argument: $1" - exit 1 - fi - shift - ;; - esac -done - -# Check required arguments -if [ -z "$INPUT_PBF" ] || [ -z "$OUTPUT_DIR" ] || [ -z "$CONFIG_FILE" ]; then - echo "Usage: $0 [options]" - echo "" - echo "Options:" - echo " --clean-docker Force rebuild of Docker image" - echo " --zoom N-M Zoom range (e.g., 6-17)" - echo " --max-file-size KB Maximum tile file size in KB" - echo "" - echo "Example:" - echo " $0 map.osm.pbf tiles features.json --zoom 6-17" - echo " $0 map.osm.pbf tiles features.json --zoom 12 --max-file-size 512" - exit 1 -fi - -# Validate input -if [ ! -f "$INPUT_PBF" ]; then - echo -e "${RED}✗ PBF file not found: $INPUT_PBF${NC}" - exit 1 -fi - -if [ ! -f "$CONFIG_FILE" ]; then - echo -e "${RED}✗ Config file not found: $CONFIG_FILE${NC}" - exit 1 -fi - -# Get absolute paths -INPUT_ABS=$(realpath "$INPUT_PBF") -CONFIG_ABS=$(realpath "$CONFIG_FILE") -OUTPUT_ABS=$(realpath -m "$OUTPUT_DIR") - -INPUT_NAME=$(basename "$INPUT_PBF") -CONFIG_NAME=$(basename "$CONFIG_FILE") -OUTPUT_NAME=$(basename "$OUTPUT_DIR") - -# Create output directory -mkdir -p "$OUTPUT_ABS" -echo -e "${GREEN}✓ Output directory ready:${NC} $OUTPUT_ABS" -echo "" - -echo -e "${GREEN}Input:${NC} $INPUT_NAME" -echo -e "${GREEN}Output:${NC} $OUTPUT_NAME" -echo -e "${GREEN}Config:${NC} $CONFIG_NAME" -echo "" -echo -e "${BLUE}Mounting paths:${NC}" -echo " PBF: $INPUT_ABS -> /input/$INPUT_NAME" -echo " Config: $CONFIG_ABS -> /config/$CONFIG_NAME" -echo " Output: $OUTPUT_ABS -> /output" -echo "" - -# Check Docker -if ! command -v docker &> /dev/null; then - echo -e "${RED}✗ Docker is not installed${NC}" - exit 1 -fi - -DOCKER_CMD="docker" -if [ "$EUID" -ne 0 ]; then - DOCKER_CMD="sudo docker" -fi - -echo -e "${YELLOW}Preparing Docker environment...${NC}" - -# Create Dockerfile -cat > /tmp/Dockerfile.tiles.light << 'EOF' -FROM python:3.11-slim - -# Install only essential dependencies -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - wget ca-certificates \ - && rm -rf /var/lib/apt/lists/* >/dev/null 2>&1 - -RUN pip install --no-cache-dir --upgrade pip >/dev/null 2>&1 - -# Install minimal Python dependencies -RUN pip install --no-cache-dir \ - ijson==3.2.3 \ - tqdm==4.66.1 \ - psutil==5.9.8 >/dev/null 2>&1 - -WORKDIR /work - -# Display installed packages for verification -RUN pip list && \ - echo "Docker image ready for tile generation" -EOF - -# Handle --clean-docker option -if [ "$FORCE_CLEAN" = true ]; then - echo -e "${YELLOW}--clean-docker flag: rebuilding Docker image${NC}" - IMAGE_EXISTS=$($DOCKER_CMD images tile-generator:light -q 2>/dev/null || true) - if [ -n "$IMAGE_EXISTS" ]; then - echo " Removing existing image..." - $DOCKER_CMD rmi tile-generator:light > /dev/null 2>&1 || true - echo " ✓ Existing image removed" - fi - echo "" -fi - -# Check if Docker image exists -IMAGE_EXISTS=$($DOCKER_CMD images tile-generator:light -q 2>/dev/null || true) -if [ -z "$IMAGE_EXISTS" ]; then - echo -e "${YELLOW}Building Docker image${NC}" - echo "" - - #$DOCKER_CMD build --progress=plain -t tile-generator:light -f /tmp/Dockerfile.tiles.light . >/dev/null 2>&1 - #$DOCKER_CMD build --progress=tty -t tile-generator:light -f /tmp/Dockerfile.tiles.light . - #DOCKER_BUILDKIT=1 \ - #$DOCKER_CMD build --progress=plain -t tile-generator:light -f /tmp/Dockerfile.tiles.light . - DOCKER_BUILDKIT=1 \ - $DOCKER_CMD build -t tile-generator:light -f /tmp/Dockerfile.tiles.light . - - - - echo "" - echo -e "${GREEN}✓ Docker image created${NC}" - - # Show image size - IMAGE_SIZE=$($DOCKER_CMD images tile-generator:light --format "{{.Size}}" 2>/dev/null || echo "unknown") - echo " Image size: $IMAGE_SIZE" -else - echo -e "${GREEN}✓ Docker image already exists${NC}" - IMAGE_SIZE=$($DOCKER_CMD images tile-generator:light --format "{{.Size}}" 2>/dev/null || echo "unknown") - echo " Image size: $IMAGE_SIZE" -fi - -rm -f /tmp/Dockerfile.tiles.light - -echo "" - -# Check if gol is installed locally -GOL_PATH="" -if command -v gol &> /dev/null; then - GOL_PATH=$(which gol) - echo -e "${GREEN}Using local gol:${NC} $GOL_PATH" -else - echo -e "${RED}✗ gol CLI not found${NC}" - echo "Install from: https://www.geodesk.com/download/" - exit 1 -fi - -echo "" -echo "🚀 Starting tile generation ..." -echo "" - -# Run in Docker -$DOCKER_CMD run --rm \ - -v "$INPUT_ABS:/input/$INPUT_NAME:ro" \ - -v "$CONFIG_ABS:/config/$CONFIG_NAME:ro" \ - -v "$GOL_PATH:/gol:ro" \ - -v "$OUTPUT_ABS:/output" \ - -v "$(pwd)/tile_generator.py:/work/tile_generator.py:ro" \ - tile-generator:light bash -c " - set -e - - echo '✓ Python environment ready' - echo '' - - # Step 1: Convert PBF to GOL - echo '🔄 Step 1/2: Converting PBF to GOL...' - echo \" PBF: /input/$INPUT_NAME\" - echo \" GOL: input.gol\" - echo '' - - # Build GOL with progress monitoring - /gol build input.gol /input/$INPUT_NAME >/dev/null 2>&1 & - GOL_PID=\$! - - # Monitor progress - while kill -0 \$GOL_PID 2>/dev/null; do - if [ -f input.gol ]; then - SIZE=\$(du -h input.gol 2>/dev/null | cut -f1 || echo \"0B\") - echo -ne \"\\r GOL size: \${SIZE} (building...)\" - fi - sleep 1 - done - - echo '' - wait \$GOL_PID - - if [ ! -f input.gol ]; then - echo '✗ GOL file not created' - exit 1 - fi - - GOL_SIZE=\$(du -h input.gol | cut -f1) - echo '✓ GOL created successfully' - echo \" Size: \$GOL_SIZE\" - echo '' - - # Step 2: Generate tiles - echo '🗺️ Step 2/2: Generating tiles ...' - echo '' - - python3 /work/tile_generator.py \ - input.gol \ - /output \ - /config/$CONFIG_NAME \ - $ZOOM_ARGS - - echo '' - - # Show results - if [ -d \"/output\" ]; then - echo '✓ Tiles generation completed' - fi - " - -# Docker cleanup logic -if [ "$FORCE_CLEAN" = true ]; then - echo "" - echo -e "${YELLOW}Cleaning up Docker resources...${NC}" - - if [ -n "$IMAGE_EXISTS" ]; then - echo " Removing Docker image: tile-generator:light" - $DOCKER_CMD rmi tile-generator:light > /dev/null 2>&1 || true - fi - - # Clean dangling images - DANGLING=$($DOCKER_CMD images -f "dangling=true" -q 2>/dev/null | wc -l) - if [ "$DANGLING" -gt 0 ]; then - echo " Removing dangling images..." - $DOCKER_CMD rmi $($DOCKER_CMD images -f "dangling=true" -q) > /dev/null 2>&1 || true - fi - - echo -e "${GREEN}✓ Docker cleanup completed${NC}" -else - echo "" - echo -e "${GREEN}✓ Docker image kept for reuse${NC}" - echo " Use --clean-docker to rebuild" - IMAGE_SIZE=$($DOCKER_CMD images tile-generator:light --format "{{.Size}}" 2>/dev/null || echo "unknown") - echo " Image size: $IMAGE_SIZE " -fi - -echo "" -echo -e "${GREEN}✓ Tiles generated in: $OUTPUT_DIR${NC}" -echo "" -echo -e "${BLUE}Summary:${NC}" -echo " • Docker image kept for reuse" -echo " • Use --clean-docker to rebuild" diff --git a/pbf_to_fgb.py b/pbf_to_fgb.py new file mode 100644 index 0000000..07bd06d --- /dev/null +++ b/pbf_to_fgb.py @@ -0,0 +1,614 @@ +#!/usr/bin/env python3 +""" +PBF to FlatGeobuf Converter + +Converts OpenStreetMap .pbf files to FlatGeobuf (.fgb) format with spatial indexing. +Filters features based on features.json configuration and organizes output by layer. + +Usage: + python pbf_to_fgb.py input.pbf output_dir features.json [--zoom 6-17] +""" + +import os +import sys +import json +import argparse +import logging +from typing import Dict, List, Optional, Tuple, Set +from collections import defaultdict +import time + +try: + import osmium + from osmium import osm +except ImportError: + print("Error: osmium not found. Install with: pip install osmium") + sys.exit(1) + +try: + import fiona + from fiona.crs import from_epsg + FIONA_AVAILABLE = True +except ImportError: + FIONA_AVAILABLE = False + +try: + import geopandas as gpd + from shapely.geometry import LineString, Polygon, MultiPolygon, Point, mapping + from shapely.ops import transform + from shapely import simplify + GEOPANDAS_AVAILABLE = True +except ImportError: + GEOPANDAS_AVAILABLE = False + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +# Layer definitions based on feature types +LAYER_MAPPING = { + # Water features + 'water': [ + 'natural=water', 'natural=coastline', 'natural=bay', + 'waterway=riverbank', 'waterway=dock', 'waterway=boatyard', + 'waterway=river', 'waterway=stream', 'waterway=canal', + 'natural=spring', 'natural=wetland' + ], + # Land use and natural areas + 'landuse': [ + 'natural=beach', 'natural=sand', 'natural=wood', + 'landuse=forest', 'natural=forest', 'natural=scrub', + 'natural=heath', 'natural=grassland', 'landuse=meadow', + 'landuse=grass', 'landuse=orchard', 'landuse=vineyard', + 'landuse=farmland', 'landuse=park', 'leisure=park', + 'leisure=nature_reserve', 'leisure=garden', 'leisure=pitch', + 'leisure=golf_course', 'landuse=residential', 'place=suburb', + 'landuse=commercial', 'landuse=retail', 'landuse=industrial', + 'landuse=construction', 'landuse=cemetery', 'landuse=allotments', + 'leisure=stadium', 'leisure=sports_centre', 'leisure=playground' + ], + # Roads and paths + 'roads': [ + 'highway=motorway', 'highway=motorway_link', + 'highway=trunk', 'highway=trunk_link', + 'highway=primary', 'highway=primary_link', + 'highway=secondary', 'highway=secondary_link', + 'highway=tertiary', 'highway=tertiary_link', + 'highway=residential', 'highway=living_street', + 'highway=unclassified', 'highway=service', + 'highway=pedestrian', 'highway=track', + 'highway=path', 'highway=footway', + 'highway=cycleway', 'highway=steps', + 'highway=crossing', 'highway=bus_stop' + ], + # Railways + 'railways': [ + 'railway=rail', 'railway=subway', 'railway=tram' + ], + # Buildings + 'buildings': [ + 'building', 'man_made=tower' + ], + # Amenities + 'amenities': [ + 'amenity=parking', 'amenity=hospital', + 'amenity=school', 'amenity=university', + 'amenity=place_of_worship' + ], + # Bridges and aeroways + 'infrastructure': [ + 'bridge=yes', 'man_made=bridge', + 'aeroway=runway', 'aeroway=taxiway', 'aeroway=apron', + 'tunnel=yes' + ], + # Natural terrain features + 'terrain': [ + 'natural=peak', 'natural=ridge', + 'natural=volcano', 'natural=cliff', + 'natural=tree_row', 'natural=tree' + ], + # Places + 'places': [ + 'place=state', 'place=town', + 'place=village', 'place=hamlet' + ] +} + + +def get_layer_for_tags(tags: Dict[str, str]) -> Optional[str]: + """Determine which layer a feature belongs to based on its tags.""" + for layer_name, feature_keys in LAYER_MAPPING.items(): + for feature_key in feature_keys: + if '=' in feature_key: + key, value = feature_key.split('=', 1) + if key in tags and tags[key] == value: + return layer_name + else: + # Key-only match (e.g., 'building') + if feature_key in tags: + return layer_name + return None + + +def get_zoom_for_tags(tags: Dict[str, str], config: Dict) -> int: + """Get minimum zoom level for feature based on config.""" + for key, value in tags.items(): + feature_key = f"{key}={value}" + if feature_key in config and isinstance(config[feature_key], dict): + return config[feature_key].get('zoom', 6) + if key in config and isinstance(config[key], dict): + return config[key].get('zoom', 6) + return 6 + + +def get_color_for_tags(tags: Dict[str, str], config: Dict) -> str: + """Get color for feature based on config.""" + for key, value in tags.items(): + feature_key = f"{key}={value}" + if feature_key in config and isinstance(config[feature_key], dict): + return config[feature_key].get('color', '#FFFFFF') + if key in config and isinstance(config[key], dict): + return config[key].get('color', '#FFFFFF') + return '#FFFFFF' + + +def get_priority_for_tags(tags: Dict[str, str], config: Dict) -> int: + """Get rendering priority for feature based on config.""" + for key, value in tags.items(): + feature_key = f"{key}={value}" + if feature_key in config and isinstance(config[feature_key], dict): + return config[feature_key].get('priority', 50) + if key in config and isinstance(config[key], dict): + return config[key].get('priority', 50) + return 50 + + +def hex_to_rgb332(hex_color: str) -> int: + """Convert hex color to RGB332 format.""" + try: + if not hex_color or not hex_color.startswith("#"): + return 0xFF + r = int(hex_color[1:3], 16) + g = int(hex_color[3:5], 16) + b = int(hex_color[5:7], 16) + return ((r & 0xE0) | ((g & 0xE0) >> 3) | (b >> 6)) + except: + return 0xFF + + +def get_simplification_tolerance(zoom: int) -> float: + """Get Douglas-Peucker simplification tolerance based on zoom level.""" + # Higher zoom = less simplification (more detail) + tolerances = { + 6: 0.001, + 7: 0.0008, + 8: 0.0005, + 9: 0.0003, + 10: 0.0002, + 11: 0.00015, + 12: 0.0001, + 13: 0.00008, + 14: 0.00005, + 15: 0.00003, + 16: 0.000015, + 17: 0.000012, + 18: 0.00001, + 19: 0.000008, + } + return tolerances.get(zoom, 0.000005) + + +class OSMHandler(osmium.SimpleHandler): + """Handler for processing OSM data from PBF files.""" + + def __init__(self, config: Dict, zoom_range: Tuple[int, int]): + super().__init__() + self.config = config + self.min_zoom, self.max_zoom = zoom_range + + # Storage for features by layer + self.features_by_layer: Dict[str, List[Dict]] = defaultdict(list) + + # Statistics + self.stats = { + 'ways_processed': 0, + 'relations_processed': 0, + 'features_extracted': 0, + 'features_filtered': 0 + } + + # Progress tracking + self.start_time = time.time() + self.last_progress_time = time.time() + self.progress_interval = 5 # Log every 5 seconds + + # Build set of interesting tags from config + self.interesting_tags = self._build_interesting_tags() + + def _build_interesting_tags(self) -> Set[str]: + """Build set of tag keys we're interested in.""" + tags = set() + for key in self.config: + if isinstance(self.config[key], dict): + if '=' in key: + tag_key = key.split('=')[0] + tags.add(tag_key) + else: + tags.add(key) + return tags + + def _log_progress(self): + """Log progress periodically.""" + current_time = time.time() + if current_time - self.last_progress_time >= self.progress_interval: + self.last_progress_time = current_time + ways = self.stats['ways_processed'] + extracted = self.stats['features_extracted'] + elapsed = current_time - self.start_time + mins, secs = divmod(int(elapsed), 60) + # Print in-place progress + print(f"\r Progress: {ways:,} ways, {extracted:,} features [{mins}m {secs:02d}s]", end='', flush=True) + + def _has_interesting_tags(self, tags) -> bool: + """Check if tags contain any interesting keys.""" + for tag in tags: + if tag.k in self.interesting_tags: + return True + return False + + def _tags_to_dict(self, tags) -> Dict[str, str]: + """Convert osmium tags to dictionary.""" + return {tag.k: tag.v for tag in tags} + + def _is_feature_in_config(self, tags: Dict[str, str]) -> bool: + """Check if feature matches any entry in config.""" + for key, value in tags.items(): + feature_key = f"{key}={value}" + if feature_key in self.config and isinstance(self.config[feature_key], dict): + return True + if key in self.config and isinstance(self.config[key], dict): + return True + return False + + def way(self, w): + """Process way - extract roads, buildings, etc.""" + self.stats['ways_processed'] += 1 + self._log_progress() + + if not self._has_interesting_tags(w.tags): + self.stats['features_filtered'] += 1 + return + + tags = self._tags_to_dict(w.tags) + + if not self._is_feature_in_config(tags): + self.stats['features_filtered'] += 1 + return + + # Get layer and zoom + layer = get_layer_for_tags(tags) + if layer is None: + self.stats['features_filtered'] += 1 + return + + min_zoom = get_zoom_for_tags(tags, self.config) + if min_zoom > self.max_zoom: + self.stats['features_filtered'] += 1 + return + + # Build geometry from node locations (provided by osmium with locations=True) + coords = [] + for node in w.nodes: + if node.location.valid(): + coords.append((node.location.lon, node.location.lat)) + + if len(coords) < 2: + self.stats['features_filtered'] += 1 + return + + # Determine geometry type + is_closed = len(coords) >= 4 and coords[0] == coords[-1] + is_area = is_closed and ( + 'building' in tags or + 'landuse' in tags or + 'natural' in tags and tags.get('natural') in ['water', 'wood', 'forest', 'beach', 'sand', 'wetland', 'grassland', 'scrub', 'heath'] or + 'leisure' in tags and tags.get('leisure') in ['park', 'garden', 'pitch', 'golf_course', 'nature_reserve', 'playground', 'sports_centre', 'stadium'] or + 'amenity' in tags and tags.get('amenity') in ['parking'] or + 'waterway' in tags and tags.get('waterway') in ['riverbank', 'dock', 'boatyard'] or + tags.get('area') == 'yes' + ) + + # Get attributes + color = get_color_for_tags(tags, self.config) + priority = get_priority_for_tags(tags, self.config) + color_rgb332 = hex_to_rgb332(color) + + # Create feature + feature = { + 'geometry_type': 'Polygon' if is_area else 'LineString', + 'coordinates': coords, + 'properties': { + 'osm_id': w.id, + 'min_zoom': min_zoom, + 'color_rgb332': color_rgb332, + 'priority': priority, + 'layer': layer, + # Store primary tag for reference + 'feature_type': self._get_primary_tag(tags) + } + } + + self.features_by_layer[layer].append(feature) + self.stats['features_extracted'] += 1 + + def _get_primary_tag(self, tags: Dict[str, str]) -> str: + """Get the primary identifying tag for a feature.""" + priority_keys = ['highway', 'railway', 'waterway', 'building', 'natural', 'landuse', 'leisure', 'amenity', 'aeroway'] + for key in priority_keys: + if key in tags: + return f"{key}={tags[key]}" + return 'unknown' + + def relation(self, r): + """Process relation - for multipolygons, etc.""" + self.stats['relations_processed'] += 1 + # TODO: Handle multipolygon relations for complex areas + pass + + +def write_fgb_layer(features: List[Dict], output_path: str, layer_name: str): + """Write features to FlatGeobuf file using GeoPandas.""" + if not GEOPANDAS_AVAILABLE: + logger.error("GeoPandas not available. Install with: pip install geopandas") + return False + + if not features: + logger.warning(f"No features to write for layer {layer_name}") + return False + + logger.debug(f"Writing {len(features)} features to {output_path}") + + # Convert features to GeoDataFrame + geometries = [] + properties_list = [] + + for feature in features: + coords = feature['coordinates'] + geom_type = feature['geometry_type'] + props = feature['properties'] + + try: + if geom_type == 'LineString': + if len(coords) >= 2: + geom = LineString(coords) + geometries.append(geom) + properties_list.append(props) + elif geom_type == 'Polygon': + if len(coords) >= 4: + geom = Polygon(coords) + if geom.is_valid: + geometries.append(geom) + properties_list.append(props) + else: + # Try to fix invalid polygon + geom = geom.buffer(0) + if geom.is_valid and not geom.is_empty: + geometries.append(geom) + properties_list.append(props) + except Exception as e: + logger.debug(f"Error creating geometry: {e}") + continue + + if not geometries: + logger.warning(f"No valid geometries for layer {layer_name}") + return False + + # Create GeoDataFrame + gdf = gpd.GeoDataFrame(properties_list, geometry=geometries, crs="EPSG:4326") + + # Write to FlatGeobuf with spatial index + try: + # Suppress pyogrio/fiona logging during write + import warnings + pyogrio_logger = logging.getLogger('pyogrio') + fiona_logger = logging.getLogger('fiona') + old_pyogrio_level = pyogrio_logger.level + old_fiona_level = fiona_logger.level + pyogrio_logger.setLevel(logging.ERROR) + fiona_logger.setLevel(logging.ERROR) + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + gdf.to_file(output_path, driver="FlatGeobuf", spatial_index=True) + + # Restore logging levels + pyogrio_logger.setLevel(old_pyogrio_level) + fiona_logger.setLevel(old_fiona_level) + + logger.debug(f"Successfully wrote {len(gdf)} features to {output_path}") + return True + except Exception as e: + logger.error(f"Error writing FlatGeobuf: {e}") + return False + + +def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, + zoom_range: Tuple[int, int] = (6, 17)): + """Main conversion function.""" + + # Load configuration + logger.info(f"Loading configuration from {config_file}") + with open(config_file, 'r') as f: + config = json.load(f) + + # Create output directory + os.makedirs(output_dir, exist_ok=True) + + # Process PBF file + file_size_mb = os.path.getsize(input_pbf) / (1024 * 1024) + logger.info(f"Processing PBF file: {input_pbf} ({file_size_mb:.1f} MB)") + logger.info(f"Zoom range: {zoom_range[0]}-{zoom_range[1]}") + + start_time = time.time() + + handler = OSMHandler(config, zoom_range) + + # First pass: cache nodes and extract features + logger.info("Processing OSM data (this may take a while for large files)...") + handler.apply_file(input_pbf, locations=True, idx='flex_mem') + print() # New line after progress + + # Log statistics + elapsed = time.time() - start_time + logger.info(f"Processing completed in {elapsed:.2f}s") + logger.info(f"Statistics:") + logger.info(f" Ways processed: {handler.stats['ways_processed']:,}") + logger.info(f" Features extracted: {handler.stats['features_extracted']:,}") + logger.info(f" Features filtered: {handler.stats['features_filtered']:,}") + + # Write FlatGeobuf files by zoom level and layer + logger.info("Writing FlatGeobuf files by zoom level...") + + files_written = [] + total_files = 0 + zoom_sizes = {} # zoom -> size in bytes + + # Calculate total expected files for progress bar + num_zooms = zoom_range[1] - zoom_range[0] + 1 + num_layers = len(handler.features_by_layer) + total_expected = num_zooms * num_layers + current_file = 0 + bar_width = 40 + + for zoom in range(zoom_range[0], zoom_range[1] + 1): + # Create zoom directory + zoom_dir = os.path.join(output_dir, str(zoom)) + os.makedirs(zoom_dir, exist_ok=True) + + zoom_files = 0 + zoom_features = 0 + zoom_size = 0 + + for layer_name, features in handler.features_by_layer.items(): + current_file += 1 + + # Update progress bar + progress = current_file / total_expected + filled = int(bar_width * progress) + bar = '█' * filled + '░' * (bar_width - filled) + print(f"\r [{bar}] {current_file}/{total_expected} (zoom {zoom}: {layer_name})", end='', flush=True) + + # Filter features for this zoom level (min_zoom <= current zoom) + zoom_features_list = [f for f in features if f['properties']['min_zoom'] <= zoom] + + if zoom_features_list: + output_path = os.path.join(zoom_dir, f"{layer_name}.fgb") + if write_fgb_layer(zoom_features_list, output_path, layer_name): + files_written.append(output_path) + file_size = os.path.getsize(output_path) + zoom_files += 1 + zoom_features += len(zoom_features_list) + zoom_size += file_size + total_files += 1 + + zoom_sizes[zoom] = zoom_size + + print() # New line after progress bar + + # Calculate total time in hh:mm:ss + total_time = time.time() - start_time + hours, remainder = divmod(int(total_time), 3600) + minutes, seconds = divmod(remainder, 60) + if hours > 0: + time_str = f"{hours}h {minutes:02d}m {seconds:02d}s" + elif minutes > 0: + time_str = f"{minutes}m {seconds:02d}s" + else: + time_str = f"{total_time:.2f}s" + + # Calculate total size + total_size = sum(zoom_sizes.values()) + + # Summary + logger.info("=" * 50) + logger.info("Conversion Summary") + logger.info("=" * 50) + logger.info(f"Input: {input_pbf}") + logger.info(f"Output directory: {output_dir}") + logger.info(f"Zoom levels: {zoom_range[0]}-{zoom_range[1]}") + logger.info(f"Total files written: {total_files}") + logger.info(f"Total time: {time_str}") + logger.info("") + logger.info("Size by zoom level:") + for zoom in sorted(zoom_sizes.keys()): + size_mb = zoom_sizes[zoom] / (1024 * 1024) + logger.info(f" Zoom {zoom:2d}: {size_mb:8.2f} MB") + logger.info(f" {'─' * 20}") + total_mb = total_size / (1024 * 1024) + logger.info(f" Total: {total_mb:8.2f} MB") + logger.info("=" * 50) + + return files_written + + +def main(): + parser = argparse.ArgumentParser( + description='Convert OpenStreetMap PBF to FlatGeobuf format', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python pbf_to_fgb.py andorra.pbf ./output features.json + python pbf_to_fgb.py spain.pbf ./output features.json --zoom 10-17 + +Output structure: + output_dir/ + ├── 6/ + │ ├── water.fgb + │ ├── roads.fgb (motorway, trunk, primary only) + │ └── ... + ├── 10/ + │ ├── water.fgb + │ ├── roads.fgb (+ secondary, tertiary) + │ └── railways.fgb + ├── 14/ + │ ├── roads.fgb (+ residential, service) + │ ├── buildings.fgb + │ └── ... + └── 17/ + └── ... (all features) + """ + ) + + parser.add_argument('input_pbf', help='Input PBF file path') + parser.add_argument('output_dir', help='Output directory for FGB files') + parser.add_argument('config_file', help='Features configuration JSON file') + parser.add_argument('--zoom', default='6-17', + help='Zoom level range (e.g., "6-17" or "12")') + + args = parser.parse_args() + + # Validate input file + if not os.path.exists(args.input_pbf): + logger.error(f"Input file not found: {args.input_pbf}") + sys.exit(1) + + if not os.path.exists(args.config_file): + logger.error(f"Config file not found: {args.config_file}") + sys.exit(1) + + # Check dependencies + if not GEOPANDAS_AVAILABLE: + logger.error("GeoPandas is required. Install with: pip install geopandas pyogrio") + sys.exit(1) + + # Parse zoom range + if '-' in args.zoom: + min_zoom, max_zoom = map(int, args.zoom.split('-')) + else: + min_zoom = max_zoom = int(args.zoom) + + # Run conversion + convert_pbf_to_fgb(args.input_pbf, args.output_dir, args.config_file, (min_zoom, max_zoom)) + + +if __name__ == '__main__': + main() diff --git a/tile_generator.py b/tile_generator.py deleted file mode 100644 index 40103cf..0000000 --- a/tile_generator.py +++ /dev/null @@ -1,1323 +0,0 @@ -#!/usr/bin/env python3 - -import math -import struct -import json -import os -import gc -import subprocess -import ijson -import time -import re -from decimal import Decimal -import argparse -import sys -import logging -from typing import List, Dict, Tuple, Optional -from concurrent.futures import ThreadPoolExecutor, as_completed - -logging.basicConfig(level=logging.INFO, format='%(message)s') -logger = logging.getLogger(__name__) - -# Constants -TILE_SIZE = 256 -UINT16_TILE_SIZE = 65536 - -DRAW_COMMANDS = { - 'POLYLINE': 2, - 'STROKE_POLYGON': 3, - 'SET_COLOR': 0x80, - 'SET_COLOR_INDEX': 0x81, -} - -# Global color palette -GLOBAL_COLOR_PALETTE = {} -GLOBAL_INDEX_TO_RGB332 = {} - -# --- CARTO-LIKE STYLE DEFINITIONS (Pixels per Zoom) --- -# OPTIMIZED FOR SMALL SCREENS: Thinner lines at low zoom, reduced max width at high zoom. -CARTO_STYLES = { - # Major roads: Visible but not overwhelming at low zoom - 'motorway': {6: 1, 10: 2, 13: 3, 15: 6, 17: 10, 18: 16}, - 'trunk': {6: 1, 10: 2, 13: 3, 15: 5, 17: 9, 18: 14}, - 'primary': {8: 1, 11: 2, 13: 3, 15: 5, 17: 9, 18: 14}, - - # Connecting roads: Keep thin until zoomed in - 'secondary': {11: 1, 13: 2, 15: 4, 17: 8, 18: 12}, - 'tertiary': {12: 1, 14: 2, 16: 5, 18: 10}, - - # Minor roads: Hairline (1px) until very close - 'residential': {13: 0.5, 15: 1, 16: 3, 18: 8}, - 'unclassified': {13: 0.5, 15: 1, 16: 3, 18: 8}, - 'service': {15: 1, 17: 3, 18: 6}, - 'track': {14: 0.5, 16: 1, 18: 4}, - - # Paths: Very subtle - 'footway': {15: 0.5, 17: 1, 19: 3}, - 'path': {15: 0.5, 17: 1, 19: 3}, - 'cycleway': {15: 0.5, 17: 1, 19: 3}, - - # Transport - 'railway': {10: 1, 14: 2, 16: 3, 18: 5}, - 'subway': {12: 1, 14: 2, 16: 3}, - - # Waterways: Drastically reduced to prevent blocking features - 'river': {8: 1, 12: 2, 14: 4, 16: 7, 18: 10}, - 'canal': {10: 1, 13: 2, 15: 4, 17: 6}, - 'stream': {14: 1, 16: 2, 18: 3}, - 'drain': {15: 1, 18: 2}, - - # Aero: Reduced so they don't cover the airport terminal buildings - 'runway': {10: 1, 12: 3, 14: 8, 16: 16, 18: 24}, - 'taxiway': {13: 1, 15: 4, 17: 8}, -} - - -def interpolate_width(styles: Dict[int, int], zoom: int) -> float: - """Linearly interpolate width between defined zoom levels.""" - zooms = sorted(styles.keys()) - - if not zooms: - return 1.0 - - if zoom <= zooms[0]: - return styles[zooms[0]] - if zoom >= zooms[-1]: - return styles[zooms[-1]] - - # Find the interval [z1, z2] containing zoom - for i in range(len(zooms) - 1): - z1, z2 = zooms[i], zooms[i+1] - if z1 <= zoom <= z2: - w1, w2 = styles[z1], styles[z2] - # Linear interpolation - return w1 + (w2 - w1) * (zoom - z1) / (z2 - z1) - - return 1.0 - - -def get_available_memory_mb() -> int: - """Detect available system memory in MB.""" - try: - if os.name == 'posix': - with open('/proc/meminfo', 'r') as f: - for line in f: - if 'MemAvailable' in line: - return int(line.split()[1]) // 1024 - elif os.name == 'nt': - import ctypes - kernel32 = ctypes.windll.kernel32 - kernel32.GetPhysicallyInstalledSystemMemory.argtypes = [ctypes.POINTER(ctypes.c_ulonglong)] - memory = ctypes.c_ulonglong() - if kernel32.GetPhysicallyInstalledSystemMemory(ctypes.byref(memory)): - return memory.value // (1024 * 1024) - except: - pass - return 4096 - - -def get_optimal_workers(cpu_count: int, available_memory_mb: int) -> int: - """Calculate optimal number of worker threads based on system resources.""" - memory_workers = max(1, min(available_memory_mb // 1024, 6)) - cpu_workers = max(1, cpu_count - 1) - - if available_memory_mb <= 8192: - max_workers = min(3, memory_workers, cpu_workers) - else: - max_workers = min(6, memory_workers, cpu_workers) - - logger.info(f"System: {cpu_count} CPU cores, {available_memory_mb}MB RAM -> Using {max_workers} workers") - return max_workers - - -def get_adaptive_batch_size(zoom: int, base_batch_size: int, available_memory_mb: int) -> int: - """Calculate adaptive batch size based on zoom level and available memory.""" - memory_factor = min(2.0, available_memory_mb / 4096) - - if zoom < 10: - adjusted_size = max(2000, int(base_batch_size * 0.6 * memory_factor)) - elif zoom < 13: - adjusted_size = max(1000, int(base_batch_size * 0.4 * memory_factor)) - elif zoom < 15: - adjusted_size = max(500, int(base_batch_size * 0.3 * memory_factor)) - else: - adjusted_size = max(250, int(base_batch_size * 0.2 * memory_factor)) - - return adjusted_size - - -def precompute_global_color_palette(config: Dict) -> int: - """Build global color palette from configuration.""" - global GLOBAL_COLOR_PALETTE, GLOBAL_INDEX_TO_RGB332 - logger.info("Building color palette...") - - unique_colors = set() - for feature_config in config.values(): - if isinstance(feature_config, dict) and 'color' in feature_config: - hex_color = feature_config['color'] - if hex_color and isinstance(hex_color, str) and hex_color.startswith("#"): - unique_colors.add(hex_color) - - sorted_colors = sorted(list(unique_colors)) - GLOBAL_COLOR_PALETTE.clear() - GLOBAL_INDEX_TO_RGB332.clear() - - for index, hex_color in enumerate(sorted_colors): - rgb332_value = hex_to_rgb332(hex_color) - GLOBAL_COLOR_PALETTE[hex_color] = index - GLOBAL_INDEX_TO_RGB332[index] = rgb332_value - - logger.info(f"Palette: {len(unique_colors)} colors") - return len(unique_colors) - - -def write_palette_bin(output_dir: str): - """Write color palette to binary file.""" - os.makedirs(output_dir, exist_ok=True) - palette_path = os.path.join(output_dir, "palette.bin") - num_colors = len(GLOBAL_INDEX_TO_RGB332) - - palette_data = bytearray() - palette_data.extend(struct.pack('> 5) & 0x07 - # Expansión estándar de 3 a 8 bits - r = (red_3bit << 5) | (red_3bit << 2) | (red_3bit >> 1) - - # Extrae el valor de 3 bits de G (bits 4, 3, 2) y lo expande a 8 bits (G888) - green_3bit = (rgb332 >> 2) & 0x07 - # Expansión estándar de 3 a 8 bits - g = (green_3bit << 5) | (green_3bit << 2) | (green_3bit >> 1) - - # Extrae el valor de 2 bits de B (bits 1, 0) y lo expande a 8 bits (B888) - blue_2bit = rgb332 & 0x03 - # Expansión estándar de 2 a 8 bits - b = (blue_2bit << 6) | (blue_2bit << 4) | (blue_2bit << 2) | blue_2bit - - palette_data.extend([r, g, b]) - else: - palette_data.extend([255, 255, 255]) - - with open(palette_path, 'wb') as f: - f.write(bytes(palette_data)) - - logger.info("Palette written") - - -def hex_to_color_index(hex_color: str) -> Optional[int]: - """Convert hex color to palette index.""" - return GLOBAL_COLOR_PALETTE.get(hex_color) - - -def hex_to_rgb332(hex_color: str) -> int: - """Convert hex color (RGB888) to RGB332 format (8-bit index).""" - try: - if not hex_color or not hex_color.startswith("#"): - return 0xFF - r = int(hex_color[1:3], 16) - g = int(hex_color[3:5], 16) - b = int(hex_color[5:7], 16) - - # RRR (3 bits), GGG (3 bits), YY (2 bits para B) - return ((r & 0xE0) | ((g & 0xE0) >> 3) | (b >> 6)) - except: - return 0xFF - - -def parse_width_value(width_str: str) -> Optional[float]: - """ - Parse width value from OSM tags, handling various formats. - Returns width in meters, or None if parsing fails. - """ - if not width_str: - return None - - width_str = str(width_str).lower().strip().replace(',', '.') - - pattern = r'([0-9]+\.?[0-9]*)\s*(m|meter|meters|metre|metres|ft|feet|foot|\'|"|in|inch|inches)?' - match = re.match(pattern, width_str) - - if not match: - return None - - try: - value = float(match.group(1)) - unit = match.group(2) if match.group(2) else 'm' - - if unit in ['ft', 'feet', 'foot', '\'']: - value = value * 0.3048 - elif unit in ['"', 'in', 'inch', 'inches']: - value = value * 0.0254 - - return value - except (ValueError, AttributeError): - return None - - -def meters_to_pixels_physical(meters: float, zoom: int, lat: float) -> float: - """Convertir metros físicos a píxeles sin escalado agresivo (factor de escala eliminado).""" - base_resolution = 156543.03392 - lat_rad = math.radians(lat) - resolution = base_resolution * math.cos(lat_rad) / (2 ** zoom) - pixels = meters / resolution - return pixels - - -def apply_width_constraints(pixels: float, feature_type: str, zoom: int) -> int: - """Apply STRICT min/max pixel constraints based on feature type for small screens.""" - - # Constraints (min_pixels, max_pixels) - # MAX values reduced by ~40-50% from original standard - constraints = { - 'motorway': (1, 18), 'trunk': (1, 16), 'primary': (1, 16), - 'secondary': (1, 12), 'tertiary': (1, 10), - 'residential': (0.5, 10), 'unclassified': (0.5, 10), - 'service': (0.5, 6), 'track': (0.5, 5), - 'footway': (0.5, 3), 'path': (0.5, 3), 'cycleway': (0.5, 3), - - 'railway': (1, 6), - - # Waterways clamped tightly - 'river': (1, 10), - 'waterway': (0.5, 8), - - 'runway': (1, 24), 'taxiway': (1, 10), - 'pipeline': (0.5, 4), 'power': (0.5, 2), - } - - min_width, max_width = constraints.get(feature_type, (0.5, 8)) - - # Low zoom overrides to ensure clean rendering - if zoom <= 12: - max_width = min(max_width, 4) # Nothing should be thicker than 4px at zoom 12 - - if zoom <= 10: - max_width = min(max_width, 3) # Nothing thicker than 3px at zoom 10 - - clamped = max(min_width, min(max_width, pixels)) - - # Round to nearest integer but ensure at least 1px if calculation > 0.5 - if clamped < 1.0 and clamped >= 0.5: - return 1 - return max(1, int(round(clamped))) - - -def get_feature_category(tags: Dict) -> str: - """Determine the feature category for styling lookups.""" - if 'highway' in tags: return tags['highway'] - elif 'railway' in tags: - r = tags['railway'] - return r if r in ['rail', 'subway', 'tram', 'light_rail'] else 'railway' - elif 'waterway' in tags: return tags['waterway'] - elif 'aeroway' in tags: return tags['aeroway'] - elif 'power' in tags: return 'power' - elif 'man_made' in tags and tags['man_made'] in ['pipeline', 'embankment']: return 'pipeline' - return 'default' - - -def get_line_width_from_tags(tags: Dict, zoom: int, coordinates, geom_type: str) -> int: - """ - Calculate line width in pixels based on: - 1. Explicit OSM tags (width, maxwidth) -> Physical Calculation - 2. If no tags -> CartoCSS-style Zoom-based Defaults - """ - if not tags: - return 1 - - # Polygons usually don't have stroke width unless specified - if geom_type in ["Polygon", "MultiPolygon"]: - if not any(k in tags for k in ['width', 'maxwidth', 'est_width']): - return 1 - - feature_category = get_feature_category(tags) - calculated_pixels = 0.0 - width_found = False - - # 1. Try explicit physical width tags - width_tags = ['width', 'maxwidth', 'est_width', 'diameter', 'gauge'] - for width_tag in width_tags: - if width_tag in tags: - width_meters = parse_width_value(tags[width_tag]) - if width_meters is not None and width_meters > 0: - # Calculate average latitude for projection - avg_lat = 0.0 - try: - if geom_type in ["LineString", "MultiLineString"]: - coords = coordinates if geom_type == "LineString" else (coordinates[0] if coordinates else []) - valid_coords = [c for c in coords if len(c) >= 2] - if valid_coords: - avg_lat = sum(c[1] for c in valid_coords) / len(valid_coords) - elif geom_type in ["Polygon", "MultiPolygon"]: - # Use first point of exterior ring - c = coordinates[0] if geom_type == "Polygon" else coordinates[0][0] - if c and len(c) > 0: avg_lat = c[0][1] - except: - avg_lat = 0.0 - - calculated_pixels = meters_to_pixels_physical(width_meters, zoom, avg_lat) - width_found = True - break - - # 2. If no explicit tag, use Carto-style defaults (Pixels based on Zoom) - if not width_found: - if feature_category in CARTO_STYLES: - calculated_pixels = interpolate_width(CARTO_STYLES[feature_category], zoom) - else: - # Fallback for unknown features - calculated_pixels = 1.5 if zoom > 14 else 1.0 - - # 3. Apply Clamping (Min/Max constraints) regardless of source - # This ensures a 500m wide river doesn't cover the screen (clamped tightly) - final_width = apply_width_constraints(calculated_pixels, feature_category, zoom) - - return final_width - - -def interpolate_curve_points(points: List, max_segment_distance: float = 0.00005) -> List: - """Interpolate additional points to smooth curves - works on geographic coordinates.""" - if len(points) < 2: - return points - - result = [points[0]] - - for i in range(len(points) - 1): - p1 = points[i] - p2 = points[i + 1] - - dx = p2[0] - p1[0] - dy = p2[1] - p1[1] - distance = math.sqrt(dx**2 + dy**2) - - # Calculate number of segments needed - if distance > max_segment_distance: - num_segments = int(math.ceil(distance / max_segment_distance)) - # Add intermediate points - for j in range(1, num_segments): - t = j / num_segments - result.append([ - p1[0] + dx * t, - p1[1] + dy * t - ]) - - result.append(p2) - - return result - - -def smooth_geometry_coords(coordinates, geom_type: str, zoom: int, tags: Dict = None): - """Smooth geometry by adding intermediate points - applies to geographic coordinates.""" - # Iniciar suavizado a partir de zoom 16 - if zoom < 16: - return coordinates - - # Determine maximum segment distance based on zoom - is_rbt = tags and tags.get('junction') == 'roundabout' - - if zoom >= 19: - max_dist = 0.000008 if is_rbt else 0.00001 - elif zoom >= 18: - max_dist = 0.00001 if is_rbt else 0.000015 - elif zoom >= 17: - max_dist = 0.000015 if is_rbt else 0.00002 - else: # zoom 16 - max_dist = 0.00002 if is_rbt else 0.00003 - - def smooth_linestring(coords): - if len(coords) < 2: - return coords - return interpolate_curve_points(coords, max_dist) - - def smooth_ring(ring): - if not ring or len(ring) < 3: - return ring - # For closed rings, remove last point, interpolate, then re-close - is_closed = ring[0] == ring[-1] - work_ring = ring[:-1] if is_closed else ring - smoothed = interpolate_curve_points(work_ring, max_dist) - if is_closed and smoothed: - smoothed.append(smoothed[0]) - return smoothed - - if geom_type == "LineString": - return smooth_linestring(coordinates) - elif geom_type == "Polygon": - if not coordinates or not coordinates[0]: - return coordinates - return [smooth_ring(coordinates[0])] - elif geom_type == "MultiLineString": - return [smooth_linestring(line) for line in coordinates] - elif geom_type == "MultiPolygon": - result = [] - for poly in coordinates: - if poly and poly[0]: - result.append([smooth_ring(poly[0])]) - return result - - return coordinates - - -def geojson_bounds(coordinates, geom_type: str) -> Tuple[float, float, float, float]: - """Calculate bounding box for geometry coordinates.""" - def flatten_coords(coords): - if not coords: - return [] - if isinstance(coords, (int, float, Decimal)): - return [float(coords)] - if len(coords) == 2: - try: - lon = float(coords[0]) if not isinstance(coords[0], (int, float)) else coords[0] - lat = float(coords[1]) if not isinstance(coords[1], (int, float)) else coords[1] - if isinstance(lon, (int, float)) and isinstance(lat, (int, float)): - return [[lon, lat]] - except (TypeError, ValueError, IndexError): - pass - result = [] - for item in coords: - result.extend(flatten_coords(item)) - return result - - flat = flatten_coords(coordinates) - if not flat: - return (0, 0, 0, 0) - - lons = [c[0] for c in flat] - lats = [c[1] for c in flat] - return (min(lons), min(lats), max(lons), max(lats)) - - -def coords_intersect_tile(coordinates, geom_type: str, tile_bbox: Tuple[float, float, float, float]) -> bool: - """Check if geometry intersects with tile bounding box.""" - minx, miny, maxx, maxy = geojson_bounds(coordinates, geom_type) - tile_minx, tile_miny, tile_maxx, tile_maxy = tile_bbox - - if maxx < tile_minx or minx > tile_maxx or maxy < tile_miny or miny > tile_maxy: - return False - return True - - -def normalize_coordinates_robust(coordinates): - """Normalize coordinates to ensure all values are floats.""" - if isinstance(coordinates, (int, float, Decimal)): - return float(coordinates) - - if isinstance(coordinates, (list, tuple)): - if len(coordinates) == 2: - first, second = coordinates[0], coordinates[1] - if (isinstance(first, (int, float, Decimal)) and - isinstance(second, (int, float, Decimal))): - return [float(first), float(second)] - - return [normalize_coordinates_robust(item) for item in coordinates] - - return coordinates - - -def clip_polygon_sutherland_hodgman(vertices, minx, miny, maxx, maxy): - """Clip polygon to bounding box using Sutherland-Hodgman algorithm.""" - if not vertices or len(vertices) < 3: - return [] - - def inside(p, edge): - if edge == 'left': - return p[0] >= minx - elif edge == 'right': - return p[0] <= maxx - elif edge == 'bottom': - return p[1] >= miny - elif edge == 'top': - return p[1] <= maxy - return False - - def compute_intersection(p1, p2, edge): - x1, y1 = float(p1[0]), float(p1[1]) - x2, y2 = float(p2[0]), float(p2[1]) - - # Evitar división por cero - if edge == 'left': - x = minx - y = y1 + (y2 - y1) * (minx - x1) / (x2 - x1) if abs(x2 - x1) > 1e-9 else y1 - elif edge == 'right': - x = maxx - y = y1 + (y2 - y1) * (maxx - x1) / (x2 - x1) if abs(x2 - x1) > 1e-9 else y1 - elif edge == 'bottom': - y = miny - x = x1 + (x2 - x1) * (miny - y1) / (y2 - y1) if abs(y2 - y1) > 1e-9 else x1 - elif edge == 'top': - y = maxy - x = x1 + (x2 - x1) * (maxy - y1) / (y2 - y1) if abs(y2 - y1) > 1e-9 else x1 - return [x, y] - - output = list(vertices) - for edge in ['left', 'right', 'bottom', 'top']: - if not output: - break - input_list = output - output = [] - if not input_list: - continue - prev_vertex = input_list[-1] - for curr_vertex in input_list: - curr_inside = inside(curr_vertex, edge) - prev_inside = inside(prev_vertex, edge) - if curr_inside: - if not prev_inside: - intersection = compute_intersection(prev_vertex, curr_vertex, edge) - output.append(intersection) - output.append(curr_vertex) - elif prev_inside: - intersection = compute_intersection(prev_vertex, curr_vertex, edge) - output.append(intersection) - prev_vertex = curr_vertex - - if output and len(output) >= 3: - if output[0] != output[-1]: - output.append(output[0]) - - return output if len(output) >= 4 else [] - - -def clip_line_to_bbox(p1, p2, bbox): - """Clip line segment to bounding box using Cohen-Sutherland algorithm.""" - minx, miny, maxx, maxy = bbox - x1, y1 = float(p1[0]), float(p1[1]) - x2, y2 = float(p2[0]), float(p2[1]) - - def compute_outcode(x, y): - code = 0 - if x < minx: - code |= 1 - if x > maxx: - code |= 2 - if y < miny: - code |= 4 - if y > maxy: - code |= 8 - return code - - outcode1 = compute_outcode(x1, y1) - outcode2 = compute_outcode(x2, y2) - - while True: - if outcode1 == 0 and outcode2 == 0: - return ([x1, y1], [x2, y2]) - if (outcode1 & outcode2) != 0: - return None - - outcode = outcode1 if outcode1 != 0 else outcode2 - - # Evitar división por cero - if outcode & 8: - x = x1 + (x2 - x1) * (maxy - y1) / (y2 - y1) if abs(y2 - y1) > 1e-9 else x1 - y = maxy - elif outcode & 4: - x = x1 + (x2 - x1) * (miny - y1) / (y2 - y1) if abs(y2 - y1) > 1e-9 else x1 - y = miny - elif outcode & 2: - y = y1 + (y2 - y1) * (maxx - x1) / (x2 - x1) if abs(x2 - x1) > 1e-9 else y1 - x = maxx - elif outcode & 1: - y = y1 + (y2 - y1) * (minx - x1) / (x2 - x1) if abs(x2 - x1) > 1e-9 else y1 - x = minx - - if outcode == outcode1: - x1, y1 = x, y - outcode1 = compute_outcode(x1, y1) - else: - x2, y2 = x, y - outcode2 = compute_outcode(x2, y2) - - -def clip_coordinates_to_tile(coordinates, geom_type: str, tile_bbox: Tuple[float, float, float, float]): - """Clip geometry coordinates to tile bounding box.""" - minx, miny, maxx, maxy = tile_bbox - - def clip_linestring(coords): - if not coords or len(coords) < 2: - return [] - clipped = [] - # TOLERANCIA AUMENTADA A 1e-5 para corregir los problemas de continuidad. - TOLERANCE = 1e-5 - for i in range(len(coords) - 1): - result = clip_line_to_bbox(coords[i], coords[i + 1], tile_bbox) - if result: - p1, p2 = result - - # REVISIÓN DE CLIPPING: Usar distancia para verificar duplicados en el borde del tile. - is_duplicate = False - if clipped: - # Compara la distancia entre el último punto añadido (p2 del segmento anterior) y p1 del segmento actual - dist = math.hypot(clipped[-1][0] - p1[0], clipped[-1][1] - p1[1]) - if dist < TOLERANCE: - is_duplicate = True - - if not is_duplicate: - clipped.append(p1) - - clipped.append(p2) - return clipped if len(clipped) >= 2 else [] - - if geom_type == "Point": - lon, lat = float(coordinates[0]), float(coordinates[1]) - if minx <= lon <= maxx and miny <= lat <= maxy: - return coordinates - return None - elif geom_type == "LineString": - clipped = clip_linestring(coordinates) - return clipped if clipped else None - elif geom_type == "Polygon": - if not coordinates or not coordinates[0]: - return None - exterior = coordinates[0] - clipped_exterior = clip_polygon_sutherland_hodgman(exterior, minx, miny, maxx, maxy) - if not clipped_exterior or len(clipped_exterior) < 4: - return None - return [clipped_exterior] - elif geom_type == "MultiLineString": - clipped_lines = [] - for line in coordinates: - clipped = clip_linestring(line) - if clipped: - clipped_lines.append(clipped) - return clipped_lines if clipped_lines else None - elif geom_type == "MultiPolygon": - clipped_polys = [] - for poly in coordinates: - if poly and poly[0]: - clipped_exterior = clip_polygon_sutherland_hodgman(poly[0], minx, miny, maxx, maxy) - if clipped_exterior and len(clipped_exterior) >= 4: - clipped_polys.append([clipped_exterior]) - return clipped_polys if clipped_polys else None - return None - - -def deg2num(lat_deg: float, lon_deg: float, zoom: int) -> Tuple[int, int]: - """Convert lat/lon to tile numbers.""" - lat_rad = math.radians(lat_deg) - n = 2.0 ** zoom - xtile = int((lon_deg + 180.0) / 360.0 * n) - ytile = int((1.0 - math.log(math.tan(lat_rad) + 1 / math.cos(lat_rad)) / math.pi) / 2.0 * n) - return xtile, ytile - - -def tile_bbox(tile_x: int, tile_y: int, zoom: int) -> Tuple[float, float, float, float]: - """Calculate tile bounding box in lat/lon coordinates.""" - n = 2.0 ** zoom - lon_min = tile_x / n * 360.0 - 180.0 - lon_max = (tile_x + 1) / n * 360.0 - 180.0 - lat_max = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * tile_y / n)))) - lat_min = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (tile_y + 1) / n)))) - return (lon_min, lat_min, lon_max, lat_max) - - -def coords_to_pixels(coordinates, zoom: int, tile_x: int, tile_y: int) -> List[Tuple[int, int]]: - """Convert lat/lon coordinates to pixel coordinates within tile.""" - n = 2.0 ** zoom - pixels = [] - - for coord in coordinates: - lon = float(coord[0]) if not isinstance(coord[0], (int, float)) else coord[0] - lat = float(coord[1]) if not isinstance(coord[1], (int, float)) else coord[1] - - x = ((lon + 180.0) / 360.0 * n - tile_x) * TILE_SIZE - lat_rad = math.radians(lat) - y = ((1.0 - math.log(math.tan(lat_rad) + 1 / math.cos(lat_rad)) / math.pi) / 2.0 * n - tile_y) * TILE_SIZE - - # Scale float pixel (0-256) to uint16 pixel (0-65536) - x_uint16 = int((x * (UINT16_TILE_SIZE - 1)) / (TILE_SIZE - 1)) - y_uint16 = int((y * (UINT16_TILE_SIZE - 1)) / (TILE_SIZE - 1)) - - # Clamp to valid uint16 range but preserve precision - x_uint16 = max(-UINT16_TILE_SIZE, min(UINT16_TILE_SIZE * 2, x_uint16)) - y_uint16 = max(-UINT16_TILE_SIZE, min(UINT16_TILE_SIZE * 2, y_uint16)) - - pixels.append((x_uint16, y_uint16)) - - return pixels - - -def pack_varint(n): - """Pack integer as variable-length integer.""" - out = bytearray() - while True: - byte = n & 0x7F - n >>= 7 - if n: - out.append(byte | 0x80) - else: - out.append(byte) - break - return out - - -def pack_zigzag(n): - """Pack signed integer using zigzag encoding.""" - return pack_varint((n << 1) ^ (n >> 31)) - - -def get_layer_priority(tags: Dict) -> int: - """Calculate rendering priority based on OSM layer tag and feature type.""" - if 'layer' in tags: - try: - layer_val = int(tags['layer']) - return layer_val * 1000 - except (ValueError, TypeError): - pass - - # Water features at bottom - if 'natural' in tags and tags['natural'] in ['water', 'coastline', 'bay']: - return 100 - if 'waterway' in tags and tags.get('waterway') in ['riverbank', 'dock', 'boatyard']: - return 100 - - # Land use and natural areas - if 'landuse' in tags: - return 200 - if 'natural' in tags and tags['natural'] in ['wood', 'forest', 'scrub', 'heath', 'grassland', 'beach', 'sand', 'wetland']: - return 200 - if 'leisure' in tags and tags['leisure'] in ['park', 'nature_reserve', 'garden']: - return 200 - - # Waterways - if 'waterway' in tags and tags['waterway'] in ['river', 'stream', 'canal']: - return 300 - - # Natural features - if 'natural' in tags and tags['natural'] in ['peak', 'ridge', 'volcano', 'cliff']: - return 400 - - # Underground features - if 'tunnel' in tags and tags['tunnel'] == 'yes': - return 500 - - # Railways - if 'railway' in tags: - return 600 - - # Minor roads - if 'highway' in tags and tags['highway'] in ['path', 'footway', 'cycleway', 'steps', 'pedestrian', 'track']: - return 700 - - # Tertiary roads - if 'highway' in tags and tags['highway'] in ['tertiary', 'tertiary_link']: - return 800 - - # Secondary roads - if 'highway' in tags and tags['highway'] in ['secondary', 'secondary_link']: - return 900 - - # Primary roads - if 'highway' in tags and tags['highway'] in ['primary', 'primary_link']: - return 1000 - - # Trunk roads - if 'highway' in tags and tags['highway'] in ['trunk', 'trunk_link']: - return 1100 - - # Motorways - if 'highway' in tags and tags['highway'] in ['motorway', 'motorway_link']: - return 1200 - - # Above-ground structures - if 'bridge' in tags and tags['bridge'] == 'yes': - return 1300 - if 'aeroway' in tags: - return 1300 - - # Buildings - if 'building' in tags: - return 1400 - - # Points of interest - if 'amenity' in tags: - return 1500 - - return 200 - - -def geometry_to_commands(geom_type: str, coordinates, color: int, zoom: int, tile_x: int, tile_y: int, - color_index: Optional[int] = None, tags: Dict = None) -> List[Dict]: - """Convert geometry to draw commands with appropriate line widths.""" - commands = [] - - if color_index is not None: - commands.append({'type': DRAW_COMMANDS['SET_COLOR_INDEX'], 'color_index': color_index}) - else: - commands.append({'type': DRAW_COMMANDS['SET_COLOR'], 'color': color}) - - line_width = get_line_width_from_tags(tags, zoom, coordinates, geom_type) - - def process_linestring(coords, width): - if len(coords) < 2: - return [] - - pixels = coords_to_pixels(coords, zoom, tile_x, tile_y) - - # Don't remove duplicate pixels - they're needed for smooth wide lines - unique_pixels = pixels - - if len(unique_pixels) < 2: - return [] - - # Always use POLYLINE for better rendering of wide lines - return [{'type': DRAW_COMMANDS['POLYLINE'], 'points': unique_pixels, 'width': width}] - - def process_polygon(coords, width): - if not coords or not coords[0] or len(coords[0]) < 3: - return [] - - exterior = coords[0] - pixels = coords_to_pixels(exterior, zoom, tile_x, tile_y) - - # Don't remove duplicates for polygons either - unique_pixels = pixels - - # Close the ring if it's not already closed - if unique_pixels and unique_pixels[0] != unique_pixels[-1]: - unique_pixels.append(unique_pixels[0]) - - if len(set(unique_pixels)) < 3: - return [] - - return [{'type': DRAW_COMMANDS['STROKE_POLYGON'], 'points': unique_pixels, 'width': width}] - - if geom_type == "LineString": - commands.extend(process_linestring(coordinates, line_width)) - elif geom_type == "Polygon": - commands.extend(process_polygon(coordinates, line_width)) - elif geom_type == "MultiLineString": - for line in coordinates: - line_commands = process_linestring(line, line_width) - if line_commands: - commands.extend(line_commands) - elif geom_type == "MultiPolygon": - for poly in coordinates: - poly_commands = process_polygon(poly, line_width) - if poly_commands: - commands.extend(poly_commands) - - return commands - - -def pack_draw_commands(commands: List[Dict]) -> bytes: - """Pack draw commands into binary format.""" - out = bytearray() - out += pack_varint(len(commands)) - - for cmd in commands: - cmd_type = cmd['type'] - out += pack_varint(cmd_type) - - if cmd_type == DRAW_COMMANDS['SET_COLOR']: - out += struct.pack("B", cmd.get('color', 0xFF)) - elif cmd_type == DRAW_COMMANDS['SET_COLOR_INDEX']: - out += pack_varint(cmd.get('color_index', 0)) - elif cmd_type in [DRAW_COMMANDS['POLYLINE'], DRAW_COMMANDS['STROKE_POLYGON']]: - points = cmd['points'] - width = cmd.get('width', 1) - out += pack_varint(width) - out += pack_varint(len(points)) - prev_x, prev_y = 0, 0 - for i, (x, y) in enumerate(points): - if i == 0: - out += pack_zigzag(x) - out += pack_zigzag(y) - else: - out += pack_zigzag(x - prev_x) - out += pack_zigzag(y - prev_y) - prev_x, prev_y = x, y - - return bytes(out) - - -def compress_goql_queries(config: Dict) -> str: - """Compress configuration into optimized GOQL query.""" - META = {'tile_size', 'viewport_size', 'toolbar_width', 'statusbar_height', 'max_cache_size', - 'thread_pool_size', 'background_colors', 'log_level', 'config_file', 'fps_limit', 'fill_polygons'} - LINEAR_TAGS = {'highway', 'railway'} - LINEAR_VALUES = {'natural': {'coastline', 'tree_row'}, 'waterway': {'river', 'stream', 'canal'}} - AREA_VALUES = {'waterway': {'riverbank', 'dock', 'boatyard'}} - - way_q = [] - area_q = [] - - for key in config: - if key in META or not isinstance(config[key], dict): - continue - if '=' in key: - tag, val = key.split('=', 1) - is_lin = (tag in LINEAR_VALUES and val in LINEAR_VALUES[tag]) or tag in LINEAR_TAGS - is_area = tag in AREA_VALUES and val in AREA_VALUES[tag] - - if is_lin and not is_area: - way_q.append(tag + "=" + val) - else: - area_q.append(tag + "=" + val) - else: - area_q.append(key) - - def group(queries): - grp = {} - single = [] - for q in queries: - if '=' in q: - t, v = q.split('=', 1) - if t not in grp: - grp[t] = [] - grp[t].append(v) - else: - single.append(q) - return grp, single - - parts = [] - way_grp, _ = group(way_q) - for tag, vals in sorted(way_grp.items()): - if len(vals) == 1: - parts.append("w[" + tag + "=" + vals[0] + "]") - else: - pat = '|'.join(vals) - parts.append("w[" + tag + "~\"^(" + pat + ")$\"]") - - area_grp, area_single = group(area_q) - for tag, vals in sorted(area_grp.items()): - if len(vals) == 1: - parts.append("nwa[" + tag + "=" + vals[0] + "]") - else: - pat = '|'.join(vals) - parts.append("nwa[" + tag + "~\"^(" + pat + ")$\"]") - - for tag in sorted(area_single): - parts.append("nwa[" + tag + "]") - - return ", ".join(parts) if parts else "*" - - -def process_feature(feature: Dict, config: Dict, zoom: int, tiles_data: Dict): - """Process a single GeoJSON feature and add it to tiles_data.""" - geom = feature.get('geometry') - props = feature.get('properties', {}) - - if not geom or geom['type'] not in ['LineString', 'Polygon', 'MultiLineString', 'MultiPolygon']: - return - - geom_type = geom['type'] - coordinates = geom['coordinates'] - - style = get_style_for_tags(props, config) - if not style: - return - - zoom_filter = style.get('zoom', 6) - if zoom < zoom_filter: - return - - coordinates = normalize_coordinates_robust(coordinates) - - # 1. Aplicar suavizado (smoothing) para alta fidelidad en curvas (Z >= 16). - coordinates = smooth_geometry_coords(coordinates, geom_type, zoom, props) - - hex_color = style.get('color', '#FFFFFF') - color = hex_to_rgb332(hex_color) - color_index = hex_to_color_index(hex_color) - priority = style.get('priority', 50) - layer_priority = get_layer_priority(props) - - minx, miny, maxx, maxy = geojson_bounds(coordinates, geom_type) - tile_x_min, tile_y_min = deg2num(miny, minx, zoom) - tile_x_max, tile_y_max = deg2num(maxy, maxx, zoom) - - # 3. Iterar y clip (Cohen-Sutherland/Sutherland-Hodgman) - for tx in range(min(tile_x_min, tile_x_max), max(tile_x_min, tile_x_max) + 1): - for ty in range(min(tile_y_min, tile_y_max), max(tile_y_min, tile_y_max) + 1): - tile_bounds = tile_bbox(tx, ty, zoom) - - if not coords_intersect_tile(coordinates, geom_type, tile_bounds): - continue - - clipped = clip_coordinates_to_tile(coordinates, geom_type, tile_bounds) - if not clipped: - continue - - commands = geometry_to_commands(geom_type, clipped, color, zoom, tx, ty, color_index, props) - if commands: - tile_key = (tx, ty) - if tile_key not in tiles_data: - tiles_data[tile_key] = [] - combined_priority = layer_priority + priority - tiles_data[tile_key].append({'commands': commands, 'priority': combined_priority}) - - -def get_style_for_tags(tags: Dict, config: Dict) -> Optional[Dict]: - """Find style configuration for given OSM tags.""" - for k, v in tags.items(): - keyval = k + "=" + str(v) - if keyval in config and isinstance(config[keyval], dict): - return config[keyval] - - for k in tags: - if k in config and isinstance(config[k], dict): - return config[k] - - return None - - -def sort_and_flatten_commands(tile_entries: List[Dict]) -> List[Dict]: - """Sort tile entries by priority and flatten to command list.""" - sorted_entries = sorted(tile_entries, key=lambda x: x['priority']) - all_commands = [] - for entry in sorted_entries: - all_commands.extend(entry['commands']) - return all_commands - - -def write_single_tile(job): - """Write a single tile to disk.""" - tx, ty, tile_entries, zoom, output_dir, max_file_size = job - commands = sort_and_flatten_commands(tile_entries) - - tile_dir = os.path.join(output_dir, str(zoom), str(tx)) - os.makedirs(tile_dir, exist_ok=True) - filepath = os.path.join(tile_dir, str(ty) + ".bin") - - data = pack_draw_commands(commands) - if len(data) > max_file_size: - data = data[:max_file_size] - - with open(filepath, 'wb') as f: - f.write(data) - - -def format_time(seconds: float) -> str: - """Format seconds into human-readable time string.""" - if seconds < 60: - return f"{seconds:.2f}s" - elif seconds < 3600: - minutes = int(seconds // 60) - secs = seconds % 60 - return f"{minutes}m {secs:.2f}s" - else: - hours = int(seconds // 3600) - minutes = int((seconds % 3600) // 60) - secs = seconds % 60 - return f"{hours}h {minutes}m {secs:.2f}s" - - -def merge_tiles_data(target: Dict, source: Dict): - """Merge source tiles data into target dictionary.""" - for tile_key, entries in source.items(): - if tile_key in target: - target[tile_key].extend(entries) - else: - target[tile_key] = entries - - -def write_tiles_batch(tiles_data: Dict, zoom: int, output_dir: str, max_file_size: int, - max_workers: int, persistent_tiles: Dict): - """Merge current batch into persistent tiles storage.""" - merge_tiles_data(persistent_tiles, tiles_data) - return len(tiles_data) - - -def write_final_tiles(persistent_tiles: Dict, zoom: int, output_dir: str, max_file_size: int, max_workers: int): - """Write all accumulated tiles to disk using thread pool.""" - if not persistent_tiles: - return 0 - - tile_jobs = [(tx, ty, entries, zoom, output_dir, max_file_size) - for (tx, ty), entries in persistent_tiles.items()] - tiles_written = len(tile_jobs) - - with ThreadPoolExecutor(max_workers=max_workers) as executor: - futures = [executor.submit(write_single_tile, job) for job in tile_jobs] - for future in as_completed(futures): - future.result() - - return tiles_written - - -def generate_tiles(gol_file: str, output_dir: str, config_file: str, zoom_levels: List[int], - max_file_size: int = 65536, base_batch_size: int = 10000): - """Main tile generation function.""" - with open(config_file) as f: - config = json.load(f) - - available_memory_mb = get_available_memory_mb() - cpu_count = os.cpu_count() or 4 - max_workers = get_optimal_workers(cpu_count, available_memory_mb) - - logger.info(f"System detected: {cpu_count} CPU cores, {available_memory_mb}MB RAM") - logger.info(f"Resource settings: {max_workers} workers, base batch size: {base_batch_size}") - - precompute_global_color_palette(config) - write_palette_bin(output_dir) - query = compress_goql_queries(config) - - gol_cmd = "/gol" if os.path.exists("/gol") else "gol" - - for zoom in zoom_levels: - zoom_start = time.time() - adaptive_batch = get_adaptive_batch_size(zoom, base_batch_size, available_memory_mb) - logger.info(f"Processing zoom {zoom} (batch size: {adaptive_batch}, workers: {max_workers})...") - - process = subprocess.Popen( - [gol_cmd, "query", gol_file, query, "-f", "geojson"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - bufsize=65536 - ) - - tiles_data = {} - persistent_tiles = {} - feature_count = 0 - batch_count = 0 - stderr_content = "" - - try: - for feature in ijson.items(process.stdout, "features.item"): - feature_count += 1 - process_feature(feature, config, zoom, tiles_data) - - if feature_count % adaptive_batch == 0: - batch_count += 1 - write_tiles_batch(tiles_data, zoom, output_dir, max_file_size, max_workers, persistent_tiles) - print(f"\rZoom {zoom}: {feature_count} features, {len(persistent_tiles)} unique tiles (batch {batch_count})...", - end='', flush=True) - tiles_data.clear() - gc.collect() - elif feature_count % 5000 == 0: - print(f"\rZoom {zoom}: {feature_count} features, {len(tiles_data)} tiles in buffer, {len(persistent_tiles)} persistent...", - end='', flush=True) - - if tiles_data: - write_tiles_batch(tiles_data, zoom, output_dir, max_file_size, max_workers, persistent_tiles) - - print(f"\rZoom {zoom}: {feature_count} features processed, writing {len(persistent_tiles)} tiles...{' ' * 20}") - sys.stdout.flush() - - total_tiles_written = write_final_tiles(persistent_tiles, zoom, output_dir, max_file_size, max_workers) - - print(f"\rZoom {zoom}: {feature_count} features processed, {total_tiles_written} tiles written{' ' * 20}") - sys.stdout.flush() - - except ijson.common.IncompleteJSONError: - pass - finally: - if process.stdout: - process.stdout.close() - if process.stderr: - stderr_content = process.stderr.read() if process.stderr else "" - if process.stderr: - process.stderr.close() - - process.wait() - if process.returncode != 0: - raise RuntimeError("gol query failed: " + stderr_content) - - zoom_elapsed = time.time() - zoom_start - logger.info(f"Zoom {zoom} completed in {format_time(zoom_elapsed)}") - - del tiles_data - del persistent_tiles - del process - gc.collect() - - -def main(): - """Command-line entry point for tile generator.""" - start_time = time.time() - - parser = argparse.ArgumentParser( - description='Generate map tiles from GoL file with OSM-compliant width handling' - ) - parser.add_argument("gol_file", help="Input GoL file path") - parser.add_argument("output_dir", help="Output directory for tiles") - parser.add_argument("config_file", help="JSON config file with feature definitions") - parser.add_argument("--zoom", default="6-17", help="Zoom level(s) to generate (e.g., '12' or '10-15')") - parser.add_argument("--max-file-size", type=int, default=128, help="Maximum tile file size in KB") - parser.add_argument("--batch-size", type=int, default=10000, - help="Base batch size (auto-adjusted per zoom level and system RAM)") - - args = parser.parse_args() - - if '-' in args.zoom: - start, end = map(int, args.zoom.split('-')) - zoom_levels = list(range(start, end + 1)) - else: - zoom_levels = [int(args.zoom)] - - max_file_size_bytes = args.max_file_size * 1024 - - logger.info("=" * 50) - logger.info("OSM-Compliant Tile Generator (CLEANED and CLIPPING FIXED)") - logger.info("=" * 50) - logger.info(f"Input: {args.gol_file}") - logger.info(f"Output: {args.output_dir}") - logger.info(f"Config: {args.config_file}") - logger.info(f"Zoom levels: {zoom_levels}") - logger.info(f"Max tile size: {args.max_file_size}KB") - logger.info(f"Base batch size: {args.batch_size} features (adaptive per zoom and system RAM)") - logger.info(f"Simplification: DISABLED (Geometry preserved)") - logger.info(f"Width handling: Hybrid (OSM Tags + CartoCSS Defaults) - SCALED DOWN for Small Screens") - logger.info("=" * 50) - - generate_tiles( - args.gol_file, - args.output_dir, - args.config_file, - zoom_levels, - max_file_size_bytes, - args.batch_size - ) - - total_count = 0 - total_size = 0 - for zoom in zoom_levels: - zoom_dir = os.path.join(args.output_dir, str(zoom)) - if os.path.exists(zoom_dir): - for x_dir in os.listdir(zoom_dir): - x_path = os.path.join(zoom_dir, x_dir) - if os.path.isdir(x_path): - for tile_file in os.listdir(x_path): - if tile_file.endswith('.bin'): - total_count += 1 - tile_path = os.path.join(x_path, tile_file) - total_size += os.path.getsize(tile_path) - - if total_count > 0: - if total_size < 1024: - size_str = f"{total_size}B" - elif total_size < 1024 * 1024: - size_str = f"{round(total_size / 1024, 1)}KB" - elif total_size < 1024 * 1024 * 1024: - size_str = f"{round(total_size / (1024 * 1024), 1)}MB" - else: - size_str = f"{round(total_size / (1024 * 1024 * 1024), 2)}GB" - - avg_size = round(total_size / total_count) - else: - size_str = "0B" - avg_size = 0 - - end_time = time.time() - elapsed_time = end_time - start_time - - logger.info("=" * 50) - logger.info("Generation Summary") - logger.info("=" * 50) - logger.info(f"Total tiles written: {total_count}") - logger.info(f"Total size: {size_str}") - logger.info(f"Average tile size: {avg_size} bytes") - logger.info(f"Palette file: {os.path.join(args.output_dir, 'palette.bin')}") - logger.info(f"Total processing time: {format_time(elapsed_time)}") - logger.info("=" * 50) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/tile_viewer.py b/tile_viewer.py deleted file mode 100644 index 82a8fe2..0000000 --- a/tile_viewer.py +++ /dev/null @@ -1,1490 +0,0 @@ -import struct -import sys -import os -import math -import pygame -import threading -import logging -from concurrent.futures import ThreadPoolExecutor, as_completed -from collections import OrderedDict -from functools import lru_cache - -# Drawing command codes (solo los usados por tile_generator.py) -DRAW_COMMANDS = { - 'LINE': 1, - 'POLYLINE': 2, - 'STROKE_POLYGON': 3, - 'SET_COLOR': 0x80, - 'SET_COLOR_INDEX': 0x81, -} - -# Tile configuration -TILE_SIZE = 256 -UINT16_TILE_SIZE = 65536 - -# Setup logging first -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') -logger = logging.getLogger(__name__) - -# Default configuration -DEFAULT_CONFIG = { - 'tile_size': 256, - 'viewport_size': 768, - 'toolbar_width': 160, - 'statusbar_height': 40, - 'max_cache_size': 1000, - 'thread_pool_size': 4, - 'background_colors': [(0, 0, 0), (255, 255, 255)], - 'log_level': 'INFO', - 'config_file': 'features.json', - 'fps_limit': 30, - 'fill_polygons': False -} - -class Config: - """Configuration management class""" - def __init__(self, config_file=None): - self.config = DEFAULT_CONFIG.copy() - self.config_file = config_file or DEFAULT_CONFIG['config_file'] - self.load_config() - - def load_config(self): - """Load configuration from file if it exists""" - try: - if os.path.exists(self.config_file): - import json - with open(self.config_file, 'r') as f: - file_config = json.load(f) - - # Merge file config with defaults - self.config.update(file_config) - logger.info(f"Loaded configuration from {self.config_file}") - else: - logger.info(f"Config file {self.config_file} not found, using defaults") - except Exception as e: - logger.error(f"Error loading config file {self.config_file}: {e}") - logger.info("Using default configuration") - - def get(self, key, default=None): - """Get configuration value""" - return self.config.get(key, default) - - def set(self, key, value): - """Set configuration value""" - self.config[key] = value - - def save_config(self): - """Save current configuration to file""" - try: - import json - with open(self.config_file, 'w') as f: - json.dump(self.config, f, indent=2) - logger.info(f"Saved configuration to {self.config_file}") - except Exception as e: - logger.error(f"Error saving config file: {e}") - -# Global configuration instance -config = Config() - -# Initialize constants from configuration -TILE_SIZE = config.get('tile_size', 256) -VIEWPORT_SIZE = config.get('viewport_size', 768) -TOOLBAR_WIDTH = config.get('toolbar_width', 160) -STATUSBAR_HEIGHT = config.get('statusbar_height', 40) -WINDOW_WIDTH = VIEWPORT_SIZE + TOOLBAR_WIDTH -WINDOW_HEIGHT = VIEWPORT_SIZE + STATUSBAR_HEIGHT - -# Global variables for palette -GLOBAL_PALETTE = {} - -class TileCache: - """LRU Cache for tile surfaces to limit memory usage""" - def __init__(self, max_size=None): - self.cache = OrderedDict() - self.max_size = max_size or config.get('max_cache_size', 1000) - self.hits = 0 - self.misses = 0 - - def get(self, key): - """Get tile surface from cache""" - if key in self.cache: - self.cache.move_to_end(key) - self.hits += 1 - return self.cache[key] - self.misses += 1 - return None - - def put(self, key, value): - """Put tile surface in cache""" - if key in self.cache: - self.cache.move_to_end(key) - else: - if len(self.cache) >= self.max_size: - removed_key, removed_value = self.cache.popitem(last=False) - logger.debug(f"Evicted tile from cache: {removed_key}") - self.cache[key] = value - - def clear(self): - """Clear all cached surfaces""" - self.cache.clear() - logger.info("Tile cache cleared") - - def get_stats(self): - """Get cache statistics""" - total = self.hits + self.misses - hit_rate = (self.hits / total * 100) if total > 0 else 0 - return { - 'size': len(self.cache), - 'max_size': self.max_size, - 'hits': self.hits, - 'misses': self.misses, - 'hit_rate': hit_rate - } - -# Global tile cache instance -tile_cache = TileCache() - -class TileLoader: - """Persistent thread pool for tile loading operations""" - def __init__(self, max_workers=None): - self.executor = ThreadPoolExecutor(max_workers=max_workers or config.get('thread_pool_size', 4)) - self.active_futures = set() - - def submit_tile_load(self, tile_info, callback=None): - """Submit a tile loading task""" - future = self.executor.submit(self._load_single_tile, tile_info) - if callback: - future.add_done_callback(callback) - self.active_futures.add(future) - return future - - def _load_single_tile(self, tile_info): - """Load a single tile (internal method) with error recovery""" - try: - x, y, zoom_level, directory, bg_color, fill_mode = tile_info - key = (zoom_level, x, y, bg_color, fill_mode) - - # Check cache first - cached_surface = tile_cache.get(key) - if cached_surface is not None: - return key, cached_surface - - tile_file = get_tile_file(directory, x, y) - if not tile_file: - logger.warning(f"No tile file found for ({x}, {y}) in {directory}") - return None, None - - # Validate tile file exists - if not os.path.exists(tile_file): - logger.warning(f"Tile file does not exist: {tile_file}") - return None, None - - surface = render_tile_surface({'x': x, 'y': y, 'file': tile_file}, bg_color, fill_mode, False) - if surface is None: - logger.error(f"Failed to render tile surface for {tile_file}") - return None, None - - tile_cache.put(key, surface) - return key, surface - - except Exception as e: - logger.error(f"Error loading tile {tile_info}: {e}") - return None, None - - def cleanup_completed_futures(self): - """Remove completed futures from active set""" - completed = [f for f in self.active_futures if f.done()] - for future in completed: - self.active_futures.discard(future) - return len(completed) - - def shutdown(self): - """Shutdown the thread pool""" - self.executor.shutdown(wait=True) - logger.info("Tile loader thread pool shutdown") - -# Global tile loader instance -tile_loader = TileLoader() - -def load_global_palette_from_bin(palette_file): - """ - Load the global palette from the binary palette file generated by tile_generator.py. - """ - global GLOBAL_PALETTE - try: - with open(palette_file, 'rb') as f: - # Read 4-byte header for number of colors - header = f.read(4) - if len(header) < 4: - logger.warning(f"Invalid palette file header: {palette_file}") - return False - - num_colors = struct.unpack(' RGB888 - GLOBAL_PALETTE = {} - for index, hex_color in enumerate(sorted_colors): - rgb888 = hex_to_rgb888(hex_color) - GLOBAL_PALETTE[index] = rgb888 - - logger.info(f"Loaded dynamic palette: {len(GLOBAL_PALETTE)} colors") - return True - except Exception as e: - logger.warning(f"Could not load palette from config: {e}") - logger.info("Will use fallback palette if needed") - return False - -def hex_to_rgb888(hex_color): - """Converts hex color to RGB888""" - try: - if not hex_color or not hex_color.startswith("#"): - return (255, 255, 255) - r = int(hex_color[1:3], 16) - g = int(hex_color[3:5], 16) - b = int(hex_color[5:7], 16) - return (r, g, b) - except: - return (255, 255, 255) - -def darken_color(rgb, amount=0.3): - """Darken RGB color by specified amount""" - return tuple(max(0, int(v * (1 - amount))) for v in rgb) - -def skip_command(cmd_type, data, offset): - """Skip command data without processing it (incluyendo ancho)""" - if cmd_type == 1: # LINE - _, offset = read_varint(data, offset) # Saltar ancho - _, offset = read_zigzag(data, offset) - _, offset = read_zigzag(data, offset) - _, offset = read_zigzag(data, offset) - _, offset = read_zigzag(data, offset) - elif cmd_type == 2: # POLYLINE - _, offset = read_varint(data, offset) # Saltar ancho - n_pts, offset = read_varint(data, offset) - for _ in range(n_pts): - if _ == 0: - _, offset = read_zigzag(data, offset) - _, offset = read_zigzag(data, offset) - else: - _, offset = read_zigzag(data, offset) - _, offset = read_zigzag(data, offset) - elif cmd_type == 3: # STROKE_POLYGON - _, offset = read_varint(data, offset) # Saltar ancho - n_pts, offset = read_varint(data, offset) - for _ in range(n_pts): - _, offset = read_zigzag(data, offset) - _, offset = read_zigzag(data, offset) - else: - # Para comandos desconocidos, saltar 4 bytes - if offset < len(data) - 4: - offset += 4 - - return offset - -@lru_cache(maxsize=1000) -def uint16_to_tile_pixel(val): - """Convert uint16 coordinate to tile pixel with caching""" - return int(round(val * (TILE_SIZE - 1) / (UINT16_TILE_SIZE - 1))) - -def get_button_icons(): - """Create beautiful, modern button icons""" - - # Background toggle icon (sun/moon) - icon_surface_bg = pygame.Surface((24, 24), pygame.SRCALPHA) - icon_surface_bg.fill((0, 0, 0, 0)) - # Sun rays - for i in range(8): - angle = i * 45 - x1 = 12 + int(8 * math.cos(math.radians(angle))) - y1 = 12 + int(8 * math.sin(math.radians(angle))) - x2 = 12 + int(10 * math.cos(math.radians(angle))) - y2 = 12 + int(10 * math.sin(math.radians(angle))) - pygame.draw.line(icon_surface_bg, (255, 255, 0), (x1, y1), (x2, y2), 2) - # Sun center - pygame.draw.circle(icon_surface_bg, (255, 255, 0), (12, 12), 6, 0) - pygame.draw.circle(icon_surface_bg, (255, 200, 0), (12, 12), 4, 0) - - # Tile labels icon (document with lines) - icon_surface_label = pygame.Surface((24, 24), pygame.SRCALPHA) - icon_surface_label.fill((0, 0, 0, 0)) - # Document background - pygame.draw.rect(icon_surface_label, (255, 255, 255), (6, 4, 12, 16), 0) - pygame.draw.rect(icon_surface_label, (200, 200, 200), (6, 4, 12, 16), 2) - # Document fold - pygame.draw.polygon(icon_surface_label, (240, 240, 240), [(18, 4), (18, 8), (14, 8)], 0) - pygame.draw.polygon(icon_surface_label, (180, 180, 180), [(18, 4), (18, 8), (14, 8)], 1) - # Text lines - pygame.draw.line(icon_surface_label, (100, 100, 100), (8, 8), (16, 8), 1) - pygame.draw.line(icon_surface_label, (100, 100, 100), (8, 10), (14, 10), 1) - pygame.draw.line(icon_surface_label, (100, 100, 100), (8, 12), (16, 12), 1) - pygame.draw.line(icon_surface_label, (100, 100, 100), (8, 14), (12, 14), 1) - - # GPS cursor icon (crosshair with target) - icon_surface_gps = pygame.Surface((24, 24), pygame.SRCALPHA) - icon_surface_gps.fill((0, 0, 0, 0)) - # Outer circle - pygame.draw.circle(icon_surface_gps, (0, 255, 0), (12, 12), 10, 2) - # Inner circle - pygame.draw.circle(icon_surface_gps, (0, 255, 0), (12, 12), 6, 2) - # Crosshair lines - pygame.draw.line(icon_surface_gps, (0, 255, 0), (12, 2), (12, 6), 2) - pygame.draw.line(icon_surface_gps, (0, 255, 0), (12, 18), (12, 22), 2) - pygame.draw.line(icon_surface_gps, (0, 255, 0), (2, 12), (6, 12), 2) - pygame.draw.line(icon_surface_gps, (0, 255, 0), (18, 12), (22, 12), 2) - # Center dot - pygame.draw.circle(icon_surface_gps, (0, 255, 0), (12, 12), 2, 0) - - # Fill polygons icon (paint bucket) - icon_surface_fill = pygame.Surface((24, 24), pygame.SRCALPHA) - icon_surface_fill.fill((0, 0, 0, 0)) - # Paint bucket body - pygame.draw.rect(icon_surface_fill, (255, 100, 100), (8, 6, 8, 10), 0) - pygame.draw.rect(icon_surface_fill, (200, 80, 80), (8, 6, 8, 10), 1) - # Paint bucket spout - pygame.draw.polygon(icon_surface_fill, (255, 100, 100), [(10, 16), (14, 16), (12, 20)], 0) - pygame.draw.polygon(icon_surface_fill, (200, 80, 80), [(10, 16), (14, 16), (12, 20)], 1) - # Paint drops - pygame.draw.circle(icon_surface_fill, (255, 100, 100), (6, 18), 1, 0) - pygame.draw.circle(icon_surface_fill, (255, 100, 100), (18, 20), 1, 0) - pygame.draw.circle(icon_surface_fill, (255, 100, 100), (4, 22), 1, 0) - - return icon_surface_bg, icon_surface_label, icon_surface_gps, icon_surface_fill - -def index_available_tiles(directory, progress_callback=None): - available_tiles = set() - if not os.path.isdir(directory): - logger.error(f"Directory does not exist: {directory}") - return available_tiles - x_dirs = [x_str for x_str in os.listdir(directory) if os.path.isdir(os.path.join(directory, x_str))] - total_x = len(x_dirs) - def index_xdir(x_str): - x_path = os.path.join(directory, x_str) - try: - x = int(x_str) - except: - return [] - files = os.listdir(x_path) - y_dict = {} - for fname in files: - if fname.endswith('.bin') or fname.endswith('.png'): - y_str = fname.split('.')[0] - if y_str.isdigit(): - y = int(y_str) - if y not in y_dict or fname.endswith('.bin'): - y_dict[y] = fname - return [(x, y) for y in y_dict] - results = [] - with ThreadPoolExecutor(min(8, os.cpu_count() or 4)) as pool: - futures = {pool.submit(index_xdir, x_str): i_x for i_x, x_str in enumerate(x_dirs)} - for i, future in enumerate(as_completed(futures)): - tiles = future.result() - results.extend(tiles) - if progress_callback is not None: - percent = (i + 1) / max(total_x, 1) - progress_callback(percent, "Indexing tiles...") - available_tiles.update(results) - return available_tiles - -def get_tile_file(directory, x, y): - """Get tile file path with validation""" - bin_path = f"{directory}/{x}/{y}.bin" - png_path = f"{directory}/{x}/{y}.png" - if os.path.isfile(bin_path): - return bin_path - elif os.path.isfile(png_path): - return png_path - return None - -def is_tile_visible(x, y, viewport_x, viewport_y): - """Check if tile is visible in current viewport""" - tile_px = x * TILE_SIZE - viewport_x - tile_py = y * TILE_SIZE - viewport_y - - # Check if tile intersects with viewport - return not (tile_px + TILE_SIZE < 0 or tile_px > VIEWPORT_SIZE or - tile_py + TILE_SIZE < 0 or tile_py > VIEWPORT_SIZE) - -def get_visible_tiles(available_tiles, viewport_x, viewport_y): - """Get list of tiles that are currently visible""" - min_tile_x = int(viewport_x // TILE_SIZE) - max_tile_x = int((viewport_x + VIEWPORT_SIZE) // TILE_SIZE) - min_tile_y = int(viewport_y // TILE_SIZE) - max_tile_y = int((viewport_y + VIEWPORT_SIZE) // TILE_SIZE) - - visible_tiles = [] - for x in range(min_tile_x, max_tile_x + 1): - for y in range(min_tile_y, max_tile_y + 1): - if (x, y) in available_tiles: - visible_tiles.append((x, y)) - - return visible_tiles - -def read_varint(data, offset): - result = 0 - shift = 0 - while True: - if offset >= len(data): - return result, offset - b = data[offset] - offset += 1 - result |= (b & 0x7F) << shift - if not (b & 0x80): - break - shift += 7 - return result, offset - -def read_zigzag(data, offset): - v, offset = read_varint(data, offset) - return (v >> 1) ^ -(v & 1), offset - -def rgb332_to_rgb888(c): - r = (c & 0xE0) - g = (c & 0x1C) << 3 - b = (c & 0x03) << 6 - return (r, g, b) - -def is_tile_border_point(pt): - x, y = pt - return (x == 0 or x == TILE_SIZE-1) or (y == 0 or y == TILE_SIZE-1) - -def is_polygon_on_tile_border(pts): - """Check if a polygon has any points on the tile border""" - for pt in pts: - if is_tile_border_point(pt): - return True - return False - -def get_polygon_border_segments(pts): - """Get border segments of a polygon, distinguishing between tile border and interior segments""" - border_segments = [] - interior_segments = [] - - for i in range(len(pts)): - pt1 = pts[i] - pt2 = pts[(i + 1) % len(pts)] # Next point (wrapping around) - - # Check if this segment is on the tile border - if is_tile_border_point(pt1) and is_tile_border_point(pt2): - border_segments.append((pt1, pt2)) - else: - interior_segments.append((pt1, pt2)) - - return border_segments, interior_segments - -def get_background_color(surface): - """Get the background color from the surface""" - # Get the color of the top-left pixel as background color - return surface.get_at((0, 0))[:3] # RGB only, ignore alpha - -def create_error_tile(error_message="Error"): - """Create a tile surface indicating an error""" - surface = pygame.Surface((TILE_SIZE, TILE_SIZE)) - surface.fill((255, 0, 0)) # Red background for errors - try: - font = pygame.font.SysFont(None, 24) - text = font.render(error_message, True, (255, 255, 255)) - text_rect = text.get_rect(center=(TILE_SIZE//2, TILE_SIZE//2)) - surface.blit(text, text_rect) - except Exception: - pass # If even error rendering fails, just return red surface - return surface - -def load_png_tile(filepath): - """Load and render PNG tile with error recovery""" - try: - if not os.path.exists(filepath): - logger.warning(f"PNG file does not exist: {filepath}") - return create_error_tile("Missing") - - img = pygame.image.load(filepath) - if img is None: - logger.error(f"Failed to load PNG image: {filepath}") - return create_error_tile("Load Failed") - - img = pygame.transform.scale(img, (TILE_SIZE, TILE_SIZE)) - surface = pygame.Surface((TILE_SIZE, TILE_SIZE)) - surface.blit(img, (0, 0)) - return surface - except pygame.error as e: - logger.error(f"Pygame error loading PNG {filepath}: {e}") - return create_error_tile("Pygame Error") - except Exception as e: - logger.error(f"Unexpected error loading PNG {filepath}: {e}") - return create_error_tile("Unknown Error") - -def parse_command_header(data, offset): - """Parse the command header and return number of commands""" - try: - num_cmds, offset = read_varint(data, offset) - return num_cmds, offset - except Exception as e: - logger.error(f"Error parsing command header: {e}") - return 0, offset - -def handle_color_command(cmd_type, data, offset, current_color): - """Handle color setting commands""" - if cmd_type == 0x80: # SET_COLOR (direct RGB332) - if offset >= len(data): - return current_color, offset, True - current_color = data[offset] - offset += 1 - return current_color, offset, True - elif cmd_type == 0x81: # SET_COLOR_INDEX (palette index) - if offset >= len(data): - return current_color, offset, True - color_index, offset = read_varint(data, offset) - - # Convert index to RGB using global palette - if color_index in GLOBAL_PALETTE: - rgb = GLOBAL_PALETTE[color_index] - # Simulate RGB332 to maintain compatibility - current_color = ((rgb[0] & 0xE0) | ((rgb[1] & 0xE0) >> 3) | (rgb[2] >> 6)) - else: - # Fallback if we don't have the palette - current_color = 255 # Default color - logger.warning(f"Unknown palette index {color_index}") - return current_color, offset, True - - return current_color, offset, False - -def draw_thick_line_rounded(surface, color, start_pos, end_pos, width): - """Draw a line with rounded ends""" - # Calculate direction vector - dx = end_pos[0] - start_pos[0] - dy = end_pos[1] - start_pos[1] - length = max(1, math.sqrt(dx*dx + dy*dy)) # Avoid division by zero - - # Normalize direction vector - dx /= length - dy /= length - - # Calculate perpendicular vector - px = -dy * width / 2 - py = dx * width / 2 - - # Create the rectangle points for the thick line - points = [ - (start_pos[0] + px, start_pos[1] + py), - (start_pos[0] - px, start_pos[1] - py), - (end_pos[0] - px, end_pos[1] - py), - (end_pos[0] + px, end_pos[1] + py) - ] - - # Draw the rectangle (main line body) - pygame.draw.polygon(surface, color, points) - - # Draw rounded ends (circles at start and end) - pygame.draw.circle(surface, color, (int(start_pos[0]), int(start_pos[1])), width // 2) - pygame.draw.circle(surface, color, (int(end_pos[0]), int(end_pos[1])), width // 2) - -def draw_thick_polyline_rounded(surface, color, points, width): - """Draw a polyline with rounded joins and ends""" - if len(points) < 2: - return - - # Draw the line segments with rounded joins - for i in range(len(points) - 1): - draw_thick_line_rounded(surface, color, points[i], points[i + 1], width) - - # Draw rounded joints at intermediate points - for i in range(1, len(points) - 1): - pygame.draw.circle(surface, color, (int(points[i][0]), int(points[i][1])), width // 2) - -def render_polygon_command(data, offset, surface, rgb, fill_polygons, line_width): - """Render STROKE_POLYGON command con ancho de línea""" - n_pts, offset = read_varint(data, offset) - pts = [] - x, y = 0, 0 - for i in range(n_pts): - if i == 0: - x, offset = read_zigzag(data, offset) - y, offset = read_zigzag(data, offset) - else: - dx, offset = read_zigzag(data, offset) - dy, offset = read_zigzag(data, offset) - x += dx - y += dy - pts.append((uint16_to_tile_pixel(x), uint16_to_tile_pixel(y))) - - if fill_polygons and len(pts) >= 3: - pygame.draw.polygon(surface, rgb, pts, 0) - # Dibujar contorno con el ancho especificado - border_segments, interior_segments = get_polygon_border_segments(pts) - - # Dibujar segmentos de borde con color original - for pt1, pt2 in border_segments: - pygame.draw.line(surface, rgb, pt1, pt2, line_width) - - # Dibujar segmentos interiores con color oscurecido - for pt1, pt2 in interior_segments: - pygame.draw.line(surface, darken_color(rgb, 0.4), pt1, pt2, line_width) - elif not fill_polygons and len(pts) >= 2: - # Sin relleno: dibujar contorno con color original en interior, color de fondo en bordes - border_segments, interior_segments = get_polygon_border_segments(pts) - bg_color = get_background_color(surface) - - # Dibujar segmentos de borde con color de fondo - for pt1, pt2 in border_segments: - pygame.draw.line(surface, bg_color, pt1, pt2, line_width) - - # Dibujar segmentos interiores con color original - for pt1, pt2 in interior_segments: - pygame.draw.line(surface, rgb, pt1, pt2, line_width) - return offset - -def render_geometry_command(cmd_type, data, offset, surface, current_color, fill_mode, current_position, movement_vector, fill_polygons=False, show_tile_labels=False): - """Render geometry commands with rounded line caps and joins""" - rgb = rgb332_to_rgb888(current_color) if current_color is not None else (255, 255, 255) - - # Leer ancho de línea (por defecto 1) - line_width = 1 - - # Si surface es None, solo saltar el renderizado pero procesar para obtener offset correcto - if surface is None: - if cmd_type == 1: # LINE - line_width, offset = read_varint(data, offset) # Leer ancho - _, offset = read_zigzag(data, offset) - _, offset = read_zigzag(data, offset) - _, offset = read_zigzag(data, offset) - _, offset = read_zigzag(data, offset) - return offset, current_position, movement_vector - elif cmd_type == 2: # POLYLINE - line_width, offset = read_varint(data, offset) # Leer ancho - n_pts, offset = read_varint(data, offset) - for _ in range(n_pts): - if _ == 0: - _, offset = read_zigzag(data, offset) - _, offset = read_zigzag(data, offset) - else: - _, offset = read_zigzag(data, offset) - _, offset = read_zigzag(data, offset) - return offset, current_position, movement_vector - elif cmd_type == 3: # STROKE_POLYGON - line_width, offset = read_varint(data, offset) # Leer ancho - n_pts, offset = read_varint(data, offset) - for _ in range(n_pts): - _, offset = read_zigzag(data, offset) - _, offset = read_zigzag(data, offset) - return offset, current_position, movement_vector - return offset, current_position, movement_vector - - if cmd_type == 1: # LINE - line_width, offset = read_varint(data, offset) # Leer ancho - x1, offset = read_zigzag(data, offset) - y1, offset = read_zigzag(data, offset) - dx, offset = read_zigzag(data, offset) - dy, offset = read_zigzag(data, offset) - x2 = x1 + dx - y2 = y1 + dy - p1 = (uint16_to_tile_pixel(x1), uint16_to_tile_pixel(y1)) - p2 = (uint16_to_tile_pixel(x2), uint16_to_tile_pixel(y2)) - - if line_width == 1: - pygame.draw.line(surface, rgb, p1, p2, line_width) - else: - draw_thick_line_rounded(surface, rgb, p1, p2, line_width) - current_position = (x2, y2) - movement_vector = (dx, dy) - elif cmd_type == 2: # POLYLINE - line_width, offset = read_varint(data, offset) # Leer ancho - n_pts, offset = read_varint(data, offset) - pts = [] - x, y = 0, 0 - for i in range(n_pts): - if i == 0: - x, offset = read_zigzag(data, offset) - y, offset = read_zigzag(data, offset) - else: - dx, offset = read_zigzag(data, offset) - dy, offset = read_zigzag(data, offset) - x += dx - y += dy - pts.append((uint16_to_tile_pixel(x), uint16_to_tile_pixel(y))) - if len(pts) >= 2: - if line_width == 1: - pygame.draw.lines(surface, rgb, False, pts, line_width) - else: - # Usar el método de líneas gruesas con joins redondos - draw_thick_polyline_rounded(surface, rgb, pts, line_width) - current_position = (x, y) - elif cmd_type == 3: # STROKE_POLYGON - line_width, offset = read_varint(data, offset) # Leer ancho - offset = render_polygon_command(data, offset, surface, rgb, fill_polygons, line_width) - else: - logger.warning(f"Unknown command type: {cmd_type} (0x{cmd_type:02x})") - - return offset, current_position, movement_vector - -def render_tile_surface(tile, bg_color, fill_mode, show_tile_labels=False): - """Main function to render tile surface from binary data with error recovery""" - try: - surface = pygame.Surface((TILE_SIZE, TILE_SIZE)) - surface.fill(bg_color) - filepath = tile['file'] - - if not filepath: - logger.error("No file path provided for tile") - return create_error_tile("No File") - - if filepath.endswith('.png'): - png_surface = load_png_tile(filepath) - if png_surface: - return png_surface - return surface - - # Validate file exists and is readable - if not os.path.exists(filepath): - logger.warning(f"Tile file does not exist: {filepath}") - return create_error_tile("Missing") - - if not os.access(filepath, os.R_OK): - logger.error(f"Tile file is not readable: {filepath}") - return create_error_tile("No Access") - - try: - with open(filepath, "rb") as f: - data = f.read() - except PermissionError as e: - logger.error(f"Permission denied reading {filepath}: {e}") - return create_error_tile("Permission") - except OSError as e: - logger.error(f"OS error reading {filepath}: {e}") - return create_error_tile("OS Error") - except Exception as e: - logger.error(f"Unexpected error reading {filepath}: {e}") - return create_error_tile("Read Error") - - if len(data) < 1: - logger.warning(f"Empty tile file: {filepath}") - return surface - - offset = 0 - current_color = None - - # Initialize rendering state - current_position = (0, 0) - movement_vector = (0, 0) - - try: - num_cmds, offset = parse_command_header(data, offset) - - # Process commands in order - for cmd_idx in range(num_cmds): - if offset >= len(data): - logger.warning(f"Command {cmd_idx} extends beyond data length in {filepath}") - break - - cmd_type, offset = read_varint(data, offset) - - # Handle color commands - current_color, offset, is_color_cmd = handle_color_command(cmd_type, data, offset, current_color) - if is_color_cmd: - continue - - # Render geometry commands - fill_polygons = config.config.get('fill_polygons', False) - offset, current_position, movement_vector = render_geometry_command( - cmd_type, data, offset, surface, current_color, fill_mode, current_position, movement_vector, fill_polygons, show_tile_labels - ) - - except Exception as e: - logger.error(f"Error parsing commands in {filepath}: {e}") - logger.error(f"Error at offset: {offset}, data length: {len(data)}") - # Return partial surface instead of error tile for parsing errors - return surface - - return surface - - except Exception as e: - logger.error(f"Critical error in render_tile_surface: {e}") - return create_error_tile("Critical") - -def center_viewport_on_central_tile(available_tiles): - if not available_tiles: - return 0, 0 - xs = [x for x, y in available_tiles] - ys = [y for x, y in available_tiles] - min_x, max_x = min(xs), max(xs) - min_y, max_y = min(ys), max(ys) - center_x = (min_x + max_x) // 2 - center_y = (min_y + max_y) // 2 - viewport_x = center_x * TILE_SIZE - VIEWPORT_SIZE // 2 - viewport_y = center_y * TILE_SIZE - VIEWPORT_SIZE // 2 - return viewport_x, viewport_y - -def clamp_viewport(viewport_x, viewport_y, available_tiles): - if not available_tiles: - return viewport_x, viewport_y - xs = [x for x, y in available_tiles] - ys = [y for x, y in available_tiles] - min_x, max_x = min(xs), max(xs) - min_y, max_y = min(ys), max(ys) - viewport_x = max(min_x * TILE_SIZE, min(viewport_x, (max_x * TILE_SIZE + TILE_SIZE) - VIEWPORT_SIZE)) - viewport_y = max(min_y * TILE_SIZE, min(viewport_y, (max_y * TILE_SIZE + TILE_SIZE) - VIEWPORT_SIZE)) - return viewport_x, viewport_y - -def draw_button(surface, text, rect, bg_color, fg_color, border_color, font, icon=None, pressed=False): - """Draw a button with improved text handling and multi-line support""" - radius = 16 - pygame.draw.rect(surface, bg_color, rect, border_radius=radius) - pygame.draw.rect(surface, border_color, rect, 2, border_radius=radius) - if pressed: - pygame.draw.rect(surface, border_color, rect, 4, border_radius=radius) - - # Calculate content area - content_x = rect.left + 12 - content_y = rect.centery - icon_width = 0 - - if icon is not None: - icon_rect = icon.get_rect() - icon_rect.centery = rect.centery - icon_rect.left = rect.left + 12 - surface.blit(icon, icon_rect) - content_x = icon_rect.right + 8 - icon_width = icon_rect.width + 8 - - # Calculate available text width - max_text_width = rect.width - icon_width - 24 # 12px margin on each side - - # Split text into words for multi-line support - words = text.split() - if not words: - return - - # Use consistent font size (14px for better readability) - button_font = pygame.font.SysFont(None, 14) - line_height = button_font.get_height() - - # Calculate how many lines we need - lines = [] - current_line = [] - current_width = 0 - - for word in words: - word_width = button_font.size(word + " ")[0] - if current_width + word_width <= max_text_width: - current_line.append(word) - current_width += word_width - else: - if current_line: - lines.append(" ".join(current_line)) - current_line = [word] - current_width = word_width - else: - # Single word is too long, force it - lines.append(word) - current_line = [] - current_width = 0 - - if current_line: - lines.append(" ".join(current_line)) - - # Limit to 2 lines maximum - if len(lines) > 2: - lines = lines[:2] - # If we have more than 2 lines, truncate the last line with "..." - if len(lines) == 2: - last_line = lines[1] - while button_font.size(last_line + "...")[0] > max_text_width and len(last_line) > 3: - last_line = last_line[:-1] - lines[1] = last_line + "..." - - # Calculate total text height - total_text_height = len(lines) * line_height - - # Center the text vertically - start_y = content_y - total_text_height // 2 - - # Render each line - for i, line in enumerate(lines): - label = button_font.render(line, True, fg_color) - text_rect = label.get_rect(midleft=(content_x, start_y + i * line_height + line_height // 2)) - surface.blit(label, text_rect) - -def show_status_progress_bar(surface, percent, text, font): - bar_max_width = WINDOW_WIDTH // 3 - bar_height = 18 - bar_margin_right = 24 - bar_x = WINDOW_WIDTH - bar_max_width - bar_margin_right - bar_y = VIEWPORT_SIZE + STATUSBAR_HEIGHT // 2 - bar_height // 2 - pygame.draw.rect(surface, (80, 80, 80), (bar_x, bar_y, bar_max_width, bar_height)) - pygame.draw.rect(surface, (30, 160, 220), (bar_x, bar_y, int(bar_max_width * percent), bar_height)) - pygame.draw.rect(surface, (120, 120, 120), (bar_x, bar_y, bar_max_width, bar_height), 2) - label = font.render(text, True, (255,255,255)) - label_rect = label.get_rect(midleft=(bar_x + 8, bar_y + bar_height//2 - label.get_height()//2)) - surface.blit(label, label_rect) - -def draw_tile_labels( - screen, font, available_tiles, viewport_x, viewport_y, zoom_level, background_color, show_tile_labels, directory, fill_polygons=False -): - if not show_tile_labels: - return - fg = (0, 0, 0) if background_color == (255,255,255) else (255,255,255) - label_bg = (240,240,240) if background_color == (255,255,255) else (32,32,32) - border = (180,180,180) if background_color == (255,255,255) else (64,64,64) - outline = (120,120,120) if background_color == (255,255,255) else (220,220,220) - for x, y in available_tiles: - px = x * TILE_SIZE - viewport_x - py = y * TILE_SIZE - viewport_y - if px + TILE_SIZE < 0 or px > VIEWPORT_SIZE or py + TILE_SIZE < 0 or py > VIEWPORT_SIZE: - continue - filename = None - if os.path.isfile(f"{directory}/{x}/{y}.bin"): - filename = f"{y}.bin" - elif os.path.isfile(f"{directory}/{x}/{y}.png"): - filename = f"{y}.png" - else: - filename = f"{y}" - txt = f"x={x} y={y} z={zoom_level} {filename}" - label_surfs = [font.render(txt, True, fg)] - lw = max(s.get_width() for s in label_surfs) - lh = sum(s.get_height() for s in label_surfs) - margin = 2 - label_rect = pygame.Rect( - px + margin, py + margin, - lw + margin * 2, lh + margin * 2 - ) - pygame.draw.rect(screen, label_bg, label_rect) - pygame.draw.rect(screen, border, label_rect, 1) - offset_y = label_rect.top + margin - for surf in label_surfs: - screen.blit(surf, (label_rect.left + margin, offset_y)) - offset_y += surf.get_height() - # Draw tile borders when show_tile_labels is enabled - draw_dashed_rect(screen, pygame.Rect(px, py, TILE_SIZE, TILE_SIZE), outline, width=1) - -def draw_dashed_rect(surface, rect, color, dash_length=6, gap_length=4, width=1): - x = rect.left - while x < rect.right: - end_x = min(x + dash_length, rect.right) - pygame.draw.line(surface, color, (x, rect.top), (end_x, rect.top), width) - x += dash_length + gap_length - x = rect.left - while x < rect.right: - end_x = min(x + dash_length, rect.right) - pygame.draw.line(surface, color, (x, rect.bottom-1), (end_x, rect.bottom-1), width) - x += dash_length + gap_length - y = rect.top - while y < rect.bottom: - end_y = min(y + dash_length, rect.bottom) - pygame.draw.line(surface, color, (rect.left, y), (rect.left, end_y), width) - y += dash_length + gap_length - y = rect.top - while y < rect.bottom: - end_y = min(y + dash_length, rect.bottom) - pygame.draw.line(surface, color, (rect.right-1, y), (rect.right-1, end_y), width) - y += dash_length + gap_length - -def pixel_to_latlon(px, py, viewport_x, viewport_y, zoom): - """Convert pixel coordinates to lat/lon""" - map_px = viewport_x + px - map_py = viewport_y + py - tile_x = map_px / TILE_SIZE - tile_y = map_py / TILE_SIZE - n = 2.0 ** zoom - lon_deg = tile_x / n * 360.0 - 180.0 - lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * tile_y / n))) - lat_deg = math.degrees(lat_rad) - return lat_deg, lon_deg - -def latlon_to_pixel(lat, lon, zoom): - """Convert lat/lon to pixel coordinates""" - n = 2.0 ** zoom - x = (lon + 180.0) / 360.0 * n - y = (1 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2 * n - map_px = x * TILE_SIZE - map_py = y * TILE_SIZE - return map_px, map_py - -def decimal_to_gms(decimal, is_latitude=True): - sign = "" - if is_latitude: - sign = "N" if decimal >= 0 else "S" - else: - sign = "E" if decimal >= 0 else "W" - decimal = abs(decimal) - degrees = int(decimal) - minutes_full = (decimal - degrees) * 60 - minutes = int(minutes_full) - seconds = (minutes_full - minutes) * 60 - return f"{degrees}°{minutes}'{seconds:.2f}\" {sign}" - -def main(base_dir): - # Try to load palette from palette.bin first, then fallback to features.json - palette_file = "palette.bin" - config_file = "features.json" - - if os.path.exists(palette_file): - if load_global_palette_from_bin(palette_file): - logger.info("Loaded palette from palette.bin") - else: - logger.warning("Failed to load palette.bin, trying features.json") - if os.path.exists(config_file): - load_global_palette_from_config(config_file) - else: - logger.info(f"Features file {config_file} not found, using fallback colors") - elif os.path.exists(config_file): - load_global_palette_from_config(config_file) - else: - logger.info(f"Features file {config_file} not found, using fallback colors") - - zoom_dirs = [d for d in os.listdir(base_dir) if os.path.isdir(os.path.join(base_dir, d)) and d.isdigit()] - zoom_levels_list = sorted([int(d) for d in zoom_dirs]) - if not zoom_levels_list: - logger.error(f"No zoom level directories found in {base_dir}") - sys.exit(1) - min_zoom = zoom_levels_list[0] - max_zoom = zoom_levels_list[-1] - zoom_levels = list(range(min_zoom, max_zoom+1)) - zoom_idx = 0 - - background_color = (0, 0, 0) - button_color = (0, 0, 0) - button_fg = (255, 255, 255) - button_border = (100,100,100) - - toolbar_x = VIEWPORT_SIZE - toolbar_y = 0 - button_height = 40 - button_margin = 16 - button_rect = pygame.Rect(toolbar_x + 30, toolbar_y + button_margin, 100, button_height) - tile_label_button_rect = pygame.Rect(toolbar_x + 30, toolbar_y + button_margin * 2 + button_height, 100, button_height) - gps_button_rect = pygame.Rect(toolbar_x + 30, toolbar_y + button_margin * 3 + button_height * 2, 100, button_height) - fill_button_rect = pygame.Rect(toolbar_x + 30, toolbar_y + button_margin * 4 + button_height * 3, 100, button_height) - - button_text_black = "Black" - button_text_white = "White" - button_pressed = False - - show_tile_labels = False - show_gps_tooltip = False - - pygame.init() - screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT)) - pygame.display.set_caption(f"Tile viewer - MAP {os.path.basename(base_dir)}") - font = pygame.font.SysFont(None, 16) - font_main = pygame.font.SysFont(None, 18) - font_b = pygame.font.SysFont(None, 16) - font_status = pygame.font.SysFont(None, 14) - clock = pygame.time.Clock() - - available_tiles = set() - - icon_bg, icon_label, icon_gps, icon_fill = get_button_icons() - - mouse_gps_coords = None - mouse_gps_rect = None - - show_index_progress = False - index_progress_percent = 0.0 - index_progress_text = "" - index_progress_done_drawn = False - show_render_progress = False - render_progress_percent = 0.0 - render_progress_text = "" - render_progress_done_drawn = False - - tiles_loading = False - tiles_loading_lock = threading.Lock() - need_redraw = True - zoom_change_pending = False - zoom_change_params = None - - def status_index_progress_callback(percent, text): - nonlocal show_index_progress, index_progress_percent, index_progress_text, need_redraw - show_index_progress = True - index_progress_percent = percent - index_progress_text = text - need_redraw = True - - def hide_index_progress(): - nonlocal show_index_progress, index_progress_percent, index_progress_text, need_redraw, index_progress_done_drawn - if index_progress_done_drawn: - show_index_progress = False - index_progress_percent = 0.0 - index_progress_text = "" - index_progress_done_drawn = False - need_redraw = True - - def status_render_progress_callback(percent, text): - nonlocal show_render_progress, render_progress_percent, render_progress_text, need_redraw - show_render_progress = True - render_progress_percent = percent - render_progress_text = text - need_redraw = True - - def hide_render_progress(): - nonlocal show_render_progress, render_progress_percent, render_progress_text, need_redraw, render_progress_done_drawn - if render_progress_done_drawn: - show_render_progress = False - render_progress_percent = 0.0 - render_progress_text = "" - render_progress_done_drawn = False - need_redraw = True - - def load_available_tiles(level, progress_callback=None): - directory = os.path.join(base_dir, str(level)) - available = index_available_tiles(directory, progress_callback) - return available, directory - - def get_tile_surface(x, y, zoom_level, directory, bg_color, fill_mode, show_tile_labels=False): - """Get tile surface with lazy loading - only load if visible""" - key = (zoom_level, x, y, bg_color, fill_mode) - if (x, y) not in available_tiles: - return None - - # Try to get from cache first - cached_surface = tile_cache.get(key) - if cached_surface is not None: - return cached_surface - - # Only load if tile is visible (lazy loading) - if not is_tile_visible(x, y, viewport_x, viewport_y): - logger.debug(f"Skipping lazy load for non-visible tile ({x}, {y})") - return None - - # Load and cache the surface - tile_file = get_tile_file(directory, x, y) - if not tile_file: - return None - - surface = render_tile_surface({'x': x, 'y': y, 'file': tile_file}, bg_color, fill_mode, show_tile_labels) - tile_cache.put(key, surface) - return surface - - def preload_tile_surfaces_threaded(tile_list, zoom_level, directory, bg_color, fill_mode, progress_callback=None, done_callback=None): - """Preload tiles using the persistent thread pool""" - total = len(tile_list) - completed_count = 0 - - def progress_callback_wrapper(future): - nonlocal completed_count - completed_count += 1 - if progress_callback is not None: - percent = completed_count / max(total, 1) - progress_callback(percent, "Loading visible tiles...") - - if completed_count >= total and done_callback: - done_callback() - - # Submit all tiles to the persistent thread pool - for x, y in tile_list: - tile_info = (x, y, zoom_level, directory, bg_color, fill_mode) - tile_loader.submit_tile_load(tile_info, progress_callback_wrapper) - - return None # No thread to return since we use persistent pool - - def set_tiles_loading(flag): - nonlocal tiles_loading, need_redraw - with tiles_loading_lock: - tiles_loading = flag - need_redraw = True - - def start_zoom_change(idx, last_mouse_pos, viewport_x, viewport_y, old_zoom_idx): - nonlocal zoom_change_pending, zoom_change_params - zoom_change_pending = True - zoom_change_params = (idx, last_mouse_pos, viewport_x, viewport_y, old_zoom_idx) - - show_index_progress = True - index_progress_percent = 0.0 - index_progress_text = "Indexing initial tiles..." - available_tiles, directory = load_available_tiles(zoom_levels[zoom_idx], status_index_progress_callback) - hide_index_progress() - viewport_x, viewport_y = center_viewport_on_central_tile(available_tiles) - - dragging = False - drag_start = None - running = True - last_mouse_pos = (VIEWPORT_SIZE // 2, VIEWPORT_SIZE // 2) - - while running: - if zoom_change_pending and not tiles_loading: - idx, last_mouse_pos_z, old_vx, old_vy, old_zoom_idx = zoom_change_params - show_index_progress = True - index_progress_percent = 0.0 - index_progress_text = "Indexing tiles..." - index_progress_done_drawn = False - available_tiles, directory = load_available_tiles(zoom_levels[idx], status_index_progress_callback) - if idx > old_zoom_idx: - lat, lon = pixel_to_latlon(last_mouse_pos_z[0], last_mouse_pos_z[1], old_vx, old_vy, zoom_levels[idx-1]) - else: - lat, lon = pixel_to_latlon(last_mouse_pos_z[0], last_mouse_pos_z[1], old_vx, old_vy, zoom_levels[idx+1]) - map_px, map_py = latlon_to_pixel(lat, lon, zoom_levels[idx]) - viewport_x, viewport_y = int(map_px - last_mouse_pos_z[0]), int(map_py - last_mouse_pos_z[1]) - viewport_x, viewport_y = clamp_viewport(viewport_x, viewport_y, available_tiles) - zoom_idx = idx - zoom_change_pending = False - need_redraw = True - logger.info(f"Changed to zoom level {zoom_levels[zoom_idx]}") - - mx, my = pygame.mouse.get_pos() - if show_gps_tooltip and 0 <= mx < VIEWPORT_SIZE and 0 <= my < VIEWPORT_SIZE: - lat, lon = pixel_to_latlon(mx, my, viewport_x, viewport_y, zoom_levels[zoom_idx]) - mouse_gps_coords = (lat, lon) - mouse_gps_rect = (mx, my) - else: - mouse_gps_coords = None - mouse_gps_rect = None - - screen.fill((70,70,70)) - pygame.draw.rect(screen, background_color, (0,0,VIEWPORT_SIZE,VIEWPORT_SIZE)) - - if available_tiles: - xs = [x for x, y in available_tiles] - ys = [y for x, y in available_tiles] - min_x = max_x = min_y = max_y = 0 - if xs and ys: - min_x, max_x = min(xs), max(xs) - min_y, max_y = min(ys), max(ys) - else: - min_x = max_x = min_y = max_y = 0 - - # Get visible tiles using the new function - visible_tiles = get_visible_tiles(available_tiles, viewport_x, viewport_y) - - # Only check for uncached tiles among visible ones (lazy loading) - uncached_tiles = [] - fill_polygons = config.config.get('fill_polygons', False) - for x, y in visible_tiles: - key = (zoom_levels[zoom_idx], x, y, background_color, fill_polygons) - if tile_cache.get(key) is None: - uncached_tiles.append((x, y)) - - if uncached_tiles and not tiles_loading: - set_tiles_loading(True) - show_render_progress = True - render_progress_percent = 0.0 - render_progress_text = "Loading visible tiles..." - def done_callback(): - set_tiles_loading(False) - preload_tile_surfaces_threaded( - uncached_tiles, zoom_levels[zoom_idx], directory, background_color, fill_polygons, - status_render_progress_callback, done_callback - ) - - for x, y in visible_tiles: - surf = get_tile_surface(x, y, zoom_levels[zoom_idx], directory, background_color, fill_polygons, show_tile_labels) - if surf: - px = x * TILE_SIZE - viewport_x - py = y * TILE_SIZE - viewport_y - screen.blit(surf, (px, py)) - - draw_tile_labels( - screen, font, available_tiles, viewport_x, viewport_y, zoom_levels[zoom_idx], background_color, show_tile_labels, directory, fill_polygons - ) - pygame.draw.rect(screen, (0,0,0), (toolbar_x, toolbar_y, TOOLBAR_WIDTH, VIEWPORT_SIZE)) - pygame.draw.line(screen, (160,160,160), (toolbar_x,0), (toolbar_x, VIEWPORT_SIZE)) - draw_button( - screen, - button_text_black if background_color == (255, 255, 255) else button_text_white, - button_rect, button_color, button_fg, button_border, font_b, - icon=icon_bg, pressed=button_pressed - ) - label_btn_text = "Tile labels ON" if show_tile_labels else "Tile labels OFF" - draw_button( - screen, label_btn_text, tile_label_button_rect, button_color, button_fg, button_border, font_b, - icon=icon_label, pressed=False - ) - gps_btn_text = "GPS Cursor ON" if show_gps_tooltip else "GPS Cursor OFF" - draw_button( - screen, gps_btn_text, gps_button_rect, button_color, button_fg, button_border, font_b, - icon=icon_gps, pressed=False - ) - - # Polygon fill button - fill_polygons = config.config.get('fill_polygons', False) - fill_btn_text = "Fill Polygons ON" if fill_polygons else "Fill Polygons OFF" - draw_button( - screen, fill_btn_text, fill_button_rect, button_color, button_fg, button_border, font_b, - icon=icon_fill, pressed=False - ) - pygame.draw.rect(screen, (0,0,0), (0, VIEWPORT_SIZE, WINDOW_WIDTH, STATUSBAR_HEIGHT)) - pygame.draw.line(screen, (160,160,160), (0, VIEWPORT_SIZE), (WINDOW_WIDTH, VIEWPORT_SIZE)) - zoom_text = f"Zoom level: {zoom_levels[zoom_idx]}" - zoom_img = font_status.render(zoom_text, True, (255,255,255)) - screen.blit(zoom_img, (16, VIEWPORT_SIZE + STATUSBAR_HEIGHT//2 - zoom_img.get_height()//2)) - - if show_gps_tooltip and mouse_gps_coords is not None: - lat, lon = mouse_gps_coords[0], mouse_gps_coords[1] - lat_gms = decimal_to_gms(lat, is_latitude=True) - lon_gms = decimal_to_gms(lon, is_latitude=False) - txt = f"lat: {lat:.6f} ({lat_gms})\nlon: {lon:.6f} ({lon_gms})" - tooltip_lines = txt.split('\n') - tooltip_surfs = [font.render(line, True, (255,255,255)) for line in tooltip_lines] - tw = max(s.get_width() for s in tooltip_surfs) - th = sum(s.get_height() for s in tooltip_surfs) - tm = 4 - mx, my = mouse_gps_rect[:2] - tooltip_rect = pygame.Rect(mx+10, my+10, tw+tm*2, th+tm*2) - pygame.draw.rect(screen, (0,0,0), tooltip_rect) - pygame.draw.rect(screen, (200,200,200), tooltip_rect, 1) - yoff = tooltip_rect.top + tm - for surf in tooltip_surfs: - screen.blit(surf, (tooltip_rect.left + tm, yoff)) - yoff += surf.get_height() - - if show_index_progress: - show_status_progress_bar(screen, index_progress_percent, index_progress_text, font_main) - if index_progress_percent >= 1.0: - index_progress_done_drawn = True - else: - index_progress_done_drawn = False - if show_render_progress: - show_status_progress_bar(screen, render_progress_percent, render_progress_text, font_main) - if render_progress_percent >= 1.0 and not uncached_tiles and not tiles_loading: - render_progress_done_drawn = True - else: - render_progress_done_drawn = False - - pygame.display.flip() - need_redraw = False - - # Cleanup completed futures periodically - completed_futures = tile_loader.cleanup_completed_futures() - if completed_futures > 0: - logger.debug(f"Cleaned up {completed_futures} completed futures") - - if index_progress_done_drawn: - hide_index_progress() - if render_progress_done_drawn: - hide_render_progress() - - can_interact = not show_index_progress and not show_render_progress and not tiles_loading - - for event in pygame.event.get(): - if event.type == pygame.QUIT: - running = False - if event.type == pygame.KEYDOWN: - base_step = VIEWPORT_SIZE // 4 - zoom_factor = 1 + ((zoom_levels[zoom_idx] - min_zoom) * 0.23) if zoom_levels[zoom_idx] > min_zoom else 1 - step = int(base_step * zoom_factor) - if event.key == pygame.K_LEFT and can_interact: - viewport_x = max(min_x * TILE_SIZE, viewport_x - step) - need_redraw = True - elif event.key == pygame.K_RIGHT and can_interact: - viewport_x = min(viewport_x + step, (max_x * TILE_SIZE + TILE_SIZE) - VIEWPORT_SIZE) - need_redraw = True - elif event.key == pygame.K_UP and can_interact: - viewport_y = max(min_y * TILE_SIZE, viewport_y - step) - need_redraw = True - elif event.key == pygame.K_DOWN and can_interact: - viewport_y = min(viewport_y + step, (max_y * TILE_SIZE + TILE_SIZE) - VIEWPORT_SIZE) - need_redraw = True - elif event.key == pygame.K_LEFTBRACKET and can_interact and not zoom_change_pending: - if zoom_idx > 0: - start_zoom_change(zoom_idx-1, last_mouse_pos, viewport_x, viewport_y, zoom_idx) - elif event.key == pygame.K_RIGHTBRACKET and can_interact and not zoom_change_pending: - if zoom_idx < len(zoom_levels) - 1: - start_zoom_change(zoom_idx+1, last_mouse_pos, viewport_x, viewport_y, zoom_idx) - elif event.key == pygame.K_l and can_interact: - show_tile_labels = not show_tile_labels - need_redraw = True - elif event.key == pygame.K_f and can_interact: - # Toggle polygon filling - config.config['fill_polygons'] = not config.config.get('fill_polygons', False) - config.save_config() - tile_cache.clear() # Clear cache to force redraw with new fill setting - need_redraw = True - logger.info(f"Polygon filling: {'ON' if config.config['fill_polygons'] else 'OFF'}") - elif event.type == pygame.MOUSEBUTTONDOWN: - if event.button == 1 and can_interact: - if tile_label_button_rect.collidepoint(event.pos): - show_tile_labels = not show_tile_labels - need_redraw = True - elif button_rect.collidepoint(event.pos): - background_color = (255, 255, 255) if background_color == (0, 0, 0) else (0, 0, 0) - need_redraw = True - logger.info(f"Background color changed to {'white' if background_color == (255, 255, 255) else 'black'}") - elif gps_button_rect.collidepoint(event.pos): - show_gps_tooltip = not show_gps_tooltip - need_redraw = True - elif fill_button_rect.collidepoint(event.pos): - # Toggle polygon filling - config.config['fill_polygons'] = not config.config.get('fill_polygons', False) - config.save_config() - tile_cache.clear() # Clear cache to force redraw with new fill setting - need_redraw = True - logger.info(f"Polygon filling: {'ON' if config.config['fill_polygons'] else 'OFF'}") - else: - dragging = True - drag_start = event.pos - drag_viewport_start = (viewport_x, viewport_y) - elif event.button == 4 and can_interact and not zoom_change_pending: - if zoom_idx < len(zoom_levels) - 1: - start_zoom_change(zoom_idx+1, last_mouse_pos, viewport_x, viewport_y, zoom_idx) - elif event.button == 5 and can_interact and not zoom_change_pending: - if zoom_idx > 0: - start_zoom_change(zoom_idx-1, last_mouse_pos, viewport_x, viewport_y, zoom_idx) - elif event.type == pygame.MOUSEBUTTONUP: - if event.button == 1: - dragging = False - drag_start = None - elif event.type == pygame.MOUSEMOTION: - mx, my = event.pos - last_mouse_pos = (mx, my) - if dragging: - dx = drag_start[0] - event.pos[0] - dy = drag_start[1] - event.pos[1] - viewport_x = drag_viewport_start[0] + dx - viewport_y = drag_viewport_start[1] + dy - viewport_x, viewport_y = clamp_viewport(viewport_x, viewport_y, available_tiles) - need_redraw = True - - clock.tick(config.get('fps_limit', 30)) - - # Shutdown thread pool before quitting - tile_loader.shutdown() - pygame.quit() - -if __name__ == "__main__": - if len(sys.argv) < 2: - logger.info("Usage: python tile_viewer.py VECTORMAP") - logger.info("Keys: [arrows] move, [ ] [ ] zoom level, mouse scroll: zoom level") - logger.info("Mouse: drag to pan, buttons for background, tile labels, GPS cursor, fill polygons. [l] toggle labels") - logger.info("Example: python tile_viewer.py VECTORMAP") - logger.info("Note: Place features.json in current directory for dynamic palette support") - sys.exit(1) - main(sys.argv[1]) \ No newline at end of file From 8fc437fb3c58a581b3975fc01280dff0d773e97b Mon Sep 17 00:00:00 2001 From: jgauchia Date: Fri, 2 Jan 2026 19:57:24 +0100 Subject: [PATCH 02/16] fix: Simplify zoom levels files --- .gitignore | 2 +- fgb_viewer.py | 364 ++++++++++++-------------------------------------- pbf_to_fgb.py | 245 ++++++++++++--------------------- 3 files changed, 178 insertions(+), 433 deletions(-) diff --git a/.gitignore b/.gitignore index eee53e6..f4c51a3 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,4 @@ VECTMAP/ migracion.md venv/ CHANGELOG_FGB_MIGRATION.md - +fgb_output/ diff --git a/fgb_viewer.py b/fgb_viewer.py index 27faa30..306b94f 100644 --- a/fgb_viewer.py +++ b/fgb_viewer.py @@ -2,7 +2,7 @@ """ FlatGeobuf Viewer - ESP32 Map Simulator -Simulates the ESP32 map rendering behavior using FlatGeobuf files. +Simulates the ESP32 map rendering behavior using unified FlatGeobuf files. Displays a 768x768 viewport (3x3 tiles of 256px) centered on given coordinates. Usage: @@ -16,6 +16,7 @@ import logging from typing import Dict, List, Tuple, Optional import json +import re try: import pygame @@ -44,19 +45,6 @@ WINDOW_WIDTH = VIEWPORT_SIZE + TOOLBAR_WIDTH WINDOW_HEIGHT = VIEWPORT_SIZE + STATUSBAR_HEIGHT -# Layer rendering order (lower = rendered first = behind) -LAYER_ORDER = [ - 'water', - 'landuse', - 'terrain', - 'railways', - 'roads', - 'infrastructure', - 'buildings', - 'amenities', - 'places' -] - def deg2num(lat_deg: float, lon_deg: float, zoom: int) -> Tuple[float, float]: """Convert lat/lon to tile numbers (floating point for sub-tile precision).""" @@ -77,67 +65,28 @@ def num2deg(xtile: float, ytile: float, zoom: int) -> Tuple[float, float]: def get_bbox_for_viewport(center_lat: float, center_lon: float, zoom: int) -> Tuple[float, float, float, float]: - """ - Calculate bounding box for 768x768 viewport centered on coordinates. - Returns (min_lon, min_lat, max_lon, max_lat) - """ - # Get tile coordinates for center + """Calculate bounding box for 768x768 viewport centered on coordinates.""" center_x, center_y = deg2num(center_lat, center_lon, zoom) - - # Calculate extent in tiles (768px = 3 tiles of 256px) - tiles_extent = VIEWPORT_SIZE / TILE_SIZE / 2.0 # 1.5 tiles from center - - # Get corner tile coordinates + tiles_extent = VIEWPORT_SIZE / TILE_SIZE / 2.0 min_tile_x = center_x - tiles_extent max_tile_x = center_x + tiles_extent min_tile_y = center_y - tiles_extent max_tile_y = center_y + tiles_extent - - # Convert back to lat/lon max_lat, min_lon = num2deg(min_tile_x, min_tile_y, zoom) min_lat, max_lon = num2deg(max_tile_x, max_tile_y, zoom) - return (min_lon, min_lat, max_lon, max_lat) def latlon_to_pixel(lat: float, lon: float, bbox: Tuple[float, float, float, float]) -> Tuple[int, int]: """Convert lat/lon to pixel coordinates within viewport.""" min_lon, min_lat, max_lon, max_lat = bbox - - # Normalize to 0-1 x_norm = (lon - min_lon) / (max_lon - min_lon) - y_norm = (max_lat - lat) / (max_lat - min_lat) # Y is inverted - - # Scale to viewport + y_norm = (max_lat - lat) / (max_lat - min_lat) px = int(x_norm * VIEWPORT_SIZE) py = int(y_norm * VIEWPORT_SIZE) - return px, py -def pixel_to_latlon(px: int, py: int, bbox: Tuple[float, float, float, float]) -> Tuple[float, float]: - """Convert pixel coordinates to lat/lon.""" - min_lon, min_lat, max_lon, max_lat = bbox - - x_norm = px / VIEWPORT_SIZE - y_norm = py / VIEWPORT_SIZE - - lon = min_lon + x_norm * (max_lon - min_lon) - lat = max_lat - y_norm * (max_lat - min_lat) - - return lat, lon - - -def hex_to_rgb(hex_color: str) -> Tuple[int, int, int]: - """Convert hex color to RGB tuple.""" - try: - if hex_color.startswith('#'): - hex_color = hex_color[1:] - return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) - except: - return (255, 255, 255) - - def rgb332_to_rgb888(c: int) -> Tuple[int, int, int]: """Convert RGB332 to RGB888.""" r = (c & 0xE0) @@ -152,66 +101,63 @@ def darken_color(rgb: Tuple[int, int, int], amount: float = 0.3) -> Tuple[int, i class FGBViewer: - """FlatGeobuf map viewer simulating ESP32 behavior.""" + """FlatGeobuf map viewer simulating ESP32 behavior with unified files.""" def __init__(self, fgb_dir: str, config_file: str = None): self.fgb_dir = fgb_dir self.config = {} - self.layer_files: Dict[str, str] = {} # layer_name -> filepath (flat structure fallback) - self.zoom_dirs: Dict[int, str] = {} # zoom_level -> directory path (new structure) + self.zoom_files: Dict[Tuple[int, int], str] = {} # (min_zoom, max_zoom) -> filepath - # Load config if provided if config_file and os.path.exists(config_file): with open(config_file, 'r') as f: self.config = json.load(f) logger.info(f"Loaded config from {config_file}") - # Index FGB files (but don't load them) - self._index_layers() + self._index_files() - # Viewport state self.center_lat = 0.0 self.center_lon = 0.0 self.zoom = 14 self.bbox = None - - # Display options - self.background_color = (255, 255, 255) # White default + self.background_color = (255, 255, 255) self.fill_polygons = True self.show_tile_grid = False - - # Stats self.last_query_stats = {} - def _index_layers(self): - """Index available zoom levels with FlatGeobuf files.""" + def _index_files(self): + """Index available unified FGB files.""" if not os.path.isdir(self.fgb_dir): logger.error(f"Directory not found: {self.fgb_dir}") return - # Check for zoom-level directory structure (new format) - self.zoom_dirs = {} - for item in os.listdir(self.fgb_dir): - item_path = os.path.join(self.fgb_dir, item) - if os.path.isdir(item_path) and item.isdigit(): - zoom_level = int(item) - self.zoom_dirs[zoom_level] = item_path - # Count FGB files in this zoom directory - fgb_count = len([f for f in os.listdir(item_path) if f.endswith('.fgb')]) - logger.info(f"Indexed zoom {zoom_level}: {fgb_count} layers") - - if self.zoom_dirs: - logger.info(f"Zoom levels available: {sorted(self.zoom_dirs.keys())}") + # Look for unified files like z6-9.fgb, z10-12.fgb, z13-17.fgb + pattern = re.compile(r'^z(\d+)-(\d+)\.fgb$') + + for filename in os.listdir(self.fgb_dir): + match = pattern.match(filename) + if match: + min_z = int(match.group(1)) + max_z = int(match.group(2)) + filepath = os.path.join(self.fgb_dir, filename) + self.zoom_files[(min_z, max_z)] = filepath + file_size = os.path.getsize(filepath) / (1024 * 1024) + logger.info(f"Indexed: {filename} (z{min_z}-{max_z}, {file_size:.1f} MB)") + + if self.zoom_files: + logger.info(f"Total unified files: {len(self.zoom_files)}") else: - # Fallback to flat structure (old format) - for filename in os.listdir(self.fgb_dir): - if filename.endswith('.fgb'): - layer_name = filename[:-4] - filepath = os.path.join(self.fgb_dir, filename) - self.layer_files[layer_name] = filepath - file_size = os.path.getsize(filepath) / (1024 * 1024) - logger.info(f"Indexed layer: {layer_name} ({file_size:.1f} MB)") - logger.info(f"Total layers indexed: {len(self.layer_files)} (flat structure)") + logger.warning("No unified FGB files found (expected z*-*.fgb)") + + def _get_file_for_zoom(self) -> Optional[str]: + """Get the FGB file for the current zoom level.""" + for (min_z, max_z), filepath in self.zoom_files.items(): + if min_z <= self.zoom <= max_z: + return filepath + # Fallback: return file with highest max_zoom + if self.zoom_files: + best_range = max(self.zoom_files.keys(), key=lambda x: x[1]) + return self.zoom_files[best_range] + return None def set_center(self, lat: float, lon: float, zoom: int = None): """Set viewport center and optionally zoom.""" @@ -221,63 +167,31 @@ def set_center(self, lat: float, lon: float, zoom: int = None): self.zoom = zoom self.bbox = get_bbox_for_viewport(self.center_lat, self.center_lon, self.zoom) logger.info(f"Viewport centered at ({lat:.6f}, {lon:.6f}) zoom {self.zoom}") - logger.info(f"Bounding box: {self.bbox}") - - def _get_zoom_dir(self) -> Optional[str]: - """Get the directory for the current zoom level.""" - if not self.zoom_dirs: - return None - # Find the best matching zoom directory - available_zooms = sorted(self.zoom_dirs.keys()) - best_zoom = available_zooms[0] # Default to lowest - - for z in available_zooms: - if z <= self.zoom: - best_zoom = z - else: - break - - return self.zoom_dirs.get(best_zoom) - - def query_features(self, layer_name: str) -> Optional[gpd.GeoDataFrame]: - """Query features from layer within current bbox using R-Tree spatial index. - - This simulates ESP32 behavior - reads only features within bbox directly - from the FGB file using its built-in R-Tree index. - """ + def query_features(self) -> Optional[gpd.GeoDataFrame]: + """Query features from unified file within current bbox.""" if self.bbox is None: return None - # Determine file path based on structure - if self.zoom_dirs: - # New zoom-based structure - zoom_dir = self._get_zoom_dir() - if zoom_dir is None: - return None - filepath = os.path.join(zoom_dir, f"{layer_name}.fgb") - if not os.path.exists(filepath): - return None - else: - # Flat structure fallback - if layer_name not in self.layer_files: - return None - filepath = self.layer_files[layer_name] + filepath = self._get_file_for_zoom() + if filepath is None: + return None - # Query directly from file using bbox (uses FGB R-Tree index) import time start = time.time() try: - # gpd.read_file with bbox parameter uses R-Tree index for FlatGeobuf result = gpd.read_file(filepath, bbox=self.bbox) + elapsed = (time.time() - start) * 1000 - elapsed = (time.time() - start) * 1000 # ms + # Filter by min_zoom if available + if 'min_zoom' in result.columns: + result = result[result['min_zoom'] <= self.zoom] - # Calculate bytes read (memory usage of loaded data) bytes_read = result.memory_usage(deep=True).sum() if len(result) > 0 else 0 - self.last_query_stats[layer_name] = { + self.last_query_stats = { + 'file': os.path.basename(filepath), 'features': len(result), 'time_ms': elapsed, 'read_kb': bytes_read / 1024 @@ -285,7 +199,7 @@ def query_features(self, layer_name: str) -> Optional[gpd.GeoDataFrame]: return result except Exception as e: - logger.debug(f"Spatial query error for {layer_name}: {e}") + logger.error(f"Query error: {e}") return None def render_to_surface(self, surface: pygame.Surface): @@ -293,26 +207,9 @@ def render_to_surface(self, surface: pygame.Surface): if self.bbox is None: return - # Clear with background color surface.fill(self.background_color) - # Render layers in order - for layer_name in LAYER_ORDER: - # Check if layer exists (either in zoom dirs or flat structure) - if self.zoom_dirs: - zoom_dir = self._get_zoom_dir() - if zoom_dir and os.path.exists(os.path.join(zoom_dir, f"{layer_name}.fgb")): - self._render_layer(surface, layer_name) - elif layer_name in self.layer_files: - self._render_layer(surface, layer_name) - - # Draw tile grid if enabled - if self.show_tile_grid: - self._draw_tile_grid(surface) - - def _render_layer(self, surface: pygame.Surface, layer_name: str): - """Render a single layer.""" - features = self.query_features(layer_name) + features = self.query_features() if features is None or len(features) == 0: return @@ -321,19 +218,21 @@ def _render_layer(self, surface: pygame.Surface, layer_name: str): features = features.sort_values('priority') for idx, row in features.iterrows(): - self._render_feature(surface, row, layer_name) + self._render_feature(surface, row) - def _render_feature(self, surface: pygame.Surface, feature, layer_name: str): + if self.show_tile_grid: + self._draw_tile_grid(surface) + + def _render_feature(self, surface: pygame.Surface, feature): """Render a single feature.""" geom = feature.geometry if geom is None or geom.is_empty: return - # Get color from properties or default if 'color_rgb332' in feature.index and feature['color_rgb332']: color = rgb332_to_rgb888(int(feature['color_rgb332'])) else: - color = self._get_layer_default_color(layer_name) + color = (200, 200, 200) geom_type = geom.geom_type @@ -364,7 +263,6 @@ def _render_linestring(self, surface: pygame.Surface, line, color: Tuple[int, in points.append((px, py)) if len(points) >= 2: - # Clip to viewport (simple) pygame.draw.lines(surface, color, False, points, width) def _render_polygon(self, surface: pygame.Surface, polygon, color: Tuple[int, int, int]): @@ -372,7 +270,6 @@ def _render_polygon(self, surface: pygame.Surface, polygon, color: Tuple[int, in if polygon.is_empty: return - # Get exterior ring exterior = polygon.exterior points = [] for coord in exterior.coords: @@ -382,37 +279,17 @@ def _render_polygon(self, surface: pygame.Surface, polygon, color: Tuple[int, in if len(points) >= 3: if self.fill_polygons: pygame.draw.polygon(surface, color, points) - # Draw border border_color = darken_color(color, 0.4) pygame.draw.polygon(surface, border_color, points, 1) else: pygame.draw.polygon(surface, color, points, 1) - def _get_layer_default_color(self, layer_name: str) -> Tuple[int, int, int]: - """Get default color for layer.""" - defaults = { - 'water': (136, 201, 250), # Light blue - 'landuse': (200, 230, 200), # Light green - 'roads': (255, 255, 255), # White - 'railways': (128, 128, 128), # Gray - 'buildings': (221, 221, 221), # Light gray - 'amenities': (255, 200, 200), # Light red - 'infrastructure': (200, 200, 200), - 'terrain': (139, 115, 85), # Brown - 'places': (100, 100, 100) - } - return defaults.get(layer_name, (200, 200, 200)) - def _draw_tile_grid(self, surface: pygame.Surface): """Draw tile grid overlay.""" grid_color = (100, 100, 100) - - # Draw vertical lines for i in range(4): x = i * TILE_SIZE pygame.draw.line(surface, grid_color, (x, 0), (x, VIEWPORT_SIZE), 1) - - # Draw horizontal lines for i in range(4): y = i * TILE_SIZE pygame.draw.line(surface, grid_color, (0, y), (VIEWPORT_SIZE, y), 1) @@ -423,9 +300,6 @@ def draw_button(surface, text, rect, bg_color, fg_color, border_color, font, pre radius = 8 pygame.draw.rect(surface, bg_color, rect, border_radius=radius) pygame.draw.rect(surface, border_color, rect, 2, border_radius=radius) - if pressed: - pygame.draw.rect(surface, border_color, rect, 4, border_radius=radius) - label = font.render(text, True, fg_color) text_rect = label.get_rect(center=rect.center) surface.blit(label, text_rect) @@ -433,31 +307,21 @@ def draw_button(surface, text, rect, bg_color, fg_color, border_color, font, pre def format_coord(decimal: float, is_latitude: bool = True) -> str: """Format decimal coordinate as degrees/minutes/seconds.""" - sign = "" - if is_latitude: - sign = "N" if decimal >= 0 else "S" - else: - sign = "E" if decimal >= 0 else "W" - + sign = "N" if is_latitude else "E" + if decimal < 0: + sign = "S" if is_latitude else "W" decimal = abs(decimal) degrees = int(decimal) minutes_full = (decimal - degrees) * 60 minutes = int(minutes_full) seconds = (minutes_full - minutes) * 60 - return f"{degrees}°{minutes}'{seconds:.1f}\"{sign}" def main(): parser = argparse.ArgumentParser( - description='FlatGeobuf Map Viewer - ESP32 Simulator', - formatter_class=argparse.RawDescriptionHelpFormatter, + description='FlatGeobuf Map Viewer - ESP32 Simulator (Unified Files)', epilog=""" -Examples: - python fgb_viewer.py ./output --lat 42.5063 --lon 1.5218 - python fgb_viewer.py ./output --lat 42.5063 --lon 1.5218 --zoom 16 - python fgb_viewer.py ./output --lat 41.3851 --lon 2.1734 --zoom 14 --config features.json - Controls: Arrow keys / Mouse drag: Pan map Mouse wheel / [ ] keys: Zoom in/out @@ -468,7 +332,7 @@ def main(): """ ) - parser.add_argument('fgb_dir', help='Directory containing FGB files') + parser.add_argument('fgb_dir', help='Directory containing unified FGB files') parser.add_argument('--lat', type=float, required=True, help='Center latitude') parser.add_argument('--lon', type=float, required=True, help='Center longitude') parser.add_argument('--zoom', type=int, default=14, help='Zoom level (default: 14)') @@ -484,27 +348,19 @@ def main(): logger.error("geopandas is required. Install with: pip install geopandas pyogrio") sys.exit(1) - if not os.path.isdir(args.fgb_dir): - logger.error(f"Directory not found: {args.fgb_dir}") - sys.exit(1) - - # Initialize viewer viewer = FGBViewer(args.fgb_dir, args.config) viewer.set_center(args.lat, args.lon, args.zoom) - # Initialize pygame pygame.init() screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT)) - pygame.display.set_caption(f"FGB Viewer - {os.path.basename(args.fgb_dir)}") + pygame.display.set_caption(f"FGB Viewer (Unified) - {os.path.basename(args.fgb_dir)}") font = pygame.font.SysFont(None, 18) font_small = pygame.font.SysFont(None, 14) clock = pygame.time.Clock() - # Create viewport surface viewport_surface = pygame.Surface((VIEWPORT_SIZE, VIEWPORT_SIZE)) - # Button definitions button_margin = 10 button_height = 35 button_width = TOOLBAR_WIDTH - 20 @@ -513,14 +369,11 @@ def main(): fill_button_rect = pygame.Rect(VIEWPORT_SIZE + 10, button_margin * 2 + button_height, button_width, button_height) grid_button_rect = pygame.Rect(VIEWPORT_SIZE + 10, button_margin * 3 + button_height * 2, button_width, button_height) - # State dragging = False drag_start = None drag_center_start = None need_redraw = True running = True - - # Pan speed (degrees per key press) pan_speed_base = 0.01 while running: @@ -529,7 +382,6 @@ def main(): running = False elif event.type == pygame.KEYDOWN: - # Calculate pan speed based on zoom pan_speed = pan_speed_base / (2 ** (viewer.zoom - 10)) if event.key == pygame.K_LEFT: @@ -553,10 +405,7 @@ def main(): viewer.set_center(viewer.center_lat, viewer.center_lon, viewer.zoom + 1) need_redraw = True elif event.key == pygame.K_b: - if viewer.background_color == (255, 255, 255): - viewer.background_color = (0, 0, 0) - else: - viewer.background_color = (255, 255, 255) + viewer.background_color = (0, 0, 0) if viewer.background_color == (255, 255, 255) else (255, 255, 255) need_redraw = True elif event.key == pygame.K_f: viewer.fill_polygons = not viewer.fill_polygons @@ -570,12 +419,9 @@ def main(): elif event.type == pygame.MOUSEBUTTONDOWN: mx, my = event.pos - if event.button == 1: # Left click + if event.button == 1: if bg_button_rect.collidepoint(mx, my): - if viewer.background_color == (255, 255, 255): - viewer.background_color = (0, 0, 0) - else: - viewer.background_color = (255, 255, 255) + viewer.background_color = (0, 0, 0) if viewer.background_color == (255, 255, 255) else (255, 255, 255) need_redraw = True elif fill_button_rect.collidepoint(mx, my): viewer.fill_polygons = not viewer.fill_polygons @@ -588,11 +434,11 @@ def main(): drag_start = (mx, my) drag_center_start = (viewer.center_lat, viewer.center_lon) - elif event.button == 4: # Scroll up - zoom in + elif event.button == 4: if viewer.zoom < 19: viewer.set_center(viewer.center_lat, viewer.center_lon, viewer.zoom + 1) need_redraw = True - elif event.button == 5: # Scroll down - zoom out + elif event.button == 5: if viewer.zoom > 1: viewer.set_center(viewer.center_lat, viewer.center_lon, viewer.zoom - 1) need_redraw = True @@ -606,7 +452,6 @@ def main(): dx = event.pos[0] - drag_start[0] dy = event.pos[1] - drag_start[1] - # Convert pixel delta to lat/lon delta if viewer.bbox: min_lon, min_lat, max_lon, max_lat = viewer.bbox lon_per_pixel = (max_lon - min_lon) / VIEWPORT_SIZE @@ -618,22 +463,14 @@ def main(): viewer.set_center(new_lat, new_lon) need_redraw = True - # Render if need_redraw: - # Render map to viewport viewer.render_to_surface(viewport_surface) - - # Clear screen screen.fill((50, 50, 50)) - - # Draw viewport screen.blit(viewport_surface, (0, 0)) - # Draw toolbar background + # Toolbar pygame.draw.rect(screen, (30, 30, 30), (VIEWPORT_SIZE, 0, TOOLBAR_WIDTH, VIEWPORT_SIZE)) - pygame.draw.line(screen, (100, 100, 100), (VIEWPORT_SIZE, 0), (VIEWPORT_SIZE, VIEWPORT_SIZE)) - # Draw buttons button_bg = (50, 50, 50) button_fg = (255, 255, 255) button_border = (100, 100, 100) @@ -647,60 +484,35 @@ def main(): grid_text = "Grid: ON" if viewer.show_tile_grid else "Grid: OFF" draw_button(screen, grid_text, grid_button_rect, button_bg, button_fg, button_border, font_small) - # Draw info in toolbar info_y = button_margin * 4 + button_height * 3 + 20 info_color = (200, 200, 200) - # Coordinates - lat_text = f"Lat: {viewer.center_lat:.6f}" - lon_text = f"Lon: {viewer.center_lon:.6f}" - zoom_text = f"Zoom: {viewer.zoom}" - - screen.blit(font_small.render(lat_text, True, info_color), (VIEWPORT_SIZE + 10, info_y)) - screen.blit(font_small.render(lon_text, True, info_color), (VIEWPORT_SIZE + 10, info_y + 18)) - screen.blit(font_small.render(zoom_text, True, info_color), (VIEWPORT_SIZE + 10, info_y + 36)) - - # DMS format - lat_dms = format_coord(viewer.center_lat, True) - lon_dms = format_coord(viewer.center_lon, False) - screen.blit(font_small.render(lat_dms, True, info_color), (VIEWPORT_SIZE + 10, info_y + 60)) - screen.blit(font_small.render(lon_dms, True, info_color), (VIEWPORT_SIZE + 10, info_y + 78)) - - # Layer query stats (R-Tree queries) - layer_y = info_y + 110 - screen.blit(font_small.render("R-Tree Queries:", True, info_color), (VIEWPORT_SIZE + 10, layer_y)) - total_features = 0 - total_time = 0 - total_size_kb = 0 - for i, layer_name in enumerate(LAYER_ORDER): - if layer_name in viewer.last_query_stats: - stats = viewer.last_query_stats[layer_name] - read_kb = stats.get('read_kb', 0) - text = f" {layer_name}: {stats['features']} ({stats['time_ms']:.0f}ms) [{read_kb:.0f}KB]" - total_features += stats['features'] - total_time += stats['time_ms'] - total_size_kb += read_kb - screen.blit(font_small.render(text, True, (150, 150, 150)), (VIEWPORT_SIZE + 10, layer_y + 18 + i * 14)) - - # Total stats - summary_y = layer_y + 18 + len(LAYER_ORDER) * 14 + 10 - screen.blit(font_small.render(f"Total: {total_features} features", True, (100, 200, 100)), (VIEWPORT_SIZE + 10, summary_y)) - screen.blit(font_small.render(f"Query time: {total_time:.0f}ms", True, (100, 200, 100)), (VIEWPORT_SIZE + 10, summary_y + 16)) - screen.blit(font_small.render(f"Data read: {total_size_kb:.0f}KB", True, (100, 200, 100)), (VIEWPORT_SIZE + 10, summary_y + 32)) - - # Draw status bar - pygame.draw.rect(screen, (30, 30, 30), (0, VIEWPORT_SIZE, WINDOW_WIDTH, STATUSBAR_HEIGHT)) - pygame.draw.line(screen, (100, 100, 100), (0, VIEWPORT_SIZE), (WINDOW_WIDTH, VIEWPORT_SIZE)) + screen.blit(font_small.render(f"Lat: {viewer.center_lat:.6f}", True, info_color), (VIEWPORT_SIZE + 10, info_y)) + screen.blit(font_small.render(f"Lon: {viewer.center_lon:.6f}", True, info_color), (VIEWPORT_SIZE + 10, info_y + 18)) + screen.blit(font_small.render(f"Zoom: {viewer.zoom}", True, info_color), (VIEWPORT_SIZE + 10, info_y + 36)) - # Status text + screen.blit(font_small.render(format_coord(viewer.center_lat, True), True, info_color), (VIEWPORT_SIZE + 10, info_y + 60)) + screen.blit(font_small.render(format_coord(viewer.center_lon, False), True, info_color), (VIEWPORT_SIZE + 10, info_y + 78)) + + # Query stats + stats_y = info_y + 110 + screen.blit(font_small.render("Query Stats:", True, info_color), (VIEWPORT_SIZE + 10, stats_y)) + if viewer.last_query_stats: + s = viewer.last_query_stats + screen.blit(font_small.render(f" File: {s.get('file', 'N/A')}", True, (150, 150, 150)), (VIEWPORT_SIZE + 10, stats_y + 18)) + screen.blit(font_small.render(f" Features: {s.get('features', 0)}", True, (150, 150, 150)), (VIEWPORT_SIZE + 10, stats_y + 32)) + screen.blit(font_small.render(f" Time: {s.get('time_ms', 0):.0f}ms", True, (150, 150, 150)), (VIEWPORT_SIZE + 10, stats_y + 46)) + screen.blit(font_small.render(f" Data: {s.get('read_kb', 0):.0f}KB", True, (150, 150, 150)), (VIEWPORT_SIZE + 10, stats_y + 60)) + + # Status bar + pygame.draw.rect(screen, (30, 30, 30), (0, VIEWPORT_SIZE, WINDOW_WIDTH, STATUSBAR_HEIGHT)) if viewer.bbox: min_lon, min_lat, max_lon, max_lat = viewer.bbox bbox_text = f"BBox: ({min_lat:.4f}, {min_lon:.4f}) to ({max_lat:.4f}, {max_lon:.4f})" screen.blit(font_small.render(bbox_text, True, (200, 200, 200)), (10, VIEWPORT_SIZE + 10)) - # Resolution info meters_per_pixel = 156543.03392 * math.cos(math.radians(viewer.center_lat)) / (2 ** viewer.zoom) - res_text = f"Resolution: {meters_per_pixel:.2f} m/px | Viewport: {VIEWPORT_SIZE}x{VIEWPORT_SIZE}px" + res_text = f"Resolution: {meters_per_pixel:.2f} m/px" screen.blit(font_small.render(res_text, True, (200, 200, 200)), (10, VIEWPORT_SIZE + 30)) pygame.display.flip() diff --git a/pbf_to_fgb.py b/pbf_to_fgb.py index 07bd06d..bb65ae1 100644 --- a/pbf_to_fgb.py +++ b/pbf_to_fgb.py @@ -3,10 +3,16 @@ PBF to FlatGeobuf Converter Converts OpenStreetMap .pbf files to FlatGeobuf (.fgb) format with spatial indexing. -Filters features based on features.json configuration and organizes output by layer. +Generates ONE unified file per zoom range with all layers combined. Usage: python pbf_to_fgb.py input.pbf output_dir features.json [--zoom 6-17] + +Output structure: + output_dir/ + ├── z6-9.fgb # All layers combined for zooms 6-9 + ├── z10-12.fgb # All layers combined for zooms 10-12 + └── z13-17.fgb # All layers combined for zooms 13-17 """ import os @@ -44,16 +50,21 @@ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) +# Zoom ranges for unified files +ZOOM_RANGES = [ + (6, 9), # Low zoom: major features only + (10, 12), # Medium zoom: more detail + (13, 17), # High zoom: full detail +] + # Layer definitions based on feature types LAYER_MAPPING = { - # Water features 'water': [ 'natural=water', 'natural=coastline', 'natural=bay', 'waterway=riverbank', 'waterway=dock', 'waterway=boatyard', 'waterway=river', 'waterway=stream', 'waterway=canal', 'natural=spring', 'natural=wetland' ], - # Land use and natural areas 'landuse': [ 'natural=beach', 'natural=sand', 'natural=wood', 'landuse=forest', 'natural=forest', 'natural=scrub', @@ -66,7 +77,6 @@ 'landuse=construction', 'landuse=cemetery', 'landuse=allotments', 'leisure=stadium', 'leisure=sports_centre', 'leisure=playground' ], - # Roads and paths 'roads': [ 'highway=motorway', 'highway=motorway_link', 'highway=trunk', 'highway=trunk_link', @@ -80,39 +90,46 @@ 'highway=cycleway', 'highway=steps', 'highway=crossing', 'highway=bus_stop' ], - # Railways 'railways': [ 'railway=rail', 'railway=subway', 'railway=tram' ], - # Buildings 'buildings': [ 'building', 'man_made=tower' ], - # Amenities 'amenities': [ 'amenity=parking', 'amenity=hospital', 'amenity=school', 'amenity=university', 'amenity=place_of_worship' ], - # Bridges and aeroways 'infrastructure': [ 'bridge=yes', 'man_made=bridge', 'aeroway=runway', 'aeroway=taxiway', 'aeroway=apron', 'tunnel=yes' ], - # Natural terrain features 'terrain': [ 'natural=peak', 'natural=ridge', 'natural=volcano', 'natural=cliff', 'natural=tree_row', 'natural=tree' ], - # Places 'places': [ 'place=state', 'place=town', 'place=village', 'place=hamlet' ] } +# Layer rendering priority (lower = rendered first = behind) +LAYER_PRIORITY = { + 'water': 10, + 'landuse': 20, + 'terrain': 30, + 'railways': 40, + 'roads': 50, + 'infrastructure': 60, + 'buildings': 70, + 'amenities': 80, + 'places': 90 +} + def get_layer_for_tags(tags: Dict[str, str]) -> Optional[str]: """Determine which layer a feature belongs to based on its tags.""" @@ -123,7 +140,6 @@ def get_layer_for_tags(tags: Dict[str, str]) -> Optional[str]: if key in tags and tags[key] == value: return layer_name else: - # Key-only match (e.g., 'building') if feature_key in tags: return layer_name return None @@ -175,28 +191,6 @@ def hex_to_rgb332(hex_color: str) -> int: return 0xFF -def get_simplification_tolerance(zoom: int) -> float: - """Get Douglas-Peucker simplification tolerance based on zoom level.""" - # Higher zoom = less simplification (more detail) - tolerances = { - 6: 0.001, - 7: 0.0008, - 8: 0.0005, - 9: 0.0003, - 10: 0.0002, - 11: 0.00015, - 12: 0.0001, - 13: 0.00008, - 14: 0.00005, - 15: 0.00003, - 16: 0.000015, - 17: 0.000012, - 18: 0.00001, - 19: 0.000008, - } - return tolerances.get(zoom, 0.000005) - - class OSMHandler(osmium.SimpleHandler): """Handler for processing OSM data from PBF files.""" @@ -204,24 +198,16 @@ def __init__(self, config: Dict, zoom_range: Tuple[int, int]): super().__init__() self.config = config self.min_zoom, self.max_zoom = zoom_range - - # Storage for features by layer - self.features_by_layer: Dict[str, List[Dict]] = defaultdict(list) - - # Statistics + self.features: List[Dict] = [] self.stats = { 'ways_processed': 0, 'relations_processed': 0, 'features_extracted': 0, 'features_filtered': 0 } - - # Progress tracking self.start_time = time.time() self.last_progress_time = time.time() - self.progress_interval = 5 # Log every 5 seconds - - # Build set of interesting tags from config + self.progress_interval = 5 self.interesting_tags = self._build_interesting_tags() def _build_interesting_tags(self) -> Set[str]: @@ -245,7 +231,6 @@ def _log_progress(self): extracted = self.stats['features_extracted'] elapsed = current_time - self.start_time mins, secs = divmod(int(elapsed), 60) - # Print in-place progress print(f"\r Progress: {ways:,} ways, {extracted:,} features [{mins}m {secs:02d}s]", end='', flush=True) def _has_interesting_tags(self, tags) -> bool: @@ -284,7 +269,6 @@ def way(self, w): self.stats['features_filtered'] += 1 return - # Get layer and zoom layer = get_layer_for_tags(tags) if layer is None: self.stats['features_filtered'] += 1 @@ -295,7 +279,6 @@ def way(self, w): self.stats['features_filtered'] += 1 return - # Build geometry from node locations (provided by osmium with locations=True) coords = [] for node in w.nodes: if node.location.valid(): @@ -305,7 +288,6 @@ def way(self, w): self.stats['features_filtered'] += 1 return - # Determine geometry type is_closed = len(coords) >= 4 and coords[0] == coords[-1] is_area = is_closed and ( 'building' in tags or @@ -317,12 +299,14 @@ def way(self, w): tags.get('area') == 'yes' ) - # Get attributes color = get_color_for_tags(tags, self.config) priority = get_priority_for_tags(tags, self.config) color_rgb332 = hex_to_rgb332(color) - # Create feature + # Combine layer priority with feature priority + layer_base_priority = LAYER_PRIORITY.get(layer, 50) + combined_priority = layer_base_priority + (priority % 10) + feature = { 'geometry_type': 'Polygon' if is_area else 'LineString', 'coordinates': coords, @@ -330,14 +314,13 @@ def way(self, w): 'osm_id': w.id, 'min_zoom': min_zoom, 'color_rgb332': color_rgb332, - 'priority': priority, + 'priority': combined_priority, 'layer': layer, - # Store primary tag for reference 'feature_type': self._get_primary_tag(tags) } } - self.features_by_layer[layer].append(feature) + self.features.append(feature) self.stats['features_extracted'] += 1 def _get_primary_tag(self, tags: Dict[str, str]) -> str: @@ -351,27 +334,32 @@ def _get_primary_tag(self, tags: Dict[str, str]) -> str: def relation(self, r): """Process relation - for multipolygons, etc.""" self.stats['relations_processed'] += 1 - # TODO: Handle multipolygon relations for complex areas pass -def write_fgb_layer(features: List[Dict], output_path: str, layer_name: str): - """Write features to FlatGeobuf file using GeoPandas.""" +def write_unified_fgb(features: List[Dict], output_path: str, max_zoom: int): + """Write all features to a single unified FlatGeobuf file.""" if not GEOPANDAS_AVAILABLE: logger.error("GeoPandas not available. Install with: pip install geopandas") return False if not features: - logger.warning(f"No features to write for layer {layer_name}") + logger.warning(f"No features to write") return False - logger.debug(f"Writing {len(features)} features to {output_path}") + # Filter features for this zoom range + filtered_features = [f for f in features if f['properties']['min_zoom'] <= max_zoom] + + if not filtered_features: + logger.warning(f"No features for max zoom {max_zoom}") + return False + + logger.info(f"Writing {len(filtered_features)} features to {output_path}") - # Convert features to GeoDataFrame geometries = [] properties_list = [] - for feature in features: + for feature in filtered_features: coords = feature['coordinates'] geom_type = feature['geometry_type'] props = feature['properties'] @@ -389,7 +377,6 @@ def write_fgb_layer(features: List[Dict], output_path: str, layer_name: str): geometries.append(geom) properties_list.append(props) else: - # Try to fix invalid polygon geom = geom.buffer(0) if geom.is_valid and not geom.is_empty: geometries.append(geom) @@ -399,15 +386,12 @@ def write_fgb_layer(features: List[Dict], output_path: str, layer_name: str): continue if not geometries: - logger.warning(f"No valid geometries for layer {layer_name}") + logger.warning(f"No valid geometries") return False - # Create GeoDataFrame gdf = gpd.GeoDataFrame(properties_list, geometry=geometries, crs="EPSG:4326") - # Write to FlatGeobuf with spatial index try: - # Suppress pyogrio/fiona logging during write import warnings pyogrio_logger = logging.getLogger('pyogrio') fiona_logger = logging.getLogger('fiona') @@ -420,11 +404,11 @@ def write_fgb_layer(features: List[Dict], output_path: str, layer_name: str): warnings.simplefilter("ignore") gdf.to_file(output_path, driver="FlatGeobuf", spatial_index=True) - # Restore logging levels pyogrio_logger.setLevel(old_pyogrio_level) fiona_logger.setLevel(old_fiona_level) - logger.debug(f"Successfully wrote {len(gdf)} features to {output_path}") + file_size_mb = os.path.getsize(output_path) / (1024 * 1024) + logger.info(f"Successfully wrote {len(gdf)} features ({file_size_mb:.2f} MB)") return True except Exception as e: logger.error(f"Error writing FlatGeobuf: {e}") @@ -433,17 +417,14 @@ def write_fgb_layer(features: List[Dict], output_path: str, layer_name: str): def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, zoom_range: Tuple[int, int] = (6, 17)): - """Main conversion function.""" + """Main conversion function - generates unified files per zoom range.""" - # Load configuration logger.info(f"Loading configuration from {config_file}") with open(config_file, 'r') as f: config = json.load(f) - # Create output directory os.makedirs(output_dir, exist_ok=True) - # Process PBF file file_size_mb = os.path.getsize(input_pbf) / (1024 * 1024) logger.info(f"Processing PBF file: {input_pbf} ({file_size_mb:.1f} MB)") logger.info(f"Zoom range: {zoom_range[0]}-{zoom_range[1]}") @@ -451,13 +432,10 @@ def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, start_time = time.time() handler = OSMHandler(config, zoom_range) - - # First pass: cache nodes and extract features logger.info("Processing OSM data (this may take a while for large files)...") handler.apply_file(input_pbf, locations=True, idx='flex_mem') - print() # New line after progress + print() - # Log statistics elapsed = time.time() - start_time logger.info(f"Processing completed in {elapsed:.2f}s") logger.info(f"Statistics:") @@ -465,56 +443,29 @@ def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, logger.info(f" Features extracted: {handler.stats['features_extracted']:,}") logger.info(f" Features filtered: {handler.stats['features_filtered']:,}") - # Write FlatGeobuf files by zoom level and layer - logger.info("Writing FlatGeobuf files by zoom level...") + # Write unified FGB files per zoom range + logger.info("Writing unified FlatGeobuf files...") files_written = [] - total_files = 0 - zoom_sizes = {} # zoom -> size in bytes - - # Calculate total expected files for progress bar - num_zooms = zoom_range[1] - zoom_range[0] + 1 - num_layers = len(handler.features_by_layer) - total_expected = num_zooms * num_layers - current_file = 0 - bar_width = 40 - - for zoom in range(zoom_range[0], zoom_range[1] + 1): - # Create zoom directory - zoom_dir = os.path.join(output_dir, str(zoom)) - os.makedirs(zoom_dir, exist_ok=True) - - zoom_files = 0 - zoom_features = 0 - zoom_size = 0 - - for layer_name, features in handler.features_by_layer.items(): - current_file += 1 - - # Update progress bar - progress = current_file / total_expected - filled = int(bar_width * progress) - bar = '█' * filled + '░' * (bar_width - filled) - print(f"\r [{bar}] {current_file}/{total_expected} (zoom {zoom}: {layer_name})", end='', flush=True) - - # Filter features for this zoom level (min_zoom <= current zoom) - zoom_features_list = [f for f in features if f['properties']['min_zoom'] <= zoom] - - if zoom_features_list: - output_path = os.path.join(zoom_dir, f"{layer_name}.fgb") - if write_fgb_layer(zoom_features_list, output_path, layer_name): - files_written.append(output_path) - file_size = os.path.getsize(output_path) - zoom_files += 1 - zoom_features += len(zoom_features_list) - zoom_size += file_size - total_files += 1 - - zoom_sizes[zoom] = zoom_size - - print() # New line after progress bar - - # Calculate total time in hh:mm:ss + total_size = 0 + + for min_z, max_z in ZOOM_RANGES: + # Skip ranges outside requested zoom + if max_z < zoom_range[0] or min_z > zoom_range[1]: + continue + + # Adjust range to requested bounds + actual_min = max(min_z, zoom_range[0]) + actual_max = min(max_z, zoom_range[1]) + + output_filename = f"z{actual_min}-{actual_max}.fgb" + output_path = os.path.join(output_dir, output_filename) + + if write_unified_fgb(handler.features, output_path, actual_max): + files_written.append(output_path) + total_size += os.path.getsize(output_path) + + # Summary total_time = time.time() - start_time hours, remainder = divmod(int(total_time), 3600) minutes, seconds = divmod(remainder, 60) @@ -525,26 +476,17 @@ def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, else: time_str = f"{total_time:.2f}s" - # Calculate total size - total_size = sum(zoom_sizes.values()) - - # Summary logger.info("=" * 50) logger.info("Conversion Summary") logger.info("=" * 50) logger.info(f"Input: {input_pbf}") logger.info(f"Output directory: {output_dir}") - logger.info(f"Zoom levels: {zoom_range[0]}-{zoom_range[1]}") - logger.info(f"Total files written: {total_files}") + logger.info(f"Files written:") + for filepath in files_written: + size_mb = os.path.getsize(filepath) / (1024 * 1024) + logger.info(f" {os.path.basename(filepath)}: {size_mb:.2f} MB") + logger.info(f"Total size: {total_size / (1024 * 1024):.2f} MB") logger.info(f"Total time: {time_str}") - logger.info("") - logger.info("Size by zoom level:") - for zoom in sorted(zoom_sizes.keys()): - size_mb = zoom_sizes[zoom] / (1024 * 1024) - logger.info(f" Zoom {zoom:2d}: {size_mb:8.2f} MB") - logger.info(f" {'─' * 20}") - total_mb = total_size / (1024 * 1024) - logger.info(f" Total: {total_mb:8.2f} MB") logger.info("=" * 50) return files_written @@ -552,29 +494,24 @@ def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, def main(): parser = argparse.ArgumentParser( - description='Convert OpenStreetMap PBF to FlatGeobuf format', + description='Convert OpenStreetMap PBF to unified FlatGeobuf format', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: python pbf_to_fgb.py andorra.pbf ./output features.json python pbf_to_fgb.py spain.pbf ./output features.json --zoom 10-17 -Output structure: +Output structure (unified files): output_dir/ - ├── 6/ - │ ├── water.fgb - │ ├── roads.fgb (motorway, trunk, primary only) - │ └── ... - ├── 10/ - │ ├── water.fgb - │ ├── roads.fgb (+ secondary, tertiary) - │ └── railways.fgb - ├── 14/ - │ ├── roads.fgb (+ residential, service) - │ ├── buildings.fgb - │ └── ... - └── 17/ - └── ... (all features) + ├── z6-9.fgb # All layers for zooms 6-9 + ├── z10-12.fgb # All layers for zooms 10-12 + └── z13-17.fgb # All layers for zooms 13-17 + +Each file contains ALL layers combined with properties: + - layer: Layer name for render ordering + - priority: Render priority (lower = behind) + - color_rgb332: 8-bit color + - min_zoom: Minimum zoom for visibility """ ) @@ -586,7 +523,6 @@ def main(): args = parser.parse_args() - # Validate input file if not os.path.exists(args.input_pbf): logger.error(f"Input file not found: {args.input_pbf}") sys.exit(1) @@ -595,18 +531,15 @@ def main(): logger.error(f"Config file not found: {args.config_file}") sys.exit(1) - # Check dependencies if not GEOPANDAS_AVAILABLE: logger.error("GeoPandas is required. Install with: pip install geopandas pyogrio") sys.exit(1) - # Parse zoom range if '-' in args.zoom: min_zoom, max_zoom = map(int, args.zoom.split('-')) else: min_zoom = max_zoom = int(args.zoom) - # Run conversion convert_pbf_to_fgb(args.input_pbf, args.output_dir, args.config_file, (min_zoom, max_zoom)) From d415b5a4cfd29b5fb0e1979d31c2d63c100e8ace Mon Sep 17 00:00:00 2001 From: jgauchia Date: Sat, 3 Jan 2026 17:25:38 +0100 Subject: [PATCH 03/16] refact(generator): Switch from unified files to z/x/y tile structure --- README.md | 95 +++++++++++---------- fgb_viewer.py | 225 ++++++++++++++++++++++++++++++++++---------------- grid.png | Bin 0 -> 125548 bytes grid2.png | Bin 0 -> 107884 bytes pbf_to_fgb.py | 209 +++++++++++++++++++++++++++------------------- 5 files changed, 327 insertions(+), 202 deletions(-) create mode 100644 grid.png create mode 100644 grid2.png diff --git a/README.md b/README.md index a627a63..3b63c73 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,11 @@ Converts OpenStreetMap PBF files to FlatGeobuf (.fgb) format with R-Tree spatial ## Features - **Direct PBF processing** - No intermediate formats (GOL, Docker, etc.) -- **R-Tree spatial index** - Fast bounding box queries -- **Zoom-level organization** - Separate files per zoom level for optimal file size +- **Tile-based structure** - Standard z/x/y tile layout (like PNG/OSM tiles) +- **R-Tree spatial index** - Fast bounding box queries per tile +- **Feature clipping** - Features clipped to tile boundaries - **Feature filtering** - Configurable via `features.json` -- **ESP32 optimized** - Small files, efficient for SD card access +- **ESP32 optimized** - Small tiles (~100KB-1MB), efficient for SD card access ## Requirements @@ -32,7 +33,7 @@ pip install geopandas pyogrio shapely pygame osmium ## Usage -### 1. Convert PBF to FlatGeobuf +### 1. Convert PBF to FlatGeobuf Tiles ```bash source venv/bin/activate @@ -54,7 +55,7 @@ python pbf_to_fgb.py features.json [--zoom 6-17] python pbf_to_fgb.py catalonia.osm.pbf ./fgb_output features.json --zoom 6-17 ``` -### 2. View Generated Files +### 2. View Generated Tiles ```bash python fgb_viewer.py --lat --lon [--zoom ] @@ -64,7 +65,7 @@ python fgb_viewer.py --lat --lon [--zoom Tuple[float, float]: def get_bbox_for_viewport(center_lat: float, center_lon: float, zoom: int) -> Tuple[float, float, float, float]: - """Calculate bounding box for 768x768 viewport centered on coordinates.""" + """Calculate bounding box for 768x768 viewport based on 3x3 tile grid.""" + # Get center tile center_x, center_y = deg2num(center_lat, center_lon, zoom) - tiles_extent = VIEWPORT_SIZE / TILE_SIZE / 2.0 - min_tile_x = center_x - tiles_extent - max_tile_x = center_x + tiles_extent - min_tile_y = center_y - tiles_extent - max_tile_y = center_y + tiles_extent + center_tile_x = int(center_x) + center_tile_y = int(center_y) + + # Bbox covers exactly 3x3 tiles around center tile + min_tile_x = center_tile_x - 1 + max_tile_x = center_tile_x + 2 # +2 because we need the right edge of tile +1 + min_tile_y = center_tile_y - 1 + max_tile_y = center_tile_y + 2 # +2 because we need the bottom edge of tile +1 + max_lat, min_lon = num2deg(min_tile_x, min_tile_y, zoom) min_lat, max_lon = num2deg(max_tile_x, max_tile_y, zoom) return (min_lon, min_lat, max_lon, max_lat) @@ -101,19 +105,19 @@ def darken_color(rgb: Tuple[int, int, int], amount: float = 0.3) -> Tuple[int, i class FGBViewer: - """FlatGeobuf map viewer simulating ESP32 behavior with unified files.""" + """FlatGeobuf map viewer simulating ESP32 behavior with tile-based files.""" def __init__(self, fgb_dir: str, config_file: str = None): self.fgb_dir = fgb_dir self.config = {} - self.zoom_files: Dict[Tuple[int, int], str] = {} # (min_zoom, max_zoom) -> filepath + self.available_zooms: Set[int] = set() if config_file and os.path.exists(config_file): with open(config_file, 'r') as f: self.config = json.load(f) logger.info(f"Loaded config from {config_file}") - self._index_files() + self._index_tiles() self.center_lat = 0.0 self.center_lon = 0.0 @@ -124,40 +128,52 @@ def __init__(self, fgb_dir: str, config_file: str = None): self.show_tile_grid = False self.last_query_stats = {} - def _index_files(self): - """Index available unified FGB files.""" + def _index_tiles(self): + """Index available zoom levels from tile structure.""" if not os.path.isdir(self.fgb_dir): logger.error(f"Directory not found: {self.fgb_dir}") return - # Look for unified files like z6-9.fgb, z10-12.fgb, z13-17.fgb - pattern = re.compile(r'^z(\d+)-(\d+)\.fgb$') - - for filename in os.listdir(self.fgb_dir): - match = pattern.match(filename) - if match: - min_z = int(match.group(1)) - max_z = int(match.group(2)) - filepath = os.path.join(self.fgb_dir, filename) - self.zoom_files[(min_z, max_z)] = filepath - file_size = os.path.getsize(filepath) / (1024 * 1024) - logger.info(f"Indexed: {filename} (z{min_z}-{max_z}, {file_size:.1f} MB)") - - if self.zoom_files: - logger.info(f"Total unified files: {len(self.zoom_files)}") + # Scan for zoom level directories (numbers) + for name in os.listdir(self.fgb_dir): + zoom_path = os.path.join(self.fgb_dir, name) + if os.path.isdir(zoom_path) and name.isdigit(): + zoom = int(name) + # Verify it has tile subdirectories + for x_name in os.listdir(zoom_path): + x_path = os.path.join(zoom_path, x_name) + if os.path.isdir(x_path) and x_name.isdigit(): + self.available_zooms.add(zoom) + break + + if self.available_zooms: + zooms_str = ", ".join(map(str, sorted(self.available_zooms))) + logger.info(f"Available zoom levels: {zooms_str}") else: - logger.warning("No unified FGB files found (expected z*-*.fgb)") - - def _get_file_for_zoom(self) -> Optional[str]: - """Get the FGB file for the current zoom level.""" - for (min_z, max_z), filepath in self.zoom_files.items(): - if min_z <= self.zoom <= max_z: - return filepath - # Fallback: return file with highest max_zoom - if self.zoom_files: - best_range = max(self.zoom_files.keys(), key=lambda x: x[1]) - return self.zoom_files[best_range] - return None + logger.warning("No tile directories found (expected z/x/y.fgb structure)") + + def _get_tiles_for_viewport(self) -> List[Tuple[int, int]]: + """Get list of tiles (x, y) that cover the current viewport.""" + center_x, center_y = deg2num(self.center_lat, self.center_lon, self.zoom) + center_tile_x = int(center_x) + center_tile_y = int(center_y) + + tiles = [] + # 3x3 grid around center + for dy in range(-1, 2): + for dx in range(-1, 2): + tile_x = center_tile_x + dx + tile_y = center_tile_y + dy + # Validate tile coordinates + max_tile = 2 ** self.zoom - 1 + if 0 <= tile_x <= max_tile and 0 <= tile_y <= max_tile: + tiles.append((tile_x, tile_y)) + + return tiles + + def _get_tile_path(self, x: int, y: int) -> str: + """Get file path for a tile.""" + return os.path.join(self.fgb_dir, str(self.zoom), str(x), f"{y}.fgb") def set_center(self, lat: float, lon: float, zoom: int = None): """Set viewport center and optionally zoom.""" @@ -166,42 +182,61 @@ def set_center(self, lat: float, lon: float, zoom: int = None): if zoom is not None: self.zoom = zoom self.bbox = get_bbox_for_viewport(self.center_lat, self.center_lon, self.zoom) - logger.info(f"Viewport centered at ({lat:.6f}, {lon:.6f}) zoom {self.zoom}") def query_features(self) -> Optional[gpd.GeoDataFrame]: - """Query features from unified file within current bbox.""" + """Query features from tiles within current viewport.""" if self.bbox is None: return None - filepath = self._get_file_for_zoom() - if filepath is None: - return None - import time start = time.time() - try: - result = gpd.read_file(filepath, bbox=self.bbox) - elapsed = (time.time() - start) * 1000 - - # Filter by min_zoom if available - if 'min_zoom' in result.columns: - result = result[result['min_zoom'] <= self.zoom] + tiles = self._get_tiles_for_viewport() + all_gdfs = [] + tiles_loaded = 0 + tiles_missing = 0 + + for tile_x, tile_y in tiles: + tile_path = self._get_tile_path(tile_x, tile_y) + if os.path.exists(tile_path): + try: + gdf = gpd.read_file(tile_path) + if len(gdf) > 0: + all_gdfs.append(gdf) + tiles_loaded += 1 + except Exception as e: + logger.warning(f"Error reading tile {tile_path}: {e}") + else: + tiles_missing += 1 - bytes_read = result.memory_usage(deep=True).sum() if len(result) > 0 else 0 + elapsed = (time.time() - start) * 1000 + if not all_gdfs: self.last_query_stats = { - 'file': os.path.basename(filepath), - 'features': len(result), - 'time_ms': elapsed, - 'read_kb': bytes_read / 1024 + 'tiles_loaded': 0, + 'tiles_missing': tiles_missing, + 'features': 0, + 'time_ms': elapsed } - - return result - except Exception as e: - logger.error(f"Query error: {e}") return None + # Combine all tile features + result = pd.concat(all_gdfs, ignore_index=True) + result = gpd.GeoDataFrame(result, crs="EPSG:4326") + + # Filter by min_zoom if available + if 'min_zoom' in result.columns: + result = result[result['min_zoom'] <= self.zoom] + + self.last_query_stats = { + 'tiles_loaded': tiles_loaded, + 'tiles_missing': tiles_missing, + 'features': len(result), + 'time_ms': elapsed + } + + return result + def render_to_surface(self, surface: pygame.Surface): """Render all visible features to pygame surface.""" if self.bbox is None: @@ -248,6 +283,27 @@ def _render_feature(self, surface: pygame.Surface, feature): elif geom_type == 'MultiPolygon': for poly in geom.geoms: self._render_polygon(surface, poly, color) + elif geom_type == 'GeometryCollection': + for g in geom.geoms: + self._render_geometry(surface, g, color) + + def _render_geometry(self, surface: pygame.Surface, geom, color: Tuple[int, int, int]): + """Render any geometry type.""" + if geom is None or geom.is_empty: + return + geom_type = geom.geom_type + if geom_type == 'Point': + self._render_point(surface, geom, color) + elif geom_type == 'LineString': + self._render_linestring(surface, geom, color) + elif geom_type == 'Polygon': + self._render_polygon(surface, geom, color) + elif geom_type == 'MultiLineString': + for line in geom.geoms: + self._render_linestring(surface, line, color) + elif geom_type == 'MultiPolygon': + for poly in geom.geoms: + self._render_polygon(surface, poly, color) def _render_point(self, surface: pygame.Surface, point, color: Tuple[int, int, int]): """Render a point feature.""" @@ -285,8 +341,12 @@ def _render_polygon(self, surface: pygame.Surface, polygon, color: Tuple[int, in pygame.draw.polygon(surface, color, points, 1) def _draw_tile_grid(self, surface: pygame.Surface): - """Draw tile grid overlay.""" + """Draw tile grid overlay with tile coordinates.""" grid_color = (100, 100, 100) + text_color = (80, 80, 80) + font = pygame.font.SysFont(None, 14) + + # Draw grid lines for i in range(4): x = i * TILE_SIZE pygame.draw.line(surface, grid_color, (x, 0), (x, VIEWPORT_SIZE), 1) @@ -294,6 +354,25 @@ def _draw_tile_grid(self, surface: pygame.Surface): y = i * TILE_SIZE pygame.draw.line(surface, grid_color, (0, y), (VIEWPORT_SIZE, y), 1) + # Draw tile coordinates + tiles = self._get_tiles_for_viewport() + center_x, center_y = deg2num(self.center_lat, self.center_lon, self.zoom) + center_tile_x = int(center_x) + center_tile_y = int(center_y) + + for dy in range(-1, 2): + for dx in range(-1, 2): + tile_x = center_tile_x + dx + tile_y = center_tile_y + dy + # Screen position for this tile + screen_x = (dx + 1) * TILE_SIZE + 5 + screen_y = (dy + 1) * TILE_SIZE + 5 + tile_path = self._get_tile_path(tile_x, tile_y) + exists = os.path.exists(tile_path) + color = (0, 100, 0) if exists else (150, 50, 50) + label = font.render(f"{tile_x}/{tile_y}", True, color) + surface.blit(label, (screen_x, screen_y)) + def draw_button(surface, text, rect, bg_color, fg_color, border_color, font, pressed=False): """Draw a button.""" @@ -320,19 +399,19 @@ def format_coord(decimal: float, is_latitude: bool = True) -> str: def main(): parser = argparse.ArgumentParser( - description='FlatGeobuf Map Viewer - ESP32 Simulator (Unified Files)', + description='FlatGeobuf Map Viewer - ESP32 Simulator (Tile Structure)', epilog=""" Controls: Arrow keys / Mouse drag: Pan map Mouse wheel / [ ] keys: Zoom in/out B: Toggle background color F: Toggle polygon fill - G: Toggle tile grid + G: Toggle tile grid (shows tile coordinates) Q / ESC: Quit """ ) - parser.add_argument('fgb_dir', help='Directory containing unified FGB files') + parser.add_argument('fgb_dir', help='Directory containing tile-based FGB files (z/x/y.fgb)') parser.add_argument('--lat', type=float, required=True, help='Center latitude') parser.add_argument('--lon', type=float, required=True, help='Center longitude') parser.add_argument('--zoom', type=int, default=14, help='Zoom level (default: 14)') @@ -353,7 +432,7 @@ def main(): pygame.init() screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT)) - pygame.display.set_caption(f"FGB Viewer (Unified) - {os.path.basename(args.fgb_dir)}") + pygame.display.set_caption(f"FGB Viewer (Tiles) - {os.path.basename(args.fgb_dir)}") font = pygame.font.SysFont(None, 18) font_small = pygame.font.SysFont(None, 14) @@ -499,10 +578,9 @@ def main(): screen.blit(font_small.render("Query Stats:", True, info_color), (VIEWPORT_SIZE + 10, stats_y)) if viewer.last_query_stats: s = viewer.last_query_stats - screen.blit(font_small.render(f" File: {s.get('file', 'N/A')}", True, (150, 150, 150)), (VIEWPORT_SIZE + 10, stats_y + 18)) + screen.blit(font_small.render(f" Tiles: {s.get('tiles_loaded', 0)}/{s.get('tiles_loaded', 0) + s.get('tiles_missing', 0)}", True, (150, 150, 150)), (VIEWPORT_SIZE + 10, stats_y + 18)) screen.blit(font_small.render(f" Features: {s.get('features', 0)}", True, (150, 150, 150)), (VIEWPORT_SIZE + 10, stats_y + 32)) screen.blit(font_small.render(f" Time: {s.get('time_ms', 0):.0f}ms", True, (150, 150, 150)), (VIEWPORT_SIZE + 10, stats_y + 46)) - screen.blit(font_small.render(f" Data: {s.get('read_kb', 0):.0f}KB", True, (150, 150, 150)), (VIEWPORT_SIZE + 10, stats_y + 60)) # Status bar pygame.draw.rect(screen, (30, 30, 30), (0, VIEWPORT_SIZE, WINDOW_WIDTH, STATUSBAR_HEIGHT)) @@ -515,6 +593,11 @@ def main(): res_text = f"Resolution: {meters_per_pixel:.2f} m/px" screen.blit(font_small.render(res_text, True, (200, 200, 200)), (10, VIEWPORT_SIZE + 30)) + # Show available zooms + if viewer.available_zooms: + zooms_str = f"Available: {min(viewer.available_zooms)}-{max(viewer.available_zooms)}" + screen.blit(font_small.render(zooms_str, True, (150, 150, 150)), (400, VIEWPORT_SIZE + 10)) + pygame.display.flip() need_redraw = False diff --git a/grid.png b/grid.png new file mode 100644 index 0000000000000000000000000000000000000000..c33cda40e694a52360aedbce9cb11cd9dd77c3fa GIT binary patch literal 125548 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℞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 LTWLVEf3U6+a-Wfqkd`)7 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

6HAz|E03ac) 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

6HAz|E03ac) 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

HJ7ofTdZg$oxJ*0*bf@mQenIOA5|ay>p* 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 Y2J5ou7+Ek~$DO>PqKz#0<<_IW0pV``IsgCw diff --git a/pbf_to_fgb.py b/pbf_to_fgb.py index 18e7877..89275e7 100644 --- a/pbf_to_fgb.py +++ b/pbf_to_fgb.py @@ -32,6 +32,7 @@ try: import osmium from osmium import osm + import osmium.geom except ImportError: print("Error: osmium not found. Install with: pip install osmium") sys.exit(1) @@ -40,6 +41,7 @@ import geopandas as gpd from shapely.geometry import LineString, Polygon, MultiPolygon, Point, box from shapely.ops import transform + import shapely.wkb GEOPANDAS_AVAILABLE = True except ImportError: GEOPANDAS_AVAILABLE = False @@ -62,10 +64,12 @@ 'landuse=grass', 'landuse=orchard', 'landuse=vineyard', 'landuse=farmland', 'landuse=park', 'leisure=park', 'leisure=nature_reserve', 'leisure=garden', 'leisure=pitch', - 'leisure=golf_course', 'landuse=residential', 'place=suburb', + 'leisure=golf_course', 'leisure=recreation_ground', 'landuse=recreation_ground', + 'landuse=residential', 'place=suburb', 'landuse=commercial', 'landuse=retail', 'landuse=industrial', 'landuse=construction', 'landuse=cemetery', 'landuse=allotments', - 'leisure=stadium', 'leisure=sports_centre', 'leisure=playground' + 'leisure=stadium', 'leisure=sports_centre', 'leisure=playground', + 'amenity=parking' ], 'roads': [ 'highway=motorway', 'highway=motorway_link', @@ -87,7 +91,7 @@ 'building', 'man_made=tower' ], 'amenities': [ - 'amenity=parking', 'amenity=hospital', + 'amenity=hospital', 'amenity=school', 'amenity=university', 'amenity=place_of_worship' ], @@ -144,13 +148,36 @@ def tile_bounds(x: int, y: int, zoom: int) -> Tuple[float, float, float, float]: return (min_lon, min_lat, max_lon, max_lat) -def get_feature_tiles(coords: List[Tuple[float, float]], zoom: int) -> Set[Tuple[int, int]]: - """Get all tiles that a feature intersects at given zoom level.""" +def get_feature_tiles(coords: List[Tuple[float, float]], zoom: int, is_polygon: bool = False) -> Set[Tuple[int, int]]: + """Get all tiles that a feature intersects at given zoom level. + + For polygons, uses bbox to ensure all covered tiles are included, + not just tiles containing vertices. + """ tiles = set() - for lon, lat in coords: - x = lon_to_tile_x(lon, zoom) - y = lat_to_tile_y(lat, zoom) - tiles.add((x, y)) + + if is_polygon and len(coords) >= 3: + # For polygons, get all tiles in the bounding box + lons = [c[0] for c in coords] + lats = [c[1] for c in coords] + min_lon, max_lon = min(lons), max(lons) + min_lat, max_lat = min(lats), max(lats) + + min_x = lon_to_tile_x(min_lon, zoom) + max_x = lon_to_tile_x(max_lon, zoom) + min_y = lat_to_tile_y(max_lat, zoom) # Note: lat is inverted + max_y = lat_to_tile_y(min_lat, zoom) + + for x in range(min_x, max_x + 1): + for y in range(min_y, max_y + 1): + tiles.add((x, y)) + else: + # For lines, just use vertex positions + for lon, lat in coords: + x = lon_to_tile_x(lon, zoom) + y = lat_to_tile_y(lat, zoom) + tiles.add((x, y)) + return tiles @@ -240,6 +267,7 @@ def __init__(self, config: Dict, zoom_range: Tuple[int, int]): self.stats = { 'ways_processed': 0, 'relations_processed': 0, + 'areas_processed': 0, 'features_extracted': 0, 'features_filtered': 0 } @@ -247,6 +275,8 @@ def __init__(self, config: Dict, zoom_range: Tuple[int, int]): self.last_progress_time = time.time() self.progress_interval = 5 self.interesting_tags = self._build_interesting_tags() + self.processed_way_ids: Set[int] = set() # Track ways processed as areas + self.wkbfab = osmium.geom.WKBFactory() # For extracting geometries from areas def _build_interesting_tags(self) -> Set[str]: """Build set of tag keys we're interested in.""" @@ -293,7 +323,11 @@ def _is_feature_in_config(self, tags: Dict[str, str]) -> bool: return False def way(self, w): - """Process way - extract roads, buildings, etc.""" + """Process way - extract roads and linear features only. + + Closed ways (areas) are handled by the area() callback to properly + support multipolygon relations. + """ self.stats['ways_processed'] += 1 self._log_progress() @@ -326,27 +360,58 @@ def way(self, w): self.stats['features_filtered'] += 1 return + # Check if this is a closed way that should be an area is_closed = len(coords) >= 4 and coords[0] == coords[-1] - is_area = is_closed and ( + is_area_tags = ( 'building' in tags or 'landuse' in tags or - 'natural' in tags and tags.get('natural') in ['water', 'wood', 'forest', 'beach', 'sand', 'wetland', 'grassland', 'scrub', 'heath'] or - 'leisure' in tags and tags.get('leisure') in ['park', 'garden', 'pitch', 'golf_course', 'nature_reserve', 'playground', 'sports_centre', 'stadium'] or - 'amenity' in tags and tags.get('amenity') in ['parking'] or - 'waterway' in tags and tags.get('waterway') in ['riverbank', 'dock', 'boatyard'] or + ('natural' in tags and tags.get('natural') in ['water', 'wood', 'forest', 'beach', 'sand', 'wetland', 'grassland', 'scrub', 'heath']) or + ('leisure' in tags and tags.get('leisure') in ['park', 'garden', 'pitch', 'golf_course', 'nature_reserve', 'playground', 'sports_centre', 'stadium']) or + ('amenity' in tags and tags.get('amenity') in ['parking', 'school', 'university', 'hospital']) or + ('waterway' in tags and tags.get('waterway') in ['riverbank', 'dock', 'boatyard']) or tags.get('area') == 'yes' ) + # Process closed areas as Polygon (parks, buildings, etc.) + # Note: area() only handles multipolygon relations in pyosmium 3.6, + # so we must process closed ways here + if is_closed and is_area_tags: + color = get_color_for_tags(tags, self.config) + priority = get_priority_for_tags(tags, self.config) + color_rgb332 = hex_to_rgb332(color) + + layer_base_priority = LAYER_PRIORITY.get(layer, 50) + combined_priority = layer_base_priority + (priority % 10) + + feature = { + 'geometry_type': 'Polygon', + 'coordinates': coords, + 'properties': { + 'osm_id': w.id, + 'min_zoom': min_zoom, + 'color_rgb332': color_rgb332, + 'priority': combined_priority, + 'layer': layer, + 'feature_type': self._get_primary_tag(tags) + } + } + + self.features.append(feature) + self.stats['features_extracted'] += 1 + # Track this way to avoid duplicate in area() if it's also a relation + self.processed_way_ids.add(w.id) + return + + # Process as LineString (roads, rivers, etc.) color = get_color_for_tags(tags, self.config) priority = get_priority_for_tags(tags, self.config) color_rgb332 = hex_to_rgb332(color) - # Combine layer priority with feature priority layer_base_priority = LAYER_PRIORITY.get(layer, 50) combined_priority = layer_base_priority + (priority % 10) feature = { - 'geometry_type': 'Polygon' if is_area else 'LineString', + 'geometry_type': 'LineString', 'coordinates': coords, 'properties': { 'osm_id': w.id, @@ -370,9 +435,86 @@ def _get_primary_tag(self, tags: Dict[str, str]) -> str: return 'unknown' def relation(self, r): - """Process relation - for multipolygons, etc.""" + """Process relation - count only, areas handled by area() callback.""" self.stats['relations_processed'] += 1 - pass + + def area(self, a): + """Process area - handles both closed ways and multipolygon relations. + + Uses WKBFactory to extract geometry, then converts to coordinate list. + """ + self.stats['areas_processed'] += 1 + self._log_progress() + + if not self._has_interesting_tags(a.tags): + self.stats['features_filtered'] += 1 + return + + tags = self._tags_to_dict(a.tags) + + if not self._is_feature_in_config(tags): + self.stats['features_filtered'] += 1 + return + + layer = get_layer_for_tags(tags) + if layer is None: + self.stats['features_filtered'] += 1 + return + + min_zoom = get_zoom_for_tags(tags, self.config) + if min_zoom > self.max_zoom: + self.stats['features_filtered'] += 1 + return + + # Skip if this way was already processed in way() callback + if a.from_way() and a.orig_id() in self.processed_way_ids: + return + + # Extract geometry using WKBFactory + try: + wkb = self.wkbfab.create_multipolygon(a) + geom = shapely.wkb.loads(wkb, hex=True) + + color = get_color_for_tags(tags, self.config) + priority = get_priority_for_tags(tags, self.config) + color_rgb332 = hex_to_rgb332(color) + + layer_base_priority = LAYER_PRIORITY.get(layer, 50) + combined_priority = layer_base_priority + (priority % 10) + + # Handle both Polygon and MultiPolygon + polygons = [] + if geom.geom_type == 'Polygon': + polygons = [geom] + elif geom.geom_type == 'MultiPolygon': + polygons = list(geom.geoms) + + for poly in polygons: + if poly.is_empty or not poly.exterior: + continue + + coords = list(poly.exterior.coords) + if len(coords) < 4: + continue + + feature = { + 'geometry_type': 'Polygon', + 'coordinates': coords, + 'properties': { + 'osm_id': a.orig_id(), + 'min_zoom': min_zoom, + 'color_rgb332': color_rgb332, + 'priority': combined_priority, + 'layer': layer, + 'feature_type': self._get_primary_tag(tags) + } + } + + self.features.append(feature) + self.stats['features_extracted'] += 1 + + except Exception as e: + self.stats['features_filtered'] += 1 def count_coords(geom) -> int: @@ -490,7 +632,9 @@ def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, start_time = time.time() handler = OSMHandler(config, zoom_range) - logger.info("Processing OSM data (this may take a while for large files)...") + logger.info("Processing OSM data with multipolygon support (2 passes)...") + + # pyosmium 3.x automatically does two passes when area() callback exists handler.apply_file(input_pbf, locations=True, idx='flex_mem') print() @@ -498,6 +642,7 @@ def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, logger.info(f"Processing completed in {elapsed:.2f}s") logger.info(f"Statistics:") logger.info(f" Ways processed: {handler.stats['ways_processed']:,}") + logger.info(f" Areas processed: {handler.stats['areas_processed']:,}") logger.info(f" Features extracted: {handler.stats['features_extracted']:,}") logger.info(f" Features filtered: {handler.stats['features_filtered']:,}") @@ -507,6 +652,11 @@ def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, total_tiles = 0 total_size = 0 + # Statistics tracking + feature_tile_counts = [] # (osm_id, feature_type, geom_type, layer, num_tiles, zoom) + tiles_by_layer = defaultdict(int) + tiles_by_geom_type = defaultdict(int) + for zoom in range(zoom_range[0], zoom_range[1] + 1): # Group features by tile at this zoom level tile_features: Dict[Tuple[int, int], List[Dict]] = defaultdict(list) @@ -517,7 +667,23 @@ def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, continue # Get all tiles this feature touches - tiles = get_feature_tiles(feature['coordinates'], zoom) + is_polygon = feature['geometry_type'] == 'Polygon' + tiles = get_feature_tiles(feature['coordinates'], zoom, is_polygon) + + # Track statistics + num_tiles = len(tiles) + if num_tiles > 1: # Only track features in multiple tiles + feature_tile_counts.append(( + feature['properties']['osm_id'], + feature['properties']['feature_type'], + feature['geometry_type'], + feature['properties']['layer'], + num_tiles, + zoom + )) + tiles_by_layer[feature['properties']['layer']] += num_tiles + tiles_by_geom_type[feature['geometry_type']] += num_tiles + for tile in tiles: tile_features[tile].append(feature) @@ -578,6 +744,53 @@ def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, logger.info(f"Nodes after simplification: {nodes_after:,}") logger.info(f"Node reduction: {reduction:.1f}%") logger.info(f"Total time: {time_str}") + + # Feature-tile distribution statistics + logger.info("") + logger.info("=" * 50) + logger.info("Feature-Tile Distribution Statistics") + logger.info("=" * 50) + + # Tiles by geometry type + logger.info("") + logger.info("Tile assignments by geometry type:") + for geom_type, count in sorted(tiles_by_geom_type.items(), key=lambda x: -x[1]): + logger.info(f" {geom_type}: {count:,} tile assignments") + + # Tiles by layer + logger.info("") + logger.info("Tile assignments by layer:") + for layer, count in sorted(tiles_by_layer.items(), key=lambda x: -x[1]): + logger.info(f" {layer}: {count:,} tile assignments") + + # Top features by tile count + if feature_tile_counts: + # Sort by num_tiles descending + sorted_features = sorted(feature_tile_counts, key=lambda x: -x[4]) + + # Top 20 features + logger.info("") + logger.info("Top 20 features by tile coverage:") + logger.info(f" {'OSM ID':<12} {'Type':<25} {'Geom':<10} {'Layer':<12} {'Tiles':>8} {'Zoom':>5}") + logger.info(f" {'-'*12} {'-'*25} {'-'*10} {'-'*12} {'-'*8} {'-'*5}") + for osm_id, feat_type, geom_type, layer, num_tiles, zoom in sorted_features[:20]: + logger.info(f" {osm_id:<12} {feat_type:<25} {geom_type:<10} {layer:<12} {num_tiles:>8,} {zoom:>5}") + + # Summary stats + all_tile_counts = [x[4] for x in feature_tile_counts] + avg_tiles = sum(all_tile_counts) / len(all_tile_counts) if all_tile_counts else 0 + max_tiles = max(all_tile_counts) if all_tile_counts else 0 + features_over_100 = sum(1 for x in all_tile_counts if x > 100) + features_over_1000 = sum(1 for x in all_tile_counts if x > 1000) + + logger.info("") + logger.info("Multi-tile feature statistics:") + logger.info(f" Features spanning multiple tiles: {len(feature_tile_counts):,}") + logger.info(f" Average tiles per multi-tile feature: {avg_tiles:.1f}") + logger.info(f" Maximum tiles for single feature: {max_tiles:,}") + logger.info(f" Features covering >100 tiles: {features_over_100:,}") + logger.info(f" Features covering >1000 tiles: {features_over_1000:,}") + logger.info("=" * 50) return total_tiles From a1c76ca5f24a97d6ec565b79acac994c0fb4d320 Mon Sep 17 00:00:00 2001 From: jgauchia Date: Wed, 7 Jan 2026 23:15:56 +0100 Subject: [PATCH 07/16] feat: RGB565 colors and OSM-carto style matching --- README.md | 6 +- features.json | 182 ++++++++++++++++++++++++++++++-------------------- fgb_viewer.py | 18 ++--- pbf_to_fgb.py | 37 +++++----- 4 files changed, 145 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 6b43401..52d0a4c 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ The `features.json` file defines which OSM features to include: | Field | Description | |-------|-------------| | `zoom` | Minimum zoom level for feature visibility | -| `color` | Hex color (converted to RGB332 internally) | +| `color` | Hex color (converted to RGB565 internally) | | `priority` | Render order (lower = background, higher = foreground) | ## FlatGeobuf Properties @@ -188,7 +188,7 @@ Each feature in the FGB tiles contains: | Property | Type | Description | |----------|------|-------------| -| `color_rgb332` | int | 8-bit color (RGB332 format) | +| `color_rgb565` | int | 16-bit color (RGB565 format) | | `min_zoom` | int | Minimum zoom level | | `priority` | int | Rendering priority | | `feature_type` | string | OSM tag (e.g., `highway=primary`) | @@ -204,7 +204,7 @@ The generated FGB tiles are optimized for ESP32: 3. **Load 3x3 grid**: Load 9 tiles around center position 4. **Read features**: Each tile is small, read sequentially without random seeks 5. **Sort by priority**: Lower priority = rendered first (background) -6. **Render features**: Use `priority` for layer ordering, `color_rgb332` for colors +6. **Render features**: Use `priority` for layer ordering, `color_rgb565` for colors ### Tile Coordinate Calculation diff --git a/features.json b/features.json index 962e31f..93b25c4 100644 --- a/features.json +++ b/features.json @@ -1,249 +1,284 @@ { - "_comment": "Priority system: layer_base + (priority % 10). Lower = behind, Higher = front", + "_comment": "OSM-style colors in RGB565 format. Priority: layer_base + (priority % 10).", "_layers": "water:10, landuse:20, terrain:30, railways:40, roads:50, infrastructure:60, buildings:70, amenities:80, places:90", "natural=coastline": { "zoom": 6, - "color": "#88c9fa", + "color": "#aad3df", "priority": 10 }, "natural=water": { "zoom": 12, - "color": "#88c9fa", + "color": "#aad3df", "priority": 11 }, "natural=bay": { "zoom": 12, - "color": "#88c9fa", + "color": "#aad3df", "priority": 11 }, "waterway=riverbank": { "zoom": 12, - "color": "#88c9fa", + "color": "#aad3df", "priority": 12 }, "waterway=dock": { "zoom": 14, - "color": "#88c9fa", + "color": "#aad3df", "priority": 12 }, "waterway=river": { "zoom": 8, - "color": "#88c9fa", + "color": "#aad3df", "priority": 15 }, "waterway=stream": { "zoom": 12, - "color": "#88c9fa", + "color": "#aad3df", "priority": 16 }, "waterway=canal": { "zoom": 12, - "color": "#88c9fa", + "color": "#aad3df", "priority": 16 }, "natural=beach": { "zoom": 12, - "color": "#f7e9c9", + "color": "#fff1ba", "priority": 20 }, "natural=sand": { "zoom": 12, - "color": "#f7e9c9", + "color": "#f5e9c6", "priority": 20 }, "natural=wetland": { "zoom": 12, - "color": "#c2e0e4", + "color": "#add19e", "priority": 21 }, "natural=wood": { "zoom": 12, - "color": "#9bc184", + "color": "#add19e", "priority": 22 }, "landuse=forest": { "zoom": 12, - "color": "#9bc184", + "color": "#add19e", "priority": 22 }, "natural=scrub": { "zoom": 14, - "color": "#b5d0a3", + "color": "#c8d7ab", "priority": 22 }, "natural=heath": { "zoom": 14, - "color": "#d4d8a3", + "color": "#d6d99f", "priority": 22 }, "natural=grassland": { "zoom": 14, - "color": "#c8e6a9", + "color": "#cdebb0", "priority": 22 }, "landuse=farmland": { "zoom": 12, - "color": "#e8e4b5", + "color": "#eef0d5", + "priority": 23 + }, + "landuse=meadow": { + "zoom": 14, + "color": "#cdebb0", + "priority": 23 + }, + "landuse=grass": { + "zoom": 14, + "color": "#cdebb0", "priority": 23 }, "landuse=residential": { "zoom": 12, - "color": "#e8e6e3", + "color": "#e0dfdf", "priority": 24 }, "landuse=commercial": { "zoom": 14, - "color": "#f0e0d0", + "color": "#f2dad9", "priority": 24 }, "landuse=retail": { "zoom": 14, - "color": "#f0e0d0", + "color": "#ffd6d1", + "priority": 24 + }, + "landuse=garages": { + "zoom": 15, + "color": "#dfddce", "priority": 24 }, "landuse=industrial": { "zoom": 12, - "color": "#d8d8d8", + "color": "#ebdbe8", "priority": 24 }, "landuse=cemetery": { "zoom": 14, - "color": "#c0c0c0", + "color": "#aacbaf", "priority": 24 }, "landuse=park": { "zoom": 12, - "color": "#b5e3b5", + "color": "#c8facc", "priority": 26 }, "leisure=park": { "zoom": 12, - "color": "#b5e3b5", + "color": "#c8facc", "priority": 26 }, "leisure=garden": { "zoom": 14, - "color": "#c8e6c8", + "color": "#cdebb0", "priority": 26 }, "leisure=playground": { "zoom": 15, - "color": "#c8e6c8", + "color": "#c8facc", "priority": 26 }, "leisure=nature_reserve": { "zoom": 12, - "color": "#b5e3b5", + "color": "#add19e", "priority": 25 }, "leisure=golf_course": { "zoom": 14, - "color": "#b5e3b5", + "color": "#def6c0", "priority": 25 }, "leisure=recreation_ground": { "zoom": 14, - "color": "#c8e6c8", + "color": "#c8facc", "priority": 25 }, "landuse=recreation_ground": { "zoom": 14, - "color": "#c8e6c8", + "color": "#c8facc", "priority": 25 }, "amenity=parking": { "zoom": 15, - "color": "#c0c0c0", + "color": "#eeeeee", + "priority": 27 + }, + "amenity=marketplace": { + "zoom": 15, + "color": "#f0e0d0", "priority": 27 }, + "landuse=village_green": { + "zoom": 14, + "color": "#cdebb0", + "priority": 26 + }, + "leisure=common": { + "zoom": 14, + "color": "#c8facc", + "priority": 26 + }, "natural=peak": { "zoom": 14, - "color": "#8b7355", + "color": "#8b4513", "priority": 30 }, "natural=volcano": { "zoom": 14, - "color": "#d84200", + "color": "#d40000", "priority": 30 }, "natural=cliff": { "zoom": 15, - "color": "#8b7355", + "color": "#999999", "priority": 31 }, "railway=rail": { "zoom": 8, - "color": "#808080", + "color": "#888888", "priority": 40 }, "railway=subway": { "zoom": 14, - "color": "#808080", + "color": "#999999", + "priority": 41 + }, + "railway=tram": { + "zoom": 14, + "color": "#888888", "priority": 41 }, "tunnel=yes": { "zoom": 14, - "color": "#a0a0a0", + "color": "#aaaaaa", "priority": 50 }, "highway=motorway": { "zoom": 6, - "color": "#ff9999", + "color": "#e990a0", "priority": 60 }, "highway=motorway_link": { "zoom": 8, - "color": "#ff9999", + "color": "#e990a0", "priority": 60 }, "highway=trunk": { "zoom": 6, - "color": "#ffbbbb", + "color": "#f9b29c", "priority": 61 }, "highway=trunk_link": { "zoom": 8, - "color": "#ffbbbb", + "color": "#f9b29c", "priority": 61 }, "highway=primary": { "zoom": 6, - "color": "#ffcc99", + "color": "#fcd6a4", "priority": 62 }, "highway=primary_link": { "zoom": 10, - "color": "#ffcc99", + "color": "#fcd6a4", "priority": 62 }, "highway=secondary": { "zoom": 10, - "color": "#ffff99", + "color": "#f7f496", "priority": 63 }, "highway=secondary_link": { "zoom": 12, - "color": "#ffff99", + "color": "#f7f496", "priority": 63 }, "highway=tertiary": { "zoom": 12, - "color": "#ffffcc", + "color": "#ffffff", "priority": 64 }, "highway=tertiary_link": { "zoom": 12, - "color": "#ffffcc", + "color": "#ffffff", "priority": 64 }, "highway=residential": { @@ -253,7 +288,7 @@ }, "highway=living_street": { "zoom": 14, - "color": "#ffffff", + "color": "#ededed", "priority": 65 }, "highway=unclassified": { @@ -263,117 +298,122 @@ }, "highway=service": { "zoom": 15, - "color": "#f0f0f0", + "color": "#ffffff", "priority": 66 }, "highway=track": { "zoom": 14, - "color": "#e8d8b0", + "color": "#996600", "priority": 67 }, "highway=path": { "zoom": 15, - "color": "#e8d8b0", + "color": "#fa8072", "priority": 68 }, + "highway=pedestrian": { + "zoom": 14, + "color": "#dddde8", + "priority": 67 + }, "highway=footway": { "zoom": 16, - "color": "#f0e8d8", + "color": "#fa8072", "priority": 68 }, "highway=cycleway": { "zoom": 14, - "color": "#a0c8f0", + "color": "#0000ff", "priority": 68 }, "highway=steps": { "zoom": 16, - "color": "#f0e8d8", + "color": "#fa8072", "priority": 68 }, "bridge=yes": { "zoom": 12, - "color": "#c0c0c0", + "color": "#b8b8b8", "priority": 70 }, "man_made=bridge": { "zoom": 12, - "color": "#c0c0c0", + "color": "#b8b8b8", "priority": 70 }, "aeroway=runway": { "zoom": 12, - "color": "#e8e8e8", + "color": "#bbbbcc", "priority": 75 }, "aeroway=taxiway": { "zoom": 14, - "color": "#e0e0e0", + "color": "#bbbbcc", "priority": 75 }, "aeroway=apron": { "zoom": 14, - "color": "#d8d8d8", + "color": "#dadae0", "priority": 74 }, "building": { "zoom": 15, - "color": "#dddddd", + "color": "#d9d0c9", "priority": 80 }, "leisure=stadium": { "zoom": 14, - "color": "#dddddd", + "color": "#c8facc", "priority": 81 }, "leisure=sports_centre": { "zoom": 15, - "color": "#dddddd", + "color": "#c8facc", "priority": 81 }, "leisure=pitch": { "zoom": 15, - "color": "#c8e6a9", + "color": "#88e0be", "priority": 79 }, "amenity=hospital": { "zoom": 14, - "color": "#ffcccc", + "color": "#f0c0c0", "priority": 85 }, "amenity=school": { "zoom": 15, - "color": "#fff8dc", + "color": "#f0f0d8", "priority": 85 }, "amenity=university": { "zoom": 14, - "color": "#fff8dc", + "color": "#f0f0d8", "priority": 85 }, "place=state": { "zoom": 6, - "color": "#4a5c6a", + "color": "#000000", "priority": 90 }, "place=town": { "zoom": 10, - "color": "#5a6c7a", + "color": "#000000", "priority": 91 }, "place=village": { "zoom": 12, - "color": "#6a7c8a", + "color": "#000000", "priority": 92 }, "place=hamlet": { "zoom": 14, - "color": "#7a8c9a", + "color": "#000000", "priority": 93 } } diff --git a/fgb_viewer.py b/fgb_viewer.py index a85d2fa..e90e1a8 100644 --- a/fgb_viewer.py +++ b/fgb_viewer.py @@ -91,11 +91,11 @@ def latlon_to_pixel(lat: float, lon: float, bbox: Tuple[float, float, float, flo return px, py -def rgb332_to_rgb888(c: int) -> Tuple[int, int, int]: - """Convert RGB332 to RGB888.""" - r = (c & 0xE0) - g = (c & 0x1C) << 3 - b = (c & 0x03) << 6 +def rgb565_to_rgb888(c: int) -> Tuple[int, int, int]: + """Convert RGB565 to RGB888.""" + r = ((c >> 11) & 0x1F) << 3 + g = ((c >> 5) & 0x3F) << 2 + b = (c & 0x1F) << 3 return (r, g, b) @@ -270,8 +270,8 @@ def _render_feature(self, surface: pygame.Surface, feature): if geom is None or geom.is_empty: return - if 'color_rgb332' in feature.index and feature['color_rgb332']: - color = rgb332_to_rgb888(int(feature['color_rgb332'])) + if 'color_rgb565' in feature.index and feature['color_rgb565']: + color = rgb565_to_rgb888(int(feature['color_rgb565'])) else: color = (200, 200, 200) @@ -425,8 +425,8 @@ def _feature_to_dict(self, row) -> dict: info['type'] = row['feature_type'] if 'layer' in row.index: info['layer'] = row['layer'] - if 'color_rgb332' in row.index and row['color_rgb332']: - r, g, b = rgb332_to_rgb888(int(row['color_rgb332'])) + if 'color_rgb565' in row.index and row['color_rgb565']: + r, g, b = rgb565_to_rgb888(int(row['color_rgb565'])) info['color'] = f"#{r:02x}{g:02x}{b:02x}" else: info['color'] = 'N/A' diff --git a/pbf_to_fgb.py b/pbf_to_fgb.py index 89275e7..d9ed93d 100644 --- a/pbf_to_fgb.py +++ b/pbf_to_fgb.py @@ -228,17 +228,18 @@ def get_priority_for_tags(tags: Dict[str, str], config: Dict) -> int: return 50 -def hex_to_rgb332(hex_color: str) -> int: - """Convert hex color to RGB332 format.""" +def hex_to_rgb565(hex_color: str) -> int: + """Convert hex color to RGB565 format (16-bit: RRRRRGGGGGGBBBBB).""" try: if not hex_color or not hex_color.startswith("#"): - return 0xFF + return 0xFFFF r = int(hex_color[1:3], 16) g = int(hex_color[3:5], 16) b = int(hex_color[5:7], 16) - return ((r & 0xE0) | ((g & 0xE0) >> 3) | (b >> 6)) + # RGB565: 5 bits R, 6 bits G, 5 bits B + return ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3) except: - return 0xFF + return 0xFFFF def get_simplify_tolerance(zoom: int) -> float: @@ -366,8 +367,8 @@ def way(self, w): 'building' in tags or 'landuse' in tags or ('natural' in tags and tags.get('natural') in ['water', 'wood', 'forest', 'beach', 'sand', 'wetland', 'grassland', 'scrub', 'heath']) or - ('leisure' in tags and tags.get('leisure') in ['park', 'garden', 'pitch', 'golf_course', 'nature_reserve', 'playground', 'sports_centre', 'stadium']) or - ('amenity' in tags and tags.get('amenity') in ['parking', 'school', 'university', 'hospital']) or + ('leisure' in tags and tags.get('leisure') in ['park', 'garden', 'pitch', 'golf_course', 'nature_reserve', 'playground', 'sports_centre', 'stadium', 'common']) or + ('amenity' in tags and tags.get('amenity') in ['parking', 'school', 'university', 'hospital', 'marketplace']) or ('waterway' in tags and tags.get('waterway') in ['riverbank', 'dock', 'boatyard']) or tags.get('area') == 'yes' ) @@ -375,10 +376,11 @@ def way(self, w): # Process closed areas as Polygon (parks, buildings, etc.) # Note: area() only handles multipolygon relations in pyosmium 3.6, # so we must process closed ways here - if is_closed and is_area_tags: + # Skip highways - always process as lines to avoid covering other features + if is_closed and is_area_tags and 'highway' not in tags: color = get_color_for_tags(tags, self.config) priority = get_priority_for_tags(tags, self.config) - color_rgb332 = hex_to_rgb332(color) + color_rgb565 = hex_to_rgb565(color) layer_base_priority = LAYER_PRIORITY.get(layer, 50) combined_priority = layer_base_priority + (priority % 10) @@ -389,7 +391,7 @@ def way(self, w): 'properties': { 'osm_id': w.id, 'min_zoom': min_zoom, - 'color_rgb332': color_rgb332, + 'color_rgb565': color_rgb565, 'priority': combined_priority, 'layer': layer, 'feature_type': self._get_primary_tag(tags) @@ -405,7 +407,7 @@ def way(self, w): # Process as LineString (roads, rivers, etc.) color = get_color_for_tags(tags, self.config) priority = get_priority_for_tags(tags, self.config) - color_rgb332 = hex_to_rgb332(color) + color_rgb565 = hex_to_rgb565(color) layer_base_priority = LAYER_PRIORITY.get(layer, 50) combined_priority = layer_base_priority + (priority % 10) @@ -416,7 +418,7 @@ def way(self, w): 'properties': { 'osm_id': w.id, 'min_zoom': min_zoom, - 'color_rgb332': color_rgb332, + 'color_rgb565': color_rgb565, 'priority': combined_priority, 'layer': layer, 'feature_type': self._get_primary_tag(tags) @@ -461,6 +463,11 @@ def area(self, a): self.stats['features_filtered'] += 1 return + # Skip highways - they should be lines, not polygons that cover other features + if 'highway' in tags: + self.stats['features_filtered'] += 1 + return + min_zoom = get_zoom_for_tags(tags, self.config) if min_zoom > self.max_zoom: self.stats['features_filtered'] += 1 @@ -477,7 +484,7 @@ def area(self, a): color = get_color_for_tags(tags, self.config) priority = get_priority_for_tags(tags, self.config) - color_rgb332 = hex_to_rgb332(color) + color_rgb565 = hex_to_rgb565(color) layer_base_priority = LAYER_PRIORITY.get(layer, 50) combined_priority = layer_base_priority + (priority % 10) @@ -503,7 +510,7 @@ def area(self, a): 'properties': { 'osm_id': a.orig_id(), 'min_zoom': min_zoom, - 'color_rgb332': color_rgb332, + 'color_rgb565': color_rgb565, 'priority': combined_priority, 'layer': layer, 'feature_type': self._get_primary_tag(tags) @@ -818,7 +825,7 @@ def main(): Each tile FGB file contains features clipped to that tile with properties: - layer: Layer name for render ordering - priority: Render priority (lower = behind) - - color_rgb332: 8-bit color + - color_rgb565: 16-bit color (RGB565) - min_zoom: Minimum zoom for visibility """ ) From c5d0a0b971ed120c18abb09d87bc7e14a7a79d3f Mon Sep 17 00:00:00 2001 From: jgauchia Date: Thu, 8 Jan 2026 00:06:54 +0100 Subject: [PATCH 08/16] perf(fgb): remove osm_id property to reduce file size --- .gitignore | 1 + fgb_viewer.py | 2 -- pbf_to_fgb.py | 18 +++++++----------- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 1e05f85..6c8ed79 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ venv/ CHANGELOG_FGB_MIGRATION.md fgb_output/ rsync_copy.sh +FGBMAP/ diff --git a/fgb_viewer.py b/fgb_viewer.py index e90e1a8..448eb61 100644 --- a/fgb_viewer.py +++ b/fgb_viewer.py @@ -434,8 +434,6 @@ def _feature_to_dict(self, row) -> dict: info['priority'] = int(row['priority']) if 'min_zoom' in row.index: info['min_zoom'] = int(row['min_zoom']) - if 'osm_id' in row.index: - info['osm_id'] = int(row['osm_id']) info['geom_type'] = row.geometry.geom_type if row.geometry else 'N/A' return info diff --git a/pbf_to_fgb.py b/pbf_to_fgb.py index d9ed93d..bc480dc 100644 --- a/pbf_to_fgb.py +++ b/pbf_to_fgb.py @@ -389,7 +389,6 @@ def way(self, w): 'geometry_type': 'Polygon', 'coordinates': coords, 'properties': { - 'osm_id': w.id, 'min_zoom': min_zoom, 'color_rgb565': color_rgb565, 'priority': combined_priority, @@ -416,7 +415,6 @@ def way(self, w): 'geometry_type': 'LineString', 'coordinates': coords, 'properties': { - 'osm_id': w.id, 'min_zoom': min_zoom, 'color_rgb565': color_rgb565, 'priority': combined_priority, @@ -508,7 +506,6 @@ def area(self, a): 'geometry_type': 'Polygon', 'coordinates': coords, 'properties': { - 'osm_id': a.orig_id(), 'min_zoom': min_zoom, 'color_rgb565': color_rgb565, 'priority': combined_priority, @@ -660,7 +657,7 @@ def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, total_size = 0 # Statistics tracking - feature_tile_counts = [] # (osm_id, feature_type, geom_type, layer, num_tiles, zoom) + feature_tile_counts = [] # (feature_type, geom_type, layer, num_tiles, zoom) tiles_by_layer = defaultdict(int) tiles_by_geom_type = defaultdict(int) @@ -681,7 +678,6 @@ def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, num_tiles = len(tiles) if num_tiles > 1: # Only track features in multiple tiles feature_tile_counts.append(( - feature['properties']['osm_id'], feature['properties']['feature_type'], feature['geometry_type'], feature['properties']['layer'], @@ -773,18 +769,18 @@ def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, # Top features by tile count if feature_tile_counts: # Sort by num_tiles descending - sorted_features = sorted(feature_tile_counts, key=lambda x: -x[4]) + sorted_features = sorted(feature_tile_counts, key=lambda x: -x[3]) # Top 20 features logger.info("") logger.info("Top 20 features by tile coverage:") - logger.info(f" {'OSM ID':<12} {'Type':<25} {'Geom':<10} {'Layer':<12} {'Tiles':>8} {'Zoom':>5}") - logger.info(f" {'-'*12} {'-'*25} {'-'*10} {'-'*12} {'-'*8} {'-'*5}") - for osm_id, feat_type, geom_type, layer, num_tiles, zoom in sorted_features[:20]: - logger.info(f" {osm_id:<12} {feat_type:<25} {geom_type:<10} {layer:<12} {num_tiles:>8,} {zoom:>5}") + logger.info(f" {'Type':<25} {'Geom':<10} {'Layer':<12} {'Tiles':>8} {'Zoom':>5}") + logger.info(f" {'-'*25} {'-'*10} {'-'*12} {'-'*8} {'-'*5}") + for feat_type, geom_type, layer, num_tiles, zoom in sorted_features[:20]: + logger.info(f" {feat_type:<25} {geom_type:<10} {layer:<12} {num_tiles:>8,} {zoom:>5}") # Summary stats - all_tile_counts = [x[4] for x in feature_tile_counts] + all_tile_counts = [x[3] for x in feature_tile_counts] avg_tiles = sum(all_tile_counts) / len(all_tile_counts) if all_tile_counts else 0 max_tiles = max(all_tile_counts) if all_tile_counts else 0 features_over_100 = sum(1 for x in all_tile_counts if x > 100) From b1dbe8a29b29f5b05a79880b4a85cdcd2ff96054 Mon Sep 17 00:00:00 2001 From: jgauchia Date: Thu, 8 Jan 2026 00:21:55 +0100 Subject: [PATCH 09/16] perf(fgb): remove feature_type property to reduce file size --- README.md | 2 -- fgb_viewer.py | 2 -- pbf_to_fgb.py | 32 ++++++++++---------------------- 3 files changed, 10 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 52d0a4c..f984d07 100644 --- a/README.md +++ b/README.md @@ -191,8 +191,6 @@ Each feature in the FGB tiles contains: | `color_rgb565` | int | 16-bit color (RGB565 format) | | `min_zoom` | int | Minimum zoom level | | `priority` | int | Rendering priority | -| `feature_type` | string | OSM tag (e.g., `highway=primary`) | -| `osm_id` | int | Original OSM ID | | `layer` | string | Layer name | ## ESP32 Implementation diff --git a/fgb_viewer.py b/fgb_viewer.py index 448eb61..6605133 100644 --- a/fgb_viewer.py +++ b/fgb_viewer.py @@ -421,8 +421,6 @@ def identify_feature_at(self, pixel_x: int, pixel_y: int) -> Optional[dict]: def _feature_to_dict(self, row) -> dict: """Convert feature row to dict with relevant info.""" info = {} - if 'feature_type' in row.index: - info['type'] = row['feature_type'] if 'layer' in row.index: info['layer'] = row['layer'] if 'color_rgb565' in row.index and row['color_rgb565']: diff --git a/pbf_to_fgb.py b/pbf_to_fgb.py index bc480dc..ee44bc3 100644 --- a/pbf_to_fgb.py +++ b/pbf_to_fgb.py @@ -392,8 +392,7 @@ def way(self, w): 'min_zoom': min_zoom, 'color_rgb565': color_rgb565, 'priority': combined_priority, - 'layer': layer, - 'feature_type': self._get_primary_tag(tags) + 'layer': layer } } @@ -418,22 +417,13 @@ def way(self, w): 'min_zoom': min_zoom, 'color_rgb565': color_rgb565, 'priority': combined_priority, - 'layer': layer, - 'feature_type': self._get_primary_tag(tags) + 'layer': layer } } self.features.append(feature) self.stats['features_extracted'] += 1 - def _get_primary_tag(self, tags: Dict[str, str]) -> str: - """Get the primary identifying tag for a feature.""" - priority_keys = ['highway', 'railway', 'waterway', 'building', 'natural', 'landuse', 'leisure', 'amenity', 'aeroway'] - for key in priority_keys: - if key in tags: - return f"{key}={tags[key]}" - return 'unknown' - def relation(self, r): """Process relation - count only, areas handled by area() callback.""" self.stats['relations_processed'] += 1 @@ -509,8 +499,7 @@ def area(self, a): 'min_zoom': min_zoom, 'color_rgb565': color_rgb565, 'priority': combined_priority, - 'layer': layer, - 'feature_type': self._get_primary_tag(tags) + 'layer': layer } } @@ -657,7 +646,7 @@ def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, total_size = 0 # Statistics tracking - feature_tile_counts = [] # (feature_type, geom_type, layer, num_tiles, zoom) + feature_tile_counts = [] # (geom_type, layer, num_tiles, zoom) tiles_by_layer = defaultdict(int) tiles_by_geom_type = defaultdict(int) @@ -678,7 +667,6 @@ def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, num_tiles = len(tiles) if num_tiles > 1: # Only track features in multiple tiles feature_tile_counts.append(( - feature['properties']['feature_type'], feature['geometry_type'], feature['properties']['layer'], num_tiles, @@ -769,18 +757,18 @@ def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, # Top features by tile count if feature_tile_counts: # Sort by num_tiles descending - sorted_features = sorted(feature_tile_counts, key=lambda x: -x[3]) + sorted_features = sorted(feature_tile_counts, key=lambda x: -x[2]) # Top 20 features logger.info("") logger.info("Top 20 features by tile coverage:") - logger.info(f" {'Type':<25} {'Geom':<10} {'Layer':<12} {'Tiles':>8} {'Zoom':>5}") - logger.info(f" {'-'*25} {'-'*10} {'-'*12} {'-'*8} {'-'*5}") - for feat_type, geom_type, layer, num_tiles, zoom in sorted_features[:20]: - logger.info(f" {feat_type:<25} {geom_type:<10} {layer:<12} {num_tiles:>8,} {zoom:>5}") + logger.info(f" {'Geom':<10} {'Layer':<12} {'Tiles':>8} {'Zoom':>5}") + logger.info(f" {'-'*10} {'-'*12} {'-'*8} {'-'*5}") + for geom_type, layer, num_tiles, zoom in sorted_features[:20]: + logger.info(f" {geom_type:<10} {layer:<12} {num_tiles:>8,} {zoom:>5}") # Summary stats - all_tile_counts = [x[3] for x in feature_tile_counts] + all_tile_counts = [x[2] for x in feature_tile_counts] avg_tiles = sum(all_tile_counts) / len(all_tile_counts) if all_tile_counts else 0 max_tiles = max(all_tile_counts) if all_tile_counts else 0 features_over_100 = sum(1 for x in all_tile_counts if x > 100) From 14fbdcf6b7604695d7d64e891a07f7b2b64d6b1a Mon Sep 17 00:00:00 2001 From: jgauchia Date: Thu, 8 Jan 2026 00:31:19 +0100 Subject: [PATCH 10/16] perf(fgb): remove layer property to reduce file size --- README.md | 1 - fgb_viewer.py | 2 -- pbf_to_fgb.py | 32 ++++++++++---------------------- 3 files changed, 10 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index f984d07..9af5118 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,6 @@ Each feature in the FGB tiles contains: | `color_rgb565` | int | 16-bit color (RGB565 format) | | `min_zoom` | int | Minimum zoom level | | `priority` | int | Rendering priority | -| `layer` | string | Layer name | ## ESP32 Implementation diff --git a/fgb_viewer.py b/fgb_viewer.py index 6605133..34b8af7 100644 --- a/fgb_viewer.py +++ b/fgb_viewer.py @@ -421,8 +421,6 @@ def identify_feature_at(self, pixel_x: int, pixel_y: int) -> Optional[dict]: def _feature_to_dict(self, row) -> dict: """Convert feature row to dict with relevant info.""" info = {} - if 'layer' in row.index: - info['layer'] = row['layer'] if 'color_rgb565' in row.index and row['color_rgb565']: r, g, b = rgb565_to_rgb888(int(row['color_rgb565'])) info['color'] = f"#{r:02x}{g:02x}{b:02x}" diff --git a/pbf_to_fgb.py b/pbf_to_fgb.py index ee44bc3..fcf100c 100644 --- a/pbf_to_fgb.py +++ b/pbf_to_fgb.py @@ -391,8 +391,7 @@ def way(self, w): 'properties': { 'min_zoom': min_zoom, 'color_rgb565': color_rgb565, - 'priority': combined_priority, - 'layer': layer + 'priority': combined_priority } } @@ -416,8 +415,7 @@ def way(self, w): 'properties': { 'min_zoom': min_zoom, 'color_rgb565': color_rgb565, - 'priority': combined_priority, - 'layer': layer + 'priority': combined_priority } } @@ -498,8 +496,7 @@ def area(self, a): 'properties': { 'min_zoom': min_zoom, 'color_rgb565': color_rgb565, - 'priority': combined_priority, - 'layer': layer + 'priority': combined_priority } } @@ -646,8 +643,7 @@ def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, total_size = 0 # Statistics tracking - feature_tile_counts = [] # (geom_type, layer, num_tiles, zoom) - tiles_by_layer = defaultdict(int) + feature_tile_counts = [] # (geom_type, num_tiles, zoom) tiles_by_geom_type = defaultdict(int) for zoom in range(zoom_range[0], zoom_range[1] + 1): @@ -668,11 +664,9 @@ def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, if num_tiles > 1: # Only track features in multiple tiles feature_tile_counts.append(( feature['geometry_type'], - feature['properties']['layer'], num_tiles, zoom )) - tiles_by_layer[feature['properties']['layer']] += num_tiles tiles_by_geom_type[feature['geometry_type']] += num_tiles for tile in tiles: @@ -748,27 +742,21 @@ def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, for geom_type, count in sorted(tiles_by_geom_type.items(), key=lambda x: -x[1]): logger.info(f" {geom_type}: {count:,} tile assignments") - # Tiles by layer - logger.info("") - logger.info("Tile assignments by layer:") - for layer, count in sorted(tiles_by_layer.items(), key=lambda x: -x[1]): - logger.info(f" {layer}: {count:,} tile assignments") - # Top features by tile count if feature_tile_counts: # Sort by num_tiles descending - sorted_features = sorted(feature_tile_counts, key=lambda x: -x[2]) + sorted_features = sorted(feature_tile_counts, key=lambda x: -x[1]) # Top 20 features logger.info("") logger.info("Top 20 features by tile coverage:") - logger.info(f" {'Geom':<10} {'Layer':<12} {'Tiles':>8} {'Zoom':>5}") - logger.info(f" {'-'*10} {'-'*12} {'-'*8} {'-'*5}") - for geom_type, layer, num_tiles, zoom in sorted_features[:20]: - logger.info(f" {geom_type:<10} {layer:<12} {num_tiles:>8,} {zoom:>5}") + logger.info(f" {'Geom':<10} {'Tiles':>8} {'Zoom':>5}") + logger.info(f" {'-'*10} {'-'*8} {'-'*5}") + for geom_type, num_tiles, zoom in sorted_features[:20]: + logger.info(f" {geom_type:<10} {num_tiles:>8,} {zoom:>5}") # Summary stats - all_tile_counts = [x[2] for x in feature_tile_counts] + all_tile_counts = [x[1] for x in feature_tile_counts] avg_tiles = sum(all_tile_counts) / len(all_tile_counts) if all_tile_counts else 0 max_tiles = max(all_tile_counts) if all_tile_counts else 0 features_over_100 = sum(1 for x in all_tile_counts if x > 100) From 2d697510786adda673fcbb10373b601095d9f950 Mon Sep 17 00:00:00 2001 From: jgauchia Date: Thu, 8 Jan 2026 18:42:10 +0100 Subject: [PATCH 11/16] perf(fgb): pack min_zoom and priority into single byte --- README.md | 7 +++++-- fgb_viewer.py | 20 ++++++++++---------- pbf_to_fgb.py | 24 +++++++++++++++++------- 3 files changed, 32 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 9af5118..ea79e69 100644 --- a/README.md +++ b/README.md @@ -189,8 +189,11 @@ Each feature in the FGB tiles contains: | Property | Type | Description | |----------|------|-------------| | `color_rgb565` | int | 16-bit color (RGB565 format) | -| `min_zoom` | int | Minimum zoom level | -| `priority` | int | Rendering priority | +| `zoom_priority` | int | Packed byte: high nibble = min_zoom (0-15), low nibble = priority/7 (0-15) | + +To unpack `zoom_priority`: +- `min_zoom = zoom_priority >> 4` +- `priority = (zoom_priority & 0x0F) * 7` ## ESP32 Implementation diff --git a/fgb_viewer.py b/fgb_viewer.py index 34b8af7..c520925 100644 --- a/fgb_viewer.py +++ b/fgb_viewer.py @@ -226,9 +226,9 @@ def query_features(self) -> Optional[gpd.GeoDataFrame]: result = pd.concat(all_gdfs, ignore_index=True) result = gpd.GeoDataFrame(result, crs="EPSG:4326") - # Filter by min_zoom if available - if 'min_zoom' in result.columns: - result = result[result['min_zoom'] <= self.zoom] + # Filter by min_zoom (unpacked from zoom_priority high nibble) + if 'zoom_priority' in result.columns: + result = result[(result['zoom_priority'].astype(int) // 16) <= self.zoom] self.last_query_stats = { 'tiles_loaded': tiles_loaded, @@ -251,9 +251,9 @@ def render_to_surface(self, surface: pygame.Surface): self.cached_features = None return - # Sort by priority if available - if 'priority' in features.columns: - features = features.sort_values('priority') + # Sort by priority (unpacked from zoom_priority low nibble) + if 'zoom_priority' in features.columns: + features = features.iloc[(features['zoom_priority'].astype(int) % 16).argsort()] # Cache for click identification self.cached_features = features @@ -426,10 +426,10 @@ def _feature_to_dict(self, row) -> dict: info['color'] = f"#{r:02x}{g:02x}{b:02x}" else: info['color'] = 'N/A' - if 'priority' in row.index: - info['priority'] = int(row['priority']) - if 'min_zoom' in row.index: - info['min_zoom'] = int(row['min_zoom']) + if 'zoom_priority' in row.index: + zp = int(row['zoom_priority']) + info['min_zoom'] = zp >> 4 + info['priority'] = (zp & 0x0F) * 7 info['geom_type'] = row.geometry.geom_type if row.geometry else 'N/A' return info diff --git a/pbf_to_fgb.py b/pbf_to_fgb.py index fcf100c..63a4dbb 100644 --- a/pbf_to_fgb.py +++ b/pbf_to_fgb.py @@ -242,6 +242,17 @@ def hex_to_rgb565(hex_color: str) -> int: return 0xFFFF +def pack_zoom_priority(min_zoom: int, priority: int) -> int: + """Pack min_zoom and priority into a single byte. + + High nibble: min_zoom (0-15) + Low nibble: priority / 7 (0-15) + """ + zoom_nibble = min(min_zoom, 15) & 0x0F + priority_nibble = min(priority // 7, 15) & 0x0F + return (zoom_nibble << 4) | priority_nibble + + def get_simplify_tolerance(zoom: int) -> float: """ Calculate simplification tolerance based on zoom level. @@ -389,9 +400,8 @@ def way(self, w): 'geometry_type': 'Polygon', 'coordinates': coords, 'properties': { - 'min_zoom': min_zoom, 'color_rgb565': color_rgb565, - 'priority': combined_priority + 'zoom_priority': pack_zoom_priority(min_zoom, combined_priority) } } @@ -413,9 +423,8 @@ def way(self, w): 'geometry_type': 'LineString', 'coordinates': coords, 'properties': { - 'min_zoom': min_zoom, 'color_rgb565': color_rgb565, - 'priority': combined_priority + 'zoom_priority': pack_zoom_priority(min_zoom, combined_priority) } } @@ -494,9 +503,8 @@ def area(self, a): 'geometry_type': 'Polygon', 'coordinates': coords, 'properties': { - 'min_zoom': min_zoom, 'color_rgb565': color_rgb565, - 'priority': combined_priority + 'zoom_priority': pack_zoom_priority(min_zoom, combined_priority) } } @@ -652,7 +660,9 @@ def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, for feature in handler.features: # Only include features visible at this zoom - if feature['properties']['min_zoom'] > zoom: + # Unpack min_zoom from zoom_priority (high nibble) + min_zoom = feature['properties']['zoom_priority'] >> 4 + if min_zoom > zoom: continue # Get all tiles this feature touches From e25f7b2e340c730304cdc7e8976f0a62974b69eb Mon Sep 17 00:00:00 2001 From: jgauchia Date: Thu, 8 Jan 2026 23:08:55 +0100 Subject: [PATCH 12/16] feat(nav): Add NAV binary format generator and viewer --- .gitignore | 1 + README.md | 204 ++++++-------- fgb_viewer.py => nav_viewer.py | 485 +++++++++++++++------------------ pbf_to_fgb.py => pbf_to_nav.py | 453 +++++++++++------------------- 4 files changed, 453 insertions(+), 690 deletions(-) rename fgb_viewer.py => nav_viewer.py (56%) rename pbf_to_fgb.py => pbf_to_nav.py (61%) diff --git a/.gitignore b/.gitignore index 6c8ed79..ce34629 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ CHANGELOG_FGB_MIGRATION.md fgb_output/ rsync_copy.sh FGBMAP/ +NAVMAP/ diff --git a/README.md b/README.md index ea79e69..bf73bcf 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,14 @@ -# OSM to FlatGeobuf Tile Generator +# OSM Tile Generator for IceNav -Converts OpenStreetMap PBF files to FlatGeobuf (.fgb) format with R-Tree spatial indexing for [IceNav](https://github.com/jgauchia/IceNav-v3) ESP32-based GPS navigator. +Converts OpenStreetMap PBF files to NAV tiles for [IceNav](https://github.com/jgauchia/IceNav-v3) ESP32-based GPS navigator. ## Features - **Direct PBF processing** - No intermediate formats (GOL, Docker, etc.) - **Tile-based structure** - Standard z/x/y tile layout (like PNG/OSM tiles) -- **R-Tree spatial index** - Fast bounding box queries per tile - **No clipping artifacts** - Features stored complete (no visible seams at tile edges) - **Feature filtering** - Configurable via `features.json` -- **ESP32 optimized** - Small tiles (~100KB-1MB), efficient for SD card access +- **ESP32 optimized** - Small tiles, efficient for SD card access - **Progress bar** - Visual progress per zoom level during generation ## Requirements @@ -32,13 +31,13 @@ source venv/bin/activate pip install geopandas pyogrio shapely pygame osmium ``` -## Usage +--- -### 1. Convert PBF to FlatGeobuf Tiles +## Generate NAV Tiles ```bash source venv/bin/activate -python pbf_to_fgb.py features.json [--zoom 6-17] +python pbf_to_nav.py features.json [--zoom 6-17] ``` **Arguments:** @@ -53,52 +52,21 @@ python pbf_to_fgb.py features.json [--zoom 6-17] **Example:** ```bash -python pbf_to_fgb.py catalonia.osm.pbf ./fgb_output features.json --zoom 6-17 +python pbf_to_nav.py andorra.osm.pbf ./nav_output features.json --zoom 6-17 ``` -**Output:** +--- -``` -Processing OSM data (this may take a while for large files)... - Progress: 1,234,567 ways, 456,789 features [2m 34s] -Processing completed in 154.23s -Statistics: - Ways processed: 1,234,567 - Features extracted: 456,789 - Features filtered: 777,778 -Generating tile-based FlatGeobuf files... - Zoom 6: [██████████████████████████████] 7/7 tiles - Zoom 6: 7 tiles written - Zoom 7: [██████████████████████████████] 20/20 tiles - Zoom 7: 20 tiles written - ... -``` - -### 2. View Generated Tiles - -The viewer simulates ESP32 rendering behavior - loads a 3x3 tile grid (768x768 pixels) centered on the given coordinates. +## View NAV Tiles ```bash -python fgb_viewer.py --lat --lon [--zoom ] +python nav_viewer.py --lat --lon [--zoom ] ``` -**Arguments:** - -| Argument | Description | Default | -|----------|-------------|---------| -| `fgb_dir` | Directory with FGB tiles | Required | -| `--lat` | Center latitude | Required | -| `--lon` | Center longitude | Required | -| `--zoom` | Zoom level (1-18) | `14` | - **Example:** ```bash -# Andorra (small test) -python fgb_viewer.py ./fgb_andorra --lat 42.5063 --lon 1.5218 --zoom 14 - -# Barcelona -python fgb_viewer.py ./fgb_output --lat 41.3851 --lon 2.1734 --zoom 14 +python nav_viewer.py ./nav_output --lat 42.5063 --lon 1.5218 --zoom 14 ``` **Viewer Controls:** @@ -110,45 +78,92 @@ python fgb_viewer.py ./fgb_output --lat 41.3851 --lon 2.1734 --zoom 14 | `[` / `]` or Mouse wheel | Zoom in/out | | `B` | Toggle background (white/black) | | `F` | Toggle polygon fill | -| `G` | Toggle tile grid (shows tile x/y coordinates) | +| `G` | Toggle tile grid | | `Q` / `ESC` | Quit | -**Viewer Features:** +--- + +## NAV Binary Format Specification + +NAV is a custom binary format optimized for sequential reading on ESP32. Uses int32 scaled coordinates instead of float64 for compact storage. + +**File Header (24 bytes):** + +| Offset | Size | Field | Description | +|--------|------|-------|-------------| +| 0 | 4 | Magic | `NAV1` (0x4E, 0x41, 0x56, 0x31) | +| 4 | 1 | Version | Format version (currently 1) | +| 5 | 2 | Feature count | Number of features (little-endian) | +| 7 | 1 | Reserved | Padding | +| 8 | 4 | Min Lon | Bounding box min longitude (int32 scaled) | +| 12 | 4 | Min Lat | Bounding box min latitude (int32 scaled) | +| 16 | 4 | Max Lon | Bounding box max longitude (int32 scaled) | +| 20 | 4 | Max Lat | Bounding box max latitude (int32 scaled) | + +**Feature Record:** -- **3x3 tile grid**: Loads exactly 9 tiles like ESP32 -- **Viewport aligned to tiles**: No gaps or overlaps at tile boundaries -- **Tile grid overlay**: Press `G` to see tile coordinates (green=exists, red=missing) -- **Query stats**: Shows tiles loaded, features count, and load time +| Size | Field | Description | +|------|-------|-------------| +| 1 | Geometry type | 1=Point, 2=LineString, 3=Polygon | +| 2 | Color | RGB565 color (little-endian) | +| 1 | Zoom/Priority | High nibble = min_zoom, low nibble = priority/7 | +| 2 | Coord count | Number of coordinates (little-endian) | +| 8×N | Coordinates | lon(int32) + lat(int32) pairs | +| 1 | Ring count | (Polygons only) Number of rings | +| 2×R | Ring ends | (Polygons only) End index of each ring | + +**Coordinate Scaling:** + +Coordinates are stored as int32 scaled by 10,000,000 (1e7): +- `int32_value = (int32_t)(float_coord * 10000000)` +- `float_coord = (double)int32_value / 10000000.0` + +This provides ~1cm precision while using half the space of float64. + +--- ## Output Structure ``` -fgb_output/ +output/ ├── 6/ │ ├── 32/ -│ │ ├── 23.fgb -│ │ └── 24.fgb +│ │ ├── 23.nav +│ │ └── 24.nav │ └── 33/ │ └── ... ├── 13/ -│ ├── 4123/ -│ │ ├── 2456.fgb -│ │ ├── 2457.fgb -│ │ └── ... -│ └── 4124/ -│ └── ... +│ └── ... └── 17/ └── ... ``` -Standard z/x/y tile structure where: +Standard z/x/y tile structure: - First level: zoom level - Second level: tile X coordinate -- Third level: tile Y coordinate (.fgb file) +- Third level: tile Y coordinate (`.nav` file) + +**Note:** Features are NOT clipped to tile boundaries. Each feature is stored complete in every tile it intersects. -Each tile contains ALL layers (water, roads, buildings, etc.) combined with properties for filtering and rendering. +--- -**Note:** Features are NOT clipped to tile boundaries. Each feature is stored complete in every tile it intersects. This avoids visible seams at tile edges - the renderer clips naturally when drawing. +## SD Card Structure + +Copy the output directory to your SD card: + +``` +/sdcard/NAVMAP/ +├── 6/ +│ └── 32/ +│ └── 23.nav +├── 13/ +│ └── 4123/ +│ └── 2456.nav +└── 17/ + └── ... +``` + +--- ## Feature Configuration @@ -174,75 +189,13 @@ The `features.json` file defines which OSM features to include: } ``` -**Fields:** - | Field | Description | |-------|-------------| | `zoom` | Minimum zoom level for feature visibility | | `color` | Hex color (converted to RGB565 internally) | | `priority` | Render order (lower = background, higher = foreground) | -## FlatGeobuf Properties - -Each feature in the FGB tiles contains: - -| Property | Type | Description | -|----------|------|-------------| -| `color_rgb565` | int | 16-bit color (RGB565 format) | -| `zoom_priority` | int | Packed byte: high nibble = min_zoom (0-15), low nibble = priority/7 (0-15) | - -To unpack `zoom_priority`: -- `min_zoom = zoom_priority >> 4` -- `priority = (zoom_priority & 0x0F) * 7` - -## ESP32 Implementation - -The generated FGB tiles are optimized for ESP32: - -1. **Calculate center tile**: Use Web Mercator projection to convert lat/lon/zoom to tile x,y -2. **Calculate viewport bbox**: Based on exact 3x3 tile boundaries (not center ± offset) -3. **Load 3x3 grid**: Load 9 tiles around center position -4. **Read features**: Each tile is small, read sequentially without random seeks -5. **Sort by priority**: Lower priority = rendered first (background) -6. **Render features**: Use `priority` for layer ordering, `color_rgb565` for colors - -### Tile Coordinate Calculation - -```cpp -// Web Mercator projection -double latRad = centerLat * M_PI / 180.0; -double n = pow(2.0, zoom); -int centerTileX = (int)((centerLon + 180.0) / 360.0 * n); -int centerTileY = (int)((1.0 - log(tan(latRad) + 1.0/cos(latRad)) / M_PI) / 2.0 * n); - -// Viewport bbox aligned to tile boundaries -int minTileX = centerTileX - 1; -int maxTileX = centerTileX + 2; // +2 for right edge -int minTileY = centerTileY - 1; -int maxTileY = centerTileY + 2; // +2 for bottom edge -``` - -### Advantages of tile-based structure - -- Small files (~100KB-1MB per tile) -- Sequential SD card reads (no random seeks = no timeout errors) -- Standard tile naming (compatible with OSM tools) -- Easy to update specific areas -- Familiar structure for map developers - -### SD Card Structure - -Copy the output directory to your SD card: - -``` -/sdcard/FGBMAP/ -├── 6/ -│ └── ... -├── 13/ -│ └── ... -└── 17/ - └── ... -``` +--- ## Download PBF Files @@ -255,4 +208,3 @@ MIT License ## Related Projects - [IceNav](https://github.com/jgauchia/IceNav-v3) - ESP32-based GPS navigator -- [FlatGeobuf](https://flatgeobuf.org/) - Geospatial format specification diff --git a/fgb_viewer.py b/nav_viewer.py similarity index 56% rename from fgb_viewer.py rename to nav_viewer.py index c520925..72d568f 100644 --- a/fgb_viewer.py +++ b/nav_viewer.py @@ -1,21 +1,20 @@ #!/usr/bin/env python3 """ -FlatGeobuf Viewer - ESP32 Map Simulator +NAV Tile Viewer - ESP32 Map Simulator -Simulates the ESP32 map rendering behavior using tile-based FlatGeobuf files. -Displays a 768x768 viewport (3x3 tiles of 256px) centered on given coordinates. +Displays NAV binary tiles in a 768x768 viewport (3x3 tiles of 256px). Usage: - python fgb_viewer.py fgb_dir --lat 42.5063 --lon 1.5218 [--zoom 14] + python nav_viewer.py nav_dir --lat 42.5063 --lon 1.5218 [--zoom 14] """ import os import sys import math +import struct import argparse import logging from typing import Dict, List, Tuple, Optional, Set -import json try: import pygame @@ -24,29 +23,29 @@ PYGAME_AVAILABLE = False print("Warning: pygame not found. Install with: pip install pygame") -try: - import geopandas as gpd - import pandas as pd - from shapely.geometry import box, Point, LineString, Polygon - GEOPANDAS_AVAILABLE = True -except ImportError: - GEOPANDAS_AVAILABLE = False - print("Warning: geopandas not found. Install with: pip install geopandas pyogrio") - logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) -# Constants matching ESP32 implementation +# Constants TILE_SIZE = 256 -VIEWPORT_SIZE = 768 # 3x3 tiles +VIEWPORT_SIZE = 768 TOOLBAR_WIDTH = 200 STATUSBAR_HEIGHT = 60 WINDOW_WIDTH = VIEWPORT_SIZE + TOOLBAR_WIDTH WINDOW_HEIGHT = VIEWPORT_SIZE + STATUSBAR_HEIGHT +# NAV format constants +NAV_MAGIC = b'NAV1' +COORD_SCALE = 10000000 # 1e7 + +# Geometry types +GEOM_POINT = 1 +GEOM_LINESTRING = 2 +GEOM_POLYGON = 3 + def deg2num(lat_deg: float, lon_deg: float, zoom: int) -> Tuple[float, float]: - """Convert lat/lon to tile numbers (floating point for sub-tile precision).""" + """Convert lat/lon to tile numbers.""" lat_rad = math.radians(lat_deg) n = 2.0 ** zoom xtile = (lon_deg + 180.0) / 360.0 * n @@ -64,17 +63,15 @@ def num2deg(xtile: float, ytile: float, zoom: int) -> Tuple[float, float]: def get_bbox_for_viewport(center_lat: float, center_lon: float, zoom: int) -> Tuple[float, float, float, float]: - """Calculate bounding box for 768x768 viewport based on 3x3 tile grid.""" - # Get center tile + """Calculate bounding box for 3x3 tile viewport.""" center_x, center_y = deg2num(center_lat, center_lon, zoom) center_tile_x = int(center_x) center_tile_y = int(center_y) - # Bbox covers exactly 3x3 tiles around center tile min_tile_x = center_tile_x - 1 - max_tile_x = center_tile_x + 2 # +2 because we need the right edge of tile +1 + max_tile_x = center_tile_x + 2 min_tile_y = center_tile_y - 1 - max_tile_y = center_tile_y + 2 # +2 because we need the bottom edge of tile +1 + max_tile_y = center_tile_y + 2 max_lat, min_lon = num2deg(min_tile_x, min_tile_y, zoom) min_lat, max_lon = num2deg(max_tile_x, max_tile_y, zoom) @@ -82,7 +79,7 @@ def get_bbox_for_viewport(center_lat: float, center_lon: float, zoom: int) -> Tu def latlon_to_pixel(lat: float, lon: float, bbox: Tuple[float, float, float, float]) -> Tuple[int, int]: - """Convert lat/lon to pixel coordinates within viewport.""" + """Convert lat/lon to pixel coordinates.""" min_lon, min_lat, max_lon, max_lat = bbox x_norm = (lon - min_lon) / (max_lon - min_lon) y_norm = (max_lat - lat) / (max_lat - min_lat) @@ -100,23 +97,82 @@ def rgb565_to_rgb888(c: int) -> Tuple[int, int, int]: def darken_color(rgb: Tuple[int, int, int], amount: float = 0.3) -> Tuple[int, int, int]: - """Darken RGB color by specified amount.""" + """Darken RGB color.""" return tuple(max(0, int(v * (1 - amount))) for v in rgb) -class FGBViewer: - """FlatGeobuf map viewer simulating ESP32 behavior with tile-based files.""" +class NavFeature: + """Parsed NAV feature.""" + def __init__(self): + self.geom_type = 0 + self.color_rgb565 = 0xFFFF + self.zoom_priority = 0 + self.coords = [] # List of (lon, lat) floats - def __init__(self, fgb_dir: str, config_file: str = None): - self.fgb_dir = fgb_dir - self.config = {} - self.available_zooms: Set[int] = set() + @property + def min_zoom(self): + return self.zoom_priority >> 4 + + @property + def priority(self): + return (self.zoom_priority & 0x0F) * 7 + + +def read_nav_tile(path: str) -> List[NavFeature]: + """Read NAV tile file and return list of features.""" + features = [] + + try: + with open(path, 'rb') as f: + # Read header (20 bytes) + magic = f.read(4) + if magic != NAV_MAGIC: + logger.warning(f"Invalid magic in {path}") + return features + + version = struct.unpack(' List[Tuple[int, int]]: - """Get list of tiles (x, y) that cover the current viewport.""" + """Get list of tiles for current viewport.""" center_x, center_y = deg2num(self.center_lat, self.center_lon, self.zoom) center_tile_x = int(center_x) center_tile_y = int(center_y) tiles = [] - # 3x3 grid around center for dy in range(-1, 2): for dx in range(-1, 2): tile_x = center_tile_x + dx tile_y = center_tile_y + dy - # Validate tile coordinates max_tile = 2 ** self.zoom - 1 if 0 <= tile_x <= max_tile and 0 <= tile_y <= max_tile: tiles.append((tile_x, tile_y)) @@ -175,167 +225,110 @@ def _get_tiles_for_viewport(self) -> List[Tuple[int, int]]: def _get_tile_path(self, x: int, y: int) -> str: """Get file path for a tile.""" - return os.path.join(self.fgb_dir, str(self.zoom), str(x), f"{y}.fgb") + return os.path.join(self.nav_dir, str(self.zoom), str(x), f"{y}.nav") def set_center(self, lat: float, lon: float, zoom: int = None): - """Set viewport center and optionally zoom.""" + """Set viewport center.""" self.center_lat = lat self.center_lon = lon if zoom is not None: self.zoom = zoom self.bbox = get_bbox_for_viewport(self.center_lat, self.center_lon, self.zoom) - def query_features(self) -> Optional[gpd.GeoDataFrame]: - """Query features from tiles within current viewport.""" + def query_features(self) -> List[NavFeature]: + """Query features from tiles.""" if self.bbox is None: - return None + return [] import time start = time.time() tiles = self._get_tiles_for_viewport() - all_gdfs = [] + all_features = [] tiles_loaded = 0 tiles_missing = 0 for tile_x, tile_y in tiles: tile_path = self._get_tile_path(tile_x, tile_y) if os.path.exists(tile_path): - try: - gdf = gpd.read_file(tile_path) - if len(gdf) > 0: - all_gdfs.append(gdf) - tiles_loaded += 1 - except Exception as e: - logger.warning(f"Error reading tile {tile_path}: {e}") + features = read_nav_tile(tile_path) + # Filter by zoom + features = [f for f in features if f.min_zoom <= self.zoom] + all_features.extend(features) + tiles_loaded += 1 else: tiles_missing += 1 elapsed = (time.time() - start) * 1000 - if not all_gdfs: - self.last_query_stats = { - 'tiles_loaded': 0, - 'tiles_missing': tiles_missing, - 'features': 0, - 'time_ms': elapsed - } - return None - - # Combine all tile features - result = pd.concat(all_gdfs, ignore_index=True) - result = gpd.GeoDataFrame(result, crs="EPSG:4326") - - # Filter by min_zoom (unpacked from zoom_priority high nibble) - if 'zoom_priority' in result.columns: - result = result[(result['zoom_priority'].astype(int) // 16) <= self.zoom] - self.last_query_stats = { 'tiles_loaded': tiles_loaded, 'tiles_missing': tiles_missing, - 'features': len(result), + 'features': len(all_features), 'time_ms': elapsed } - return result + return all_features def render_to_surface(self, surface: pygame.Surface): - """Render all visible features to pygame surface.""" + """Render features to pygame surface.""" if self.bbox is None: return surface.fill(self.background_color) features = self.query_features() - if features is None or len(features) == 0: + if not features: self.cached_features = None return - # Sort by priority (unpacked from zoom_priority low nibble) - if 'zoom_priority' in features.columns: - features = features.iloc[(features['zoom_priority'].astype(int) % 16).argsort()] - - # Cache for click identification + # Sort by priority + features.sort(key=lambda f: f.priority) self.cached_features = features - for idx, row in features.iterrows(): - self._render_feature(surface, row) + for feature in features: + self._render_feature(surface, feature) if self.show_tile_grid: self._draw_tile_grid(surface) - def _render_feature(self, surface: pygame.Surface, feature): + def _render_feature(self, surface: pygame.Surface, feature: NavFeature): """Render a single feature.""" - geom = feature.geometry - if geom is None or geom.is_empty: + if not feature.coords: return - if 'color_rgb565' in feature.index and feature['color_rgb565']: - color = rgb565_to_rgb888(int(feature['color_rgb565'])) - else: - color = (200, 200, 200) - - geom_type = geom.geom_type - - if geom_type == 'Point': - self._render_point(surface, geom, color) - elif geom_type == 'LineString': - self._render_linestring(surface, geom, color) - elif geom_type == 'Polygon': - self._render_polygon(surface, geom, color) - elif geom_type == 'MultiLineString': - for line in geom.geoms: - self._render_linestring(surface, line, color) - elif geom_type == 'MultiPolygon': - for poly in geom.geoms: - self._render_polygon(surface, poly, color) - elif geom_type == 'GeometryCollection': - for g in geom.geoms: - self._render_geometry(surface, g, color) - - def _render_geometry(self, surface: pygame.Surface, geom, color: Tuple[int, int, int]): - """Render any geometry type.""" - if geom is None or geom.is_empty: - return - geom_type = geom.geom_type - if geom_type == 'Point': - self._render_point(surface, geom, color) - elif geom_type == 'LineString': - self._render_linestring(surface, geom, color) - elif geom_type == 'Polygon': - self._render_polygon(surface, geom, color) - elif geom_type == 'MultiLineString': - for line in geom.geoms: - self._render_linestring(surface, line, color) - elif geom_type == 'MultiPolygon': - for poly in geom.geoms: - self._render_polygon(surface, poly, color) - - def _render_point(self, surface: pygame.Surface, point, color: Tuple[int, int, int]): - """Render a point feature.""" - px, py = latlon_to_pixel(point.y, point.x, self.bbox) - if 0 <= px < VIEWPORT_SIZE and 0 <= py < VIEWPORT_SIZE: - pygame.draw.circle(surface, color, (px, py), 3) - - def _render_linestring(self, surface: pygame.Surface, line, color: Tuple[int, int, int], width: int = 1): - """Render a linestring feature.""" + color = rgb565_to_rgb888(feature.color_rgb565) + + if feature.geom_type == GEOM_POINT: + self._render_point(surface, feature, color) + elif feature.geom_type == GEOM_LINESTRING: + self._render_linestring(surface, feature, color) + elif feature.geom_type == GEOM_POLYGON: + self._render_polygon(surface, feature, color) + + def _render_point(self, surface: pygame.Surface, feature: NavFeature, color: Tuple[int, int, int]): + """Render point.""" + if feature.coords: + lon, lat = feature.coords[0] + px, py = latlon_to_pixel(lat, lon, self.bbox) + if 0 <= px < VIEWPORT_SIZE and 0 <= py < VIEWPORT_SIZE: + pygame.draw.circle(surface, color, (px, py), 3) + + def _render_linestring(self, surface: pygame.Surface, feature: NavFeature, color: Tuple[int, int, int]): + """Render linestring.""" points = [] - for coord in line.coords: - px, py = latlon_to_pixel(coord[1], coord[0], self.bbox) + for lon, lat in feature.coords: + px, py = latlon_to_pixel(lat, lon, self.bbox) points.append((px, py)) if len(points) >= 2: - pygame.draw.lines(surface, color, False, points, width) + pygame.draw.lines(surface, color, False, points, 1) - def _render_polygon(self, surface: pygame.Surface, polygon, color: Tuple[int, int, int]): - """Render a polygon feature.""" - if polygon.is_empty: - return - - exterior = polygon.exterior + def _render_polygon(self, surface: pygame.Surface, feature: NavFeature, color: Tuple[int, int, int]): + """Render polygon.""" points = [] - for coord in exterior.coords: - px, py = latlon_to_pixel(coord[1], coord[0], self.bbox) + for lon, lat in feature.coords: + px, py = latlon_to_pixel(lat, lon, self.bbox) points.append((px, py)) if len(points) >= 3: @@ -347,12 +340,10 @@ def _render_polygon(self, surface: pygame.Surface, polygon, color: Tuple[int, in pygame.draw.polygon(surface, color, points, 1) def _draw_tile_grid(self, surface: pygame.Surface): - """Draw tile grid overlay with tile coordinates.""" + """Draw tile grid overlay.""" grid_color = (100, 100, 100) - text_color = (80, 80, 80) font = pygame.font.SysFont(None, 14) - # Draw grid lines for i in range(4): x = i * TILE_SIZE pygame.draw.line(surface, grid_color, (x, 0), (x, VIEWPORT_SIZE), 1) @@ -360,7 +351,6 @@ def _draw_tile_grid(self, surface: pygame.Surface): y = i * TILE_SIZE pygame.draw.line(surface, grid_color, (0, y), (VIEWPORT_SIZE, y), 1) - # Draw tile coordinates tiles = self._get_tiles_for_viewport() center_x, center_y = deg2num(self.center_lat, self.center_lon, self.zoom) center_tile_x = int(center_x) @@ -370,7 +360,6 @@ def _draw_tile_grid(self, surface: pygame.Surface): for dx in range(-1, 2): tile_x = center_tile_x + dx tile_y = center_tile_y + dy - # Screen position for this tile screen_x = (dx + 1) * TILE_SIZE + 5 screen_y = (dy + 1) * TILE_SIZE + 5 tile_path = self._get_tile_path(tile_x, tile_y) @@ -380,119 +369,97 @@ def _draw_tile_grid(self, surface: pygame.Surface): surface.blit(label, (screen_x, screen_y)) def identify_feature_at(self, pixel_x: int, pixel_y: int) -> Optional[dict]: - """Identify feature at given pixel coordinates. - - Returns dict with feature info or None if no feature found. - """ + """Identify feature at pixel coordinates.""" if self.cached_features is None or self.bbox is None: return None - # Convert pixel to lat/lon min_lon, min_lat, max_lon, max_lat = self.bbox lon = min_lon + (pixel_x / VIEWPORT_SIZE) * (max_lon - min_lon) lat = max_lat - (pixel_y / VIEWPORT_SIZE) * (max_lat - min_lat) - click_point = Point(lon, lat) - - # Search features in reverse priority order (top features first) - features_sorted = self.cached_features.sort_values('priority', ascending=False) if 'priority' in self.cached_features.columns else self.cached_features - - for idx, row in features_sorted.iterrows(): - geom = row.geometry - if geom is None or geom.is_empty: - continue - - # Check if point is within/near the geometry - if geom.geom_type == 'Polygon' or geom.geom_type == 'MultiPolygon': - if geom.contains(click_point): - return self._feature_to_dict(row) - elif geom.geom_type == 'LineString' or geom.geom_type == 'MultiLineString': - # For lines, use a small buffer (tolerance in degrees ~5 pixels) - tolerance = (max_lon - min_lon) / VIEWPORT_SIZE * 5 - if geom.buffer(tolerance).contains(click_point): - return self._feature_to_dict(row) - elif geom.geom_type == 'Point': - tolerance = (max_lon - min_lon) / VIEWPORT_SIZE * 10 - if click_point.distance(geom) < tolerance: - return self._feature_to_dict(row) + # Search in reverse priority order + for feature in reversed(self.cached_features): + if self._point_in_feature(lon, lat, feature): + return { + 'color': f"#{rgb565_to_rgb888(feature.color_rgb565)[0]:02x}{rgb565_to_rgb888(feature.color_rgb565)[1]:02x}{rgb565_to_rgb888(feature.color_rgb565)[2]:02x}", + 'min_zoom': feature.min_zoom, + 'priority': feature.priority, + 'geom_type': ['?', 'Point', 'Line', 'Polygon'][feature.geom_type], + 'coords': len(feature.coords) + } return None - def _feature_to_dict(self, row) -> dict: - """Convert feature row to dict with relevant info.""" - info = {} - if 'color_rgb565' in row.index and row['color_rgb565']: - r, g, b = rgb565_to_rgb888(int(row['color_rgb565'])) - info['color'] = f"#{r:02x}{g:02x}{b:02x}" - else: - info['color'] = 'N/A' - if 'zoom_priority' in row.index: - zp = int(row['zoom_priority']) - info['min_zoom'] = zp >> 4 - info['priority'] = (zp & 0x0F) * 7 - info['geom_type'] = row.geometry.geom_type if row.geometry else 'N/A' - return info - - -def draw_button(surface, text, rect, bg_color, fg_color, border_color, font, pressed=False): + def _point_in_feature(self, lon: float, lat: float, feature: NavFeature) -> bool: + """Check if point is in/near feature.""" + if feature.geom_type == GEOM_POLYGON: + # Simple point-in-polygon test + n = len(feature.coords) + inside = False + j = n - 1 + for i in range(n): + xi, yi = feature.coords[i] + xj, yj = feature.coords[j] + if ((yi > lat) != (yj > lat)) and (lon < (xj - xi) * (lat - yi) / (yj - yi) + xi): + inside = not inside + j = i + return inside + elif feature.geom_type == GEOM_LINESTRING: + # Check distance to line segments + tolerance = (self.bbox[2] - self.bbox[0]) / VIEWPORT_SIZE * 5 + for i in range(len(feature.coords) - 1): + x1, y1 = feature.coords[i] + x2, y2 = feature.coords[i + 1] + dist = self._point_to_segment_dist(lon, lat, x1, y1, x2, y2) + if dist < tolerance: + return True + elif feature.geom_type == GEOM_POINT and feature.coords: + x, y = feature.coords[0] + tolerance = (self.bbox[2] - self.bbox[0]) / VIEWPORT_SIZE * 10 + if abs(lon - x) < tolerance and abs(lat - y) < tolerance: + return True + return False + + def _point_to_segment_dist(self, px, py, x1, y1, x2, y2): + """Calculate distance from point to line segment.""" + dx = x2 - x1 + dy = y2 - y1 + if dx == 0 and dy == 0: + return math.sqrt((px - x1)**2 + (py - y1)**2) + t = max(0, min(1, ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy))) + proj_x = x1 + t * dx + proj_y = y1 + t * dy + return math.sqrt((px - proj_x)**2 + (py - proj_y)**2) + + +def draw_button(surface, text, rect, bg_color, fg_color, border_color, font): """Draw a button.""" - radius = 8 - pygame.draw.rect(surface, bg_color, rect, border_radius=radius) - pygame.draw.rect(surface, border_color, rect, 2, border_radius=radius) + pygame.draw.rect(surface, bg_color, rect, border_radius=8) + pygame.draw.rect(surface, border_color, rect, 2, border_radius=8) label = font.render(text, True, fg_color) text_rect = label.get_rect(center=rect.center) surface.blit(label, text_rect) -def format_coord(decimal: float, is_latitude: bool = True) -> str: - """Format decimal coordinate as degrees/minutes/seconds.""" - sign = "N" if is_latitude else "E" - if decimal < 0: - sign = "S" if is_latitude else "W" - decimal = abs(decimal) - degrees = int(decimal) - minutes_full = (decimal - degrees) * 60 - minutes = int(minutes_full) - seconds = (minutes_full - minutes) * 60 - return f"{degrees}°{minutes}'{seconds:.1f}\"{sign}" - - def main(): - parser = argparse.ArgumentParser( - description='FlatGeobuf Map Viewer - ESP32 Simulator (Tile Structure)', - epilog=""" -Controls: - Arrow keys / Mouse drag: Pan map - Mouse wheel / [ ] keys: Zoom in/out - B: Toggle background color - F: Toggle polygon fill - G: Toggle tile grid (shows tile coordinates) - Q / ESC: Quit - """ - ) - - parser.add_argument('fgb_dir', help='Directory containing tile-based FGB files (z/x/y.fgb)') + parser = argparse.ArgumentParser(description='NAV Tile Viewer') + parser.add_argument('nav_dir', help='Directory with NAV tiles') parser.add_argument('--lat', type=float, required=True, help='Center latitude') parser.add_argument('--lon', type=float, required=True, help='Center longitude') - parser.add_argument('--zoom', type=int, default=14, help='Zoom level (default: 14)') - parser.add_argument('--config', help='Features configuration JSON file') + parser.add_argument('--zoom', type=int, default=14, help='Zoom level') args = parser.parse_args() if not PYGAME_AVAILABLE: - logger.error("pygame is required. Install with: pip install pygame") + logger.error("pygame required") sys.exit(1) - if not GEOPANDAS_AVAILABLE: - logger.error("geopandas is required. Install with: pip install geopandas pyogrio") - sys.exit(1) - - viewer = FGBViewer(args.fgb_dir, args.config) + viewer = NAVViewer(args.nav_dir) viewer.set_center(args.lat, args.lon, args.zoom) pygame.init() screen = pygame.display.set_mode((WINDOW_WIDTH, WINDOW_HEIGHT)) - pygame.display.set_caption(f"FGB Viewer (Tiles) - {os.path.basename(args.fgb_dir)}") + pygame.display.set_caption(f"NAV Viewer - {os.path.basename(args.nav_dir)}") font = pygame.font.SysFont(None, 18) font_small = pygame.font.SysFont(None, 14) @@ -569,12 +536,11 @@ def main(): viewer.show_tile_grid = not viewer.show_tile_grid need_redraw = True elif mx < VIEWPORT_SIZE and my < VIEWPORT_SIZE: - # Left click = drag dragging = True drag_start = (mx, my) drag_center_start = (viewer.center_lat, viewer.center_lon) - elif event.button == 3: # Right click = identify feature + elif event.button == 3: if mx < VIEWPORT_SIZE and my < VIEWPORT_SIZE: feature_info = viewer.identify_feature_at(mx, my) viewer.selected_feature = feature_info @@ -637,11 +603,8 @@ def main(): screen.blit(font_small.render(f"Lon: {viewer.center_lon:.6f}", True, info_color), (VIEWPORT_SIZE + 10, info_y + 18)) screen.blit(font_small.render(f"Zoom: {viewer.zoom}", True, info_color), (VIEWPORT_SIZE + 10, info_y + 36)) - screen.blit(font_small.render(format_coord(viewer.center_lat, True), True, info_color), (VIEWPORT_SIZE + 10, info_y + 60)) - screen.blit(font_small.render(format_coord(viewer.center_lon, False), True, info_color), (VIEWPORT_SIZE + 10, info_y + 78)) - # Query stats - stats_y = info_y + 110 + stats_y = info_y + 70 screen.blit(font_small.render("Query Stats:", True, info_color), (VIEWPORT_SIZE + 10, stats_y)) if viewer.last_query_stats: s = viewer.last_query_stats @@ -649,35 +612,25 @@ def main(): screen.blit(font_small.render(f" Features: {s.get('features', 0)}", True, (150, 150, 150)), (VIEWPORT_SIZE + 10, stats_y + 32)) screen.blit(font_small.render(f" Time: {s.get('time_ms', 0):.0f}ms", True, (150, 150, 150)), (VIEWPORT_SIZE + 10, stats_y + 46)) - # Selected feature info + # Selected feature feature_y = stats_y + 80 screen.blit(font_small.render("Click Feature:", True, info_color), (VIEWPORT_SIZE + 10, feature_y)) if viewer.selected_feature: - f = viewer.selected_feature line_y = feature_y + 18 - highlight_color = (100, 200, 100) - for key, value in f.items(): + for key, value in viewer.selected_feature.items(): text = f" {key}: {value}" - screen.blit(font_small.render(text, True, highlight_color), (VIEWPORT_SIZE + 10, line_y)) + screen.blit(font_small.render(text, True, (100, 200, 100)), (VIEWPORT_SIZE + 10, line_y)) line_y += 14 else: - screen.blit(font_small.render(" (click on map)", True, (100, 100, 100)), (VIEWPORT_SIZE + 10, feature_y + 18)) + screen.blit(font_small.render(" (right-click)", True, (100, 100, 100)), (VIEWPORT_SIZE + 10, feature_y + 18)) # Status bar pygame.draw.rect(screen, (30, 30, 30), (0, VIEWPORT_SIZE, WINDOW_WIDTH, STATUSBAR_HEIGHT)) - if viewer.bbox: - min_lon, min_lat, max_lon, max_lat = viewer.bbox - bbox_text = f"BBox: ({min_lat:.4f}, {min_lon:.4f}) to ({max_lat:.4f}, {max_lon:.4f})" - screen.blit(font_small.render(bbox_text, True, (200, 200, 200)), (10, VIEWPORT_SIZE + 10)) - - meters_per_pixel = 156543.03392 * math.cos(math.radians(viewer.center_lat)) / (2 ** viewer.zoom) - res_text = f"Resolution: {meters_per_pixel:.2f} m/px" - screen.blit(font_small.render(res_text, True, (200, 200, 200)), (10, VIEWPORT_SIZE + 30)) - - # Show available zooms - if viewer.available_zooms: - zooms_str = f"Available: {min(viewer.available_zooms)}-{max(viewer.available_zooms)}" - screen.blit(font_small.render(zooms_str, True, (150, 150, 150)), (400, VIEWPORT_SIZE + 10)) + screen.blit(font_small.render("NAV Format - IceNav Navigation Tiles", True, (200, 200, 200)), (10, VIEWPORT_SIZE + 10)) + + if viewer.available_zooms: + zooms_str = f"Available: {min(viewer.available_zooms)}-{max(viewer.available_zooms)}" + screen.blit(font_small.render(zooms_str, True, (150, 150, 150)), (10, VIEWPORT_SIZE + 30)) pygame.display.flip() need_redraw = False diff --git a/pbf_to_fgb.py b/pbf_to_nav.py similarity index 61% rename from pbf_to_fgb.py rename to pbf_to_nav.py index 63a4dbb..28ff88f 100644 --- a/pbf_to_fgb.py +++ b/pbf_to_nav.py @@ -1,18 +1,18 @@ #!/usr/bin/env python3 """ -PBF to FlatGeobuf Converter - Tile Structure +PBF to NAV Tile Converter -Converts OpenStreetMap .pbf files to FlatGeobuf (.fgb) format with tile structure. -Generates individual FGB files per tile (z/x/y structure like PNG tiles). +Converts OpenStreetMap .pbf files to NAV binary format (.nav) with tile structure. +NAV format uses int32 coordinates (scaled by 1e7) for ~50% size reduction vs FlatGeobuf. Usage: - python pbf_to_fgb.py input.pbf output_dir features.json [--zoom 6-17] + python pbf_to_nav.py input.pbf output_dir features.json [--zoom 6-17] Output structure: output_dir/ ├── 13/ │ ├── 4123/ - │ │ ├── 2456.fgb + │ │ ├── 2456.nav │ │ └── ... │ └── ... └── 14/ @@ -25,7 +25,8 @@ import argparse import logging import math -from typing import Dict, List, Optional, Tuple, Set +import struct +from typing import Dict, List, Tuple, Set from collections import defaultdict import time @@ -38,17 +39,39 @@ sys.exit(1) try: - import geopandas as gpd - from shapely.geometry import LineString, Polygon, MultiPolygon, Point, box - from shapely.ops import transform + from shapely.geometry import Polygon import shapely.wkb - GEOPANDAS_AVAILABLE = True + SHAPELY_AVAILABLE = True except ImportError: - GEOPANDAS_AVAILABLE = False + SHAPELY_AVAILABLE = False + print("Warning: shapely not found. Multipolygon support disabled.") logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) +# NAV format constants +NAV_MAGIC = b'NAV1' +NAV_VERSION = 1 +COORD_SCALE = 10000000 # 1e7 for ~1cm precision + +# Geometry types +GEOM_POINT = 1 +GEOM_LINESTRING = 2 +GEOM_POLYGON = 3 + +# Layer rendering priority (lower = rendered first = behind) +LAYER_PRIORITY = { + 'water': 10, + 'landuse': 20, + 'terrain': 30, + 'railways': 40, + 'roads': 50, + 'infrastructure': 60, + 'buildings': 70, + 'amenities': 80, + 'places': 90 +} + # Layer definitions based on feature types LAYER_MAPPING = { 'water': [ @@ -111,19 +134,6 @@ ] } -# Layer rendering priority (lower = rendered first = behind) -LAYER_PRIORITY = { - 'water': 10, - 'landuse': 20, - 'terrain': 30, - 'railways': 40, - 'roads': 50, - 'infrastructure': 60, - 'buildings': 70, - 'amenities': 80, - 'places': 90 -} - def lon_to_tile_x(lon: float, zoom: int) -> int: """Convert longitude to tile X coordinate.""" @@ -149,15 +159,10 @@ def tile_bounds(x: int, y: int, zoom: int) -> Tuple[float, float, float, float]: def get_feature_tiles(coords: List[Tuple[float, float]], zoom: int, is_polygon: bool = False) -> Set[Tuple[int, int]]: - """Get all tiles that a feature intersects at given zoom level. - - For polygons, uses bbox to ensure all covered tiles are included, - not just tiles containing vertices. - """ + """Get all tiles that a feature intersects at given zoom level.""" tiles = set() if is_polygon and len(coords) >= 3: - # For polygons, get all tiles in the bounding box lons = [c[0] for c in coords] lats = [c[1] for c in coords] min_lon, max_lon = min(lons), max(lons) @@ -165,14 +170,13 @@ def get_feature_tiles(coords: List[Tuple[float, float]], zoom: int, is_polygon: min_x = lon_to_tile_x(min_lon, zoom) max_x = lon_to_tile_x(max_lon, zoom) - min_y = lat_to_tile_y(max_lat, zoom) # Note: lat is inverted + min_y = lat_to_tile_y(max_lat, zoom) max_y = lat_to_tile_y(min_lat, zoom) for x in range(min_x, max_x + 1): for y in range(min_y, max_y + 1): tiles.add((x, y)) else: - # For lines, just use vertex positions for lon, lat in coords: x = lon_to_tile_x(lon, zoom) y = lat_to_tile_y(lat, zoom) @@ -181,7 +185,7 @@ def get_feature_tiles(coords: List[Tuple[float, float]], zoom: int, is_polygon: return tiles -def get_layer_for_tags(tags: Dict[str, str]) -> Optional[str]: +def get_layer_for_tags(tags: Dict[str, str]) -> str: """Determine which layer a feature belongs to based on its tags.""" for layer_name, feature_keys in LAYER_MAPPING.items(): for feature_key in feature_keys: @@ -229,40 +233,32 @@ def get_priority_for_tags(tags: Dict[str, str], config: Dict) -> int: def hex_to_rgb565(hex_color: str) -> int: - """Convert hex color to RGB565 format (16-bit: RRRRRGGGGGGBBBBB).""" + """Convert hex color to RGB565 format.""" try: if not hex_color or not hex_color.startswith("#"): return 0xFFFF r = int(hex_color[1:3], 16) g = int(hex_color[3:5], 16) b = int(hex_color[5:7], 16) - # RGB565: 5 bits R, 6 bits G, 5 bits B return ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3) except: return 0xFFFF def pack_zoom_priority(min_zoom: int, priority: int) -> int: - """Pack min_zoom and priority into a single byte. - - High nibble: min_zoom (0-15) - Low nibble: priority / 7 (0-15) - """ + """Pack min_zoom and priority into a single byte.""" zoom_nibble = min(min_zoom, 15) & 0x0F priority_nibble = min(priority // 7, 15) & 0x0F return (zoom_nibble << 4) | priority_nibble -def get_simplify_tolerance(zoom: int) -> float: - """ - Calculate simplification tolerance based on zoom level. +def coord_to_int32(lon: float, lat: float) -> Tuple[int, int]: + """Convert float coordinates to int32 (scaled by 1e7).""" + return (int(lon * COORD_SCALE), int(lat * COORD_SCALE)) - At each zoom, a tile is 256 pixels wide and covers: - tile_width_degrees = 360 / (2^zoom) - pixel_size_degrees = tile_width_degrees / 256 - We use 1 pixel as tolerance - points closer than this are redundant. - """ +def get_simplify_tolerance(zoom: int) -> float: + """Calculate simplification tolerance based on zoom level.""" tile_width_degrees = 360.0 / (2.0 ** zoom) pixel_size_degrees = tile_width_degrees / 256.0 return pixel_size_degrees @@ -287,8 +283,8 @@ def __init__(self, config: Dict, zoom_range: Tuple[int, int]): self.last_progress_time = time.time() self.progress_interval = 5 self.interesting_tags = self._build_interesting_tags() - self.processed_way_ids: Set[int] = set() # Track ways processed as areas - self.wkbfab = osmium.geom.WKBFactory() # For extracting geometries from areas + self.processed_way_ids: Set[int] = set() + self.wkbfab = osmium.geom.WKBFactory() def _build_interesting_tags(self) -> Set[str]: """Build set of tag keys we're interested in.""" @@ -335,11 +331,7 @@ def _is_feature_in_config(self, tags: Dict[str, str]) -> bool: return False def way(self, w): - """Process way - extract roads and linear features only. - - Closed ways (areas) are handled by the area() callback to properly - support multipolygon relations. - """ + """Process way - extract roads and linear features.""" self.stats['ways_processed'] += 1 self._log_progress() @@ -372,7 +364,6 @@ def way(self, w): self.stats['features_filtered'] += 1 return - # Check if this is a closed way that should be an area is_closed = len(coords) >= 4 and coords[0] == coords[-1] is_area_tags = ( 'building' in tags or @@ -384,62 +375,39 @@ def way(self, w): tags.get('area') == 'yes' ) - # Process closed areas as Polygon (parks, buildings, etc.) - # Note: area() only handles multipolygon relations in pyosmium 3.6, - # so we must process closed ways here - # Skip highways - always process as lines to avoid covering other features - if is_closed and is_area_tags and 'highway' not in tags: - color = get_color_for_tags(tags, self.config) - priority = get_priority_for_tags(tags, self.config) - color_rgb565 = hex_to_rgb565(color) - - layer_base_priority = LAYER_PRIORITY.get(layer, 50) - combined_priority = layer_base_priority + (priority % 10) - - feature = { - 'geometry_type': 'Polygon', - 'coordinates': coords, - 'properties': { - 'color_rgb565': color_rgb565, - 'zoom_priority': pack_zoom_priority(min_zoom, combined_priority) - } - } - - self.features.append(feature) - self.stats['features_extracted'] += 1 - # Track this way to avoid duplicate in area() if it's also a relation - self.processed_way_ids.add(w.id) - return - - # Process as LineString (roads, rivers, etc.) color = get_color_for_tags(tags, self.config) priority = get_priority_for_tags(tags, self.config) color_rgb565 = hex_to_rgb565(color) - layer_base_priority = LAYER_PRIORITY.get(layer, 50) combined_priority = layer_base_priority + (priority % 10) - feature = { - 'geometry_type': 'LineString', - 'coordinates': coords, - 'properties': { + if is_closed and is_area_tags and 'highway' not in tags: + feature = { + 'geom_type': GEOM_POLYGON, + 'coords': coords, 'color_rgb565': color_rgb565, 'zoom_priority': pack_zoom_priority(min_zoom, combined_priority) } - } + self.features.append(feature) + self.stats['features_extracted'] += 1 + self.processed_way_ids.add(w.id) + return + feature = { + 'geom_type': GEOM_LINESTRING, + 'coords': coords, + 'color_rgb565': color_rgb565, + 'zoom_priority': pack_zoom_priority(min_zoom, combined_priority) + } self.features.append(feature) self.stats['features_extracted'] += 1 def relation(self, r): - """Process relation - count only, areas handled by area() callback.""" + """Process relation.""" self.stats['relations_processed'] += 1 def area(self, a): - """Process area - handles both closed ways and multipolygon relations. - - Uses WKBFactory to extract geometry, then converts to coordinate list. - """ + """Process area - handles multipolygon relations.""" self.stats['areas_processed'] += 1 self._log_progress() @@ -458,7 +426,6 @@ def area(self, a): self.stats['features_filtered'] += 1 return - # Skip highways - they should be lines, not polygons that cover other features if 'highway' in tags: self.stats['features_filtered'] += 1 return @@ -468,11 +435,9 @@ def area(self, a): self.stats['features_filtered'] += 1 return - # Skip if this way was already processed in way() callback if a.from_way() and a.orig_id() in self.processed_way_ids: return - # Extract geometry using WKBFactory try: wkb = self.wkbfab.create_multipolygon(a) geom = shapely.wkb.loads(wkb, hex=True) @@ -480,11 +445,9 @@ def area(self, a): color = get_color_for_tags(tags, self.config) priority = get_priority_for_tags(tags, self.config) color_rgb565 = hex_to_rgb565(color) - layer_base_priority = LAYER_PRIORITY.get(layer, 50) combined_priority = layer_base_priority + (priority % 10) - # Handle both Polygon and MultiPolygon polygons = [] if geom.geom_type == 'Polygon': polygons = [geom] @@ -500,14 +463,11 @@ def area(self, a): continue feature = { - 'geometry_type': 'Polygon', - 'coordinates': coords, - 'properties': { - 'color_rgb565': color_rgb565, - 'zoom_priority': pack_zoom_priority(min_zoom, combined_priority) - } + 'geom_type': GEOM_POLYGON, + 'coords': coords, + 'color_rgb565': color_rgb565, + 'zoom_priority': pack_zoom_priority(min_zoom, combined_priority) } - self.features.append(feature) self.stats['features_extracted'] += 1 @@ -515,107 +475,94 @@ def area(self, a): self.stats['features_filtered'] += 1 -def count_coords(geom) -> int: - """Count total coordinates in a geometry.""" - if hasattr(geom, 'exterior'): # Polygon - return len(geom.exterior.coords) + sum(len(ring.coords) for ring in geom.interiors) - elif hasattr(geom, 'coords'): # LineString/Point - return len(geom.coords) - return 0 - - -# Global simplification statistics -simplify_stats = {'nodes_before': 0, 'nodes_after': 0} - +def simplify_coords(coords: List[Tuple[float, float]], tolerance: float) -> List[Tuple[float, float]]: + """Simple Douglas-Peucker-like simplification.""" + if len(coords) <= 2: + return coords -def write_tile_fgb(features: List[Dict], output_path: str, zoom: int): - """Write features to a single tile FlatGeobuf file. + # Use shapely for simplification if available + if SHAPELY_AVAILABLE: + from shapely.geometry import LineString + line = LineString(coords) + simplified = line.simplify(tolerance, preserve_topology=True) + return list(simplified.coords) - Features are NOT clipped to tile boundaries to avoid visible seams. - Each feature is stored complete in every tile it touches. - The renderer clips to viewport naturally. + return coords - Geometries are simplified based on zoom level - points representing - less than 1 pixel are removed to reduce file size. - """ - global simplify_stats - - if not GEOPANDAS_AVAILABLE: - logger.error("GeoPandas not available") - return False +def write_nav_tile(features: List[Dict], output_path: str, zoom: int) -> bool: + """Write features to NAV binary tile format.""" if not features: return False - # Calculate simplification tolerance (1 pixel in degrees) tolerance = get_simplify_tolerance(zoom) - geometries = [] - properties_list = [] - - for feature in features: - coords = feature['coordinates'] - geom_type = feature['geometry_type'] - props = feature['properties'] + # Calculate bounding box + all_lons = [] + all_lats = [] + for f in features: + for lon, lat in f['coords']: + all_lons.append(lon) + all_lats.append(lat) - try: - if geom_type == 'LineString': - if len(coords) >= 2: - geom = LineString(coords) - nodes_before = len(geom.coords) - # Simplify: remove points closer than 1 pixel - geom = geom.simplify(tolerance, preserve_topology=True) - # After simplification, ensure still valid LineString (min 2 points) - if not geom.is_empty and len(geom.coords) >= 2: - simplify_stats['nodes_before'] += nodes_before - simplify_stats['nodes_after'] += len(geom.coords) - geometries.append(geom) - properties_list.append(props) - elif geom_type == 'Polygon': - if len(coords) >= 4: - geom = Polygon(coords) - if not geom.is_valid: - geom = geom.buffer(0) - nodes_before = count_coords(geom) - # Simplify: remove points closer than 1 pixel - geom = geom.simplify(tolerance, preserve_topology=True) - if geom.is_valid and not geom.is_empty: - simplify_stats['nodes_before'] += nodes_before - simplify_stats['nodes_after'] += count_coords(geom) - geometries.append(geom) - properties_list.append(props) - except Exception as e: - continue - - if not geometries: + if not all_lons: return False + min_lon = min(all_lons) + min_lat = min(all_lats) + max_lon = max(all_lons) + max_lat = max(all_lats) + # Create output directory os.makedirs(os.path.dirname(output_path), exist_ok=True) - gdf = gpd.GeoDataFrame(properties_list, geometry=geometries, crs="EPSG:4326") + with open(output_path, 'wb') as f: + # Write header (20 bytes) + f.write(NAV_MAGIC) # 4 bytes + f.write(struct.pack(' 2: + coords = simplify_coords(coords, tolerance) + + # Skip if too few coordinates after simplification + if feature['geom_type'] == GEOM_LINESTRING and len(coords) < 2: + continue + if feature['geom_type'] == GEOM_POLYGON and len(coords) < 4: + continue - try: - import warnings - # Silence pyogrio "Created X records" messages - pyogrio_logger = logging.getLogger('pyogrio') - old_level = pyogrio_logger.level - pyogrio_logger.setLevel(logging.WARNING) - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - gdf.to_file(output_path, driver="FlatGeobuf", spatial_index=True) - pyogrio_logger.setLevel(old_level) - return True - except Exception as e: - logger.error(f"Error writing {output_path}: {e}") - return False + # Write feature header + f.write(struct.pack('> 4 + min_zoom = feature['zoom_priority'] >> 4 if min_zoom > zoom: continue - # Get all tiles this feature touches - is_polygon = feature['geometry_type'] == 'Polygon' - tiles = get_feature_tiles(feature['coordinates'], zoom, is_polygon) - - # Track statistics - num_tiles = len(tiles) - if num_tiles > 1: # Only track features in multiple tiles - feature_tile_counts.append(( - feature['geometry_type'], - num_tiles, - zoom - )) - tiles_by_geom_type[feature['geometry_type']] += num_tiles + is_polygon = feature['geom_type'] == GEOM_POLYGON + tiles = get_feature_tiles(feature['coords'], zoom, is_polygon) for tile in tiles: tile_features[tile].append(feature) @@ -690,26 +617,22 @@ def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, tile_items = list(tile_features.items()) for i, ((x, y), features) in enumerate(tile_items): - # Progress bar progress = (i + 1) / num_tiles bar_width = 30 filled = int(bar_width * progress) bar = '█' * filled + '░' * (bar_width - filled) print(f"\r Zoom {zoom:2d}: [{bar}] {i+1}/{num_tiles} tiles", end='', flush=True) - # Create directory structure: output_dir/zoom/x/y.fgb tile_dir = os.path.join(output_dir, str(zoom), str(x)) - tile_path = os.path.join(tile_dir, f"{y}.fgb") + tile_path = os.path.join(tile_dir, f"{y}.nav") - if write_tile_fgb(features, tile_path, zoom): + if write_nav_tile(features, tile_path, zoom): tiles_written += 1 total_size += os.path.getsize(tile_path) - # Clear line and show final count print(f"\r Zoom {zoom:2d}: {tiles_written} tiles written" + " " * 30) total_tiles += tiles_written - # Summary total_time = time.time() - start_time hours, remainder = divmod(int(total_time), 3600) minutes, seconds = divmod(remainder, 60) @@ -720,66 +643,15 @@ def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, else: time_str = f"{total_time:.2f}s" - # Simplification statistics - nodes_before = simplify_stats['nodes_before'] - nodes_after = simplify_stats['nodes_after'] - if nodes_before > 0: - reduction = (1 - nodes_after / nodes_before) * 100 - else: - reduction = 0 - logger.info("=" * 50) logger.info("Conversion Summary") logger.info("=" * 50) logger.info(f"Input: {input_pbf}") logger.info(f"Output directory: {output_dir}") + logger.info(f"Format: NAV binary tiles (.nav)") logger.info(f"Total tiles: {total_tiles}") logger.info(f"Total size: {total_size / (1024 * 1024):.2f} MB") - logger.info(f"Nodes before simplification: {nodes_before:,}") - logger.info(f"Nodes after simplification: {nodes_after:,}") - logger.info(f"Node reduction: {reduction:.1f}%") logger.info(f"Total time: {time_str}") - - # Feature-tile distribution statistics - logger.info("") - logger.info("=" * 50) - logger.info("Feature-Tile Distribution Statistics") - logger.info("=" * 50) - - # Tiles by geometry type - logger.info("") - logger.info("Tile assignments by geometry type:") - for geom_type, count in sorted(tiles_by_geom_type.items(), key=lambda x: -x[1]): - logger.info(f" {geom_type}: {count:,} tile assignments") - - # Top features by tile count - if feature_tile_counts: - # Sort by num_tiles descending - sorted_features = sorted(feature_tile_counts, key=lambda x: -x[1]) - - # Top 20 features - logger.info("") - logger.info("Top 20 features by tile coverage:") - logger.info(f" {'Geom':<10} {'Tiles':>8} {'Zoom':>5}") - logger.info(f" {'-'*10} {'-'*8} {'-'*5}") - for geom_type, num_tiles, zoom in sorted_features[:20]: - logger.info(f" {geom_type:<10} {num_tiles:>8,} {zoom:>5}") - - # Summary stats - all_tile_counts = [x[1] for x in feature_tile_counts] - avg_tiles = sum(all_tile_counts) / len(all_tile_counts) if all_tile_counts else 0 - max_tiles = max(all_tile_counts) if all_tile_counts else 0 - features_over_100 = sum(1 for x in all_tile_counts if x > 100) - features_over_1000 = sum(1 for x in all_tile_counts if x > 1000) - - logger.info("") - logger.info("Multi-tile feature statistics:") - logger.info(f" Features spanning multiple tiles: {len(feature_tile_counts):,}") - logger.info(f" Average tiles per multi-tile feature: {avg_tiles:.1f}") - logger.info(f" Maximum tiles for single feature: {max_tiles:,}") - logger.info(f" Features covering >100 tiles: {features_over_100:,}") - logger.info(f" Features covering >1000 tiles: {features_over_1000:,}") - logger.info("=" * 50) return total_tiles @@ -787,33 +659,22 @@ def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, def main(): parser = argparse.ArgumentParser( - description='Convert OpenStreetMap PBF to tile-based FlatGeobuf format', + description='Convert OpenStreetMap PBF to NAV binary tile format', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" -Examples: - python pbf_to_fgb.py andorra.pbf ./output features.json - python pbf_to_fgb.py spain.pbf ./output features.json --zoom 10-17 +NAV Format - IceNav Navigation Tiles: + - int32 coordinates (scaled by 1e7) for ~50% size reduction + - Simple binary format optimized for ESP32 + - Same z/x/y tile structure as standard map tiles -Output structure (tile-based): - output_dir/ - ├── 13/ - │ ├── 4123/ - │ │ ├── 2456.fgb - │ │ └── ... - │ └── ... - └── 14/ - └── ... - -Each tile FGB file contains features clipped to that tile with properties: - - layer: Layer name for render ordering - - priority: Render priority (lower = behind) - - color_rgb565: 16-bit color (RGB565) - - min_zoom: Minimum zoom for visibility +Examples: + python pbf_to_nav.py andorra.pbf ./output features.json + python pbf_to_nav.py spain.pbf ./output features.json --zoom 10-17 """ ) parser.add_argument('input_pbf', help='Input PBF file path') - parser.add_argument('output_dir', help='Output directory for FGB tiles') + parser.add_argument('output_dir', help='Output directory for NAV tiles') parser.add_argument('config_file', help='Features configuration JSON file') parser.add_argument('--zoom', default='6-17', help='Zoom level range (e.g., "6-17" or "12")') @@ -828,16 +689,12 @@ def main(): logger.error(f"Config file not found: {args.config_file}") sys.exit(1) - if not GEOPANDAS_AVAILABLE: - logger.error("GeoPandas is required. Install with: pip install geopandas pyogrio") - sys.exit(1) - if '-' in args.zoom: min_zoom, max_zoom = map(int, args.zoom.split('-')) else: min_zoom = max_zoom = int(args.zoom) - convert_pbf_to_fgb(args.input_pbf, args.output_dir, args.config_file, (min_zoom, max_zoom)) + convert_pbf_to_nav(args.input_pbf, args.output_dir, args.config_file, (min_zoom, max_zoom)) if __name__ == '__main__': From 110760c9d4c98ed3c150a6866b781ccdd1773cfc Mon Sep 17 00:00:00 2001 From: jgauchia Date: Fri, 9 Jan 2026 16:00:21 +0100 Subject: [PATCH 13/16] docs: Update README.md --- .gitignore | 1 + README.md | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index ce34629..7d15226 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +CONTEXT_SESSION.md __pycache__/ *.png *.pbf diff --git a/README.md b/README.md index bf73bcf..4c3a2e0 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,13 @@ python nav_viewer.py ./nav_output --lat 42.5063 --lon 1.5218 --zoom 14 ## NAV Binary Format Specification -NAV is a custom binary format optimized for sequential reading on ESP32. Uses int32 scaled coordinates instead of float64 for compact storage. +NAV is a proprietary binary format designed as a lightweight alternative to FlatGeobuf for embedded devices with limited resources. Unlike FlatGeobuf (which uses FlatBuffers serialization and R-Tree spatial indexing), NAV uses a minimal sequential structure optimized for ESP32's SD card access patterns. + +**Key differences from FlatGeobuf:** +- No FlatBuffers dependency - pure binary format +- No R-Tree index - tiles are small enough for sequential reading +- int32 scaled coordinates instead of float64 (~50% smaller) +- Minimal header overhead **File Header (24 bytes):** From d9783599d7a278c31beaf967e1437e122c685edf Mon Sep 17 00:00:00 2001 From: jgauchia Date: Fri, 9 Jan 2026 16:13:15 +0100 Subject: [PATCH 14/16] perf(nav): Pre-sort features by priority for faster ESP32 rendering --- nav_viewer.py | 2 +- pbf_to_nav.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/nav_viewer.py b/nav_viewer.py index 72d568f..91a4888 100644 --- a/nav_viewer.py +++ b/nav_viewer.py @@ -282,7 +282,7 @@ def render_to_surface(self, surface: pygame.Surface): self.cached_features = None return - # Sort by priority + # Sort by priority (needed when combining multiple tiles) features.sort(key=lambda f: f.priority) self.cached_features = features diff --git a/pbf_to_nav.py b/pbf_to_nav.py index 28ff88f..ff4d55c 100644 --- a/pbf_to_nav.py +++ b/pbf_to_nav.py @@ -626,6 +626,9 @@ def convert_pbf_to_nav(input_pbf: str, output_dir: str, config_file: str, tile_dir = os.path.join(output_dir, str(zoom), str(x)) tile_path = os.path.join(tile_dir, f"{y}.nav") + # Pre-sort by priority (low nibble) for streaming render on ESP32 + features.sort(key=lambda f: f['zoom_priority'] & 0x0F) + if write_nav_tile(features, tile_path, zoom): tiles_written += 1 total_size += os.path.getsize(tile_path) From 4773ec21db8ace33a57b49556dcf89ebf27dc019 Mon Sep 17 00:00:00 2001 From: jgauchia Date: Fri, 9 Jan 2026 16:26:15 +0100 Subject: [PATCH 15/16] refactor: Remove unused functions and orphaned files --- nav_viewer.py | 11 +++++------ pbf_to_nav.py | 20 -------------------- 2 files changed, 5 insertions(+), 26 deletions(-) diff --git a/nav_viewer.py b/nav_viewer.py index 91a4888..39cc089 100644 --- a/nav_viewer.py +++ b/nav_viewer.py @@ -130,10 +130,10 @@ def read_nav_tile(path: str) -> List[NavFeature]: logger.warning(f"Invalid magic in {path}") return features - version = struct.unpack(' List[NavFeature]: lat = lat_int / COORD_SCALE feature.coords.append((lon, lat)) - # For polygons, read ring info + # For polygons, skip ring info (unused) if feature.geom_type == GEOM_POLYGON: ring_count = struct.unpack(' int: return int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n) -def tile_bounds(x: int, y: int, zoom: int) -> Tuple[float, float, float, float]: - """Get bounding box for a tile (min_lon, min_lat, max_lon, max_lat).""" - n = 2.0 ** zoom - min_lon = x / n * 360.0 - 180.0 - max_lon = (x + 1) / n * 360.0 - 180.0 - max_lat = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n)))) - min_lat = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n)))) - return (min_lon, min_lat, max_lon, max_lat) - - def get_feature_tiles(coords: List[Tuple[float, float]], zoom: int, is_polygon: bool = False) -> Set[Tuple[int, int]]: """Get all tiles that a feature intersects at given zoom level.""" tiles = set() @@ -252,11 +242,6 @@ def pack_zoom_priority(min_zoom: int, priority: int) -> int: return (zoom_nibble << 4) | priority_nibble -def coord_to_int32(lon: float, lat: float) -> Tuple[int, int]: - """Convert float coordinates to int32 (scaled by 1e7).""" - return (int(lon * COORD_SCALE), int(lat * COORD_SCALE)) - - def get_simplify_tolerance(zoom: int) -> float: """Calculate simplification tolerance based on zoom level.""" tile_width_degrees = 360.0 / (2.0 ** zoom) @@ -274,7 +259,6 @@ def __init__(self, config: Dict, zoom_range: Tuple[int, int]): self.features: List[Dict] = [] self.stats = { 'ways_processed': 0, - 'relations_processed': 0, 'areas_processed': 0, 'features_extracted': 0, 'features_filtered': 0 @@ -402,10 +386,6 @@ def way(self, w): self.features.append(feature) self.stats['features_extracted'] += 1 - def relation(self, r): - """Process relation.""" - self.stats['relations_processed'] += 1 - def area(self, a): """Process area - handles multipolygon relations.""" self.stats['areas_processed'] += 1 From acbe5e9a0ebf51878b567a6c154a5b6450fe5c62 Mon Sep 17 00:00:00 2001 From: jgauchia Date: Sat, 10 Jan 2026 10:32:47 +0100 Subject: [PATCH 16/16] fix(viewer): add round joins for thick lines to smooth curves --- README.md | 24 ++++++++++++------ nav_viewer.py | 18 +++++++++----- pbf_to_nav.py | 69 +++++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 90 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 4c3a2e0..11d3578 100644 --- a/README.md +++ b/README.md @@ -93,18 +93,16 @@ NAV is a proprietary binary format designed as a lightweight alternative to Flat - int32 scaled coordinates instead of float64 (~50% smaller) - Minimal header overhead -**File Header (24 bytes):** +**File Header (22 bytes):** | Offset | Size | Field | Description | |--------|------|-------|-------------| | 0 | 4 | Magic | `NAV1` (0x4E, 0x41, 0x56, 0x31) | -| 4 | 1 | Version | Format version (currently 1) | -| 5 | 2 | Feature count | Number of features (little-endian) | -| 7 | 1 | Reserved | Padding | -| 8 | 4 | Min Lon | Bounding box min longitude (int32 scaled) | -| 12 | 4 | Min Lat | Bounding box min latitude (int32 scaled) | -| 16 | 4 | Max Lon | Bounding box max longitude (int32 scaled) | -| 20 | 4 | Max Lat | Bounding box max latitude (int32 scaled) | +| 4 | 2 | Feature count | Number of features (little-endian) | +| 6 | 4 | Min Lon | Bounding box min longitude (int32 scaled) | +| 10 | 4 | Min Lat | Bounding box min latitude (int32 scaled) | +| 14 | 4 | Max Lon | Bounding box max longitude (int32 scaled) | +| 18 | 4 | Max Lat | Bounding box max latitude (int32 scaled) | **Feature Record:** @@ -113,11 +111,21 @@ NAV is a proprietary binary format designed as a lightweight alternative to Flat | 1 | Geometry type | 1=Point, 2=LineString, 3=Polygon | | 2 | Color | RGB565 color (little-endian) | | 1 | Zoom/Priority | High nibble = min_zoom, low nibble = priority/7 | +| 1 | Width | Line width in pixels (1-15, from OSM `width`/`lanes` tags) | | 2 | Coord count | Number of coordinates (little-endian) | | 8×N | Coordinates | lon(int32) + lat(int32) pairs | | 1 | Ring count | (Polygons only) Number of rings | | 2×R | Ring ends | (Polygons only) End index of each ring | +**Width Calculation:** + +Width is derived from OSM tags and converted to pixels at the tile's zoom level: +- `width=*` tag: meters converted to pixels +- `lanes=*` tag: lanes × 3.5m converted to pixels +- Default: 1 pixel if no width tag present + +Formula: `pixels = width_meters / (156543 × cos(lat) / 2^zoom)` + **Coordinate Scaling:** Coordinates are stored as int32 scaled by 10,000,000 (1e7): diff --git a/nav_viewer.py b/nav_viewer.py index 39cc089..fd73b27 100644 --- a/nav_viewer.py +++ b/nav_viewer.py @@ -107,6 +107,7 @@ def __init__(self): self.geom_type = 0 self.color_rgb565 = 0xFFFF self.zoom_priority = 0 + self.width = 1 # Line width in pixels (NAV v2) self.coords = [] # List of (lon, lat) floats @property @@ -124,25 +125,24 @@ def read_nav_tile(path: str) -> List[NavFeature]: try: with open(path, 'rb') as f: - # Read header (20 bytes) + # Read header (22 bytes) magic = f.read(4) if magic != NAV_MAGIC: logger.warning(f"Invalid magic in {path}") return features - f.read(1) # version (unused) feature_count = struct.unpack('= 2: - pygame.draw.lines(surface, color, False, points, 1) + width = max(1, feature.width) + pygame.draw.lines(surface, color, False, points, width) + # Add round joins for thick lines + if width > 2: + radius = width // 2 + for px, py in points: + pygame.draw.circle(surface, color, (px, py), radius) def _render_polygon(self, surface: pygame.Surface, feature: NavFeature, color: Tuple[int, int, int]): """Render polygon.""" diff --git a/pbf_to_nav.py b/pbf_to_nav.py index 4af7a9b..450c763 100644 --- a/pbf_to_nav.py +++ b/pbf_to_nav.py @@ -51,7 +51,6 @@ # NAV format constants NAV_MAGIC = b'NAV1' -NAV_VERSION = 1 COORD_SCALE = 10000000 # 1e7 for ~1cm precision # Geometry types @@ -59,6 +58,22 @@ GEOM_LINESTRING = 2 GEOM_POLYGON = 3 +# Tags that support width (LineStrings only) +WIDTH_TAGS = {'highway', 'railway', 'waterway'} + + +def meters_to_pixels(width_meters: float, zoom: int, lat: float = 45.0) -> int: + """Convert width in meters to pixels at given zoom level. + + Uses approximation for given latitude (default 45° for Europe). + Formula: meters_per_pixel ≈ 156543 * cos(lat) / 2^zoom + """ + import math + meters_per_pixel = 156543.0 * math.cos(math.radians(lat)) / (2 ** zoom) + pixels = int(width_meters / meters_per_pixel + 0.5) + return max(1, min(15, pixels)) # Clamp to 1-15 + + # Layer rendering priority (lower = rendered first = behind) LAYER_PRIORITY = { 'water': 10, @@ -370,22 +385,55 @@ def way(self, w): 'geom_type': GEOM_POLYGON, 'coords': coords, 'color_rgb565': color_rgb565, - 'zoom_priority': pack_zoom_priority(min_zoom, combined_priority) + 'zoom_priority': pack_zoom_priority(min_zoom, combined_priority), + 'width_meters': 0.0 # Polygons don't use width } self.features.append(feature) self.stats['features_extracted'] += 1 self.processed_way_ids.add(w.id) return + # Extract width in meters for roads/railways/waterways + width_meters = 0.0 + if any(tag in tags for tag in WIDTH_TAGS): + width_meters = self._get_width_meters(tags) + feature = { 'geom_type': GEOM_LINESTRING, 'coords': coords, 'color_rgb565': color_rgb565, - 'zoom_priority': pack_zoom_priority(min_zoom, combined_priority) + 'zoom_priority': pack_zoom_priority(min_zoom, combined_priority), + 'width_meters': width_meters } self.features.append(feature) self.stats['features_extracted'] += 1 + def _get_width_meters(self, tags: Dict[str, str]) -> float: + """Extract width in meters from OSM tags. + + Priority: + 1. width=* tag (meters) + 2. lanes=* tag (lanes × 3.5m) + 3. Return 0 (will become 1 pixel default) + """ + # Check for explicit width tag + if 'width' in tags: + try: + width_str = tags['width'].replace('m', '').replace(' ', '').strip() + return float(width_str) + except (ValueError, TypeError): + pass + + # Check for lanes tag + if 'lanes' in tags: + try: + lanes = int(tags['lanes']) + return lanes * 3.5 # Standard lane width + except (ValueError, TypeError): + pass + + return 0.0 + def area(self, a): """Process area - handles multipolygon relations.""" self.stats['areas_processed'] += 1 @@ -446,7 +494,8 @@ def area(self, a): 'geom_type': GEOM_POLYGON, 'coords': coords, 'color_rgb565': color_rgb565, - 'zoom_priority': pack_zoom_priority(min_zoom, combined_priority) + 'zoom_priority': pack_zoom_priority(min_zoom, combined_priority), + 'width_meters': 0.0 # Polygons don't use width } self.features.append(feature) self.stats['features_extracted'] += 1 @@ -497,11 +546,9 @@ def write_nav_tile(features: List[Dict], output_path: str, zoom: int) -> bool: os.makedirs(os.path.dirname(output_path), exist_ok=True) with open(output_path, 'wb') as f: - # Write header (20 bytes) + # Write header (22 bytes) f.write(NAV_MAGIC) # 4 bytes - f.write(struct.pack(' bool: if feature['geom_type'] == GEOM_POLYGON and len(coords) < 4: continue + # Calculate width in pixels (convert meters to pixels at this zoom) + width_meters = feature.get('width_meters', 0.0) + if width_meters > 0: + width_pixels = meters_to_pixels(width_meters, zoom) + else: + width_pixels = 1 # Default width + # Write feature header f.write(struct.pack('

HJ7ofTdZg$oxJ*0*bf@mQenIOA5|ay>p* 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 Y2J5ou7+Ek~$DO>PqKz#0<<_IW0pV``IsgCw literal 0 HcmV?d00001 diff --git a/pbf_to_fgb.py b/pbf_to_fgb.py index bb65ae1..0cef665 100644 --- a/pbf_to_fgb.py +++ b/pbf_to_fgb.py @@ -1,18 +1,22 @@ #!/usr/bin/env python3 """ -PBF to FlatGeobuf Converter +PBF to FlatGeobuf Converter - Tile Structure -Converts OpenStreetMap .pbf files to FlatGeobuf (.fgb) format with spatial indexing. -Generates ONE unified file per zoom range with all layers combined. +Converts OpenStreetMap .pbf files to FlatGeobuf (.fgb) format with tile structure. +Generates individual FGB files per tile (z/x/y structure like PNG tiles). Usage: python pbf_to_fgb.py input.pbf output_dir features.json [--zoom 6-17] Output structure: output_dir/ - ├── z6-9.fgb # All layers combined for zooms 6-9 - ├── z10-12.fgb # All layers combined for zooms 10-12 - └── z13-17.fgb # All layers combined for zooms 13-17 + ├── 13/ + │ ├── 4123/ + │ │ ├── 2456.fgb + │ │ └── ... + │ └── ... + └── 14/ + └── ... """ import os @@ -20,6 +24,7 @@ import json import argparse import logging +import math from typing import Dict, List, Optional, Tuple, Set from collections import defaultdict import time @@ -31,18 +36,10 @@ print("Error: osmium not found. Install with: pip install osmium") sys.exit(1) -try: - import fiona - from fiona.crs import from_epsg - FIONA_AVAILABLE = True -except ImportError: - FIONA_AVAILABLE = False - try: import geopandas as gpd - from shapely.geometry import LineString, Polygon, MultiPolygon, Point, mapping + from shapely.geometry import LineString, Polygon, MultiPolygon, Point, box from shapely.ops import transform - from shapely import simplify GEOPANDAS_AVAILABLE = True except ImportError: GEOPANDAS_AVAILABLE = False @@ -50,13 +47,6 @@ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) -# Zoom ranges for unified files -ZOOM_RANGES = [ - (6, 9), # Low zoom: major features only - (10, 12), # Medium zoom: more detail - (13, 17), # High zoom: full detail -] - # Layer definitions based on feature types LAYER_MAPPING = { 'water': [ @@ -131,6 +121,39 @@ } +def lon_to_tile_x(lon: float, zoom: int) -> int: + """Convert longitude to tile X coordinate.""" + n = 2.0 ** zoom + return int((lon + 180.0) / 360.0 * n) + + +def lat_to_tile_y(lat: float, zoom: int) -> int: + """Convert latitude to tile Y coordinate.""" + n = 2.0 ** zoom + lat_rad = math.radians(lat) + return int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n) + + +def tile_bounds(x: int, y: int, zoom: int) -> Tuple[float, float, float, float]: + """Get bounding box for a tile (min_lon, min_lat, max_lon, max_lat).""" + n = 2.0 ** zoom + min_lon = x / n * 360.0 - 180.0 + max_lon = (x + 1) / n * 360.0 - 180.0 + max_lat = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n)))) + min_lat = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n)))) + return (min_lon, min_lat, max_lon, max_lat) + + +def get_feature_tiles(coords: List[Tuple[float, float]], zoom: int) -> Set[Tuple[int, int]]: + """Get all tiles that a feature intersects at given zoom level.""" + tiles = set() + for lon, lat in coords: + x = lon_to_tile_x(lon, zoom) + y = lat_to_tile_y(lat, zoom) + tiles.add((x, y)) + return tiles + + def get_layer_for_tags(tags: Dict[str, str]) -> Optional[str]: """Determine which layer a feature belongs to based on its tags.""" for layer_name, feature_keys in LAYER_MAPPING.items(): @@ -337,29 +360,24 @@ def relation(self, r): pass -def write_unified_fgb(features: List[Dict], output_path: str, max_zoom: int): - """Write all features to a single unified FlatGeobuf file.""" +def write_tile_fgb(features: List[Dict], output_path: str): + """Write features to a single tile FlatGeobuf file. + + Features are NOT clipped to tile boundaries to avoid visible seams. + Each feature is stored complete in every tile it touches. + The renderer clips to viewport naturally. + """ if not GEOPANDAS_AVAILABLE: - logger.error("GeoPandas not available. Install with: pip install geopandas") + logger.error("GeoPandas not available") return False if not features: - logger.warning(f"No features to write") - return False - - # Filter features for this zoom range - filtered_features = [f for f in features if f['properties']['min_zoom'] <= max_zoom] - - if not filtered_features: - logger.warning(f"No features for max zoom {max_zoom}") return False - logger.info(f"Writing {len(filtered_features)} features to {output_path}") - geometries = [] properties_list = [] - for feature in filtered_features: + for feature in features: coords = feature['coordinates'] geom_type = feature['geometry_type'] props = feature['properties'] @@ -368,56 +386,47 @@ def write_unified_fgb(features: List[Dict], output_path: str, max_zoom: int): if geom_type == 'LineString': if len(coords) >= 2: geom = LineString(coords) - geometries.append(geom) - properties_list.append(props) + if not geom.is_empty: + geometries.append(geom) + properties_list.append(props) elif geom_type == 'Polygon': if len(coords) >= 4: geom = Polygon(coords) - if geom.is_valid: + if not geom.is_valid: + geom = geom.buffer(0) + if geom.is_valid and not geom.is_empty: geometries.append(geom) properties_list.append(props) - else: - geom = geom.buffer(0) - if geom.is_valid and not geom.is_empty: - geometries.append(geom) - properties_list.append(props) except Exception as e: - logger.debug(f"Error creating geometry: {e}") continue if not geometries: - logger.warning(f"No valid geometries") return False + # Create output directory + os.makedirs(os.path.dirname(output_path), exist_ok=True) + gdf = gpd.GeoDataFrame(properties_list, geometry=geometries, crs="EPSG:4326") try: import warnings + # Silence pyogrio "Created X records" messages pyogrio_logger = logging.getLogger('pyogrio') - fiona_logger = logging.getLogger('fiona') - old_pyogrio_level = pyogrio_logger.level - old_fiona_level = fiona_logger.level - pyogrio_logger.setLevel(logging.ERROR) - fiona_logger.setLevel(logging.ERROR) - + old_level = pyogrio_logger.level + pyogrio_logger.setLevel(logging.WARNING) with warnings.catch_warnings(): warnings.simplefilter("ignore") gdf.to_file(output_path, driver="FlatGeobuf", spatial_index=True) - - pyogrio_logger.setLevel(old_pyogrio_level) - fiona_logger.setLevel(old_fiona_level) - - file_size_mb = os.path.getsize(output_path) / (1024 * 1024) - logger.info(f"Successfully wrote {len(gdf)} features ({file_size_mb:.2f} MB)") + pyogrio_logger.setLevel(old_level) return True except Exception as e: - logger.error(f"Error writing FlatGeobuf: {e}") + logger.error(f"Error writing {output_path}: {e}") return False def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, zoom_range: Tuple[int, int] = (6, 17)): - """Main conversion function - generates unified files per zoom range.""" + """Main conversion function - generates tile-based FGB files.""" logger.info(f"Loading configuration from {config_file}") with open(config_file, 'r') as f: @@ -443,27 +452,52 @@ def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, logger.info(f" Features extracted: {handler.stats['features_extracted']:,}") logger.info(f" Features filtered: {handler.stats['features_filtered']:,}") - # Write unified FGB files per zoom range - logger.info("Writing unified FlatGeobuf files...") + # Group features by tile for each zoom level + logger.info("Generating tile-based FlatGeobuf files...") - files_written = [] + total_tiles = 0 total_size = 0 - for min_z, max_z in ZOOM_RANGES: - # Skip ranges outside requested zoom - if max_z < zoom_range[0] or min_z > zoom_range[1]: + for zoom in range(zoom_range[0], zoom_range[1] + 1): + # Group features by tile at this zoom level + tile_features: Dict[Tuple[int, int], List[Dict]] = defaultdict(list) + + for feature in handler.features: + # Only include features visible at this zoom + if feature['properties']['min_zoom'] > zoom: + continue + + # Get all tiles this feature touches + tiles = get_feature_tiles(feature['coordinates'], zoom) + for tile in tiles: + tile_features[tile].append(feature) + + if not tile_features: continue - # Adjust range to requested bounds - actual_min = max(min_z, zoom_range[0]) - actual_max = min(max_z, zoom_range[1]) + num_tiles = len(tile_features) + tiles_written = 0 + tile_items = list(tile_features.items()) - output_filename = f"z{actual_min}-{actual_max}.fgb" - output_path = os.path.join(output_dir, output_filename) + for i, ((x, y), features) in enumerate(tile_items): + # Progress bar + progress = (i + 1) / num_tiles + bar_width = 30 + filled = int(bar_width * progress) + bar = '█' * filled + '░' * (bar_width - filled) + print(f"\r Zoom {zoom:2d}: [{bar}] {i+1}/{num_tiles} tiles", end='', flush=True) - if write_unified_fgb(handler.features, output_path, actual_max): - files_written.append(output_path) - total_size += os.path.getsize(output_path) + # Create directory structure: output_dir/zoom/x/y.fgb + tile_dir = os.path.join(output_dir, str(zoom), str(x)) + tile_path = os.path.join(tile_dir, f"{y}.fgb") + + if write_tile_fgb(features, tile_path): + tiles_written += 1 + total_size += os.path.getsize(tile_path) + + # Clear line and show final count + print(f"\r Zoom {zoom:2d}: {tiles_written} tiles written" + " " * 30) + total_tiles += tiles_written # Summary total_time = time.time() - start_time @@ -481,33 +515,34 @@ def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, logger.info("=" * 50) logger.info(f"Input: {input_pbf}") logger.info(f"Output directory: {output_dir}") - logger.info(f"Files written:") - for filepath in files_written: - size_mb = os.path.getsize(filepath) / (1024 * 1024) - logger.info(f" {os.path.basename(filepath)}: {size_mb:.2f} MB") + logger.info(f"Total tiles: {total_tiles}") logger.info(f"Total size: {total_size / (1024 * 1024):.2f} MB") logger.info(f"Total time: {time_str}") logger.info("=" * 50) - return files_written + return total_tiles def main(): parser = argparse.ArgumentParser( - description='Convert OpenStreetMap PBF to unified FlatGeobuf format', + description='Convert OpenStreetMap PBF to tile-based FlatGeobuf format', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: python pbf_to_fgb.py andorra.pbf ./output features.json python pbf_to_fgb.py spain.pbf ./output features.json --zoom 10-17 -Output structure (unified files): +Output structure (tile-based): output_dir/ - ├── z6-9.fgb # All layers for zooms 6-9 - ├── z10-12.fgb # All layers for zooms 10-12 - └── z13-17.fgb # All layers for zooms 13-17 - -Each file contains ALL layers combined with properties: + ├── 13/ + │ ├── 4123/ + │ │ ├── 2456.fgb + │ │ └── ... + │ └── ... + └── 14/ + └── ... + +Each tile FGB file contains features clipped to that tile with properties: - layer: Layer name for render ordering - priority: Render priority (lower = behind) - color_rgb332: 8-bit color @@ -516,7 +551,7 @@ def main(): ) parser.add_argument('input_pbf', help='Input PBF file path') - parser.add_argument('output_dir', help='Output directory for FGB files') + parser.add_argument('output_dir', help='Output directory for FGB tiles') parser.add_argument('config_file', help='Features configuration JSON file') parser.add_argument('--zoom', default='6-17', help='Zoom level range (e.g., "6-17" or "12")') From 77bb20c454cbfb434a7ff691f12025a5dd0f51b7 Mon Sep 17 00:00:00 2001 From: jgauchia Date: Sun, 4 Jan 2026 21:36:51 +0100 Subject: [PATCH 04/16] docs: Update README.md --- README.md | 66 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 3b63c73..6b43401 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,10 @@ Converts OpenStreetMap PBF files to FlatGeobuf (.fgb) format with R-Tree spatial - **Direct PBF processing** - No intermediate formats (GOL, Docker, etc.) - **Tile-based structure** - Standard z/x/y tile layout (like PNG/OSM tiles) - **R-Tree spatial index** - Fast bounding box queries per tile -- **Feature clipping** - Features clipped to tile boundaries +- **No clipping artifacts** - Features stored complete (no visible seams at tile edges) - **Feature filtering** - Configurable via `features.json` - **ESP32 optimized** - Small tiles (~100KB-1MB), efficient for SD card access +- **Progress bar** - Visual progress per zoom level during generation ## Requirements @@ -55,8 +56,28 @@ python pbf_to_fgb.py features.json [--zoom 6-17] python pbf_to_fgb.py catalonia.osm.pbf ./fgb_output features.json --zoom 6-17 ``` +**Output:** + +``` +Processing OSM data (this may take a while for large files)... + Progress: 1,234,567 ways, 456,789 features [2m 34s] +Processing completed in 154.23s +Statistics: + Ways processed: 1,234,567 + Features extracted: 456,789 + Features filtered: 777,778 +Generating tile-based FlatGeobuf files... + Zoom 6: [██████████████████████████████] 7/7 tiles + Zoom 6: 7 tiles written + Zoom 7: [██████████████████████████████] 20/20 tiles + Zoom 7: 20 tiles written + ... +``` + ### 2. View Generated Tiles +The viewer simulates ESP32 rendering behavior - loads a 3x3 tile grid (768x768 pixels) centered on the given coordinates. + ```bash python fgb_viewer.py --lat --lon [--zoom ] ``` @@ -73,6 +94,10 @@ python fgb_viewer.py --lat --lon [--zoom Date: Mon, 5 Jan 2026 17:45:55 +0100 Subject: [PATCH 05/16] feat(generator): Add node simplification based on zoom level (Douglas-Peucker, 1px tolerance) --- pbf_to_fgb.py | 66 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/pbf_to_fgb.py b/pbf_to_fgb.py index 0cef665..18e7877 100644 --- a/pbf_to_fgb.py +++ b/pbf_to_fgb.py @@ -214,6 +214,21 @@ def hex_to_rgb332(hex_color: str) -> int: return 0xFF +def get_simplify_tolerance(zoom: int) -> float: + """ + Calculate simplification tolerance based on zoom level. + + At each zoom, a tile is 256 pixels wide and covers: + tile_width_degrees = 360 / (2^zoom) + pixel_size_degrees = tile_width_degrees / 256 + + We use 1 pixel as tolerance - points closer than this are redundant. + """ + tile_width_degrees = 360.0 / (2.0 ** zoom) + pixel_size_degrees = tile_width_degrees / 256.0 + return pixel_size_degrees + + class OSMHandler(osmium.SimpleHandler): """Handler for processing OSM data from PBF files.""" @@ -360,13 +375,31 @@ def relation(self, r): pass -def write_tile_fgb(features: List[Dict], output_path: str): +def count_coords(geom) -> int: + """Count total coordinates in a geometry.""" + if hasattr(geom, 'exterior'): # Polygon + return len(geom.exterior.coords) + sum(len(ring.coords) for ring in geom.interiors) + elif hasattr(geom, 'coords'): # LineString/Point + return len(geom.coords) + return 0 + + +# Global simplification statistics +simplify_stats = {'nodes_before': 0, 'nodes_after': 0} + + +def write_tile_fgb(features: List[Dict], output_path: str, zoom: int): """Write features to a single tile FlatGeobuf file. Features are NOT clipped to tile boundaries to avoid visible seams. Each feature is stored complete in every tile it touches. The renderer clips to viewport naturally. + + Geometries are simplified based on zoom level - points representing + less than 1 pixel are removed to reduce file size. """ + global simplify_stats + if not GEOPANDAS_AVAILABLE: logger.error("GeoPandas not available") return False @@ -374,6 +407,9 @@ def write_tile_fgb(features: List[Dict], output_path: str): if not features: return False + # Calculate simplification tolerance (1 pixel in degrees) + tolerance = get_simplify_tolerance(zoom) + geometries = [] properties_list = [] @@ -386,7 +422,13 @@ def write_tile_fgb(features: List[Dict], output_path: str): if geom_type == 'LineString': if len(coords) >= 2: geom = LineString(coords) - if not geom.is_empty: + nodes_before = len(geom.coords) + # Simplify: remove points closer than 1 pixel + geom = geom.simplify(tolerance, preserve_topology=True) + # After simplification, ensure still valid LineString (min 2 points) + if not geom.is_empty and len(geom.coords) >= 2: + simplify_stats['nodes_before'] += nodes_before + simplify_stats['nodes_after'] += len(geom.coords) geometries.append(geom) properties_list.append(props) elif geom_type == 'Polygon': @@ -394,7 +436,12 @@ def write_tile_fgb(features: List[Dict], output_path: str): geom = Polygon(coords) if not geom.is_valid: geom = geom.buffer(0) + nodes_before = count_coords(geom) + # Simplify: remove points closer than 1 pixel + geom = geom.simplify(tolerance, preserve_topology=True) if geom.is_valid and not geom.is_empty: + simplify_stats['nodes_before'] += nodes_before + simplify_stats['nodes_after'] += count_coords(geom) geometries.append(geom) properties_list.append(props) except Exception as e: @@ -427,6 +474,8 @@ def write_tile_fgb(features: List[Dict], output_path: str): def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, zoom_range: Tuple[int, int] = (6, 17)): """Main conversion function - generates tile-based FGB files.""" + global simplify_stats + simplify_stats = {'nodes_before': 0, 'nodes_after': 0} logger.info(f"Loading configuration from {config_file}") with open(config_file, 'r') as f: @@ -491,7 +540,7 @@ def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, tile_dir = os.path.join(output_dir, str(zoom), str(x)) tile_path = os.path.join(tile_dir, f"{y}.fgb") - if write_tile_fgb(features, tile_path): + if write_tile_fgb(features, tile_path, zoom): tiles_written += 1 total_size += os.path.getsize(tile_path) @@ -510,6 +559,14 @@ def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, else: time_str = f"{total_time:.2f}s" + # Simplification statistics + nodes_before = simplify_stats['nodes_before'] + nodes_after = simplify_stats['nodes_after'] + if nodes_before > 0: + reduction = (1 - nodes_after / nodes_before) * 100 + else: + reduction = 0 + logger.info("=" * 50) logger.info("Conversion Summary") logger.info("=" * 50) @@ -517,6 +574,9 @@ def convert_pbf_to_fgb(input_pbf: str, output_dir: str, config_file: str, logger.info(f"Output directory: {output_dir}") logger.info(f"Total tiles: {total_tiles}") logger.info(f"Total size: {total_size / (1024 * 1024):.2f} MB") + logger.info(f"Nodes before simplification: {nodes_before:,}") + logger.info(f"Nodes after simplification: {nodes_after:,}") + logger.info(f"Node reduction: {reduction:.1f}%") logger.info(f"Total time: {time_str}") logger.info("=" * 50) From 73f630c4ea343bd103d88326afc4f8cc32cac0ab Mon Sep 17 00:00:00 2001 From: jgauchia Date: Wed, 7 Jan 2026 20:48:54 +0100 Subject: [PATCH 06/16] fix(generator): reorganize layer priorities and fix duplicate features processing --- .gitignore | 3 + features.json | 127 +++++++++++++++---------- fgb_viewer.py | 87 +++++++++++++++++ grid.png | Bin 125548 -> 0 bytes grid2.png | Bin 107884 -> 0 bytes pbf_to_fgb.py | 255 +++++++++++++++++++++++++++++++++++++++++++++----- 6 files changed, 399 insertions(+), 73 deletions(-) delete mode 100644 grid.png delete mode 100644 grid2.png diff --git a/.gitignore b/.gitignore index f4c51a3..1e05f85 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +__pycache__/ +*.png *.pbf *.geojson *.bin @@ -16,3 +18,4 @@ migracion.md venv/ CHANGELOG_FGB_MIGRATION.md fgb_output/ +rsync_copy.sh diff --git a/features.json b/features.json index 111af95..962e31f 100644 --- a/features.json +++ b/features.json @@ -1,4 +1,7 @@ { + "_comment": "Priority system: layer_base + (priority % 10). Lower = behind, Higher = front", + "_layers": "water:10, landuse:20, terrain:30, railways:40, roads:50, infrastructure:60, buildings:70, amenities:80, places:90", + "natural=coastline": { "zoom": 6, "color": "#88c9fa", @@ -7,22 +10,22 @@ "natural=water": { "zoom": 12, "color": "#88c9fa", - "priority": 10 + "priority": 11 }, "natural=bay": { "zoom": 12, "color": "#88c9fa", - "priority": 10 + "priority": 11 }, "waterway=riverbank": { "zoom": 12, "color": "#88c9fa", - "priority": 10 + "priority": 12 }, "waterway=dock": { "zoom": 14, "color": "#88c9fa", - "priority": 10 + "priority": 12 }, "waterway=river": { "zoom": 8, @@ -32,12 +35,12 @@ "waterway=stream": { "zoom": 12, "color": "#88c9fa", - "priority": 15 + "priority": 16 }, "waterway=canal": { "zoom": 12, "color": "#88c9fa", - "priority": 15 + "priority": 16 }, "natural=beach": { @@ -53,119 +56,144 @@ "natural=wetland": { "zoom": 12, "color": "#c2e0e4", - "priority": 20 + "priority": 21 }, "natural=wood": { "zoom": 12, "color": "#9bc184", - "priority": 25 + "priority": 22 }, "landuse=forest": { "zoom": 12, "color": "#9bc184", - "priority": 25 + "priority": 22 }, "natural=scrub": { "zoom": 14, "color": "#b5d0a3", - "priority": 25 + "priority": 22 }, "natural=heath": { "zoom": 14, "color": "#d4d8a3", - "priority": 25 + "priority": 22 }, "natural=grassland": { "zoom": 14, "color": "#c8e6a9", - "priority": 25 + "priority": 22 }, "landuse=farmland": { "zoom": 12, "color": "#e8e4b5", - "priority": 25 + "priority": 23 + }, + + "landuse=residential": { + "zoom": 12, + "color": "#e8e6e3", + "priority": 24 + }, + "landuse=commercial": { + "zoom": 14, + "color": "#f0e0d0", + "priority": 24 + }, + "landuse=retail": { + "zoom": 14, + "color": "#f0e0d0", + "priority": 24 + }, + "landuse=industrial": { + "zoom": 12, + "color": "#d8d8d8", + "priority": 24 + }, + "landuse=cemetery": { + "zoom": 14, + "color": "#c0c0c0", + "priority": 24 }, "landuse=park": { "zoom": 12, "color": "#b5e3b5", - "priority": 30 + "priority": 26 }, "leisure=park": { "zoom": 12, "color": "#b5e3b5", - "priority": 30 + "priority": 26 + }, + "leisure=garden": { + "zoom": 14, + "color": "#c8e6c8", + "priority": 26 + }, + "leisure=playground": { + "zoom": 15, + "color": "#c8e6c8", + "priority": 26 }, "leisure=nature_reserve": { "zoom": 12, "color": "#b5e3b5", - "priority": 30 + "priority": 25 }, "leisure=golf_course": { "zoom": 14, "color": "#b5e3b5", - "priority": 30 - }, - - "landuse=residential": { - "zoom": 12, - "color": "#e8e6e3", - "priority": 35 + "priority": 25 }, - "landuse=commercial": { + "leisure=recreation_ground": { "zoom": 14, - "color": "#f0e0d0", - "priority": 35 + "color": "#c8e6c8", + "priority": 25 }, - "landuse=retail": { + "landuse=recreation_ground": { "zoom": 14, - "color": "#f0e0d0", - "priority": 35 - }, - "landuse=industrial": { - "zoom": 12, - "color": "#d8d8d8", - "priority": 35 + "color": "#c8e6c8", + "priority": 25 }, - "landuse=cemetery": { - "zoom": 14, + "amenity=parking": { + "zoom": 15, "color": "#c0c0c0", - "priority": 35 + "priority": 27 }, "natural=peak": { "zoom": 14, "color": "#8b7355", - "priority": 40 + "priority": 30 }, "natural=volcano": { "zoom": 14, "color": "#d84200", - "priority": 40 + "priority": 30 }, "natural=cliff": { "zoom": 15, "color": "#8b7355", - "priority": 40 + "priority": 31 }, "railway=rail": { "zoom": 8, "color": "#808080", - "priority": 50 + "priority": 40 }, "railway=subway": { "zoom": 14, "color": "#808080", - "priority": 50 + "priority": 41 }, "tunnel=yes": { "zoom": 14, "color": "#a0a0a0", - "priority": 55 + "priority": 50 }, "highway=motorway": { @@ -288,7 +316,7 @@ "aeroway=apron": { "zoom": 14, "color": "#d8d8d8", - "priority": 75 + "priority": 74 }, "building": { @@ -299,24 +327,19 @@ "leisure=stadium": { "zoom": 14, "color": "#dddddd", - "priority": 80 + "priority": 81 }, "leisure=sports_centre": { "zoom": 15, "color": "#dddddd", - "priority": 80 + "priority": 81 }, "leisure=pitch": { "zoom": 15, "color": "#c8e6a9", - "priority": 80 + "priority": 79 }, - "amenity=parking": { - "zoom": 15, - "color": "#e8e8e8", - "priority": 85 - }, "amenity=hospital": { "zoom": 14, "color": "#ffcccc", diff --git a/fgb_viewer.py b/fgb_viewer.py index 98e1e75..a85d2fa 100644 --- a/fgb_viewer.py +++ b/fgb_viewer.py @@ -127,6 +127,8 @@ def __init__(self, fgb_dir: str, config_file: str = None): self.fill_polygons = True self.show_tile_grid = False self.last_query_stats = {} + self.cached_features = None # Store features for click identification + self.selected_feature = None # Currently selected feature info def _index_tiles(self): """Index available zoom levels from tile structure.""" @@ -246,12 +248,16 @@ def render_to_surface(self, surface: pygame.Surface): features = self.query_features() if features is None or len(features) == 0: + self.cached_features = None return # Sort by priority if available if 'priority' in features.columns: features = features.sort_values('priority') + # Cache for click identification + self.cached_features = features + for idx, row in features.iterrows(): self._render_feature(surface, row) @@ -373,6 +379,66 @@ def _draw_tile_grid(self, surface: pygame.Surface): label = font.render(f"{tile_x}/{tile_y}", True, color) surface.blit(label, (screen_x, screen_y)) + def identify_feature_at(self, pixel_x: int, pixel_y: int) -> Optional[dict]: + """Identify feature at given pixel coordinates. + + Returns dict with feature info or None if no feature found. + """ + if self.cached_features is None or self.bbox is None: + return None + + # Convert pixel to lat/lon + min_lon, min_lat, max_lon, max_lat = self.bbox + lon = min_lon + (pixel_x / VIEWPORT_SIZE) * (max_lon - min_lon) + lat = max_lat - (pixel_y / VIEWPORT_SIZE) * (max_lat - min_lat) + + click_point = Point(lon, lat) + + # Search features in reverse priority order (top features first) + features_sorted = self.cached_features.sort_values('priority', ascending=False) if 'priority' in self.cached_features.columns else self.cached_features + + for idx, row in features_sorted.iterrows(): + geom = row.geometry + if geom is None or geom.is_empty: + continue + + # Check if point is within/near the geometry + if geom.geom_type == 'Polygon' or geom.geom_type == 'MultiPolygon': + if geom.contains(click_point): + return self._feature_to_dict(row) + elif geom.geom_type == 'LineString' or geom.geom_type == 'MultiLineString': + # For lines, use a small buffer (tolerance in degrees ~5 pixels) + tolerance = (max_lon - min_lon) / VIEWPORT_SIZE * 5 + if geom.buffer(tolerance).contains(click_point): + return self._feature_to_dict(row) + elif geom.geom_type == 'Point': + tolerance = (max_lon - min_lon) / VIEWPORT_SIZE * 10 + if click_point.distance(geom) < tolerance: + return self._feature_to_dict(row) + + return None + + def _feature_to_dict(self, row) -> dict: + """Convert feature row to dict with relevant info.""" + info = {} + if 'feature_type' in row.index: + info['type'] = row['feature_type'] + if 'layer' in row.index: + info['layer'] = row['layer'] + if 'color_rgb332' in row.index and row['color_rgb332']: + r, g, b = rgb332_to_rgb888(int(row['color_rgb332'])) + info['color'] = f"#{r:02x}{g:02x}{b:02x}" + else: + info['color'] = 'N/A' + if 'priority' in row.index: + info['priority'] = int(row['priority']) + if 'min_zoom' in row.index: + info['min_zoom'] = int(row['min_zoom']) + if 'osm_id' in row.index: + info['osm_id'] = int(row['osm_id']) + info['geom_type'] = row.geometry.geom_type if row.geometry else 'N/A' + return info + def draw_button(surface, text, rect, bg_color, fg_color, border_color, font, pressed=False): """Draw a button.""" @@ -509,10 +575,17 @@ def main(): viewer.show_tile_grid = not viewer.show_tile_grid need_redraw = True elif mx < VIEWPORT_SIZE and my < VIEWPORT_SIZE: + # Left click = drag dragging = True drag_start = (mx, my) drag_center_start = (viewer.center_lat, viewer.center_lon) + elif event.button == 3: # Right click = identify feature + if mx < VIEWPORT_SIZE and my < VIEWPORT_SIZE: + feature_info = viewer.identify_feature_at(mx, my) + viewer.selected_feature = feature_info + need_redraw = True + elif event.button == 4: if viewer.zoom < 19: viewer.set_center(viewer.center_lat, viewer.center_lon, viewer.zoom + 1) @@ -582,6 +655,20 @@ def main(): screen.blit(font_small.render(f" Features: {s.get('features', 0)}", True, (150, 150, 150)), (VIEWPORT_SIZE + 10, stats_y + 32)) screen.blit(font_small.render(f" Time: {s.get('time_ms', 0):.0f}ms", True, (150, 150, 150)), (VIEWPORT_SIZE + 10, stats_y + 46)) + # Selected feature info + feature_y = stats_y + 80 + screen.blit(font_small.render("Click Feature:", True, info_color), (VIEWPORT_SIZE + 10, feature_y)) + if viewer.selected_feature: + f = viewer.selected_feature + line_y = feature_y + 18 + highlight_color = (100, 200, 100) + for key, value in f.items(): + text = f" {key}: {value}" + screen.blit(font_small.render(text, True, highlight_color), (VIEWPORT_SIZE + 10, line_y)) + line_y += 14 + else: + screen.blit(font_small.render(" (click on map)", True, (100, 100, 100)), (VIEWPORT_SIZE + 10, feature_y + 18)) + # Status bar pygame.draw.rect(screen, (30, 30, 30), (0, VIEWPORT_SIZE, WINDOW_WIDTH, STATUSBAR_HEIGHT)) if viewer.bbox: diff --git a/grid.png b/grid.png deleted file mode 100644 index c33cda40e694a52360aedbce9cb11cd9dd77c3fa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 125548 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℞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 LTWLVEf3U6+a-Wfqkd`)7 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