|
4 | 4 |
|
5 | 5 | import numpy as np |
6 | 6 | import xarray as xr |
7 | | -from PIL import Image, ImageOps |
| 7 | +from affine import Affine |
| 8 | +from PIL import Image, ImageDraw, ImageFont, ImageOps |
| 9 | +from pyproj import CRS |
| 10 | +from rasterio.enums import Resampling |
| 11 | +from rasterio.transform import from_bounds |
| 12 | +from rasterio.warp import reproject |
8 | 13 | from tilebox.datasets.assets import AssetCollection |
9 | 14 | from tilebox.storage.aio import Client as StorageClient |
10 | 15 | from tilebox.storage.geotiff import window_from_bounds |
11 | 16 |
|
| 17 | +_ASSETS_DIR = Path(__file__).parent.parent / "assets" |
| 18 | +_FONT_PATH = _ASSETS_DIR / "Poppins-Medium.ttf" |
| 19 | +_LOGO_PATH = _ASSETS_DIR / "tilebox-logo.png" |
| 20 | +_TEXT_COLOR = (21, 27, 43, 255) |
| 21 | +_FRAME_SIZE = 900 |
| 22 | + |
12 | 23 |
|
13 | 24 | def lut(gamma: float, black_point: int, white_point: int) -> list[int]: |
14 | 25 | """Build an RGB display LUT with fixed levels and gamma correction. |
@@ -41,20 +52,86 @@ async def read_visual(datapoint: xr.Dataset, bounds: tuple[float, float, float, |
41 | 52 | require_fully_contained=True, |
42 | 53 | ) |
43 | 54 | chunk = await geotiff.read(window=window) |
44 | | - return np.moveaxis(chunk.data[:3], 0, -1) |
| 55 | + return align_visual( |
| 56 | + chunk.data, |
| 57 | + source_transform=chunk.transform, |
| 58 | + source_crs=chunk.crs, |
| 59 | + source_nodata=chunk.nodata, |
| 60 | + bounds=bounds, |
| 61 | + ) |
| 62 | + |
| 63 | + |
| 64 | +def align_visual( |
| 65 | + data: np.ndarray, |
| 66 | + *, |
| 67 | + source_transform: Affine, |
| 68 | + source_crs: CRS | str, |
| 69 | + source_nodata: float | None, |
| 70 | + bounds: tuple[float, float, float, float], |
| 71 | +) -> np.ndarray: |
| 72 | + """Reproject RGB data onto one fixed WGS84 grid so frames stay aligned.""" |
| 73 | + destination = np.zeros((3, _FRAME_SIZE, _FRAME_SIZE), dtype=data.dtype) |
| 74 | + reproject( |
| 75 | + data[:3], |
| 76 | + destination, |
| 77 | + src_transform=source_transform, |
| 78 | + src_crs=source_crs, |
| 79 | + src_nodata=source_nodata, |
| 80 | + dst_transform=from_bounds(*bounds, width=_FRAME_SIZE, height=_FRAME_SIZE), |
| 81 | + dst_crs="EPSG:4326", |
| 82 | + resampling=Resampling.bilinear, |
| 83 | + ) |
| 84 | + return np.moveaxis(destination, 0, -1) |
45 | 85 |
|
46 | 86 |
|
47 | 87 | def render_frame( |
48 | 88 | visual: np.ndarray, |
49 | 89 | *, |
50 | 90 | destination: Path, |
| 91 | + caption: str, |
51 | 92 | ) -> None: |
52 | | - """Render an unlabelled square RGB frame.""" |
| 93 | + """Render a branded square RGB frame with a readable caption.""" |
53 | 94 | image = Image.fromarray(visual) |
54 | | - size = min(image.width, image.height, 900) |
| 95 | + size = min(image.width, image.height, _FRAME_SIZE) |
55 | 96 | frame = ImageOps.fit(image, (size, size), method=Image.Resampling.LANCZOS) |
56 | 97 | frame = frame.point(SENTINEL2_DISPLAY_LUT) |
57 | 98 |
|
| 99 | + overlay = Image.new("RGBA", frame.size) |
| 100 | + draw = ImageDraw.Draw(overlay) |
| 101 | + margin = max(4, round(size * 0.027)) |
| 102 | + panel_height = max(24, round(size * 0.09)) |
| 103 | + panel_bounds = (margin, size - margin - panel_height, size - margin, size - margin) |
| 104 | + corner_radius = max(5, round(panel_height * 0.24)) |
| 105 | + shadow_offset = max(1, round(size * 0.004)) |
| 106 | + shadow_bounds = ( |
| 107 | + panel_bounds[0] + shadow_offset, |
| 108 | + panel_bounds[1] + shadow_offset, |
| 109 | + panel_bounds[2] + shadow_offset, |
| 110 | + panel_bounds[3] + shadow_offset, |
| 111 | + ) |
| 112 | + draw.rounded_rectangle(shadow_bounds, radius=corner_radius, fill=(0, 0, 0, 70)) |
| 113 | + draw.rounded_rectangle(panel_bounds, radius=corner_radius, fill=(255, 255, 255, 235)) |
| 114 | + |
| 115 | + content_padding = max(4, round(panel_height * 0.2)) |
| 116 | + logo_height = panel_height - 2 * content_padding |
| 117 | + with Image.open(_LOGO_PATH) as source_logo: |
| 118 | + logo = source_logo.convert("RGBA") |
| 119 | + logo_width = round(logo.width * logo_height / logo.height) |
| 120 | + logo = logo.resize((logo_width, logo_height), Image.Resampling.LANCZOS) |
| 121 | + logo_x = panel_bounds[0] + content_padding |
| 122 | + logo_y = panel_bounds[1] + (panel_height - logo_height) // 2 |
| 123 | + overlay.alpha_composite(logo, (logo_x, logo_y)) |
| 124 | + |
| 125 | + font = ImageFont.truetype(_FONT_PATH, max(10, round(panel_height * 0.34))) |
| 126 | + caption_bounds = draw.textbbox((0, 0), caption, font=font) |
| 127 | + caption_width = caption_bounds[2] - caption_bounds[0] |
| 128 | + caption_height = caption_bounds[3] - caption_bounds[1] |
| 129 | + caption_x = panel_bounds[2] - content_padding - caption_width |
| 130 | + caption_y = panel_bounds[1] + (panel_height - caption_height) // 2 - caption_bounds[1] |
| 131 | + draw.text((caption_x, caption_y), caption, fill=_TEXT_COLOR, font=font) |
| 132 | + |
| 133 | + frame = Image.alpha_composite(frame.convert("RGBA"), overlay).convert("RGB") |
| 134 | + |
58 | 135 | destination.parent.mkdir(parents=True, exist_ok=True) |
59 | 136 | frame.save(destination, format="PNG", optimize=True) |
60 | 137 |
|
|
0 commit comments