1// Copyright 2014 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5'use strict';
6
7/**
8 * A namespace for image filter utilities.
9 */
10var filter = {};
11
12/**
13 * Create a filter from name and options.
14 *
15 * @param {string} name Maps to a filter method name.
16 * @param {Object} options A map of filter-specific options.
17 * @return {function(ImageData,ImageData,number,number)} created function.
18 */
19filter.create = function(name, options) {
20  var filterFunc = filter[name](options);
21  return function() {
22    var time = Date.now();
23    filterFunc.apply(null, arguments);
24    var dst = arguments[0];
25    var mPixPerSec = dst.width * dst.height / 1000 / (Date.now() - time);
26    ImageUtil.trace.report(name, Math.round(mPixPerSec * 10) / 10 + 'Mps');
27  }
28};
29
30/**
31 * Apply a filter to a image by splitting it into strips.
32 *
33 * To be used with large images to avoid freezing up the UI.
34 *
35 * @param {HTMLCanvasElement} dstCanvas Destination canvas.
36 * @param {HTMLCanvasElement} srcCanvas Source canvas.
37 * @param {function(ImageData,ImageData,number,number)} filterFunc Filter.
38 * @param {function(number, number)} progressCallback Progress callback.
39 * @param {number} maxPixelsPerStrip Pixel number to process at once.
40 */
41filter.applyByStrips = function(
42    dstCanvas, srcCanvas, filterFunc, progressCallback, maxPixelsPerStrip) {
43  var dstContext = dstCanvas.getContext('2d');
44  var srcContext = srcCanvas.getContext('2d');
45  var source = srcContext.getImageData(0, 0, srcCanvas.width, srcCanvas.height);
46
47  var stripCount = Math.ceil(srcCanvas.width * srcCanvas.height /
48      (maxPixelsPerStrip || 1000000));  // 1 Mpix is a reasonable default.
49
50  var strip = srcContext.getImageData(0, 0,
51      srcCanvas.width, Math.ceil(srcCanvas.height / stripCount));
52
53  var offset = 0;
54
55  function filterStrip() {
56    // If the strip overlaps the bottom of the source image we cannot shrink it
57    // and we cannot fill it partially (since canvas.putImageData always draws
58    // the entire buffer).
59    // Instead we move the strip up several lines (converting those lines
60    // twice is a small price to pay).
61    if (offset > source.height - strip.height) {
62      offset = source.height - strip.height;
63    }
64
65    filterFunc(strip, source, 0, offset);
66    dstContext.putImageData(strip, 0, offset);
67
68    offset += strip.height;
69
70    if (offset < source.height) {
71      setTimeout(filterStrip, 0);
72    } else {
73      ImageUtil.trace.reportTimer('filter-commit');
74    }
75
76    progressCallback(offset, source.height);
77  }
78
79  ImageUtil.trace.resetTimer('filter-commit');
80  filterStrip();
81};
82
83/**
84 * Return a color histogram for an image.
85 *
86 * @param {HTMLCanvasElement|ImageData} source Image data to analyze.
87 * @return {{r: Array.<number>, g: Array.<number>, b: Array.<number>}}
88 *     histogram.
89 */
90filter.getHistogram = function(source) {
91  var imageData;
92  if (source.constructor.name == 'HTMLCanvasElement') {
93    imageData = source.getContext('2d').
94        getImageData(0, 0, source.width, source.height);
95  } else {
96    imageData = source;
97  }
98
99  var r = [];
100  var g = [];
101  var b = [];
102
103  for (var i = 0; i != 256; i++) {
104    r.push(0);
105    g.push(0);
106    b.push(0);
107  }
108
109  var data = imageData.data;
110  var maxIndex = 4 * imageData.width * imageData.height;
111  for (var index = 0; index != maxIndex;) {
112    r[data[index++]]++;
113    g[data[index++]]++;
114    b[data[index++]]++;
115    index++;
116  }
117
118  return { r: r, g: g, b: b };
119};
120
121/**
122 * Compute the function for every integer value from 0 up to maxArg.
123 *
124 * Rounds and clips the results to fit the [0..255] range.
125 * Useful to speed up pixel manipulations.
126 *
127 * @param {number} maxArg Maximum argument value (inclusive).
128 * @param {function(number): number} func Function to precompute.
129 * @return {Uint8Array} Computed results.
130 */
131filter.precompute = function(maxArg, func) {
132  var results = new Uint8Array(maxArg + 1);
133  for (var arg = 0; arg <= maxArg; arg++) {
134    results[arg] = Math.max(0, Math.min(0xFF, Math.round(func(arg))));
135  }
136  return results;
137};
138
139/**
140 * Convert pixels by applying conversion tables to each channel individually.
141 *
142 * @param {Array.<number>} rMap Red channel conversion table.
143 * @param {Array.<number>} gMap Green channel conversion table.
144 * @param {Array.<number>} bMap Blue channel conversion table.
145 * @param {ImageData} dst Destination image data. Can be smaller than the
146 *                        source, must completely fit inside the source.
147 * @param {ImageData} src Source image data.
148 * @param {number} offsetX Horizontal offset of dst relative to src.
149 * @param {number} offsetY Vertical offset of dst relative to src.
150 */
151filter.mapPixels = function(rMap, gMap, bMap, dst, src, offsetX, offsetY) {
152  var dstData = dst.data;
153  var dstWidth = dst.width;
154  var dstHeight = dst.height;
155
156  var srcData = src.data;
157  var srcWidth = src.width;
158  var srcHeight = src.height;
159
160  if (offsetX < 0 || offsetX + dstWidth > srcWidth ||
161      offsetY < 0 || offsetY + dstHeight > srcHeight)
162      throw new Error('Invalid offset');
163
164  var dstIndex = 0;
165  for (var y = 0; y != dstHeight; y++) {
166    var srcIndex = (offsetX + (offsetY + y) * srcWidth) * 4;
167    for (var x = 0; x != dstWidth; x++) {
168      dstData[dstIndex++] = rMap[srcData[srcIndex++]];
169      dstData[dstIndex++] = gMap[srcData[srcIndex++]];
170      dstData[dstIndex++] = bMap[srcData[srcIndex++]];
171      dstIndex++;
172      srcIndex++;
173    }
174  }
175};
176
177/**
178 * Number of digits after period(in binary form) to preserve.
179 * @type {number}
180 */
181filter.FIXED_POINT_SHIFT = 16;
182
183/**
184 * Maximum value that can be represented in fixed point without overflow.
185 * @type {number}
186 */
187filter.MAX_FLOAT_VALUE = 0x7FFFFFFF >> filter.FIXED_POINT_SHIFT;
188
189/**
190 * Converts floating point to fixed.
191 * @param {number} x Number to convert.
192 * @return {number} Converted number.
193 */
194filter.floatToFixedPoint = function(x) {
195  // Math.round on negative arguments causes V8 to deoptimize the calling
196  // function, so we are using >> 0 instead.
197  return (x * (1 << filter.FIXED_POINT_SHIFT)) >> 0;
198};
199
200/**
201 * Perform an image convolution with a symmetrical 5x5 matrix:
202 *
203 *  0  0 w3  0  0
204 *  0 w2 w1 w2  0
205 * w3 w1 w0 w1 w3
206 *  0 w2 w1 w2  0
207 *  0  0 w3  0  0
208 *
209 * @param {Array.<number>} weights See the picture above.
210 * @param {ImageData} dst Destination image data. Can be smaller than the
211 *                        source, must completely fit inside the source.
212 * @param {ImageData} src Source image data.
213 * @param {number} offsetX Horizontal offset of dst relative to src.
214 * @param {number} offsetY Vertical offset of dst relative to src.
215 */
216filter.convolve5x5 = function(weights, dst, src, offsetX, offsetY) {
217  var w0 = filter.floatToFixedPoint(weights[0]);
218  var w1 = filter.floatToFixedPoint(weights[1]);
219  var w2 = filter.floatToFixedPoint(weights[2]);
220  var w3 = filter.floatToFixedPoint(weights[3]);
221
222  var dstData = dst.data;
223  var dstWidth = dst.width;
224  var dstHeight = dst.height;
225  var dstStride = dstWidth * 4;
226
227  var srcData = src.data;
228  var srcWidth = src.width;
229  var srcHeight = src.height;
230  var srcStride = srcWidth * 4;
231  var srcStride2 = srcStride * 2;
232
233  if (offsetX < 0 || offsetX + dstWidth > srcWidth ||
234      offsetY < 0 || offsetY + dstHeight > srcHeight)
235    throw new Error('Invalid offset');
236
237  // Javascript is not very good at inlining constants.
238  // We inline manually and assert that the constant is equal to the variable.
239  if (filter.FIXED_POINT_SHIFT != 16)
240    throw new Error('Wrong fixed point shift');
241
242  var margin = 2;
243
244  var startX = Math.max(0, margin - offsetX);
245  var endX = Math.min(dstWidth, srcWidth - margin - offsetX);
246
247  var startY = Math.max(0, margin - offsetY);
248  var endY = Math.min(dstHeight, srcHeight - margin - offsetY);
249
250  for (var y = startY; y != endY; y++) {
251    var dstIndex = y * dstStride + startX * 4;
252    var srcIndex = (y + offsetY) * srcStride + (startX + offsetX) * 4;
253
254    for (var x = startX; x != endX; x++) {
255      for (var c = 0; c != 3; c++) {
256        var sum = w0 * srcData[srcIndex] +
257                  w1 * (srcData[srcIndex - 4] +
258                        srcData[srcIndex + 4] +
259                        srcData[srcIndex - srcStride] +
260                        srcData[srcIndex + srcStride]) +
261                  w2 * (srcData[srcIndex - srcStride - 4] +
262                        srcData[srcIndex + srcStride - 4] +
263                        srcData[srcIndex - srcStride + 4] +
264                        srcData[srcIndex + srcStride + 4]) +
265                  w3 * (srcData[srcIndex - 8] +
266                        srcData[srcIndex + 8] +
267                        srcData[srcIndex - srcStride2] +
268                        srcData[srcIndex + srcStride2]);
269        if (sum < 0)
270          dstData[dstIndex++] = 0;
271        else if (sum > 0xFF0000)
272          dstData[dstIndex++] = 0xFF;
273        else
274          dstData[dstIndex++] = sum >> 16;
275        srcIndex++;
276      }
277      srcIndex++;
278      dstIndex++;
279    }
280  }
281};
282
283/**
284 * Compute the average color for the image.
285 *
286 * @param {ImageData} imageData Image data to analyze.
287 * @return {{r: number, g: number, b: number}} average color.
288 */
289filter.getAverageColor = function(imageData) {
290  var data = imageData.data;
291  var width = imageData.width;
292  var height = imageData.height;
293
294  var total = 0;
295  var r = 0;
296  var g = 0;
297  var b = 0;
298
299  var maxIndex = 4 * width * height;
300  for (var i = 0; i != maxIndex;) {
301    total++;
302    r += data[i++];
303    g += data[i++];
304    b += data[i++];
305    i++;
306  }
307  if (total == 0) return { r: 0, g: 0, b: 0 };
308  return { r: r / total, g: g / total, b: b / total };
309};
310
311/**
312 * Compute the average color with more weight given to pixes at the center.
313 *
314 * @param {ImageData} imageData Image data to analyze.
315 * @return {{r: number, g: number, b: number}} weighted average color.
316 */
317filter.getWeightedAverageColor = function(imageData) {
318  var data = imageData.data;
319  var width = imageData.width;
320  var height = imageData.height;
321
322  var total = 0;
323  var r = 0;
324  var g = 0;
325  var b = 0;
326
327  var center = Math.floor(width / 2);
328  var maxDist = center * Math.sqrt(2);
329  maxDist *= 2; // Weaken the effect of distance
330
331  var i = 0;
332  for (var x = 0; x != width; x++) {
333    for (var y = 0; y != height; y++) {
334      var dist = Math.sqrt(
335          (x - center) * (x - center) + (y - center) * (y - center));
336      var weight = (maxDist - dist) / maxDist;
337
338      total += weight;
339      r += data[i++] * weight;
340      g += data[i++] * weight;
341      b += data[i++] * weight;
342      i++;
343    }
344  }
345  if (total == 0) return { r: 0, g: 0, b: 0 };
346  return { r: r / total, g: g / total, b: b / total };
347};
348
349/**
350 * Copy part of src image to dst, applying matrix color filter on-the-fly.
351 *
352 * The copied part of src should completely fit into dst (there is no clipping
353 * on either side).
354 *
355 * @param {Array.<number>} matrix 3x3 color matrix.
356 * @param {ImageData} dst Destination image data.
357 * @param {ImageData} src Source image data.
358 * @param {number} offsetX X offset in source to start processing.
359 * @param {number} offsetY Y offset in source to start processing.
360 */
361filter.colorMatrix3x3 = function(matrix, dst, src, offsetX, offsetY) {
362  var c11 = filter.floatToFixedPoint(matrix[0]);
363  var c12 = filter.floatToFixedPoint(matrix[1]);
364  var c13 = filter.floatToFixedPoint(matrix[2]);
365  var c21 = filter.floatToFixedPoint(matrix[3]);
366  var c22 = filter.floatToFixedPoint(matrix[4]);
367  var c23 = filter.floatToFixedPoint(matrix[5]);
368  var c31 = filter.floatToFixedPoint(matrix[6]);
369  var c32 = filter.floatToFixedPoint(matrix[7]);
370  var c33 = filter.floatToFixedPoint(matrix[8]);
371
372  var dstData = dst.data;
373  var dstWidth = dst.width;
374  var dstHeight = dst.height;
375
376  var srcData = src.data;
377  var srcWidth = src.width;
378  var srcHeight = src.height;
379
380  if (offsetX < 0 || offsetX + dstWidth > srcWidth ||
381      offsetY < 0 || offsetY + dstHeight > srcHeight)
382      throw new Error('Invalid offset');
383
384  // Javascript is not very good at inlining constants.
385  // We inline manually and assert that the constant is equal to the variable.
386  if (filter.FIXED_POINT_SHIFT != 16)
387    throw new Error('Wrong fixed point shift');
388
389  var dstIndex = 0;
390  for (var y = 0; y != dstHeight; y++) {
391    var srcIndex = (offsetX + (offsetY + y) * srcWidth) * 4;
392    for (var x = 0; x != dstWidth; x++) {
393      var r = srcData[srcIndex++];
394      var g = srcData[srcIndex++];
395      var b = srcData[srcIndex++];
396      srcIndex++;
397
398      var rNew = r * c11 + g * c12 + b * c13;
399      var gNew = r * c21 + g * c22 + b * c23;
400      var bNew = r * c31 + g * c32 + b * c33;
401
402      if (rNew < 0) {
403        dstData[dstIndex++] = 0;
404      } else if (rNew > 0xFF0000) {
405        dstData[dstIndex++] = 0xFF;
406      } else {
407        dstData[dstIndex++] = rNew >> 16;
408      }
409
410      if (gNew < 0) {
411        dstData[dstIndex++] = 0;
412      } else if (gNew > 0xFF0000) {
413        dstData[dstIndex++] = 0xFF;
414      } else {
415        dstData[dstIndex++] = gNew >> 16;
416      }
417
418      if (bNew < 0) {
419        dstData[dstIndex++] = 0;
420      } else if (bNew > 0xFF0000) {
421        dstData[dstIndex++] = 0xFF;
422      } else {
423        dstData[dstIndex++] = bNew >> 16;
424      }
425
426      dstIndex++;
427    }
428  }
429};
430
431/**
432 * Return a convolution filter function bound to specific weights.
433 *
434 * @param {Array.<number>} weights Weights for the convolution matrix
435 *                                 (not normalized).
436 * @return {function(ImageData,ImageData,number,number)} Convolution filter.
437 */
438filter.createConvolutionFilter = function(weights) {
439  // Normalize the weights to sum to 1.
440  var total = 0;
441  for (var i = 0; i != weights.length; i++) {
442    total += weights[i] * (i ? 4 : 1);
443  }
444
445  var normalized = [];
446  for (i = 0; i != weights.length; i++) {
447    normalized.push(weights[i] / total);
448  }
449  for (; i < 4; i++) {
450    normalized.push(0);
451  }
452
453  var maxWeightedSum = 0xFF *
454      Math.abs(normalized[0]) +
455      Math.abs(normalized[1]) * 4 +
456      Math.abs(normalized[2]) * 4 +
457      Math.abs(normalized[3]) * 4;
458  if (maxWeightedSum > filter.MAX_FLOAT_VALUE)
459    throw new Error('convolve5x5 cannot convert the weights to fixed point');
460
461  return filter.convolve5x5.bind(null, normalized);
462};
463
464/**
465 * Creates matrix filter.
466 * @param {Array.<number>} matrix Color transformation matrix.
467 * @return {function(ImageData,ImageData,number,number)} Matrix filter.
468 */
469filter.createColorMatrixFilter = function(matrix) {
470  for (var r = 0; r != 3; r++) {
471    var maxRowSum = 0;
472    for (var c = 0; c != 3; c++) {
473      maxRowSum += 0xFF * Math.abs(matrix[r * 3 + c]);
474    }
475    if (maxRowSum > filter.MAX_FLOAT_VALUE)
476      throw new Error(
477          'colorMatrix3x3 cannot convert the matrix to fixed point');
478  }
479  return filter.colorMatrix3x3.bind(null, matrix);
480};
481
482/**
483 * Return a blur filter.
484 * @param {Object} options Blur options.
485 * @return {function(ImageData,ImageData,number,number)} Blur filter.
486 */
487filter.blur = function(options) {
488  if (options.radius == 1)
489    return filter.createConvolutionFilter(
490        [1, options.strength]);
491  else if (options.radius == 2)
492    return filter.createConvolutionFilter(
493        [1, options.strength, options.strength]);
494  else
495    return filter.createConvolutionFilter(
496        [1, options.strength, options.strength, options.strength]);
497};
498
499/**
500 * Return a sharpen filter.
501 * @param {Object} options Sharpen options.
502 * @return {function(ImageData,ImageData,number,number)} Sharpen filter.
503 */
504filter.sharpen = function(options) {
505  if (options.radius == 1)
506    return filter.createConvolutionFilter(
507        [5, -options.strength]);
508  else if (options.radius == 2)
509    return filter.createConvolutionFilter(
510        [10, -options.strength, -options.strength]);
511  else
512    return filter.createConvolutionFilter(
513        [15, -options.strength, -options.strength, -options.strength]);
514};
515
516/**
517 * Return an exposure filter.
518 * @param {Object} options exposure options.
519 * @return {function(ImageData,ImageData,number,number)} Exposure filter.
520 */
521filter.exposure = function(options) {
522  var pixelMap = filter.precompute(
523    255,
524    function(value) {
525     if (options.brightness > 0) {
526       value *= (1 + options.brightness);
527     } else {
528       value += (0xFF - value) * options.brightness;
529     }
530     return 0x80 +
531         (value - 0x80) * Math.tan((options.contrast + 1) * Math.PI / 4);
532    });
533
534  return filter.mapPixels.bind(null, pixelMap, pixelMap, pixelMap);
535};
536
537/**
538 * Return a color autofix filter.
539 * @param {Object} options Histogram for autofix.
540 * @return {function(ImageData,ImageData,number,number)} Autofix filter.
541 */
542filter.autofix = function(options) {
543  return filter.mapPixels.bind(null,
544      filter.autofix.stretchColors(options.histogram.r),
545      filter.autofix.stretchColors(options.histogram.g),
546      filter.autofix.stretchColors(options.histogram.b));
547};
548
549/**
550 * Return a conversion table that stretches the range of colors used
551 * in the image to 0..255.
552 * @param {Array.<number>} channelHistogram Histogram to calculate range.
553 * @return {Uint8Array} Color mapping array.
554 */
555filter.autofix.stretchColors = function(channelHistogram) {
556  var range = filter.autofix.getRange(channelHistogram);
557  return filter.precompute(
558      255,
559      function(x) {
560        return (x - range.first) / (range.last - range.first) * 255;
561      }
562  );
563};
564
565/**
566 * Return a range that encloses non-zero elements values in a histogram array.
567 * @param {Array.<number>} channelHistogram Histogram to analyze.
568 * @return {{first: number, last: number}} Channel range in histogram.
569 */
570filter.autofix.getRange = function(channelHistogram) {
571  var first = 0;
572  while (first < channelHistogram.length && channelHistogram[first] == 0)
573    first++;
574
575  var last = channelHistogram.length - 1;
576  while (last >= 0 && channelHistogram[last] == 0)
577    last--;
578
579  if (first >= last) // Stretching does not make sense
580    return {first: 0, last: channelHistogram.length - 1};
581  else
582    return {first: first, last: last};
583};
584
585/**
586 * Minimum channel offset that makes visual difference. If autofix calculated
587 * offset is less than SENSITIVITY, probably autofix is not needed.
588 * Reasonable empirical value.
589 * @type {number}
590 */
591filter.autofix.SENSITIVITY = 8;
592
593/**
594 * @param {Array.<number>} channelHistogram Histogram to analyze.
595 * @return {boolean} True if stretching this range to 0..255 would make
596 *                   a visible difference.
597 */
598filter.autofix.needsStretching = function(channelHistogram) {
599  var range = filter.autofix.getRange(channelHistogram);
600  return (range.first >= filter.autofix.SENSITIVITY ||
601          range.last <= 255 - filter.autofix.SENSITIVITY);
602};
603
604/**
605 * @param {{r: Array.<number>, g: Array.<number>, b: Array.<number>}} histogram
606 * @return {boolean} True if the autofix would make a visible difference.
607 */
608filter.autofix.isApplicable = function(histogram) {
609  return filter.autofix.needsStretching(histogram.r) ||
610         filter.autofix.needsStretching(histogram.g) ||
611         filter.autofix.needsStretching(histogram.b);
612};
613