homography-transform
Version:
A robust TypeScript implementation of homography-based transformation between 2D planes, ideal for computer vision and image mapping
579 lines (519 loc) • 24.4 kB
HTML
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Homography Transform Test</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/numeric/1.2.6/numeric.min.js"></script>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
background-color: #f0f0f0;
}
.container {
display: flex;
gap: 20px;
margin-bottom: 20px;
}
.canvas-wrapper {
background: white;
padding: 10px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
position: relative;
}
.transformed-coords {
margin-top: 10px;
padding: 10px;
background: white;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
color: #28a745;
}
canvas {
border: 1px solid #ccc;
margin: 5px;
max-width: 100%;
height: auto;
}
#sourceCanvas {
width: 800px; /* Fixed display width */
}
.controls {
margin: 20px 0;
padding: 10px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.resolution-controls {
margin: 10px 0;
display: flex;
align-items: center;
gap: 10px;
}
.resolution-info {
font-size: 12px;
color: #666;
margin-left: 10px;
}
.resolution-controls input {
width: 80px;
padding: 4px;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
padding: 8px 16px;
margin: 0 8px;
border: none;
border-radius: 4px;
background-color: #007bff;
color: white;
cursor: pointer;
}
button:hover {
background-color: #0056b3;
}
.status {
margin-top: 10px;
font-style: italic;
color: #666;
}
.coordinates {
position: absolute;
bottom: -25px;
left: 10px;
font-size: 12px;
color: #666;
}
.point-label {
position: absolute;
background: rgba(255, 255, 255, 0.8);
padding: 2px 4px;
border-radius: 3px;
font-size: 10px;
color: #007bff;
pointer-events: none;
}
.moveable {
cursor: move;
}
</style>
</head>
<body>
<h1>Homography Transform Test</h1>
<div class="controls">
<div class="resolution-controls">
<label>Mobile Resolution:</label>
<input type="number" id="mobileWidth" value="2340" placeholder="Width">
<span>×</span>
<input type="number" id="mobileHeight" value="1080" placeholder="Height">
<button id="applyResolution">Apply</button>
<span class="resolution-info">(Display scaled to 800px width)</span>
</div>
<button id="resetSourceBtn">Reset Source Points</button>
<button id="resetTargetBtn">Reset Target Points</button>
<button id="transformBtn" disabled>Transform</button>
<button id="clearBtn">Clear All</button>
<div class="status" id="status">Select at least 4 points on each canvas</div>
</div>
<div class="container">
<div class="canvas-wrapper">
<h3>Mobile Screen (Samsung Galaxy S22 Ultra - Landscape)</h3>
<canvas id="sourceCanvas" width="2340" height="1080"></canvas>
<div id="sourceCoords" class="coordinates"></div>
</div>
<div class="canvas-wrapper">
<h3>Webcam View (640×480)</h3>
<canvas id="targetCanvas" width="640" height="480"></canvas>
<div id="targetCoords" class="coordinates"></div>
<div id="transformedCoords" class="transformed-coords">Transformed coordinates will appear here</div>
</div>
</div>
<script type="module">
import { Plane, PlaneTransformer } from './bundle.js';
const sourceCanvas = document.getElementById('sourceCanvas');
const targetCanvas = document.getElementById('targetCanvas');
const resetSourceBtn = document.getElementById('resetSourceBtn');
const resetTargetBtn = document.getElementById('resetTargetBtn');
const transformBtn = document.getElementById('transformBtn');
const clearBtn = document.getElementById('clearBtn');
const statusDiv = document.getElementById('status');
const sourceCoordsDiv = document.getElementById('sourceCoords');
const targetCoordsDiv = document.getElementById('targetCoords');
const mobileWidthInput = document.getElementById('mobileWidth');
const mobileHeightInput = document.getElementById('mobileHeight');
const applyResolutionBtn = document.getElementById('applyResolution');
const sourcePoints = [];
const targetPoints = [];
let currentPhase = 'source'; // 'source' or 'target'
let displayScale = 1;
let isDragging = false;
let dragPoint = null;
let dragPointIndex = -1;
let dragCanvas = null;
const DRAG_THRESHOLD = 10; // pixels
let transformedOutline = null; // Store the transformed outline
function updateScale() {
const displayWidth = 800; // Fixed display width
displayScale = sourceCanvas.width / displayWidth;
sourceCanvas.style.width = displayWidth + 'px';
sourceCanvas.style.height = (sourceCanvas.height / displayScale) + 'px';
}
function getScaledCoordinates(e, canvas) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
return {
x: Math.round((e.clientX - rect.left) * scaleX),
y: Math.round((e.clientY - rect.top) * scaleY)
};
}
function drawPoint(ctx, x, y, index, color = '#007bff') {
const scale = ctx.canvas === sourceCanvas ? 1/displayScale : 1;
const radius = 5/scale;
// Draw point
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
// Draw point number
ctx.fillStyle = 'white';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = `${10/scale}px Arial`;
ctx.fillText((index + 1).toString(), x, y);
// Draw coordinates
ctx.fillStyle = color;
ctx.font = `${12/scale}px Arial`;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(`(${Math.round(x)},${Math.round(y)})`, x + 10/scale, y + 10/scale);
}
function updateCoordinates(e, canvas, coordsDiv) {
const coords = getScaledCoordinates(e, canvas);
coordsDiv.textContent = `x: ${coords.x}, y: ${coords.y}`;
}
function drawTransformedOutline() {
if (!transformedOutline) return;
const targetCtx = targetCanvas.getContext('2d');
targetCtx.beginPath();
transformedOutline.forEach((point, i) => {
if (i === 0) {
targetCtx.moveTo(point.x, point.y);
} else {
targetCtx.lineTo(point.x, point.y);
}
// Draw coordinates for each transformed point
targetCtx.fillStyle = '#28a745';
targetCtx.font = '12px Arial';
targetCtx.textAlign = 'left';
targetCtx.textBaseline = 'top';
targetCtx.fillText(`(${Math.round(point.x)},${Math.round(point.y)})`, point.x + 5, point.y + 5);
});
targetCtx.closePath();
targetCtx.strokeStyle = '#28a745';
targetCtx.lineWidth = 2;
targetCtx.stroke();
}
function redrawCanvas() {
const sourceCtx = sourceCanvas.getContext('2d');
const targetCtx = targetCanvas.getContext('2d');
// Clear canvases
sourceCtx.clearRect(0, 0, sourceCanvas.width, sourceCanvas.height);
targetCtx.clearRect(0, 0, targetCanvas.width, targetCanvas.height);
// Draw grid lines
function drawGrid(ctx, width, height) {
const scale = ctx.canvas === sourceCanvas ? 1/displayScale : 1;
ctx.strokeStyle = '#eee';
ctx.lineWidth = 1;
// Draw vertical lines
for (let x = 50; x < width; x += 50) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
ctx.fillStyle = '#999';
ctx.font = `${10/scale}px Arial`;
ctx.fillText(x.toString(), x, height - 5/scale);
}
// Draw horizontal lines
for (let y = 50; y < height; y += 50) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
ctx.fillStyle = '#999';
ctx.font = `${10/scale}px Arial`;
ctx.fillText(y.toString(), 5/scale, y);
}
}
drawGrid(sourceCtx, sourceCanvas.width, sourceCanvas.height);
drawGrid(targetCtx, targetCanvas.width, targetCanvas.height);
// Draw points
sourcePoints.forEach((p, i) => drawPoint(sourceCtx, p.x, p.y, i));
targetPoints.forEach((p, i) => drawPoint(targetCtx, p.x, p.y, i));
// Draw lines between points
function drawLines(ctx, points) {
if (points.length > 1) {
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
points.forEach((p, i) => {
if (i > 0) ctx.lineTo(p.x, p.y);
});
ctx.strokeStyle = '#007bff44';
ctx.lineWidth = 1;
ctx.stroke();
}
}
drawLines(sourceCtx, sourcePoints);
drawLines(targetCtx, targetPoints);
// Draw transformed outline if it exists
drawTransformedOutline();
}
function updateStatus() {
const minPoints = 4;
const sourceCount = sourcePoints.length;
const targetCount = targetPoints.length;
if (sourceCount < minPoints) {
statusDiv.textContent = `Select ${minPoints - sourceCount} more points on source canvas`;
currentPhase = 'source';
} else if (targetCount < minPoints) {
statusDiv.textContent = `Select ${minPoints - targetCount} more points on target canvas`;
currentPhase = 'target';
} else if (sourceCount === targetCount) {
statusDiv.textContent = 'Ready to transform! Click Transform button or move points to update';
transformBtn.disabled = false;
// Auto-transform when we have enough points
transformPlanes();
}
redrawCanvas();
}
function isNearPoint(x, y, point, canvas) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
// Convert point coordinates to display coordinates
const displayX = point.x / scaleX;
const displayY = point.y / scaleY;
// Convert mouse coordinates to display coordinates
const mouseDisplayX = x / scaleX;
const mouseDisplayY = y / scaleY;
// Calculate distance in display coordinates
const dx = mouseDisplayX - displayX;
const dy = mouseDisplayY - displayY;
const distance = Math.sqrt(dx * dx + dy * dy);
console.log('Canvas:', canvas.id);
console.log('Scale:', { x: scaleX, y: scaleY });
console.log('Mouse display:', {x: mouseDisplayX, y: mouseDisplayY});
console.log('Point display:', {x: displayX, y: displayY});
console.log('Distance (display pixels):', distance);
return distance < DRAG_THRESHOLD;
}
function findNearestPoint(x, y, canvas) {
const points = canvas === sourceCanvas ? sourcePoints : targetPoints;
for (let i = 0; i < points.length; i++) {
if (isNearPoint(x, y, points[i], canvas)) {
console.log('Found nearest point:', i);
return { point: points[i], index: i };
}
}
return null;
}
function handleMouseMove(e, canvas) {
const coords = getScaledCoordinates(e, canvas);
const coordsDiv = canvas === sourceCanvas ? sourceCoordsDiv : targetCoordsDiv;
if (isDragging && dragCanvas === canvas) {
console.log('Dragging point:', dragPointIndex, 'New position:', coords);
console.log('Canvas:', canvas.id);
// Update point position while dragging
dragPoint.x = Math.round(coords.x);
dragPoint.y = Math.round(coords.y);
// If we have enough points, transform while dragging
if (sourcePoints.length >= 4 && sourcePoints.length === targetPoints.length) {
transformPlanes();
} else {
redrawCanvas();
}
} else {
// Check if mouse is near any point
const nearest = findNearestPoint(coords.x, coords.y, canvas);
canvas.style.cursor = nearest ? 'move' : 'default';
// Highlight the point if mouse is near
redrawCanvas();
if (nearest) {
console.log('Mouse near point:', nearest.index, 'at', nearest.point, 'on', canvas.id);
const ctx = canvas.getContext('2d');
drawPoint(ctx, nearest.point.x, nearest.point.y, nearest.index, '#ff4444');
}
}
updateCoordinates(e, canvas, coordsDiv);
}
function handleMouseDown(e, canvas) {
const coords = getScaledCoordinates(e, canvas);
console.log('Mouse down at:', coords, 'on', canvas.id);
const nearest = findNearestPoint(coords.x, coords.y, canvas);
if (nearest) {
console.log('Starting drag on point:', nearest.index, 'on', canvas.id);
isDragging = true;
dragPoint = nearest.point;
dragPointIndex = nearest.index;
dragCanvas = canvas;
canvas.style.cursor = 'move';
}
}
function handleMouseUp(e) {
if (isDragging) {
console.log('Ending drag of point:', dragPointIndex, 'on', dragCanvas.id);
isDragging = false;
dragPoint = null;
dragPointIndex = -1;
dragCanvas = null;
sourceCanvas.style.cursor = 'default';
targetCanvas.style.cursor = 'default';
// Check if we have enough points to transform
if (sourcePoints.length >= 4 && sourcePoints.length === targetPoints.length) {
console.log('Auto-transforming after point move');
transformPlanes();
}
}
}
function transformPlanes() {
try {
console.log('Starting transformation with points:',
'Source:', JSON.stringify(sourcePoints),
'Target:', JSON.stringify(targetPoints)
);
const sourcePlane = new Plane(sourceCanvas.width, sourceCanvas.height);
const targetPlane = new Plane(targetCanvas.width, targetCanvas.height);
const pointPairs = sourcePoints.map((source, i) => ({
source,
target: targetPoints[i]
}));
const transformer = new PlaneTransformer(sourcePlane, targetPlane, pointPairs);
// Store transformed outline points
const corners = [
{ x: 0, y: 0 },
{ x: sourceCanvas.width, y: 0 },
{ x: sourceCanvas.width, y: sourceCanvas.height },
{ x: 0, y: sourceCanvas.height }
];
transformedOutline = corners.map(corner => transformer.transform(corner));
// Update transformed coordinates display
const transformedCoordsDiv = document.getElementById('transformedCoords');
const coordsText = transformedOutline.map((point, i) => {
const labels = ['Top-Left', 'Top-Right', 'Bottom-Right', 'Bottom-Left'];
return `${labels[i]}: (${Math.round(point.x)}, ${Math.round(point.y)})`;
}).join(' | ');
transformedCoordsDiv.textContent = coordsText;
// Draw the outline
redrawCanvas();
const error = transformer.getTransformationError();
console.log('Transformation complete, error:', error);
statusDiv.textContent = `Transformation complete! Average error: ${error.toFixed(2)} pixels`;
} catch (error) {
console.error('Transformation error:', error);
statusDiv.textContent = `Error: ${error.message}`;
transformedOutline = null;
document.getElementById('transformedCoords').textContent = 'Transformation failed';
}
}
function initializeSourcePoints() {
console.log('Initializing source points...');
console.log('Before clear:', sourcePoints.length);
sourcePoints.splice(0, sourcePoints.length); // Force clear array
console.log('After clear:', sourcePoints.length);
const margin = 100;
sourcePoints.push(
{ x: margin, y: margin }, // Top-left
{ x: sourceCanvas.width - margin, y: margin }, // Top-right
{ x: sourceCanvas.width - margin, y: sourceCanvas.height - margin }, // Bottom-right
{ x: margin, y: sourceCanvas.height - margin } // Bottom-left
);
console.log('After adding new points:', sourcePoints.length);
console.log('Source points:', JSON.stringify(sourcePoints));
updateStatus();
}
function initializeTargetPoints() {
console.log('Initializing target points...');
console.log('Before clear:', targetPoints.length);
targetPoints.splice(0, targetPoints.length); // Force clear array
console.log('After clear:', targetPoints.length);
const margin = 30;
targetPoints.push(
{ x: margin, y: margin }, // Top-left
{ x: targetCanvas.width - margin, y: margin }, // Top-right
{ x: targetCanvas.width - margin, y: targetCanvas.height - margin }, // Bottom-right
{ x: margin, y: targetCanvas.height - margin } // Bottom-left
);
console.log('After adding new points:', targetPoints.length);
console.log('Target points:', JSON.stringify(targetPoints));
updateStatus();
}
function applyResolution() {
const width = parseInt(mobileWidthInput.value) || 2340;
const height = parseInt(mobileHeightInput.value) || 1080;
sourceCanvas.width = width;
sourceCanvas.height = height;
updateScale();
sourcePoints.length = 0;
initializeSourcePoints();
updateStatus();
}
// Update event listeners
sourceCanvas.addEventListener('mousemove', e => handleMouseMove(e, sourceCanvas));
targetCanvas.addEventListener('mousemove', e => handleMouseMove(e, targetCanvas));
sourceCanvas.addEventListener('mousedown', e => handleMouseDown(e, sourceCanvas));
targetCanvas.addEventListener('mousedown', e => handleMouseDown(e, targetCanvas));
document.addEventListener('mouseup', handleMouseUp);
sourceCanvas.addEventListener('mouseleave', () => {
sourceCoordsDiv.textContent = '';
if (!isDragging) sourceCanvas.style.cursor = 'default';
});
targetCanvas.addEventListener('mouseleave', () => {
targetCoordsDiv.textContent = '';
if (!isDragging) targetCanvas.style.cursor = 'default';
});
// Update reset buttons
document.getElementById('resetSourceBtn').addEventListener('click', () => {
console.log('Reset source button clicked');
sourcePoints.splice(0, sourcePoints.length); // Force clear array
console.log('Source points cleared, length:', sourcePoints.length);
redrawCanvas();
console.log('Canvas redrawn');
initializeSourcePoints();
});
document.getElementById('resetTargetBtn').addEventListener('click', () => {
console.log('Reset target button clicked');
targetPoints.splice(0, targetPoints.length); // Force clear array
console.log('Target points cleared, length:', targetPoints.length);
redrawCanvas();
console.log('Canvas redrawn');
initializeTargetPoints();
});
clearBtn.addEventListener('click', () => {
console.log('Clear all button clicked');
sourcePoints.splice(0, sourcePoints.length); // Force clear array
targetPoints.splice(0, targetPoints.length); // Force clear array
transformedOutline = null; // Clear the transformed outline
console.log('Points cleared - Source:', sourcePoints.length, 'Target:', targetPoints.length);
currentPhase = 'source';
transformBtn.disabled = true;
redrawCanvas();
console.log('Canvas redrawn');
updateStatus();
});
transformBtn.addEventListener('click', transformPlanes);
// Add resolution change handler
applyResolutionBtn.addEventListener('click', applyResolution);
// Initialize scaling and points
updateScale();
initializeSourcePoints();
initializeTargetPoints();
updateStatus();
</script>
</body>
</html>