Use season hero photos as poster bg + switch image to Alpine
posters_service.render_season_poster() now fetches /dilbert/sports/
formula1/cars/{year}.jpg from the files-api CDN, centre-crops to 2:3,
and composites a vertical dark gradient so the FORMULA 1 / year /
WORLD CHAMPIONSHIP text stays legible over any photo.
Dockerfile:
- python:3.12-slim → python:3.12-alpine (smaller, no fastf1 leftover)
- fonts-dejavu-core → ttf-liberation: same font Arch dev ships, so
posters render byte-identical on dev and in the container.
- Drop fastf1 (unused in the served code path); add numpy explicitly
because models/geo_json/geometry.py imports it at module load.
This commit is contained in:
+59
-8
@@ -14,6 +14,7 @@ so cold paths stay cheap.
|
|||||||
|
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -23,6 +24,10 @@ from PIL import Image, ImageDraw, ImageFont
|
|||||||
from circuits_service import CircuitService
|
from circuits_service import CircuitService
|
||||||
from ergast_service import ErgastService
|
from ergast_service import ErgastService
|
||||||
|
|
||||||
|
# Where season background images live. Hosted by the dilbert files-api module —
|
||||||
|
# one landscape JPG per F1 season (key for the season-card grid in Dilbert's UI).
|
||||||
|
SEASON_BG_URL_TEMPLATE = "https://files-api.novox.be/dilbert/sports/formula1/cars/{season}.jpg"
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
POSTER_W, POSTER_H = 1000, 1500
|
POSTER_W, POSTER_H = 1000, 1500
|
||||||
@@ -35,10 +40,12 @@ CHECKER_H = 60
|
|||||||
CHECKER_SQ = 30
|
CHECKER_SQ = 30
|
||||||
|
|
||||||
# Slim-image-friendly font candidates. The Dockerfile installs fonts-dejavu-core
|
# Slim-image-friendly font candidates. The Dockerfile installs fonts-dejavu-core
|
||||||
# so DejaVuSans-Bold is always present; the others are fallbacks for local dev.
|
# so DejaVuSans-Bold is always present in production; the others cover common
|
||||||
|
# local-dev paths (Arch Linux ships Liberation Sans at /usr/share/fonts/liberation).
|
||||||
FONT_CANDIDATES = [
|
FONT_CANDIDATES = [
|
||||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", # Debian/Ubuntu
|
||||||
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
|
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", # Fedora
|
||||||
|
"/usr/share/fonts/liberation/LiberationSans-Bold.ttf", # Arch
|
||||||
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -162,18 +169,62 @@ class PostersService:
|
|||||||
|
|
||||||
# ------------------------------------------------------------------ season poster
|
# ------------------------------------------------------------------ season poster
|
||||||
|
|
||||||
|
def _fetch_season_background(self, season: int) -> Optional[Image.Image]:
|
||||||
|
"""Pull the season's hero photo from the dilbert files-api CDN."""
|
||||||
|
url = SEASON_BG_URL_TEMPLATE.format(season=season)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=10) as resp:
|
||||||
|
bg = Image.open(io.BytesIO(resp.read())).convert("RGB")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"No season background for {season} ({url}): {e}")
|
||||||
|
return None
|
||||||
|
# Cover-fit into 1000x1500: scale so both dims meet/exceed target, centre-crop.
|
||||||
|
sw, sh = bg.size
|
||||||
|
scale = max(POSTER_W / sw, POSTER_H / sh)
|
||||||
|
bg = bg.resize((int(sw * scale), int(sh * scale)), Image.LANCZOS)
|
||||||
|
nw, nh = bg.size
|
||||||
|
left = (nw - POSTER_W) // 2
|
||||||
|
top = (nh - POSTER_H) // 2
|
||||||
|
return bg.crop((left, top, left + POSTER_W, top + POSTER_H))
|
||||||
|
|
||||||
|
def _vertical_dark_gradient(self) -> Image.Image:
|
||||||
|
"""A vertical alpha gradient that's darkest top + bottom — keeps the photo's
|
||||||
|
midsection visible while making wordmark / year / footer text legible."""
|
||||||
|
overlay = Image.new("RGBA", (POSTER_W, POSTER_H), (0, 0, 0, 0))
|
||||||
|
ovr = overlay.load()
|
||||||
|
for y in range(POSTER_H):
|
||||||
|
if y < 500:
|
||||||
|
# Strong dim at top for the wordmark + year, fading to nothing at y=500
|
||||||
|
alpha = 220 - (y * 220 // 500)
|
||||||
|
elif y > 1100:
|
||||||
|
# Strong dim at bottom for "WORLD CHAMPIONSHIP" footer
|
||||||
|
alpha = (y - 1100) * 220 // (POSTER_H - 1100)
|
||||||
|
alpha = min(alpha, 220)
|
||||||
|
else:
|
||||||
|
# Mild dim through the middle to anchor text contrast
|
||||||
|
alpha = 60
|
||||||
|
for x in range(POSTER_W):
|
||||||
|
ovr[x, y] = (0, 0, 0, alpha)
|
||||||
|
return overlay
|
||||||
|
|
||||||
def render_season_poster(self, season: int) -> bytes:
|
def render_season_poster(self, season: int) -> bytes:
|
||||||
cache_path = self.cache_dir / f"season-{season}.png"
|
cache_path = self.cache_dir / f"season-{season}.png"
|
||||||
if cache_path.exists():
|
if cache_path.exists():
|
||||||
return cache_path.read_bytes()
|
return cache_path.read_bytes()
|
||||||
|
|
||||||
img = Image.new("RGB", (POSTER_W, POSTER_H), BG)
|
bg = self._fetch_season_background(season)
|
||||||
draw = ImageDraw.Draw(img)
|
if bg is None:
|
||||||
|
# Fallback to the all-black layout if the background isn't available.
|
||||||
|
img = Image.new("RGB", (POSTER_W, POSTER_H), BG)
|
||||||
|
else:
|
||||||
|
# Composite the dimming gradient over the hero photo for text legibility.
|
||||||
|
img = Image.alpha_composite(bg.convert("RGBA"), self._vertical_dark_gradient()).convert("RGB")
|
||||||
|
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
self._checker_strip(draw, 0)
|
self._checker_strip(draw, 0)
|
||||||
self._centered(draw, "FORMULA 1", y=240, size=120, fill=RED)
|
self._centered(draw, "FORMULA 1", y=110, size=88, fill=RED)
|
||||||
self._centered(draw, str(season), y=440, size=420, fill=WHITE)
|
self._centered(draw, str(season), y=240, size=240, fill=WHITE)
|
||||||
self._centered(draw, "WORLD CHAMPIONSHIP", y=1100, size=56, fill=GREY)
|
self._centered(draw, "WORLD CHAMPIONSHIP", y=POSTER_H - 200, size=48, fill=WHITE)
|
||||||
self._checker_strip(draw, POSTER_H - CHECKER_H)
|
self._checker_strip(draw, POSTER_H - CHECKER_H)
|
||||||
|
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
|
|||||||
+18
-16
@@ -1,24 +1,26 @@
|
|||||||
FROM python:3.12-slim
|
FROM python:3.12-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Poster rendering needs:
|
# Runtime deps:
|
||||||
# - libcairo2 (cairosvg ↔ Cairo bindings) for SVG → PNG raster
|
# - cairo provides libcairo so cairosvg can rasterise SVG → PNG.
|
||||||
# - fonts-dejavu-core for the DejaVuSans-Bold font used by PostersService
|
# - ttf-liberation gives Liberation Sans Bold at
|
||||||
# These add ~15 MB to the image but keep poster generation in-process and fast.
|
# /usr/share/fonts/liberation/LiberationSans-Bold.ttf — exactly where Arch
|
||||||
RUN apt-get update && \
|
# installs it, so dev (shanks) and prod (this container) render with the
|
||||||
apt-get install -y --no-install-recommends \
|
# same font and the posters come out identical.
|
||||||
libcairo2 \
|
RUN apk add --no-cache \
|
||||||
fonts-dejavu-core \
|
cairo \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
ttf-liberation
|
||||||
|
|
||||||
|
# All pip deps ship musllinux wheels — no build-deps needed.
|
||||||
|
# numpy is pulled in by models/geo_json/geometry.py for vector ops at import time.
|
||||||
RUN pip install --no-cache-dir \
|
RUN pip install --no-cache-dir \
|
||||||
fastapi \
|
fastapi \
|
||||||
uvicorn \
|
uvicorn \
|
||||||
fastf1 \
|
python-slugify \
|
||||||
python-slugify \
|
pillow \
|
||||||
pillow \
|
cairosvg \
|
||||||
cairosvg
|
numpy
|
||||||
|
|
||||||
COPY cdn-api.py circuits_service.py ergast_service.py posters_service.py ./
|
COPY cdn-api.py circuits_service.py ergast_service.py posters_service.py ./
|
||||||
COPY models/ ./models/
|
COPY models/ ./models/
|
||||||
|
|||||||
Reference in New Issue
Block a user