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 }