mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
* fix(display): tear down Vegas mode on controller cleanup DisplayController.cleanup() never called VegasModeCoordinator.cleanup(), so the Vegas teardown (stop, pipeline/stream reset, adapter cache drop) was unreachable. Call it before the display manager is cleaned up, and skip it when Vegas was never created. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(vegas): default max_cycle_duration to the documented 240s The template, the web UI help, CONFIG_REFERENCE and the controller all say 240, but the code defaulted to 600 in two places, so a config without the key ran Vegas iterations 2.5x longer than documented. from_config now falls back to the dataclass field defaults instead of repeating each one, so the two copies can no longer drift, and the controller's follower scroll-speed default reads VegasModeConfig's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(display): let run.py -d show display_manager's DEBUG output display_manager pinned its logger to INFO at import, overriding the root level, so debug mode never showed its DEBUG lines. Use get_logger() from src.logging_config like the rest of the core and leave the level to the logging setup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(display): run each startup validation check once StartupValidator.validate_all() ran twice at boot, before and after the plugin manager was created, so every config, cache, display and systemd-unit warning was logged twice. The second pass now runs only the plugin checks. Drop the commented-out raise_on_errors line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(vegas): one INFO line per plugin-list refresh StreamManager logged "=" * 60 banners and a line per plugin (INCLUDED, SKIPPED, FETCHING CONTENT, SEGMENT CREATED) at INFO on every refresh and fetch, i.e. at each cycle start and every 30s. Log one INFO summary of the rotation per refresh and move the per-plugin detail, the weighting breakdown and "no content this cycle" to DEBUG (the adapter still warns when every content path fails). Also drop the check/cross marks from the controller's log messages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(vegas): drop the per-iteration static-mode plugin scan run_iteration() rebuilt _static_mode_plugins on every iteration, asking every plugin for its display mode and logging the set at INFO, but nothing ever read it: static pauses are triggered by _check_static_plugin_trigger() from the next segment. Delete it, the coordinator's get_ordered_plugins() that only it used, and the write-only _static_pause_plugin / _static_pause_start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(vegas): remove the staging buffer that was never filled StreamManager and RenderPipeline carried a double-buffer design that nothing used: _staging_buffer was only ever cleared or swapped, so swap_buffers() never did anything and should_recompose()'s staging_count > 0 branch was dead, and _active_scroll_image, _staging_scroll_image, _is_rendering, _last_frame_time and _frame_interval were written but never read. Delete the machinery and rewrite the docstrings around what actually carries updates: _pending_updates, consumed by process_updates() in swap mode and invalidate_pending_updates() in continuous mode. should_recompose() no longer builds a buffer-status dict every frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(display): tidy the display controller without changing behaviour - Import VegasModeCoordinator locally instead of through module globals (there is no circular import to avoid). - Drop hasattr() checks on attributes PluginManager.__init__ always sets (plugin_executor, plugin_last_update, get_plugin_lock, run_scheduled_updates*, stop_update_worker) and the dead "older manager" fallbacks; keep the health_tracker None checks, now via _health_tracker(). - Extract _display_once() for the per-frame display call both render loops copied, _advance_on_demand() for the two on-demand rotations, _reset_on_demand_fields() for the error and clear paths, and _timezone() / _in_window() for the two schedule checks. - Remove always-true conditions and the unreachable non-plugin else branch in run(), and read _was_display_active / _last_published_mode / vegas_coordinator directly now that __init__ declares them. - Declare the follower render state in __init__, name its tuning constants, add _follower_sign(), and share the 90/s sync send interval with the render pipeline (SYNC_SEND_INTERVAL). - Delete history narration and the "Opt #N" labels, fix the comment that called _scroll_speed constant (hot reload updates it), and drop a startup timing log that measured nothing. - render_pipeline / plugin_adapter: read display_manager.width/height as the properties they are, drop an empty TYPE_CHECKING block, an aliased threading import and a duplicated `if result and self.sync_manager:`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(display): trim dead code from display_manager - Add _new_canvas() for the image/draw/fontmode="1" setup that was copied six times. - Call resolve_double_sided() and compose_pixel_mapper_config() directly instead of through a module alias and a passthrough method, and replace the comment that said the passthrough read class attributes. - Delete the unused _initialized flag and _ORIENTATION_ROTATE_DEGREES alias (no core or monorepo reader; tests stop resetting the flag), the test pattern's unreachable no-matrix branch (it only runs once the matrix exists), `del old_image # help GC` (a no-op on a local), a duplicated early return in process_deferred_updates, and stale comments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(vegas): remove unread fields and test-only helpers, fix docstrings - ContentSegment: drop total_width, fetched_at, is_stale, image_count and is_static, none of which is read. - StreamManager: drop _current_index (never advanced) and the test-only get_all_content_for_composition() and has_pending_updates(); VegasModeConfig: drop the test-only is_plugin_included(). - geometry.find_blank_cut() has had no production caller since the crop moved to item boundaries; delete it and its tests. - PluginAdapter: the _finalize docstring described separator_width between every image, and _crop_to_budget's said cuts snap to the nearest blank column; both now describe what the code does. - Coordinator: the static-pause interrupt log no longer blames follower mode for every interrupt, and set_update_callback names the callback the controller actually wires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(scroll): correct ScrollHelper comments and drop dead branches - Four comments said the strip always starts with display_width of blank; it does only when lead_gap is None (Vegas passes its own). - Delete the "Width calculation mismatch" warning: the image is created at the calculated width, so the two can never differ. - Remove the two scroll_delay <= 0 fallbacks (which disagreed with each other): set_scroll_delay clamps it to at least 0.001 and nothing in core or the plugin monorepo assigns it directly. - Trim the scipy history from the blend docstring. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(run): drop a redundant comment Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(display): log set_scrolling_state only when it changes Vegas and scrolling plugins set the scrolling state every frame, so once display_manager's DEBUG output became visible in debug mode it printed "Scrolling state set to: True" about 120 times a second. Log only when the value differs from the previous one; the state, activity timestamp and frame hold still update on every call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): display-vegas Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
322 lines
13 KiB
Python
322 lines
13 KiB
Python
"""Tests for Vegas mode geometry primitives."""
|
|
|
|
import numpy as np
|
|
import pytest
|
|
from PIL import Image
|
|
|
|
from src.vegas_mode.geometry import (
|
|
DEFAULT_INK_THRESHOLD,
|
|
column_has_ink,
|
|
content_bounds,
|
|
dead_window_stats,
|
|
edge_blank,
|
|
separation_gap,
|
|
trim_to_content,
|
|
window_coverage_stats,
|
|
)
|
|
|
|
|
|
def make_img(width, height=8, fill=(0, 0, 0)):
|
|
return Image.new('RGB', (width, height), fill)
|
|
|
|
|
|
def paint(img, x0, x1, color=(255, 255, 255)):
|
|
"""Fill columns [x0, x1) with a colour."""
|
|
block = Image.new('RGB', (x1 - x0, img.height), color)
|
|
img.paste(block, (x0, 0))
|
|
return img
|
|
|
|
|
|
class TestColumnHasInk:
|
|
def test_all_black_has_no_ink(self):
|
|
assert not column_has_ink(make_img(16)).any()
|
|
|
|
def test_marks_only_painted_columns(self):
|
|
img = paint(make_img(16), 4, 8)
|
|
ink = column_has_ink(img)
|
|
assert ink.tolist() == [False] * 4 + [True] * 4 + [False] * 8
|
|
|
|
def test_threshold_is_exclusive(self):
|
|
# A pixel exactly at the threshold is not ink; one above it is.
|
|
at = paint(make_img(4), 0, 4, (DEFAULT_INK_THRESHOLD,) * 3)
|
|
above = paint(make_img(4), 0, 4, (DEFAULT_INK_THRESHOLD + 1,) * 3)
|
|
assert not column_has_ink(at).any()
|
|
assert column_has_ink(above).all()
|
|
|
|
def test_single_bright_channel_counts(self):
|
|
img = paint(make_img(4), 1, 2, (0, 0, 200))
|
|
assert column_has_ink(img).tolist() == [False, True, False, False]
|
|
|
|
def test_one_lit_pixel_lights_the_column(self):
|
|
img = make_img(4, height=8)
|
|
img.putpixel((2, 5), (255, 255, 255))
|
|
assert column_has_ink(img).tolist() == [False, False, True, False]
|
|
|
|
|
|
class TestContentBounds:
|
|
def test_blank_returns_none(self):
|
|
assert content_bounds(make_img(16)) is None
|
|
|
|
def test_finds_inclusive_bounds(self):
|
|
assert content_bounds(paint(make_img(20), 5, 12)) == (5, 11)
|
|
|
|
def test_full_width_content(self):
|
|
assert content_bounds(paint(make_img(10), 0, 10)) == (0, 9)
|
|
|
|
def test_spans_interior_gap(self):
|
|
img = paint(make_img(30), 2, 5)
|
|
paint(img, 20, 25)
|
|
assert content_bounds(img) == (2, 24)
|
|
|
|
|
|
class TestTrimToContent:
|
|
def test_blank_image_reports_blank(self):
|
|
result = trim_to_content(make_img(512))
|
|
assert result.is_blank
|
|
assert result.image is None
|
|
assert result.width == 0
|
|
assert result.original_width == 512
|
|
|
|
def test_trims_both_edges(self):
|
|
result = trim_to_content(paint(make_img(512), 100, 150))
|
|
assert not result.is_blank
|
|
assert result.width == 50
|
|
assert result.trimmed_left == 100
|
|
assert result.trimmed_right == 362
|
|
assert result.removed == 462
|
|
|
|
def test_preserves_interior_gap(self):
|
|
# Two content blocks with a wide blank between them: the gap is the
|
|
# plugin's layout and must survive trimming.
|
|
img = paint(make_img(400), 50, 80)
|
|
paint(img, 300, 330)
|
|
result = trim_to_content(img)
|
|
assert result.width == 280 # 50..329 inclusive
|
|
assert column_has_ink(result.image).sum() == 60
|
|
|
|
def test_full_width_content_is_returned_unchanged(self):
|
|
img = paint(make_img(128), 0, 128)
|
|
result = trim_to_content(img)
|
|
assert result.image is img
|
|
assert result.removed == 0
|
|
|
|
def test_non_black_background_is_never_trimmed(self):
|
|
# A plugin drawing on a dark-but-not-black background fills every
|
|
# column with ink, so there is nothing to reclaim.
|
|
result = trim_to_content(make_img(256, fill=(0, 0, 40)))
|
|
assert result.removed == 0
|
|
assert result.width == 256
|
|
|
|
def test_padding_keeps_margin_up_to_what_exists(self):
|
|
result = trim_to_content(paint(make_img(512), 100, 150), padding=8)
|
|
assert result.trimmed_left == 92
|
|
assert result.width == 66 # 50 content + 8 each side
|
|
|
|
def test_padding_cannot_widen_beyond_original(self):
|
|
# Content starts 2px in; padding of 8 can only reclaim the 2 available.
|
|
result = trim_to_content(paint(make_img(64), 2, 60), padding=8)
|
|
assert result.trimmed_left == 0
|
|
assert result.trimmed_right == 0
|
|
assert result.width == 64
|
|
|
|
def test_height_is_preserved(self):
|
|
result = trim_to_content(paint(make_img(200, height=64), 10, 20))
|
|
assert result.image.height == 64
|
|
|
|
def test_real_world_of_the_day_case(self):
|
|
# Measured on devpi: "No Data" occupying 35px of a 512px canvas.
|
|
result = trim_to_content(paint(make_img(512, height=64), 4, 39))
|
|
assert result.width == 35
|
|
assert result.removed == 477
|
|
|
|
|
|
class TestDeadWindowStats:
|
|
def test_fully_inked_ticker_has_no_dead_windows(self):
|
|
stats = dead_window_stats(paint(make_img(400), 0, 400), viewport_width=100)
|
|
assert stats.dead_windows == 0
|
|
assert stats.dead_ratio == 0.0
|
|
assert stats.longest_dead_run == 0
|
|
|
|
def test_fully_blank_ticker_is_all_dead(self):
|
|
stats = dead_window_stats(make_img(400), viewport_width=100)
|
|
assert stats.total_windows == 301
|
|
assert stats.dead_windows == 301
|
|
assert stats.dead_ratio == 1.0
|
|
assert stats.longest_dead_run == 301
|
|
|
|
def test_leading_blank_run_is_measured(self):
|
|
# 512px of black then solid content: windows fully inside the black
|
|
# stretch are dead. With a 100px viewport, starts 0..412 exist and a
|
|
# window is dead while it holds >=95 blank columns.
|
|
img = paint(make_img(1024), 512, 1024)
|
|
stats = dead_window_stats(img, viewport_width=100)
|
|
assert stats.dead_windows == 418 # starts 0..417 keep >=95 blank cols
|
|
assert stats.longest_dead_run == 418
|
|
|
|
def test_narrow_content_island_still_leaves_dead_windows(self):
|
|
# 35px of content in a 512px field, viewed 100px at a time: no window
|
|
# can be 95% blank once it overlaps 35 lit columns, but the windows
|
|
# clear of it are dead.
|
|
img = paint(make_img(512), 100, 135)
|
|
stats = dead_window_stats(img, viewport_width=100)
|
|
assert stats.dead_windows > 0
|
|
assert stats.dead_ratio == pytest.approx(
|
|
stats.dead_windows / stats.total_windows
|
|
)
|
|
|
|
def test_step_reduces_sampling(self):
|
|
img = paint(make_img(1000), 500, 1000)
|
|
exact = dead_window_stats(img, viewport_width=100, step=1)
|
|
strided = dead_window_stats(img, viewport_width=100, step=10)
|
|
assert strided.total_windows < exact.total_windows
|
|
# Same underlying shape, so the ratios should stay close.
|
|
assert strided.dead_ratio == pytest.approx(exact.dead_ratio, abs=0.02)
|
|
|
|
def test_image_narrower_than_viewport_is_one_window(self):
|
|
stats = dead_window_stats(make_img(50), viewport_width=100)
|
|
assert stats.total_windows == 1
|
|
assert stats.dead_windows == 1
|
|
|
|
def test_zero_viewport_is_handled(self):
|
|
stats = dead_window_stats(make_img(50), viewport_width=0)
|
|
assert stats.total_windows == 0
|
|
assert stats.dead_ratio == 0.0
|
|
|
|
def test_longest_run_picks_the_larger_of_two_gaps(self):
|
|
# Short blank gap, content, then a long blank gap.
|
|
img = make_img(1000)
|
|
paint(img, 150, 400)
|
|
paint(img, 500, 520)
|
|
stats = dead_window_stats(img, viewport_width=100)
|
|
# The 400..500 gap is only 100 wide; the tail from 520 is 480 wide.
|
|
assert stats.longest_dead_run >= 380
|
|
|
|
|
|
class TestWindowCoverageStats:
|
|
def test_solid_content_is_fully_covered(self):
|
|
stats = window_coverage_stats(paint(make_img(600), 0, 600), viewport_width=100)
|
|
assert stats.mean_ink_ratio == 1.0
|
|
assert stats.min_ink_ratio == 1.0
|
|
assert stats.sparse_windows == 0
|
|
|
|
def test_blank_strip_is_entirely_sparse(self):
|
|
stats = window_coverage_stats(make_img(600), viewport_width=100)
|
|
assert stats.mean_ink_ratio == 0.0
|
|
assert stats.sparse_ratio == 1.0
|
|
|
|
def test_catches_sliver_windows_that_dead_ratio_misses(self):
|
|
# Narrow content islands separated by more than the viewport. A window
|
|
# holding one whole 40px island carries 472 blank columns — under the
|
|
# 486 needed to count as "dead" — yet only 7.8% ink, so it still reads
|
|
# as an empty panel. Coverage must flag strictly more positions than
|
|
# the dead-window scan does.
|
|
img = paint(make_img(2000), 0, 40)
|
|
paint(img, 1000, 1040)
|
|
dead = dead_window_stats(img, viewport_width=512)
|
|
cover = window_coverage_stats(img, viewport_width=512, sparse_ink_ratio=0.10)
|
|
assert cover.sparse_windows > dead.dead_windows
|
|
assert cover.min_ink_ratio == 0.0
|
|
|
|
def test_adjacent_full_width_segments_stay_partially_covered(self):
|
|
# Documents why the dead-window scan alone understated the problem:
|
|
# two 512px segments with mid-canvas content never fully blank the
|
|
# viewport, they just hold it at a thin ~28%.
|
|
img = paint(make_img(1024), 185, 330)
|
|
paint(img, 697, 842)
|
|
dead = dead_window_stats(img, viewport_width=512)
|
|
cover = window_coverage_stats(img, viewport_width=512)
|
|
assert dead.dead_windows == 0
|
|
assert cover.mean_ink_ratio == pytest.approx(0.283, abs=0.01)
|
|
|
|
def test_min_ink_ratio_finds_the_worst_position(self):
|
|
# A wide blank tail guarantees at least one totally empty viewport.
|
|
img = paint(make_img(1200), 0, 200)
|
|
stats = window_coverage_stats(img, viewport_width=200)
|
|
assert stats.min_ink_ratio == 0.0
|
|
assert stats.mean_ink_ratio > 0.0
|
|
|
|
def test_sparse_threshold_is_respected(self):
|
|
# 40 inked columns in a 200px viewport = 20% coverage everywhere the
|
|
# island is fully inside the window.
|
|
img = paint(make_img(400), 100, 140)
|
|
lenient = window_coverage_stats(img, viewport_width=200, sparse_ink_ratio=0.05)
|
|
strict = window_coverage_stats(img, viewport_width=200, sparse_ink_ratio=0.50)
|
|
assert strict.sparse_windows > lenient.sparse_windows
|
|
|
|
def test_step_approximates_exact_scan(self):
|
|
img = paint(make_img(2000), 300, 500)
|
|
paint(img, 1200, 1400)
|
|
exact = window_coverage_stats(img, viewport_width=512, step=1)
|
|
strided = window_coverage_stats(img, viewport_width=512, step=4)
|
|
assert strided.mean_ink_ratio == pytest.approx(exact.mean_ink_ratio, abs=0.01)
|
|
|
|
def test_zero_viewport_is_handled(self):
|
|
stats = window_coverage_stats(make_img(50), viewport_width=0)
|
|
assert stats.total_windows == 0
|
|
assert stats.sparse_ratio == 0.0
|
|
|
|
def test_image_narrower_than_viewport(self):
|
|
stats = window_coverage_stats(paint(make_img(50), 0, 50), viewport_width=100)
|
|
assert stats.total_windows == 1
|
|
assert stats.mean_ink_ratio == pytest.approx(0.5)
|
|
|
|
|
|
class TestLongestRunHelper:
|
|
@pytest.mark.parametrize("flags,expected", [
|
|
([], 0),
|
|
([False, False], 0),
|
|
([True], 1),
|
|
([True, True, False, True], 2),
|
|
([False, True, True, True, False, True], 3),
|
|
([True, True, True], 3),
|
|
])
|
|
def test_run_lengths(self, flags, expected):
|
|
from src.vegas_mode.geometry import _longest_true_run
|
|
assert _longest_true_run(np.array(flags, dtype=bool)) == expected
|
|
|
|
|
|
class TestEdgeBlank:
|
|
def test_measures_both_edges(self):
|
|
assert edge_blank(paint(make_img(100), 20, 60)) == (20, 40)
|
|
|
|
def test_flush_content_has_no_blank(self):
|
|
assert edge_blank(paint(make_img(50), 0, 50)) == (0, 0)
|
|
|
|
def test_blank_image_reports_full_width_both_sides(self):
|
|
# No ink means nothing to be close to.
|
|
assert edge_blank(make_img(64)) == (64, 64)
|
|
|
|
|
|
class TestSeparationGap:
|
|
def test_flush_edges_get_the_full_target(self):
|
|
a = paint(make_img(50), 0, 50)
|
|
b = paint(make_img(50), 0, 50)
|
|
assert separation_gap(a, b, target=24) == 24
|
|
|
|
def test_existing_margins_reduce_the_added_gap(self):
|
|
# 8px blank on each facing edge already covers 16 of the 24 target.
|
|
a = paint(make_img(50), 0, 42)
|
|
b = paint(make_img(50), 8, 50)
|
|
assert separation_gap(a, b, target=24) == 8
|
|
|
|
def test_ample_existing_margin_adds_nothing(self):
|
|
a = paint(make_img(100), 0, 60)
|
|
b = paint(make_img(100), 40, 100)
|
|
assert separation_gap(a, b, target=24) == 0
|
|
|
|
def test_minimum_is_a_floor(self):
|
|
a = paint(make_img(100), 0, 60)
|
|
b = paint(make_img(100), 40, 100)
|
|
assert separation_gap(a, b, target=24, minimum=4) == 4
|
|
|
|
def test_never_negative(self):
|
|
a = paint(make_img(200), 0, 10)
|
|
b = paint(make_img(200), 190, 200)
|
|
assert separation_gap(a, b, target=8) == 0
|
|
|
|
def test_sports_card_case_gets_real_separation(self):
|
|
# The reported problem: cards drawn edge to edge sat 8px apart under a
|
|
# 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
|