mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
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
This commit is contained in:
@@ -134,6 +134,7 @@
|
||||
"intra_plugin_gap": 8,
|
||||
"render_width_pct": 100,
|
||||
"min_content_separation": 24,
|
||||
"min_cut_gap": 6,
|
||||
"auto_trim": true,
|
||||
"trim_threshold": 10,
|
||||
"content_padding": 8,
|
||||
|
||||
@@ -64,6 +64,14 @@ class VegasModeConfig:
|
||||
# per cycle, so a 20-plugin install took seven cycles to come around.
|
||||
plugins_per_cycle: int = 6
|
||||
|
||||
# Minimum run of blank columns that counts as a boundary between items when
|
||||
# an oversized segment has to be narrowed. Measured on rendered text, the
|
||||
# gaps between characters are a single column while gaps between items are
|
||||
# 8px and up, so anything above 1 stops a cut landing inside a word. Cutting
|
||||
# mid-word orphaned the tail into the next cycle, which showed up as a lone
|
||||
# letter floating between two unrelated plugins.
|
||||
min_cut_gap: int = 6
|
||||
|
||||
# Cap on one plugin's share of a cycle, as a multiple of display width.
|
||||
# A single ticker returning 7,000px would otherwise hold the panel for over
|
||||
# two minutes. Overflow is deferred to later cycles rather than discarded.
|
||||
@@ -108,6 +116,7 @@ class VegasModeConfig:
|
||||
render_width_pct=int(vegas_config.get('render_width_pct', 100)),
|
||||
min_content_separation=int(
|
||||
vegas_config.get('min_content_separation', 24)),
|
||||
min_cut_gap=int(vegas_config.get('min_cut_gap', 6)),
|
||||
auto_trim=vegas_config.get('auto_trim', True),
|
||||
trim_threshold=int(vegas_config.get('trim_threshold', 10)),
|
||||
content_padding=int(vegas_config.get('content_padding', 8)),
|
||||
@@ -136,6 +145,7 @@ class VegasModeConfig:
|
||||
'intra_plugin_gap': self.intra_plugin_gap,
|
||||
'render_width_pct': self.render_width_pct,
|
||||
'min_content_separation': self.min_content_separation,
|
||||
'min_cut_gap': self.min_cut_gap,
|
||||
'auto_trim': self.auto_trim,
|
||||
'trim_threshold': self.trim_threshold,
|
||||
'content_padding': self.content_padding,
|
||||
@@ -238,6 +248,11 @@ class VegasModeConfig:
|
||||
"min_content_separation must be between 0 and 256, "
|
||||
f"got {self.min_content_separation}")
|
||||
|
||||
if not 1 <= self.min_cut_gap <= 128:
|
||||
errors.append(
|
||||
"min_cut_gap must be between 1 and 128, "
|
||||
f"got {self.min_cut_gap}")
|
||||
|
||||
if self.intra_plugin_gap < 0:
|
||||
errors.append(
|
||||
f"intra_plugin_gap must be >= 0, got {self.intra_plugin_gap}")
|
||||
@@ -305,6 +320,8 @@ class VegasModeConfig:
|
||||
if 'min_content_separation' in vegas_config:
|
||||
self.min_content_separation = int(
|
||||
vegas_config['min_content_separation'])
|
||||
if 'min_cut_gap' in vegas_config:
|
||||
self.min_cut_gap = int(vegas_config['min_cut_gap'])
|
||||
if 'auto_trim' in vegas_config:
|
||||
self.auto_trim = vegas_config['auto_trim']
|
||||
if 'trim_threshold' in vegas_config:
|
||||
|
||||
@@ -16,7 +16,7 @@ All column scans go through numpy: a Python-level per-column loop over a
|
||||
path.
|
||||
"""
|
||||
|
||||
from typing import NamedTuple, Optional, Tuple
|
||||
from typing import List, NamedTuple, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
@@ -192,6 +192,83 @@ def separation_gap(
|
||||
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()))
|
||||
|
||||
|
||||
def find_item_boundary(
|
||||
img: Image.Image,
|
||||
target: int,
|
||||
min_run: int,
|
||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Find the column nearest ``target`` that sits inside a gap between items.
|
||||
|
||||
Used to narrow an oversized segment without cutting through a word. Only
|
||||
runs of at least ``min_run`` blank columns are considered, so the
|
||||
single-column gaps between characters are never chosen — cutting there
|
||||
orphaned the tail of a word into the following cycle, which is how a lone
|
||||
"y" from "Wednesday" ended up floating between two unrelated plugins.
|
||||
|
||||
Args:
|
||||
img: Image to cut
|
||||
target: Preferred cut column
|
||||
min_run: Minimum blank-run width that counts as an item boundary
|
||||
threshold: Ink threshold
|
||||
|
||||
Returns:
|
||||
A column inside a qualifying gap, or None when the image has no such
|
||||
gap at all — in which case the caller must not cut it.
|
||||
"""
|
||||
runs = blank_runs(img, min_run, threshold)
|
||||
if not runs:
|
||||
return None
|
||||
|
||||
# Nearest point of the nearest run. For a run left of target that is its
|
||||
# end (content resumes just after), for a run right of target its start
|
||||
# (content stopped just before) — the right choice in both directions.
|
||||
def clamp_to_run(run: Tuple[int, int]) -> int:
|
||||
start, end = run
|
||||
return max(start, min(target, end - 1))
|
||||
|
||||
return min((clamp_to_run(r) for r in runs), key=lambda c: abs(c - target))
|
||||
|
||||
|
||||
def find_blank_cut(
|
||||
img: Image.Image,
|
||||
target: int,
|
||||
|
||||
@@ -13,7 +13,7 @@ from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING
|
||||
from PIL import Image
|
||||
|
||||
from src.vegas_mode.geometry import (
|
||||
find_blank_cut,
|
||||
blank_runs,
|
||||
separation_gap,
|
||||
trim_to_content,
|
||||
)
|
||||
@@ -386,22 +386,48 @@ class PluginAdapter:
|
||||
if offset >= img.width:
|
||||
offset = 0
|
||||
|
||||
# Snap both edges to blank columns. The search radius is generous
|
||||
# enough to clear a wide glyph but small enough not to distort the
|
||||
# requested budget much.
|
||||
snap = max(8, self.display_width // 16)
|
||||
start = find_blank_cut(img, offset, snap, self.config.trim_threshold)
|
||||
end = find_blank_cut(
|
||||
img, min(start + budget, img.width), snap, self.config.trim_threshold)
|
||||
if end <= start:
|
||||
end = min(start + budget, img.width)
|
||||
# Cut only where the plugin left a real gap between items. Snapping to
|
||||
# any blank column used to pick the single-column gaps between
|
||||
# characters, splitting a word and orphaning its tail into the next
|
||||
# cycle — a lone "y" from "Wednesday" floating between two unrelated
|
||||
# plugins. Overshooting the budget is the lesser evil.
|
||||
min_run = max(2, self.config.min_cut_gap)
|
||||
gaps = blank_runs(img, min_run, self.config.trim_threshold)
|
||||
|
||||
if not gaps:
|
||||
# No internal gaps means continuous content — a map, a chart, a
|
||||
# photo — where any column is as good as any other, so cut to the
|
||||
# budget exactly. The gap rule exists to protect discrete items
|
||||
# (words, ticker entries); it would be wrong to let a solid image
|
||||
# escape the cap in its name.
|
||||
end = min(offset + budget, img.width)
|
||||
self._item_offsets[plugin_id] = 0 if end >= img.width else end
|
||||
logger.info(
|
||||
"[%s] Width budget %dpx: cropped continuous %dpx image to "
|
||||
"[%d:%d] (no item gaps of %dpx+ to align to)",
|
||||
plugin_id, budget, img.width, offset, end, min_run
|
||||
)
|
||||
return img.crop((offset, 0, end, img.height))
|
||||
|
||||
# Cut mid-gap so the content either side keeps some breathing room.
|
||||
cuts = sorted({0, img.width} | {(a + b) // 2 for a, b in gaps})
|
||||
|
||||
start = max((c for c in cuts if c <= offset), default=0)
|
||||
later = [c for c in cuts if c > start]
|
||||
if not later:
|
||||
end = img.width
|
||||
else:
|
||||
within = [c for c in later if c <= start + budget]
|
||||
# No boundary inside the budget: take the next one and overrun,
|
||||
# because the alternative is cutting through an item.
|
||||
end = max(within) if within else min(later)
|
||||
|
||||
# Next cycle resumes where this one stopped; wrap when the strip ends.
|
||||
self._item_offsets[plugin_id] = 0 if end >= img.width else end
|
||||
|
||||
logger.info(
|
||||
"[%s] Width budget %dpx: cropped single %dpx image to [%d:%d] "
|
||||
"(%dpx), window advances next cycle",
|
||||
"(%dpx) at item boundaries, window advances next cycle",
|
||||
plugin_id, budget, img.width, start, end, end - start
|
||||
)
|
||||
return img.crop((start, 0, end, img.height))
|
||||
|
||||
@@ -969,3 +969,132 @@ class TestRotationAcrossMultipleCycles:
|
||||
for _ in range(10):
|
||||
adapter.invalidate_cache('rows')
|
||||
assert adapter.get_content(NativePlugin(rows), 'rows')
|
||||
|
||||
|
||||
def word_strip(words, letter_w=7, letter_gap=1, item_gap=32, height=DISPLAY_H):
|
||||
"""
|
||||
Build a ticker-like strip: 'words' of solid blocks separated by 1px, with
|
||||
a wide gap between words. Mirrors real rendered text, where the measured
|
||||
gap between characters is a single column and the gap between items is 8px+.
|
||||
|
||||
Returns (image, list of (word_start, word_end) column ranges).
|
||||
"""
|
||||
spans = []
|
||||
x = 0
|
||||
for w_i, letters in enumerate(words):
|
||||
start = x
|
||||
for l_i in range(letters):
|
||||
x += letter_w
|
||||
if l_i < letters - 1:
|
||||
x += letter_gap
|
||||
spans.append((start, x))
|
||||
if w_i < len(words) - 1:
|
||||
x += item_gap
|
||||
img = Image.new('RGB', (x, height), (0, 0, 0))
|
||||
for w_i, letters in enumerate(words):
|
||||
sx = spans[w_i][0]
|
||||
for l_i in range(letters):
|
||||
lx = sx + l_i * (letter_w + letter_gap)
|
||||
img.paste(Image.new('RGB', (letter_w, height), (255, 255, 255)), (lx, 0))
|
||||
return img, spans
|
||||
|
||||
|
||||
class TestCutsNeverSplitWords:
|
||||
"""
|
||||
A cut placed in a 1px inter-letter gap orphans the tail of a word into the
|
||||
next cycle. That is what produced a lone "y" from "Wednesday" floating
|
||||
between two unrelated plugins.
|
||||
"""
|
||||
|
||||
def test_cut_lands_in_an_item_gap_not_between_letters(self):
|
||||
from src.vegas_mode.geometry import blank_runs
|
||||
img, spans = word_strip([9, 9, 9, 9, 9]) # five 9-letter words
|
||||
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.2,
|
||||
min_cut_gap=6)
|
||||
# Budget deliberately lands mid-word if cut naively.
|
||||
images = adapter.get_content(NativePlugin([img]), 'ticker')
|
||||
cut_width = images[0].width
|
||||
|
||||
# Every qualifying gap midpoint is a legal cut; assert we used one.
|
||||
legal = {0, img.width} | {(a + b) // 2 for a, b in blank_runs(img, 6)}
|
||||
assert cut_width in {c for c in legal}, \
|
||||
f"cut at {cut_width} is not an item boundary; legal: {sorted(legal)}"
|
||||
|
||||
def test_no_partial_letter_at_either_edge(self):
|
||||
# A split letter shows as a lit column touching the crop edge with the
|
||||
# rest of its glyph missing. Requiring the edges to be blank is the
|
||||
# simplest way to assert we cut inside a gap.
|
||||
from src.vegas_mode.geometry import column_has_ink
|
||||
img, _ = word_strip([9, 9, 9, 9, 9, 9])
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.25,
|
||||
min_cut_gap=6)
|
||||
out = adapter.get_content(NativePlugin([img]), 'ticker')[0]
|
||||
ink = column_has_ink(out)
|
||||
assert not ink[0] or not ink[-1] or out.width == img.width, \
|
||||
"crop edges land on ink, so a glyph was cut through"
|
||||
|
||||
def test_rotation_never_orphans_a_fragment(self):
|
||||
# Walk the window across the whole strip and assert no slice is a
|
||||
# narrow sliver, which is what an orphaned letter looks like.
|
||||
img, _ = word_strip([9] * 8)
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.2,
|
||||
min_cut_gap=6)
|
||||
widths = []
|
||||
for _ in range(12):
|
||||
adapter.invalidate_cache('ticker')
|
||||
out = adapter.get_content(NativePlugin([img]), 'ticker')
|
||||
assert out
|
||||
widths.append(out[0].width)
|
||||
# A single 7px letter is the fragment signature; nothing that narrow.
|
||||
assert min(widths) > 10, f"orphaned fragment in {widths}"
|
||||
|
||||
def test_continuous_image_is_still_cut_to_budget(self):
|
||||
# A map or chart has no item gaps; the gap rule must not let it escape
|
||||
# the cap, because any column there is as good as another.
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
|
||||
min_cut_gap=6)
|
||||
solid = canvas([(0, 4000)], width=4000)
|
||||
out = adapter.get_content(NativePlugin([solid]), 'geochron')[0]
|
||||
assert out.width == DISPLAY_W
|
||||
|
||||
def test_overruns_budget_rather_than_splitting(self):
|
||||
# One very long item with no internal gap: the cut must wait for the
|
||||
# next real boundary even though that exceeds the budget.
|
||||
img, spans = word_strip([80, 9], letter_gap=1, item_gap=32)
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.1,
|
||||
min_cut_gap=6)
|
||||
out = adapter.get_content(NativePlugin([img]), 'longitem')[0]
|
||||
assert out.width > int(DISPLAY_W * 0.1), \
|
||||
"should overrun to the next boundary instead of cutting the item"
|
||||
|
||||
def test_min_cut_gap_of_two_still_excludes_letter_spacing(self):
|
||||
from src.vegas_mode.geometry import blank_runs
|
||||
img, _ = word_strip([9, 9, 9])
|
||||
# 1px letter gaps must never qualify, whatever the setting.
|
||||
assert all(end - start >= 2 for start, end in blank_runs(img, 2))
|
||||
|
||||
|
||||
def test_permissive_gap_rule_would_have_split_a_word(self):
|
||||
"""
|
||||
Pins the actual regression. With min_cut_gap=1 the 1px gaps between
|
||||
letters qualify as cut points, so the crop lands inside a word; with the
|
||||
default it can only land in the wide gaps between items.
|
||||
"""
|
||||
from src.vegas_mode.geometry import blank_runs
|
||||
img, _ = word_strip([9, 9, 9, 9, 9])
|
||||
|
||||
letter_gaps = {(a + b) // 2 for a, b in blank_runs(img, 1)}
|
||||
item_gaps = {(a + b) // 2 for a, b in blank_runs(img, 6)}
|
||||
|
||||
# The permissive rule offers many more cut points, and the extra ones
|
||||
# are exactly the mid-word positions.
|
||||
assert len(letter_gaps) > len(item_gaps)
|
||||
mid_word = letter_gaps - item_gaps
|
||||
assert mid_word, "expected inter-letter gaps to exist in the fixture"
|
||||
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.2,
|
||||
min_cut_gap=6)
|
||||
out = adapter.get_content(NativePlugin([img]), 'ticker')[0]
|
||||
assert out.width not in mid_word, \
|
||||
f"cut at {out.width} is a mid-word position"
|
||||
|
||||
@@ -924,7 +924,7 @@ def save_main_config():
|
||||
'vegas_max_plugin_width_ratio', 'vegas_dynamic_duration_enabled',
|
||||
'vegas_min_cycle_duration', 'vegas_max_cycle_duration',
|
||||
'vegas_intra_plugin_gap', 'vegas_render_width_pct',
|
||||
'vegas_min_content_separation']
|
||||
'vegas_min_content_separation', 'vegas_min_cut_gap']
|
||||
|
||||
if any(k in data for k in vegas_fields):
|
||||
if 'display' not in current_config:
|
||||
@@ -977,6 +977,7 @@ def save_main_config():
|
||||
'vegas_intra_plugin_gap': ('intra_plugin_gap', 0, 128),
|
||||
'vegas_render_width_pct': ('render_width_pct', 10, 100),
|
||||
'vegas_min_content_separation': ('min_content_separation', 0, 256),
|
||||
'vegas_min_cut_gap': ('min_cut_gap', 1, 128),
|
||||
'vegas_target_fps': ('target_fps', 30, 200),
|
||||
'vegas_buffer_ahead': ('buffer_ahead', 1, 5),
|
||||
'vegas_trim_threshold': ('trim_threshold', 0, 254),
|
||||
|
||||
@@ -623,6 +623,19 @@
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<div class="form-group" id="setting-display-vegas_min_cut_gap" data-setting-key="display.vegas_scroll.min_cut_gap">
|
||||
<label for="vegas_min_cut_gap" class="block text-sm font-medium text-gray-700">Min Cut Gap (pixels){{ ui.help_tip('When a plugin is too wide for its share of a cycle and has to be narrowed, the cut is only made where there is at least this much blank space (1–128 px).\nDefault: 6. The gaps between letters are about 1px wide, so a smaller value lets a cut land inside a word and orphan its last letter into the next cycle. Raise it if cuts still land awkwardly. Continuous images such as maps have no gaps and are cut to fit regardless.', 'Min Cut Gap') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_min_cut_gap"
|
||||
name="vegas_min_cut_gap"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('min_cut_gap', 6) }}"
|
||||
min="1"
|
||||
max="128"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plugin Order Section -->
|
||||
|
||||
Reference in New Issue
Block a user