みかづきブログ・カスタム

基本的にはちょちょいのほいです。

JavaScriptで台形補正 ▫️

WebGLで台形補正の処理を書いてみました。

ソースコード

const state = {
  corners: null,
  selectedCornerIndex: null,
  image: null,
  gridSize: 50,
  isDragging: false,
  dragStartPos: null
};

const mat4 = {
  create: () => new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
  ortho: (out, left, right, bottom, top, near, far) => {
    const lr = 1 / (left - right);
    const bt = 1 / (bottom - top);
    const nf = 1 / (near - far);

    out[0] = -2 * lr;
    out[1] = 0;
    out[2] = 0;
    out[3] = 0;
    out[4] = 0;
    out[5] = -2 * bt;
    out[6] = 0;
    out[7] = 0;
    out[8] = 0;
    out[9] = 0;
    out[10] = 2 * nf;
    out[11] = 0;
    out[12] = (left + right) * lr;
    out[13] = (top + bottom) * bt;
    out[14] = (far + near) * nf;
    out[15] = 1;

    return out;
  },
  fromValues: (m00, m01, m02, m03, m10, m11, m12, m13, m20, m21, m22, m23, m30, m31, m32, m33) => {
    return new Float32Array([m00, m01, m02, m03, m10, m11, m12, m13, m20, m21, m22, m23, m30, m31, m32, m33]);
  }
};

function solveLinearSystem(A, b) {
  const n = A.length;
  const augmented = A.map((row, i) => [...row, b[i]]);

  for (let i = 0; i < n; i++) {
    let maxRow = i;

    for (let k = i + 1; k < n; k++) {
      if (Math.abs(augmented[k][i]) > Math.abs(augmented[maxRow][i])) {
        maxRow = k;
      }
    }

    [augmented[i], augmented[maxRow]] = [augmented[maxRow], augmented[i]];

    if (Math.abs(augmented[i][i]) < 1e-10) {
      console.error('Singular matrix detected');

      return new Array(n).fill(0).map((_, idx) => idx < 2 ? 1 : 0);
    }

    for (let k = i + 1; k < n; k++) {
      const factor = augmented[k][i] / augmented[i][i];

      for (let j = i; j <= n; j++) {
        augmented[k][j] -= factor * augmented[i][j];
      }
    }
  }

  const x = new Array(n).fill(0);

  for (let i = n - 1; i >= 0; i--) {
    x[i] = augmented[i][n];

    for (let j = i + 1; j < n; j++) {
      x[i] -= augmented[i][j] * x[j];
    }

    x[i] /= augmented[i][i];
  }

  return x;
}

function calculateHomography(src, dst) {
  const A = [];

  for (let i = 0; i < 4; i++) {
    const [x, y] = src[i];
    const [xp, yp] = dst[i];

    A.push([-x, -y, -1, 0, 0, 0, x * xp, y * xp, xp]);
    A.push([0, 0, 0, -x, -y, -1, x * yp, y * yp, yp]);
  }

  const A_mat = A.map(row => row.slice(0, 8));
  const b = A.map(row => -row[8]);
  const h = solveLinearSystem(A_mat, b);

  return [...h, 1];
}

function calculatePerspectiveMatrix(corners) {
  const src = [[0, 0], [1, 0], [0, 1], [1, 1]];
  const dst = corners;
  const H = calculateHomography(src, dst);
  const matrix = mat4.fromValues(
    H[0], H[3], 0, H[6],
    H[1], H[4], 0, H[7],
    0, 0, 1, 0,
    H[2], H[5], 0, H[8]
  );

  return matrix;
}

const canvas = document.getElementById('glCanvas');
const gl = canvas.getContext('webgl');

if (!gl) {
  alert('WebGL not supported');
  throw new Error('WebGL not supported');
}

const vertexShaderSource = `
  attribute vec3 position;
  attribute vec2 texCoord;

  uniform mat4 projectionMatrix;
  uniform mat4 viewMatrix;
  uniform mat4 modelMatrix;

  varying vec2 v_texCoord;

  void main() {
    gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
    v_texCoord = texCoord;
  }
`;

const fragmentShaderSource = `
  precision mediump float;

  uniform sampler2D texture;

  varying vec2 v_texCoord;

  void main() {
    gl_FragColor = texture2D(texture, v_texCoord);
  }
`;

function createShader(gl, type, source) {
  const shader = gl.createShader(type);

  gl.shaderSource(shader, source);
  gl.compileShader(shader);

  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    console.error('Shader compile error:', gl.getShaderInfoLog(shader));
    gl.deleteShader(shader);

    return null;
  }

  return shader;
}

