# Plan 02: Dynamic Tiler Service (TiTiler) **Status**: Pending Implementation **Date**: 2026-02-27 --- ## Objective Deploy a dynamic tiling service to serve Cloud Optimized GeoTIFFs (COGs) from MinIO as XYZ map tiles for the React frontend. This enables efficient map rendering without downloading entire raster files. --- ## 1. Architecture Overview ```mermaid graph TD A[React Frontend] -->|Tile Request XYZ/zoom/x/y| B[Ingress] B --> C[TiTiler Service] C -->|Read COG tiles| D[MinIO] C -->|Return PNG/Tiles| A E[Worker] -->|Upload COG| D F[API] -->|Generate URLs| C ``` --- ## 2. Technology Choice ### 2.1 TiTiler vs Rio-Tiler | Feature | TiTiler | Rio-Tiler | |---------|---------|-----------| | Deployment | Docker/Cloud Native | Python Library | | API REST | ✅ Built-in | ❌ Manual | | Cloud Optimized | ✅ Native | ✅ Native | | Multi-source | ✅ Yes | ✅ Yes | | Dynamic tiling | ✅ Yes | ✅ Yes | | **Recommendation** | **TiTiler** | - | **Chosen**: **TiTiler** (modern, API-first, Kubernetes-ready) ### 2.2 Alternative: Custom Tiler with Rio-Tiler If TiTiler has issues, implement custom FastAPI endpoint: - Use `rio-tiler` as library - Create `/tiles/{job_id}/{z}/{x}/{y}` endpoint - Read from MinIO on-demand --- ## 3. Deployment Strategy ### 3.1 Kubernetes Deployment Create `k8s/25-tiler.yaml`: ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: geocrop-tiler namespace: geocrop labels: app: geocrop-tiler spec: replicas: 2 selector: matchLabels: app: geocrop-tiler template: metadata: labels: app: geocrop-tiler spec: containers: - name: tiler image: ghcr.io/developmentseed/titiler:latest ports: - containerPort: 8000 env: - name: MINIO_ENDPOINT value: "minio.geocrop.svc.cluster.local:9000" - name: AWS_ACCESS_KEY_ID valueFrom: secretKeyRef: name: geocrop-secrets key: minio-access-key - name: AWS_SECRET_ACCESS_KEY valueFrom: secretKeyRef: name: geocrop-secrets key: minio-secret-key - name: AWS_S3_ENDPOINT_URL value: "http://minio.geocrop.svc.cluster.local:9000" - name: TILED_READER value: "cog" resources: requests: memory: "512Mi" cpu: "250m" limits: memory: "2Gi" cpu: "1000m" livenessProbe: httpGet: path: /healthz port: 8000 initialDelaySeconds: 10 periodSeconds: 30 readinessProbe: httpGet: path: /healthz port: 8000 initialDelaySeconds: 5 periodSeconds: 10 --- apiVersion: v1 kind: Service metadata: name: geocrop-tiler namespace: geocrop spec: selector: app: geocrop-tiler ports: - port: 8000 targetPort: 8000 type: ClusterIP ``` ### 3.2 Ingress Configuration Add to existing ingress or create new: ```yaml apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: geocrop-tiler namespace: geocrop annotations: nginx.ingress.kubernetes.io/proxy-body-size: "50m" cert-manager.io/cluster-issuer: letsencrypt-prod spec: ingressClassName: nginx tls: - hosts: - tiles.portfolio.techarvest.co.zw secretName: geocrop-tiler-tls rules: - host: tiles.portfolio.techarvest.co.zw http: paths: - path: / pathType: Prefix backend: service: name: geocrop-tiler port: number: 8000 ``` ### 3.3 DNS Configuration Add A record: - `tiles.portfolio.techarvest.co.zw` → `167.86.68.48` (ingress IP) --- ## 4. TiTiler API Usage ### 4.1 Available Endpoints | Endpoint | Description | |----------|-------------| | `GET /cog/tiles/{z}/{x}/{y}.png` | Get tile as PNG | | `GET /cog/tiles/{z}/{x}/{y}.webp` | Get tile as WebP | | `GET /cog/point/{lon},{lat}` | Get pixel value at point | | `GET /cog/bounds` | Get raster bounds | | `GET /cog/info` | Get raster metadata | | `GET /cog/stats` | Get raster statistics | ### 4.2 Tile URL Format ```javascript // For a COG in MinIO: const tileUrl = `https://tiles.portfolio.techarvest.co.zw/cog/tiles/{z}/{x}/{y}.png?url=s3://geocrop-results/jobs/${jobId}/refined.tif`; // Or with custom colormap: const tileUrl = `https://tiles.portfolio.techarvest.co.zw/cog/tiles/{z}/{x}/{y}.png?url=s3://geocrop-results/jobs/${jobId}/refined.tif&colormap=${colormapId}`; ``` ### 4.3 Multiple Layers ```javascript // True color (Sentinel-2) const trueColorUrl = `https://tiles.portfolio.techarvest.co.zw/cog/tiles/{z}/{x}/{y}.png?url=s3://geocrop-results/jobs/${jobId}/truecolor.tif`; // NDVI const ndviUrl = `https://tiles.portfolio.techarvest.co.zw/cog/tiles/{z}/{x}/{y}.png?url=s3://geocrop-results/jobs/${jobId}/ndvi_peak.tif&colormap=ndvi`; // DW Baseline const dwUrl = `https://tiles.portfolio.techarvest.co.zw/cog/tiles/{z}/{x}/{y}.png?url=s3://geocrop-baselines/DW_Zim_HighestConf_${year}/${year+1}.tif`; ``` --- ## 5. Color Mapping ### 5.1 Crop Classification Colors Define colormap for LULC classes: ```json { "colormap": { "0": [27, 158, 119], // cropland - green "1": [229, 245, 224], // forest - dark green "2": [247, 252, 245], // grass - light green "3": [224, 236, 244], // shrubland - teal "4": [158, 188, 218], // water - blue "5": [240, 240, 240], // builtup - gray "6": [150, 150, 150], // bare - brown/gray } } ``` ### 5.2 NDVI Color Scale Use built-in `viridis` or custom: ```javascript const ndviColormap = { 0: [68, 1, 84], // Low - purple 100: [253, 231, 37], // High - yellow }; ``` --- ## 6. Frontend Integration ### 6.1 React Leaflet Integration ```javascript // Using react-leaflet import { TileLayer } from 'react-leaflet'; // Main result layer // DW baseline comparison ``` ### 6.2 Layer Switching Implement layer switcher in React: ```javascript const layerOptions = [ { id: 'refined', label: 'Refined Crop Map', urlTemplate: '...' }, { id: 'dw', label: 'Dynamic World Baseline', urlTemplate: '...' }, { id: 'truecolor', label: 'True Color', urlTemplate: '...' }, { id: 'ndvi', label: 'Peak NDVI', urlTemplate: '...' }, ]; ``` --- ## 7. Performance Optimization ### 7.1 Caching Strategy TiTiler automatically handles tile caching, but add: ```yaml # Kubernetes annotations for caching annotations: nginx.ingress.kubernetes.io/enable-access-log: "false" nginx.ingress.kubernetes.io/proxy-cache-valid: "200 1h" ``` ### 7.2 MinIO Performance - Ensure COGs have internal tiling (256x256) - Use DEFLATE compression - Set appropriate overview levels ### 7.3 TiTiler Configuration ```python # titiler/settings.py READER = "cog" CACHE_CONTROL = "public, max-age=3600" TILES_CACHE_MAX_AGE = 3600 # seconds # Environment variables for S3/MinIO AWS_ACCESS_KEY_ID=minioadmin AWS_SECRET_ACCESS_KEY=minioadmin12 AWS_REGION=dummy AWS_S3_ENDPOINT=http://minio.geocrop.svc.cluster.local:9000 AWS_HTTPS=NO ``` --- ## 8. Security ### 8.1 MinIO Access TiTiler needs read access to MinIO: - Use IAM-like policies via MinIO - Restrict to specific buckets ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": {"AWS": ["arn:aws:iam::system:user/tiler"]}, "Action": ["s3:GetObject"], "Resource": [ "arn:aws:s3:::geocrop-results/*", "arn:aws:s3:::geocrop-baselines/*" ] } ] } ``` ### 8.2 Ingress Security - Keep TLS enabled - Consider rate limiting on tile endpoints ### 8.3 Security Model (Portfolio-Safe) Two patterns: **Pattern A (Recommended): API Generates Signed Tile URLs** - Frontend requests "tile access token" per job layer - API issues short-lived signed URL(s) - Frontend uses those URLs as tile template **Pattern B: Tiler Behind Auth Proxy** - API acts as proxy adding Authorization header - More complex Start with Pattern A if TiTiler can read signed URLs; otherwise Pattern B. --- ## 9. Implementation Checklist - [ ] Create Kubernetes deployment manifest for TiTiler - [ ] Create Service - [ ] Create Ingress with TLS - [ ] Add DNS A record for tiles subdomain - [ ] Configure MinIO bucket policies for TiTiler access - [ ] Deploy to cluster - [ ] Test tile endpoint with sample COG - [ ] Verify performance (< 1s per tile) - [ ] Integrate with frontend --- ## 10. Alternative: Custom Tiler Service If TiTiler has compatibility issues, implement custom: ```python # apps/tiler/main.py from fastapi import FastAPI, HTTPException from rio_tiler.io import COGReader import boto3 app = FastAPI() s3 = boto3.client('s3', endpoint_url='http://minio.geocrop.svc.cluster.local:9000', aws_access_key_id=os.getenv('AWS_ACCESS_KEY_ID'), aws_secret_access_key=os.getenv('AWS_SECRET_ACCESS_KEY'), ) @app.get("/tiles/{job_id}/{z}/{x}/{y}.png") async def get_tile(job_id: str, z: int, x: int, y: int): s3_key = f"jobs/{job_id}/refined.tif" # Generate presigned URL (short expiry) presigned_url = s3.generate_presigned_url( 'get_object', Params={'Bucket': 'geocrop-results', 'Key': s3_key}, ExpiresIn=300 ) # Read tile with rio-tiler with COGReader(presigned_url) as cog: tile = cog.tile(x, y, z) return Response(tile, media_type="image/png") ``` --- ## 11. Technical Notes ### 11.1 COG Requirements For efficient tiling, COGs must have: - Internal tiling (256x256) - Overviews at multiple zoom levels - Appropriate compression ### 11.2 Coordinate Reference System Zimbabwe uses: - EPSG:32736 (UTM Zone 36S) for local - EPSG:4326 (WGS84) for web tiles TiTiler handles reprojection automatically. ### 11.3 Tile URL Expiry For signed URLs: - Generate with long expiry (24h) for job results - Or use bucket policies for public read - Pass URL as query param to TiTiler --- ## 12. Next Steps After implementation approval: 1. Create TiTiler Kubernetes manifests 2. Configure ingress and TLS 3. Set up DNS 4. Deploy and test 5. Integrate with frontend layer switcher