mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
* fix(display): tear down Vegas mode on controller cleanup DisplayController.cleanup() never called VegasModeCoordinator.cleanup(), so the Vegas teardown (stop, pipeline/stream reset, adapter cache drop) was unreachable. Call it before the display manager is cleaned up, and skip it when Vegas was never created. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(vegas): default max_cycle_duration to the documented 240s The template, the web UI help, CONFIG_REFERENCE and the controller all say 240, but the code defaulted to 600 in two places, so a config without the key ran Vegas iterations 2.5x longer than documented. from_config now falls back to the dataclass field defaults instead of repeating each one, so the two copies can no longer drift, and the controller's follower scroll-speed default reads VegasModeConfig's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(display): let run.py -d show display_manager's DEBUG output display_manager pinned its logger to INFO at import, overriding the root level, so debug mode never showed its DEBUG lines. Use get_logger() from src.logging_config like the rest of the core and leave the level to the logging setup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(display): run each startup validation check once StartupValidator.validate_all() ran twice at boot, before and after the plugin manager was created, so every config, cache, display and systemd-unit warning was logged twice. The second pass now runs only the plugin checks. Drop the commented-out raise_on_errors line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(vegas): one INFO line per plugin-list refresh StreamManager logged "=" * 60 banners and a line per plugin (INCLUDED, SKIPPED, FETCHING CONTENT, SEGMENT CREATED) at INFO on every refresh and fetch, i.e. at each cycle start and every 30s. Log one INFO summary of the rotation per refresh and move the per-plugin detail, the weighting breakdown and "no content this cycle" to DEBUG (the adapter still warns when every content path fails). Also drop the check/cross marks from the controller's log messages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(vegas): drop the per-iteration static-mode plugin scan run_iteration() rebuilt _static_mode_plugins on every iteration, asking every plugin for its display mode and logging the set at INFO, but nothing ever read it: static pauses are triggered by _check_static_plugin_trigger() from the next segment. Delete it, the coordinator's get_ordered_plugins() that only it used, and the write-only _static_pause_plugin / _static_pause_start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(vegas): remove the staging buffer that was never filled StreamManager and RenderPipeline carried a double-buffer design that nothing used: _staging_buffer was only ever cleared or swapped, so swap_buffers() never did anything and should_recompose()'s staging_count > 0 branch was dead, and _active_scroll_image, _staging_scroll_image, _is_rendering, _last_frame_time and _frame_interval were written but never read. Delete the machinery and rewrite the docstrings around what actually carries updates: _pending_updates, consumed by process_updates() in swap mode and invalidate_pending_updates() in continuous mode. should_recompose() no longer builds a buffer-status dict every frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(display): tidy the display controller without changing behaviour - Import VegasModeCoordinator locally instead of through module globals (there is no circular import to avoid). - Drop hasattr() checks on attributes PluginManager.__init__ always sets (plugin_executor, plugin_last_update, get_plugin_lock, run_scheduled_updates*, stop_update_worker) and the dead "older manager" fallbacks; keep the health_tracker None checks, now via _health_tracker(). - Extract _display_once() for the per-frame display call both render loops copied, _advance_on_demand() for the two on-demand rotations, _reset_on_demand_fields() for the error and clear paths, and _timezone() / _in_window() for the two schedule checks. - Remove always-true conditions and the unreachable non-plugin else branch in run(), and read _was_display_active / _last_published_mode / vegas_coordinator directly now that __init__ declares them. - Declare the follower render state in __init__, name its tuning constants, add _follower_sign(), and share the 90/s sync send interval with the render pipeline (SYNC_SEND_INTERVAL). - Delete history narration and the "Opt #N" labels, fix the comment that called _scroll_speed constant (hot reload updates it), and drop a startup timing log that measured nothing. - render_pipeline / plugin_adapter: read display_manager.width/height as the properties they are, drop an empty TYPE_CHECKING block, an aliased threading import and a duplicated `if result and self.sync_manager:`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(display): trim dead code from display_manager - Add _new_canvas() for the image/draw/fontmode="1" setup that was copied six times. - Call resolve_double_sided() and compose_pixel_mapper_config() directly instead of through a module alias and a passthrough method, and replace the comment that said the passthrough read class attributes. - Delete the unused _initialized flag and _ORIENTATION_ROTATE_DEGREES alias (no core or monorepo reader; tests stop resetting the flag), the test pattern's unreachable no-matrix branch (it only runs once the matrix exists), `del old_image # help GC` (a no-op on a local), a duplicated early return in process_deferred_updates, and stale comments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(vegas): remove unread fields and test-only helpers, fix docstrings - ContentSegment: drop total_width, fetched_at, is_stale, image_count and is_static, none of which is read. - StreamManager: drop _current_index (never advanced) and the test-only get_all_content_for_composition() and has_pending_updates(); VegasModeConfig: drop the test-only is_plugin_included(). - geometry.find_blank_cut() has had no production caller since the crop moved to item boundaries; delete it and its tests. - PluginAdapter: the _finalize docstring described separator_width between every image, and _crop_to_budget's said cuts snap to the nearest blank column; both now describe what the code does. - Coordinator: the static-pause interrupt log no longer blames follower mode for every interrupt, and set_update_callback names the callback the controller actually wires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(scroll): correct ScrollHelper comments and drop dead branches - Four comments said the strip always starts with display_width of blank; it does only when lead_gap is None (Vegas passes its own). - Delete the "Width calculation mismatch" warning: the image is created at the calculated width, so the two can never differ. - Remove the two scroll_delay <= 0 fallbacks (which disagreed with each other): set_scroll_delay clamps it to at least 0.001 and nothing in core or the plugin monorepo assigns it directly. - Trim the scipy history from the blend docstring. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(run): drop a redundant comment Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(display): log set_scrolling_state only when it changes Vegas and scrolling plugins set the scrolling state every frame, so once display_manager's DEBUG output became visible in debug mode it printed "Scrolling state set to: True" about 120 times a second. Log only when the value differs from the previous one; the state, activity timestamp and frame hold still update on every call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): display-vegas Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
388 lines
14 KiB
Python
388 lines
14 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 List, 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 edge_blank(
|
|
img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD
|
|
) -> Tuple[int, int]:
|
|
"""
|
|
Blank column counts at the left and right edges of an image.
|
|
|
|
Used to space items by *measured* separation rather than a flat added gap.
|
|
A fixed gap gets this wrong in both directions at once: card-style content
|
|
drawn flush to its own edges ends up nearly touching its neighbour, while
|
|
content that already carries wide margins gets pushed even further apart.
|
|
|
|
Args:
|
|
img: Image to measure
|
|
threshold: Ink threshold
|
|
|
|
Returns:
|
|
(left_blank, right_blank). For an entirely blank image both are the
|
|
full width, since there is no ink to be close to.
|
|
"""
|
|
bounds = content_bounds(img, threshold)
|
|
if bounds is None:
|
|
return img.width, img.width
|
|
first, last = bounds
|
|
return first, img.width - 1 - last
|
|
|
|
|
|
def separation_gap(
|
|
left_img: Image.Image,
|
|
right_img: Image.Image,
|
|
target: int,
|
|
minimum: int = 0,
|
|
threshold: int = DEFAULT_INK_THRESHOLD,
|
|
) -> int:
|
|
"""
|
|
Columns to insert between two images so their ink is ``target`` apart.
|
|
|
|
Only the shortfall is added: if the two images already carry enough blank
|
|
at the facing edges, nothing (beyond ``minimum``) is inserted.
|
|
|
|
Args:
|
|
left_img: Image on the left
|
|
right_img: Image on the right
|
|
target: Desired blank columns between the two pieces of ink
|
|
minimum: Floor applied regardless of what the images already have
|
|
threshold: Ink threshold
|
|
|
|
Returns:
|
|
Number of columns to insert, never negative
|
|
"""
|
|
existing = edge_blank(left_img, threshold)[1] + edge_blank(right_img, threshold)[0]
|
|
return max(minimum, target - existing, 0)
|
|
|
|
|
|
def blank_runs(
|
|
img: Image.Image,
|
|
min_run: int,
|
|
threshold: int = DEFAULT_INK_THRESHOLD,
|
|
) -> List[Tuple[int, int]]:
|
|
"""
|
|
Find maximal runs of blank columns at least ``min_run`` wide.
|
|
|
|
Distinguishes item boundaries from letter spacing. Measured on real
|
|
rendered text, the gaps *between characters* are a single column, while the
|
|
gaps a plugin puts *between items* are 8px and up (the stocks ticker uses
|
|
32px, baseball 48px). Treating any blank column as a cut point therefore
|
|
slices words in half; requiring a run excludes letter spacing.
|
|
|
|
Args:
|
|
img: Image to scan
|
|
min_run: Minimum consecutive blank columns to qualify
|
|
threshold: Ink threshold
|
|
|
|
Returns:
|
|
List of (start, end) half-open column ranges, in left-to-right order
|
|
"""
|
|
blank = ~column_has_ink(img, threshold)
|
|
if not blank.any():
|
|
return []
|
|
|
|
# Vectorised run detection: pad with False so runs touching either edge get
|
|
# a boundary, then read starts and ends off the first difference. A Python
|
|
# loop here would be far too slow on a 17,000px ticker strip.
|
|
padded = np.concatenate(([False], blank, [False]))
|
|
diff = np.diff(padded.astype(np.int8))
|
|
starts = np.flatnonzero(diff == 1)
|
|
ends = np.flatnonzero(diff == -1)
|
|
|
|
long_enough = (ends - starts) >= max(1, min_run)
|
|
return list(zip(starts[long_enough].tolist(), ends[long_enough].tolist()))
|
|
|
|
|
|
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())
|