1 // Copyright (c) 2012 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