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.
93 lines
3.3 KiB
Python
93 lines
3.3 KiB
Python
"""Tests for linear-light blending in the per-LED reduction.
|
|
|
|
Verifies the sRGB↔linear conversion correctness and that the edge-reduction
|
|
kernel, when ``linear=True``, blends in linear light (a mid-grey from black +
|
|
white is brighter than the gamma-space mean) — plus the CalibrationConfig
|
|
round-trip of the opt-in flag.
|
|
"""
|
|
|
|
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.linear_light import (
|
|
SRGB_TO_LINEAR_LUT,
|
|
linear_to_srgb_uint8,
|
|
srgb_to_linear,
|
|
)
|
|
|
|
|
|
class TestConversions:
|
|
def test_lut_endpoints_and_monotonic(self):
|
|
assert SRGB_TO_LINEAR_LUT.shape == (256,)
|
|
assert SRGB_TO_LINEAR_LUT[0] == 0.0
|
|
assert abs(SRGB_TO_LINEAR_LUT[255] - 1.0) < 1e-6
|
|
assert np.all(np.diff(SRGB_TO_LINEAR_LUT) > 0) # strictly increasing
|
|
|
|
def test_mid_grey_decodes_below_half(self):
|
|
# sRGB 0.5 (≈128) is ~0.214 in linear light, not 0.5.
|
|
assert 0.18 < float(SRGB_TO_LINEAR_LUT[128]) < 0.25
|
|
|
|
def test_round_trip_is_near_identity(self):
|
|
ramp = np.arange(256, dtype=np.uint8)
|
|
back = linear_to_srgb_uint8(srgb_to_linear(ramp))
|
|
assert np.max(np.abs(back.astype(int) - ramp.astype(int))) <= 1
|
|
|
|
def test_linear_mean_of_black_and_white_is_brighter(self):
|
|
lin = (srgb_to_linear(np.array([0, 255], dtype=np.uint8))).mean()
|
|
encoded = int(linear_to_srgb_uint8(np.array([lin], dtype=np.float32))[0])
|
|
assert encoded > 127 # brighter than the sRGB mean (127)
|
|
assert 180 < encoded < 195 # ~188
|
|
|
|
|
|
class TestEdgeReductionLinear:
|
|
def _edge(self):
|
|
# top edge (axis=0): shape (rows=2, width=4, 3); two black + two white cols
|
|
e = np.zeros((2, 4, 3), dtype=np.uint8)
|
|
e[:, 2:, :] = 255
|
|
return e
|
|
|
|
def test_srgb_blend_is_plain_mean(self):
|
|
out = average_edge_to_leds(self._edge(), "top", 1, {}, "k", linear=False)
|
|
assert int(out[0, 0]) == 127
|
|
|
|
def test_linear_blend_is_brighter(self):
|
|
out = average_edge_to_leds(self._edge(), "top", 1, {}, "k", linear=True)
|
|
assert int(out[0, 0]) > 127
|
|
assert 180 < int(out[0, 0]) < 195
|
|
|
|
def test_uniform_edge_unchanged_by_linear(self):
|
|
e = np.full((2, 4, 3), 200, dtype=np.uint8)
|
|
out = average_edge_to_leds(e, "top", 1, {}, "k", linear=True)
|
|
# A flat colour survives the decode→mean→encode round-trip (±1).
|
|
assert abs(int(out[0, 0]) - 200) <= 1
|
|
|
|
|
|
class TestCalibrationRoundTrip:
|
|
def test_linear_blend_round_trips_simple(self):
|
|
cfg = calibration_from_dict(
|
|
{
|
|
"mode": "simple",
|
|
"layout": "clockwise",
|
|
"start_position": "bottom_left",
|
|
"leds_top": 5,
|
|
"linear_blend": True,
|
|
}
|
|
)
|
|
assert cfg.linear_blend is True
|
|
assert calibration_to_dict(cfg).get("linear_blend") is True
|
|
|
|
def test_default_is_off_and_omitted(self):
|
|
cfg = calibration_from_dict(
|
|
{
|
|
"mode": "simple",
|
|
"layout": "clockwise",
|
|
"start_position": "bottom_left",
|
|
"leds_top": 5,
|
|
}
|
|
)
|
|
assert cfg.linear_blend is False
|
|
assert "linear_blend" not in calibration_to_dict(cfg)
|