mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
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
This commit is contained in:
@@ -222,16 +222,19 @@ def find_blank_cut(
|
||||
return target
|
||||
|
||||
ink = column_has_ink(img, threshold)
|
||||
lo = max(0, target - search_radius)
|
||||
hi = min(width - 1, target + search_radius)
|
||||
|
||||
# target may legitimately equal width (a cut after the last column), but
|
||||
# there is no column to inspect there, so both bounds stop at width - 1.
|
||||
lo = max(0, min(target - search_radius, width - 1))
|
||||
hi = max(0, min(target + search_radius, width - 1))
|
||||
|
||||
# Walk outwards from target so the nearest gap wins.
|
||||
for offset in range(0, search_radius + 1):
|
||||
right = target + offset
|
||||
if right <= hi and not ink[right]:
|
||||
if lo <= right <= hi and not ink[right]:
|
||||
return right
|
||||
left = target - offset
|
||||
if left >= lo and not ink[left]:
|
||||
if lo <= left <= hi and not ink[left]:
|
||||
return left
|
||||
|
||||
return target
|
||||
|
||||
@@ -928,3 +928,44 @@ class TestBudgetUsesMeasuredGaps:
|
||||
|
||||
p = RenderPipeline(VegasModeConfig(lead_in_width=0, **cfg), DM(), FakeStream())
|
||||
assert p._join_plugin_rows(selected).width <= DISPLAY_W
|
||||
|
||||
|
||||
class TestRotationAcrossMultipleCycles:
|
||||
"""
|
||||
The single-image crop advances a window across cycles. The second and later
|
||||
passes are where start + budget can land exactly on the image width, which
|
||||
crashed find_blank_cut in the field and lost that plugin's content for the
|
||||
cycle. First-pass-only tests never reach it.
|
||||
"""
|
||||
|
||||
def test_window_advances_over_many_cycles_without_error(self):
|
||||
# Mirrors the field case: 1840px stocks strip, 1536px budget, so the
|
||||
# second pass starts at 1536 and start + budget == 3072 -> clamped to
|
||||
# the 1840 width, i.e. target == img.width.
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=3.0)
|
||||
strip = canvas([(0, 1840)], width=1840)
|
||||
widths = []
|
||||
for _ in range(8):
|
||||
adapter.invalidate_cache('ledmatrix-stocks')
|
||||
images = adapter.get_content(NativePlugin([strip]), 'ledmatrix-stocks')
|
||||
assert images, "content must never be lost mid-rotation"
|
||||
widths.append(images[0].width)
|
||||
assert all(w > 0 for w in widths)
|
||||
|
||||
def test_offset_wraps_back_to_zero_at_the_end(self):
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=3.0)
|
||||
strip = canvas([(0, 1840)], width=1840)
|
||||
seen_reset = False
|
||||
for _ in range(6):
|
||||
adapter.invalidate_cache('s')
|
||||
adapter.get_content(NativePlugin([strip]), 's')
|
||||
if adapter._item_offsets.get('s', 0) == 0:
|
||||
seen_reset = True
|
||||
assert seen_reset, "window should wrap round rather than stall at the end"
|
||||
|
||||
def test_multi_row_rotation_never_returns_empty(self):
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
|
||||
rows = [canvas([(0, 200)], width=200) for _ in range(7)]
|
||||
for _ in range(10):
|
||||
adapter.invalidate_cache('rows')
|
||||
assert adapter.get_content(NativePlugin(rows), 'rows')
|
||||
|
||||
@@ -10,6 +10,7 @@ from src.vegas_mode.geometry import (
|
||||
content_bounds,
|
||||
dead_window_stats,
|
||||
edge_blank,
|
||||
find_blank_cut,
|
||||
separation_gap,
|
||||
trim_to_content,
|
||||
window_coverage_stats,
|
||||
@@ -319,3 +320,51 @@ class TestSeparationGap:
|
||||
# flat gap; measured separation lifts them to the 24px target.
|
||||
card = paint(make_img(150), 0, 150)
|
||||
assert separation_gap(card, card, target=24, minimum=8) == 24
|
||||
|
||||
|
||||
class TestFindBlankCut:
|
||||
def test_snaps_to_the_nearest_gap(self):
|
||||
img = paint(make_img(200), 0, 90)
|
||||
paint(img, 110, 200)
|
||||
# 100 is inside the 90..110 gap already.
|
||||
assert find_blank_cut(img, 100, 20) == 100
|
||||
|
||||
def test_walks_outwards_to_find_a_gap(self):
|
||||
img = paint(make_img(200), 0, 95)
|
||||
paint(img, 105, 200)
|
||||
cut = find_blank_cut(img, 90, 20)
|
||||
assert 95 <= cut < 105
|
||||
|
||||
def test_solid_ink_returns_the_target(self):
|
||||
assert find_blank_cut(paint(make_img(200), 0, 200), 100, 20) == 100
|
||||
|
||||
def test_target_at_image_width_does_not_index_past_the_end(self):
|
||||
# A cut after the last column is legal. Indexing ink[width] raised
|
||||
# IndexError in the field, losing that plugin's content for the cycle.
|
||||
# Reached once the rotation offset advances so start + budget lands
|
||||
# exactly on the image width.
|
||||
img = paint(make_img(1840), 0, 1840)
|
||||
assert find_blank_cut(img, 1840, 32) == 1840
|
||||
|
||||
def test_target_past_image_width_is_clamped(self):
|
||||
img = paint(make_img(100), 0, 100)
|
||||
assert find_blank_cut(img, 500, 32) == 100
|
||||
|
||||
def test_target_at_width_with_a_trailing_gap_snaps_back(self):
|
||||
# Content 0..179, blank 180..199. The nearest blank column to 200 is
|
||||
# 199, not the start of the gap — nearest is what keeps the cut as
|
||||
# close as possible to the requested budget.
|
||||
img = paint(make_img(200), 0, 180)
|
||||
assert find_blank_cut(img, 200, 32) == 199
|
||||
|
||||
def test_zero_radius_returns_the_target(self):
|
||||
assert find_blank_cut(paint(make_img(100), 0, 100), 50, 0) == 50
|
||||
|
||||
def test_negative_target_is_clamped_to_zero(self):
|
||||
assert find_blank_cut(paint(make_img(100), 0, 100), -20, 8) == 0
|
||||
|
||||
@pytest.mark.parametrize("target", [0, 1, 50, 99, 100])
|
||||
def test_never_raises_across_the_range(self, target):
|
||||
img = paint(make_img(100), 0, 100)
|
||||
cut = find_blank_cut(img, target, 16)
|
||||
assert 0 <= cut <= 100
|
||||
|
||||
Reference in New Issue
Block a user