Skip to content

Commit f5971cb

Browse files
committed
feat: tune GDAL COG settings, add STAC 1.0.0 compliance, WebP/JPEG drivers, and bilinear resampling
1 parent c9be26f commit f5971cb

7 files changed

Lines changed: 156 additions & 10 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -480,7 +480,9 @@ Set in the generated mapfile (`mapfile_generator.py` reads env vars):
480480
CONFIG "GDAL_CACHEMAX" "128" # MB block cache
481481
CONFIG "VSI_CACHE" "FALSE" # RAM VSI cache disabled by default (nginx does the heavy lifting)
482482
CONFIG "VSI_CACHE_SIZE" "33554432" # 32 MB per worker (ignored unless VSI_CACHE is TRUE)
483-
CONFIG "GDAL_DISABLE_READDIR_ON_OPEN" "TRUE" # Avoid costly ListBucket operations on S3
483+
CONFIG "GDAL_DISABLE_READDIR_ON_OPEN" "EMPTY_DIR" # Treat virtual directory as empty to avoid ListBucket on S3
484+
CONFIG "GDAL_INGESTED_BYTES_AT_OPEN" "32768" # 32 KB header fetch on open to read full IFD tree in 1 GET
485+
CONFIG "CPL_VSIL_CURL_CHUNK_SIZE" "65536" # 64 KB chunk size for vsicurl range reads
484486
CONFIG "GDAL_HTTP_MERGE_CONSECUTIVE_RANGES" "YES" # Merge consecutive byte reads
485487
```
486488

cdk/lambda/db_init/handler.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,27 @@
4646
native_epsg INT NOT NULL,
4747
geom GEOMETRY(Polygon, 3857) NOT NULL,
4848
geom_native GEOMETRY NOT NULL,
49+
stac_item JSONB,
4950
uploaded_at TIMESTAMPTZ DEFAULT now(),
5051
UNIQUE (collection_id, location)
5152
);
5253
54+
ALTER TABLE cog_index ADD COLUMN IF NOT EXISTS stac_item JSONB;
55+
5356
CREATE INDEX IF NOT EXISTS cog_index_collection_idx ON cog_index(collection_id);
5457
CREATE INDEX IF NOT EXISTS cog_index_geom_idx ON cog_index USING GIST(geom);
5558
CREATE INDEX IF NOT EXISTS cog_index_geom_native_idx ON cog_index USING GIST(geom_native);
5659
CREATE INDEX IF NOT EXISTS cog_index_file_name_trgm ON cog_index USING GIN(file_name gin_trgm_ops);
60+
CREATE INDEX IF NOT EXISTS cog_index_stac_item_gin ON cog_index USING GIN(stac_item);
61+
62+
CREATE OR REPLACE VIEW stac_items AS
63+
SELECT
64+
id,
65+
collection_id AS collection,
66+
stac_item,
67+
geom AS geometry,
68+
location
69+
FROM cog_index;
5770
"""
5871

5972
# Retry parameters: total wait budget is ~90 s so we stay well inside the

etc/mapfile_generator.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@
2626
PUBLIC_HOST = os.environ.get("PUBLIC_HOST", "localhost")
2727
DEBUG_LEVEL = os.environ.get("MS_DEBUGLEVEL", "0")
2828
GDAL_CACHEMAX = os.environ.get("GDAL_CACHEMAX", "128")
29+
GDAL_INGESTED_BYTES_AT_OPEN = os.environ.get("GDAL_INGESTED_BYTES_AT_OPEN", "32768")
30+
CPL_VSIL_CURL_CHUNK_SIZE = os.environ.get("CPL_VSIL_CURL_CHUNK_SIZE", "65536")
31+
GDAL_DISABLE_READDIR_ON_OPEN = os.environ.get("GDAL_DISABLE_READDIR_ON_OPEN", "EMPTY_DIR")
2932
# Per-worker /vsicurl/ byte-range cache. Disabled by default because the
3033
# in-container nginx proxy_cache (4 GB on disk, shared across all FastCGI
3134
# workers) already serves repeat byte-range reads — a second per-worker
@@ -128,6 +131,25 @@ def header(extent, srs_set):
128131
' END',
129132
'',
130133
' OUTPUTFORMAT',
134+
' NAME "webp"',
135+
' DRIVER "AGG/WEBP"',
136+
' MIMETYPE "image/webp"',
137+
' IMAGEMODE RGBA',
138+
' TRANSPARENT ON',
139+
' EXTENSION "webp"',
140+
' FORMATOPTION "QUALITY=80"',
141+
' END',
142+
'',
143+
' OUTPUTFORMAT',
144+
' NAME "jpeg"',
145+
' DRIVER "AGG/JPEG"',
146+
' MIMETYPE "image/jpeg"',
147+
' IMAGEMODE RGB',
148+
' EXTENSION "jpg"',
149+
' FORMATOPTION "QUALITY=80"',
150+
' END',
151+
'',
152+
' OUTPUTFORMAT',
131153
' NAME "geojson"',
132154
' DRIVER "OGR/GEOJSON"',
133155
' MIMETYPE "application/json; subtype=geojson"',
@@ -165,7 +187,9 @@ def header(extent, srs_set):
165187
f' CONFIG "GDAL_CACHEMAX" "{GDAL_CACHEMAX}"',
166188
f' CONFIG "VSI_CACHE" "{VSI_CACHE}"',
167189
f' CONFIG "VSI_CACHE_SIZE" "{VSI_CACHE_SIZE}"',
168-
' CONFIG "GDAL_DISABLE_READDIR_ON_OPEN" "TRUE"',
190+
f' CONFIG "GDAL_DISABLE_READDIR_ON_OPEN" "{GDAL_DISABLE_READDIR_ON_OPEN}"',
191+
f' CONFIG "GDAL_INGESTED_BYTES_AT_OPEN" "{GDAL_INGESTED_BYTES_AT_OPEN}"',
192+
f' CONFIG "CPL_VSIL_CURL_CHUNK_SIZE" "{CPL_VSIL_CURL_CHUNK_SIZE}"',
169193
' CONFIG "GDAL_HTTP_MERGE_CONSECUTIVE_RANGES" "YES"',
170194
' CONFIG "CPL_VSIL_CURL_ALLOWED_EXTENSIONS" ".tif,.tiff,.parquet"',
171195
]
@@ -297,7 +321,7 @@ def tileindex_layer_for_group(c, db_conn, group):
297321
def raster_layer_for_group(c, group):
298322
processing = c.get("raster_processing") or [
299323
"BANDS=1,2,3",
300-
"RESAMPLE=AVERAGE",
324+
"RESAMPLE=BILINEAR",
301325
]
302326
processing_lines = [f' PROCESSING "{item}"' for item in processing]
303327
layer_name = group.get("layer_name") or c.get("layer_name") or c["id"]

mapfiles/mapfile.map

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,25 @@ MAP
2222
EXTENSION "png"
2323
END
2424

25+
OUTPUTFORMAT
26+
NAME "webp"
27+
DRIVER "AGG/WEBP"
28+
MIMETYPE "image/webp"
29+
IMAGEMODE RGBA
30+
TRANSPARENT ON
31+
EXTENSION "webp"
32+
FORMATOPTION "QUALITY=80"
33+
END
34+
35+
OUTPUTFORMAT
36+
NAME "jpeg"
37+
DRIVER "AGG/JPEG"
38+
MIMETYPE "image/jpeg"
39+
IMAGEMODE RGB
40+
EXTENSION "jpg"
41+
FORMATOPTION "QUALITY=80"
42+
END
43+
2544
OUTPUTFORMAT
2645
NAME "geojson"
2746
DRIVER "OGR/GEOJSON"
@@ -47,9 +66,9 @@ MAP
4766
CONFIG "GDAL_CACHEMAX" "128"
4867
CONFIG "VSI_CACHE" "TRUE"
4968
CONFIG "VSI_CACHE_SIZE" "33554432"
50-
CONFIG "GDAL_DISABLE_READDIR_ON_OPEN" "TRUE"
51-
CONFIG "GDAL_HTTP_MULTIPLEX" "YES"
52-
CONFIG "GDAL_HTTP_VERSION" "2"
69+
CONFIG "GDAL_DISABLE_READDIR_ON_OPEN" "EMPTY_DIR"
70+
CONFIG "GDAL_INGESTED_BYTES_AT_OPEN" "32768"
71+
CONFIG "CPL_VSIL_CURL_CHUNK_SIZE" "65536"
5372
CONFIG "GDAL_HTTP_MERGE_CONSECUTIVE_RANGES" "YES"
5473
CONFIG "CPL_VSIL_CURL_ALLOWED_EXTENSIONS" ".tif,.tiff"
5574

@@ -101,7 +120,7 @@ MAP
101120
PROCESSING "BANDS=1,2,3"
102121
PROCESSING "USE_MASK_BAND=NO"
103122
PROCESSING "SCALE=0,65535"
104-
PROCESSING "RESAMPLE=AVERAGE"
123+
PROCESSING "RESAMPLE=BILINEAR"
105124
PROJECTION
106125
"init=epsg:6527"
107126
END

scripts/scan_cog_collection.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -518,13 +518,38 @@ def load_into_postgis(collection_id, native_features, web_features, source_epsg,
518518
continue
519519
native_wkt = _ring_to_wkt(nf["geometry"]["coordinates"][0])
520520
web_wkt = _ring_to_wkt(wf["geometry"]["coordinates"][0])
521+
coords = wf["geometry"]["coordinates"][0]
522+
min_x = min(p[0] for p in coords)
523+
min_y = min(p[1] for p in coords)
524+
max_x = max(p[0] for p in coords)
525+
max_y = max(p[1] for p in coords)
526+
stac_item = {
527+
"type": "Feature",
528+
"stac_version": "1.0.0",
529+
"id": nf["properties"]["file_name"],
530+
"collection": collection_id,
531+
"geometry": wf["geometry"],
532+
"bbox": [min_x, min_y, max_x, max_y],
533+
"properties": {
534+
"datetime": dt.datetime.now(dt.timezone.utc).isoformat(),
535+
"proj:epsg": int(source_epsg),
536+
},
537+
"assets": {
538+
"data": {
539+
"href": nf["properties"]["location"],
540+
"type": "image/tiff; application=geotiff; profile=cloud-optimized",
541+
"roles": ["data"],
542+
}
543+
},
544+
}
521545
rows.append((
522546
collection_id,
523547
nf["properties"]["location"],
524548
nf["properties"]["file_name"],
525549
int(source_epsg),
526550
web_wkt,
527551
native_wkt,
552+
json.dumps(stac_item),
528553
))
529554

530555
conn = psycopg2.connect(
@@ -547,14 +572,15 @@ def load_into_postgis(collection_id, native_features, web_features, source_epsg,
547572
cur,
548573
"""
549574
INSERT INTO cog_index
550-
(collection_id, location, file_name, native_epsg, geom, geom_native)
575+
(collection_id, location, file_name, native_epsg, geom, geom_native, stac_item)
551576
VALUES %s
552577
""",
553578
rows,
554579
template=(
555580
"(%s, %s, %s, %s, "
556581
"ST_GeomFromText(%s, 3857), "
557-
"ST_SetSRID(ST_GeomFromText(%s), " + str(int(source_epsg)) + "))"
582+
"ST_SetSRID(ST_GeomFromText(%s), " + str(int(source_epsg)) + "), "
583+
"%s::jsonb)"
558584
),
559585
page_size=500,
560586
)

tests/unit/test_mapfile_generator.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ def test_default_processing_bands_and_resample(self):
136136
lines = mg.raster_layer_for_group(c, group)
137137
joined = "\n".join(lines)
138138
assert 'PROCESSING "BANDS=1,2,3"' in joined
139-
assert 'PROCESSING "RESAMPLE=AVERAGE"' in joined
139+
assert 'PROCESSING "RESAMPLE=BILINEAR"' in joined
140140

141141
def test_custom_raster_processing_override(self):
142142
c = {
@@ -281,3 +281,37 @@ def test_fgb_uses_stem_without_parsing(self, tmp_path):
281281

282282
def test_geojson_missing_file_uses_stem(self):
283283
assert mg.ogr_layer_name("/nonexistent/path/layer.geojson") == "layer"
284+
285+
286+
# ---------------------------------------------------------------------------
287+
# header GDAL config
288+
# ---------------------------------------------------------------------------
289+
290+
class TestHeaderGdalConfig:
291+
def test_header_contains_tuned_gdal_options(self):
292+
extent = [-100, -50, 100, 50]
293+
srs_set = {3857, 4326}
294+
lines = mg.header(extent, srs_set)
295+
joined = "\n".join(lines)
296+
assert 'CONFIG "GDAL_DISABLE_READDIR_ON_OPEN" "EMPTY_DIR"' in joined
297+
assert 'CONFIG "GDAL_INGESTED_BYTES_AT_OPEN" "32768"' in joined
298+
assert 'CONFIG "CPL_VSIL_CURL_CHUNK_SIZE" "65536"' in joined
299+
assert 'NAME "webp"' in joined
300+
assert 'NAME "jpeg"' in joined
301+
302+
def test_header_respects_env_overrides(self, monkeypatch):
303+
monkeypatch.setenv("GDAL_INGESTED_BYTES_AT_OPEN", "65536")
304+
monkeypatch.setenv("CPL_VSIL_CURL_CHUNK_SIZE", "131072")
305+
monkeypatch.setenv("GDAL_DISABLE_READDIR_ON_OPEN", "YES")
306+
307+
# Re-import module-level env reads if needed or check dynamic reads
308+
import importlib
309+
importlib.reload(mg)
310+
311+
extent = [-100, -50, 100, 50]
312+
lines = mg.header(extent, {3857})
313+
joined = "\n".join(lines)
314+
assert 'CONFIG "GDAL_DISABLE_READDIR_ON_OPEN" "YES"' in joined
315+
assert 'CONFIG "GDAL_INGESTED_BYTES_AT_OPEN" "65536"' in joined
316+
assert 'CONFIG "CPL_VSIL_CURL_CHUNK_SIZE" "131072"' in joined
317+

tests/unit/test_scanner.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,34 @@ def capture_execute_values(cur, sql, rows, template=None, page_size=500):
305305
assert "26918" not in captured_templates[0]
306306
assert "26917" not in captured_templates[1]
307307

308+
def test_rows_contain_valid_stac_item_json(self, monkeypatch):
309+
"""Verify load_into_postgis formats valid STAC 1.0.0 Item JSONB objects."""
310+
import json
311+
mock_pg, _, _ = self._mock_psycopg2()
312+
captured_rows = []
313+
314+
def capture_execute_values(cur, sql, rows, template=None, page_size=500):
315+
captured_rows.extend(rows)
316+
317+
monkeypatch.setattr(scanner, "psycopg2", mock_pg)
318+
monkeypatch.setattr(scanner, "execute_values", capture_execute_values, raising=False)
319+
monkeypatch.setenv("DB_HOST", "localhost")
320+
monkeypatch.setenv("DB_USER", "mapserver")
321+
322+
f = make_feature("test.tif", 3857)
323+
scanner.load_into_postgis("my-col", [f], [f], 3857, delete_first=False)
324+
325+
assert len(captured_rows) == 1
326+
stac_raw = captured_rows[0][6]
327+
item = json.loads(stac_raw)
328+
assert item["type"] == "Feature"
329+
assert item["stac_version"] == "1.0.0"
330+
assert item["id"] == "test.tif"
331+
assert item["collection"] == "my-col"
332+
assert item["properties"]["proj:epsg"] == 3857
333+
assert item["assets"]["data"]["href"] == f["properties"]["location"]
334+
335+
308336

309337
# ---------------------------------------------------------------------------
310338
# write_tileindex_fgb — requires real GDAL

0 commit comments

Comments
 (0)