#2 Season hero photo as poster bg + switch to Alpine image

已合併
jschoubben 1 周之前 將 2 次代碼提交從 feat/season-poster-bg合併至 master
  1. +33
    -5
      cdn-api.py
  2. +450
    -21
      posters_service.py
  3. +18
    -16
      service/Dockerfile

+ 33
- 5
cdn-api.py 查看文件

@@ -1,5 +1,5 @@
from fastapi import FastAPI, HTTPException, Response
from fastapi.responses import FileResponse, Response as FastAPIResponse
from fastapi.responses import FileResponse, Response as FastAPIResponse, PlainTextResponse
from fastapi.middleware.cors import CORSMiddleware
from pathlib import Path
import logging
@@ -7,7 +7,7 @@ import logging
# Import our CircuitService
from circuits_service import CircuitService, Format
from ergast_service import ErgastService
from posters_service import PostersService
from posters_service import PostersService, BackgroundStyle
from models.track_layout import TrackLayout

# Initialize FastAPI app
@@ -71,9 +71,15 @@ async def root():


@app.get("/posters/race/{grand_prix_name}/{season}.png")
async def get_race_poster(grand_prix_name: str, season: int):
"""Plex-ready 1000x1500 race poster (PNG). Cached on disk."""
png = posters_service.render_race_poster(grand_prix_name, season)
async def get_race_poster(grand_prix_name: str, season: int, bg: BackgroundStyle = BackgroundStyle.AERIAL):
"""Plex-ready 1000x1500 race poster (PNG). Cached on disk per (race, bg).

`?bg=` selects the backdrop:
- aerial (default) — Esri World Imagery satellite at the circuit's coords
- osm — OpenStreetMap Carto street-map at the circuit's coords
- none — plain black background (skip tile fetch)
"""
png = posters_service.render_race_poster(grand_prix_name, season, bg=bg)
if not png:
raise HTTPException(status_code=404, detail=f"No poster available for {grand_prix_name} {season}")
return FastAPIResponse(
@@ -93,6 +99,28 @@ async def get_season_poster(season: int):
headers={"Cache-Control": "public, max-age=31536000"},
)


@app.get("/kometa/formula-1-metadata.yml")
async def get_kometa_metadata():
"""Generate a Kometa-compatible metadata YAML for the Formula 1 library.

One entry per F1 season from 1950..current+1. Each entry:
- uses Kometa's f1_season builder for round/episode names
- sets url_poster on the show (per-season hero from files-api)
- sets url_poster on each Plex 'season' (== each F1 round) from the
f1-circuits race poster endpoint

Kometa harmlessly skips shows / seasons that don't exist in the Plex
library, so we can emit the full schedule and let Kometa do the matching.
"""
yaml = posters_service.render_kometa_metadata()
return PlainTextResponse(
content=yaml,
media_type="text/yaml; charset=utf-8",
# YAML changes only when the Jolpica schedule changes — cache 1h.
headers={"Cache-Control": "public, max-age=3600"},
)

@app.get("/circuits")
async def get_countries():
"""Get list of all available countries with their slugs"""


+ 450
- 21
posters_service.py 查看文件

