使用大比例因子调整图像大小

Image resize with large scaling factor

对于上下文,此问题遵循 this one

这个着色器的目的是有一个可预测的图像大小调整算法,这样我就知道来自 webgl 端的结果图像是否可以与来自服务器端的图像进行比较,在上下文中感知哈希。

我正在使用这个库 method 在服务器端调整大小,我正在尝试使用纹理查找通过着色器复制它。

我一直在尝试实现基本版本(使用库中的 Nearest/Box 内核),包括将输入图像分成多个框,并对所有包含的像素进行平均,所有像素都共享相同的权重。

我附上了工作程序的片段,显示了它的结果(左)显示在参考图像(右)旁边。即使缩放看起来有效,参考照片(从库中计算)和 webgl 版本(查看右侧第 7 行)之间也存在显着差异。控制台记录像素值并计算不同像素的数量(注意:基础图像是灰度)。

我猜错误来自于纹理查找,所选纹理元素是否正确地属于盒子,我对纹理坐标的位置以及它们如何与特定纹理元素相关感到有点困惑。例如,我添加了 0.5 偏移量以定位纹素中心,但结果不匹配。

Base image dimensions: 341x256

Target dimensions: 9x9 (The aspect ratio is indeed different.)

(根据这些尺寸,可以猜出不同的盒子,并添加相应的纹理查找指令,这里一个盒子的尺寸为38x29)

const targetWidth = 9;
const targetHeight = 9;

let referencePixels, resizedPixels;

const baseImage = new Image();
baseImage.src = 'https://i.imgur.com/O6aW2Tg.png';
baseImage.crossOrigin = 'anonymous';
baseImage.onload = function() {
  render(baseImage);
};

const referenceCanvas = document.getElementById('reference-canvas');
const referenceImage = new Image();
referenceImage.src = 'https://i.imgur.com/s9Mrsjm.png';
referenceImage.crossOrigin = 'anonymous';
referenceImage.onload = function() {
  referenceCanvas.width = referenceImage.width;
  referenceCanvas.height = referenceImage.height;
  referenceCanvas
    .getContext('2d')
    .drawImage(
      referenceImage,
      0,
      0,
      referenceImage.width,
      referenceImage.height
    );
  referencePixels = referenceCanvas
    .getContext('2d')
    .getImageData(0, 0, targetWidth, targetHeight).data;
  if (resizedPixels !== undefined) {
    compare();
  }
};

const horizontalVertexShaderSource = `#version 300 es
precision mediump float;

in vec2 position;
out vec2 textureCoordinate;

void main() {
  textureCoordinate = vec2(1.0 - position.x, 1.0 - position.y);
  gl_Position = vec4((1.0 - 2.0 * position), 0, 1);
}`;

const horizontalFragmentShaderSource = `#version 300 es
precision mediump float;

uniform sampler2D inputTexture;
in vec2 textureCoordinate;
out vec4 fragColor;

void main() {
    vec2 texelSize = 1.0 / vec2(textureSize(inputTexture, 0));
    float sumWeight = 0.0;
    vec3 sum = vec3(0.0);

    float cursorTextureCoordinateX = 0.0;
    float cursorTextureCoordinateY = 0.0;
    float boundsFactor = 0.0;
    vec4 cursorPixel = vec4(0.0);

    // These values corresponds to the center of the texture pixels,
    // that are belong to the current "box",
    // here we need 38 pixels from the base image
    // to make one pixel on the resized version.
    ${[
      -18.5,
      -17.5,
      -16.5,
      -15.5,
      -14.5,
      -13.5,
      -12.5,
      -11.5,
      -10.5,
      -9.5,
      -8.5,
      -7.5,
      -6.5,
      -5.5,
      -4.5,
      -3.5,
      -2.5,
      -1.5,
      -0.5,
      0.5,
      1.5,
      2.5,
      3.5,
      4.5,
      5.5,
      6.5,
      7.5,
      8.5,
      9.5,
      10.5,
      11.5,
      12.5,
      13.5,
      14.5,
      15.5,
      16.5,
      17.5,
      18.5,
    ]
      .map(texelIndex => {
        return `
    cursorTextureCoordinateX = textureCoordinate.x + texelSize.x * ${texelIndex.toFixed(
      2
    )};
    cursorTextureCoordinateY = textureCoordinate.y;
    cursorPixel = texture(
        inputTexture,
        vec2(cursorTextureCoordinateX, cursorTextureCoordinateY)
    );
    // Whether this texel belongs to the texture or not.
    boundsFactor = 1.0 - step(0.51, abs(0.5 - cursorTextureCoordinateX));
    sum += boundsFactor * cursorPixel.rgb * 1.0;
    sumWeight += boundsFactor * 1.0;`;
      })
      .join('')}

    fragColor = vec4(sum / sumWeight, 1.0);
}`;

