| | <html> |
| | <head> |
| | <meta content="text/html;charset=utf-8" http-equiv="Content-Type" /> |
| | <title>Candle YOLOv8 Rust/WASM</title> |
| | </head> |
| | <body></body> |
| | </html> |
| |
|
| | <!doctype html> |
| | <html> |
| | <head> |
| | <meta charset="UTF-8" /> |
| | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| | <style> |
| | @import url("https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@200;300;400&family=Source+Sans+3:wght@100;200;300;400;500;600;700;800;900&display=swap"); |
| | html, |
| | body { |
| | font-family: "Source Sans 3", sans-serif; |
| | } |
| | code, |
| | output, |
| | select, |
| | pre { |
| | font-family: "Source Code Pro", monospace; |
| | } |
| | </style> |
| | <script src="https://cdn.tailwindcss.com"></script> |
| | <script |
| | src="https://cdn.jsdelivr.net/gh/huggingface/hub-js-utils/share-canvas.js" |
| | type="module" |
| | ></script> |
| | <script type="module"> |
| | const MODEL_BASEURL = |
| | "https://huggingface.co/lmz/candle-yolo-v8/resolve/main/"; |
| | |
| | const MODELS = { |
| | yolov8n: { |
| | model_size: "n", |
| | url: "yolov8n.safetensors", |
| | }, |
| | yolov8s: { |
| | model_size: "s", |
| | url: "yolov8s.safetensors", |
| | }, |
| | yolov8m: { |
| | model_size: "m", |
| | url: "yolov8m.safetensors", |
| | }, |
| | yolov8l: { |
| | model_size: "l", |
| | url: "yolov8l.safetensors", |
| | }, |
| | yolov8x: { |
| | model_size: "x", |
| | url: "yolov8x.safetensors", |
| | }, |
| | yolov8n_pose: { |
| | model_size: "n", |
| | url: "yolov8n-pose.safetensors", |
| | }, |
| | yolov8s_pose: { |
| | model_size: "s", |
| | url: "yolov8s-pose.safetensors", |
| | }, |
| | yolov8m_pose: { |
| | model_size: "m", |
| | url: "yolov8m-pose.safetensors", |
| | }, |
| | yolov8l_pose: { |
| | model_size: "l", |
| | url: "yolov8l-pose.safetensors", |
| | }, |
| | yolov8x_pose: { |
| | model_size: "x", |
| | url: "yolov8x-pose.safetensors", |
| | }, |
| | }; |
| | |
| | const COCO_PERSON_SKELETON = [ |
| | [4, 0], |
| | [3, 0], |
| | [16, 14], |
| | [14, 12], |
| | [6, 12], |
| | [6, 5], |
| | [6, 8], |
| | [8, 10], |
| | [1, 2], |
| | [1, 3], |
| | [2, 4], |
| | [3, 5], |
| | [4, 6], |
| | [5, 7], |
| | [7, 9], |
| | [5, 11], |
| | [11, 12], |
| | [11, 13], |
| | [13, 15], |
| | ]; |
| | |
| | |
| | const yoloWorker = new Worker("./yoloWorker.js", { type: "module" }); |
| | |
| | let hasImage = false; |
| | |
| | document.querySelector("#image-select").addEventListener("click", (e) => { |
| | const target = e.target; |
| | if (target.nodeName === "IMG") { |
| | const href = target.src; |
| | drawImageCanvas(href); |
| | } |
| | }); |
| | |
| | document.querySelector("#file-upload").addEventListener("change", (e) => { |
| | const target = e.target; |
| | if (target.files.length > 0) { |
| | const href = URL.createObjectURL(target.files[0]); |
| | drawImageCanvas(href); |
| | } |
| | }); |
| | |
| | const dropArea = document.querySelector("#drop-area"); |
| | dropArea.addEventListener("dragenter", (e) => { |
| | e.preventDefault(); |
| | dropArea.classList.add("border-blue-700"); |
| | }); |
| | dropArea.addEventListener("dragleave", (e) => { |
| | e.preventDefault(); |
| | dropArea.classList.remove("border-blue-700"); |
| | }); |
| | dropArea.addEventListener("dragover", (e) => { |
| | e.preventDefault(); |
| | }); |
| | dropArea.addEventListener("drop", (e) => { |
| | e.preventDefault(); |
| | dropArea.classList.remove("border-blue-700"); |
| | const url = e.dataTransfer.getData("text/uri-list"); |
| | const files = e.dataTransfer.files; |
| | |
| | if (files.length > 0) { |
| | const href = URL.createObjectURL(files[0]); |
| | drawImageCanvas(href); |
| | } else if (url) { |
| | drawImageCanvas(url); |
| | } |
| | }); |
| | |
| | document.querySelector("#clear-btn").addEventListener("click", () => { |
| | drawImageCanvas(); |
| | }); |
| | |
| | function drawImageCanvas(imgURL) { |
| | const canvas = document.querySelector("#canvas"); |
| | const canvasResult = document.querySelector("#canvas-result"); |
| | canvasResult |
| | .getContext("2d") |
| | .clearRect(0, 0, canvas.width, canvas.height); |
| | const ctx = canvas.getContext("2d"); |
| | ctx.clearRect(0, 0, canvas.width, canvas.height); |
| | document.querySelector("#share-btn").classList.add("invisible"); |
| | document.querySelector("#clear-btn").classList.add("invisible"); |
| | document.querySelector("#detect").disabled = true; |
| | hasImage = false; |
| | canvas.parentElement.style.height = "auto"; |
| | |
| | if (imgURL && imgURL !== "") { |
| | const img = new Image(); |
| | img.crossOrigin = "anonymous"; |
| | |
| | img.onload = () => { |
| | canvas.width = img.width; |
| | canvas.height = img.height; |
| | ctx.drawImage(img, 0, 0); |
| | |
| | canvas.parentElement.style.height = canvas.offsetHeight + "px"; |
| | hasImage = true; |
| | document.querySelector("#detect").disabled = false; |
| | document.querySelector("#clear-btn").classList.remove("invisible"); |
| | }; |
| | img.src = imgURL; |
| | } |
| | } |
| | |
| | async function classifyImage( |
| | imageURL, |
| | modelID, |
| | modelURL, |
| | modelSize, |
| | confidence, |
| | iou_threshold, |
| | updateStatus |
| | ) { |
| | return new Promise((resolve, reject) => { |
| | yoloWorker.postMessage({ |
| | imageURL, |
| | modelID, |
| | modelURL, |
| | modelSize, |
| | confidence, |
| | iou_threshold, |
| | }); |
| | yoloWorker.addEventListener("message", (event) => { |
| | if ("status" in event.data) { |
| | updateStatus(event.data.status); |
| | } |
| | if ("error" in event.data) { |
| | reject(new Error(event.data.error)); |
| | } |
| | if (event.data.status === "complete") { |
| | resolve(event.data); |
| | } |
| | }); |
| | }); |
| | } |
| | |
| | document.querySelector("#detect").addEventListener("click", async () => { |
| | if (!hasImage) { |
| | return; |
| | } |
| | const modelID = document.querySelector("#model").value; |
| | const modelURL = MODEL_BASEURL + MODELS[modelID].url; |
| | const modelSize = MODELS[modelID].model_size; |
| | const confidence = parseFloat( |
| | document.querySelector("#confidence").value |
| | ); |
| | const iou_threshold = parseFloat( |
| | document.querySelector("#iou_threshold").value |
| | ); |
| | |
| | const canvasInput = document.querySelector("#canvas"); |
| | const canvas = document.querySelector("#canvas-result"); |
| | canvas.width = canvasInput.width; |
| | canvas.height = canvasInput.height; |
| | |
| | const scale = canvas.width / canvas.offsetWidth; |
| | |
| | const ctx = canvas.getContext("2d"); |
| | ctx.drawImage(canvasInput, 0, 0); |
| | const imageURL = canvas.toDataURL(); |
| | |
| | const results = await await classifyImage( |
| | imageURL, |
| | modelID, |
| | modelURL, |
| | modelSize, |
| | confidence, |
| | iou_threshold, |
| | updateStatus |
| | ); |
| | |
| | const { output } = results; |
| | |
| | ctx.lineWidth = 1 + 2 * scale; |
| | ctx.strokeStyle = "#3c8566"; |
| | ctx.fillStyle = "#0dff9a"; |
| | const fontSize = 14 * scale; |
| | ctx.font = `${fontSize}px sans-serif`; |
| | for (const detection of output) { |
| | |
| | let xmin, xmax, ymin, ymax, label, confidence, keypoints; |
| | if ("keypoints" in detection) { |
| | xmin = detection.xmin; |
| | xmax = detection.xmax; |
| | ymin = detection.ymin; |
| | ymax = detection.ymax; |
| | confidence = detection.confidence; |
| | keypoints = detection.keypoints; |
| | } else { |
| | const [_label, bbox] = detection; |
| | label = _label; |
| | xmin = bbox.xmin; |
| | xmax = bbox.xmax; |
| | ymin = bbox.ymin; |
| | ymax = bbox.ymax; |
| | confidence = bbox.confidence; |
| | } |
| | const [x, y, w, h] = [xmin, ymin, xmax - xmin, ymax - ymin]; |
| | |
| | const text = `${label ? label + " " : ""}${confidence.toFixed(2)}`; |
| | const width = ctx.measureText(text).width; |
| | ctx.fillStyle = "#3c8566"; |
| | ctx.fillRect(x - 2, y - fontSize, width + 4, fontSize); |
| | ctx.fillStyle = "#e3fff3"; |
| | |
| | ctx.strokeRect(x, y, w, h); |
| | ctx.fillText(text, x, y - 2); |
| | if (keypoints) { |
| | ctx.save(); |
| | ctx.fillStyle = "magenta"; |
| | ctx.strokeStyle = "yellow"; |
| | |
| | for (const keypoint of keypoints) { |
| | const { x, y } = keypoint; |
| | ctx.beginPath(); |
| | ctx.arc(x, y, 3, 0, 2 * Math.PI); |
| | ctx.fill(); |
| | } |
| | ctx.beginPath(); |
| | for (const [xid, yid] of COCO_PERSON_SKELETON) { |
| | |
| | if (keypoints[xid] && keypoints[yid]) { |
| | ctx.moveTo(keypoints[xid].x, keypoints[xid].y); |
| | ctx.lineTo(keypoints[yid].x, keypoints[yid].y); |
| | } |
| | } |
| | ctx.stroke(); |
| | ctx.restore(); |
| | } |
| | } |
| | }); |
| | |
| | function updateStatus(statusMessage) { |
| | const button = document.querySelector("#detect"); |
| | if (statusMessage === "detecting") { |
| | button.disabled = true; |
| | button.classList.add("bg-blue-700"); |
| | button.classList.remove("bg-blue-950"); |
| | button.textContent = "Predicting..."; |
| | } else if (statusMessage === "complete") { |
| | button.disabled = false; |
| | button.classList.add("bg-blue-950"); |
| | button.classList.remove("bg-blue-700"); |
| | button.textContent = "Predict"; |
| | document.querySelector("#share-btn").classList.remove("invisible"); |
| | } |
| | } |
| | document.querySelector("#share-btn").addEventListener("click", () => { |
| | shareToCommunity( |
| | "lmz/candle-yolo", |
| | "Candle + YOLOv8", |
| | "YOLOv8 with [Candle](https://github.com/huggingface/candle)", |
| | "canvas-result", |
| | "share-btn" |
| | ); |
| | }); |
| | </script> |
| | </head> |
| | <body class="container max-w-4xl mx-auto p-4"> |
| | <main class="grid grid-cols-1 gap-8 relative"> |
| | <span class="absolute text-5xl -ml-[1em]"> 🕯️ </span> |
| | <div> |
| | <h1 class="text-5xl font-bold">Candle YOLOv8</h1> |
| | <h2 class="text-2xl font-bold">Rust/WASM Demo</h2> |
| | <p class="max-w-lg"> |
| | This demo showcases object detection and pose estimation models in |
| | your browser using Rust/WASM. It utilizes |
| | <a |
| | href="https://huggingface.co/lmz/candle-yolo-v8" |
| | target="_blank" |
| | class="underline hover:text-blue-500 hover:no-underline" |
| | > |
| | safetensor's YOLOv8 models |
| | </a> |
| | and a WASM runtime built with |
| | <a |
| | href="https://github.com/huggingface/candle/" |
| | target="_blank" |
| | class="underline hover:text-blue-500 hover:no-underline" |
| | >Candle </a |
| | >. |
| | </p> |
| | <p> |
| | To run pose estimation, select a yolo pose model from the dropdown |
| | </p> |
| | </div> |
| |
|
| | <div> |
| | <label for="model" class="font-medium">Models Options: </label> |
| | <select |
| | id="model" |
| | class="border-2 border-gray-500 rounded-md font-light" |
| | > |
| | <option value="yolov8n" selected>yolov8n (6.37 MB)</option> |
| | <option value="yolov8s">yolov8s (22.4 MB)</option> |
| | <option value="yolov8m">yolov8m (51.9 MB)</option> |
| | <option value="yolov8l">yolov8l (87.5 MB)</option> |
| | <option value="yolov8x">yolov8x (137 MB)</option> |
| | |
| | <option value="yolov8n_pose">yolov8n_pose (6.65 MB)</option> |
| | <option value="yolov8s_pose">yolov8s_pose (23.3 MB)</option> |
| | <option value="yolov8m_pose">yolov8m_pose (53 MB)</option> |
| | <option value="yolov8l_pose">yolov8l_pose (89.1 MB)</option> |
| | <option value="yolov8x_pose">yolov8x_pose (139 MB)</option> |
| | </select> |
| | </div> |
| | <div> |
| | <button |
| | id="detect" |
| | disabled |
| | class="bg-gray-700 hover:bg-gray-800 text-white font-normal py-2 px-4 rounded disabled:bg-gray-300 disabled:cursor-not-allowed" |
| | > |
| | Predict |
| | </button> |
| | </div> |
| | |
| | <div class="relative"> |
| | <div class="py-1"> |
| | <button |
| | id="clear-btn" |
| | class="text-xs bg-white rounded-md disabled:opacity-50 flex gap-1 items-center ml-auto invisible" |
| | > |
| | <svg |
| | class="" |
| | xmlns="http://www.w3.org/2000/svg" |
| | viewBox="0 0 13 12" |
| | height="1em" |
| | > |
| | <path |
| | d="M1.6.7 12 11.1M12 .7 1.6 11.1" |
| | stroke="#2E3036" |
| | stroke-width="2" |
| | /> |
| | </svg> |
| | Clear image |
| | </button> |
| | </div> |
| | <div |
| | id="drop-area" |
| | class="flex flex-col items-center justify-center border-2 border-gray-300 border-dashed rounded-xl relative aspect-video w-full overflow-hidden" |
| | > |
| | <div |
| | class="flex flex-col items-center justify-center space-y-1 text-center" |
| | > |
| | <svg |
| | width="25" |
| | height="25" |
| | viewBox="0 0 25 25" |
| | fill="none" |
| | xmlns="http://www.w3.org/2000/svg" |
| | > |
| | <path |
| | d="M3.5 24.3a3 3 0 0 1-1.9-.8c-.5-.5-.8-1.2-.8-1.9V2.9c0-.7.3-1.3.8-1.9.6-.5 1.2-.7 2-.7h18.6c.7 0 1.3.2 1.9.7.5.6.7 1.2.7 2v18.6c0 .7-.2 1.4-.7 1.9a3 3 0 0 1-2 .8H3.6Zm0-2.7h18.7V2.9H3.5v18.7Zm2.7-2.7h13.3c.3 0 .5 0 .6-.3v-.7l-3.7-5a.6.6 0 0 0-.6-.2c-.2 0-.4 0-.5.3l-3.5 4.6-2.4-3.3a.6.6 0 0 0-.6-.3c-.2 0-.4.1-.5.3l-2.7 3.6c-.1.2-.2.4 0 .7.1.2.3.3.6.3Z" |
| | fill="#000" |
| | /> |
| | </svg> |
| | <div class="flex text-sm text-gray-600"> |
| | <label |
| | for="file-upload" |
| | class="relative cursor-pointer bg-white rounded-md font-medium text-blue-950 hover:text-blue-700" |
| | > |
| | <span>Drag and drop your image here</span> |
| | <span class="block text-xs">or</span> |
| | <span class="block text-xs">Click to upload</span> |
| | </label> |
| | </div> |
| | <input |
| | id="file-upload" |
| | name="file-upload" |
| | type="file" |
| | class="sr-only" |
| | /> |
| | </div> |
| | <canvas |
| | id="canvas" |
| | class="absolute pointer-events-none w-full" |
| | ></canvas> |
| | <canvas |
| | id="canvas-result" |
| | class="absolute pointer-events-none w-full" |
| | ></canvas> |
| | </div> |
| | <div class="text-right py-2"> |
| | <button |
| | id="share-btn" |
| | class="bg-white rounded-md hover:outline outline-orange-200 disabled:opacity-50 invisible" |
| | > |
| | <img |
| | src="https://huggingface.co/datasets/huggingface/badges/raw/main/share-to-community-sm.svg" |
| | /> |
| | </button> |
| | </div> |
| | </div> |
| | <div> |
| | <div class="flex gap-3 items-center" id="image-select"> |
| | <h3 class="font-medium">Examples:</h3> |
| |
|
| | <img |
| | src="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/candle/examples/sf.jpg" |
| | class="cursor-pointer w-24 h-24 object-cover" |
| | /> |
| | <img |
| | src="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/candle/examples/bike.jpeg" |
| | class="cursor-pointer w-24 h-24 object-cover" |
| | /> |
| | <img |
| | src="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/candle/examples/000000000077.jpg" |
| | class="cursor-pointer w-24 h-24 object-cover" |
| | /> |
| | </div> |
| | </div> |
| | <div> |
| | <div class="grid grid-cols-3 max-w-md items-center gap-3"> |
| | <label class="text-sm font-medium" for="confidence" |
| | >Confidence Threshold</label |
| | > |
| | <input |
| | type="range" |
| | id="confidence" |
| | name="confidence" |
| | min="0" |
| | max="1" |
| | step="0.01" |
| | value="0.25" |
| | oninput="this.nextElementSibling.value = Number(this.value).toFixed(2)" |
| | /> |
| | <output |
| | class="text-xs font-light px-1 py-1 border border-gray-700 rounded-md w-min" |
| | >0.25</output |
| | > |
| |
|
| | <label class="text-sm font-medium" for="iou_threshold" |
| | >IoU Threshold</label |
| | > |
| |
|
| | <input |
| | type="range" |
| | id="iou_threshold" |
| | name="iou_threshold" |
| | min="0" |
| | max="1" |
| | step="0.01" |
| | value="0.45" |
| | oninput="this.nextElementSibling.value = Number(this.value).toFixed(2)" |
| | /> |
| | <output |
| | class="font-extralight text-xs px-1 py-1 border border-gray-700 rounded-md w-min" |
| | >0.45</output |
| | > |
| | </div> |
| | </div> |
| | </main> |
| | </body> |
| | </html> |
| |
|