<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover">
<title>ArUco Mobile Scanner</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
background: #101722;
color: white;
font-family: Arial, sans-serif;
}
header {
padding: 18px;
text-align: center;
background: #192535;
}
header h2 { margin: 0 0 6px; }
main {
max-width: 650px;
margin: auto;
padding: 14px;
}
.camera {
position: relative;
width: 100%;
background: black;
border-radius: 12px;
overflow: hidden;
aspect-ratio: 4 / 3;
}
video, #overlay {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
video { transform: scaleX(1); }
#overlay { pointer-events: none; }
.controls {
display: flex;
gap: 10px;
margin: 14px 0;
}
button {
flex: 1;
padding: 15px 8px;
border: 0;
border-radius: 9px;
background: #16865b;
color: white;
font-weight: bold;
font-size: 15px;
}
button.secondary { background: #34465d; }
button:disabled { opacity: .45; }
.panel {
background: #1b2939;
padding: 15px;
border-radius: 10px;
margin-top: 12px;
}
#status {
color: #facc15;
font-size: 14px;
}
.marker {
padding: 12px 0;
border-bottom: 1px solid #35465b;
font-size: 14px;
line-height: 1.7;
}
.marker:last-child { border: 0; }
.muted { color: #aab8ca; }
</style>
</head>
<body>
<header>
<h2>ARUCO SCANNER</h2>
<div class="muted">Original ArUco · 100 mm markers</div>
</header>
<main>
<div class="camera">
<video id="video" autoplay playsinline muted></video>
<canvas id="overlay"></canvas>
</div>
<div class="controls">
<button id="start">START SCANNING</button>
<button id="switch" class="secondary">SWITCH CAMERA</button>
<button id="stop" class="secondary">STOP</button>
</div>
<div class="panel">
<div id="status">Loading OpenCV...</div>
</div>
<div class="panel">
<strong>Detected markers</strong>
<div id="results" class="muted">
No markers detected yet.
</div>
</div>
<div class="panel muted">
Marker width: 100 mm<br>
Dictionary: DICT_ARUCO_ORIGINAL<br>
Processing: On device
</div>
</main>
<canvas id="processing" hidden></canvas>
<script src="https://docs.opencv.org/5.0.0/opencv.js"></script>
<script>
const video = document.getElementById("video");
const overlay = document.getElementById("overlay");
const processing = document.getElementById("processing");
const statusBox = document.getElementById("status");
const results = document.getElementById("results");
const ctx = overlay.getContext("2d");
const MARKER_SIZE_MM = 100;
let stream = null;
let detector = null;
let running = false;
let cameraFacing = "environment";
let lastProcess = 0;
function status(message) {
statusBox.textContent = message;
}
// Wait for OpenCV to finish loading.
const cvReady = new Promise((resolve, reject) => {
let attempts = 0;
const check = setInterval(() => {
attempts++;
if (window.cv && cv.Mat) {
clearInterval(check);
resolve(cv);
}
if (attempts > 300) {
clearInterval(check);
reject(new Error("OpenCV failed to load."));
}
}, 100);
});
async function initializeDetector() {
const cv = await cvReady;
// OpenCV 5 ArUco API
if (!cv.ArucoDetector ||
!cv.getPredefinedDictionary ||
!cv.DetectorParameters) {
throw new Error(
"This OpenCV build does not expose the required ArUco API."
);
}
const dictionary =
cv.getPredefinedDictionary(cv.DICT_ARUCO_ORIGINAL);
const parameters = new cv.DetectorParameters();
detector = new cv.ArucoDetector(dictionary, parameters);
status("OpenCV ready. Camera is ready.");
}
async function startCamera() {
try {
if (running) return;
if (!detector) {
await initializeDetector();
}
stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
facingMode: { ideal: cameraFacing },
width: { ideal: 1280 },
height: { ideal: 720 }
}
});
video.srcObject = stream;
await video.play();
overlay.width = video.videoWidth;
overlay.height = video.videoHeight;
processing.width = video.videoWidth;
processing.height = video.videoHeight;
running = true;
status("Camera active. Looking for ArUco markers...");
requestAnimationFrame(scan);
} catch (error) {
status("Camera error: " + error.message);
console.error(error);
}
}
function stopCamera() {
running = false;
if (stream) {
stream.getTracks().forEach(track => track.stop());
stream = null;
}
video.srcObject = null;
ctx.clearRect(0, 0, overlay.width, overlay.height);
results.textContent = "No markers detected.";
status("Scanner stopped.");
}
async function switchCamera() {
if (!running) {
cameraFacing =
cameraFacing === "environment" ? "user" : "environment";
status("Camera preference changed.");
return;
}
cameraFacing =
cameraFacing === "environment" ? "user" : "environment";
stopCamera();
await startCamera();
}
function scan(timestamp) {
if (!running) return;
requestAnimationFrame(scan);
// Limit processing to approximately 8 frames per second.
if (timestamp - lastProcess < 125) return;
lastProcess = timestamp;
try {
detectMarkers();
} catch (error) {
status("Detection error: " + error.message);
console.error(error);
stopCamera();
}
}
function detectMarkers() {
const cv = window.cv;
const pctx = processing.getContext("2d");
pctx.drawImage(
video, 0, 0, processing.width, processing.height
);
const src = cv.imread(processing);
const corners = new cv.MatVector();
const ids = new cv.Mat();
try {
detector.detectMarkers(src, corners, ids);
ctx.clearRect(0, 0, overlay.width, overlay.height);
const detected = [];
for (let i = 0; i < ids.rows; i++) {
const id = ids.intAt(i, 0);
const cornerMat = corners.get(i);
const points = cornerMat.data32F;
const pts = [];
for (let j = 0; j < 4; j++) {
pts.push({
x: points[j * 2],
y: points[j * 2 + 1]
});
}
drawMarker(pts, id);
const centerX = pts.reduce((s, p) => s + p.x, 0) / 4;
const centerY = pts.reduce((s, p) => s + p.y, 0) / 4;
const pose = estimatePose(pts);
detected.push({
id,
centerX,
centerY,
pose
});
cornerMat.delete();
}
displayResults(detected);
if (detected.length) {
status(detected.length + " marker(s) detected.");
} else {
status("Camera active. Searching for markers...");
}
} finally {
src.delete();
corners.delete();
ids.delete();
}
}
function drawMarker(points, id) {
ctx.strokeStyle = "#22e879";
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < 4; i++) {
ctx.lineTo(points[i].x, points[i].y);
}
ctx.closePath();
ctx.stroke();
ctx.fillStyle = "#22e879";
ctx.font = "bold 22px Arial";
ctx.fillText("ID: " + id, points[0].x, points[0].y - 10);
}
function estimatePose(points) {
const cv = window.cv;
const width = processing.width;
const height = processing.height;
// Approximate camera intrinsics.
// Replace these with calibrated camera values later.
const focal = width * 0.90;
const cameraMatrix = cv.matFromArray(
3, 3, cv.CV_64F,
[
focal, 0, width / 2,
0, focal, height / 2,
0, 0, 1
]
);
const distCoeffs = cv.Mat.zeros(1, 5, cv.CV_64F);
const half = MARKER_SIZE_MM / 2;
// Object points ordered to match the detected corners.
const objectPoints = cv.matFromArray(
4, 1, cv.CV_32FC3,
[
-half, half, 0,
half, half, 0,
half,-half, 0,
-half,-half, 0
]
);
const imagePoints = cv.matFromArray(
4, 1, cv.CV_32FC2,
points.flatMap(p => [p.x, p.y])
);
const rvec = new cv.Mat();
const tvec = new cv.Mat();
try {
const success = cv.solvePnP(
objectPoints,
imagePoints,
cameraMatrix,
distCoeffs,
rvec,
tvec,
false,
cv.SOLVEPNP_IPPE_SQUARE
);
if (!success) return null;
const rotation = new cv.Mat();
try {
cv.Rodrigues(rvec, rotation);
const r = rotation.data64F;
// Euler angle extraction (XYZ convention).
const pitch = Math.asin(
Math.max(-1, Math.min(1, -r[6]))
);
const roll = Math.atan2(r[7], r[8]);
const yaw = Math.atan2(r[3], r[0]);
return {
x: tvec.data64F[0],
y: tvec.data64F[1],
z: tvec.data64F[2],
roll: roll * 180 / Math.PI,
pitch: pitch * 180 / Math.PI,
yaw: yaw * 180 / Math.PI
};
} finally {
rotation.delete();
}
} finally {
cameraMatrix.delete();
distCoeffs.delete();
objectPoints.delete();
imagePoints.delete();
rvec.delete();
tvec.delete();
}
}
function displayResults(markers) {
if (!markers.length) {
results.textContent = "No markers detected yet.";
return;
}
results.innerHTML = "";
markers.forEach(marker => {
const div = document.createElement("div");
div.className = "marker";
const p = marker.pose;
let details =
"<strong>Marker ID: " + marker.id + "</strong><br>" +
"Screen X: " + marker.centerX.toFixed(1) + " px<br>" +
"Screen Y: " + marker.centerY.toFixed(1) + " px<br>";
if (p) {
details +=
"X: " + p.x.toFixed(1) + " mm<br>" +
"Y: " + p.y.toFixed(1) + " mm<br>" +
"Z: " + p.z.toFixed(1) + " mm<br>" +
"Roll: " + p.roll.toFixed(1) + "°<br>" +
"Pitch: " + p.pitch.toFixed(1) + "°<br>" +
"Yaw: " + p.yaw.toFixed(1) + "°";
} else {
details += "Pose unavailable.";
}
div.innerHTML = details;
results.appendChild(div);
});
}
document.getElementById("start").onclick = startCamera;
document.getElementById("stop").onclick = stopCamera;
document.getElementById("switch").onclick = switchCamera;
cvReady.then(() => {
status("OpenCV loaded. Ready to scan.");
}).catch(error => {
status(error.message);
});
</script>
</body>
</html>