const verticalVertexShaderSource = `#version 300 es
precision mediump float;

in vec2 position;
out vec2 textureCoordinate;

void main() {
  textureCoordinate = vec2(1.0 - position.x, position.y);
  gl_Position = vec4((1.0 - 2.0 * position), 0, 1);
}`;

const verticalFragmentShaderSource = `#version 300 es
precision mediump float;

uniform sampler2D inputTexture;
in vec2 textureCoordinate;
out vec4 fragColor;

void main() {
    vec2 texelSize = 1.0 / vec2(textureSize(inputTexture, 0));
    float sumWeight = 0.0;
    vec3 sum = vec3(0.0);

    float cursorTextureCoordinateX = 0.0;
    float cursorTextureCoordinateY = 0.0;
    float boundsFactor = 0.0;
    vec4 cursorPixel = vec4(0.0);

    ${[
      -14, -13, -12, -11, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14
    ]
      .map(texelIndex => {
        return `
    cursorTextureCoordinateX = textureCoordinate.x;
    cursorTextureCoordinateY = textureCoordinate.y + texelSize.y * ${texelIndex.toFixed(
      2
    )};
    cursorPixel = texture(
        inputTexture,
        vec2(cursorTextureCoordinateX, cursorTextureCoordinateY)
    );
    boundsFactor = 1.0 - step(0.51, abs(0.5 - cursorTextureCoordinateY));
    sum += boundsFactor * cursorPixel.rgb * 1.0;
    sumWeight += boundsFactor * 1.0;`;
      })
      .join('')}

  fragColor = vec4(sum / sumWeight, 1.0);
}`;

