From 7b6b7cd153318572064cbe6da7540cbc88121b67 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Wed, 29 Jul 2026 15:14:41 -0400 Subject: [PATCH] Sub-pixel scrolling: motion at the frame rate, not the pixel rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --- config/config.template.json | 1 + src/common/scroll_helper.py | 67 +++++++++++++- src/vegas_mode/config.py | 11 +++ src/vegas_mode/render_pipeline.py | 1 + test/test_scroll_helper_continuous.py | 91 +++++++++++++++++++ web_interface/blueprints/api_v3.py | 5 +- .../templates/v3/partials/display.html | 11 +++ 7 files changed, 182 insertions(+), 5 deletions(-) diff --git a/config/config.template.json b/config/config.template.json index 831ef12d..35d4c7ad 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -136,6 +136,7 @@ "min_content_separation": 24, "min_cut_gap": 6, "continuous_scroll": true, + "smooth_scroll": true, "extend_threshold_screens": 2.0, "auto_trim": true, "trim_threshold": 10, diff --git a/src/common/scroll_helper.py b/src/common/scroll_helper.py index 6916e5ed..4f2e215e 100644 --- a/src/common/scroll_helper.py +++ b/src/common/scroll_helper.py @@ -348,13 +348,72 @@ class ScrollHelper: """ if not self.cached_image or self.cached_array is None: return None - - # Use integer pixel positioning for high FPS scrolling (like stock ticker) + start_x_int = int(self.scroll_position) end_x_int = start_x_int + self.display_width - - # Fast integer pixel path (no interpolation - high frame rate provides smoothness) + + # 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). diff --git a/src/vegas_mode/config.py b/src/vegas_mode/config.py index 0a89e0c0..97e7ae9b 100644 --- a/src/vegas_mode/config.py +++ b/src/vegas_mode/config.py @@ -58,6 +58,13 @@ class VegasModeConfig: # switched off at the start of every cycle. lead_in_width: int = 0 + # Blend between neighbouring pixel positions so motion happens at the frame + # rate rather than the scroll speed. With integer positioning the number of + # distinct frames per second equals scroll_speed, so at 50px/s the motion is + # 50 discrete 1px steps however fast the loop runs. The trade is a slight + # horizontal softening of text, since each frame is a blend of two positions. + smooth_scroll: bool = True + # Keep one continuous strip, extending it with the next group of plugins as # the scroll approaches the end, instead of composing a fresh strip and # swapping it in. A swap stops the motion, substitutes every pixel at once @@ -129,6 +136,7 @@ class VegasModeConfig: min_content_separation=int( vegas_config.get('min_content_separation', 24)), min_cut_gap=int(vegas_config.get('min_cut_gap', 6)), + smooth_scroll=vegas_config.get('smooth_scroll', True), continuous_scroll=vegas_config.get('continuous_scroll', True), extend_threshold_screens=float( vegas_config.get('extend_threshold_screens', 2.0)), @@ -161,6 +169,7 @@ class VegasModeConfig: 'render_width_pct': self.render_width_pct, 'min_content_separation': self.min_content_separation, 'min_cut_gap': self.min_cut_gap, + 'smooth_scroll': self.smooth_scroll, 'continuous_scroll': self.continuous_scroll, 'extend_threshold_screens': self.extend_threshold_screens, 'auto_trim': self.auto_trim, @@ -344,6 +353,8 @@ class VegasModeConfig: vegas_config['min_content_separation']) if 'min_cut_gap' in vegas_config: self.min_cut_gap = int(vegas_config['min_cut_gap']) + if 'smooth_scroll' in vegas_config: + self.smooth_scroll = vegas_config['smooth_scroll'] if 'continuous_scroll' in vegas_config: self.continuous_scroll = vegas_config['continuous_scroll'] if 'extend_threshold_screens' in vegas_config: diff --git a/src/vegas_mode/render_pipeline.py b/src/vegas_mode/render_pipeline.py index 88bd8a76..94b1e086 100644 --- a/src/vegas_mode/render_pipeline.py +++ b/src/vegas_mode/render_pipeline.py @@ -124,6 +124,7 @@ class RenderPipeline: """Configure ScrollHelper with current settings.""" self.scroll_helper.set_frame_based_scrolling(self.config.frame_based_scrolling) self.scroll_helper.set_scroll_delay(self.config.scroll_delay) + self.scroll_helper.set_sub_pixel_scrolling(self.config.smooth_scroll) # Config scroll_speed is always pixels per second, but ScrollHelper # interprets it differently based on frame_based_scrolling mode: diff --git a/test/test_scroll_helper_continuous.py b/test/test_scroll_helper_continuous.py index 6737b577..346450d2 100644 --- a/test/test_scroll_helper_continuous.py +++ b/test/test_scroll_helper_continuous.py @@ -243,3 +243,94 @@ class TestContinuousScrollingEndToEnd: assert max(widths) < 3000, f"strip grew unbounded: max {max(widths)}" assert not sh.scroll_complete, "continuous strip should never complete" + + +class TestSubPixelBlending: + """ + Integer positioning quantises motion to whole pixels, so distinct frames per + second equals scroll speed regardless of frame rate — at 50px/s and 78fps, + 36% of frames were identical. Blending between neighbouring positions gives + motion at the frame rate instead. + """ + + def _strip(self, width=2000): + rng = np.random.default_rng(0) + arr = (rng.random((H, width, 3)) * 255).astype(np.uint8) + sh = helper() + sh.create_scrolling_image([Image.fromarray(arr)], + item_gap=0, element_gap=0, lead_gap=0) + return sh + + def _frame(self, sh, pos, subpixel): + sh.sub_pixel_scrolling = subpixel + sh.scroll_position = pos + return np.asarray(sh.get_visible_portion()).astype(int) + + def test_integer_mode_ignores_the_fraction(self): + sh = self._strip() + a = self._frame(sh, 500.0, False) + b = self._frame(sh, 500.9, False) + assert np.array_equal(a, b), "integer positioning should not move sub-pixel" + + def test_blending_moves_within_a_pixel(self): + sh = self._strip() + a = self._frame(sh, 500.0, True) + b = self._frame(sh, 500.5, True) + assert not np.array_equal(a, b) + + def test_zero_fraction_matches_the_integer_frame(self): + # No interpolation to do, so it must be pixel-identical and take the + # cheap path. + sh = self._strip() + assert np.array_equal(self._frame(sh, 700.0, True), + self._frame(sh, 700.0, False)) + + def test_blend_is_monotonic_between_neighbours(self): + # Marching the fraction from 0 to 1 should approach the next integer + # frame, not wander. + sh = self._strip() + target = self._frame(sh, 501.0, False) + dists = [] + for frac in (0.0, 0.25, 0.5, 0.75): + f = self._frame(sh, 500.0 + frac, True) + dists.append(np.abs(f - target).mean()) + assert dists == sorted(dists, reverse=True), f"not converging: {dists}" + + def test_blend_endpoints_bracket_the_two_frames(self): + sh = self._strip() + near = self._frame(sh, 500.0, False) + far = self._frame(sh, 501.0, False) + mid = self._frame(sh, 500.5, True) + # Every blended pixel must lie between its two sources. + lo = np.minimum(near, far) + hi = np.maximum(near, far) + assert (mid >= lo - 1).all() and (mid <= hi + 1).all() + + def test_output_size_and_mode_are_unchanged(self): + sh = self._strip() + sh.sub_pixel_scrolling = True + sh.scroll_position = 300.4 + frame = sh.get_visible_portion() + assert frame.size == (W, H) + assert frame.mode == 'RGB' + + def test_works_near_the_end_of_the_strip(self): + # One of the two slices wraps here; must not raise or missize. + sh = self._strip(width=600) + sh.sub_pixel_scrolling = True + sh.scroll_position = float(600 - W // 2) + 0.5 + frame = sh.get_visible_portion() + assert frame is not None and frame.size == (W, H) + + def test_works_at_the_very_last_column(self): + sh = self._strip(width=600) + sh.sub_pixel_scrolling = True + sh.scroll_position = 599.5 + assert sh.get_visible_portion().size == (W, H) + + @pytest.mark.parametrize("frac", [0.01, 0.1, 0.33, 0.5, 0.67, 0.9, 0.99]) + def test_never_raises_across_the_fraction_range(self, frac): + sh = self._strip() + sh.sub_pixel_scrolling = True + sh.scroll_position = 400.0 + frac + assert sh.get_visible_portion().size == (W, H) diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index d30d770d..c197fc30 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -925,7 +925,8 @@ def save_main_config(): 'vegas_min_cycle_duration', 'vegas_max_cycle_duration', 'vegas_intra_plugin_gap', 'vegas_render_width_pct', 'vegas_min_content_separation', 'vegas_min_cut_gap', - 'vegas_continuous_scroll', 'vegas_extend_threshold_screens'] + 'vegas_continuous_scroll', 'vegas_extend_threshold_screens', + 'vegas_smooth_scroll'] if any(k in data for k in vegas_fields): if 'display' not in current_config: @@ -945,6 +946,8 @@ def save_main_config(): data.get('vegas_dynamic_duration_enabled')) vegas_config['continuous_scroll'] = _coerce_to_bool( data.get('vegas_continuous_scroll')) + vegas_config['smooth_scroll'] = _coerce_to_bool( + data.get('vegas_smooth_scroll')) # max_plugin_width_ratio is the one fractional setting, so it is # handled outside the integer loop below. diff --git a/web_interface/templates/v3/partials/display.html b/web_interface/templates/v3/partials/display.html index 78da91d9..01efd788 100644 --- a/web_interface/templates/v3/partials/display.html +++ b/web_interface/templates/v3/partials/display.html @@ -510,6 +510,17 @@ +
+ +
+