@@ -13,7 +13,11 @@ so cold paths stay cheap.
"""

import io
import json
import logging
import math
import urllib.request
from enum import Enum
from pathlib import Path
from typing import Optional

@@ -23,6 +27,43 @@ from PIL import Image, ImageDraw, ImageFont
from circuits_service import CircuitService
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"


class BackgroundStyle(str, Enum):
"""Selectable backdrop for race posters."""
AERIAL = "aerial" # Esri World Imagery satellite tiles (default)
OSM = "osm" # OpenStreetMap Carto street-map tiles
DARK = "dark" # CartoDB Dark Matter — minimalist black-and-grey map
LIGHT = "light" # CartoDB Positron — minimalist light/grey map
NONE = "none" # Plain black, no map fetch — fast fallback


# XYZ tile templates. None require API keys at our volume.
TILE_PROVIDERS = {
BackgroundStyle.AERIAL: {
"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
"attribution": "Esri, Maxar, Earthstar Geographics",
},
BackgroundStyle.OSM: {
"url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
"attribution": "© OpenStreetMap contributors",
},
BackgroundStyle.DARK: {
"url": "https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
"attribution": "© CartoDB, © OpenStreetMap contributors",
},
BackgroundStyle.LIGHT: {
"url": "https://basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png",
"attribution": "© CartoDB, © OpenStreetMap contributors",
},
}

# Polite UA — both providers expect one and Esri throttles aggressively without it.
TILE_USER_AGENT = "f1-circuits-posters/1.0 (+https://f1-circuits.zurag.be)"

logger = logging.getLogger(__name__)

POSTER_W, POSTER_H = 1000, 1500
@@ -34,11 +75,19 @@ GREY = (160, 160, 170)
CHECKER_H = 60
CHECKER_SQ = 30

# Vertical pixel where we want the circuit's geographic centre to land. Picked so
# the track ends up visually centred *in the visible aerial band* — between the
# heading block at the top and the date strip at the bottom, not in the full
# 1000x1500 poster middle (which would look high once the heading dim is over it).
GEO_CENTER_Y = 935

# 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 = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", # Debian/Ubuntu
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", # Fedora
"/usr/share/fonts/liberation/LiberationSans-Bold.ttf", # Arch
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
]

@@ -52,6 +101,25 @@ class PostersService:
logger.warning("No bundled font found — poster text will use PIL's default bitmap font")
self.cache_dir = Path("cache/posters")
self.cache_dir.mkdir(parents=True, exist_ok=True)
# Pre-tuned zooms per circuit, keyed by slugified circuit name.
# Falls back to DEFAULT_ZOOM when not found.
self._zoom_by_circuit = self._load_zoom_lookup()

DEFAULT_ZOOM = 15

@staticmethod
def _load_zoom_lookup() -> dict:
"""Index f1-locations.json by slugified circuit name for fast zoom lookup."""
from ergast_service import slugify
path = Path("f1-locations.json")
if not path.exists():
return {}
try:
data = json.loads(path.read_text())
except Exception as e:
logger.warning(f"Could not load f1-locations.json: {e}")
return {}
return {slugify(entry["name"]): entry for entry in data if "name" in entry}

# ------------------------------------------------------------------ utils

@@ -78,6 +146,214 @@ class PostersService:
fill = WHITE if (col + row) % 2 == 0 else BG
draw.rectangle([x, yy, x + sq, yy + sq], fill=fill)

# ---------------------------------------------------------- map tiles

@staticmethod
def _latlon_to_pixel(lat: float, lon: float, zoom: int) -> tuple[float, float]:
"""Standard Web-Mercator pixel coordinates (slippy-map convention, 256-px tiles)."""
n = 2.0 ** zoom * 256
x = (lon + 180.0) / 360.0 * n
lat_rad = math.radians(lat)
y = (1.0 - math.log(math.tan(lat_rad) + 1.0 / math.cos(lat_rad)) / math.pi) / 2.0 * n
return x, y

def _fetch_map_background(self, lat: float, lon: float, zoom: int, style: BackgroundStyle) -> Optional[Image.Image]:
"""Stitch enough 256-px tiles around (lat, lon) to fill the full POSTER_W x POSTER_H."""
provider = TILE_PROVIDERS.get(style)
if not provider:
return None

# Centre pixel and the slab we need to cover. Anchor the geographic
# centre at GEO_CENTER_Y rather than POSTER_H/2 so the track lands in
# the middle of the *visible* aerial band.
cpx, cpy = self._latlon_to_pixel(lat, lon, zoom)
left, top = int(cpx - POSTER_W / 2), int(cpy - GEO_CENTER_Y)
tile_x_lo, tile_y_lo = left // 256, top // 256
tile_x_hi = (left + POSTER_W + 255) // 256
tile_y_hi = (top + POSTER_H + 255) // 256

canvas_w = (tile_x_hi - tile_x_lo) * 256
canvas_h = (tile_y_hi - tile_y_lo) * 256
canvas = Image.new("RGB", (canvas_w, canvas_h), BG)

any_ok = False
for tx in range(tile_x_lo, tile_x_hi):
for ty in range(tile_y_lo, tile_y_hi):
url = provider["url"].format(z=zoom, x=tx, y=ty)
try:
req = urllib.request.Request(url, headers={"User-Agent": TILE_USER_AGENT})
with urllib.request.urlopen(req, timeout=10) as resp:
tile = Image.open(io.BytesIO(resp.read())).convert("RGB")
canvas.paste(tile, ((tx - tile_x_lo) * 256, (ty - tile_y_lo) * 256))
any_ok = True
except Exception as e:
logger.warning(f"Tile fetch failed {style.value} z={zoom} x={tx} y={ty}: {e}")

if not any_ok:
return None

ox = left - tile_x_lo * 256
oy = top - tile_y_lo * 256
return canvas.crop((ox, oy, ox + POSTER_W, oy + POSTER_H))

def _draw_track_projected(
self,
img: Image.Image,
coords: list,
lat0: float,
lon0: float,
zoom: int,
) -> bool:
"""Draw the track onto `img` by projecting each GeoJSON (lon, lat) point
into pixel space using the same web-mercator math as the map background.
This guarantees the white outline overlays the actual circuit in the
underlying aerial / OSM tiles.

