master
John Lauer LED colour pass: coloured STEP is the default (lens colour from the datasheet dominant wavelength, softened), plain STE… b15ea53 20d ago
#!/usr/bin/env python3
"""Dominant wavelength (or CIE xy) -> sRGB. CIE 1931 2-degree spectral-locus chromaticities (5 nm table),
linear interpolation, XYZ->linear sRGB (IEC 61966-2-1), primary result = along_white()
(keeps lambda_d exactly); the OKLCh gamut cusp at the same hue is printed for reference only."""
import sys, json
LOC = {460:(.1440,.0297),465:(.1355,.0399),470:(.1241,.0578),475:(.1096,.0868),480:(.0913,.1327),
       515:(.0389,.8120),520:(.0743,.8338),525:(.1142,.8262),530:(.1547,.8059),535:(.1929,.7816),
       595:(.6029,.3965),600:(.6270,.3725),605:(.6482,.3514),610:(.6658,.3340),615:(.6801,.3197),
       620:(.6915,.3083),625:(.7006,.2993),630:(.7079,.2920),635:(.7140,.2859),640:(.7190,.2809)}
def locus(wl):
    lo = max(k for k in LOC if k <= wl); hi = min(k for k in LOC if k >= wl)
    if lo == hi: return LOC[lo]
    t = (wl-lo)/(hi-lo); return tuple(LOC[lo][i]+(LOC[hi][i]-LOC[lo][i])*t for i in (0,1))
M = [[3.2406,-1.5372,-0.4986],[-0.9689,1.8758,0.0415],[0.0557,-0.2040,1.0570]]
def xy2lin(x, y):
    X, Y, Z = x/y, 1.0, (1-x-y)/y
    return [M[i][0]*X+M[i][1]*Y+M[i][2]*Z for i in range(3)]
def enc(c): return 12.92*c if c <= 0.0031308 else 1.055*c**(1/2.4)-0.055
def along_white(lin):
    """Intersection of the white->spectral line (in xy) with the sRGB gamut: adds the least D65 white that
    makes every channel >= 0, so the result keeps the dominant wavelength EXACTLY (the definition of lambda_d)."""
    w = xy2lin(.3127, .3290)
    if min(lin) < 0:
        k = max(-lin[i]/w[i] for i in range(3) if lin[i] < 0); lin = [lin[i]+k*w[i] for i in range(3)]
    mx = max(lin); lin = [max(0, c/mx) for c in lin]
    return [round(enc(c), 4) for c in lin]
def cbrt(v): return v**(1/3) if v >= 0 else -((-v)**(1/3))
def lin2ok(c):
    r,g,b = c
    l = cbrt(.4122214708*r+.5363325363*g+.0514459929*b); m = cbrt(.2119034982*r+.6806995451*g+.1073969566*b); s = cbrt(.0883024619*r+.2817188376*g+.6299787005*b)
    return [.2104542553*l+.7936177850*m-.0040720468*s, 1.9779984951*l-2.4285922050*m+.4505937099*s, .0259040371*l+.7827717662*m-.8086757660*s]
def ok2lin(o):
    L,a,b = o
    l = (L+.3963377774*a+.2158037573*b)**3; m = (L-.1055613458*a-.0638541728*b)**3; s = (L-.0894841775*a-1.2914855480*b)**3
    return [4.0767416621*l-3.3077115913*m+.2309699292*s, -1.2684380046*l+2.6097574011*m-.3413193965*s, -.0041960863*l-.7034186147*m+1.7076147010*s]
def to_srgb(lin):
    mx = max(lin); lin = [c/mx for c in lin]
    L, a, b = lin2ok(lin); lo, hi = 0.0, 1.0
    ok = lambda k: all(-1e-6 <= c <= 1+1e-6 for c in ok2lin([L, a*k, b*k]))
    if not ok(1.0):
        for _ in range(40):
            mid = (lo+hi)/2
            if ok(mid): lo = mid
            else: hi = mid
        lin = [min(1, max(0, c)) for c in ok2lin([L, a*lo, b*lo])]
    return [round(enc(c), 4) for c in lin], [round(c, 4) for c in lin]
def cusp(lin):
    """Most chromatic in-gamut sRGB colour at the spectral colour's OKLab hue (the gamut cusp)."""
    import math
    L0, a, b = lin2ok(lin); h = math.atan2(b, a); best = (0, None)
    for i in range(200):
        L = 0.2 + 0.78*i/199; lo, hi = 0.0, 0.5
        for _ in range(40):
            mid = (lo+hi)/2
            if all(-1e-7 <= c <= 1+1e-7 for c in ok2lin([L, mid*math.cos(h), mid*math.sin(h)])): lo = mid
            else: hi = mid
        if lo > best[0]: best = (lo, L)
        C, L = best
    out = [min(1, max(0, c)) for c in ok2lin([best[1], best[0]*math.cos(h), best[0]*math.sin(h)])]
    return [round(enc(c), 4) for c in out], round(math.degrees(h) % 360, 1)
if __name__ == '__main__':
    a = sys.argv[1]
    x, y = (map(float, a.split(','))) if ',' in a else locus(float(a))
    d = along_white(xy2lin(x, y)); c, h = cusp(xy2lin(x, y))
    hx = lambda s: '#%02X%02X%02X' % tuple(round(v*255) for v in s)
    print(json.dumps({'input': a, 'xy': [round(x,4), round(y,4)], 'srgb': d, 'hex': hx(d), 'method': 'lambda_d-preserving: white-point line to spectral locus, clipped at the sRGB gamut edge, max channel 1', 'reference_oklch_cusp_hex': hx(c), 'oklab_hue_deg': h}))