| 196 | } |
| 197 | |
| 198 | inline float compute_eta_from_input(const colorimetric_detail::RgbcctProfile& profile, |
| 199 | float s_r, float s_g, float s_b) FL_NOEXCEPT { |
| 200 | // Compute the input chromaticity in the *source* color space (#2705) when |
| 201 | // the warm path carries populated input_xy_* fields. Falling back to the |
| 202 | // emitter-space projection used previously would bias eta toward the LED |
| 203 | // gamut, so a neutral source white wouldn't blend symmetrically across |
| 204 | // warm/cool when the source primaries (e.g. sRGB) differ from the LED |
| 205 | // primaries — exactly the regression flagged on this PR. |
| 206 | EtaSourceMatrixCache& cache = |
| 207 | fl::Singleton<EtaSourceMatrixCache>::instance(); |
| 208 | const fl::DiodeProfile& wp = profile.warm_path; |
| 209 | const bool key_changed = |
| 210 | !cache.initialized || |
| 211 | !xy_equal(cache.xy_r, wp.input_xy_r) || |
| 212 | !xy_equal(cache.xy_g, wp.input_xy_g) || |
| 213 | !xy_equal(cache.xy_b, wp.input_xy_b) || |
| 214 | !xy_equal(cache.xy_w, wp.input_xy_w); |
| 215 | if (key_changed) { |
| 216 | cache.xy_r[0] = wp.input_xy_r[0]; cache.xy_r[1] = wp.input_xy_r[1]; |
| 217 | cache.xy_g[0] = wp.input_xy_g[0]; cache.xy_g[1] = wp.input_xy_g[1]; |
| 218 | cache.xy_b[0] = wp.input_xy_b[0]; cache.xy_b[1] = wp.input_xy_b[1]; |
| 219 | cache.xy_w[0] = wp.input_xy_w[0]; cache.xy_w[1] = wp.input_xy_w[1]; |
| 220 | cache.has_M_src = wp.input_xy_w[1] > 1e-6f && |
| 221 | colorimetric_detail::build_source_matrix( |
| 222 | wp.input_xy_r, wp.input_xy_g, |
| 223 | wp.input_xy_b, wp.input_xy_w, cache.M_src); |
| 224 | cache.initialized = true; |
| 225 | } |
| 226 | |
| 227 | float X = 0.0f, Y = 0.0f, Z = 0.0f; |
| 228 | if (cache.has_M_src) { |
| 229 | const float s[3] = { s_r, s_g, s_b }; |
| 230 | float xyz[3]; |
| 231 | colorimetric_detail::matvec3(cache.M_src, s, xyz); |
| 232 | X = xyz[0]; Y = xyz[1]; Z = xyz[2]; |
| 233 | } else { |
| 234 | // Legacy emitter-space fallback for profiles without populated source. |
| 235 | // Three xyY_to_XYZ calls per pixel — degraded but still cheap, and |
| 236 | // only reached when the user explicitly opts out of source space. |
| 237 | float P_R[3], P_G[3], P_B[3]; |
| 238 | colorimetric_detail::xyY_to_XYZ(wp.xy_r[0], wp.xy_r[1], wp.lum_r, P_R); |
| 239 | colorimetric_detail::xyY_to_XYZ(wp.xy_g[0], wp.xy_g[1], wp.lum_g, P_G); |
| 240 | colorimetric_detail::xyY_to_XYZ(wp.xy_b[0], wp.xy_b[1], wp.lum_b, P_B); |
| 241 | X = P_R[0]*s_r + P_G[0]*s_g + P_B[0]*s_b; |
| 242 | Y = P_R[1]*s_r + P_G[1]*s_g + P_B[1]*s_b; |
| 243 | Z = P_R[2]*s_r + P_G[2]*s_g + P_B[2]*s_b; |
| 244 | } |
| 245 | const float sum = X + Y + Z; |
| 246 | if (sum < 1e-9f) return 0.5f; |
| 247 | const float input_x = X / sum; |
| 248 | const float warm_x = profile.warm_path.xy_w[0]; |
| 249 | const float cool_x = profile.cool_path.xy_w[0]; |
| 250 | if (cool_x == warm_x) return 0.5f; |
| 251 | const float eta = (input_x - warm_x) / (cool_x - warm_x); |
| 252 | if (eta < 0.0f) return 0.0f; |
| 253 | if (eta > 1.0f) return 1.0f; |
| 254 | return eta; |
| 255 | } |
no test coverage detected