Returns True on success, False if there's nothing usable to draw."""
if not coords:
return False

cpx, cpy = self._latlon_to_pixel(lat0, lon0, zoom)
# The cropped poster window's top-left absolute pixel — anchored so the
# geographic centre lands at (POSTER_W/2, GEO_CENTER_Y), matching the
# offset used by _fetch_map_background so the polyline overlays cleanly.
win_left = cpx - POSTER_W / 2
win_top = cpy - GEO_CENTER_Y

pts: list[tuple[float, float]] = []
for entry in coords:
# GeoJSON LineString is [[lon, lat], ...]; tolerate (lon, lat) tuples too.
if len(entry) < 2:
continue
lon, lat = float(entry[0]), float(entry[1])
px, py = self._latlon_to_pixel(lat, lon, zoom)
pts.append((px - win_left, py - win_top))

if len(pts) < 2:
return False

d = ImageDraw.Draw(img)
# Subtle dark stroke under, then the bright white line, so the outline
# stays legible whether the bg is the bright Mediterranean or a dark
# CartoDB tile.
d.line(pts + [pts[0]], fill=(0, 0, 0), width=10, joint="curve")
d.line(pts + [pts[0]], fill=WHITE, width=6, joint="curve")
return True

def _render_world_locator(self, lat: float, lon: float, size: int = 220) -> Optional[Image.Image]:
"""A small picture-in-picture map showing where on Earth this race is.
Uses CartoDB Dark Matter at zoom 5 — tight enough that the host country
fills most of the inset, while neighbours and coastlines still give the
viewer a "here's the region" anchor. A marker (red dot + white ring)
is drawn over the exact spot."""
zoom = 5
# Use the same stitcher with a custom canvas target.
provider = TILE_PROVIDERS[BackgroundStyle.DARK]
cpx, cpy = self._latlon_to_pixel(lat, lon, zoom)
# Frame 700x700 px window centred on the location, then downscale to `size`.
win = 700
left, top = int(cpx - win / 2), int(cpy - win / 2)
tx0, ty0 = left // 256, top // 256
tx1, ty1 = (left + win + 255) // 256, (top + win + 255) // 256
canvas = Image.new("RGB", ((tx1 - tx0) * 256, (ty1 - ty0) * 256), BG)
any_ok = False
for tx in range(tx0, tx1):
for ty in range(ty0, ty1):
# Wrap longitude tiles (world is cyclic at zoom 2 = 4 tiles wide)
wrapped_x = tx % (2 ** zoom)
if wrapped_x < 0:
wrapped_x += 2 ** zoom
if not (0 <= ty < 2 ** zoom):
continue
url = provider["url"].format(z=zoom, x=wrapped_x, y=ty)
try:
req = urllib.request.Request(url, headers={"User-Agent": TILE_USER_AGENT})
with urllib.request.urlopen(req, timeout=10) as resp:
tile = Image.open(io.BytesIO(resp.read())).convert("RGB")
canvas.paste(tile, ((tx - tx0) * 256, (ty - ty0) * 256))
any_ok = True
except Exception as e:
logger.warning(f"Locator tile fail {tx},{ty}: {e}")
if not any_ok:
return None
ox, oy = left - tx0 * 256, top - ty0 * 256
cropped = canvas.crop((ox, oy, ox + win, oy + win)).resize((size, size), Image.LANCZOS)

