mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 06:45:09 +00:00
Three new hardware-free modules holding code the scoreboard plugins carry as identical copies (executable AST, docstrings stripped, checked across every carrying plugin at ledmatrix-plugins 30455671). The bodies are the plugins'; the changes are type annotations for the mypy ratchet, the colour helpers losing their leading underscore as public free functions, and two comments that described the plugins' files. - src/common/sports_celebration.py: SportsCelebrationMixin, the score/win takeover drawn by afl, football, hockey, nrl and soccer (_draw_celebration_layout and the palette, backdrop, scenery, confetti, crest and _fit_font steps, with their class constants), plus the colour helpers (logo_palette, lift_color, cap_luminance, mix_color, ...). Only the drawing: _start_celebration, _check_for_goal/_check_for_score, _check_for_win and display() differ between the plugins and stay there. - src/common/sports_fetch.py: SportsFetchMixin, the four SportsCore methods identical in all nine scoreboards: _fetch_season_directly, _background_fetches_espn_ranges, _needs_previous_day and _wants_live_odds, with _LOOKBACK_CUTOFF_HOUR and _LIVE_ODDS_LOOKAHEAD. _get_timezone, _extract_game_details and _fetch_data are as identical and stay behind, for the reasons sports_shared gives (a per-plugin import; the abstract contract); so does SportsUpcoming.__init__, since no src/common mixin has a constructor. - src/common/sports_card_wrappers.py: SportsCardWrappersMixin, the seventeen sports_card delegations the eight game renderers carry (15 in all eight, 2 in all but football, whose own versions override them). _schema_font_size/_resolve_font_size look identical but read each plugin's own _SCHEMA_PATH, so they stay. Each mixin has no __init__ and creates no attributes (the host contract is declared as annotations only), defines no name the mixins beside it define, and documents the attributes it reads; a host-contract test parses each and fails on an undocumented read. A method kept on a plugin's class wins over the mixin's. Tests: behaviour ported from the plugins' celebration, odds, lookback and date-range tests against stub hosts carrying exactly the contract, with crests drawn by the test (test_sports_celebration.py, test_sports_fetch.py, test_sports_card_wrappers.py), and test_sports_stage3_parity.py, which with LEDMATRIX_PLUGINS set compares every body with every plugin copy that is left (58 pass against the plugins today; a copy that is gone counts as adopted). All three modules are on the mypy ratchet, in src/common/README.md, the CHANGELOG's Unreleased section and SPORTS_UNIFICATION's module table. Nothing in core uses them yet. Full suite: the same 67 failing test ids as main (Windows-only), 77 more passing. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
781 lines
34 KiB
Python
781 lines
34 KiB
Python
"""How the scoreboards draw a score or win celebration.
|
|
|
|
Five scoreboards -- afl, football, hockey, nrl and soccer -- take over the
|
|
panel when a team scores or wins: a backdrop in the scoring team's colours
|
|
read off its crest, scenery for the kind of score, confetti, the headline and
|
|
the score with the scoring side's digits breathing. The drawing is identical
|
|
in all five ``sports.py`` copies (executable AST, docstrings stripped), and
|
|
so are the colour helpers it uses; they were copied here from
|
|
ledmatrix-plugins ``30455671`` (origin/main, 2026-09-29).
|
|
|
|
Only the drawing moved. What *arms* a celebration stays in each plugin,
|
|
because it differs: which scores count (``_check_for_goal`` /
|
|
``_check_for_score``, and nrl matches favourites by team id), the phrase and
|
|
the scenery (``_start_celebration``), and when a win fires
|
|
(``_check_for_win``). So does ``display()``, which decides whether the
|
|
takeover or the scorebug is on screen. A plugin hands this mixin a
|
|
celebration dict and it draws it.
|
|
|
|
The colour helpers are public free functions here (``logo_palette``,
|
|
``lift_color``, ``mix_color``, ...); in the plugins they were the same
|
|
functions with a leading underscore.
|
|
|
|
THE CELEBRATION DICT
|
|
--------------------
|
|
Built by the plugin's ``_start_celebration``. Read here: ``game`` (a
|
|
view-model dict; ``<side>_id``, ``<side>_abbr``, ``<side>_logo_path`` and
|
|
``<side>_logo_url`` for the crests, ``id`` for the confetti seed),
|
|
``scored_side`` (``"away"`` or ``"home"``), ``away_score``, ``home_score``,
|
|
``phrase``, ``started_at`` (a ``time.time()`` value) and ``motif``
|
|
(``"score"``, ``"kick"``, ``"touchdown"``, ``"net"`` or ``"win"``; anything
|
|
else draws the ``"score"`` diagonals). The drawing caches what it derives in
|
|
the same dict, under ``_palette``, ``_backdrop``, ``_confetti`` and
|
|
``_crests``, so each is worked out once per celebration.
|
|
|
|
WHAT A HOST MUST PROVIDE
|
|
------------------------
|
|
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
|
|
test in ``test/test_sports_celebration.py`` fails if a read is added without
|
|
being listed here. All five scoreboards' ``SportsLive`` provide them.
|
|
|
|
- ``display_manager`` -- ``image`` is replaced with the frame, then
|
|
``update_display()``; ``clear()`` on ``force_clear``. Its ``matrix``
|
|
width and height are used when it has a matrix, else ``display_width`` /
|
|
``display_height``.
|
|
- ``fonts`` -- ``"time"`` and ``"status"`` for the headline (the first that
|
|
fits), ``"score"`` for the score.
|
|
- ``logger``.
|
|
- ``_load_and_resize_logo(team_id, abbr, logo_path, logo_url)`` -- a crest
|
|
as an RGBA image, or ``None``.
|
|
- ``_draw_text_with_outline(draw, text, position, font, fill=...)`` -- on
|
|
``SportsCoreSharedMixin``.
|
|
- ``celebration_duration``, ``celebration_team_colors`` and
|
|
``celebration_confetti``, read with ``getattr`` (defaults 8, on, on).
|
|
|
|
Mix it in ahead of the mode classes, e.g.
|
|
``class SportsLive(SportsCelebrationMixin, SportsLiveSharedMixin,
|
|
SportsCore)``. It defines nothing any of them define, so the order only
|
|
matters for a plugin that keeps its own copy of one of these methods: a
|
|
method on the plugin's class always wins over the mixin's.
|
|
"""
|
|
|
|
import colorsys
|
|
import logging
|
|
import math
|
|
import random
|
|
import time
|
|
from typing import Any, Callable, ClassVar, Dict, List, Optional, Sequence, Tuple
|
|
|
|
from PIL import Image, ImageDraw
|
|
|
|
#: A colour as the helpers return it: three 0-255 channels.
|
|
Color = Tuple[int, ...]
|
|
#: ``deep``, ``glow``, ``headline`` and ``accent``; see ``logo_palette``.
|
|
Palette = Dict[str, Color]
|
|
#: One confetti flake: column, start height, fall speed, sway phase, size
|
|
#: in pixels, colour.
|
|
Flake = Tuple[float, float, float, float, int, Color]
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Colour helpers for the score/win celebration
|
|
#
|
|
# Module level rather than methods: they are pure, which is what makes the
|
|
# palette testable without standing up a live manager, and they are shared by
|
|
# the takeover's backdrop, confetti and text.
|
|
# ----------------------------------------------------------------------
|
|
|
|
#: The crest is sampled at this resolution. Big enough that a secondary
|
|
#: colour survives (a helmet stripe, a trim), small enough that the whole
|
|
#: sample is ~1600 pixels of pure-Python work, once per team.
|
|
_PALETTE_SAMPLE_PX = 40
|
|
#: Above this, a colour carries team identity; below it, it is a grey.
|
|
_PALETTE_VIVID_SATURATION = 0.22
|
|
#: Ignore pixels this dark -- crest outlines, drop shadows, anti-aliasing.
|
|
_PALETTE_MIN_CHANNEL = 24
|
|
#: How far apart two bins must be to count as a second, different colour.
|
|
_PALETTE_DISTINCT_DISTANCE = 90.0
|
|
#: Never bleed a lifted colour below this saturation; past it a hue stops
|
|
#: being the team's colour and starts being a pastel.
|
|
_PALETTE_MIN_SATURATION = 0.42
|
|
#: Lift a headline colour until it is at least this luminous. Chosen so
|
|
#: midnight navy reaches a blue that reads at 6px on a panel without
|
|
#: becoming a different colour.
|
|
_PALETTE_HEADLINE_LUMINANCE = 112.0
|
|
#: A crest colour this luminous already reads on a panel, so it is preferred
|
|
#: over a darker one that would have to be lifted to get there. Lifting is a
|
|
#: compromise -- Green Bay's dark green only reaches legibility as a teal --
|
|
#: and most teams whose primary is dark carry a bright second colour that is
|
|
#: just as much theirs. This is what picks the Packers' gold over that teal.
|
|
_PALETTE_LEGIBLE_LUMINANCE = 90.0
|
|
#: ...but only from a colour the crest actually means. The pixels where a
|
|
#: bright edge is anti-aliased into a dark fill are luminous too, and there is
|
|
#: always a band of them: Kansas City's white-on-red outline leaves a pink at
|
|
#: luminance 90 that would otherwise be preferred over the red itself. A blend
|
|
#: is a mix, so it is markedly less saturated than either colour it sits
|
|
#: between -- that pink is 0.48 where the red is 0.96 and the Packers' gold,
|
|
#: which this must keep, is 0.89.
|
|
_PALETTE_LEGIBLE_SATURATION = 0.65
|
|
#: And it has to be a band of the crest, not a speck of one.
|
|
_PALETTE_LEGIBLE_AREA = 0.02
|
|
#: Cap the backdrop's luminance so the headline stays legible over it,
|
|
#: and the scenery's so it stays behind the headline. Both are luminance and
|
|
#: not HSV value on purpose: a silver crest -- the Raiders, or the grey
|
|
#: placeholder a failed logo download leaves behind -- has a value of ~0.95,
|
|
#: and capping that at 0.34 still yields a light grey card that white text
|
|
#: then vanishes into. Scaling the channels down is also hue-exact, which is
|
|
#: what lets this be the plain arithmetic that lifting a colour cannot be.
|
|
_PALETTE_BACKDROP_LUMINANCE = 34.0
|
|
_PALETTE_SCENERY_LUMINANCE = 70.0
|
|
|
|
|
|
def rgb_luminance(color: Sequence[float]) -> float:
|
|
"""Rec. 709 relative luminance, 0-255."""
|
|
return 0.2126 * color[0] + 0.7152 * color[1] + 0.0722 * color[2]
|
|
|
|
|
|
def rgb_saturation(color: Sequence[float]) -> float:
|
|
"""HSV saturation, 0-1."""
|
|
high = max(color)
|
|
return (high - min(color)) / high if high else 0.0
|
|
|
|
|
|
def color_distance(a: Sequence[float], b: Sequence[float]) -> float:
|
|
"""Euclidean distance between two colours in RGB."""
|
|
return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b)))
|
|
|
|
|
|
def mix_color(a: Sequence[float], b: Sequence[float], t: float) -> Color:
|
|
"""Blend ``a`` towards ``b``; t=0 is all a, t=1 is all b."""
|
|
t = min(max(t, 0.0), 1.0)
|
|
return tuple(int(round(a[i] + (b[i] - a[i]) * t)) for i in range(3))
|
|
|
|
|
|
def scale_color(color: Sequence[float], factor: float) -> Color:
|
|
"""Scale a colour's brightness, clamped to the panel's range."""
|
|
return tuple(min(255, max(0, int(round(c * factor)))) for c in color)
|
|
|
|
|
|
def lift_color(color: Sequence[float], min_luminance: float = _PALETTE_HEADLINE_LUMINANCE,
|
|
cap_saturation: float = 0.92) -> Color:
|
|
"""Raise a colour's brightness until it reads on a panel, keeping its hue.
|
|
|
|
Scaling the channels directly is what the obvious version of this does,
|
|
and it shifts hue badly on exactly the colours that need lifting: it turns
|
|
Baltimore's navy-purple into magenta. Working in HSV and raising only the
|
|
value leaves the hue where the team put it.
|
|
"""
|
|
if rgb_luminance(color) >= min_luminance:
|
|
return tuple(int(c) for c in color)
|
|
hue, saturation, value = colorsys.rgb_to_hsv(*[c / 255.0 for c in color])
|
|
if saturation < 0.12:
|
|
# A grey or a silver has no hue to preserve; just make it bright.
|
|
lifted = colorsys.hsv_to_rgb(hue, saturation, max(value, 0.85))
|
|
return tuple(int(round(c * 255)) for c in lifted)
|
|
saturation = min(saturation, cap_saturation)
|
|
|
|
def _rgb(s: float, v: float) -> Color:
|
|
return tuple(int(round(c * 255)) for c in colorsys.hsv_to_rgb(hue, s, v))
|
|
|
|
out = _rgb(saturation, value)
|
|
while value < 1.0 and rgb_luminance(out) < min_luminance:
|
|
value = min(1.0, value + 0.05)
|
|
out = _rgb(saturation, value)
|
|
# Blue carries almost no luminance -- pure blue sits at 18 of 255 -- so a
|
|
# navy or a deep purple runs out of value long before it is legible.
|
|
# Bleeding saturation out of it is the only way up, and it keeps the hue
|
|
# (Baltimore stays purple, just a lighter one) where giving up would
|
|
# leave the headline unreadable. Floored so it never washes out to white.
|
|
while saturation > _PALETTE_MIN_SATURATION and rgb_luminance(out) < min_luminance:
|
|
saturation = max(_PALETTE_MIN_SATURATION, saturation - 0.05)
|
|
out = _rgb(saturation, value)
|
|
return out
|
|
|
|
|
|
def cap_luminance(color: Sequence[float], max_luminance: float) -> Color:
|
|
"""Darken a colour until it is no brighter than ``max_luminance``.
|
|
|
|
A straight channel scale, which is exactly hue-preserving on the way down
|
|
-- unlike lifting, where clamping at 255 is what bends the hue.
|
|
"""
|
|
luminance = rgb_luminance(color)
|
|
if luminance <= max_luminance or luminance <= 0:
|
|
return tuple(int(c) for c in color)
|
|
return scale_color(color, max_luminance / luminance)
|
|
|
|
|
|
def dim_rgba(image: Image.Image, factor: float) -> Image.Image:
|
|
"""Scale an RGBA image's colour channels, leaving its alpha alone.
|
|
|
|
ImageEnhance.Brightness would scale the alpha band too, which fades the
|
|
crest out instead of dimming it and leaves its anti-aliased edge looking
|
|
chewed against the backdrop.
|
|
"""
|
|
red, green, blue, alpha = image.split()
|
|
lut = [min(255, int(i * factor)) for i in range(256)]
|
|
return Image.merge(
|
|
"RGBA", (red.point(lut), green.point(lut), blue.point(lut), alpha)
|
|
)
|
|
|
|
|
|
_Buckets = Dict[Tuple[int, int, int], List[int]]
|
|
|
|
|
|
def _palette_buckets(logo: Image.Image) -> Tuple[_Buckets, _Buckets]:
|
|
"""Bucket a crest's opaque pixels into coarse colour bins.
|
|
|
|
Returns ``(vivid, neutral)``; each maps a 3-bit-per-channel key to
|
|
``[r_sum, g_sum, b_sum, count]``. Neutral holds the greys, silvers and
|
|
whites that carry no identity on their own but are all a monochrome crest
|
|
-- the Raiders' silver on black -- has to offer.
|
|
"""
|
|
sample = logo.convert("RGBA")
|
|
sample.thumbnail((_PALETTE_SAMPLE_PX, _PALETTE_SAMPLE_PX), Image.Resampling.BOX)
|
|
vivid: _Buckets = {}
|
|
neutral: _Buckets = {}
|
|
# tobytes() rather than getdata(): same pixels, no per-pixel Python
|
|
# object, and getdata() is deprecated from Pillow 14.
|
|
raw = sample.tobytes()
|
|
for i in range(0, len(raw) - 3, 4):
|
|
red, green, blue, alpha = raw[i], raw[i + 1], raw[i + 2], raw[i + 3]
|
|
if alpha < 160:
|
|
continue
|
|
high, low = max(red, green, blue), min(red, green, blue)
|
|
if high < _PALETTE_MIN_CHANNEL:
|
|
continue
|
|
target = vivid if (high - low) / high >= _PALETTE_VIVID_SATURATION else neutral
|
|
acc = target.setdefault((red >> 5, green >> 5, blue >> 5), [0, 0, 0, 0])
|
|
acc[0] += red
|
|
acc[1] += green
|
|
acc[2] += blue
|
|
acc[3] += 1
|
|
return vivid, neutral
|
|
|
|
|
|
def _bucket_mean(acc: List[int]) -> Color:
|
|
count = acc[3]
|
|
return (acc[0] // count, acc[1] // count, acc[2] // count)
|
|
|
|
|
|
def _bucket_headline_score(acc: List[int]) -> float:
|
|
"""How well a colour bin would serve as 6px of text on a panel.
|
|
|
|
Area alone picks the biggest block of colour, which on a lot of crests is
|
|
a dark navy fill -- correct as a backdrop, invisible as text. Weighting
|
|
area by saturation and by luminance picks the colour the team is loud in:
|
|
Chicago's orange over its navy, Baltimore's gold over its purple.
|
|
"""
|
|
color = _bucket_mean(acc)
|
|
return (
|
|
acc[3]
|
|
* (0.30 + 0.70 * rgb_saturation(color))
|
|
* (0.20 + 0.80 * min(1.0, rgb_luminance(color) / 120.0))
|
|
)
|
|
|
|
|
|
def logo_palette(logo: Image.Image) -> Optional[Palette]:
|
|
"""Pick a celebration palette out of a team crest, or None.
|
|
|
|
Two rankings, because a crest's largest colour and its most legible one
|
|
are usually not the same and the takeover needs both:
|
|
|
|
* ``deep`` -- the largest vivid area, darkened into the background wash.
|
|
This is what the team reads as at a glance: Chicago navy, Dallas navy,
|
|
Baltimore purple.
|
|
* ``headline`` -- the vivid area that best survives being shrunk to text,
|
|
then lifted until it is legible: Chicago orange, Baltimore gold.
|
|
* ``accent`` -- the next vivid colour far enough away from the headline to
|
|
be told apart, for confetti. Falls back to the headline.
|
|
|
|
A crest with no vivid pixels at all falls back to its brightest neutral,
|
|
which for the Raiders' silver-on-black is exactly the right answer.
|
|
"""
|
|
try:
|
|
vivid, neutral = _palette_buckets(logo)
|
|
except Exception: # noqa: BLE001 - a crest is never worth the takeover
|
|
return None
|
|
|
|
pool = list(vivid.values())
|
|
if not pool and neutral:
|
|
pool = [
|
|
max(
|
|
neutral.values(),
|
|
key=lambda acc: acc[3]
|
|
* (0.2 + 0.8 * min(1.0, rgb_luminance(_bucket_mean(acc)) / 160.0)),
|
|
)
|
|
]
|
|
if not pool:
|
|
return None
|
|
|
|
deep_base = _bucket_mean(max(pool, key=lambda acc: acc[3]))
|
|
ranked = sorted(pool, key=_bucket_headline_score, reverse=True)
|
|
headline_base = _bucket_mean(ranked[0])
|
|
vivid_pixels = sum(acc[3] for acc in pool)
|
|
for acc in ranked:
|
|
candidate = _bucket_mean(acc)
|
|
if (
|
|
rgb_luminance(candidate) >= _PALETTE_LEGIBLE_LUMINANCE
|
|
and rgb_saturation(candidate) >= _PALETTE_LEGIBLE_SATURATION
|
|
and acc[3] >= max(3, vivid_pixels * _PALETTE_LEGIBLE_AREA)
|
|
):
|
|
headline_base = candidate
|
|
break
|
|
headline = lift_color(headline_base)
|
|
|
|
accent = headline
|
|
for acc in ranked[1:]:
|
|
candidate = _bucket_mean(acc)
|
|
if color_distance(candidate, headline_base) > _PALETTE_DISTINCT_DISTANCE:
|
|
accent = lift_color(candidate)
|
|
break
|
|
|
|
deep = cap_luminance(deep_base, _PALETTE_BACKDROP_LUMINANCE)
|
|
return {
|
|
"deep": deep,
|
|
# Scenery is the backdrop carried a little way towards the headline:
|
|
# tied to the team's colours, and guaranteed to be visible even when
|
|
# the backdrop is nearly black.
|
|
"glow": cap_luminance(
|
|
mix_color(deep, headline, 0.22), _PALETTE_SCENERY_LUMINANCE
|
|
),
|
|
"headline": headline,
|
|
"accent": accent,
|
|
}
|
|
|
|
|
|
class SportsCelebrationMixin:
|
|
"""Draws a score/win celebration takeover. See the module docstring."""
|
|
|
|
# The host contract, declared for type checking only: these create no
|
|
# attributes, so the host's own values are what the methods read.
|
|
display_manager: Any
|
|
display_width: int
|
|
display_height: int
|
|
fonts: Dict[str, Any]
|
|
logger: logging.Logger
|
|
_load_and_resize_logo: Callable[..., Optional[Image.Image]]
|
|
_draw_text_with_outline: Callable[..., None]
|
|
|
|
def _fit_font(self, draw, text: str, max_width: int, fonts: list):
|
|
"""Return the first font whose rendered ``text`` fits ``max_width``,
|
|
falling back to the last (smallest) font."""
|
|
for font in fonts:
|
|
if draw.textlength(text, font=font) <= max_width - 2:
|
|
return font
|
|
return fonts[-1]
|
|
|
|
# ------------------------------------------------------------------
|
|
# Celebration palette
|
|
#
|
|
# The takeover is drawn in the scoring team's own colours, taken from the
|
|
# pixels of its crest.
|
|
#
|
|
# ESPN does serve team.color / team.alternateColor, but only inside
|
|
# _extract_game_details_common -- a function each scoreboard lineage
|
|
# keeps its own copy of -- so reading it there would drag every one of
|
|
# them into a celebration change. The crest is already downloaded,
|
|
# decoded and sitting in the logo cache by the time a celebration draws,
|
|
# so the colours come from it instead: no extra request, no per-league
|
|
# colour table to maintain, and it works for any team ESPN can name --
|
|
# including the FCS opponents no table would list.
|
|
#
|
|
# Where a crest's colour differs from the club's published one it
|
|
# tends to differ usefully: a published primary is often a near-black
|
|
# navy, or an actual #000000, where what the crest carries is the colour
|
|
# that reads on an LED panel. Measured across all 32 clubs in
|
|
# football-scoreboard.
|
|
# ------------------------------------------------------------------
|
|
|
|
#: Used when the crest yields nothing (no logo on disk yet, or the grey
|
|
#: placeholder a failed download leaves) or team colours are switched
|
|
#: off -- the navy and amber the celebration wore before it had a palette.
|
|
_DEFAULT_CELEBRATION_PALETTE: ClassVar[Palette] = {
|
|
"deep": (10, 10, 40),
|
|
"glow": (30, 30, 86),
|
|
"headline": (255, 208, 56),
|
|
"accent": (255, 255, 255),
|
|
}
|
|
|
|
def _celebration_palette(self, celebration: Dict) -> Palette:
|
|
"""The scoring team's colours, derived once per celebration."""
|
|
cached: Optional[Palette] = celebration.get("_palette")
|
|
if cached is not None:
|
|
return cached
|
|
|
|
palette = dict(self._DEFAULT_CELEBRATION_PALETTE)
|
|
if getattr(self, "celebration_team_colors", True):
|
|
try:
|
|
game = celebration["game"]
|
|
side = celebration.get("scored_side") or "home"
|
|
logo = self._load_and_resize_logo(
|
|
game.get("%s_id" % side),
|
|
game.get("%s_abbr" % side),
|
|
game.get("%s_logo_path" % side),
|
|
game.get("%s_logo_url" % side),
|
|
)
|
|
derived = logo_palette(logo) if logo is not None else None
|
|
if derived:
|
|
palette = derived
|
|
except Exception as e: # noqa: BLE001 - never lose a takeover to a crest
|
|
self.logger.debug(f"Celebration palette fell back to the default: {e}")
|
|
|
|
celebration["_palette"] = palette
|
|
return palette
|
|
|
|
# ------------------------------------------------------------------
|
|
# Celebration choreography
|
|
#
|
|
# Every frame is a finished card. The beats below shift the emphasis --
|
|
# an opening colour hit, confetti, a breathing score -- but none of them
|
|
# leaves the panel mid-wipe, because on a switch-mode board the core
|
|
# drives this plugin at 1 FPS (display_controller reserves its high-FPS
|
|
# loop for plugins that scroll or declare needs_high_fps), so any single
|
|
# frame may be the only one a viewer ever sees of it.
|
|
# ------------------------------------------------------------------
|
|
|
|
#: Fraction of the celebration spent on the opening colour hit.
|
|
_CELEBRATION_IMPACT: ClassVar[float] = 0.11
|
|
#: Fraction of it after which the takeover eases back down.
|
|
_CELEBRATION_SETTLE: ClassVar[float] = 0.80
|
|
#: Seconds per breath of the scoring side's digits. Deliberately a
|
|
#: continuous sine rather than an on/off toggle: the 4 Hz flash this
|
|
#: replaced was sampled once a second on a switch-mode board, which
|
|
#: aliases into a colour that changes at random. A ramp degrades into a
|
|
#: slow glow instead, and still reads as a pulse at 125 FPS.
|
|
_CELEBRATION_BREATH_SECONDS: ClassVar[float] = 1.7
|
|
|
|
def _celebration_backdrop(
|
|
self,
|
|
celebration: Dict,
|
|
width: int,
|
|
height: int,
|
|
palette: Palette,
|
|
) -> Image.Image:
|
|
"""The static half of the takeover: a team-colour gradient with the
|
|
scenery for this kind of score painted into it.
|
|
|
|
Built once per celebration per panel size and copied per frame, so the
|
|
per-pixel work never lands on the render path.
|
|
"""
|
|
cached: Optional[Tuple[Tuple[int, int], Image.Image]] = celebration.get("_backdrop")
|
|
if cached is not None and cached[0] == (width, height):
|
|
return cached[1]
|
|
|
|
# One column, then stretched: filling the panel pixel by pixel would
|
|
# be `width` times the work for the same image.
|
|
column = Image.new("RGB", (1, max(height, 1)))
|
|
pixels: Any = column.load()
|
|
for y in range(height):
|
|
k = y / max(height - 1, 1)
|
|
pixels[0, y] = mix_color(palette["deep"], (0, 0, 0), 0.18 + 0.82 * k)
|
|
backdrop = column.resize((width, height)).convert("RGBA")
|
|
|
|
try:
|
|
self._draw_celebration_motif(
|
|
ImageDraw.Draw(backdrop),
|
|
celebration.get("motif") or "score",
|
|
width,
|
|
height,
|
|
palette,
|
|
)
|
|
except Exception as e: # noqa: BLE001 - scenery is never worth a blank panel
|
|
self.logger.debug(f"Celebration motif skipped: {e}")
|
|
|
|
celebration["_backdrop"] = ((width, height), backdrop)
|
|
return backdrop
|
|
|
|
def _draw_celebration_motif(
|
|
self,
|
|
draw,
|
|
motif: str,
|
|
width: int,
|
|
height: int,
|
|
palette: Palette,
|
|
) -> None:
|
|
"""Paint the scenery for one kind of score, dim enough to stay behind
|
|
the headline and the score instead of competing with them."""
|
|
glow = palette["glow"]
|
|
if motif == "kick":
|
|
# The uprights a field goal or an extra point went through,
|
|
# spread wide enough to frame the score rather than sit beside it.
|
|
half = max(8, min(width // 3, height))
|
|
mid = width // 2
|
|
crossbar = int(height * 0.60)
|
|
draw.line([(mid - half, int(height * 0.08)), (mid - half, crossbar)], fill=glow)
|
|
draw.line([(mid + half, int(height * 0.08)), (mid + half, crossbar)], fill=glow)
|
|
draw.line([(mid - half, crossbar), (mid + half, crossbar)], fill=glow)
|
|
draw.line([(mid, crossbar), (mid, height - 1)], fill=glow)
|
|
elif motif == "touchdown":
|
|
# The goal line, with its hash marks.
|
|
line_y = int(height * 0.36)
|
|
draw.line([(0, line_y), (width, line_y)], fill=glow)
|
|
for x in range(3, width, 9):
|
|
draw.line([(x, line_y - 2), (x, line_y + 2)], fill=glow)
|
|
elif motif == "net":
|
|
# The goal a puck just went into: frame, posts and mesh, sized to
|
|
# frame the score the way the uprights do.
|
|
half = max(7, min(width // 4, height))
|
|
mid = width // 2
|
|
top = int(height * 0.34)
|
|
draw.rectangle([(mid - half, top), (mid + half, height - 1)], outline=glow)
|
|
step = max(3, (half * 2) // 6)
|
|
for x in range(mid - half + step, mid + half, step):
|
|
draw.line([(x, top + 1), (x, height - 2)], fill=glow)
|
|
for y in range(top + step, height - 1, step):
|
|
draw.line([(mid - half + 1, y), (mid + half - 1, y)], fill=glow)
|
|
elif motif == "win":
|
|
# A sunburst behind the winner.
|
|
cx, cy = width // 2, height // 2
|
|
reach = max(width, height)
|
|
for i in range(10):
|
|
angle = (math.pi * 2 * i / 10) + math.pi / 20
|
|
draw.line(
|
|
[
|
|
(cx, cy),
|
|
(cx + math.cos(angle) * reach, cy + math.sin(angle) * reach),
|
|
],
|
|
fill=glow,
|
|
)
|
|
else:
|
|
for x in range(-height, width + height, 11):
|
|
draw.line([(x, height), (x + height, 0)], fill=glow)
|
|
|
|
def _celebration_confetti(
|
|
self,
|
|
celebration: Dict,
|
|
width: int,
|
|
height: int,
|
|
palette: Palette,
|
|
) -> List[Flake]:
|
|
"""Seed the confetti once per celebration.
|
|
|
|
Seeded from the game rather than the clock, so the same score always
|
|
produces the same fall -- which is what lets a golden screen lock the
|
|
effect down instead of having to tolerate it.
|
|
"""
|
|
cached: Optional[Tuple[Tuple[int, int], List[Flake]]] = celebration.get("_confetti")
|
|
if cached is not None and cached[0] == (width, height):
|
|
return cached[1]
|
|
|
|
# Sparse on purpose. At one flake per 170 square pixels a 128x32
|
|
# panel carried 24 single-pixel specks over the headline and the
|
|
# score, which reads as a dead-pixel problem rather than as confetti.
|
|
count = max(6, min(22, (width * height) // 260))
|
|
seed = "%s/%s" % (
|
|
(celebration.get("game") or {}).get("id", "?"),
|
|
celebration.get("phrase", ""),
|
|
)
|
|
rng = random.Random(seed) # nosec B311 - confetti, not security
|
|
# Team colours, plus a pale tint of the headline rather than a flat
|
|
# white, so the fall still belongs to the team that scored.
|
|
colors = [
|
|
palette["headline"],
|
|
palette["accent"],
|
|
mix_color(palette["headline"], (255, 255, 255), 0.55),
|
|
]
|
|
flakes = [
|
|
(
|
|
float(rng.randrange(max(width, 1))), # column
|
|
rng.uniform(0.0, float(height)), # start height
|
|
rng.uniform(0.40, 1.15), # fall speed
|
|
rng.uniform(0.0, math.pi * 2), # sway phase
|
|
2 if rng.random() < 0.6 else 1, # size in pixels
|
|
colors[rng.randrange(len(colors))],
|
|
)
|
|
for _ in range(count)
|
|
]
|
|
celebration["_confetti"] = ((width, height), flakes)
|
|
return flakes
|
|
|
|
def _draw_celebration_confetti(
|
|
self,
|
|
draw,
|
|
celebration: Dict,
|
|
width: int,
|
|
height: int,
|
|
palette: Palette,
|
|
elapsed: float,
|
|
progress: float,
|
|
) -> None:
|
|
"""Draw the confetti for this instant, thinning it out as the
|
|
celebration eases back towards the scorebug."""
|
|
flakes = self._celebration_confetti(celebration, width, height, palette)
|
|
fade = 1.0
|
|
if progress > self._CELEBRATION_SETTLE:
|
|
fade = max(
|
|
0.0,
|
|
1.0
|
|
- (progress - self._CELEBRATION_SETTLE)
|
|
/ (1.0 - self._CELEBRATION_SETTLE),
|
|
)
|
|
if fade <= 0.02:
|
|
return
|
|
alpha = int(235 * fade)
|
|
for column, start, speed, phase, size, color in flakes:
|
|
y = (start + speed * elapsed * height * 0.42) % (height + 4) - 2
|
|
x = column + math.sin(elapsed * 2.1 + phase) * 2.4
|
|
draw.rectangle(
|
|
[(int(x), int(y)), (int(x) + size - 1, int(y) + size - 1)],
|
|
fill=tuple(color) + (alpha,),
|
|
)
|
|
|
|
def _celebration_crests(
|
|
self, celebration: Dict, height: int
|
|
) -> Dict[str, Optional[Image.Image]]:
|
|
"""The two crests for the takeover, with the side that did not score
|
|
dimmed so the scoring team reads at a glance."""
|
|
cached: Optional[Tuple[int, Dict[str, Optional[Image.Image]]]] = celebration.get("_crests")
|
|
if cached is not None and cached[0] == height:
|
|
return cached[1]
|
|
|
|
game = celebration["game"]
|
|
scored = celebration.get("scored_side")
|
|
crests: Dict[str, Optional[Image.Image]] = {}
|
|
for side in ("away", "home"):
|
|
logo = None
|
|
try:
|
|
logo = self._load_and_resize_logo(
|
|
game.get("%s_id" % side),
|
|
game.get("%s_abbr" % side),
|
|
game.get("%s_logo_path" % side),
|
|
game.get("%s_logo_url" % side),
|
|
)
|
|
except Exception as e: # noqa: BLE001 - a crest is never worth the panel
|
|
self.logger.debug(f"Celebration logo load failed: {e}")
|
|
if logo is not None and side != scored:
|
|
logo = dim_rgba(logo, 0.40)
|
|
crests[side] = logo
|
|
|
|
celebration["_crests"] = (height, crests)
|
|
return crests
|
|
|
|
def _draw_celebration_layout(self, celebration: Dict, force_clear: bool = False) -> None:
|
|
"""Render the full-screen goal/win takeover."""
|
|
if force_clear:
|
|
self.display_manager.clear()
|
|
|
|
display_width = (
|
|
self.display_manager.matrix.width
|
|
if hasattr(self.display_manager, "matrix") and self.display_manager.matrix
|
|
else self.display_width
|
|
)
|
|
display_height = (
|
|
self.display_manager.matrix.height
|
|
if hasattr(self.display_manager, "matrix") and self.display_manager.matrix
|
|
else self.display_height
|
|
)
|
|
|
|
elapsed = max(0.0, time.time() - celebration["started_at"])
|
|
# getattr throughout the render path: the golden-screen tests build a
|
|
# live manager through __new__ and set only what they draw with, and a
|
|
# celebration must never be lost to a missing knob.
|
|
duration = max(float(getattr(self, "celebration_duration", 8) or 8), 0.5)
|
|
progress = min(elapsed / duration, 1.0)
|
|
palette = self._celebration_palette(celebration)
|
|
|
|
main_img = self._celebration_backdrop(
|
|
celebration, display_width, display_height, palette
|
|
).copy()
|
|
|
|
# Crests at the edges, bleeding off as the scorebug's do.
|
|
crests = self._celebration_crests(celebration, display_height)
|
|
center_y = display_height // 2
|
|
home_logo, away_logo = crests.get("home"), crests.get("away")
|
|
if home_logo is not None:
|
|
main_img.paste(
|
|
home_logo,
|
|
(display_width - home_logo.width + 2, center_y - home_logo.height // 2),
|
|
home_logo,
|
|
)
|
|
if away_logo is not None:
|
|
main_img.paste(away_logo, (-2, center_y - away_logo.height // 2), away_logo)
|
|
|
|
# The opening hit: the team's headline colour washes the panel and
|
|
# decays out of it. Held below opaque so the outlined text drawn on
|
|
# top still reads in whichever frame happens to catch it.
|
|
impact = max(0.0, 1.0 - progress / self._CELEBRATION_IMPACT)
|
|
if impact > 0.0:
|
|
# Scaled by how colourful the team is. A saturated crest gets the
|
|
# full hit; a silver one -- the Raiders, or the grey placeholder a
|
|
# failed logo download leaves -- would otherwise wash the whole
|
|
# panel out to the same flat grey as its own headline colour.
|
|
punch = 0.45 + 0.55 * rgb_saturation(palette["headline"])
|
|
alpha = int(140 * punch * (impact ** 1.5))
|
|
if alpha > 0:
|
|
main_img = Image.alpha_composite(
|
|
main_img,
|
|
Image.new(
|
|
"RGBA",
|
|
(display_width, display_height),
|
|
tuple(palette["headline"]) + (alpha,),
|
|
),
|
|
)
|
|
|
|
overlay = Image.new("RGBA", (display_width, display_height), (0, 0, 0, 0))
|
|
draw = ImageDraw.Draw(overlay)
|
|
|
|
if getattr(self, "celebration_confetti", True):
|
|
try:
|
|
self._draw_celebration_confetti(
|
|
draw,
|
|
celebration,
|
|
display_width,
|
|
display_height,
|
|
palette,
|
|
elapsed,
|
|
progress,
|
|
)
|
|
except Exception as e: # noqa: BLE001
|
|
self.logger.debug(f"Celebration confetti skipped: {e}")
|
|
|
|
# Headline across the top, shrunk to fit the panel width, struck
|
|
# white on the opening hit and settling into the team's colour.
|
|
phrase = celebration["phrase"]
|
|
phrase_font = self._fit_font(
|
|
draw, phrase, display_width, [self.fonts["time"], self.fonts["status"]]
|
|
)
|
|
phrase_width = draw.textlength(phrase, font=phrase_font)
|
|
# Eased in by colour rather than by position. Sliding it down into
|
|
# place put the first frame at y=-3 with its top row cut off, and on a
|
|
# 1 FPS board that clipped frame can be the only one anyone sees.
|
|
self._draw_text_with_outline(
|
|
draw,
|
|
phrase,
|
|
((display_width - phrase_width) // 2, 1),
|
|
phrase_font,
|
|
fill=mix_color(palette["headline"], (255, 255, 255), impact),
|
|
)
|
|
|
|
# Score centred low, the scoring side's digits breathing in the team's
|
|
# headline colour so the change reads at a glance.
|
|
away_text = str(celebration["away_score"])
|
|
home_text = str(celebration["home_score"])
|
|
score_font = self.fonts["score"]
|
|
segments = [
|
|
(away_text, celebration["scored_side"] == "away"),
|
|
("-", False),
|
|
(home_text, celebration["scored_side"] == "home"),
|
|
]
|
|
total_width = sum(draw.textlength(seg, font=score_font) for seg, _ in segments)
|
|
breath = 0.72 + 0.28 * (
|
|
0.5
|
|
+ 0.5 * math.sin(2 * math.pi * elapsed / self._CELEBRATION_BREATH_SECONDS)
|
|
)
|
|
highlight = scale_color(palette["headline"], breath)
|
|
x = (display_width - total_width) // 2
|
|
# display_height - 14 was sized for the old fixed 8px score. #338
|
|
# scales the score with the panel (16px at 48 and 64 tall), which put
|
|
# the bottom of the digits off the panel. Lift it by the measured ink
|
|
# (+1 for the outline stroke) only when it would clip, so panels where
|
|
# it always fitted render exactly as before.
|
|
score_text = "".join(seg for seg, _ in segments)
|
|
ink_bottom = draw.textbbox((0, 0), score_text, font=score_font)[3]
|
|
y = min(display_height - 14, display_height - ink_bottom - 2)
|
|
for seg, is_highlight in segments:
|
|
color = highlight if is_highlight else (216, 216, 216)
|
|
self._draw_text_with_outline(draw, seg, (int(x), y), score_font, fill=color)
|
|
x += draw.textlength(seg, font=score_font)
|
|
|
|
main_img = Image.alpha_composite(main_img, overlay).convert("RGB")
|
|
self.display_manager.image = main_img
|
|
self.display_manager.update_display()
|