
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();