mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-03 01:38:06 +00:00
* Vegas mode: reclaim dead space and pace the rotation 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 * Drop unused Optional import from the vegas audit script Flagged by Codacy (F401). Any, Dict and List are all still used. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Align Vegas API bounds with validate(), fix audit config plumbing Both from review feedback on #423. The web API's accepted ranges disagreed with VegasModeConfig.validate(), which is what actually gates Vegas starting: scroll_speed 1-100 -> 1-200 (a slider value of 150 returned 400) separator_width 0-500 -> 0-128 target_fps 1-200 -> 30-200 buffer_ahead 1-20 -> 1-5 The three loose ones were the dangerous direction: the value saved with a 200, then VegasModeCoordinator.start() failed validation with only a log line, so the ticker silently never ran. The UI already matched validate() in all four cases, so the API was the odd one out. test_vegas_api_bounds_match_validate parses the numeric_fields map out of api_v3 and asserts every bound against validate(), plus that validate() accepts both endpoints and rejects just outside them, so these cannot drift apart again. That test immediately caught a missing upper bound on min_plugin_width, now added — unbounded it would drop every segment and leave a blank ticker. Separately, vegas_audit.py constructed PluginAdapter without the config, so it fell back to VegasModeConfig() defaults and would report trimming and width-budget behaviour that differed from the user's config.json. It now passes the loaded config exactly as the coordinator does. This is the same class of drift the explicit lead_gap and grouping arguments already guard against. Output is unchanged on a rig whose config matches the defaults. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Vegas mode: render plugins narrower, space rows by measured separation Trimming reclaims blank margins but cannot compact a layout that genuinely spans the display — a five-column forecast, a progress bar drawn at 100% width, a stat block with the panel's whole width between its elements. Those need the plugin to make different layout decisions, which means telling it the screen is narrower while it renders. DisplayManager.render_size() presents a smaller logical canvas for the duration of a Vegas content fetch, reusing the same _LogicalMatrix indirection double-sided mode already relies on so plugins see a consistent size from every accessor. Plugins that size themselves from matrix.width need no changes at all; one that wants to be explicit can read the new BasePlugin.get_vegas_render_width(). Width is a percentage so a single setting travels across panel sizes: vegas_scroll.render_width_pct globally, or vegas_width_pct in an individual plugin's config. Measured on a 512x64 panel with real data: ledmatrix-weather 1536px -> 576px (forecast becomes narrow cards) youtube-stats 353px -> 199px (2% blank left, so genuinely compact) geochron 453px -> 153px (ink density rises to 100%) ledmatrix-flights 950px -> 740px The youtube-stats figure is the clearest evidence the layout itself changed rather than being cropped: at full width the content had to be trimmed from 512px to 353px, whereas at 40% it arrives with almost no blank to reclaim. Row spacing is now measured rather than added. A flat gap gets it wrong in both directions at once — content drawn flush to its own edges ends up nearly touching (reported for recent sports scores, which sat 8px apart), while content already carrying wide margins gets pushed even further out. separation_gap() measures the blank each pair already has and adds only the shortfall, up to min_content_separation (default 24). intra_plugin_gap stays as a floor applied regardless. Two tests shipped in the previous commit encoded the old flat-gap arithmetic and are updated to the measured semantics, including one renamed to reflect that zero intra_plugin_gap alone no longer butts rows together. Also fixes a real bug found while testing: the harness display manager had no render_size(), and because the adapter catches broadly that surfaced as "no content" rather than an error, silently dropping five plugins. Added the context to VisualTestDisplayManager for parity, and _render_at() now degrades to a no-op on any display manager lacking it, so a third-party or older harness loses the narrowing rather than the content. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Vegas mode: end cycles before the wrap, keep the width budget honest Three fixes, the first a regression from lead_in_width defaulting to 0. get_visible_portion wraps: once scroll_position + display_width passes the end of the strip it fills the right of the frame from the *head* of the same strip. So the final display_width of travel showed the cycle's first plugin re-entering on the right while its last plugin exited on the left, and the recompose that followed replaced both at once. On a 512px panel at 50px/s that was 10.2s of two plugins on screen at once, ending in a hard cut — reported as the ticker "switching mid-scroll" from F1 to news. That used to be invisible because the strip began with a full display_width of blank, so the wrapped-in region was black. Removing that blank (it was 10s of dead panel per cycle) exposed the wrap. Cycles now end one display width earlier, before any wrapped content appears, clamped for strips no wider than the display so they don't complete instantly and spin the recompose loop. Verified on hardware: a 3936px strip now completes at 68.5s, exactly (3936 - 512) / 50. Second, auto_trim=False also skipped the width budget, which is an unrelated concern — turning off margin cropping should not let one plugin hold the panel for minutes. Seen in the field: the F1 scoreboard contributed 116 images and 14,848px untouched, giving a 33,821px cycle (11 minutes of content). The budget now applies regardless of trimming; with it restored that cycle is 6,362px. Third, the budget accounted for row gaps using the flat intra_plugin_gap while the compositor had moved to measured separation, so it under-counted by up to (min_content_separation - intra_plugin_gap) per row and a many-row plugin overran its cap. Both now use the same separation_gap() rule, and a test asserts the composed block fits the budget end to end rather than trusting the two paths to agree. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Fix IndexError in find_blank_cut when the cut lands on the image edge A cut position after the last column is legitimate — _crop_to_budget asks for min(start + budget, img.width), which equals the width whenever the remaining strip is shorter than the budget. find_blank_cut clamped target to width but then walked leftwards starting at target itself, so ink[width] raised IndexError. Caught on hardware: it killed the ledmatrix-stocks fetch, and because _fetch_plugin_content catches broadly that surfaced as the plugin silently contributing nothing for the cycle. Only reachable on the second or later pass of the rotating window over a single oversized image, which is why the existing tests missed it — they all exercised the first pass, where start is 0 and start + budget is comfortably inside the image. Added TestRotationAcrossMultipleCycles, which walks the window round several times and asserts content is never lost, plus direct coverage of find_blank_cut at and beyond the image edge. Both bounds now stop at width - 1 so neither direction can index past the end. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Only cut oversized segments at real gaps between items The width-budget crop snapped to the nearest blank column, and in rendered text the gap between two characters is a single column. So a cut routinely landed inside a word: the cycle showed "Wednesda" and the orphaned "y" turned up as a lone floating letter in the next cycle, positioned after whatever plugin happened to precede it. Measured on the clock-simple segment to confirm: its blank runs are [1, 1, 1, 1, 1, 8, 8] — five single-column letter gaps, every one of which find_blank_cut would happily have chosen. Cuts now only land in a run of at least min_cut_gap blank columns (default 6), which excludes letter spacing while still finding the gaps plugins put between items (the stocks ticker uses 32px, baseball 48px). Where no boundary falls inside the budget the cut waits for the next one and overruns, because splitting an item is worse than a slightly long segment. Continuous content is treated differently on purpose: an image with no internal gaps is a map or a chart, where any column is as good as another, so it is still cut to the budget exactly. The gap rule protects discrete items; letting a solid image escape the cap in its name would be wrong. blank_runs() is vectorised — 48ms for a 17,000px strip, against seconds for a per-column Python loop. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Hold capture_mode for every plugin render, not just narrowed ones The native content path only entered capture_mode when it was also narrowing the canvas, so at full width — which is every plugin without a vegas_width_pct override, i.e. most of them — a plugin calling update_display() while building its Vegas content wrote straight to the hardware. That is a visible flash mid-scroll, and it lines up with the flash reported at cycle transitions, when several plugins are fetched back to back. Suppression is now unconditional; the narrowing context stays separate because it is already a no-op at full width. Both contexts are reached through helpers that degrade to nullcontext when the display manager lacks them. That matters more than it looks: the adapter's handlers are deliberately broad, so an AttributeError from a missing context does not surface as an error — it surfaces as the plugin contributing nothing. Making the call unconditional without this turned 44 tests red for exactly that reason, all of them reporting lost content rather than the real cause. The test double now provides capture_mode and render_size too, so tests exercise the real contexts instead of silently taking the degraded path. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Vegas mode: one continuous strip instead of swapping cycles A cycle used to be a discrete strip that got replaced: motion stopped, every pixel was substituted at once, and the next group started with the viewport already full. That is the freeze, the flash and the jump. The strip is now extended rather than replaced. ScrollHelper gains append_content(), which adds items on the right without touching scroll_position or total_distance_scrolled, so motion continues and the next group simply arrives from the right. Because completion is measured against total_scroll_width, extending also defers completion — there is no longer a cycle boundary to see. drop_scrolled_prefix() reclaims what has gone past, keeping the strip bounded however long Vegas runs (observed 5,000-11,000px against an unbounded strip otherwise). It shifts total_distance_scrolled and total_scroll_width together so the completion arithmetic is unchanged, and refuses to run while the viewport is wrapping: wrapping reads the head of the strip into the right of the frame, so trimming the head there would visibly change the picture. A test caught that. Groups are prepared off the render thread. The constraint is that the canvas and the matrix proxy are process-wide mutable state, so narrowing or capturing through them from another thread would corrupt the frame the render loop is pushing. get_content() therefore takes offscreen_only: the background thread uses only paths that avoid the canvas, and anything needing it is marked and picked up on the render thread. That puts the expensive work (native renders of leaderboard and baseball cards, seconds each) in the background and leaves the cheap work (display capture, 40-600ms) in the foreground. DisplayManager's capture flag is now thread-local. As a shared flag, a background capture would have suppressed the render loop's own frame pushes for its duration, freezing the panel precisely when the point was to avoid a freeze. Canvas-bound plugins are drained one at a time rather than as a batch: six at once held the render thread for 1.75s. Drains are also spaced by two seconds while the lookahead is healthy, since taking them back to back turns one long stall into a run of short ones. When the strip is genuinely running short the throttle is ignored, because content matters more than smoothness there. Measured on hardware: zero cycle-complete swaps, drains landing 2-4s apart, lookahead holding at 1,200-3,500px, no errors. Set continuous_scroll false to restore the swap behaviour; the old path is intact. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Pace the Vegas frame loop adaptively: 31.5 -> 78.7 fps The loop slept a fixed frame_interval on top of however long the frame took, so at a measured 31.6ms per frame a flat 8ms of that was pure idle — a quarter of the budget spent not rendering. It now sleeps only the remainder of the budget. Measured on hardware: 31.5 fps to 78.7 fps sustained, with CPU going *down* from 150% to 127%. Scroll speed is unchanged at 49.9px/s against a configured 50, because motion is derived from elapsed time rather than frame count — this buys smoothness, not speed. Worth recording what the bottleneck was not: the per-frame render path measures 0.34ms in total (0.18ms for the numpy slice, 0.17ms for the dirty-tracking digest), which is a theoretical 2900 fps. Optimising any of that would have been wasted effort. The frame was idle, not busy. Also nices the prefetch thread. Its work is PIL and numpy that releases the GIL, so the scheduler can act on the priority, and without it the prefetch competes for the same cores as the render loop. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Sub-pixel scrolling: motion at the frame rate, not the pixel rate With integer positioning the number of distinct frames per second equals the scroll speed in px/s, however fast the loop renders. Measured at 50px/s and 78.7fps, 36% of frames were byte-identical: the extra frames cost work and bought no motion, and what was left was 50 discrete 1px steps a second. Two things were wrong with the pre-existing sub-pixel support. get_visible_portion never consulted sub_pixel_scrolling — it always took the integer path, so the flag and _get_visible_portion_subpixel were dead code. And that implementation needed scipy.ndimage.shift, which is not installed on the target devices (HAS_SCIPY is False there), so it would not have interpolated even if reached. Verified both: positions 1000.0 and 1000.5 produced identical frames either way. Blending is now wired up and implemented with numpy. Two details make it affordable: slice cached_array directly instead of building two PIL images only to convert them straight back (the naive version measured 15x the integer path), and use fixed-point uint16 multiply-add rather than float32, which suits the Pi's cores and gives finer weighting than the panel can resolve. Result 0.939ms against 0.237ms — 0.70ms added per frame, a 1065fps ceiling. Measured on hardware: 81.2 fps with blending on, against 78.7 with it off, so no cost within noise — and every frame is now a distinct position rather than one in three being a repeat. The trade is a slight horizontal softening of text, since each frame blends two positions. Set smooth_scroll false for maximum crispness. Also benchmarked and cleared as non-issues: extending the strip costs 9.4ms on an 11,000px strip and trimming 2.5ms, both under one frame at this rate. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Add overflow handling: keep ordered content whole instead of rotating a window The width budget split any oversized plugin by advancing a window each cycle. That is right for interchangeable items — news headlines, odds, stock prices — but wrong for ordered content: a league table showed ranks 1-6, then resumed at 7 two rotations later, which reads as out of order and out of context. Nobody needs rank 23 in a ticker; they need the top of the table, every time. overflow_mode chooses between them: rotate — advance a window each cycle so everything is seen eventually (unchanged default) truncate — always show the start and drop the rest, keeping ordered content coherent. Records no window state, so every pass starts at the top. Per-plugin vegas_overflow overrides the global setting, since one install has both kinds of plugin. Also adds per-plugin vegas_max_width_screens, so content that must stay whole can be given more room — or uncapped with 0 — without lifting the cap on every ticker. Applied on the test rig: f1-scoreboard and ledmatrix-leaderboard set to truncate, and baseball given 4.5 screens because it was showing 8 of 9 games when the whole slate needed only a little more room. Verified: F1 now reports "the first 10 of 116 ... the rest are not shown", baseball has dropped out of the budget log entirely, and stocks, odds-ticker and stock-news still rotate. Also corrects the crop log, which claimed "window advances next cycle" unconditionally and so misreported truncated crops. A test now pins the behaviour behind the message: truncate must leave no offset recorded. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Stop Vegas mode showing last night's games as if they were live A game that was live in the evening was still being drawn as live the next morning. Two faults combined to freeze plugin visuals indefinitely. PR #291 added a call to plugin_adapter.invalidate_plugin_scroll_cache() so a plugin's own cached scroll image would be rebuilt from fresh data. That method was never implemented. hot_swap_content() wraps the call in a broad except, so every hot swap has raised AttributeError and been swallowed silently ever since — which is why the visuals it was meant to keep fresh never were. Continuous scrolling then removed the only path that reached it at all: should_recompose() and hot_swap_content() are called from the non-continuous branch of run_frame(), and continuous_scroll defaults to True. So on a default install the pending-update flags were set by the update tick, never consumed, and grew without bound. Together these froze content completely, because refetching is not enough on its own: the sports plugins' get_vegas_content() regenerates only "if the cache is empty", so take_next_group() kept receiving the same picture however often it asked. Fixed by: - Implementing invalidate_plugin_scroll_cache(). It covers both layouts — a helper directly on the plugin (stocks, news, odds-ticker) and one owned by a scroll-display manager (the sports scoreboards, which is the shape that produced this bug) — and clears cached_image and cached_array together, since the array is the image's numpy mirror. - Adding StreamManager.invalidate_pending_updates() and calling it from the continuous branch. It only drops the caches; the plugin recomposes when it next comes round in the rotation. process_updates() is wrong here: it refetches synchronously and merges into the active buffer that continuous mode bypasses, and hot_swap_content() rebuilds and repositions the whole strip, which is the freeze-and-jump this mode exists to avoid. Tests assert the fix rather than the implementation: 14 of the 17 new tests fail without it. Includes the wiring itself, since the regression was a call that was simply absent, and a check that the scroll position is untouched so this cannot regress into the swap's visible jump. All Vegas suites pass (355 tests). test_display_controller_vegas_tick.py still cannot be collected off-device for want of rgbmatrix, identically with and without this change. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Fix two CodeRabbit-flagged test assertions in vegas density tests test_prepared_group_is_used_without_refetching had a tautological final assertion; now checks stream.calls directly. test_no_partial_letter_at_either_edge required both crop edges to be blank, but the left edge here is always the crop's start position with no lead-in gap in word_strip, so it legitimately carries ink — only the right edge is an actual cut and needs the check. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --------- Co-authored-by: Claude <noreply@anthropic.com>
1070 lines
49 KiB
Python
1070 lines
49 KiB
Python
"""
|
||
Scroll Helper
|
||
|
||
Handles scrolling text and image content for LED matrix displays.
|
||
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||
|
||
Features:
|
||
- Pre-rendered scrolling image caching with numpy array optimization
|
||
- Fast numpy-based image slicing for high-performance scrolling (100+ FPS)
|
||
- Scroll position management with wrap-around
|
||
- Dynamic duration calculation based on content width
|
||
- Frame rate tracking and logging
|
||
- Scrolling state management integration with display_manager
|
||
- Support for both continuous and bounded scrolling modes
|
||
- Pre-allocated buffers to minimize memory allocations
|
||
"""
|
||
|
||
import logging
|
||
import time
|
||
from typing import Optional, Dict, Any
|
||
from PIL import Image
|
||
import numpy as np
|
||
|
||
# Try to import scipy for sub-pixel interpolation, fallback to simpler method if not available
|
||
try:
|
||
from scipy.ndimage import shift
|
||
HAS_SCIPY = True
|
||
except ImportError:
|
||
HAS_SCIPY = False
|
||
|
||
|
||
class ScrollHelper:
|
||
"""
|
||
Helper class for scrolling text and image content on LED displays.
|
||
|
||
Provides functionality for:
|
||
- Creating and caching scrolling images (with numpy array optimization)
|
||
- Fast numpy-based image slicing for high-performance scrolling
|
||
- Managing scroll position with wrap-around
|
||
- Calculating dynamic display duration
|
||
- Frame rate tracking and performance monitoring
|
||
- Integration with display manager scrolling state
|
||
- Pre-allocated buffers for minimal memory allocations
|
||
|
||
Performance optimizations:
|
||
- Uses numpy arrays for fast array slicing instead of PIL crop operations
|
||
- Pre-computes numpy array from PIL image to avoid repeated conversions
|
||
- Reuses pre-allocated frame buffer to minimize allocations
|
||
- Optimized for 100+ FPS scrolling performance
|
||
"""
|
||
|
||
def __init__(self, display_width: int, display_height: int,
|
||
logger: Optional[logging.Logger] = None):
|
||
"""
|
||
Initialize the ScrollHelper.
|
||
|
||
Args:
|
||
display_width: Width of the LED matrix display
|
||
display_height: Height of the LED matrix display
|
||
logger: Optional logger instance
|
||
"""
|
||
self.display_width = display_width
|
||
self.display_height = display_height
|
||
self.logger = logger or logging.getLogger(__name__)
|
||
|
||
# Scrolling state
|
||
self.scroll_position = 0.0
|
||
self.total_distance_scrolled = 0.0 # Track total distance including wrap-arounds
|
||
self.scroll_speed = 1.0
|
||
self.scroll_delay = 0.001 # Minimal delay for high FPS (1ms)
|
||
self.cached_image: Optional[Image.Image] = None
|
||
self.cached_array: Optional[np.ndarray] = None # Numpy array cache for fast operations
|
||
self.total_scroll_width = 0
|
||
|
||
# Pre-allocated buffer for output frame (reused to avoid allocations)
|
||
self._frame_buffer: Optional[np.ndarray] = None
|
||
|
||
# Sub-pixel scrolling settings (disabled - using high FPS integer scrolling instead)
|
||
self.sub_pixel_scrolling = False # Disabled - use high frame rate for smoothness
|
||
self._last_integer_position = 0 # Cache for integer position to avoid repeated calculations
|
||
|
||
# Frame-based scrolling settings
|
||
self.frame_based_scrolling = False # If True, use scroll_delay to throttle and move scroll_speed pixels
|
||
self.last_step_time = 0.0 # Track last step time for frame-based throttling
|
||
|
||
# Time tracking for scroll updates
|
||
self.last_update_time: Optional[float] = None
|
||
|
||
# High FPS settings
|
||
self.target_fps = 120 # Target 120 FPS for smooth scrolling
|
||
self.frame_time_target = 1.0 / self.target_fps
|
||
|
||
# Dynamic duration settings
|
||
self.dynamic_duration_enabled = True
|
||
self.min_duration = 30
|
||
self.max_duration = 300
|
||
self.duration_buffer = 0.1
|
||
self.calculated_duration = 60
|
||
self.scroll_start_time: Optional[float] = None
|
||
self.last_progress_log_time: Optional[float] = None
|
||
self.progress_log_interval = 5.0 # seconds
|
||
|
||
# Frame rate tracking
|
||
self.frame_count = 0
|
||
self.last_frame_time = time.time()
|
||
self.last_fps_log_time = time.time()
|
||
self.frame_times = []
|
||
|
||
# Scrolling state management
|
||
self.is_scrolling = False
|
||
self.scroll_complete = False
|
||
|
||
def create_scrolling_image(self, content_items: list,
|
||
item_gap: int = 32,
|
||
element_gap: int = 16,
|
||
lead_gap: Optional[int] = None) -> Image.Image:
|
||
"""
|
||
Create a wide image containing all content items for scrolling.
|
||
|
||
Args:
|
||
content_items: List of PIL Images to include in scroll
|
||
item_gap: Gap between different items
|
||
element_gap: Gap between elements within an item
|
||
lead_gap: Blank columns before the first item. Defaults to a full
|
||
display width, which makes a standalone ticker scroll in from
|
||
off-screen. Callers that loop many plugins back-to-back (Vegas
|
||
mode) pass a smaller value, since a full display width of black
|
||
reads as the panel being switched off at the start of every
|
||
cycle.
|
||
|
||
Returns:
|
||
PIL Image containing all content arranged horizontally
|
||
"""
|
||
if lead_gap is None:
|
||
lead_gap = self.display_width
|
||
lead_gap = max(0, int(lead_gap))
|
||
if not content_items:
|
||
# Create empty image if no content
|
||
# Still set total_scroll_width to 0 to indicate no scrollable content
|
||
self.total_scroll_width = 0
|
||
self.cached_image = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0))
|
||
self.cached_array = np.array(self.cached_image)
|
||
self.scroll_position = 0.0
|
||
self.total_distance_scrolled = 0.0
|
||
self.scroll_complete = False
|
||
return self.cached_image
|
||
|
||
# Calculate total width needed
|
||
# Sum of all item widths
|
||
total_width = sum(img.width for img in content_items)
|
||
# Add item gaps between items (not after last item)
|
||
total_width += item_gap * (len(content_items) - 1)
|
||
# Add element_gap after each item (matches positioning logic)
|
||
total_width += element_gap * len(content_items)
|
||
|
||
# Add initial gap before first item
|
||
total_width += lead_gap
|
||
|
||
# Create the full scrolling image
|
||
full_image = Image.new('RGB', (total_width, self.display_height), (0, 0, 0))
|
||
|
||
# Position items
|
||
current_x = lead_gap # Start with initial gap
|
||
|
||
for i, img in enumerate(content_items):
|
||
# Paste the item image
|
||
full_image.paste(img, (current_x, 0))
|
||
current_x += img.width + element_gap
|
||
|
||
# Add gap between items (except after last item)
|
||
if i < len(content_items) - 1:
|
||
current_x += item_gap
|
||
|
||
# Store the image and update scroll width
|
||
self.cached_image = full_image
|
||
# Convert to numpy array for fast operations
|
||
self.cached_array = np.array(full_image)
|
||
|
||
# Use actual image width instead of calculated width to ensure accuracy
|
||
# This fixes cases where width calculation doesn't match actual positioning
|
||
actual_image_width = full_image.width
|
||
self.total_scroll_width = actual_image_width
|
||
|
||
# Log if there's a mismatch (indicating a bug in width calculation)
|
||
if actual_image_width != total_width:
|
||
self.logger.warning(
|
||
"Width calculation mismatch: calculated=%dpx, actual=%dpx (diff=%dpx). "
|
||
"Using actual width for scroll calculations.",
|
||
total_width, actual_image_width, abs(actual_image_width - total_width)
|
||
)
|
||
|
||
self.scroll_position = 0.0
|
||
self.total_distance_scrolled = 0.0
|
||
self.scroll_complete = False
|
||
|
||
# Pre-allocate frame buffer if needed
|
||
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
||
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
||
|
||
# Calculate dynamic duration
|
||
self._calculate_dynamic_duration()
|
||
now = time.time()
|
||
self.scroll_start_time = now
|
||
self.last_progress_log_time = now
|
||
self.logger.info(
|
||
"Dynamic duration target set to %ds (min=%ds, max=%ds, buffer=%.2f)",
|
||
self.calculated_duration,
|
||
self.min_duration,
|
||
self.max_duration,
|
||
self.duration_buffer,
|
||
)
|
||
|
||
self.logger.info(
|
||
"Created scrolling image: %dx%dpx (total_scroll_width=%dpx, %d items, item_gap=%d, element_gap=%d)",
|
||
actual_image_width, self.display_height, self.total_scroll_width,
|
||
len(content_items), item_gap, element_gap
|
||
)
|
||
return full_image
|
||
|
||
def update_scroll_position(self) -> None:
|
||
"""
|
||
Update scroll position with high FPS control and handle wrap-around.
|
||
"""
|
||
if not self.cached_image:
|
||
return
|
||
|
||
# Calculate frame time for consistent scroll speed regardless of FPS
|
||
current_time = time.time()
|
||
if self.last_update_time is None:
|
||
self.last_update_time = current_time
|
||
|
||
delta_time = current_time - self.last_update_time
|
||
self.last_update_time = current_time
|
||
|
||
if self.scroll_start_time is None:
|
||
self.scroll_start_time = current_time
|
||
self.last_progress_log_time = current_time
|
||
|
||
# Update scroll position
|
||
if self.frame_based_scrolling:
|
||
# Frame-based: move fixed amount when scroll_delay has passed
|
||
# This matches stock ticker behavior: move pixels, then wait scroll_delay
|
||
# Initialize last_step_time on first call to prevent huge initial jump
|
||
if self.last_step_time == 0.0:
|
||
self.last_step_time = current_time
|
||
|
||
# Check if scroll_delay has passed
|
||
time_since_last_step = current_time - self.last_step_time
|
||
if time_since_last_step >= self.scroll_delay:
|
||
# Move pixels (can move multiple steps if lag occurred, but cap to prevent huge jumps)
|
||
steps = int(time_since_last_step / self.scroll_delay)
|
||
# Cap at reasonable number to prevent huge jumps from lag
|
||
max_steps = max(1, int(0.04 / self.scroll_delay)) # Limit to 0.04s (2 steps at 50 FPS) for smoother scrolling
|
||
steps = min(steps, max_steps)
|
||
pixels_to_move = self.scroll_speed * steps
|
||
# Update last_step_time, preserving fractional delay for smooth timing
|
||
self.last_step_time = current_time - (time_since_last_step % self.scroll_delay)
|
||
else:
|
||
pixels_to_move = 0.0
|
||
else:
|
||
# Time-based: move based on time delta (correct speed over time)
|
||
# scroll_speed is pixels per second
|
||
pixels_to_move = self.scroll_speed * delta_time
|
||
|
||
self.scroll_position += pixels_to_move
|
||
self.total_distance_scrolled += pixels_to_move
|
||
|
||
# Calculate required total distance: total_scroll_width only.
|
||
# The image already includes display_width pixels of blank padding at the start
|
||
# (added by create_scrolling_image), so once scroll_position reaches
|
||
# total_scroll_width the last card has fully scrolled off the left edge.
|
||
# Adding display_width here would cause 1-2 extra wrap-arounds on wide chains.
|
||
required_total_distance = self.total_scroll_width
|
||
|
||
# Guard: zero-width content has nothing to scroll — keep position at 0 and skip
|
||
# completion/wrap logic to avoid producing an invalid -1 position.
|
||
if required_total_distance == 0:
|
||
self.scroll_position = 0
|
||
return
|
||
|
||
# Check completion FIRST (before wrap-around) to prevent visual loop
|
||
# When dynamic duration is enabled and cycle is complete, stop at end instead of wrapping
|
||
is_complete = self.total_distance_scrolled >= required_total_distance
|
||
|
||
if is_complete:
|
||
# Only log completion once to avoid spam
|
||
if not self.scroll_complete:
|
||
elapsed = current_time - (self.scroll_start_time or current_time)
|
||
scroll_percent = (self.total_distance_scrolled / required_total_distance * 100) if required_total_distance > 0 else 0.0
|
||
position_percent = (self.scroll_position / self.total_scroll_width * 100) if self.total_scroll_width > 0 else 0.0
|
||
self.logger.info(
|
||
"Scroll cycle COMPLETE: scrolled %.0f/%d px (%.1f%%, position=%.0f/%.0f px, %.1f%%) - elapsed %.2fs, target %.2fs",
|
||
self.total_distance_scrolled,
|
||
required_total_distance,
|
||
scroll_percent,
|
||
self.scroll_position,
|
||
self.total_scroll_width,
|
||
position_percent,
|
||
elapsed,
|
||
self.calculated_duration,
|
||
)
|
||
self.scroll_complete = True
|
||
|
||
# Clamp position to prevent wrap when complete
|
||
if self.scroll_position >= self.total_scroll_width:
|
||
self.scroll_position = self.total_scroll_width - 1
|
||
self.logger.debug("Clamped scroll position to %d (max=%d)", self.scroll_position, self.total_scroll_width - 1)
|
||
else:
|
||
self.scroll_complete = False
|
||
|
||
# Only wrap-around if cycle is not complete yet
|
||
if self.scroll_position >= self.total_scroll_width:
|
||
elapsed = current_time - self.scroll_start_time
|
||
self.scroll_position = self.scroll_position - self.total_scroll_width
|
||
self.logger.info(
|
||
"Scroll wrap-around detected: position reset, total_distance=%.0f/%d px (elapsed %.2fs, target %.2fs)",
|
||
self.total_distance_scrolled,
|
||
required_total_distance,
|
||
elapsed,
|
||
self.calculated_duration,
|
||
)
|
||
|
||
if (
|
||
self.dynamic_duration_enabled
|
||
and self.last_progress_log_time is not None
|
||
and current_time - self.last_progress_log_time >= self.progress_log_interval
|
||
):
|
||
elapsed_time = current_time - (self.scroll_start_time or current_time)
|
||
# The image already includes display_width padding, so we only need total_scroll_width
|
||
required_total_distance = self.total_scroll_width
|
||
self.logger.info(
|
||
"Scroll progress: elapsed=%.2fs, target=%.2fs, total_scrolled=%.0f/%d px (%.1f%%)",
|
||
elapsed_time,
|
||
self.calculated_duration,
|
||
self.total_distance_scrolled,
|
||
required_total_distance,
|
||
(self.total_distance_scrolled / required_total_distance * 100) if required_total_distance > 0 else 0.0,
|
||
)
|
||
self.last_progress_log_time = current_time
|
||
|
||
def get_visible_portion(self) -> Optional[Image.Image]:
|
||
"""
|
||
Get the currently visible portion of the scrolling image using fast numpy operations.
|
||
Uses integer pixel positioning for high-performance scrolling.
|
||
|
||
Returns:
|
||
PIL Image showing the visible portion, or None if no cached image
|
||
"""
|
||
if not self.cached_image or self.cached_array is None:
|
||
return None
|
||
|
||
start_x_int = int(self.scroll_position)
|
||
end_x_int = start_x_int + self.display_width
|
||
|
||
# Integer positioning quantises motion to whole pixels, so the number of
|
||
# distinct frames per second equals the scroll speed in px/s, no matter
|
||
# how fast the loop renders. At 50px/s and 78fps that made 36% of frames
|
||
# identical: the extra frames cost work and bought nothing. Blending
|
||
# between the two neighbouring positions gives motion at the frame rate
|
||
# instead of the step rate.
|
||
if self.sub_pixel_scrolling:
|
||
fractional = self.scroll_position - start_x_int
|
||
if fractional > 0.0:
|
||
return self._blend_visible_portion(start_x_int, fractional)
|
||
|
||
return self._get_visible_portion_integer(start_x_int, end_x_int)
|
||
|
||
def _blend_visible_portion(self, start_x: int, fractional: float) -> Image.Image:
|
||
"""
|
||
Linear blend between the frames at ``start_x`` and ``start_x + 1``.
|
||
|
||
Implemented with numpy rather than scipy.ndimage.shift: scipy is not
|
||
installed on the target devices (HAS_SCIPY is False there), which is why
|
||
the pre-existing sub-pixel path was dead code — get_visible_portion never
|
||
consulted the flag, and the scipy fallback would not have interpolated
|
||
anyway.
|
||
|
||
Args:
|
||
start_x: Left column of the earlier of the two frames
|
||
fractional: How far between the two, in [0, 1)
|
||
|
||
Returns:
|
||
The blended frame
|
||
"""
|
||
width = self.display_width
|
||
strip_width = self.cached_array.shape[1]
|
||
|
||
if start_x + width + 1 <= strip_width:
|
||
# Slice the backing array directly. Going via
|
||
# _get_visible_portion_integer would build two PIL images only for
|
||
# them to be converted straight back to arrays, which measured 15x
|
||
# the cost of the integer path.
|
||
near = self.cached_array[:, start_x:start_x + width]
|
||
far = self.cached_array[:, start_x + 1:start_x + 1 + width]
|
||
else:
|
||
# Close enough to the end that one of the slices wraps; let the
|
||
# integer path handle that and pay the conversion. Continuous mode
|
||
# extends the strip before reaching here, so this is the rare case.
|
||
near = np.asarray(
|
||
self._get_visible_portion_integer(start_x, start_x + width))
|
||
far = np.asarray(
|
||
self._get_visible_portion_integer(start_x + 1, start_x + 1 + width))
|
||
|
||
# Fixed-point rather than float32: integer multiply-add on uint16 is
|
||
# markedly faster than float maths on the Pi's ARM cores, and 8 bits of
|
||
# weight is finer than the panel can show.
|
||
weight = int(fractional * 256.0)
|
||
blended = (
|
||
(near.astype(np.uint16) * (256 - weight)
|
||
+ far.astype(np.uint16) * weight) >> 8
|
||
).astype(np.uint8)
|
||
|
||
return Image.frombytes(
|
||
'RGB', (width, self.display_height),
|
||
np.ascontiguousarray(blended).tobytes()
|
||
)
|
||
|
||
def _get_visible_portion_integer(self, start_x: int, end_x: int) -> Image.Image:
|
||
"""Fast integer pixel extraction (no interpolation).
|
||
|
||
Uses Image.frombytes instead of Image.fromarray: frombytes skips
|
||
numpy's array-protocol overhead and is ~50% faster for the display-sized
|
||
slices (128×32 = 12 KB) used here.
|
||
"""
|
||
_size = (self.display_width, self.display_height)
|
||
img_w = self.cached_image.width
|
||
|
||
if end_x <= img_w:
|
||
# Normal case: single contiguous slice (fastest path)
|
||
frame_array = np.ascontiguousarray(self.cached_array[:, start_x:end_x])
|
||
return Image.frombytes('RGB', _size, frame_array.tobytes())
|
||
else:
|
||
# Ensure frame buffer is allocated for all non-simple paths
|
||
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
||
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
||
|
||
width1 = img_w - start_x
|
||
if width1 > 0:
|
||
# Wrap-around: tail of image + head of image
|
||
self._frame_buffer[:, :width1] = self.cached_array[:, start_x:]
|
||
remaining_width = self.display_width - width1
|
||
self._frame_buffer[:, width1:] = self.cached_array[:, :remaining_width]
|
||
else:
|
||
# Edge case: start_x at or past image end — show from beginning,
|
||
# clamped to available width (scroll_position should wrap before
|
||
# reaching this state in normal operation).
|
||
available = min(self.display_width, img_w)
|
||
self._frame_buffer[:, :available] = self.cached_array[:, :available]
|
||
if available < self.display_width:
|
||
self._frame_buffer[:, available:] = 0
|
||
|
||
return Image.frombytes('RGB', _size, self._frame_buffer.tobytes())
|
||
|
||
def _get_visible_portion_subpixel(self, start_x_int: int, fractional: float) -> Image.Image:
|
||
"""
|
||
Get visible portion with sub-pixel interpolation for smooth scrolling.
|
||
Uses bilinear interpolation to blend between pixels.
|
||
"""
|
||
# We need to extract a region that's 1 pixel wider to allow for interpolation
|
||
start_x = start_x_int
|
||
end_x = start_x_int + self.display_width + 1
|
||
|
||
# Check if we need wrap-around
|
||
if end_x <= self.cached_image.width:
|
||
# Normal case: extract region with 1 extra pixel for interpolation
|
||
source_region = self.cached_array[:, start_x:end_x]
|
||
|
||
# Use bilinear interpolation for sub-pixel shifting
|
||
if HAS_SCIPY:
|
||
# Use scipy for high-quality sub-pixel shifting
|
||
shifted = shift(source_region, (0, -fractional, 0), mode='nearest', order=1, prefilter=False)
|
||
# Extract the display_width portion
|
||
frame_array = shifted[:, :self.display_width].astype(np.uint8)
|
||
else:
|
||
# Fallback: simple linear interpolation using numpy
|
||
# Blend between current and next pixel based on fractional part
|
||
frame_array = self._interpolate_subpixel(source_region, fractional)
|
||
|
||
return Image.fromarray(frame_array)
|
||
else:
|
||
# Wrap-around case with sub-pixel
|
||
# Use pre-allocated buffer
|
||
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
||
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
||
|
||
width1 = self.cached_image.width - start_x
|
||
if width1 > 0:
|
||
# First part from end of image
|
||
# Need width1 + 1 pixels for interpolation
|
||
source1_width = min(width1 + 1, self.cached_image.width - start_x)
|
||
source1 = self.cached_array[:, start_x:start_x + source1_width]
|
||
if HAS_SCIPY:
|
||
shifted1 = shift(source1, (0, -fractional, 0), mode='nearest', order=1, prefilter=False)
|
||
# Ensure we get exactly width1 pixels, padding if necessary
|
||
if shifted1.shape[1] >= width1:
|
||
self._frame_buffer[:, :width1] = shifted1[:, :width1].astype(np.uint8)
|
||
else:
|
||
# Shifted array is smaller - pad with zeros or repeat last pixel
|
||
actual_width = shifted1.shape[1]
|
||
self._frame_buffer[:, :actual_width] = shifted1.astype(np.uint8)
|
||
if actual_width < width1:
|
||
# Pad with last pixel
|
||
self._frame_buffer[:, actual_width:width1] = shifted1[:, -1:].astype(np.uint8)
|
||
else:
|
||
interpolated1 = self._interpolate_subpixel(source1, fractional, output_width=width1)
|
||
# Ensure exact width match
|
||
if interpolated1.shape[1] == width1:
|
||
self._frame_buffer[:, :width1] = interpolated1
|
||
else:
|
||
# Handle size mismatch
|
||
copy_width = min(width1, interpolated1.shape[1])
|
||
self._frame_buffer[:, :copy_width] = interpolated1[:, :copy_width]
|
||
if copy_width < width1:
|
||
self._frame_buffer[:, copy_width:width1] = interpolated1[:, -1:]
|
||
|
||
# Second part from beginning
|
||
remaining_width = self.display_width - width1
|
||
if remaining_width > 0:
|
||
source2 = self.cached_array[:, :remaining_width + 1]
|
||
if HAS_SCIPY:
|
||
shifted2 = shift(source2, (0, -fractional, 0), mode='nearest', order=1, prefilter=False)
|
||
# Ensure we get exactly remaining_width pixels
|
||
if shifted2.shape[1] >= remaining_width:
|
||
self._frame_buffer[:, width1:width1 + remaining_width] = shifted2[:, :remaining_width].astype(np.uint8)
|
||
else:
|
||
# Shifted array is smaller - pad if necessary
|
||
actual_width = shifted2.shape[1]
|
||
self._frame_buffer[:, width1:width1 + actual_width] = shifted2.astype(np.uint8)
|
||
if actual_width < remaining_width:
|
||
self._frame_buffer[:, width1 + actual_width:width1 + remaining_width] = shifted2[:, -1:].astype(np.uint8)
|
||
else:
|
||
interpolated2 = self._interpolate_subpixel(source2, fractional, output_width=remaining_width)
|
||
# Ensure exact width match
|
||
if interpolated2.shape[1] == remaining_width:
|
||
self._frame_buffer[:, width1:] = interpolated2
|
||
else:
|
||
copy_width = min(remaining_width, interpolated2.shape[1])
|
||
self._frame_buffer[:, width1:width1 + copy_width] = interpolated2[:, :copy_width]
|
||
if copy_width < remaining_width:
|
||
self._frame_buffer[:, width1 + copy_width:width1 + remaining_width] = interpolated2[:, -1:]
|
||
else:
|
||
# Edge case: wrap to beginning
|
||
source = self.cached_array[:, :self.display_width + 1]
|
||
if HAS_SCIPY:
|
||
shifted = shift(source, (0, -fractional, 0), mode='nearest', order=1, prefilter=False)
|
||
# Ensure we get exactly display_width pixels
|
||
if shifted.shape[1] >= self.display_width:
|
||
self._frame_buffer = shifted[:, :self.display_width].astype(np.uint8)
|
||
else:
|
||
# Shifted array is smaller - pad if necessary
|
||
actual_width = shifted.shape[1]
|
||
self._frame_buffer[:, :actual_width] = shifted.astype(np.uint8)
|
||
if actual_width < self.display_width:
|
||
self._frame_buffer[:, actual_width:] = shifted[:, -1:].astype(np.uint8)
|
||
else:
|
||
interpolated = self._interpolate_subpixel(source, fractional, output_width=self.display_width)
|
||
# _interpolate_subpixel now always returns exact width, so this should work
|
||
self._frame_buffer = interpolated
|
||
|
||
return Image.fromarray(self._frame_buffer)
|
||
|
||
def _interpolate_subpixel(self, source: np.ndarray, fractional: float, output_width: Optional[int] = None) -> np.ndarray:
|
||
"""
|
||
Simple linear interpolation for sub-pixel positioning.
|
||
Blends between adjacent pixels based on fractional offset.
|
||
|
||
Args:
|
||
source: Source array to interpolate (width should be at least output_width + 1)
|
||
fractional: Fractional part of scroll position (0.0-1.0)
|
||
output_width: Desired output width (defaults to display_width)
|
||
|
||
Returns:
|
||
Interpolated array of shape (height, output_width, 3) - ALWAYS exactly output_width
|
||
"""
|
||
if output_width is None:
|
||
output_width = self.display_width
|
||
|
||
# Always return exactly output_width pixels, padding if necessary
|
||
result = np.zeros((source.shape[0], output_width, 3), dtype=np.uint8)
|
||
|
||
# Ensure we have enough source pixels for interpolation
|
||
if source.shape[1] < 2:
|
||
# Very small source - just copy what we have and pad
|
||
copy_width = min(source.shape[1], output_width)
|
||
result[:, :copy_width] = source[:, :copy_width].astype(np.uint8)
|
||
if copy_width < output_width:
|
||
# Pad with last pixel
|
||
result[:, copy_width:] = source[:, -1:].astype(np.uint8)
|
||
return result
|
||
|
||
# Calculate how many pixels we can actually interpolate
|
||
# Need at least 2 pixels to interpolate, so max output is source.shape[1] - 1
|
||
max_interpolated_width = source.shape[1] - 1
|
||
interpolated_width = min(output_width, max_interpolated_width)
|
||
|
||
if interpolated_width > 0:
|
||
# Extract pixels at x and x+1 for interpolation
|
||
pixels_x = source[:, :interpolated_width].astype(np.float32)
|
||
pixels_x1 = source[:, 1:interpolated_width + 1].astype(np.float32)
|
||
|
||
# Linear interpolation
|
||
interpolated = pixels_x * (1.0 - fractional) + pixels_x1 * fractional
|
||
|
||
# Clip and convert back to uint8
|
||
interpolated = np.clip(interpolated, 0, 255).astype(np.uint8)
|
||
|
||
# Copy interpolated portion to result
|
||
result[:, :interpolated_width] = interpolated
|
||
|
||
# If we need more pixels than we can interpolate, pad with last pixel
|
||
if interpolated_width < output_width:
|
||
result[:, interpolated_width:] = source[:, -1:].astype(np.uint8)
|
||
|
||
return result
|
||
|
||
def calculate_dynamic_duration(self) -> int:
|
||
"""
|
||
Calculate display duration based on content width and scroll settings.
|
||
|
||
Returns:
|
||
Duration in seconds
|
||
"""
|
||
if not self.dynamic_duration_enabled:
|
||
return self.min_duration
|
||
|
||
# Validate total_scroll_width is set and valid
|
||
if not self.total_scroll_width or self.total_scroll_width <= 0:
|
||
if self.total_scroll_width == 0:
|
||
self.logger.warning(
|
||
"Dynamic duration calculation skipped: total_scroll_width is 0. "
|
||
"Ensure create_scrolling_image() or set_scrolling_image() has been called. "
|
||
"Using minimum duration: %ds",
|
||
self.min_duration
|
||
)
|
||
else:
|
||
self.logger.warning(
|
||
"Dynamic duration calculation skipped: total_scroll_width is invalid (%s). "
|
||
"Using minimum duration: %ds",
|
||
self.total_scroll_width,
|
||
self.min_duration
|
||
)
|
||
return self.min_duration
|
||
|
||
try:
|
||
# Calculate total scroll distance needed
|
||
# The image already includes display_width padding at the start, so we need
|
||
# to scroll total_scroll_width pixels to show all content, plus display_width
|
||
# more pixels to ensure the last content scrolls completely off the screen
|
||
total_scroll_distance = self.total_scroll_width + self.display_width
|
||
|
||
# Calculate effective pixels per second based on scrolling mode
|
||
if self.frame_based_scrolling:
|
||
# Frame-based mode: scroll_speed is pixels per frame, scroll_delay is seconds per frame
|
||
# Effective pixels per second = pixels per frame / seconds per frame
|
||
if self.scroll_delay > 0:
|
||
pixels_per_second = self.scroll_speed / self.scroll_delay
|
||
else:
|
||
# Fallback if scroll_delay is invalid
|
||
pixels_per_second = self.scroll_speed * 50 # Assume 50 FPS default
|
||
self.logger.warning("Invalid scroll_delay (%s), using fallback calculation", self.scroll_delay)
|
||
scroll_mode_str = "frame-based"
|
||
else:
|
||
# Time-based mode: scroll_speed is already pixels per second
|
||
pixels_per_second = self.scroll_speed
|
||
scroll_mode_str = "time-based"
|
||
|
||
# Calculate time based on effective pixels per second
|
||
total_time = total_scroll_distance / pixels_per_second
|
||
|
||
# Add buffer time for smooth cycling
|
||
buffer_time = total_time * self.duration_buffer
|
||
calculated_duration = int(total_time + buffer_time)
|
||
|
||
# Apply min/max limits
|
||
if calculated_duration < self.min_duration:
|
||
self.calculated_duration = self.min_duration
|
||
elif calculated_duration > self.max_duration:
|
||
self.calculated_duration = self.max_duration
|
||
else:
|
||
self.calculated_duration = calculated_duration
|
||
|
||
self.logger.debug("Dynamic duration calculation (%s mode):", scroll_mode_str)
|
||
self.logger.debug(" Display width: %dpx", self.display_width)
|
||
self.logger.debug(" Content width: %dpx", self.total_scroll_width)
|
||
self.logger.debug(" Total scroll distance: %dpx", total_scroll_distance)
|
||
if self.frame_based_scrolling:
|
||
self.logger.debug(" Scroll speed: %.2f px/frame, delay: %.3fs", self.scroll_speed, self.scroll_delay)
|
||
self.logger.debug(" Effective speed: %.1f px/second", pixels_per_second)
|
||
else:
|
||
self.logger.debug(" Scroll speed: %.1f px/second", pixels_per_second)
|
||
self.logger.debug(" Base time: %.2fs", total_time)
|
||
self.logger.debug(" Buffer time: %.2fs", buffer_time)
|
||
self.logger.debug(" Final duration: %ds", self.calculated_duration)
|
||
|
||
return self.calculated_duration
|
||
|
||
except (ValueError, ZeroDivisionError, TypeError) as e:
|
||
self.logger.error("Error calculating dynamic duration: %s", e)
|
||
return self.min_duration
|
||
|
||
def is_scroll_complete(self) -> bool:
|
||
"""
|
||
Check if the current scroll cycle is complete.
|
||
|
||
Returns:
|
||
True if scroll has wrapped around to the beginning
|
||
"""
|
||
return self.scroll_complete
|
||
|
||
def append_content(self, content_items: list,
|
||
item_gap: int = 32,
|
||
element_gap: int = 0) -> bool:
|
||
"""
|
||
Append items to the right of the existing strip, preserving scroll state.
|
||
|
||
Lets a caller keep one continuous strip instead of replacing it. Vegas
|
||
mode uses this so the next group of plugins scrolls in from the right
|
||
rather than the strip being swapped out underneath the viewer — a swap
|
||
shows as a flash and a hard cut to already-full-screen content.
|
||
|
||
``scroll_position`` and ``total_distance_scrolled`` are untouched, so
|
||
motion continues uninterrupted; only the strip gets longer. Because
|
||
completion is measured against ``total_scroll_width``, extending the
|
||
strip also defers completion, which is the intent.
|
||
|
||
Args:
|
||
content_items: Images to append, in order
|
||
item_gap: Gap between appended items, and between the existing
|
||
content and the first appended item
|
||
element_gap: Extra gap after each item, mirroring
|
||
create_scrolling_image
|
||
|
||
Returns:
|
||
True if content was appended
|
||
"""
|
||
if not content_items:
|
||
return False
|
||
|
||
if self.cached_image is None or self.cached_array is None:
|
||
# Nothing to extend yet — this is just the first build.
|
||
self.create_scrolling_image(
|
||
content_items, item_gap=item_gap, element_gap=element_gap, lead_gap=0)
|
||
return True
|
||
|
||
gap = max(0, item_gap)
|
||
addition_width = (
|
||
sum(img.width for img in content_items)
|
||
+ gap * len(content_items) # one leading gap per item
|
||
+ element_gap * len(content_items)
|
||
)
|
||
|
||
addition = Image.new('RGB', (addition_width, self.display_height), (0, 0, 0))
|
||
x = 0
|
||
for img in content_items:
|
||
x += gap # separate from whatever precedes
|
||
addition.paste(img, (x, 0))
|
||
x += img.width + element_gap
|
||
|
||
# numpy concatenate then one conversion back, rather than allocating a
|
||
# full-width PIL image and pasting twice: the strip can be tens of
|
||
# thousands of columns wide and this runs on the render path.
|
||
self.cached_array = np.concatenate(
|
||
(self.cached_array, np.array(addition)), axis=1)
|
||
self.cached_image = Image.fromarray(self.cached_array)
|
||
self.total_scroll_width = self.cached_image.width
|
||
self.scroll_complete = False
|
||
|
||
self.logger.info(
|
||
"Appended %d item(s) (%dpx) to scroll strip: now %dpx, position %.0f",
|
||
len(content_items), addition_width, self.total_scroll_width,
|
||
self.scroll_position
|
||
)
|
||
return True
|
||
|
||
def drop_scrolled_prefix(self, keep_before: int = 0) -> int:
|
||
"""
|
||
Discard columns that have already scrolled past, to bound memory.
|
||
|
||
A continuously extended strip would otherwise grow without limit. All
|
||
the positional state is shifted by the amount removed so the visible
|
||
frame and the completion arithmetic are unchanged:
|
||
``total_distance_scrolled`` and ``total_scroll_width`` both shrink by the
|
||
same amount, preserving their difference.
|
||
|
||
Args:
|
||
keep_before: Columns to retain behind the current position, as a
|
||
safety margin against a caller reading slightly behind it
|
||
|
||
Returns:
|
||
Number of columns actually removed
|
||
"""
|
||
if self.cached_image is None or self.cached_array is None:
|
||
return 0
|
||
|
||
# While the viewport wraps, get_visible_portion fills its right-hand side
|
||
# from the *head* of the strip, so trimming the head would change what
|
||
# is on screen. Continuous mode extends before ever reaching that state;
|
||
# refusing here keeps "trimming is invisible" true unconditionally.
|
||
if self.scroll_position + self.display_width > self.cached_image.width:
|
||
return 0
|
||
|
||
cut = int(self.scroll_position) - max(0, keep_before)
|
||
if cut <= 0:
|
||
return 0
|
||
# Never trim so far that the remaining strip is narrower than the
|
||
# viewport, or get_visible_portion has nothing to slice.
|
||
cut = min(cut, max(0, self.cached_image.width - self.display_width))
|
||
if cut <= 0:
|
||
return 0
|
||
|
||
# .copy() so the original buffer is released rather than kept alive by
|
||
# a numpy view.
|
||
self.cached_array = self.cached_array[:, cut:].copy()
|
||
self.cached_image = Image.fromarray(self.cached_array)
|
||
self.total_scroll_width = self.cached_image.width
|
||
self.scroll_position -= cut
|
||
self.total_distance_scrolled = max(0.0, self.total_distance_scrolled - cut)
|
||
|
||
self.logger.debug(
|
||
"Dropped %dpx of scrolled strip: now %dpx, position %.0f",
|
||
cut, self.total_scroll_width, self.scroll_position
|
||
)
|
||
return cut
|
||
|
||
def remaining_unscrolled(self) -> int:
|
||
"""Columns of strip still to the right of the viewport."""
|
||
if self.cached_image is None:
|
||
return 0
|
||
return max(0, self.total_scroll_width - int(self.scroll_position)
|
||
- self.display_width)
|
||
|
||
def reset_scroll(self) -> None:
|
||
"""
|
||
Reset scroll position to beginning.
|
||
"""
|
||
self.scroll_position = 0.0
|
||
self.total_distance_scrolled = 0.0
|
||
self.scroll_complete = False
|
||
now = time.time()
|
||
self.scroll_start_time = now
|
||
self.last_progress_log_time = now
|
||
self.last_step_time = now # Reset step timer
|
||
# Reset last_update_time to prevent large delta_time on next update
|
||
# This ensures smooth scrolling after reset without jumping ahead
|
||
self.last_update_time = now
|
||
self.logger.debug("Scroll position reset")
|
||
|
||
def reset(self) -> None:
|
||
"""Alias for reset_scroll() for convenience."""
|
||
self.reset_scroll()
|
||
|
||
def set_scrolling_image(self, image: Image.Image) -> None:
|
||
"""
|
||
Set a pre-rendered scrolling image and initialize all required state.
|
||
|
||
This method should be used when plugins create their own scrolling image
|
||
instead of using create_scrolling_image(). It properly initializes both
|
||
cached_image and cached_array, and updates all related state.
|
||
|
||
Args:
|
||
image: PIL Image containing the scrolling content
|
||
"""
|
||
if image is None:
|
||
self.logger.warning("Attempted to set None as scrolling image, clearing cache instead")
|
||
self.clear_cache()
|
||
return
|
||
|
||
# Set the cached image
|
||
self.cached_image = image
|
||
|
||
# Convert to numpy array for fast operations (required for get_visible_portion)
|
||
self.cached_array = np.array(image)
|
||
|
||
# Update scroll width
|
||
self.total_scroll_width = image.width
|
||
|
||
# Reset scroll position
|
||
self.scroll_position = 0.0
|
||
self.total_distance_scrolled = 0.0
|
||
self.scroll_complete = False
|
||
|
||
# Pre-allocate frame buffer if needed
|
||
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
||
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
||
|
||
# Calculate dynamic duration
|
||
self._calculate_dynamic_duration()
|
||
|
||
# Reset timing
|
||
now = time.time()
|
||
self.scroll_start_time = now
|
||
self.last_progress_log_time = now
|
||
self.last_step_time = now # Initialize step timer for frame-based scrolling
|
||
|
||
self.logger.debug("Set scrolling image: %dx%d, total_scroll_width=%d",
|
||
image.width, image.height, self.total_scroll_width)
|
||
|
||
def set_scroll_speed(self, speed: float) -> None:
|
||
"""
|
||
Set the scroll speed.
|
||
|
||
In time-based mode: pixels per second (typically 10-200)
|
||
In frame-based mode: pixels per frame (typically 0.5-5 for smooth scrolling)
|
||
|
||
Args:
|
||
speed: Scroll speed (interpretation depends on frame_based_scrolling mode)
|
||
"""
|
||
if self.frame_based_scrolling:
|
||
# In frame-based mode, clamp to reasonable pixels per frame (0.1-5)
|
||
# Higher values cause visible jumps - 1-2 pixels/frame is ideal for smoothness
|
||
self.scroll_speed = max(0.1, min(5.0, speed))
|
||
self.logger.debug(f"Scroll speed set to: {self.scroll_speed} pixels/frame (frame-based mode)")
|
||
else:
|
||
# In time-based mode, clamp to pixels per second (1-500)
|
||
self.scroll_speed = max(1.0, min(500.0, speed))
|
||
self.logger.debug(f"Scroll speed set to: {self.scroll_speed} pixels/second (time-based mode)")
|
||
|
||
def set_scroll_delay(self, delay: float) -> None:
|
||
"""
|
||
Set the delay between scroll frames.
|
||
|
||
Args:
|
||
delay: Delay in seconds (typically 0.001-0.1)
|
||
"""
|
||
self.scroll_delay = max(0.001, min(1.0, delay))
|
||
self.logger.debug(f"Scroll delay set to: {self.scroll_delay}")
|
||
|
||
def set_target_fps(self, fps: float) -> None:
|
||
"""
|
||
Set the target frames per second for scrolling.
|
||
|
||
Args:
|
||
fps: Target FPS (typically 30-200, default 120)
|
||
"""
|
||
self.target_fps = max(30.0, min(200.0, fps))
|
||
self.frame_time_target = 1.0 / self.target_fps
|
||
self.logger.debug(f"Target FPS set to: {self.target_fps} FPS (frame_time_target: {self.frame_time_target:.4f}s)")
|
||
|
||
def set_sub_pixel_scrolling(self, enabled: bool) -> None:
|
||
"""
|
||
Enable or disable sub-pixel scrolling for smoother movement.
|
||
|
||
When enabled, uses interpolation to blend between pixels for fractional
|
||
scroll positions, resulting in smooth scrolling even at slow speeds.
|
||
When disabled, uses integer pixel positioning (faster but may skip pixels).
|
||
|
||
Args:
|
||
enabled: True to enable sub-pixel scrolling (default: True)
|
||
"""
|
||
self.sub_pixel_scrolling = enabled
|
||
self.logger.debug(f"Sub-pixel scrolling {'enabled' if enabled else 'disabled'}")
|
||
|
||
def set_frame_based_scrolling(self, enabled: bool) -> None:
|
||
"""
|
||
Enable or disable frame-based scrolling.
|
||
|
||
When enabled, update_scroll_position() respects scroll_delay and moves
|
||
scroll_speed pixels per step. This provides a "stepped" look similar to
|
||
traditional tickers and can be visually smoother on LED matrices.
|
||
|
||
Args:
|
||
enabled: True to enable frame-based scrolling (default: False)
|
||
"""
|
||
self.frame_based_scrolling = enabled
|
||
self.last_step_time = time.time() # Reset step timer
|
||
self.logger.debug(f"Frame-based scrolling {'enabled' if enabled else 'disabled'}")
|
||
|
||
def set_dynamic_duration_settings(self, enabled: bool = True,
|
||
min_duration: int = 30,
|
||
max_duration: int = 300,
|
||
buffer: float = 0.1) -> None:
|
||
"""
|
||
Configure dynamic duration calculation.
|
||
|
||
Args:
|
||
enabled: Enable dynamic duration calculation
|
||
min_duration: Minimum duration in seconds
|
||
max_duration: Maximum duration in seconds
|
||
buffer: Buffer percentage (0.0-1.0)
|
||
"""
|
||
self.dynamic_duration_enabled = enabled
|
||
self.min_duration = max(10, min_duration)
|
||
self.max_duration = max(self.min_duration, max_duration)
|
||
self.duration_buffer = max(0.0, min(1.0, buffer))
|
||
|
||
self.logger.debug(f"Dynamic duration settings: enabled={enabled}, "
|
||
f"min={self.min_duration}s, max={self.max_duration}s, "
|
||
f"buffer={self.duration_buffer*100}%")
|
||
|
||
def get_dynamic_duration(self) -> int:
|
||
"""
|
||
Get the calculated dynamic duration.
|
||
|
||
Returns:
|
||
Duration in seconds
|
||
"""
|
||
return self.calculated_duration
|
||
|
||
def _calculate_dynamic_duration(self) -> None:
|
||
"""Internal method to calculate dynamic duration."""
|
||
self.calculated_duration = self.calculate_dynamic_duration()
|
||
|
||
def log_frame_rate(self) -> None:
|
||
"""
|
||
Log frame rate statistics for performance monitoring.
|
||
"""
|
||
current_time = time.time()
|
||
|
||
# Calculate instantaneous frame time
|
||
frame_time = current_time - self.last_frame_time
|
||
self.frame_times.append(frame_time)
|
||
|
||
# Keep only last 100 frames for average
|
||
if len(self.frame_times) > 100:
|
||
self.frame_times.pop(0)
|
||
|
||
# Log FPS every 5 seconds to avoid spam
|
||
if current_time - self.last_fps_log_time >= 5.0:
|
||
avg_frame_time = sum(self.frame_times) / len(self.frame_times)
|
||
avg_fps = 1.0 / avg_frame_time if avg_frame_time > 0 else 0
|
||
instant_fps = 1.0 / frame_time if frame_time > 0 else 0
|
||
|
||
self.logger.info(f"Scroll frame stats - Avg FPS: {avg_fps:.1f}, "
|
||
f"Current FPS: {instant_fps:.1f}, "
|
||
f"Frame time: {frame_time*1000:.2f}ms")
|
||
self.last_fps_log_time = current_time
|
||
self.frame_count = 0
|
||
|
||
self.last_frame_time = current_time
|
||
self.frame_count += 1
|
||
|
||
def clear_cache(self) -> None:
|
||
"""
|
||
Clear the cached scrolling image.
|
||
"""
|
||
self.cached_image = None
|
||
self.cached_array = None
|
||
self.total_scroll_width = 0
|
||
self.scroll_position = 0.0
|
||
self.total_distance_scrolled = 0.0
|
||
self.scroll_complete = False
|
||
self.scroll_start_time = None
|
||
self.last_progress_log_time = None
|
||
self.logger.debug("Scroll cache cleared")
|
||
|
||
def get_scroll_info(self) -> Dict[str, Any]:
|
||
"""
|
||
Get current scroll state information.
|
||
|
||
Returns:
|
||
Dictionary with scroll state information
|
||
"""
|
||
# The image already includes display_width padding, so we only need total_scroll_width
|
||
required_total_distance = self.total_scroll_width if self.total_scroll_width > 0 else 0
|
||
return {
|
||
'scroll_position': self.scroll_position,
|
||
'total_distance_scrolled': self.total_distance_scrolled,
|
||
'required_total_distance': required_total_distance,
|
||
'scroll_speed': self.scroll_speed,
|
||
'scroll_delay': self.scroll_delay,
|
||
'total_width': self.total_scroll_width,
|
||
'is_scrolling': self.is_scrolling,
|
||
'scroll_complete': self.scroll_complete,
|
||
'dynamic_duration': self.calculated_duration,
|
||
'elapsed_time': (time.time() - self.scroll_start_time)
|
||
if self.scroll_start_time
|
||
else None,
|
||
'cached_image_size': (self.cached_image.width, self.cached_image.height) if self.cached_image else None
|
||
}
|