function render(image) {
  const canvas = document.getElementById('canvas');
  const gl = canvas.getContext('webgl2');
  if (!gl) {
    return;
  }

  const positionBuffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
  gl.bufferData(
    gl.ARRAY_BUFFER,
    new Float32Array([-1, -1, -1, 1, 1, 1, -1, -1, 1, 1, 1, -1]),
    gl.STATIC_DRAW
  );
  gl.bindBuffer(gl.ARRAY_BUFFER, null);

  const horizontalProgram = webglUtils.createProgramFromSources(gl, [
    horizontalVertexShaderSource,
    horizontalFragmentShaderSource,
  ]);
  const horizontalPositionAttributeLocation = gl.getAttribLocation(
    horizontalProgram,
    'position'
  );
  const horizontalInputTextureUniformLocation = gl.getUniformLocation(
    horizontalProgram,
    'inputTexture'
  );
  const horizontalVao = gl.createVertexArray();
  gl.bindVertexArray(horizontalVao);
  gl.enableVertexAttribArray(horizontalPositionAttributeLocation);
  gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
  gl.vertexAttribPointer(
    horizontalPositionAttributeLocation,
    2,
    gl.FLOAT,
    false,
    0,
    0
  );
  gl.bindVertexArray(null);
  gl.bindBuffer(gl.ARRAY_BUFFER, null);

  const verticalProgram = webglUtils.createProgramFromSources(gl, [
    verticalVertexShaderSource,
    verticalFragmentShaderSource,
  ]);
  const verticalPositionAttributeLocation = gl.getAttribLocation(
    verticalProgram,
    'position'
  );
  const verticalInputTextureUniformLocation = gl.getUniformLocation(
    verticalProgram,
    'inputTexture'
  );
  const verticalVao = gl.createVertexArray();
  gl.bindVertexArray(verticalVao);
  gl.enableVertexAttribArray(verticalPositionAttributeLocation);
  gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
  gl.vertexAttribPointer(
    verticalPositionAttributeLocation,
    2,
    gl.FLOAT,
    false,
    0,
    0
  );
  gl.bindVertexArray(null);
  gl.bindBuffer(gl.ARRAY_BUFFER, null);

  const rawTexture = gl.createTexture();
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_2D, rawTexture);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);

  const horizontalTexture = gl.createTexture();
  gl.activeTexture(gl.TEXTURE1);
  gl.bindTexture(gl.TEXTURE_2D, horizontalTexture);
  gl.texImage2D(
    gl.TEXTURE_2D,
    0,
    gl.RGBA,
    targetWidth,
    image.height,
    0,
    gl.RGBA,
    gl.UNSIGNED_BYTE,
    null
  );
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);

  const framebuffer = gl.createFramebuffer();

  // Step 1: Draw horizontally-resized image to the horizontalTexture;
  gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
  gl.framebufferTexture2D(
    gl.FRAMEBUFFER,
    gl.COLOR_ATTACHMENT0,
    gl.TEXTURE_2D,
    horizontalTexture,
    0
  );
  gl.viewport(0, 0, targetWidth, image.height);
  gl.clearColor(0, 0, 0, 1.0);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  gl.useProgram(horizontalProgram);
  gl.uniform1i(horizontalInputTextureUniformLocation, 0);
  gl.bindVertexArray(horizontalVao);
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_2D, rawTexture);
  gl.drawArrays(gl.TRIANGLES, 0, 6);
  gl.bindVertexArray(null);

  // Step 2: Draw vertically-resized image to canvas (from the horizontalTexture);
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);

  gl.viewport(0, 0, targetWidth, targetHeight);
  gl.clearColor(0, 0, 0, 1.0);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  gl.useProgram(verticalProgram);
  gl.uniform1i(verticalInputTextureUniformLocation, 1);
  gl.bindVertexArray(verticalVao);
  gl.activeTexture(gl.TEXTURE1);
  gl.bindTexture(gl.TEXTURE_2D, horizontalTexture);
  gl.drawArrays(gl.TRIANGLES, 0, 6);
  gl.bindVertexArray(null);

  const _resizedPixels = new Uint8Array(4 * targetWidth * targetHeight);
  gl.readPixels(
    0,
    0,
    targetWidth,
    targetHeight,
    gl.RGBA,
    gl.UNSIGNED_BYTE,
    _resizedPixels
  );
  resizedPixels = _resizedPixels;
  if (referencePixels !== undefined) {
    compare();
  }
}

function compare() {
  console.log('= Resized (webgl) =');
  console.log(resizedPixels);
  console.log('= Reference (rust library) =');
  console.log(referencePixels);

  let differenceCount = 0;
  for (
    let pixelIndex = 0;
    pixelIndex <= targetWidth * targetHeight;
    pixelIndex++
  ) {
    if (resizedPixels[4 * pixelIndex] !== referencePixels[4 * pixelIndex]) {
      differenceCount++;
    }
  }
  console.log(`Number of different pixels: ${differenceCount}`);
}
body {
  image-rendering: pixelated;
  image-rendering: -moz-crisp-edges;
}
<canvas id="canvas" width="9" height="9" style="transform: scale(20); margin: 100px;"></canvas>
<canvas id="reference-canvas" width="9" height="9" style="transform: scale(20); margin: 100px;"></canvas>
<script src="https://webgl2fundamentals.org/webgl/resources/webgl-utils.js"></script>


跟进

我用了第三种方法调整了图片大小(使用图片处理软件),结果和参考图片完全一样。 在我的图像数据作为原始 Uint8Array 导入的用例中,屏幕上没有显示任何内容,但我使用 canvas 准备了代码片段以使其更直观。

在任何一种情况下,在代码片段和我的内部用例中,结果都与参考不匹配,差异为 "significant"。如果比较两张图片,webgl 版本肯定比参考版本(在两个方向上)更模糊,参考中的边缘更清晰。更可能的原因是 webgl "box" 定义更松散并且捕获了太多纹理像素。

