image.js 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. import { PNG } from 'pngjs';
  2. import jpeg from 'jpeg-js';
  3. import * as fs from 'fs/promises';
  4. class Image {
  5. constructor( width, height, data ) {
  6. this.width = width;
  7. this.height = height;
  8. this.data = data;
  9. }
  10. get bitmap() {
  11. return { width: this.width, height: this.height, data: this.data };
  12. }
  13. clone() {
  14. return new Image( this.width, this.height, Buffer.from( this.data ) );
  15. }
  16. scale( factor ) {
  17. if ( factor >= 1 ) {
  18. console.warn( 'Image.scale() is optimized for downscaling only.' );
  19. }
  20. const newWidth = Math.round( this.width * factor );
  21. const newHeight = Math.round( this.height * factor );
  22. const newData = Buffer.alloc( newWidth * newHeight * 4 );
  23. // Box filter for downscaling (averages all source pixels in the region)
  24. const scaleX = this.width / newWidth;
  25. const scaleY = this.height / newHeight;
  26. for ( let y = 0; y < newHeight; y ++ ) {
  27. for ( let x = 0; x < newWidth; x ++ ) {
  28. // Calculate source region
  29. const srcX0 = x * scaleX;
  30. const srcY0 = y * scaleY;
  31. const srcX1 = ( x + 1 ) * scaleX;
  32. const srcY1 = ( y + 1 ) * scaleY;
  33. const x0 = Math.floor( srcX0 );
  34. const y0 = Math.floor( srcY0 );
  35. const x1 = Math.min( Math.ceil( srcX1 ), this.width );
  36. const y1 = Math.min( Math.ceil( srcY1 ), this.height );
  37. const dstIdx = ( y * newWidth + x ) * 4;
  38. const sums = [ 0, 0, 0, 0 ];
  39. let totalWeight = 0;
  40. // Average all pixels in the source region with proper weighting
  41. for ( let sy = y0; sy < y1; sy ++ ) {
  42. for ( let sx = x0; sx < x1; sx ++ ) {
  43. // Calculate coverage weight for edge pixels
  44. const wx0 = Math.max( 0, Math.min( 1, sx + 1 - srcX0 ) );
  45. const wx1 = Math.max( 0, Math.min( 1, srcX1 - sx ) );
  46. const wy0 = Math.max( 0, Math.min( 1, sy + 1 - srcY0 ) );
  47. const wy1 = Math.max( 0, Math.min( 1, srcY1 - sy ) );
  48. const weight = Math.min( wx0, wx1 ) * Math.min( wy0, wy1 );
  49. const srcIdx = ( sy * this.width + sx ) * 4;
  50. for ( let c = 0; c < 4; c ++ ) {
  51. sums[ c ] += this.data[ srcIdx + c ] * weight;
  52. }
  53. totalWeight += weight;
  54. }
  55. }
  56. for ( let c = 0; c < 4; c ++ ) {
  57. newData[ dstIdx + c ] = Math.round( sums[ c ] / totalWeight );
  58. }
  59. }
  60. }
  61. this.width = newWidth;
  62. this.height = newHeight;
  63. this.data = newData;
  64. return this;
  65. }
  66. compare( other, diff, threshold = 0.1 ) {
  67. if ( this.width !== other.width || this.height !== other.height ) {
  68. throw new Error( 'Image sizes do not match' );
  69. }
  70. const maxDelta = 255 * 255 * 3; // Max squared distance in RGB space
  71. let numDiffPixels = 0;
  72. for ( let i = 0; i < this.data.length; i += 4 ) {
  73. const dr = this.data[ i ] - other.data[ i ];
  74. const dg = this.data[ i + 1 ] - other.data[ i + 1 ];
  75. const db = this.data[ i + 2 ] - other.data[ i + 2 ];
  76. // Squared Euclidean distance normalized to 0-1
  77. const delta = ( dr * dr + dg * dg + db * db ) / maxDelta;
  78. if ( delta > threshold * threshold ) {
  79. numDiffPixels ++;
  80. // Mark difference in red
  81. diff.data[ i ] = 255;
  82. diff.data[ i + 1 ] = 0;
  83. diff.data[ i + 2 ] = 0;
  84. diff.data[ i + 3 ] = 255;
  85. } else {
  86. // Dim matching pixels
  87. diff.data[ i ] = this.data[ i ] * 0.2;
  88. diff.data[ i + 1 ] = this.data[ i + 1 ] * 0.2;
  89. diff.data[ i + 2 ] = this.data[ i + 2 ] * 0.2;
  90. diff.data[ i + 3 ] = 255;
  91. }
  92. }
  93. return numDiffPixels;
  94. }
  95. async write( filepath, quality = 95 ) {
  96. const rawImageData = {
  97. data: this.data,
  98. width: this.width,
  99. height: this.height
  100. };
  101. const encoded = jpeg.encode( rawImageData, quality );
  102. await fs.writeFile( filepath, encoded.data );
  103. }
  104. static async read( input ) {
  105. let buffer;
  106. if ( typeof input === 'string' ) {
  107. buffer = await fs.readFile( input );
  108. } else {
  109. buffer = input;
  110. }
  111. // Check if PNG (starts with PNG signature)
  112. if ( buffer[ 0 ] === 0x89 && buffer[ 1 ] === 0x50 && buffer[ 2 ] === 0x4E && buffer[ 3 ] === 0x47 ) {
  113. const png = PNG.sync.read( buffer );
  114. return new Image( png.width, png.height, png.data );
  115. }
  116. // Otherwise assume JPEG
  117. const decoded = jpeg.decode( buffer, { useTArray: true } );
  118. return new Image( decoded.width, decoded.height, Buffer.from( decoded.data ) );
  119. }
  120. }
  121. export { Image };
粤ICP备19079148号