From eaef098d96c8998b52c4c3ba7d7f90023b5cd7c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Mar 2026 08:14:06 -1000 Subject: [PATCH 1/2] [light] Fix gamma LUT quantizing small brightness to zero The gamma LUT refactor (#14123) introduced a regression where small brightness values (e.g. 1%) get quantized to exactly 0.0 because the uint16 LUT entries round down to 0 for indices 1-3 with the default gamma of 2.8. This breaks zero_means_zero: true in FloatOutput because the min_power scaling is skipped when state == 0.0, causing LEDs to turn off completely instead of respecting the configured min_power. Fix by clamping non-zero LUT entries to a minimum of 1, preserving the invariant that non-zero input always produces non-zero output. Fixes #15055 --- esphome/components/light/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index 4403281116..bf382e8f5a 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -88,7 +88,15 @@ def _get_or_create_gamma_table(gamma_correct): if gamma_correct > 0: forward = [ - HexInt(min(65535, int(round((i / 255.0) ** gamma_correct * 65535)))) + # Clamp non-zero indices to 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. + HexInt( + max(1, min(65535, int(round((i / 255.0) ** gamma_correct * 65535)))) + if i > 0 + else HexInt(0) + ) for i in range(256) ] else: From 41ff09d18a870bfc56e7f39d7f5d7212f5348217 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Mar 2026 08:23:52 -1000 Subject: [PATCH 2/2] [light] Extract generate_gamma_table and add tests Extract the gamma table generation into a public generate_gamma_table() function and add unit tests covering table properties and the zero_means_zero regression from #15055. --- esphome/components/light/__init__.py | 28 +++-- tests/unit_tests/components/light/__init__.py | 0 .../components/light/test_gamma_table.py | 117 ++++++++++++++++++ 3 files changed, 134 insertions(+), 11 deletions(-) create mode 100644 tests/unit_tests/components/light/__init__.py create mode 100644 tests/unit_tests/components/light/test_gamma_table.py diff --git a/esphome/components/light/__init__.py b/esphome/components/light/__init__.py index bf382e8f5a..64452e4282 100644 --- a/esphome/components/light/__init__.py +++ b/esphome/components/light/__init__.py @@ -81,17 +81,16 @@ def _get_data() -> LightData: return CORE.data[DOMAIN] -def _get_or_create_gamma_table(gamma_correct): - data = _get_data() - if gamma_correct in data.gamma_tables: - return data.gamma_tables[gamma_correct] +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: - forward = [ - # Clamp non-zero indices to 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. + return [ HexInt( max(1, min(65535, int(round((i / 255.0) ** gamma_correct * 65535)))) if i > 0 @@ -99,8 +98,15 @@ def _get_or_create_gamma_table(gamma_correct): ) for i in range(256) ] - else: - forward = [HexInt(int(round(i / 255.0 * 65535))) 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] + + 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) diff --git a/tests/unit_tests/components/light/__init__.py b/tests/unit_tests/components/light/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit_tests/components/light/test_gamma_table.py b/tests/unit_tests/components/light/test_gamma_table.py new file mode 100644 index 0000000000..a302a355dc --- /dev/null +++ b/tests/unit_tests/components/light/test_gamma_table.py @@ -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