[light] Fix gamma LUT quantizing small brightness to zero (#15060)

This commit is contained in:
J. Nick Koston
2026-03-22 10:19:28 -10:00
committed by Jesse Hills
parent 67ab2e143c
commit de3292c828
3 changed files with 138 additions and 7 deletions

View File

@@ -81,18 +81,32 @@ def _get_data() -> LightData:
return CORE.data[DOMAIN]
def generate_gamma_table(gamma_correct: float) -> list[HexInt]:
"""Generate a 256-entry uint16 gamma lookup table.
For gamma > 0, non-zero indices are clamped to a minimum of 1 to preserve
the invariant that non-zero input always produces non-zero output. Without
this, small brightness values (e.g. 1%) get quantized to exactly 0.0,
which breaks zero_means_zero logic in FloatOutput.
"""
if gamma_correct > 0:
return [
HexInt(
max(1, min(65535, int(round((i / 255.0) ** gamma_correct * 65535))))
if i > 0
else HexInt(0)
)
for i in range(256)
]
return [HexInt(int(round(i / 255.0 * 65535))) for i in range(256)]
def _get_or_create_gamma_table(gamma_correct):
data = _get_data()
if gamma_correct in data.gamma_tables:
return data.gamma_tables[gamma_correct]
if gamma_correct > 0:
forward = [
HexInt(min(65535, int(round((i / 255.0) ** gamma_correct * 65535))))
for i in range(256)
]
else:
forward = [HexInt(int(round(i / 255.0 * 65535))) for i in range(256)]
forward = generate_gamma_table(gamma_correct)
gamma_str = f"{gamma_correct}".replace(".", "_")
fwd_id = ID(f"gamma_{gamma_str}_fwd", is_declaration=True, type=cg.uint16)

View File

@@ -0,0 +1,117 @@
"""Tests for the gamma LUT table generation."""
import pytest
from esphome.components.light import generate_gamma_table
def _simulate_gamma_correct_lut(table: list[int], value: float) -> float:
"""Simulate the C++ gamma_correct_lut interpolation from light_state.cpp."""
if value <= 0.0:
return 0.0
if value >= 1.0:
return 1.0
scaled = value * 255.0
idx = int(scaled)
if idx >= 255:
return table[255] / 65535.0
frac = scaled - idx
a = float(table[idx])
b = float(table[idx + 1])
return (a + frac * (b - a)) / 65535.0
def test_table_length() -> None:
"""Table must always have exactly 256 entries."""
table = generate_gamma_table(2.8)
assert len(table) == 256
def test_index_zero_is_zero() -> None:
"""Index 0 must be 0 so true off remains off."""
for gamma in (1.0, 2.0, 2.2, 2.8, 3.0):
table = generate_gamma_table(gamma)
assert table[0] == 0, f"gamma={gamma}"
def test_index_255_is_max() -> None:
"""Index 255 must be 65535 (full on)."""
for gamma in (1.0, 2.0, 2.2, 2.8, 3.0):
table = generate_gamma_table(gamma)
assert table[255] == 65535, f"gamma={gamma}"
@pytest.mark.parametrize("gamma", [1.0, 2.0, 2.2, 2.8, 3.0])
def test_nonzero_indices_are_nonzero(gamma: float) -> None:
"""All indices > 0 must produce non-zero values.
This prevents zero_means_zero breakage: non-zero input must always
produce non-zero output so FloatOutput applies min_power scaling.
"""
table = generate_gamma_table(gamma)
for i in range(1, 256):
assert table[i] >= 1, f"gamma={gamma}, index {i}: got {table[i]}"
@pytest.mark.parametrize("gamma", [1.0, 2.0, 2.2, 2.8, 3.0])
def test_table_monotonically_nondecreasing(gamma: float) -> None:
"""The gamma table must be monotonically non-decreasing."""
table = generate_gamma_table(gamma)
for i in range(1, 256):
assert table[i] >= table[i - 1], (
f"gamma={gamma}: table[{i}]={table[i]} < table[{i - 1}]={table[i - 1]}"
)
def test_linear_gamma() -> None:
"""With gamma=0 (linear), table should be evenly spaced."""
table = generate_gamma_table(0)
assert table[0] == 0
assert table[128] == round(128 / 255.0 * 65535)
assert table[255] == 65535
@pytest.mark.parametrize("brightness", [0.01, 0.005, 0.001, 1 / 255])
def test_small_brightness_nonzero_after_lut(brightness: float) -> None:
"""Small but non-zero brightness must produce non-zero output through the LUT.
Regression test for #15055: with zero_means_zero=true, a gamma-corrected
value of exactly 0.0 causes FloatOutput to skip min_power scaling, turning
the LED off instead of to minimum brightness.
"""
table = generate_gamma_table(2.8)
result = _simulate_gamma_correct_lut(table, brightness)
assert result > 0.0, (
f"brightness={brightness}: gamma LUT returned 0.0, would break zero_means_zero"
)
@pytest.mark.parametrize("gamma", [1.0, 2.0, 2.2, 2.8, 3.0])
def test_small_brightness_nonzero_all_gammas(gamma: float) -> None:
"""1% brightness must be non-zero for all common gamma values."""
table = generate_gamma_table(gamma)
result = _simulate_gamma_correct_lut(table, 0.01)
assert result > 0.0, f"gamma={gamma}: 1% brightness returned 0.0"
def test_lut_zero_returns_zero() -> None:
"""LUT with input 0.0 must return 0.0."""
table = generate_gamma_table(2.8)
assert _simulate_gamma_correct_lut(table, 0.0) == 0.0
def test_lut_one_returns_one() -> None:
"""LUT with input 1.0 must return 1.0."""
table = generate_gamma_table(2.8)
assert _simulate_gamma_correct_lut(table, 1.0) == 1.0
def test_lut_output_monotonically_nondecreasing() -> None:
"""LUT output must be monotonically non-decreasing across the full range."""
table = generate_gamma_table(2.8)
prev = 0.0
for i in range(1001):
value = i / 1000.0
result = _simulate_gamma_correct_lut(table, value)
assert result >= prev, f"value={value}: result {result} < previous {prev}"
prev = result