452 lines
10 KiB
Markdown
452 lines
10 KiB
Markdown
# 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
|
|
<TileLayer
|
|
url={`https://tiles.portfolio.techarvest.co.zw/cog/tiles/{z}/{x}/{y}.png?url=s3://geocrop-results/jobs/${jobId}/refined.tif`}
|
|
attribution='© GeoCrop'
|
|
/>
|
|
|
|
// DW baseline comparison
|
|
<TileLayer
|
|
url={`https://tiles.portfolio.techarvest.co.zw/cog/tiles/{z}/{x}/{y}.png?url=s3://geocrop-baselines/DW_Zim_HighestConf_${year}/${year+1}.tif`}
|
|
attribution='Dynamic World'
|
|
/>
|
|
```
|
|
|
|
### 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
|