vendor: add closed-form RGB→PWM calculator for ALM tests
Pure-Python port of the Input Sheet RGB→PWM pipeline (color management + luminance management + temperature compensation) used by the ALM firmware. Exposes compute_pwm(r, g, b, temp_c) returning both the non-compensated and the temperature-compensated 16-bit PWM tuples. Imported by tests/hardware/alm_helpers.py to predict expected PWM values from RGB inputs in PWM-validation assertions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
582764d410
commit
079abc9356
286
vendor/rgb_to_pwm.py
vendored
Normal file
286
vendor/rgb_to_pwm.py
vendored
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
"""
|
||||||
|
rgb_to_pwm.py
|
||||||
|
|
||||||
|
Closed-form Python port of the Input Sheet RGB to PWM pipeline.
|
||||||
|
|
||||||
|
Input : RGB (0..255) per channel, ambient temperature (C).
|
||||||
|
Output : (pwm_no_comp, pwm_comp) -- 16-bit PWM tuples for each channel.
|
||||||
|
|
||||||
|
Mirrors the spreadsheet chain:
|
||||||
|
Input Sheet B/C/D (RGB)
|
||||||
|
==> Color Management AP3# (gamut + calibration)
|
||||||
|
==> Lumin managment BH3# (luminance scaling)
|
||||||
|
==> Input Sheet I/J/K (pwm_no_comp)
|
||||||
|
==> Input Sheet L/M/N (pwm_comp, with temperature)
|
||||||
|
|
||||||
|
All integer math uses FLOOR.MATH / CEILING.MATH semantics
|
||||||
|
(towards -infinity / +infinity).
|
||||||
|
"""
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Tuple, List
|
||||||
|
import math
|
||||||
|
|
||||||
|
# Color config constants
|
||||||
|
L2 = 255
|
||||||
|
L3 = 65536
|
||||||
|
L4 = 256
|
||||||
|
L5 = 65536
|
||||||
|
L8 = 65536
|
||||||
|
L11 = 32768
|
||||||
|
L12 = 64879
|
||||||
|
K4 = 8
|
||||||
|
K10 = 16
|
||||||
|
|
||||||
|
# Lumin managment constants
|
||||||
|
B1 = 256
|
||||||
|
D1 = 32768
|
||||||
|
B15 = 8
|
||||||
|
C15 = 256
|
||||||
|
|
||||||
|
# Input Sheet constants
|
||||||
|
V32 = 15
|
||||||
|
V33 = 3840
|
||||||
|
|
||||||
|
# Calibration vectors
|
||||||
|
CALIB_LM_SCALED: Tuple[int, int, int] = (2396, 5534, 1740) # ColorConfig!B24:D24 = Lumin!A6#
|
||||||
|
TARGET_LM_SCALED: Tuple[int, int, int] = (2396, 4433, 1620) # InputSheet!V12:X12 = Lumin!A3#
|
||||||
|
A11_RATIO_Q15: Tuple[int, int, int] = tuple(
|
||||||
|
math.floor(TARGET_LM_SCALED[i] * D1 / CALIB_LM_SCALED[i]) for i in range(3)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Color Management calibration matrix (ColorConfig!B9:D11)
|
||||||
|
COLOR_MATRIX: List[List[int]] = [
|
||||||
|
[3437, 1404, 1198],
|
||||||
|
[1581, 4206, 628],
|
||||||
|
[ 110, 851, 5908],
|
||||||
|
]
|
||||||
|
|
||||||
|
# Calibration determinant (Color Management!B44/C44/D44)
|
||||||
|
B44 = 1504796096380
|
||||||
|
C44 = B44 >> 16 # 22961366
|
||||||
|
D44 = math.floor(math.log2(C44) + 1) - 16 # 9
|
||||||
|
|
||||||
|
# Target XYZ matrix columns (Input Sheet V/W/X 26:28)
|
||||||
|
TARGET_X = (5665, 2396, 0)
|
||||||
|
TARGET_Y = (1094, 5534, 996)
|
||||||
|
TARGET_Z = (9618, 1740, 51922)
|
||||||
|
|
||||||
|
# Temperature compensation table (T, kR, kG, kB) -- 96 rows, T from -40..150 step 2
|
||||||
|
TEMP_TABLE: List[Tuple[float, float, float, float]] = [
|
||||||
|
(-40, 1.391998, 1.099121, 0.940033), (-38, 1.379791, 1.096924, 0.942169),
|
||||||
|
(-36, 1.367584, 1.094727, 0.944305), (-34, 1.355377, 1.092529, 0.946442),
|
||||||
|
(-32, 1.34317, 1.090332, 0.948578), (-30, 1.330963, 1.088135, 0.950714),
|
||||||
|
(-28, 1.318756, 1.085938, 0.95285), (-26, 1.306549, 1.08374, 0.954987),
|
||||||
|
(-24, 1.294342, 1.081543, 0.957123), (-22, 1.282135, 1.079346, 0.959259),
|
||||||
|
(-20, 1.269928, 1.077148, 0.961395), (-18, 1.257721, 1.074951, 0.963531),
|
||||||
|
(-16, 1.245514, 1.072754, 0.965668), (-14, 1.233307, 1.070557, 0.967804),
|
||||||
|
(-12, 1.2211, 1.068359, 0.96994), (-10, 1.208893, 1.066162, 0.972076),
|
||||||
|
(-8, 1.196686, 1.062317, 0.974213), (-6, 1.184479, 1.058716, 0.976349),
|
||||||
|
(-4, 1.172272, 1.055115, 0.978485), (-2, 1.160065, 1.051514, 0.980621),
|
||||||
|
(0, 1.147858, 1.047913, 0.982758), (2, 1.135651, 1.044312, 0.984894),
|
||||||
|
(4, 1.123444, 1.04071, 0.98703), (6, 1.111237, 1.037109, 0.989166),
|
||||||
|
(8, 1.09903, 1.033508, 0.991302), (10, 1.086823, 1.029907, 0.993439),
|
||||||
|
(12, 1.074615, 1.026306, 0.995575), (14, 1.062408, 1.022705, 0.997711),
|
||||||
|
(16, 1.050201, 1.019104, 0.999847), (18, 1.037994, 1.015503, 1.001984),
|
||||||
|
(20, 1.025787, 1.011902, 1.00412), (22, 1.01358, 1.008301, 1.006256),
|
||||||
|
(24, 1.001373, 1.0047, 1.008392), (26, 0.989166, 1.001099, 1.010529),
|
||||||
|
(28, 0.976959, 0.997498, 1.012665), (30, 0.965851, 0.992767, 1.014801),
|
||||||
|
(32, 0.952484, 0.988617, 1.016937), (34, 0.939117, 0.984467, 1.019073),
|
||||||
|
(36, 0.925751, 0.980316, 1.02121), (38, 0.912384, 0.976166, 1.023346),
|
||||||
|
(40, 0.899017, 0.972015, 1.025482), (42, 0.885651, 0.967865, 1.027618),
|
||||||
|
(44, 0.872284, 0.963715, 1.029755), (46, 0.858917, 0.959564, 1.031891),
|
||||||
|
(48, 0.845551, 0.955414, 1.034027), (50, 0.832184, 0.951263, 1.036163),
|
||||||
|
(52, 0.818817, 0.947113, 1.0383), (54, 0.80545, 0.942963, 1.040436),
|
||||||
|
(56, 0.792084, 0.938812, 1.042572), (58, 0.778717, 0.934662, 1.044708),
|
||||||
|
(60, 0.76535, 0.930511, 1.046844), (62, 0.751984, 0.926361, 1.048981),
|
||||||
|
(64, 0.738617, 0.922211, 1.051117), (66, 0.72525, 0.91806, 1.053253),
|
||||||
|
(68, 0.711884, 0.91391, 1.055389), (70, 0.698517, 0.90976, 1.057526),
|
||||||
|
(72, 0.68515, 0.905609, 1.059662), (74, 0.671783, 0.901459, 1.061798),
|
||||||
|
(76, 0.658417, 0.897308, 1.063934), (78, 0.64505, 0.893158, 1.066071),
|
||||||
|
(80, 0.631683, 0.889008, 1.068207), (82, 0.618317, 0.884857, 1.070343),
|
||||||
|
(84, 0.60495, 0.880707, 1.072479), (86, 0.591583, 0.876556, 1.074615),
|
||||||
|
(88, 0.578217, 0.872406, 1.076752), (90, 0.56485, 0.868256, 1.078888),
|
||||||
|
(92, 0.551483, 0.864105, 1.081024), (94, 0.538116, 0.859955, 1.08316),
|
||||||
|
(96, 0.52475, 0.855804, 1.085297), (98, 0.511383, 0.851654, 1.087433),
|
||||||
|
(100, 0.498016, 0.847504, 1.089569), (102, 0.48465, 0.843353, 1.087433),
|
||||||
|
(104, 0.468872, 0.839203, 1.087128), (106, 0.457336, 0.835052, 1.086823),
|
||||||
|
(108, 0.445801, 0.830902, 1.086517), (110, 0.434265, 0.826752, 1.086212),
|
||||||
|
(112, 0.422729, 0.822601, 1.085907), (114, 0.411194, 0.818451, 1.085602),
|
||||||
|
(116, 0.399658, 0.817047, 1.085297), (118, 0.388123, 0.812225, 1.084991),
|
||||||
|
(120, 0.376587, 0.807404, 1.084686), (122, 0.365051, 0.802582, 1.084381),
|
||||||
|
(124, 0.355804, 0.79776, 1.084076), (126, 0.346466, 0.792938, 1.083771),
|
||||||
|
(128, 0.337128, 0.788116, 1.083466), (130, 0.327789, 0.783295, 1.081909),
|
||||||
|
(132, 0.318451, 0.778473, 1.07782), (134, 0.309113, 0.773651, 1.07373),
|
||||||
|
(136, 0.299774, 0.768829, 1.069641), (138, 0.290436, 0.764008, 1.065552),
|
||||||
|
(140, 0.287231, 0.759186, 1.061462), (142, 0.280396, 0.754364, 1.057373),
|
||||||
|
(144, 0.27356, 0.749542, 1.053284), (146, 0.266724, 0.74472, 1.049194),
|
||||||
|
(148, 0.259888, 0.739899, 1.045105), (150, 0.253052, 0.735077, 1.041016),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _round_half_away(x: float) -> int:
|
||||||
|
"""Excel ROUND() rounds half away from zero."""
|
||||||
|
if x >= 0:
|
||||||
|
return int(math.floor(x + 0.5))
|
||||||
|
return -int(math.floor(-x + 0.5))
|
||||||
|
|
||||||
|
|
||||||
|
def _mdet3(m: List[List[int]]) -> int:
|
||||||
|
a, b, c = m[0]; d, e, f = m[1]; g, h, i = m[2]
|
||||||
|
return a*(e*i - f*h) - b*(d*i - f*g) + c*(d*h - e*g)
|
||||||
|
|
||||||
|
|
||||||
|
def color_management_pwm(r: int, g: int, b: int) -> Tuple[int, int, int]:
|
||||||
|
"""RGB (0..255) -> Color Management AP3# (Q15 PWM_LED) via Cramer rule."""
|
||||||
|
rgb = (r, g, b)
|
||||||
|
F = tuple(
|
||||||
|
math.floor(sum(COLOR_MATRIX[i][j] * rgb[j] for j in range(3)) / L2)
|
||||||
|
for i in range(3)
|
||||||
|
)
|
||||||
|
bits = max(F[i].bit_length() if F[i] > 0 else 0 for i in range(3))
|
||||||
|
N = bits - K10 if bits > 16 else 0
|
||||||
|
O = tuple(F[i] >> N for i in range(3))
|
||||||
|
|
||||||
|
R_num = _mdet3([[O[0], TARGET_Y[0], TARGET_Z[0]],
|
||||||
|
[O[1], TARGET_Y[1], TARGET_Z[1]],
|
||||||
|
[O[2], TARGET_Y[2], TARGET_Z[2]]])
|
||||||
|
S_num = _mdet3([[TARGET_X[0], O[0], TARGET_Z[0]],
|
||||||
|
[TARGET_X[1], O[1], TARGET_Z[1]],
|
||||||
|
[TARGET_X[2], O[2], TARGET_Z[2]]])
|
||||||
|
T_num = _mdet3([[TARGET_X[0], TARGET_Y[0], O[0]],
|
||||||
|
[TARGET_X[1], TARGET_Y[1], O[1]],
|
||||||
|
[TARGET_X[2], TARGET_Y[2], O[2]]])
|
||||||
|
R_det = math.floor(R_num)
|
||||||
|
S_det = math.floor(S_num)
|
||||||
|
T_det = math.floor(T_num)
|
||||||
|
|
||||||
|
V_shift = K4 + K4 # 16
|
||||||
|
def shr(x, s):
|
||||||
|
if x >= 0: return x >> s
|
||||||
|
return -((-x) >> s)
|
||||||
|
W = (shr(R_det, V_shift), shr(S_det, V_shift), shr(T_det, V_shift))
|
||||||
|
|
||||||
|
denom = C44 >> D44
|
||||||
|
pwm = tuple(
|
||||||
|
math.floor((W[i] >> D44) / denom * L11) if denom != 0 else 0
|
||||||
|
for i in range(3)
|
||||||
|
)
|
||||||
|
return pwm
|
||||||
|
|
||||||
|
|
||||||
|
def lumin_management(pwm_led: Tuple[int, int, int]) -> Tuple[int, int, int]:
|
||||||
|
"""Color Management AP3# -> Input Sheet I/J/K (BH3#)."""
|
||||||
|
L = pwm_led
|
||||||
|
if all(L[i] <= 0 for i in range(3)):
|
||||||
|
return (0, 0, 0)
|
||||||
|
AB = []
|
||||||
|
for i in range(3):
|
||||||
|
if L[i] <= 0:
|
||||||
|
AB.append(10**18)
|
||||||
|
else:
|
||||||
|
AB.append(math.floor((A11_RATIO_Q15[i] / L[i]) * D1))
|
||||||
|
AE = min(AB)
|
||||||
|
AF = tuple(math.floor((L[i] * AE + D1) / D1) for i in range(3))
|
||||||
|
AL = tuple(math.floor((AF[i] * CALIB_LM_SCALED[i] + D1) / D1) for i in range(3))
|
||||||
|
AO = sum(AL)
|
||||||
|
if AO <= V33:
|
||||||
|
AP = 1 << B15
|
||||||
|
else:
|
||||||
|
AP = math.floor((V33 << B15) / AO)
|
||||||
|
AX = tuple(math.ceil(AF[i] * AP / C15) for i in range(3))
|
||||||
|
BH = tuple((AX[i] * L12) >> 15 for i in range(3))
|
||||||
|
return BH
|
||||||
|
|
||||||
|
|
||||||
|
def compute_pwm_no_comp(r: int, g: int, b: int) -> Tuple[int, int, int]:
|
||||||
|
"""RGB -> Input Sheet I/J/K (PWM without temp compensation)."""
|
||||||
|
return lumin_management(color_management_pwm(r, g, b))
|
||||||
|
|
||||||
|
|
||||||
|
def _interp_temp(temp_c: float) -> Tuple[float, float, float]:
|
||||||
|
table = TEMP_TABLE
|
||||||
|
if temp_c <= table[0][0]:
|
||||||
|
return (table[0][1], table[0][2], table[0][3])
|
||||||
|
if temp_c >= table[-1][0]:
|
||||||
|
return (table[-1][1], table[-1][2], table[-1][3])
|
||||||
|
for i in range(len(table) - 1):
|
||||||
|
t0 = table[i][0]; t1 = table[i+1][0]
|
||||||
|
if t0 <= temp_c <= t1:
|
||||||
|
f = (temp_c - t0) / (t1 - t0)
|
||||||
|
return (
|
||||||
|
table[i][1] + f * (table[i+1][1] - table[i][1]),
|
||||||
|
table[i][2] + f * (table[i+1][2] - table[i][2]),
|
||||||
|
table[i][3] + f * (table[i+1][3] - table[i][3]),
|
||||||
|
)
|
||||||
|
return (table[-1][1], table[-1][2], table[-1][3])
|
||||||
|
|
||||||
|
|
||||||
|
def temp_comp_q15(temp_c: float) -> Tuple[int, int, int]:
|
||||||
|
"""Q7/R7/S7 = ROUND(k * L11)."""
|
||||||
|
k = _interp_temp(temp_c)
|
||||||
|
return (
|
||||||
|
_round_half_away(k[0] * L11),
|
||||||
|
_round_half_away(k[1] * L11),
|
||||||
|
_round_half_away(k[2] * L11),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_temp_compensation(pwm_no_comp: Tuple[int, int, int],
|
||||||
|
temp_c: float = 25.0) -> Tuple[int, int, int]:
|
||||||
|
"""Mirrors Input Sheet L3/M3/N3:",
|
||||||
|
m = MIN_i ( q_i * L12 / I_i if I_i > 0 else INF )
|
||||||
|
if m < 2^15: out_i = MIN(65535, ROUND(m * I_i / q_i))
|
||||||
|
else: out_i = MIN(65535, ROUND(I_i * 2^15 / q_i))
|
||||||
|
"""
|
||||||
|
p = pwm_no_comp
|
||||||
|
q = temp_comp_q15(temp_c)
|
||||||
|
INF = 4294967295
|
||||||
|
ratios = [(q[i] * L12 / p[i]) if p[i] > 0 else INF for i in range(3)]
|
||||||
|
m = min(ratios)
|
||||||
|
out = []
|
||||||
|
for i in range(3):
|
||||||
|
if q[i] <= 0:
|
||||||
|
out.append(p[i]); continue
|
||||||
|
if m < L11:
|
||||||
|
out.append(min(65535, _round_half_away(m * p[i] / q[i])))
|
||||||
|
else:
|
||||||
|
out.append(min(65535, _round_half_away(p[i] * L11 / q[i])))
|
||||||
|
return tuple(out)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PwmResult:
|
||||||
|
pwm_no_comp: Tuple[int, int, int] # Input Sheet I/J/K
|
||||||
|
pwm_comp: Tuple[int, int, int] # Input Sheet L/M/N
|
||||||
|
|
||||||
|
|
||||||
|
def compute_pwm(r: int, g: int, b: int, temp_c: float = 25.0) -> PwmResult:
|
||||||
|
"""RGB (0..255) and ambient temperature (C) -> 16-bit PWM (with and without temp comp)."""
|
||||||
|
no_comp = compute_pwm_no_comp(r, g, b)
|
||||||
|
comp = apply_temp_compensation(no_comp, temp_c)
|
||||||
|
return PwmResult(pwm_no_comp=no_comp, pwm_comp=comp)
|
||||||
|
|
||||||
|
|
||||||
|
EXPECTED = [
|
||||||
|
((0, 180, 80), (5989, 41414, 2920), (6017, 41294, 2893)),
|
||||||
|
((0, 255, 0), (6240, 42226, 160), (6270, 42104, 159)),
|
||||||
|
((0, 0, 255), (2425, 29812, 44360), (2437, 29726, 43944)),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def self_check(verbose: bool = False) -> bool:
|
||||||
|
ok = True
|
||||||
|
for rgb, exp_nc, exp_c in EXPECTED:
|
||||||
|
res = compute_pwm(*rgb, temp_c=25.0)
|
||||||
|
nc_ok = res.pwm_no_comp == exp_nc
|
||||||
|
c_ok = res.pwm_comp == exp_c
|
||||||
|
ok = ok and nc_ok and c_ok
|
||||||
|
if verbose or not (nc_ok and c_ok):
|
||||||
|
print(f"RGB={rgb}")
|
||||||
|
print(f" no_comp: got={res.pwm_no_comp} exp={exp_nc} {nc_ok}")
|
||||||
|
print(f" comp: got={res.pwm_comp} exp={exp_c} {c_ok}")
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("self_check:", "PASS" if self_check(verbose=True) else "FAIL")
|
||||||
Loading…
x
Reference in New Issue
Block a user