Spaces:
Running
Running
| <html lang="zh-TW"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <title>PCB瑕疵偵測系統 (YOLOv7 ONNX)</title> | |
| <script src="https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/ort.min.js"></script> | |
| <style> | |
| body { font-family: Arial, sans-serif; max-width: 800px; margin: 30px auto; text-align: center; background: #f0f2f5; padding: 0 10px; } | |
| .container { background: white; padding: 20px; border-radius: 12px; box-shadow: 0 4px 10px rgba(0,0,0,0.1); } | |
| /* 關鍵:限制容器最大寬度,並讓內容置中 */ | |
| .canvas-container { | |
| position: relative; | |
| display: inline-block; | |
| margin-top: 20px; | |
| max-width: 100%; /* 防止外層超出版面 */ | |
| } | |
| /* 關鍵:限制 Canvas 的顯示寬度,高度會依比例自適應 */ | |
| canvas { | |
| max-width: 100%; | |
| height: auto ; | |
| display: block; | |
| } | |
| #outputCanvas { position: absolute; left: 0; top: 0; } | |
| #imageLoader { margin: 20px 0; } | |
| #status { color: #666; font-style: italic; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h2>🔍 PCB瑕疵偵測系統 (YOLOv7)</h2> | |
| <p id="status">正在下載並初始化 ONNX 模型,請稍候...</p> | |
| <input type="file" id="imageLoader" accept="image/*" disabled> | |
| <div class="canvas-container"> | |
| <canvas id="inputCanvas"></canvas> | |
| <canvas id="outputCanvas"></canvas> | |
| </div> | |
| </div> | |
| <script> | |
| const LABELS = ['missing_hole', 'mouse_bite', 'open_circuit', 'short', 'spur', 'spurious_copper']; | |
| const MODEL_SIZE = 640; | |
| let session = null; | |
| async function init() { | |
| try { | |
| // 加上穩定版時間戳記,避免快取卡死 | |
| const url = window.location.href + "resolve/main/best.onnx?t=" + new Date().getTime(); | |
| session = await ort.InferenceSession.create(url, { | |
| executionProviders: ['wasm'], | |
| numThreads: 1 | |
| }); | |
| document.getElementById('status').innerText = "模型載入成功!請上傳圖片。"; | |
| document.getElementById('imageLoader').disabled = false; | |
| } catch (e) { | |
| // 如果 resolve 失敗,退回本地嘗試 | |
| try { | |
| session = await ort.InferenceSession.create('./best.onnx', { executionProviders: ['wasm'], numThreads: 1 }); | |
| document.getElementById('status').innerText = "模型載入成功!請上傳圖片。"; | |
| document.getElementById('imageLoader').disabled = false; | |
| } catch(err) { | |
| document.getElementById('status').innerText = "模型載入失敗,請確認 best.onnx 是否正確上傳。"; | |
| console.error(e); | |
| } | |
| } | |
| } | |
| init(); | |
| document.getElementById('imageLoader').addEventListener('change', handleImage); | |
| function handleImage(e) { | |
| const reader = new FileReader(); | |
| reader.onload = function(event) { | |
| const img = new Image(); | |
| img.onload = function() { detect(img); } | |
| img.src = event.target.result; | |
| } | |
| reader.readAsDataURL(e.target.files[0]); | |
| } | |
| async function detect(img) { | |
| document.getElementById('status').innerText = "計算中..."; | |
| const inputCanvas = document.getElementById('inputCanvas'); | |
| const outputCanvas = document.getElementById('outputCanvas'); | |
| const ctx = inputCanvas.getContext('2d'); | |
| const outCtx = outputCanvas.getContext('2d'); | |
| // 畫布的繪圖解析度維持圖片原始尺寸(保證畫質不變形) | |
| inputCanvas.width = img.width;[cite: 3] | |
| inputCanvas.height = img.height;[cite: 3] | |
| outputCanvas.width = img.width;[cite: 3] | |
| outputCanvas.height = img.height;[cite: 3] | |
| ctx.drawImage(img, 0, 0);[cite: 3] | |
| outCtx.clearRect(0, 0, img.width, img.height);[cite: 3] | |
| // 1. 預處理:縮放與標準化 | |
| const resizeCanvas = document.createElement('canvas');[cite: 3] | |
| resizeCanvas.width = MODEL_SIZE;[cite: 3] | |
| resizeCanvas.height = MODEL_SIZE;[cite: 3] | |
| const resizeCtx = resizeCanvas.getContext('2d');[cite: 3] | |
| resizeCtx.drawImage(img, 0, 0, MODEL_SIZE, MODEL_SIZE);[cite: 3] | |
| const imgData = resizeCtx.getImageData(0, 0, MODEL_SIZE, MODEL_SIZE).data;[cite: 3] | |
| const float32Data = new Float32Array(3 * MODEL_SIZE * MODEL_SIZE);[cite: 3] | |
| for (let i = 0; i < MODEL_SIZE * MODEL_SIZE; i++) { | |
| float32Data[i] = imgData[i * 4] / 255.0;[cite: 3] | |
| float32Data[i + MODEL_SIZE * MODEL_SIZE] = imgData[i * 4 + 1] / 255.0;[cite: 3] | |
| float32Data[i + 2 * MODEL_SIZE * MODEL_SIZE] = imgData[i * 4 + 2] / 255.0;[cite: 3] | |
| } | |
| // 2. 執行 ONNX 推理 | |
| const inputTensor = new ort.Tensor('float32', float32Data, [1, 3, MODEL_SIZE, MODEL_SIZE]);[cite: 3] | |
| const feeds = {};[cite: 3] | |
| feeds[session.inputNames[0]] = inputTensor;[cite: 3] | |
| const outputMap = await session.run(feeds);[cite: 3] | |
| const rawOutput = outputMap[session.outputNames[0]].data;[cite: 3] | |
| // 3. 後處理 | |
| const numClasses = LABELS.length;[cite: 3] | |
| const numElements = 5 + numClasses;[cite: 3] | |
| const totalBoxes = rawOutput.length / numElements;[cite: 3] | |
| let boxes = [];[cite: 3] | |
| const confThreshold = 0.4;[cite: 3] | |
| for (let i = 0; i < totalBoxes; i++) {[cite: 3] | |
| const index = i * numElements;[cite: 3] | |
| const boxScore = rawOutput[index + 4];[cite: 3] | |
| if (boxScore > confThreshold) {[cite: 3] | |
| let maxClassScore = 0;[cite: 3] | |
| let classId = -1;[cite: 3] | |
| for (let c = 0; c < numClasses; c++) {[cite: 3] | |
| const classScore = rawOutput[index + 5 + c];[cite: 3] | |
| if (classScore > maxClassScore) {[cite: 3] | |
| maxClassScore = classScore;[cite: 3] | |
| classId = c;[cite: 3] | |
| } | |
| } | |
| const confidence = boxScore * maxClassScore;[cite: 3] | |
| if (confidence > confThreshold) {[cite: 3] | |
| const cx = rawOutput[index + 0];[cite: 3] | |
| const cy = rawOutput[index + 1];[cite: 3] | |
| const w = rawOutput[index + 2];[cite: 3] | |
| const h = rawOutput[index + 3];[cite: 3] | |
| const x1 = (cx - w / 2) * (img.width / MODEL_SIZE);[cite: 3] | |
| const y1 = (cy - h / 2) * (img.height / MODEL_SIZE);[cite: 3] | |
| const boxW = w * (img.width / MODEL_SIZE);[cite: 3] | |
| const boxH = h * (img.height / MODEL_SIZE);[cite: 3] | |
| boxes.push({ x1, y1, w: boxW, h: boxH, score: confidence, classId });[cite: 3] | |
| } | |
| } | |
| } | |
| // 4. NMS 非極大值抑制 | |
| boxes.sort((a, b) => b.score - a.score);[cite: 3] | |
| let resultBoxes = [];[cite: 3] | |
| while (boxes.length > 0) {[cite: 3] | |
| let chosen = boxes.shift();[cite: 3] | |
| resultBoxes.push(chosen);[cite: 3] | |
| boxes = boxes.filter(b => {[cite: 3] | |
| const interX = Math.max(chosen.x1, b.x1);[cite: 3] | |
| const interY = Math.max(chosen.y1, b.y1);[cite: 3] | |
| const interW = Math.min(chosen.x1 + chosen.w, b.x1 + b.w) - interX;[cite: 3] | |
| const interH = Math.min(chosen.y1 + chosen.h, b.y1 + b.h) - interY;[cite: 3] | |
| if (interW <= 0 || interH <= 0) return true;[cite: 3] | |
| const interArea = interW * interH;[cite: 3] | |
| const iou = interArea / (chosen.w * chosen.h + b.w * b.h - interArea);[cite: 3] | |
| return iou < 0.45;[cite: 3] | |
| }); | |
| } | |
| // 💡 調整畫框的粗細與字體大小,使其能隨圖片解析度動態放大,防止在大圖中方框變太細 | |
| const baseScale = Math.max(img.width / 800, 1); | |
| // 5. 使用 Canvas 繪製結果 | |
| resultBoxes.forEach(box => {[cite: 3] | |
| outCtx.strokeStyle = "#FF3B30";[cite: 3] | |
| outCtx.lineWidth = 3 * baseScale; // 根據大圖等比例加粗線條 | |
| outCtx.strokeRect(box.x1, box.y1, box.w, box.h);[cite: 3] | |
| outCtx.fillStyle = "#FF3B30";[cite: 3] | |
| outCtx.font = `bold ${Math.round(16 * baseScale)}px Arial`; // 根據大圖等比例放大標籤文字 | |
| outCtx.fillText(`${LABELS[box.classId]} ${(box.score * 100).toFixed(0)}%`, box.x1, box.y1 - (7 * baseScale));[cite: 3] | |
| }); | |
| document.getElementById('status').innerText = `辨識完成!偵測到 ${resultBoxes.length} 個物件。`;[cite: 3] | |
| } | |
| </script> | |
| </body> | |
| </html> |