我应该更有针对性地提出问题。在考虑浮点错误和格式实现之前,我想确保着色器正常运行,尤其是当我对我的纹理贴图不太自信时。

如何将纹理坐标从 0..1 转换为纹理查找,尤其是当 width/newWidth 不是彼此的倍数时?当片段着色器从顶点着色器接收到纹理坐标时,它对应于渲染像素的质心,还是其他什么?

我应该使用 gl_FragCoord 作为参考点而不是纹理坐标吗? (我尝试按照建议使用 texFetch,但我不知道如何使用纹理 coordinates/vertex 着色器输出制作 link。)

我没有仔细查看代码,但有几个地方可能会出错。

WebGL 默认为抗锯齿canvas

您需要通过将 {antialias: false} 传递给 getContext 来关闭此功能,如

const gl = someCanvas.getContext('webgl2', {antialias: false});

换句话说,您绘制的像素比您想象的要多,WebGL 正在使用 OpenGL 的内置抗锯齿功能缩小它们。对于这种情况,结果可能相同,但您可能应该关闭该功能。

RUST 加载器可能正在应用 PNG 颜色space

PNG 文件格式有颜色space 设置。 loader 是否应用这些设置以及它们如何应用这些设置对于每个 loader 都是不同的,所以换句话说,你需要检查 rust 代码。有几个具有极端 colorspace/color 配置文件设置的小 PNG 可供测试 this test

浏览器可能正在应用 PNG 颜色space

它可能会破坏的下一个地方是浏览器可能正在应用来自文件

的显示器颜色校正和/或颜色space

对于 WebGL,您可以在将图像作为纹理上传之前通过设置 gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE) 关闭任何颜色space 应用程序。

不幸的是,在 canvas 2D 中使用图像时没有这样的设置,因此您可能需要通过将图像绘制成 2D canvas 并调用 getImageData 来获取比较数据寻找其他方式。一种方法是将比较图像加载到 WebGL(设置上述设置后),渲染它并使用 gl.readPixels

读回

Canvas2d 使用预乘 alpha

另一个可能会出错的地方,但我猜这里不相关的是 canvas 2d 使用预乘 alpha,这意味着如果图像中的任何 alpha 不是 255,则渲染为 2D canvas有损。

您可以考虑使用不使用图像的硬编码测试,而不是去做所有这些工作。这样您就可以暂时避免 colorspace 问题,只需确保着色器正常工作即可。制作一个 76x76 的图像数据数组,转换为 2x2.

其他

精度

使用 highp 而不是 mediump。这不会影响桌面上的任何内容,但会影响移动设备。

texelFetch

另外仅供参考,在 WebGL2 中,您可以使用 texelFetch(samplerUniform, ivec2(intPixelX, intPixelY), mipLevel) 读取单个纹理 pixels/texels,这比操作 texture(sampleUniform, normalizedTextureCoords)

的归一化纹理坐标要容易得多

循环

我注意到您不是在使用循环而是在生成代码。只要可以在编译时展开的循环就应该可以工作,所以你可以这样做

for (int i = -17; i < 19; ++i) {
  sum += texelFetch(sampler, ivec2(intX + i, intY), 0);
}

并且在着色器生成时

for (int i = ${start}; i < ${end}; ++i) {

或类似的东西。这可能更容易推理?

浮动转换问题

您正在将数据上传到 gl.RGBA 纹理并将数据用作浮点数。可能会有精度损失。您可以将纹理上传为 gl.RGBA8UI(无符号 8 位纹理)

gl.texImage2D(target, level, gl.RGBA8UI, gl.RGBA_INTEGER, gl.UNSIGNED_BYTE, image)

然后在着色器中使用 usampler2D 并使用

将像素读取为无符号整数
uvec4 color = texelFetch(someUnsignedSampler2D, ivec2(px, py), 0);

并在着色器中使用无符号整数完成其余所有操作

您还可以创建一个 gl.RGBA8UI 纹理并将其附加到帧缓冲区,这样您就可以将结果写为无符号整数,然后 readPixels 结果。

这有望消除任何无符号字节 -> 浮点数 -> 无符号字节精度问题。

我猜如果你看一下 Rust 代码,它可能会以整数形式完成所有工作 space?