1 module photog.color; 2 3 import mir.math.common : optmath; 4 import mir.ndslice : Slice, sliced, SliceKind; 5 6 import photog.utils; 7 8 // dfmt off 9 /** 10 RGB working spaces. 11 */ 12 enum WorkingSpace 13 { 14 sRgb = "sRgb" 15 } 16 17 /** 18 Color space conversion matrices. 19 */ 20 private static 21 { 22 auto sRgbRgb2Xyz = [ 23 0.4124564, 0.3575761, 0.1804375, 24 0.2126729, 0.7151522, 0.0721750, 25 0.0193339, 0.1191920, 0.9503041 26 ]; 27 28 auto sRgbRgb2XyzReverse = [ 29 0.1804375, 0.3575761, 0.4124564, 30 0.0721750, 0.7151522, 0.2126729, 31 0.9503041, 0.1191920, 0.0193339, 32 ]; 33 34 auto sRgbXyz2Rgb = [ 35 3.2404542, -1.5371385, -0.4985314, 36 -0.9692660, 1.8760108, 0.0415560, 37 0.0556434, -0.2040259, 1.0572252 38 ]; 39 } 40 41 /** 42 Standard illuminants (XYZ). 43 */ 44 enum Illuminant 45 { 46 d65 = [0.95047, 1.0, 1.08883] 47 } 48 49 /** 50 Chromatic adaptation methods. 51 */ 52 enum ChromAdaptMethod 53 { 54 bradford = "bradford" 55 } 56 57 /** 58 Chromatic adaptation method matrices. 59 */ 60 private static 61 { 62 auto bradfordXyz2Lms = [ 63 0.8951, 0.2664, -0.1614, 64 -0.7502, 1.7135, 0.0367, 65 0.0389, -0.0685, 1.0296 66 ]; 67 68 auto bradfordInvXyz2Lms = [ 69 0.9869929, -0.1470543, 0.1599627, 70 0.4323053, 0.5183603, 0.0492912, 71 -0.0085287, 0.0400428, 0.9684867 72 ]; 73 } 74 // dfmt on 75 76 /** 77 Convert RGB input to XYZ. 78 */ 79 Slice!(ReturnType*, 3) rgb2Xyz(WorkingSpace workingSpace = WorkingSpace.sRgb, ReturnType = double, 80 T)(Slice!(T, 3) input) 81 { 82 return rgbBgr2Xyz!(false, workingSpace, ReturnType)(input); 83 } 84 85 /// 86 unittest 87 { 88 import std.math : approxEqual; 89 90 // dfmt off 91 Slice!(double*, 3) rgb = [ 92 1.0, 0.0, 0.0, 93 0.0, 1.0, 0.0, 94 0.0, 0.0, 1.0, 95 0.470588, 0.470588, 0.470588 96 ].sliced(4, 1, 3); 97 98 Slice!(double*, 3) xyz = [ 99 0.412456, 0.212673, 0.019334, 100 0.357576, 0.715152, 0.119192, 101 0.180438, 0.072175, 0.950304, 102 0.178518, 0.187821, 0.204505 103 ].sliced(4, 1, 3); 104 // dfmt on 105 106 assert(approxEqual(rgb.rgb2Xyz, xyz)); 107 } 108 109 /** 110 Convert BGR input to XYZ. 111 */ 112 Slice!(ReturnType*, 3) bgr2Xyz(WorkingSpace workingSpace = WorkingSpace.sRgb, ReturnType = double, 113 T)(Slice!(T, 3) input) 114 { 115 return rgbBgr2Xyz!(true, workingSpace, ReturnType)(input); 116 } 117 118 /// 119 unittest 120 { 121 import std.math : approxEqual; 122 123 // dfmt off 124 Slice!(double*, 3) bgr = [ 125 0.0, 0.0, 1.0, 126 0.0, 1.0, 0.0, 127 1.0, 0.0, 0.0, 128 0.470588, 0.470588, 0.470588 129 ].sliced(4, 1, 3); 130 131 Slice!(double*, 3) xyz = [ 132 0.412456, 0.212673, 0.019334, 133 0.357576, 0.715152, 0.119192, 134 0.180438, 0.072175, 0.950304, 135 0.178518, 0.187821, 0.204505 136 ].sliced(4, 1, 3); 137 // dfmt on 138 139 assert(approxEqual(bgr.bgr2Xyz, xyz)); 140 } 141 142 private Slice!(ReturnType*, 3) rgbBgr2Xyz(bool isBgr, WorkingSpace workingSpace, ReturnType, T)( 143 Slice!(T, 3) input) 144 in 145 { 146 import std.traits : isFloatingPoint; 147 148 static assert(isFloatingPoint!ReturnType, "Return type must be floating point."); 149 assert(input.shape[2] == 3, "Input requires 3 channels."); 150 } 151 do 152 { 153 import mir.ndslice : each, uninitSlice, zip; 154 155 auto output = uninitSlice!ReturnType(input.shape); 156 auto inputPack = pixelPack!3(input); 157 auto outputPack = pixelPack!3(output); 158 auto pixelZip = zip(inputPack, outputPack); 159 160 static if (isBgr) 161 auto conversionMatrix = mixin(workingSpace ~ "Rgb2XyzReverse").sliced(3, 3); 162 else 163 auto conversionMatrix = mixin(workingSpace ~ "Rgb2Xyz").sliced(3, 3); 164 165 pixelZip.each!((z) { 166 z.rgbBgr2XyzImpl!(isBgr, workingSpace)(conversionMatrix); 167 }); 168 169 return output; 170 } 171 172 private void rgbBgr2XyzImpl(bool isBgr, WorkingSpace workingSpace, T, U)(T pixelZip, 173 Slice!(U, 2) conversionMatrix) 174 { 175 import kaleidic.lubeck : mtimes; 176 import mir.ndslice : each; 177 178 // pixelZip[0] = input 179 // pixelZip[1] = output 180 181 auto output = sliced(pixelZip[0].ptr, 3); 182 183 // Linearize 184 output.each!((ref chnl) { chnl.linearize; }); 185 186 // Convert to XYZ 187 output = conversionMatrix.mtimes(output); 188 189 pixelZip[1][] = output.field; 190 } 191 192 @optmath private void linearize(T)(ref T chnl) 193 { 194 import mir.math.common : pow; 195 196 if (chnl <= 0.04045) 197 chnl = chnl / 12.92; 198 else 199 chnl = pow((chnl + 0.055) / 1.055, 2.4); 200 } 201 202 /** 203 Convert XYZ input to RGB. 204 */ 205 Slice!(ReturnType*, 3) xyz2Rgb(WorkingSpace workingSpace = WorkingSpace.sRgb, ReturnType = double, 206 T)(Slice!(T, 3) input) 207 { 208 return xyz2RgbBgr!(false, workingSpace, ReturnType)(input); 209 } 210 211 /// 212 unittest 213 { 214 import std.math : approxEqual; 215 216 // dfmt off 217 Slice!(double*, 3) rgb = [ 218 1.0, 0.0, 0.0, 219 0.0, 1.0, 0.0, 220 0.0, 0.0, 1.0, 221 0.470588, 0.470588, 0.470588 222 ].sliced(4, 1, 3); 223 224 Slice!(double*, 3) xyz = [ 225 0.412456, 0.212673, 0.019334, 226 0.357576, 0.715152, 0.119192, 227 0.180438, 0.072175, 0.950304, 228 0.178518, 0.187821, 0.204505 229 ].sliced(4, 1, 3); 230 // dfmt on 231 232 assert(approxEqual(xyz.xyz2Rgb, rgb, 0.01, 1e-04)); 233 } 234 235 /** 236 Convert XYZ input to BGR. 237 */ 238 Slice!(ReturnType*, 3) xyz2Bgr(WorkingSpace workingSpace = WorkingSpace.sRgb, ReturnType = double, 239 T)(Slice!(T, 3) input) 240 { 241 return xyz2RgbBgr!(true, workingSpace, ReturnType)(input); 242 } 243 244 /// 245 unittest 246 { 247 import std.math : approxEqual; 248 249 // dfmt off 250 Slice!(double*, 3) bgr = [ 251 0.0, 0.0, 1.0, 252 0.0, 1.0, 0.0, 253 1.0, 0.0, 0.0, 254 0.470588, 0.470588, 0.470588 255 ].sliced(4, 1, 3); 256 257 Slice!(double*, 3) xyz = [ 258 0.412456, 0.212673, 0.019334, 259 0.357576, 0.715152, 0.119192, 260 0.180438, 0.072175, 0.950304, 261 0.178518, 0.187821, 0.204505 262 ].sliced(4, 1, 3); 263 // dfmt on 264 265 assert(approxEqual(xyz.xyz2Bgr, bgr, 0.01, 1e-04)); 266 } 267 268 private Slice!(ReturnType*, 3) xyz2RgbBgr(bool isBgr, WorkingSpace workingSpace, ReturnType, T)( 269 Slice!(T, 3) input) 270 in 271 { 272 import std.traits : isFloatingPoint; 273 274 static assert(isFloatingPoint!ReturnType, "Return type must be floating point."); 275 } 276 do 277 { 278 import mir.ndslice : each, uninitSlice, zip; 279 280 auto output = uninitSlice!ReturnType(input.shape); 281 auto inputPack = pixelPack!3(input); 282 auto outputPack = pixelPack!3(output); 283 auto pixelZip = zip(inputPack, outputPack); 284 auto conversionMatrix = mixin(workingSpace ~ "Xyz2Rgb").sliced(3, 3); // TODO: Known at compile-time. Send as template? 285 pixelZip.each!((z) { 286 z.xyz2RgbBgrImpl!(isBgr, workingSpace)(conversionMatrix); 287 }); 288 // No need to unpack output. Underlying data has already been changed. 289 return output; 290 } 291 292 private void xyz2RgbBgrImpl(bool isBgr, WorkingSpace workingSpace, T, U)(T pixelZip, 293 Slice!(U, 2) conversionMatrix) 294 { 295 import kaleidic.lubeck : mtimes; 296 import mir.ndslice : each; 297 298 // pixelZip[0] = input 299 // pixelZip[1] = output 300 301 // Convert to RGB 302 // TODO: Is manual matrix math faster here? 303 auto output = conversionMatrix.mtimes(sliced(pixelZip[0].ptr, 3)); 304 305 // Un-linearize 306 output.each!((ref chnl) { chnl.unlinearize; }); 307 308 static if (isBgr) 309 { 310 pixelZip[1][] = [output.field[2], output.field[1], output.field[0]]; 311 } 312 else 313 { 314 pixelZip[1][] = output.field; 315 } 316 } 317 318 @optmath private void unlinearize(T)(ref T chnl) 319 { 320 import mir.math.common : pow; 321 322 if (chnl <= 0.0031308) 323 chnl = chnl * 12.92; 324 else 325 chnl = (1.055 * pow(chnl, (1 / 2.4))) - 0.055; 326 } 327 328 /** 329 Chromatically adapt RGB input from the given source illuminant to the given destination illuminant. 330 331 We employ the von Kries coefficient law for chromatic adaptation. A cone response under a source 332 illuminant is converted to one under a destination illuminant via diagonal scaling of the cone 333 response components. The output of the chromatic adaptation between a pair of illuminants can be 334 tweaked by chromatic adaption methods which define the conversion to and from XYZ to cone responses 335 (LMS). 336 337 References: 338 http://www.brucelindbloom.com/index.html?Eqn_ChromAdapt.html 339 https://en.wikipedia.org/wiki/Von_Kries_coefficient_law#Chromatic_adaptation 340 https://en.wikipedia.org/wiki/CIE_1931_color_space#Color_matching_functions 341 https://en.wikipedia.org/wiki/LMS_color_space 342 http://scottburns.us/chromatic-adaptation-transform-by-reflectance-reconstruction/ 343 344 Params: 345 method = Method to use for chromatic adaptation. Bradford by default. 346 workingSpace = Working space for both the input and the eventual output. sRGB by default. 347 input = RBG image in floating point form. 348 srcIlluminant = XYZ illuminant under which input was recorded. 349 destIlluminant = XYZ illuminant that input should be chromatically adapted to. 350 351 Returns: 352 Chromatically adapted floating point RGB image. 353 */ 354 Slice!(Iterator, 3) chromAdapt(ChromAdaptMethod method = ChromAdaptMethod.bradford, 355 WorkingSpace workingSpace = WorkingSpace.sRgb, Iterator)(Slice!(Iterator, 356 3) input, double[] srcIlluminant, double[] destIlluminant) 357 in 358 { 359 import std.traits : isFloatingPoint; 360 361 static assert(isFloatingPoint!(IteratorType!Iterator), "Input values must be floating point."); 362 assert(srcIlluminant.length == 3); 363 assert(destIlluminant.length == 3); 364 } 365 do 366 { 367 import kaleidic.lubeck : mtimes; 368 import mir.ndslice : diagonal, each, reshape, slice, uninitSlice, zip; 369 370 auto xyz2Lms = mixin(method ~ "Xyz2Lms"); 371 auto lms2Xyz = mixin(method ~ "InvXyz2Lms"); 372 373 auto lmsSrc = xyz2Lms.sliced(3, 3).mtimes(srcIlluminant.sliced(3, 1)); 374 auto lmsDest = xyz2Lms.sliced(3, 3).mtimes(destIlluminant.sliced(3, 1)); 375 376 int err; 377 auto lmsGain = slice!double([3, 3], 0); 378 auto diag = lmsGain.diagonal; 379 diag[] = (lmsDest / lmsSrc).reshape([3], err); 380 assert(err == 0); 381 382 auto transform = lms2Xyz.sliced(3, 3).mtimes(lmsGain).mtimes(xyz2Lms.sliced(3, 3)); 383 384 auto output = uninitSlice!(IteratorType!Iterator)(input.shape); 385 auto inputPack = pixelPack!3(input.rgb2Xyz!workingSpace); 386 auto outputPack = pixelPack!3(output); 387 auto pixelZip = zip(inputPack, outputPack); 388 389 pixelZip.each!((z) { z.chromAdaptImpl(transform); }); 390 391 return output.xyz2Rgb!workingSpace; 392 } 393 394 private void chromAdaptImpl(T, U)(T pixelZip, Slice!(U, 2) transform) 395 { 396 import kaleidic.lubeck : mtimes; 397 398 auto output = sliced(pixelZip[0].ptr, 3); 399 400 output = transform.mtimes(output); 401 402 pixelZip[1][] = output.field; 403 } 404 405 /// 406 unittest 407 { 408 import mir.ndslice : reshape, slice; 409 410 // dfmt off 411 ubyte[] uImage = [ 412 255, 0, 0, 413 0, 255, 0, 414 0, 0, 255, 415 120, 120, 120 416 ]; 417 418 auto image = uImage.sliced(4, 1, 3).toFloating; 419 420 int err; 421 double[] srcIlluminant = image 422 .imageMean 423 .reshape([1, 1, 3], err) // TODO: Get rid of the need for reshaping. 424 .rgb2Xyz 425 .field; 426 assert(err == 0); 427 //dfmt on 428 429 auto ca = chromAdapt(image, srcIlluminant, Illuminant.d65); 430 // TODO: Add assertion on chromAdapt output. 431 }