From 59362dd985cfe12d82819dc412a8dacf1d0b73f7 Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Tue, 28 Oct 2025 22:33:56 +0100 Subject: [PATCH 1/4] sketch custom tiler --- docker-compose.yml | 5 +- tiler/Dockerfile | 10 ++ tiler/app.py | 406 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 tiler/Dockerfile create mode 100644 tiler/app.py diff --git a/docker-compose.yml b/docker-compose.yml index 843a0b9..0aed5c8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,7 +40,10 @@ services: ports: - "8001:8001" titiler: - image: ghcr.io/developmentseed/titiler:latest + build: + context: . + dockerfile: tiler/Dockerfile + command: ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] container_name: titiler platform: linux/amd64 environment: diff --git a/tiler/Dockerfile b/tiler/Dockerfile new file mode 100644 index 0000000..eba6d7e --- /dev/null +++ b/tiler/Dockerfile @@ -0,0 +1,10 @@ +FROM ghcr.io/developmentseed/titiler:latest + +COPY tiler/app.py app.py + +ENV MODULE_NAME=app +ENV VARIABLE_NAME=app +ENV HOST=0.0.0.0 +ENV PORT=80 +ENV WEB_CONCURRENCY=1 +CMD gunicorn -k uvicorn.workers.UvicornWorker ${MODULE_NAME}:${VARIABLE_NAME} --bind ${HOST}:${PORT} --workers ${WEB_CONCURRENCY} diff --git a/tiler/app.py b/tiler/app.py new file mode 100644 index 0000000..b995c7f --- /dev/null +++ b/tiler/app.py @@ -0,0 +1,406 @@ +"""titiler app.""" + +import json +import logging +from logging import config as log_config +from typing import Annotated, Literal, Optional + +import jinja2 +import rasterio +from fastapi import HTTPException, Path, FastAPI, Query +from rio_tiler.io import Reader +from starlette.middleware.cors import CORSMiddleware +from starlette.requests import Request +from starlette.templating import Jinja2Templates +from starlette_cramjam.middleware import CompressionMiddleware + +from titiler.application import __version__ as titiler_version +from titiler.application.settings import ApiSettings +from titiler.core.errors import DEFAULT_STATUS_CODES, add_exception_handlers +from titiler.core.factory import ( + AlgorithmFactory, + ColorMapFactory, + TilerFactory, + TMSFactory, +) +from titiler.core.middleware import ( + CacheControlMiddleware, + LoggerMiddleware, + TotalTimeMiddleware, +) +from titiler.core.models.OGC import Conformance, Landing +from titiler.core.resources.enums import MediaType +from titiler.core.utils import accept_media_type, create_html_response, update_openapi +from pydantic_settings import BaseSettings, SettingsConfigDict + +api_settings = ApiSettings() + +templates_location = [ + jinja2.PackageLoader("titiler.application", "templates"), + jinja2.PackageLoader("titiler.core", "templates"), +] + +jinja2_env = jinja2.Environment( + autoescape=jinja2.select_autoescape(["html", "xml"]), + loader=jinja2.ChoiceLoader(templates_location), +) +titiler_templates = Jinja2Templates(env=jinja2_env) + + +############################################################################### + +app = FastAPI( + title=api_settings.name, + openapi_url="/api", + docs_url="/api.html", + description=api_settings.description, + version=titiler_version, + root_path=api_settings.root_path, +) + +# Fix OpenAPI response header for OGC Common compatibility +update_openapi(app) + +TITILER_CONFORMS_TO = { + "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/core", + "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/landing-page", + "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/oas30", + "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/html", + "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/json", +} + +############################################################################### +# TiTiler endpoints +def DatasetPathParams( + dataset_id: Annotated[ + Literal["coral", "other"], + Path(description="Dataset"), + ], + year: Annotated[ + int, + Path(description="Year") + ] +) -> str: + """Custom Dataset Path Parameter which define dataset_id and year PATH parameter and return a VRT url.""" + name: str + if dataset_id == "coral": + name = "nbgi_clipped" + elif dataset_id == "other": + raise HTTPException(status_code=404, detail="Not Found.") + + return f"https://syd1.digitaloceanspaces.com/mis-geotiff-storage/production/raster/{name}_{year}.vrt" + + +tiler = TilerFactory( + reader=Reader, + path_dependency=DatasetPathParams, + add_ogc_maps=True, + templates=titiler_templates, + router_prefix="/dataset/{dataset_id}/years/{year}", +) +app.include_router(tiler.router, prefix="/dataset/{dataset_id}/years/{year}") + +TITILER_CONFORMS_TO.update(tiler.conforms_to) + +############################################################################### +# TileMatrixSets endpoints +tms = TMSFactory(templates=titiler_templates) +app.include_router( + tms.router, + tags=["Tiling Schemes"], +) +TITILER_CONFORMS_TO.update(tms.conforms_to) + +############################################################################### +# Algorithms endpoints +algorithms = AlgorithmFactory(templates=titiler_templates) +app.include_router( + algorithms.router, + tags=["Algorithms"], +) +TITILER_CONFORMS_TO.update(algorithms.conforms_to) + +############################################################################### +# Colormaps endpoints +cmaps = ColorMapFactory(templates=titiler_templates) +app.include_router( + cmaps.router, + tags=["ColorMaps"], +) +TITILER_CONFORMS_TO.update(cmaps.conforms_to) + + +add_exception_handlers(app, DEFAULT_STATUS_CODES) + +# Set all CORS enabled origins +if api_settings.cors_origins: + app.add_middleware( + CORSMiddleware, + allow_origins=api_settings.cors_origins, + allow_credentials=True, + allow_methods=api_settings.cors_allow_methods, + allow_headers=["*"], + ) + +app.add_middleware( + CompressionMiddleware, + minimum_size=0, + exclude_mediatype={ + "image/jpeg", + "image/jpg", + "image/png", + "image/jp2", + "image/webp", + }, + compression_level=6, +) + +app.add_middleware( + CacheControlMiddleware, + cachecontrol=api_settings.cachecontrol, + exclude_path={r"/healthz"}, +) + +if api_settings.debug: + app.add_middleware(LoggerMiddleware) + app.add_middleware(TotalTimeMiddleware) + + log_config.dictConfig( + { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "detailed": { + "format": "%(asctime)s - %(levelname)s - %(name)s - %(message)s" + }, + "request": { + "format": ( + "%(asctime)s - %(levelname)s - %(name)s - %(message)s " + + json.dumps( + { + k: f"%({k})s" + for k in [ + "http.method", + "http.referer", + "http.request.header.origin", + "http.route", + "http.target", + "http.request.header.content-length", + "http.request.header.accept-encoding", + "http.request.header.origin", + "titiler.path_params", + "titiler.query_params", + ] + } + ) + ), + }, + }, + "handlers": { + "console_detailed": { + "class": "logging.StreamHandler", + "level": "WARNING", + "formatter": "detailed", + "stream": "ext://sys.stdout", + }, + "console_request": { + "class": "logging.StreamHandler", + "level": "DEBUG", + "formatter": "request", + "stream": "ext://sys.stdout", + }, + }, + "loggers": { + "titiler": { + "level": "INFO", + "handlers": ["console_detailed"], + "propagate": False, + }, + "titiler.requests": { + "level": "INFO", + "handlers": ["console_request"], + "propagate": False, + }, + }, + } + ) + + +@app.get( + "/healthz", + description="Health Check.", + summary="Health Check.", + operation_id="healthCheck", + tags=["Health Check"], +) +def application_health_check(): + """Health check.""" + return { + "versions": { + "titiler": titiler_version, + "rasterio": rasterio.__version__, + "gdal": rasterio.__gdal_version__, + "proj": rasterio.__proj_version__, + "geos": rasterio.__geos_version__, + } + } + + +@app.get( + "/", + response_model=Landing, + response_model_exclude_none=True, + responses={ + 200: { + "content": { + "text/html": {}, + "application/json": {}, + } + }, + }, + tags=["OGC Common"], +) +def landing( + request: Request, + f: Annotated[ + Optional[Literal["html", "json"]], + Query( + description="Response MediaType. Defaults to endpoint's default or value defined in `accept` header." + ), + ] = None, +): + """TiTiler landing page.""" + data = { + "title": "TiTiler", + "description": "A modern dynamic tile server built on top of FastAPI and Rasterio/GDAL.", + "links": [ + { + "title": "Landing page", + "href": str(request.url_for("landing")), + "type": "text/html", + "rel": "self", + }, + { + "title": "The API definition (JSON)", + "href": str(request.url_for("openapi")), + "type": "application/vnd.oai.openapi+json;version=3.0", + "rel": "service-desc", + }, + { + "title": "The API documentation", + "href": str(request.url_for("swagger_ui_html")), + "type": "text/html", + "rel": "service-doc", + }, + { + "title": "Conformance Declaration", + "href": str(request.url_for("conformance")), + "type": "text/html", + "rel": "http://www.opengis.net/def/rel/ogc/1.0/conformance", + }, + { + "title": "List of Available TileMatrixSets", + "href": str(request.url_for("tilematrixsets")), + "type": "application/json", + "rel": "http://www.opengis.net/def/rel/ogc/1.0/tiling-schemes", + }, + { + "title": "List of Available Algorithms", + "href": str(request.url_for("available_algorithms")), + "type": "application/json", + "rel": "data", + }, + { + "title": "List of Available ColorMaps", + "href": str(request.url_for("available_colormaps")), + "type": "application/json", + "rel": "data", + }, + { + "title": "TiTiler Documentation (external link)", + "href": "https://developmentseed.org/titiler/", + "type": "text/html", + "rel": "doc", + }, + { + "title": "TiTiler source code (external link)", + "href": "https://github.com/developmentseed/titiler", + "type": "text/html", + "rel": "doc", + }, + ], + } + + if f: + output_type = MediaType[f] + else: + accepted_media = [MediaType.html, MediaType.json] + output_type = ( + accept_media_type(request.headers.get("accept", ""), accepted_media) + or MediaType.json + ) + + if output_type == MediaType.html: + return create_html_response( + request, + data, + title="TiTiler", + template_name="landing", + templates=titiler_templates, + ) + + return data + + +@app.get( + "/conformance", + response_model=Conformance, + response_model_exclude_none=True, + responses={ + 200: { + "content": { + "text/html": {}, + "application/json": {}, + } + }, + }, + tags=["OGC Common"], +) +def conformance( + request: Request, + f: Annotated[ + Optional[Literal["html", "json"]], + Query( + description="Response MediaType. Defaults to endpoint's default or value defined in `accept` header." + ), + ] = None, +): + """Conformance classes. + + Called with `GET /conformance`. + + Returns: + Conformance classes which the server conforms to. + + """ + data = {"conformsTo": sorted(TITILER_CONFORMS_TO)} + + if f: + output_type = MediaType[f] + else: + accepted_media = [MediaType.html, MediaType.json] + output_type = ( + accept_media_type(request.headers.get("accept", ""), accepted_media) + or MediaType.json + ) + + if output_type == MediaType.html: + return create_html_response( + request, + data, + title="Conformance", + template_name="conformance", + templates=titiler_templates, + ) + + return data From bf69d8b7d724ba1dc769e30edb7457b2cab4f710 Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Tue, 28 Oct 2025 22:35:50 +0100 Subject: [PATCH 2/4] lint --- tiler/app.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tiler/app.py b/tiler/app.py index b995c7f..2d10640 100644 --- a/tiler/app.py +++ b/tiler/app.py @@ -69,6 +69,7 @@ "http://www.opengis.net/spec/ogcapi-common-1/1.0/conf/json", } + ############################################################################### # TiTiler endpoints def DatasetPathParams( @@ -76,10 +77,7 @@ def DatasetPathParams( Literal["coral", "other"], Path(description="Dataset"), ], - year: Annotated[ - int, - Path(description="Year") - ] + year: Annotated[int, Path(description="Year")], ) -> str: """Custom Dataset Path Parameter which define dataset_id and year PATH parameter and return a VRT url.""" name: str @@ -94,7 +92,7 @@ def DatasetPathParams( tiler = TilerFactory( reader=Reader, path_dependency=DatasetPathParams, - add_ogc_maps=True, + add_ogc_maps=True, templates=titiler_templates, router_prefix="/dataset/{dataset_id}/years/{year}", ) From ee1704537f9502699ddaebf8c0e14731c02f4c8f Mon Sep 17 00:00:00 2001 From: Wei Ji <23487320+weiji14@users.noreply.github.com> Date: Fri, 31 Oct 2025 17:01:17 +1300 Subject: [PATCH 3/4] Enable .vrt file extensions --- vbos/datasets/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vbos/datasets/models.py b/vbos/datasets/models.py index 92f864c..476ffe6 100644 --- a/vbos/datasets/models.py +++ b/vbos/datasets/models.py @@ -57,7 +57,7 @@ class RasterFile(models.Model): unique=True, validators=[ FileExtensionValidator( - allowed_extensions=["tiff", "tif", "geotiff", "gtiff"] + allowed_extensions=["tiff", "tif", "geotiff", "gtiff", "vrt"] ) ], ) From d47280c8f4a9b83f16a7905dcd19e26bfda8bb38 Mon Sep 17 00:00:00 2001 From: Wei Ji <23487320+weiji14@users.noreply.github.com> Date: Fri, 31 Oct 2025 17:47:11 +1300 Subject: [PATCH 4/4] Add rasterfile extension migration file --- .../migrations/0017_alter_rasterfile_file.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 vbos/datasets/migrations/0017_alter_rasterfile_file.py diff --git a/vbos/datasets/migrations/0017_alter_rasterfile_file.py b/vbos/datasets/migrations/0017_alter_rasterfile_file.py new file mode 100644 index 0000000..cb7c2ad --- /dev/null +++ b/vbos/datasets/migrations/0017_alter_rasterfile_file.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.5 on 2025-10-31 04:45 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("datasets", "0016_alter_rasterdataset_unique_together_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="rasterfile", + name="file", + field=models.FileField( + unique=True, + upload_to="staging/raster/", + validators=[ + django.core.validators.FileExtensionValidator( + allowed_extensions=["tiff", "tif", "geotiff", "gtiff", "vrt"] + ) + ], + ), + ), + ]