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
This commit is contained in:
ChuckBuilds
2026-07-29 15:14:41 -04:00
co-authored by Claude
parent 932e89ccb8
commit 7b6b7cd153
7 changed files with 182 additions and 5 deletions
+63 -4
View File
@@ -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).
+11
View File
@@ -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:
+1
View File
@@ -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: