Files
ledgrab/server/tests/test_linear_light.py
alexei.dolgolyov 6745e25b20 feat: roadmap batch (2026-06-19) — solar/linear-light/dither/nanoleaf + integrations
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.
2026-06-22 23:21:24 +03:00

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)