# Marker: red filled circle + white ring at the exact spot (centre of the frame).
d = ImageDraw.Draw(cropped, "RGBA")
cx, cy = size // 2, size // 2
d.ellipse([cx - 9, cy - 9, cx + 9, cy + 9], outline=(255, 255, 255, 255), width=2)
d.ellipse([cx - 5, cy - 5, cx + 5, cy + 5], fill=RED + (255,))

# Thin white frame around the inset itself.
d.rectangle([0, 0, size - 1, size - 1], outline=(255, 255, 255, 200), width=2)
return cropped

# Visible aerial slot (between heading band and date strip) — target the
# bbox to ~85% of it so there's some breathing room around the track.
_BBOX_TARGET_W = int(POSTER_W * 0.85)
_BBOX_TARGET_H = int((1300 - 540) * 0.85) # visible aerial slot height, scaled

def _zoom_from_bbox(self, coords: list, max_zoom: int = 17, min_zoom: int = 10) -> int:
"""Pick the tightest zoom level at which the track's GeoJSON bbox still
fits inside the visible aerial area. Walks from high → low zoom and
returns the first zoom that fits — i.e. the most zoomed-in view that
still shows the whole lap."""
if len(coords) < 2:
return self.DEFAULT_ZOOM
lons = [float(c[0]) for c in coords if len(c) >= 2]
lats = [float(c[1]) for c in coords if len(c) >= 2]
if not lons or not lats:
return self.DEFAULT_ZOOM
lat_min, lat_max = min(lats), max(lats)
lon_min, lon_max = min(lons), max(lons)
for zoom in range(max_zoom, min_zoom - 1, -1):
x_lo, y_hi = self._latlon_to_pixel(lat_min, lon_min, zoom) # SW (small x, large y)
x_hi, y_lo = self._latlon_to_pixel(lat_max, lon_max, zoom) # NE (large x, small y)
if (x_hi - x_lo) <= self._BBOX_TARGET_W and (y_hi - y_lo) <= self._BBOX_TARGET_H:
return zoom
return min_zoom

def _resolve_circuit_geo(self, grand_prix_name: str, season: int) -> Optional[tuple[float, float, int]]:
"""Pull (lat, lon, zoom) for the circuit hosting this GP in this season.

Centre comes from the GeoJSON bbox midpoint (geometrically accurate),
zoom is computed from the bbox so a tight street circuit gets zoomed in
and a sprawling road course is zoomed out — automatically, no hand-tuning.

Falls back to Jolpica's circuit coordinate + f1-locations.json's hand-
tuned zoom when GeoJSON coords aren't available for this layout."""
races = self.ergast._fetch_season_schedule(season)
race = next((r for r in races if r["raceName"] == grand_prix_name), None)
if not race:
return None

layout = self.circuits.get_circuit_layout_by_ergast_data(grand_prix_name, season)
if layout:
try:
layout.load_geo_json_data()
coords = layout.coordinates
except Exception:
coords = None
if coords and len(coords) >= 2:
lons = [float(c[0]) for c in coords if len(c) >= 2]
lats = [float(c[1]) for c in coords if len(c) >= 2]
lat_c = (min(lats) + max(lats)) / 2
lon_c = (min(lons) + max(lons)) / 2
return lat_c, lon_c, self._zoom_from_bbox(coords)

# Fallback path: Jolpica coordinate + hand-tuned zoom
loc = race.get("Circuit", {}).get("Location", {})
if "lat" not in loc or "long" not in loc:
return None
lat, lon = float(loc["lat"]), float(loc["long"])
from ergast_service import slugify
entry = self._zoom_by_circuit.get(slugify(race["Circuit"].get("circuitName", "")))
zoom = entry["zoom"] if entry and "zoom" in entry else self.DEFAULT_ZOOM
return lat, lon, zoom

# ---------------------------------------------------------- track raster

