Home | History | Annotate | Download | only in image_editor
      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  */
     10 var 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  */
     19 filter.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  */
     41 filter.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  */
     90 filter.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  */
    131 filter.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  */
    151 filter.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  */
    181 filter.FIXED_POINT_SHIFT = 16;
    182 
    183 /**
    184  * Maximum value that can be represented in fixed point without overflow.
    185  * @type {number}
    186  */
    187 filter.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  */
    194 filter.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  */
    216 filter.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  */
    289 filter.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  */
    317 filter.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  */
    361 filter.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  */
    438 filter.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  */
    469 filter.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  */
    487 filter.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  */
    504 filter.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  */
    521 filter.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  */
    542 filter.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  */
    555 filter.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  */
    570 filter.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  */
    591 filter.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  */
    598 filter.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  */
    608 filter.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