function createProgram(gl, vertexShader, fragmentShader) {
  const program = gl.createProgram();

  gl.attachShader(program, vertexShader);
  gl.attachShader(program, fragmentShader);
  gl.linkProgram(program);

  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    console.error('Program link error:', gl.getProgramInfoLog(program));
    gl.deleteProgram(program);

    return null;
  }

  return program;
}

const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);
const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);
const program = createProgram(gl, vertexShader, fragmentShader);
const positionLoc = gl.getAttribLocation(program, 'position');
const texCoordLoc = gl.getAttribLocation(program, 'texCoord');
const projectionMatrixLoc = gl.getUniformLocation(program, 'projectionMatrix');
const viewMatrixLoc = gl.getUniformLocation(program, 'viewMatrix');
const modelMatrixLoc = gl.getUniformLocation(program, 'modelMatrix');
const textureLoc = gl.getUniformLocation(program, 'texture');
const vertices = new Float32Array([
  0, 0, 0, 0, 0,
  1, 0, 0, 1, 0,
  0, 1, 0, 0, 1,
  0, 1, 0, 0, 1,
  1, 0, 0, 1, 0,
  1, 1, 0, 1, 1
]);
const vertexBuffer = gl.createBuffer();
const texture = gl.createTexture();
const sourceCanvas = document.createElement('canvas');
const sourceCtx = sourceCanvas.getContext('2d');

gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
gl.bindTexture(gl.TEXTURE_2D, texture);
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);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);

function resizeCanvas() {
  const width = window.innerWidth;
  const height = window.innerHeight;

  canvas.width = width;
  canvas.height = height;
  sourceCanvas.width = width;
  sourceCanvas.height = height;
  gl.viewport(0, 0, width, height);

  if (!state.corners) {
    state.corners = [
      [0, 0],
      [1, 0],
      [0, 1],
      [1, 1]
    ];
  }

  updateControlPoints();
  render();
}

function renderGrid() {
  const width = sourceCanvas.width;
  const height = sourceCanvas.height;
  const gridSize = state.gridSize;
  const gridCols = Math.floor(width / gridSize);
  const gridRows = Math.floor(height / gridSize);
  const gridWidth = gridCols * gridSize;
  const gridHeight = gridRows * gridSize;
  const marginX = Math.floor((width - gridWidth) / 2);
  const marginY = Math.floor((height - gridHeight) / 2);

  sourceCtx.fillStyle = '#000';
  sourceCtx.fillRect(0, 0, width, height);
  sourceCtx.strokeStyle = '#00f';
  sourceCtx.lineWidth = 1;
  sourceCtx.save();
  sourceCtx.translate(marginX, marginY);

  for (let x = 0; x <= gridWidth; x += gridSize) {
    sourceCtx.beginPath();
    sourceCtx.moveTo(x, 0);
    sourceCtx.lineTo(x, gridHeight);
    sourceCtx.stroke();
  }

  for (let y = 0; y <= gridHeight; y += gridSize) {
    sourceCtx.beginPath();
    sourceCtx.moveTo(0, y);
    sourceCtx.lineTo(gridWidth, y);
    sourceCtx.stroke();
  }

  sourceCtx.restore();
}

function renderImage() {
  const width = sourceCanvas.width;
  const height = sourceCanvas.height;

  renderGrid();

  if (state.image) {
    const imgWidth = state.image.width;
    const imgHeight = state.image.height;
    const scale = Math.min(width / imgWidth, height / imgHeight);
    const drawWidth = imgWidth * scale;
    const drawHeight = imgHeight * scale;
    const x = (width - drawWidth) / 2;
    const y = (height - drawHeight) / 2;

    sourceCtx.drawImage(state.image, x, y, drawWidth, drawHeight);
  }
}

function render() {
  const stride = 5 * Float32Array.BYTES_PER_ELEMENT;
  const projectionMatrix = mat4.create();
  const viewMatrix = mat4.create();
  const modelMatrix = calculatePerspectiveMatrix(state.corners);

  renderImage();

  gl.clearColor(0, 0, 0, 1);
  gl.clear(gl.COLOR_BUFFER_BIT);
  gl.enable(gl.BLEND);
  gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
  gl.useProgram(program);
  gl.bindTexture(gl.TEXTURE_2D, texture);
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, sourceCanvas);
  gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
  gl.enableVertexAttribArray(positionLoc);
  gl.vertexAttribPointer(positionLoc, 3, gl.FLOAT, false, stride, 0);
  gl.enableVertexAttribArray(texCoordLoc);
  gl.vertexAttribPointer(texCoordLoc, 2, gl.FLOAT, false, stride, 3 * Float32Array.BYTES_PER_ELEMENT);
  mat4.ortho(projectionMatrix, 0, 1, 1, 0, -1, 1);
  gl.uniformMatrix4fv(projectionMatrixLoc, false, projectionMatrix);
  gl.uniformMatrix4fv(viewMatrixLoc, false, viewMatrix);
  gl.uniformMatrix4fv(modelMatrixLoc, false, modelMatrix);
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_2D, texture);
  gl.uniform1i(textureLoc, 0);
  gl.drawArrays(gl.TRIANGLES, 0, 6);
}

