6745e25b20
Eight roadmap features from the 2026-06-19 review, each a full vertical (backend + tests + frontend + i18n en/ru/zh); ~67 new unit tests: - automations: SolarRule sunrise/sunset trigger (new utils/solar.py, shared with the daylight cycle; window logic mirrors TimeOfDayRule) - ci: best-effort arm64 multi-arch Docker manifest via QEMU + docker manifest (release.yml; amd64 path untouched, continue-on-error) - game-integration: wire the orphaned LoLPoller via a LoLPollManager + a shared runtime_state module (poll lifecycle on enable/CRUD/startup/shutdown) - ui: color-harmony gradient generator (complementary/analogous/triadic/...) - effects: audio-reactive palette modulation (new audio_energy_tap; brightness/ saturation modulation across all 12 procedural effects) - capture: linear-light blending + spatio-temporal dithering, opt-in per calibration (new utils/linear_light.py, utils/dither.py) - devices: Nanoleaf extControl v2 per-panel UDP streaming (per_panel mode) Also bundles the pending 2026-06-18 production-review fixes and other in-progress work already in the working tree (manual-trigger rule, etc.), since they share files and could not be cleanly separated. Gate: ruff + tsc clean; pytest 2654 passed / 2 skipped. The single failing test (automation manual_trigger handler coverage) is a separate in-progress item owned elsewhere, intentionally left as-is.
114 lines
4.2 KiB
Python
114 lines
4.2 KiB
Python
"""Tests for spatio-temporal dithering of the final 8-bit quantization.
|
|
|
|
The load-bearing property is *temporal convergence*: over many frames, the
|
|
dithered output time-averages back to the true sub-integer value (that's what
|
|
lets the eye see a higher effective bit depth instead of banding).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
|
|
from ledgrab.core.capture.calibration import calibration_from_dict, calibration_to_dict
|
|
from ledgrab.core.capture.edge_interpolation import average_edge_to_leds
|
|
from ledgrab.utils.dither import ordered_dither_quantize
|
|
|
|
|
|
class TestOrderedDither:
|
|
def test_single_frame_is_floor_or_ceil(self):
|
|
vals = np.array([[100.4, 50.0, 200.7]], dtype=np.float32)
|
|
out = ordered_dither_quantize(vals, frame_index=7)
|
|
assert out.dtype == np.uint8
|
|
assert out[0, 0] in (100, 101)
|
|
assert out[0, 1] == 50 # exact integer never moves
|
|
assert out[0, 2] in (200, 201)
|
|
|
|
def test_integers_are_stable_across_frames(self):
|
|
vals = np.array([[10.0, 20.0, 30.0]], dtype=np.float32)
|
|
for f in range(20):
|
|
out = ordered_dither_quantize(vals, frame_index=f)
|
|
assert list(out[0]) == [10, 20, 30]
|
|
|
|
def test_temporal_average_converges_to_true_value(self):
|
|
vals = np.array([[100.4, 50.9, 200.25]], dtype=np.float32)
|
|
acc = np.zeros(3, dtype=np.float64)
|
|
n = 2000
|
|
for f in range(n):
|
|
acc += ordered_dither_quantize(vals, frame_index=f)[0]
|
|
avg = acc / n
|
|
assert abs(avg[0] - 100.4) < 0.3
|
|
assert abs(avg[1] - 50.9) < 0.3
|
|
assert abs(avg[2] - 200.25) < 0.3
|
|
|
|
def test_same_threshold_across_channels_preserves_hue_steps(self):
|
|
# All three channels share the per-LED threshold, so an equal-channel
|
|
# grey never splits into a coloured pixel.
|
|
vals = np.array([[123.5, 123.5, 123.5]], dtype=np.float32)
|
|
for f in range(50):
|
|
out = ordered_dither_quantize(vals, frame_index=f)
|
|
assert out[0, 0] == out[0, 1] == out[0, 2]
|
|
|
|
def test_clips_to_byte_range(self):
|
|
vals = np.array([[-5.0, 255.9, 300.0]], dtype=np.float32)
|
|
out = ordered_dither_quantize(vals, frame_index=3)
|
|
assert out[0, 0] == 0
|
|
assert out[0, 1] == 255
|
|
assert out[0, 2] == 255
|
|
|
|
|
|
class TestEdgeReductionDither:
|
|
def _half_edge(self):
|
|
# 2 columns (black, white) → 1 LED whose mean is exactly 127.5, i.e. a
|
|
# value the plain uint8 path can only represent as 127.
|
|
e = np.zeros((2, 2, 3), dtype=np.uint8)
|
|
e[:, 1, :] = 255
|
|
return e
|
|
|
|
def test_dithered_output_varies_by_frame(self):
|
|
edge = self._half_edge()
|
|
seen = {
|
|
int(average_edge_to_leds(edge, "top", 1, {}, "k", dither=True, frame_index=f)[0, 0])
|
|
for f in range(50)
|
|
}
|
|
# The 127.5 LED straddles two codes → dither flips it across frames.
|
|
assert seen == {127, 128}
|
|
|
|
def test_temporal_average_recovers_the_half_step(self):
|
|
edge = self._half_edge()
|
|
# Plain quantization truncates 127.5 → 127; dither recovers ~127.5.
|
|
plain = int(average_edge_to_leds(edge, "top", 1, {}, "k")[0, 0])
|
|
acc = np.zeros(3, dtype=np.float64)
|
|
n = 1500
|
|
for f in range(n):
|
|
acc += average_edge_to_leds(edge, "top", 1, {}, "k", dither=True, frame_index=f)[0]
|
|
avg = acc[0] / n
|
|
assert plain == 127
|
|
assert abs(avg - 127.5) < 0.2
|
|
|
|
|
|
class TestCalibrationRoundTrip:
|
|
def test_dither_round_trips(self):
|
|
cfg = calibration_from_dict(
|
|
{
|
|
"mode": "simple",
|
|
"layout": "clockwise",
|
|
"start_position": "bottom_left",
|
|
"leds_top": 5,
|
|
"dither": True,
|
|
}
|
|
)
|
|
assert cfg.dither is True
|
|
assert calibration_to_dict(cfg).get("dither") is True
|
|
|
|
def test_dither_default_off_and_omitted(self):
|
|
cfg = calibration_from_dict(
|
|
{
|
|
"mode": "simple",
|
|
"layout": "clockwise",
|
|
"start_position": "bottom_left",
|
|
"leds_top": 5,
|
|
}
|
|
)
|
|
assert cfg.dither is False
|
|
assert "dither" not in calibration_to_dict(cfg)
|