新增「框選辨識主體」功能,降低相似照片互相認錯(掃描頁 v9)

根本原因:
MindAR 以整張照片的特徵點做比對,同場地、同構圖的照片
(例如活動現場連拍)大部分特徵點落在共同背景(場地線條、
天花板、浮水印),會穩定地互相認錯、播出別張的影片。
調高 warmupTolerance 實測無效:錯誤目標是持續穩定匹配,
嚴格化只是更慢地確認錯的答案。

影響:
背景相似的多組照片無法在同一個特徵檔中正確區分。

修法:
1. 管理頁每組配對新增「框主體」:在照片上拖曳框選最有辨識度
   的區域(相對座標存於 pairs.json,照片檔不動),編譯時只用
   框內影像產生特徵。框的長寬下限 15%,實測框太小(僅臉部
   特寫)會特徵點不足而完全偵測不到。
2. server 新增 PUT /api/pairs/:id/crop 儲存裁切框;更動後
   自動標記「有變更尚未編譯」。
3. 掃描頁依 mapping 中的 aspect 與 crop 將影片平面放大平移,
   辨識目標雖只是照片的一塊,影片仍精確覆蓋整張照片
   (已用假相機截圖驗證幾何)。無裁切的舊資料行為不變。

已知限制:兩張照片若互相拍到彼此的主體(同兩人、同場地的
連拍),框選也無法完全區分——框內容本來就存在於另一張裡。
這種情況仍建議更換其中一張照片。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 11:58:49 +08:00
co-authored by Claude Fable 5
parent 77fff4326c
commit 0ec645d1e0
3 changed files with 196 additions and 11 deletions
+157 -6
View File
@@ -34,6 +34,7 @@
}
button:disabled { opacity: .5; cursor: default; }
button.danger { background: transparent; color: #d33; padding: 4px 8px; font-weight: 400; }
button.plain { background: transparent; color: #2b6cf5; padding: 4px 8px; font-weight: 400; }
button.big { padding: 12px 28px; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th { text-align: left; font-size: 12px; color: #6b7590; font-weight: 500; padding: 6px 8px; }
@@ -50,6 +51,30 @@
#msg.err { color: #d33; }
.muted { color: #6b7590; font-size: 13px; }
progress { width: 240px; }
#cropModal {
position: fixed; inset: 0; z-index: 100; display: flex;
align-items: center; justify-content: center; padding: 20px;
background: rgba(0, 0, 0, .55);
}
#cropModal[hidden] { display: none; }
#cropPanel {
background: #fff; border-radius: 12px; padding: 20px;
max-width: 560px; max-height: 92vh; overflow: auto;
}
@media (prefers-color-scheme: dark) { #cropPanel { background: #1a1f2b; } }
#cropPanel h2 { font-size: 16px; margin: 0 0 8px; }
#cropStage {
position: relative; display: inline-block; margin-top: 10px;
touch-action: none; user-select: none; overflow: hidden; border-radius: 8px;
}
#cropImg { display: block; max-width: 100%; max-height: 58vh; }
#cropRect {
position: absolute; border: 2px solid #2b6cf5; pointer-events: none;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, .5);
}
#cropMsg { min-height: 1.4em; margin-top: 8px; }
#cropMsg.err { color: #d33; }
.cropBtns { display: flex; gap: 10px; justify-content: flex-end; margin-top: 12px; }
</style>
</head>
<body>
@@ -82,6 +107,24 @@
</table>
</div>
<div id="cropModal" hidden>
<div id="cropPanel">
<h2>框選辨識主體</h2>
<p class="muted" style="margin:0">
在照片上拖曳,框出最有辨識度的區域(例如人物)。背景相似的多張照片各自框選主體後,
掃描就不會互相認錯;影片播放時仍會覆蓋整張照片。
框的長寬至少要佔照片 15%,框太小特徵點不足反而難辨識。改完記得重新編譯。
</p>
<div id="cropStage"><img id="cropImg" draggable="false"><div id="cropRect" hidden></div></div>
<div id="cropMsg" class="muted"></div>
<div class="cropBtns">
<button type="button" id="cropClear" class="danger">清除(用整張照片)</button>
<button type="button" id="cropCancel" style="background:#6b7590">取消</button>
<button type="button" id="cropSave">儲存</button>
</div>
</div>
</div>
<script>
let state = null;
@@ -131,6 +174,12 @@ async function refresh() {
tdTime.textContent = new Date(p.createdAt).toLocaleString();
const tdDel = document.createElement('td');
const cropBtn = document.createElement('button');
cropBtn.className = 'plain';
cropBtn.textContent = p.crop ? '主體✓' : '框主體';
cropBtn.title = '框選辨識主體(背景相似的照片才不會互相認錯)';
cropBtn.onclick = () => openCropModal(p);
tdDel.appendChild(cropBtn);
const del = document.createElement('button');
del.className = 'danger';
del.textContent = '刪除';
@@ -150,6 +199,99 @@ async function refresh() {
// 且掃描端(尤其 iOS Safari)的 WebGL 記憶體撐得住
const MAX_EDGE = 1280;
// ---- 框選辨識主體(裁切框只影響編譯輸入,照片檔不動)----
const cropModal = document.getElementById('cropModal');
const cropStage = document.getElementById('cropStage');
const cropImg = document.getElementById('cropImg');
const cropRect = document.getElementById('cropRect');
const cropMsg = document.getElementById('cropMsg');
const CROP_MIN = 0.15; // 框的長寬至少要佔照片的比例
let cropPair = null;
let cropRel = null; // {x,y,w,h} 相對座標,null = 整張照片
function openCropModal(pair) {
cropPair = pair;
cropRel = pair.crop ? { ...pair.crop } : null;
cropMsg.className = 'muted';
cropMsg.textContent = '';
cropImg.src = '/data/photos/' + encodeURIComponent(pair.photo) + '?t=' + pair.createdAt;
cropModal.hidden = false;
if (cropImg.complete) renderCropRect();
else cropImg.onload = renderCropRect;
}
function renderCropRect() {
if (!cropRel) {
cropRect.hidden = true;
return;
}
cropRect.hidden = false;
cropRect.style.left = cropRel.x * cropImg.clientWidth + 'px';
cropRect.style.top = cropRel.y * cropImg.clientHeight + 'px';
cropRect.style.width = cropRel.w * cropImg.clientWidth + 'px';
cropRect.style.height = cropRel.h * cropImg.clientHeight + 'px';
}
// 拖曳畫框(pointer events 同時支援滑鼠與觸控)
let dragStart = null;
function pointToRel(e) {
const r = cropImg.getBoundingClientRect();
return {
x: Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)),
y: Math.min(1, Math.max(0, (e.clientY - r.top) / r.height)),
};
}
cropStage.addEventListener('pointerdown', (e) => {
e.preventDefault();
cropStage.setPointerCapture(e.pointerId);
dragStart = pointToRel(e);
});
cropStage.addEventListener('pointermove', (e) => {
if (!dragStart) return;
const p = pointToRel(e);
cropRel = {
x: Math.min(dragStart.x, p.x),
y: Math.min(dragStart.y, p.y),
w: Math.abs(p.x - dragStart.x),
h: Math.abs(p.y - dragStart.y),
};
renderCropRect();
});
cropStage.addEventListener('pointerup', () => { dragStart = null; });
window.addEventListener('resize', () => {
if (!cropModal.hidden) renderCropRect();
});
document.getElementById('cropClear').onclick = () => {
cropRel = null;
renderCropRect();
cropMsg.className = 'muted';
cropMsg.textContent = '已清除,按「儲存」後會改用整張照片。';
};
document.getElementById('cropCancel').onclick = () => { cropModal.hidden = true; };
document.getElementById('cropSave').onclick = async () => {
if (cropRel && (cropRel.w < CROP_MIN || cropRel.h < CROP_MIN)) {
cropMsg.className = 'err';
cropMsg.textContent = `框太小了:長寬都要至少佔照片 ${CROP_MIN * 100}%,不然特徵點不足難以辨識。`;
return;
}
try {
const res = await fetch('/api/pairs/' + cropPair.id + '/crop', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ crop: cropRel }),
});
const json = await res.json();
if (!res.ok) throw new Error(json.error || res.statusText);
cropModal.hidden = true;
refresh();
} catch (err) {
cropMsg.className = 'err';
cropMsg.textContent = '儲存失敗:' + err.message;
}
};
function loadImageFromFile(file) {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
@@ -251,17 +393,23 @@ document.getElementById('compileBtn').addEventListener('click', async () => {
pairs.map((p) => loadImage('/data/photos/' + encodeURIComponent(p.photo)))
);
// 有框選主體的配對只用框內區域編譯(背景相似的照片才不會互相認錯)。
// 大圖先縮到長邊 MAX_EDGE 再編譯(雙保險:新上傳的照片已在上傳時縮圖,
// 這裡再保護縮圖修正前上傳的舊照片)。
// 曾發生 4 張 2244x4131 原圖直接編譯後,iPhone 掃任何照片都沒反應
// (桌面 Chrome 正常,iOS WebGL 資源上限較低,辨識引擎無聲掛掉)
const compileInputs = images.map((img) => {
const scale = Math.min(1, MAX_EDGE / Math.max(img.naturalWidth, img.naturalHeight));
if (scale === 1) return img;
const compileInputs = images.map((img, i) => {
const crop = pairs[i].crop;
const sx = crop ? crop.x * img.naturalWidth : 0;
const sy = crop ? crop.y * img.naturalHeight : 0;
const sw = crop ? crop.w * img.naturalWidth : img.naturalWidth;
const sh = crop ? crop.h * img.naturalHeight : img.naturalHeight;
const scale = Math.min(1, MAX_EDGE / Math.max(sw, sh));
if (scale === 1 && !crop) return img;
const c = document.createElement('canvas');
c.width = Math.round(img.naturalWidth * scale);
c.height = Math.round(img.naturalHeight * scale);
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
c.width = Math.round(sw * scale);
c.height = Math.round(sh * scale);
c.getContext('2d').drawImage(img, sx, sy, sw, sh, 0, 0, c.width, c.height);
return c;
});
@@ -274,10 +422,13 @@ document.getElementById('compileBtn').addEventListener('click', async () => {
const buffer = await compiler.exportData();
status.textContent = '上傳特徵檔…';
// aspect 一律是整張照片的長寬比;有裁切時另外附上裁切框,
// 掃描頁靠這兩個值把影片平面校正回「覆蓋整張照片」
const mapping = {
targets: pairs.map((p, i) => ({
pairId: p.id,
aspect: images[i].naturalHeight / images[i].naturalWidth,
crop: p.crop || undefined,
})),
};
const fd = new FormData();
+17 -5
View File
@@ -78,7 +78,7 @@
<p id="status">載入中…</p>
<button id="startBtn" hidden>開始掃描</button>
<p class="hint">按下開始後,將相機對準已登錄的照片,<br>對應的影片就會覆蓋在照片上播放。</p>
<p class="hint" style="font-size:11px">v8</p>
<p class="hint" style="font-size:11px">v9</p>
</div>
<button id="stopBtn" title="停止"></button>
<a id="dlBtn" title="下載影片" download></a>
@@ -209,12 +209,24 @@ function buildScene(version, entries) {
const anchor = document.createElement('a-entity');
anchor.setAttribute('mindar-image-target', 'targetIndex: ' + t.index);
// MindAR 的目標寬度固定為 1,用照片長寬比讓影片剛好蓋住整張照片
// MindAR 的目標寬度固定為 1(辨識目標=編譯輸入的那塊影像)。
// 沒裁切時目標就是整張照片,影片平面直接用照片長寬比;
// 有框選主體時目標只是照片的一塊,要按裁切框把影片平面放大並平移,
// 讓影片仍然剛好覆蓋整張照片(座標換算:1 目標單位 = 裁切框寬)
const aspect = t.aspect || 1;
const crop = t.crop;
let pw = 1, ph = aspect, px = 0, py = 0;
if (crop && crop.w > 0 && crop.h > 0) {
pw = 1 / crop.w;
ph = aspect / crop.w;
px = (0.5 - crop.x - crop.w / 2) / crop.w;
py = -(0.5 - crop.y - crop.h / 2) * aspect / crop.w;
}
const plane = document.createElement('a-video');
plane.setAttribute('src', '#vid-' + t.index);
plane.setAttribute('width', '1');
plane.setAttribute('height', String(t.aspect || 1));
plane.setAttribute('position', '0 0 0');
plane.setAttribute('width', String(pw));
plane.setAttribute('height', String(ph));
plane.setAttribute('position', `${px} ${py} 0`);
anchor.appendChild(plane);
let isFound = false;
+22
View File
@@ -137,6 +137,28 @@ shared.post(
}
);
// 裁切框(相對座標 0~1,null 表示用整張照片)。只影響編譯輸入,照片檔不動
function isValidCrop(c) {
if (c === null) return true;
if (!c || typeof c !== 'object') return false;
const nums = [c.x, c.y, c.w, c.h];
if (!nums.every((n) => typeof n === 'number' && Number.isFinite(n))) return false;
return c.x >= 0 && c.y >= 0 && c.w > 0 && c.h > 0 && c.x + c.w <= 1.0001 && c.y + c.h <= 1.0001;
}
shared.put('/api/pairs/:id/crop', express.json(), (req, res) => {
const pair = db.pairs.find((p) => p.id === req.params.id);
if (!pair) return res.status(404).json({ error: '找不到這組配對' });
const crop = req.body ? req.body.crop : undefined;
if (crop === undefined || !isValidCrop(crop)) {
return res.status(400).json({ error: 'crop 格式錯誤(需為 null 或 {x,y,w,h} 相對座標)' });
}
if (crop === null) delete pair.crop;
else pair.crop = { x: +crop.x.toFixed(4), y: +crop.y.toFixed(4), w: +crop.w.toFixed(4), h: +crop.h.toFixed(4) };
persistDb();
res.json({ ok: true, pair });
});
shared.delete('/api/pairs/:id', (req, res) => {
const idx = db.pairs.findIndex((p) => p.id === req.params.id);
if (idx === -1) return res.status(404).json({ error: '找不到這組配對' });