function updateControlPoints() {
  const container = document.getElementById('controlPoints');
  const width = canvas.width;
  const height = canvas.height;

  container.innerHTML = '';

  state.corners.forEach((corner, index) => {
    const [nx, ny] = corner;
    const x = nx * width;
    const y = ny * height;
    const div = document.createElement('div');

    div.className = 'corner';

    if (state.selectedCornerIndex === index) {
      div.classList.add('selected');
    }

    div.style.left = `${x}px`;
    div.style.top = `${y}px`;
    div.addEventListener('mousedown', (e) => {
      e.stopPropagation();
      state.isDragging = true;
      state.dragStartPos = { x: e.clientX, y: e.clientY };
      state.selectedCornerIndex = index;
      updateControlPoints();
    });
    container.appendChild(div);
  });
}

document.addEventListener('keydown', (e) => {
  if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Space'].includes(e.key)) {
    e.preventDefault();
  }

  if (state.selectedCornerIndex === null) return;

  const step = e.shiftKey ? 0.01 : 0.001;

  let [x, y] = state.corners[state.selectedCornerIndex];

  switch (e.key) {
    case 'ArrowLeft':
      x = Math.max(0, x - step);
      break;
    case 'ArrowRight':
      x = Math.min(1, x + step);
      break;
    case 'ArrowUp':
      y = Math.max(0, y - step);
      break;
    case 'ArrowDown':
      y = Math.min(1, y + step);
      break;
    case 'r':
    case 'R':
      resetCorners();
      e.preventDefault();
      return;
    default:
      return;
  }

  state.corners[state.selectedCornerIndex] = [x, y];
  updateControlPoints();
  render();
});

function resetCorners() {
  state.corners = [[0, 0], [1, 0], [0, 1], [1, 1]];
  saveCornersToStorage();
  updateControlPoints();
  render();
}

document.addEventListener('mousemove', (e) => {
  if (!state.isDragging || state.selectedCornerIndex === null) return;

  const width = canvas.width;
  const height = canvas.height;
  const nx = Math.max(0, Math.min(1, e.clientX / width));
  const ny = Math.max(0, Math.min(1, e.clientY / height));

  state.corners[state.selectedCornerIndex] = [nx, ny];

  updateControlPoints();
  render();
});

document.addEventListener('mouseup', (e) => {
  if (!state.isDragging) return;

  const dragDistance = Math.sqrt(
    Math.pow(e.clientX - state.dragStartPos.x, 2) +
    Math.pow(e.clientY - state.dragStartPos.y, 2)
  );

  if (dragDistance < 5) {
    state.selectedCornerIndex = state.selectedCornerIndex;
  }

  state.isDragging = false;
  state.dragStartPos = null;

  saveCornersToStorage();
  updateControlPoints();
});

const dropOverlay = document.getElementById('dropOverlay');

document.body.addEventListener('dragover', (e) => {
  e.preventDefault();
  dropOverlay.classList.add('active');
});

document.body.addEventListener('dragleave', (e) => {
  if (e.target === document.body) {
    dropOverlay.classList.remove('active');
  }
});

document.body.addEventListener('drop', (e) => {
  e.preventDefault();
  dropOverlay.classList.remove('active');

  const files = e.dataTransfer.files;

  if (files.length > 0 && files[0].type.startsWith('image/')) {
    const reader = new FileReader();

    reader.onload = (event) => {
      const img = new Image();

      img.onload = () => {
        state.image = img;
        render();
      };
      img.src = event.target.result;
    };
    reader.readAsDataURL(files[0]);
  }
});

document.addEventListener('click', () => {
  if (state.selectedCornerIndex !== null) {
    state.selectedCornerIndex = null;
    updateControlPoints();
  }
});

window.addEventListener('resize', resizeCanvas);
resizeCanvas();

function animate() {
  render();
  requestAnimationFrame(animate);
}

animate();