mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
On a wide panel Vegas mode spent much of its time showing black. At 50px/s on a 512px display, one display width of blank is 10.2 seconds, which makes several long-standing behaviours expensive: - ScrollHelper prepended a full display width of black as an "initial gap", charged once per cycle — 10.2s of black at the start of every rotation. - Plugins without get_vegas_content() are captured off a full-display canvas, so their blank margins entered the ticker too. Measured: of-the-day drew 35px of "No Data" on a 512px canvas (92% blank), youtube-stats 142px of content with 185px of black either side. Only the scroll_helper path had any trimming. - Cycle transitions deliberately pushed a blank frame and then recomposed synchronously: 84ms at best, 4.8s at worst, every millisecond of it black. - buffer_ahead doubled as the cycle size, so a 21-plugin install showed 3 plugins per cycle and took ~7 cycles to come around. - separator_width was applied between every image rather than at plugin boundaries, so a per-row ticker like the F1 scoreboard (116 images, which it renders 4px apart internally) got a 32px chasm between each row — and the width budget didn't count those gaps, so the plugin quietly occupied far more of the panel than intended. Changes: - src/vegas_mode/geometry.py: numpy column-ink primitives shared by the trimmer and the audit tool, so the number reported is the number acted on. A Python per-column loop over a 17,000px strip is far too slow for the render path. - PluginAdapter trims every content path, not just scroll_helper. Only outer edges are cropped: interior blank columns are the plugin's own layout (logo left, score right) and closing them would corrupt the design. A plugin on a non-black background is inherently unaffected. - ScrollHelper.create_scrolling_image takes an explicit lead_gap, still defaulting to display_width so the many standalone-ticker callers are unchanged. Vegas passes lead_in_width (default 0). - Cycle end holds the last rendered frame instead of blanking, turning the recompose into a brief freeze rather than the panel switching off. - plugins_per_cycle (default 6) is split from buffer_ahead, which goes back to being only a prefetch low-water mark. - max_plugin_width_ratio (default 3x display width) caps one plugin's share of a cycle. Overflow is deferred, not discarded: a rotation offset advances each fetch so later rows appear on subsequent cycles. Single oversized images are cropped at a blank column so the cut misses glyphs. - Composition groups images by plugin: rows are joined by intra_plugin_gap (default 8) and separator_width applies only between plugins. The width budget now counts those gaps. - Plugin data updates no longer run on the Vegas render path. All new settings are user-configurable in Display -> Vegas Scroll, including min/max cycle duration and dynamic duration, which previously existed in code but were reachable only by hand-editing config.json. Measured with scripts/dev/vegas_audit.py on a 512x64 panel: mean ink coverage 42.7% -> 69.4% fully blank 5.9% -> 0% reads as empty 13.6% -> 0% worst blank stretch 4.8s -> 0s full rotation 414s -> 123s plugins per cycle 3 -> 6 Note the metric choice: a "fully blank" scan (>=95% black viewport) reported only 0.4% and badly understated the problem, because two full-width segments with mid-canvas content never fully blank the viewport — they hold it at ~28%. window_coverage_stats grades every viewport position by how much ink it carries, which is what tracks perceived dead time. Known remaining: cycle transitions still freeze ~3.5s while the next cycle is fetched. Fixing that needs background prefetch, which is deferred because the fallback-capture path mutates the shared display_manager.image and racing it against the render loop risks torn frames. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
342 lines
12 KiB
Python
342 lines
12 KiB
Python
"""
|
|
Geometry primitives for Vegas Mode.
|
|
|
|
Pure, side-effect-free measurements over PIL images. Two consumers:
|
|
|
|
- ``PluginAdapter`` trims the blank margins plugins bake into their content
|
|
before it enters the ticker (see ``trim_to_content``).
|
|
- ``scripts/dev/vegas_audit.py`` reports how much of the composed ticker is
|
|
dead space (see ``dead_window_stats``).
|
|
|
|
Keeping both on the same primitives means the number the audit reports is the
|
|
number the trimmer acted on.
|
|
|
|
All column scans go through numpy: a Python-level per-column loop over a
|
|
17,000px-wide ticker image takes seconds, which is far too slow for the render
|
|
path.
|
|
"""
|
|
|
|
from typing import NamedTuple, Optional, Tuple
|
|
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
# A pixel counts as "ink" when any channel exceeds this. Chosen to ignore the
|
|
# 1-2/255 noise that JPEG-sourced logos and alpha compositing leave behind in
|
|
# nominally black areas, while still treating any deliberately drawn dark grey
|
|
# as real content.
|
|
DEFAULT_INK_THRESHOLD = 10
|
|
|
|
# A window counts as "dead" when this fraction of its columns carry no ink.
|
|
DEFAULT_DEAD_WINDOW_RATIO = 0.95
|
|
|
|
|
|
def column_has_ink(img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD) -> np.ndarray:
|
|
"""
|
|
Return a boolean array, one entry per image column, True where the column
|
|
contains at least one pixel brighter than ``threshold`` in any channel.
|
|
|
|
Args:
|
|
img: Image to scan (converted to RGB internally)
|
|
threshold: Per-channel value a pixel must exceed to count as ink
|
|
|
|
Returns:
|
|
Bool array of shape (width,)
|
|
"""
|
|
arr = np.asarray(img if img.mode == 'RGB' else img.convert('RGB'))
|
|
if arr.ndim != 3:
|
|
# Degenerate/empty image — treat every column as blank.
|
|
return np.zeros(img.width, dtype=bool)
|
|
# Collapse rows and channels: a column is ink if any pixel in it is bright.
|
|
return arr.max(axis=(0, 2)) > threshold
|
|
|
|
|
|
def content_bounds(
|
|
img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD
|
|
) -> Optional[Tuple[int, int]]:
|
|
"""
|
|
Find the first and last columns containing ink.
|
|
|
|
Args:
|
|
img: Image to measure
|
|
threshold: Ink threshold
|
|
|
|
Returns:
|
|
(first_col, last_col) inclusive, or None if the image is entirely blank
|
|
"""
|
|
ink = column_has_ink(img, threshold)
|
|
if not ink.any():
|
|
return None
|
|
first = int(ink.argmax())
|
|
last = len(ink) - 1 - int(ink[::-1].argmax())
|
|
return first, last
|
|
|
|
|
|
class TrimResult(NamedTuple):
|
|
"""Outcome of a ``trim_to_content`` call."""
|
|
|
|
image: Optional[Image.Image] # None when the source was entirely blank
|
|
original_width: int
|
|
trimmed_left: int
|
|
trimmed_right: int
|
|
|
|
@property
|
|
def is_blank(self) -> bool:
|
|
"""True when the source image carried no ink at all."""
|
|
return self.image is None
|
|
|
|
@property
|
|
def width(self) -> int:
|
|
"""Width after trimming (0 for a blank source)."""
|
|
return 0 if self.image is None else self.image.width
|
|
|
|
@property
|
|
def removed(self) -> int:
|
|
"""Total columns removed."""
|
|
return self.trimmed_left + self.trimmed_right
|
|
|
|
|
|
def trim_to_content(
|
|
img: Image.Image,
|
|
threshold: int = DEFAULT_INK_THRESHOLD,
|
|
padding: int = 0,
|
|
) -> TrimResult:
|
|
"""
|
|
Crop blank columns off the left and right edges of an image.
|
|
|
|
Only the outer edges are considered. Blank columns *between* two pieces of
|
|
content are deliberately preserved — those are the plugin's own layout
|
|
(e.g. a logo on the left and a score on the right), and closing them up
|
|
would corrupt the design rather than reclaim dead space.
|
|
|
|
A plugin drawing on a non-black background is unaffected: every column of a
|
|
filled background carries ink, so there is nothing to trim.
|
|
|
|
Args:
|
|
img: Image to trim
|
|
threshold: Ink threshold
|
|
padding: Columns of the original blank margin to keep on each side, as
|
|
breathing room. Capped at what the margin actually contains, so
|
|
this never widens the image beyond its original bounds.
|
|
|
|
Returns:
|
|
TrimResult. When the image is entirely blank, ``image`` is None and the
|
|
caller decides whether to skip the plugin.
|
|
"""
|
|
bounds = content_bounds(img, threshold)
|
|
if bounds is None:
|
|
return TrimResult(None, img.width, 0, 0)
|
|
|
|
first, last = bounds
|
|
pad = max(0, padding)
|
|
left = max(0, first - pad)
|
|
right = min(img.width, last + 1 + pad)
|
|
|
|
if left == 0 and right == img.width:
|
|
return TrimResult(img, img.width, 0, 0)
|
|
|
|
cropped = img.crop((left, 0, right, img.height))
|
|
return TrimResult(cropped, img.width, left, img.width - right)
|
|
|
|
|
|
def find_blank_cut(
|
|
img: Image.Image,
|
|
target: int,
|
|
search_radius: int,
|
|
threshold: int = DEFAULT_INK_THRESHOLD,
|
|
) -> int:
|
|
"""
|
|
Find a column near ``target`` that carries no ink, so an image can be cut
|
|
there without slicing through a glyph or logo.
|
|
|
|
Used when a single oversized segment has to be narrowed to fit a width
|
|
budget. Cutting at an arbitrary column would leave half a character
|
|
hanging at the panel edge; snapping to the nearest gap hides the cut.
|
|
|
|
Args:
|
|
img: Image to cut
|
|
target: Preferred cut column
|
|
search_radius: How far either side of ``target`` to look
|
|
threshold: Ink threshold
|
|
|
|
Returns:
|
|
A blank column within the search window, or ``target`` clamped to the
|
|
image bounds when the window contains no blank column at all.
|
|
"""
|
|
width = img.width
|
|
target = max(0, min(target, width))
|
|
if search_radius <= 0 or width == 0:
|
|
return target
|
|
|
|
ink = column_has_ink(img, threshold)
|
|
lo = max(0, target - search_radius)
|
|
hi = min(width - 1, target + search_radius)
|
|
|
|
# Walk outwards from target so the nearest gap wins.
|
|
for offset in range(0, search_radius + 1):
|
|
right = target + offset
|
|
if right <= hi and not ink[right]:
|
|
return right
|
|
left = target - offset
|
|
if left >= lo and not ink[left]:
|
|
return left
|
|
|
|
return target
|
|
|
|
|
|
class DeadWindowStats(NamedTuple):
|
|
"""How much of a composed ticker reads as blank to a viewer."""
|
|
|
|
total_windows: int
|
|
dead_windows: int
|
|
longest_dead_run: int # consecutive dead windows (i.e. scroll steps)
|
|
|
|
@property
|
|
def dead_ratio(self) -> float:
|
|
"""Fraction of viewport positions that are effectively blank."""
|
|
if self.total_windows <= 0:
|
|
return 0.0
|
|
return self.dead_windows / self.total_windows
|
|
|
|
|
|
def dead_window_stats(
|
|
img: Image.Image,
|
|
viewport_width: int,
|
|
threshold: int = DEFAULT_INK_THRESHOLD,
|
|
dead_ratio: float = DEFAULT_DEAD_WINDOW_RATIO,
|
|
step: int = 1,
|
|
) -> DeadWindowStats:
|
|
"""
|
|
Slide a viewport across a composed ticker image and count how many
|
|
positions are effectively blank.
|
|
|
|
This models what the viewer actually experiences: the ticker is only ever
|
|
seen ``viewport_width`` columns at a time, so a stretch of blank wider than
|
|
the viewport becomes a period where the panel looks switched off. Measuring
|
|
per-window rather than per-column is what makes the result correspond to
|
|
perceived dead time.
|
|
|
|
Args:
|
|
img: Composed ticker image
|
|
viewport_width: Display width in pixels
|
|
threshold: Ink threshold
|
|
dead_ratio: Fraction of blank columns for a window to count as dead
|
|
step: Column stride between sampled windows. 1 is exact; larger values
|
|
trade precision for speed on very wide images.
|
|
|
|
Returns:
|
|
DeadWindowStats. ``longest_dead_run`` is in units of ``step`` columns,
|
|
so multiply by ``step`` for pixels.
|
|
"""
|
|
if viewport_width <= 0 or img.width <= 0:
|
|
return DeadWindowStats(0, 0, 0)
|
|
|
|
ink = column_has_ink(img, threshold)
|
|
step = max(1, step)
|
|
|
|
# Prefix sum of ink counts lets each window be evaluated in constant time,
|
|
# instead of re-summing viewport_width columns per position.
|
|
prefix = np.concatenate(([0], np.cumsum(ink)))
|
|
|
|
# Only whole windows are sampled; a partial tail window would report
|
|
# artificially dead because it has fewer columns to draw ink from.
|
|
last_start = img.width - viewport_width
|
|
if last_start < 0:
|
|
# Image narrower than the viewport — evaluate it as a single window.
|
|
blank_cols = len(ink) - int(prefix[-1])
|
|
is_dead = blank_cols >= dead_ratio * len(ink)
|
|
return DeadWindowStats(1, 1 if is_dead else 0, 1 if is_dead else 0)
|
|
|
|
starts = np.arange(0, last_start + 1, step)
|
|
ink_counts = prefix[starts + viewport_width] - prefix[starts]
|
|
blank_counts = viewport_width - ink_counts
|
|
dead = blank_counts >= dead_ratio * viewport_width
|
|
|
|
longest = _longest_true_run(dead)
|
|
return DeadWindowStats(len(starts), int(dead.sum()), longest)
|
|
|
|
|
|
class CoverageStats(NamedTuple):
|
|
"""How well-filled the viewport stays as the ticker scrolls past."""
|
|
|
|
total_windows: int
|
|
mean_ink_ratio: float # average fraction of the viewport carrying ink
|
|
min_ink_ratio: float # worst viewport position in the cycle
|
|
sparse_windows: int # positions below the "looks empty" threshold
|
|
longest_sparse_run: int # consecutive sparse positions, in steps
|
|
|
|
@property
|
|
def sparse_ratio(self) -> float:
|
|
"""Fraction of viewport positions that read as near-empty."""
|
|
if self.total_windows <= 0:
|
|
return 0.0
|
|
return self.sparse_windows / self.total_windows
|
|
|
|
|
|
def window_coverage_stats(
|
|
img: Image.Image,
|
|
viewport_width: int,
|
|
threshold: int = DEFAULT_INK_THRESHOLD,
|
|
sparse_ink_ratio: float = 0.10,
|
|
step: int = 1,
|
|
) -> CoverageStats:
|
|
"""
|
|
Measure how full the viewport stays across a whole scroll cycle.
|
|
|
|
``dead_window_stats`` only catches viewport positions that are *entirely*
|
|
blank. That misses the more common complaint: a position holding one narrow
|
|
sliver of content at the very edge, with the other 90% black. Such a
|
|
position is not "dead" by that definition but still looks switched off.
|
|
This function grades every position by how much ink it carries, so
|
|
"there is always something to see" becomes measurable.
|
|
|
|
Args:
|
|
img: Composed ticker image
|
|
viewport_width: Display width in pixels
|
|
threshold: Ink threshold
|
|
sparse_ink_ratio: A position with less than this fraction of inked
|
|
columns counts as reading near-empty
|
|
step: Column stride between sampled positions
|
|
|
|
Returns:
|
|
CoverageStats
|
|
"""
|
|
if viewport_width <= 0 or img.width <= 0:
|
|
return CoverageStats(0, 0.0, 0.0, 0, 0)
|
|
|
|
ink = column_has_ink(img, threshold)
|
|
step = max(1, step)
|
|
prefix = np.concatenate(([0], np.cumsum(ink)))
|
|
|
|
last_start = img.width - viewport_width
|
|
if last_start < 0:
|
|
ratio = float(prefix[-1]) / viewport_width
|
|
sparse = ratio < sparse_ink_ratio
|
|
return CoverageStats(1, ratio, ratio, 1 if sparse else 0, 1 if sparse else 0)
|
|
|
|
starts = np.arange(0, last_start + 1, step)
|
|
ratios = (prefix[starts + viewport_width] - prefix[starts]) / viewport_width
|
|
sparse_flags = ratios < sparse_ink_ratio
|
|
|
|
return CoverageStats(
|
|
total_windows=len(starts),
|
|
mean_ink_ratio=float(ratios.mean()),
|
|
min_ink_ratio=float(ratios.min()),
|
|
sparse_windows=int(sparse_flags.sum()),
|
|
longest_sparse_run=_longest_true_run(sparse_flags),
|
|
)
|
|
|
|
|
|
def _longest_true_run(flags: np.ndarray) -> int:
|
|
"""Length of the longest consecutive run of True in a boolean array."""
|
|
if flags.size == 0 or not flags.any():
|
|
return 0
|
|
# Reset a running counter at every False by subtracting the cumulative max
|
|
# of the counter's value at the preceding False positions.
|
|
idx = np.arange(len(flags))
|
|
not_flag = ~flags
|
|
# For each position, the index of the most recent False at or before it.
|
|
last_false = np.maximum.accumulate(np.where(not_flag, idx, -1))
|
|
run_lengths = idx - last_false
|
|
return int(run_lengths[flags].max())
|