88
99import yaml
1010
11+ import os
12+
1113from .raster_processor import RasterProcessor
1214from .vector_processor import VectorProcessor
15+ from helpers .cog_converter import COGConverter
16+ from helpers .mapbox_uploader import upload_to_mapbox
17+ from helpers .mbtiles_converter import MBTilesConverterFactory
1318
1419# ── Shared helpers ────────────────────────────────────────────────────────────
1520
@@ -32,6 +37,140 @@ def _resolve_upload_flag(
3237 return layer_config .get ('upload' , False )
3338
3439
40+ # ── Pre-styled raster (no QML) ───────────────────────────────────────────────
41+
42+ def _native_max_zoom (file : Path ) -> int :
43+ """Return the native max zoom of a raster based on its resolution."""
44+ import math
45+ import rasterio
46+ from rasterio .warp import transform_bounds
47+ with rasterio .open (file ) as src :
48+ bounds = transform_bounds (src .crs , "EPSG:4326" , * src .bounds )
49+ res_x = abs (src .transform .a )
50+ # Convert native resolution to degrees, then to web mercator metres
51+ lng_span = bounds [2 ] - bounds [0 ]
52+ px_deg = lng_span / src .width if src .crs .is_geographic else None
53+ if px_deg is None :
54+ # Projected: convert pixel size to approximate degrees at scene centre
55+ centre_lat = (bounds [1 ] + bounds [3 ]) / 2
56+ metres_per_deg = 111320 * math .cos (math .radians (centre_lat ))
57+ px_deg = res_x / metres_per_deg
58+ # zoom = log2(360 / (px_deg * 256))
59+ zoom = math .log2 (360 / (px_deg * 256 ))
60+ return max (1 , round (zoom ))
61+
62+
63+ def _normalize_to_uint8 (input_file : Path , output_file : Path , percentile : float = 2.0 ) -> None :
64+ """
65+ Stretch each band to uint8 using percentile clipping.
66+
67+ Values below the low percentile → 0, above the high percentile → 255.
68+ This avoids black output when the source is float32 with a narrow value range.
69+
70+ Args:
71+ input_file: Source float raster.
72+ output_file: Destination uint8 GeoTIFF.
73+ percentile: Low/high percentile used for clipping (default 2 / 98).
74+ """
75+ import numpy as np
76+ import rasterio
77+
78+ with rasterio .open (input_file ) as src :
79+ meta = src .meta .copy ()
80+ data = src .read ()
81+ nodata = src .nodata
82+
83+ bands , height , width = data .shape
84+ out = np .zeros ((bands , height , width ), dtype = np .uint8 )
85+
86+ for i in range (bands ):
87+ band = data [i ].astype (np .float64 )
88+ valid_mask = band != nodata if nodata is not None else np .ones_like (band , dtype = bool )
89+ valid = band [valid_mask ]
90+ if valid .size == 0 :
91+ continue
92+ lo = np .percentile (valid , percentile )
93+ hi = np .percentile (valid , 100 - percentile )
94+ if hi == lo :
95+ continue
96+ stretched = np .clip ((band - lo ) / (hi - lo ) * 255 , 0 , 255 )
97+ out [i ] = stretched .astype (np .uint8 )
98+
99+ meta .update ({"dtype" : "uint8" , "nodata" : None })
100+ with rasterio .open (output_file , "w" , ** meta ) as dst :
101+ dst .write (out )
102+
103+
104+ def process_prestyled_raster (
105+ input_file : Path ,
106+ output_file : Path ,
107+ layer_name : str ,
108+ max_zoom : int = None ,
109+ percentile : float = 2.0 ,
110+ upload : bool = False ,
111+ ):
112+ """
113+ Convert an already-styled (RGB/RGBA) raster to COG + MBTiles and optionally
114+ upload to Mapbox. Use this when the source file already has colour bands and
115+ no QML styling is needed.
116+
117+ Float32 inputs are automatically normalized to uint8 via percentile stretching
118+ before COG/MBTiles conversion (otherwise tiles render as all black).
119+
120+ Args:
121+ input_file: Path to the input GeoTIFF.
122+ output_file: Path for the output GeoTIFF / MBTiles (same stem, different suffix).
123+ layer_name: Display name used when uploading to Mapbox.
124+ max_zoom: Maximum zoom level for COG conversion. If None, auto-detected
125+ from the file's native resolution.
126+ percentile: Percentile used for float→uint8 contrast stretch (default 2/98).
127+ upload: Whether to upload the MBTiles to Mapbox.
128+ """
129+ import rasterio
130+
131+ input_file = Path (input_file )
132+ output_file = Path (output_file )
133+ output_file .parent .mkdir (parents = True , exist_ok = True )
134+
135+ if max_zoom is None :
136+ max_zoom = _native_max_zoom (input_file )
137+ print (f"🔍 Auto-detected max zoom: { max_zoom } " )
138+
139+ # Normalize float data to uint8 so PNG tiles render correctly
140+ with rasterio .open (input_file ) as src :
141+ needs_normalization = src .dtypes [0 ] not in ("uint8" , "byte" )
142+
143+ cog_input = input_file
144+ if needs_normalization :
145+ normalized_path = output_file .parent / f"normalized_{ output_file .name } "
146+ print (f"⚖️ Normalizing float32 → uint8 (percentile clip { percentile } /{ 100 - percentile } )" )
147+ _normalize_to_uint8 (input_file , normalized_path , percentile = percentile )
148+ cog_input = normalized_path
149+
150+ print (f"☁️ Converting to COG: { cog_input .name } " )
151+ COGConverter .convert (cog_input , output_file , max_zoom = max_zoom )
152+
153+ if needs_normalization :
154+ normalized_path .unlink ()
155+
156+ tile_format = "JPEG" if needs_normalization else "PNG"
157+ mbtiles_path = output_file .with_suffix (".mbtiles" )
158+ print (f"📦 Converting to MBTiles ({ tile_format } ): { mbtiles_path .name } " )
159+ MBTilesConverterFactory .convert (output_file , mbtiles_path , tile_format = tile_format )
160+
161+ if upload :
162+ print (f"☁️ Uploading to Mapbox: { layer_name } " )
163+ upload_to_mapbox (
164+ source = mbtiles_path ,
165+ display_name = layer_name ,
166+ username = os .getenv ("MAPBOX_USER" ),
167+ token = os .getenv ("MAPBOX_TOKEN" ),
168+ )
169+
170+ print (f"✅ Done: { mbtiles_path } " )
171+ return mbtiles_path
172+
173+
35174# ── Raster ────────────────────────────────────────────────────────────────────
36175
37176def _process_rasters (
0 commit comments