Skip to content

Commit b94464b

Browse files
Add tilebox logo to timelapse frames
1 parent ca47e25 commit b94464b

11 files changed

Lines changed: 293 additions & 18 deletions

File tree

‎.gitattributes‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
seasonal-timelapse/assets/*.png filter=lfs diff=lfs merge=lfs -text
2+
seasonal-timelapse/assets/*.ttf filter=lfs diff=lfs merge=lfs -text

‎seasonal-timelapse/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,9 @@ JSON represents tuples as arrays and datetimes as RFC 3339 strings.
8989

9090
## Outputs
9191

92-
The workflow writes its result to `outputs/<job-specific-directory>/timelapse.webp` in the runner's working directory. Intermediate PNG frames are stored in the `frames/` directory beside it.
92+
The workflow writes its result to `outputs/<job-specific-directory>/timelapse.webp` in the runner's working directory and uploads it to workflow storage as `<job-specific-directory>/timelapse.webp`. The final structured log message includes its `storage_path`, which the Console quickstart uses to display the animation. Intermediate PNG frames are stored in the local `frames/` directory beside it.
9393

94-
Output directories include the dates, coordinates, and square width, so separate runs do not overwrite each other. Outputs are local to the runners. The frame and assembly tasks therefore need runners that share the same working directory and filesystem.
94+
Output directories include the dates, coordinates, and square width, so separate runs do not overwrite each other. Intermediate outputs are local to the runners. The frame and assembly tasks therefore need runners that share the same working directory and filesystem.
9595

9696
## Explore locally
9797

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
version https://git-lfs.github.com/spec/v1
2+
oid sha256:8d909883de81344e0fbcfef30e931872e92d9aeecdf85b6dcf6e0b28c078e98e
3+
size 156520
Lines changed: 3 additions & 0 deletions
Loading

‎seasonal-timelapse/pyproject.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ description = "Build low-cloud seasonal Sentinel-2 RGB timelapses"
55
readme = "README.md"
66
requires-python = ">=3.12"
77
dependencies = [
8+
"niquests>=3.21.0",
89
"pillow>=11.3.0",
910
"pyproj>=3.7.2",
11+
"rasterio>=1.4.3",
1012
"shapely>=2.1.2",
1113
"tilebox",
1214
"xarray>=2025.9.0",

‎seasonal-timelapse/seasonal_rgb_timelapse/imagery.py‎

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,22 @@
44

55
import numpy as np
66
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
813
from tilebox.datasets.assets import AssetCollection
914
from tilebox.storage.aio import Client as StorageClient
1015
from tilebox.storage.geotiff import window_from_bounds
1116

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+
1223

1324
def lut(gamma: float, black_point: int, white_point: int) -> list[int]:
1425
"""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,
4152
require_fully_contained=True,
4253
)
4354
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)
4585

4686

4787
def render_frame(
4888
visual: np.ndarray,
4989
*,
5090
destination: Path,
91+
caption: str,
5192
) -> None:
52-
"""Render an unlabelled square RGB frame."""
93+
"""Render a branded square RGB frame with a readable caption."""
5394
image = Image.fromarray(visual)
54-
size = min(image.width, image.height, 900)
95+
size = min(image.width, image.height, _FRAME_SIZE)
5596
frame = ImageOps.fit(image, (size, size), method=Image.Resampling.LANCZOS)
5697
frame = frame.point(SENTINEL2_DISPLAY_LUT)
5798

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+
58135
destination.parent.mkdir(parents=True, exist_ok=True)
59136
frame.save(destination, format="PNG", optimize=True)
60137

‎seasonal-timelapse/seasonal_rgb_timelapse/tasks.py‎

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
from __future__ import annotations
22

3+
import hashlib
34
from datetime import UTC, datetime, timedelta
45
from pathlib import Path
6+
from urllib.parse import quote
57
from uuid import UUID
68

9+
import niquests
710
import xarray as xr
811
from pyproj import CRS, Transformer
912
from shapely import Geometry, Point, box, transform
@@ -112,7 +115,7 @@ def identifier() -> tuple[str, str]:
112115
return "tilebox.com/seasonal-rgb-timelapse/RenderSeasonalFrame", "v1.0"
113116

114117
async def execute(self, context: ExecutionContext) -> None:
115-
"""Read and render one unlabelled seasonal RGB frame."""
118+
"""Read and render one branded seasonal RGB frame."""
116119
context.current_task.display = f"Render {self.season}"
117120
datapoint = Client().dataset(DATASET).collection(COLLECTION).find(self.datapoint_id)
118121
stac_id = str(datapoint.stac_id.item())
@@ -125,10 +128,13 @@ async def execute(self, context: ExecutionContext) -> None:
125128
frame_name = f"{self.season_start:%y_%m}-{end_year % 100:02d}_{end_month:02d}.png"
126129
relative_frame_path = Path("frames") / frame_name
127130
frame_path = _output_root() / self.output_name / relative_frame_path
128-
render_frame(visual, destination=frame_path)
131+
render_frame(visual, destination=frame_path, caption=stac_id)
129132
context.progress().done(1)
130133
context.logger.info(
131-
"Rendered seasonal frame", frame=str(relative_frame_path), season=self.season, stac_id=stac_id
134+
"Rendered seasonal frame",
135+
frame=str(relative_frame_path),
136+
season=self.season,
137+
stac_id=stac_id,
132138
)
133139

134140

@@ -154,10 +160,44 @@ def execute(self, context: ExecutionContext) -> None:
154160
with context.tracer.span("encode-webp"):
155161
encode_animated(frame_paths, destination)
156162
context.logger.info(
157-
f"Timelapse saved to {destination}",
163+
"Timelapse saved to runner output directory",
164+
local_path=destination,
158165
frames=len(frame_paths),
159166
size_bytes=destination.stat().st_size,
160167
)
168+
with context.tracer.span("upload-webp"):
169+
storage_path = _upload_to_workflow_storage(destination, relative_destination.as_posix(), context)
170+
context.logger.info(
171+
"Timelapse uploaded to workflow storage",
172+
storage_path=storage_path,
173+
)
174+
175+
176+
def _upload_to_workflow_storage(source: Path, object_path: str, context: ExecutionContext) -> str:
177+
"""Upload a file through the workflow storage HTTP endpoint."""
178+
content = source.read_bytes()
179+
digest = hashlib.sha256(content).hexdigest()
180+
181+
# Workflow storage is an upcoming API. Until the high-level client lands, reuse the low-level client connection
182+
# inherited by both direct runners and release workers.
183+
client = context.runner_context.storage_locations._client # noqa: SLF001
184+
api_url = client._auth["url"].removesuffix("/") # noqa: SLF001
185+
token = client._auth["token"] # noqa: SLF001
186+
if not token:
187+
raise RuntimeError("workflow storage upload requires an authenticated Tilebox client")
188+
189+
encoded_path = quote(object_path, safe="/")
190+
response = niquests.put(
191+
f"{api_url}/v1/storage/{digest}/{encoded_path}",
192+
data=content,
193+
headers={
194+
"Authorization": f"Bearer {token}",
195+
"Content-Type": "image/webp",
196+
},
197+
timeout=60,
198+
)
199+
response.raise_for_status()
200+
return str(response.json()["path"])
161201

162202

163203
def _output_root() -> Path:

‎seasonal-timelapse/tests/test_imagery.py‎

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from pathlib import Path
22

33
import numpy as np
4+
from affine import Affine
45
from PIL import Image
56

6-
from seasonal_rgb_timelapse.imagery import encode_animated, lut, render_frame
7+
from seasonal_rgb_timelapse.imagery import align_visual, encode_animated, lut, render_frame
78

89

910
def test_lut_applies_fixed_levels_to_each_rgb_channel() -> None:
@@ -17,15 +18,18 @@ def test_lut_applies_fixed_levels_to_each_rgb_channel() -> None:
1718
assert table[110] == 255
1819

1920

20-
def test_render_frame_writes_unlabelled_square_image(tmp_path: Path) -> None:
21-
"""A rendered frame contains only a square crop of the source image."""
22-
visual = np.full((60, 100, 3), (10, 20, 30), dtype=np.uint8)
21+
def test_render_frame_writes_branded_square_image(tmp_path: Path) -> None:
22+
"""A rendered frame contains a square crop and readable branding panel."""
23+
visual = np.full((300, 500, 3), (10, 20, 30), dtype=np.uint8)
2324
destination = tmp_path / "frame.png"
2425

25-
render_frame(visual, destination=destination)
26+
render_frame(visual, destination=destination, caption="S2B_33UXP_20260306_0_L2A")
2627

2728
with Image.open(destination) as image:
28-
assert image.size == (60, 60)
29+
assert image.size == (300, 300)
30+
assert max(image.getpixel((150, 150))) < 40
31+
panel = np.asarray(image)[266:292, 8:292]
32+
assert np.count_nonzero(panel[:, :, 0] > 200) > panel.shape[0] * panel.shape[1] // 2
2933

3034

3135
def test_encode_animated_writes_looping_webp(tmp_path: Path) -> None:
@@ -49,9 +53,49 @@ def test_render_frame_uses_fixed_sentinel2_display_levels(tmp_path: Path) -> Non
4953
dark_path = tmp_path / "dark.png"
5054
bright_path = tmp_path / "bright.png"
5155

52-
render_frame(np.full((8, 8, 3), 5, dtype=np.uint8), destination=dark_path)
53-
render_frame(np.full((8, 8, 3), 250, dtype=np.uint8), destination=bright_path)
56+
render_frame(
57+
np.full((100, 100, 3), 5, dtype=np.uint8),
58+
destination=dark_path,
59+
caption="S2B_33UXP_20260306_0_L2A",
60+
)
61+
render_frame(
62+
np.full((100, 100, 3), 250, dtype=np.uint8),
63+
destination=bright_path,
64+
caption="S2B_33UXP_20260306_0_L2A",
65+
)
5466

5567
with Image.open(dark_path) as dark, Image.open(bright_path) as bright:
5668
assert dark.getpixel((0, 0)) == (0, 0, 0)
5769
assert bright.getpixel((0, 0)) == (255, 255, 255)
70+
71+
72+
def test_align_visual_keeps_world_coordinates_fixed_across_source_grids() -> None:
73+
"""The same geographic feature lands on the same output row for every scene."""
74+
first = np.zeros((3, 10, 10), dtype=np.uint8)
75+
first[:, 4, :] = 255
76+
shifted = np.zeros((3, 12, 12), dtype=np.uint8)
77+
shifted[:, 5, 1:11] = 255
78+
79+
first_aligned = align_visual(
80+
first,
81+
source_transform=Affine.translation(0, 10) * Affine.scale(1, -1),
82+
source_crs="EPSG:4326",
83+
source_nodata=None,
84+
bounds=(0, 0, 10, 10),
85+
)
86+
shifted_aligned = align_visual(
87+
shifted,
88+
source_transform=Affine.translation(-1, 11) * Affine.scale(1, -1),
89+
source_crs="EPSG:4326",
90+
source_nodata=None,
91+
bounds=(0, 0, 10, 10),
92+
)
93+
94+
assert first_aligned.shape == (900, 900, 3)
95+
first_profile = first_aligned[:, :, 0].mean(axis=1)
96+
shifted_profile = shifted_aligned[:, :, 0].mean(axis=1)
97+
assert np.argmax(first_profile) == np.argmax(shifted_profile)
98+
np.testing.assert_array_equal(
99+
np.flatnonzero(first_profile > first_profile.max() / 2),
100+
np.flatnonzero(shifted_profile > shifted_profile.max() / 2),
101+
)

‎seasonal-timelapse/tests/test_tasks.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
from datetime import datetime, timezone
2+
from pathlib import Path
3+
from types import SimpleNamespace
4+
from unittest.mock import Mock
25
from uuid import UUID
36

47
import pytest
@@ -11,6 +14,7 @@
1114
RenderSeasonalFrame,
1215
_season_name,
1316
_square_aoi,
17+
_upload_to_workflow_storage,
1418
)
1519

1620

@@ -56,6 +60,33 @@ def test_root_task_defaults_to_an_optional_time_range() -> None:
5660
assert task.max_cloud_percent == 20.0
5761

5862

63+
def test_upload_uses_runner_api_connection(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
64+
"""Workflow storage reuses the API connection inherited by the runner."""
65+
source = tmp_path / "timelapse.webp"
66+
source.write_bytes(b"webp")
67+
response = Mock()
68+
response.json.return_value = {
69+
"path": "/019d1c17-8636-7259-a5fd-dda1f9f26c1e/a57bb082e728a0cdce930ecfcccf4510a3a247be5f322b09b3a971a3f5ed34f8/seasonal output/timelapse.webp"
70+
}
71+
put = Mock(return_value=response)
72+
monkeypatch.setattr("seasonal_rgb_timelapse.tasks.niquests.put", put)
73+
client = SimpleNamespace(_auth={"url": "https://api.tilebox.com/", "token": "secret"})
74+
context = SimpleNamespace(
75+
runner_context=SimpleNamespace(storage_locations=SimpleNamespace(_client=client)),
76+
)
77+
78+
storage_path = _upload_to_workflow_storage(source, "seasonal output/timelapse.webp", context)
79+
80+
put.assert_called_once_with(
81+
"https://api.tilebox.com/v1/storage/a57bb082e728a0cdce930ecfcccf4510a3a247be5f322b09b3a971a3f5ed34f8/seasonal%20output/timelapse.webp",
82+
data=b"webp",
83+
headers={"Authorization": "Bearer secret", "Content-Type": "image/webp"},
84+
timeout=60,
85+
)
86+
response.raise_for_status.assert_called_once_with()
87+
assert storage_path == response.json.return_value["path"]
88+
89+
5990
def test_season_name_for_cross_year_season() -> None:
6091
"""December seasons include both years."""
6192
name, start = _season_name(xr.DataArray(datetime(2025, 12, 1)))

‎seasonal-timelapse/tilebox.workflow.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ include = [
88
"pyproject.toml",
99
"uv.lock",
1010
"runner.py",
11+
"assets/**",
1112
"seasonal_rgb_timelapse/**",
1213
]
1314
exclude = [

0 commit comments

Comments
 (0)