def _rasterise_track(self, svg_path: Path, target_w: int = 700) -> Optional[Image.Image]:
try:
png_bytes = cairosvg.svg2png(
@@ -99,7 +375,12 @@ class PostersService:

# ------------------------------------------------------------------ race poster

def render_race_poster(self, grand_prix_name: str, season: int) -> Optional[bytes]:
def render_race_poster(
self,
grand_prix_name: str,
season: int,
bg: BackgroundStyle = BackgroundStyle.AERIAL,
) -> Optional[bytes]:
races = self.ergast._fetch_season_schedule(season)
race = next((r for r in races if r["raceName"] == grand_prix_name), None)
if not race:
@@ -114,12 +395,25 @@ class PostersService:
logger.error(f"Layout not resolved for {grand_prix_name} {season}")
return None

cache_key = f"{season}-{round_num:02d}-{layout.slug}.png"
cache_key = f"{season}-{round_num:02d}-{layout.slug}-{bg.value}.png"
cache_path = self.cache_dir / cache_key
if cache_path.exists():
return cache_path.read_bytes()

img = Image.new("RGB", (POSTER_W, POSTER_H), BG)
# Fetch map background if requested; fall back to flat black if anything fails.
map_bg: Optional[Image.Image] = None
if bg is not BackgroundStyle.NONE:
geo = self._resolve_circuit_geo(grand_prix_name, season)
if geo:
lat, lon, zoom = geo
map_bg = self._fetch_map_background(lat, lon, zoom, bg)

if map_bg is not None:
# Dim the underlying map so the white track + heading text stay legible.
img = Image.alpha_composite(map_bg.convert("RGBA"), self._race_dark_overlay()).convert("RGB")
else:
img = Image.new("RGB", (POSTER_W, POSTER_H), BG)

draw = ImageDraw.Draw(img)

# Top checker
@@ -136,16 +430,45 @@ class PostersService:
self._centered(draw, name_short, y=300, size=110, fill=WHITE)
self._centered(draw, "GRAND PRIX", y=440, size=44, fill=GREY)

# Track outline
svg_path = Path("circuits") / layout.relative_svg_filepath
if svg_path.exists():
track = self._rasterise_track(svg_path, target_w=720)
if track:
# Centre between y=560 and y=1250
slot_top, slot_bottom = 560, 1250
tx = (POSTER_W - track.width) // 2
ty = slot_top + ((slot_bottom - slot_top) - track.height) // 2
img.paste(track, (tx, ty), track)
# Track outline.
# When a map background is present we project the GeoJSON lat/lon points
# using the same zoom/centre we used for the tiles — that way the white
# outline is geographically aligned with the actual track in the photo.
# Without a map (bg=NONE) there's nothing to align with so we fall back
# to the hand-drawn SVG centred in the slot.
projected = False
if map_bg is not None:
try:
# GeoJSON is lazy-loaded — trigger the read before pulling coords.
layout.load_geo_json_data()
projected = self._draw_track_projected(
img, layout.coordinates, *self._resolve_circuit_geo(grand_prix_name, season)
)
except Exception as e:
logger.warning(f"GeoJSON track projection failed; falling back to SVG: {e}")

if not projected:
svg_path = Path("circuits") / layout.relative_svg_filepath
if svg_path.exists():
track = self._rasterise_track(svg_path, target_w=720)
if track:
slot_top, slot_bottom = 560, 1250
tx = (POSTER_W - track.width) // 2
ty = slot_top + ((slot_bottom - slot_top) - track.height) // 2
img.paste(track, (tx, ty), track)

# World locator inset (top-right) — picture-in-picture pointing at the
# exact race location on the globe. Only meaningful when a map bg is on.
# Sized + positioned so it tucks under the top checker and sits to the
# right of the centred FORMULA 1 wordmark without overlapping.
if bg is not BackgroundStyle.NONE:
geo = self._resolve_circuit_geo(grand_prix_name, season)
if geo:
lat, lon, _ = geo
locator = self._render_world_locator(lat, lon, size=140)
if locator:
margin = 25
img.paste(locator, (POSTER_W - locator.width - margin, CHECKER_H + margin))

# Date
if date:
@@ -160,20 +483,126 @@ class PostersService:
cache_path.write_bytes(data)
return data

# ------------------------------------------------------------------ kometa metadata

BASE_URL = "https://f1-circuits.zurag.be"

def render_kometa_metadata(self, first_season: int = 1950, last_offset: int = 1) -> str:
"""Emit the Kometa metadata YAML for the Formula 1 library, covering
every F1 season from `first_season` through current_year + last_offset.

Per show: f1_season builder + url_poster pointing at the season hero.
Per Plex season (== round): url_poster pointing at the race poster.
Kometa silently skips shows / seasons that don't exist in the library."""
from datetime import datetime
from urllib.parse import quote

last_season = datetime.utcnow().year + last_offset
lines: list[str] = [
"# Auto-generated by f1-circuits (/kometa/formula-1-metadata.yml).",
"# Do not commit — fetch via metadata_files.url in Kometa config.",
"metadata:",
]
for season in range(first_season, last_season + 1):
races = self.ergast._fetch_season_schedule(season)
lines.append(f' "Season {season}":')
lines.append(f" f1_season: {season}")
lines.append(" round_prefix: true")
lines.append(" shorten_gp: true")
lines.append(f" url_poster: {self.BASE_URL}/posters/season/{season}.png")
if not races:
continue
lines.append(" seasons:")
for race in races:
try:
round_num = int(race["round"])
name = race["raceName"]
except (KeyError, ValueError):
continue
race_url = f"{self.BASE_URL}/posters/race/{quote(name)}/{season}.png"
lines.append(f" {round_num}:")
lines.append(f" url_poster: {race_url}")
lines.append("")
return "\n".join(lines)

# ------------------------------------------------------------------ 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 _race_dark_overlay(self) -> Image.Image:
"""Dimming overlay for race posters when a map background is present.
Stronger at top (heading area) and middle (track), lighter elsewhere so
the underlying map detail still reads through."""
overlay = Image.new("RGBA", (POSTER_W, POSTER_H), (0, 0, 0, 0))
ovr = overlay.load()
for y in range(POSTER_H):
if y < 540:
# Strong dim under the FORMULA 1 / Round / GP-name block
alpha = 200
elif y < 1300:
# Mid-section behind the track — light dim
alpha = 110
else:
# Bottom (date strip + checker)
alpha = 200
for x in range(POSTER_W):
ovr[x, y] = (0, 0, 0, alpha)
return overlay

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:
cache_path = self.cache_dir / f"season-{season}.png"
if cache_path.exists():
return cache_path.read_bytes()

img = Image.new("RGB", (POSTER_W, POSTER_H), BG)
draw = ImageDraw.Draw(img)
bg = self._fetch_season_background(season)
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._centered(draw, "FORMULA 1", y=240, size=120, fill=RED)
self._centered(draw, str(season), y=440, size=420, fill=WHITE)
self._centered(draw, "WORLD CHAMPIONSHIP", y=1100, size=56, fill=GREY)
self._centered(draw, "FORMULA 1", y=110, size=88, fill=RED)
self._centered(draw, str(season), y=240, size=240, fill=WHITE)
self._centered(draw, "WORLD CHAMPIONSHIP", y=POSTER_H - 200, size=48, fill=WHITE)
self._checker_strip(draw, POSTER_H - CHECKER_H)

buf = io.BytesIO()


+ 18
- 16
service/Dockerfile 查看文件

@@ -1,24 +1,26 @@
FROM python:3.12-slim
FROM python:3.12-alpine

WORKDIR /app

# Poster rendering needs:
# - libcairo2 (cairosvg ↔ Cairo bindings) for SVG → PNG raster
# - fonts-dejavu-core for the DejaVuSans-Bold font used by PostersService
# These add ~15 MB to the image but keep poster generation in-process and fast.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libcairo2 \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
# Runtime deps:
# - cairo provides libcairo so cairosvg can rasterise SVG → PNG.
# - ttf-liberation gives Liberation Sans Bold at
# /usr/share/fonts/liberation/LiberationSans-Bold.ttf — exactly where Arch
# installs it, so dev (shanks) and prod (this container) render with the
# same font and the posters come out identical.
RUN apk add --no-cache \
cairo \
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 \
fastapi \
uvicorn \
fastf1 \
python-slugify \
pillow \
cairosvg
fastapi \
uvicorn \
python-slugify \
pillow \
cairosvg \
numpy

COPY cdn-api.py circuits_service.py ergast_service.py posters_service.py ./
COPY models/ ./models/


Loading…
取消
儲存