<!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>