diff --git a/vendor/rgb_to_pwm.py b/vendor/rgb_to_pwm.py new file mode 100644 index 0000000..c204404 --- /dev/null +++ b/vendor/rgb_to_pwm.py @@ -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") \ No newline at end of file