Skip to content

Commit 1c2cc5d

Browse files
authored
Merge pull request #207 from Vizzuality/data/nigeria_fcs
Nigeria FCS — Sentinel-1 MTC layer processing
2 parents b8abc6d + 154d0c2 commit 1c2cc5d

3 files changed

Lines changed: 187 additions & 10 deletions

File tree

data-processing/notebooks/01_raster_layers.ipynb

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"\n",
3636
"sys.path.append(\"../src\")\n",
3737
"\n",
38-
"from data_processing.process_layers import process_raster_layers\n",
38+
"from data_processing.process_layers import process_prestyled_raster, process_raster_layers\n",
3939
"from data_processing.utils import clip_rasters_by_vector, merge_tifs, resample_raster\n",
4040
"\n",
4141
"project_root = Path(\"/Users/sofia/Documents/Repos/esa/data-processing\")\n",
@@ -1036,6 +1036,29 @@
10361036
" upload_override=True\n",
10371037
")"
10381038
]
1039+
},
1040+
{
1041+
"cell_type": "markdown",
1042+
"id": "nigeria-fcs-mtc-header",
1043+
"metadata": {},
1044+
"source": [
1045+
"#### Nigeria - FCS"
1046+
]
1047+
},
1048+
{
1049+
"cell_type": "code",
1050+
"execution_count": null,
1051+
"id": "nigeria-fcs-mtc-cell",
1052+
"metadata": {},
1053+
"outputs": [],
1054+
"source": [
1055+
"process_prestyled_raster(\n",
1056+
" input_file=project_root / \"data/raw/FCS/Nigeria/Rasters/MTC/SENTINEL-1_MTC_IW1-IW2-IW3_VV_RIGHT_ASCENDING_20150523174456_20150604174457.tif\",\n",
1057+
" output_file=project_root / \"data/processed/FCS/Nigeria/Rasters/MTC/sentinel1_mtc.tif\",\n",
1058+
" layer_name=\"Nigeria_Sentinel1_MTC\",\n",
1059+
" upload=False,\n",
1060+
")"
1061+
]
10391062
}
10401063
],
10411064
"metadata": {
@@ -1059,4 +1082,4 @@
10591082
},
10601083
"nbformat": 4,
10611084
"nbformat_minor": 0
1062-
}
1085+
}

data-processing/src/data_processing/process_layers.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,13 @@
88

99
import yaml
1010

11+
import os
12+
1113
from .raster_processor import RasterProcessor
1214
from .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

37176
def _process_rasters(

data-processing/src/helpers/mbtiles_converter.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,23 +25,36 @@ class GeoTIFFConverter(MBTilesConverter):
2525
"""Converter for GeoTIFF files."""
2626

2727
def convert(
28-
self, input_path: Path, output_path: Path, overview_levels: list[int] = None
28+
self,
29+
input_path: Path,
30+
output_path: Path,
31+
overview_levels: list[int] = None,
32+
tile_format: str = "PNG",
2933
) -> None:
30-
"""Convert a Cloud-Optimized GeoTIFF to MBTiles with overviews."""
34+
"""Convert a Cloud-Optimized GeoTIFF to MBTiles with overviews.
35+
36+
Args:
37+
tile_format: ``"PNG"`` (lossless, best for classified maps) or
38+
``"JPEG"`` (lossy, much smaller for continuous imagery).
39+
"""
3140
if overview_levels is None:
3241
overview_levels = [2, 4, 8, 16, 32, 64]
33-
self._convert_to_mbtiles(input_path, output_path)
42+
self._convert_to_mbtiles(input_path, output_path, tile_format=tile_format)
43+
self._add_overviews(output_path, overview_levels)
3444

35-
def _convert_to_mbtiles(self, input_path: Path, output_path: Path) -> None:
36-
# Ensure output directory exists
45+
def _convert_to_mbtiles(
46+
self, input_path: Path, output_path: Path, tile_format: str = "PNG"
47+
) -> None:
3748
output_path.parent.mkdir(parents=True, exist_ok=True)
3849

3950
command = [
4051
"gdal_translate",
4152
"-of",
4253
"MBTiles",
54+
"-a_nodata",
55+
"none",
4356
"-co",
44-
"TILE_FORMAT=PNG",
57+
f"TILE_FORMAT={tile_format}",
4558
"-co",
4659
"BLOCKSIZE=512",
4760
"--config",
@@ -142,10 +155,12 @@ def create_converter(cls, file_path: Path) -> MBTilesConverter:
142155
return converter_class()
143156

144157
@classmethod
145-
def convert(cls, input_path: Path, output_path: Path, **kwargs) -> None:
158+
def convert(
159+
cls, input_path: Path, output_path: Path, tile_format: str = "PNG", **kwargs
160+
) -> None:
146161
"""Convenience method to convert file using appropriate converter."""
147162
converter = cls.create_converter(input_path)
148-
converter.convert(input_path, output_path, **kwargs)
163+
converter.convert(input_path, output_path, tile_format=tile_format, **kwargs)
149164

150165
@classmethod
151166
def register_converter(cls, extension: str, converter_class: Type[MBTilesConverter]) -> None:

0 commit comments

Comments
 (0)