Files
JianMiauandClaude Fable 5 0ec645d1e0 新增「框選辨識主體」功能,降低相似照片互相認錯(掃描頁 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>
2026-07-28 11:58:49 +08:00

455 lines
18 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>動態相片管理</title>
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 24px; font-family: system-ui, -apple-system, "Noto Sans TC", sans-serif;
background: #f2f4f8; color: #1c2333;
}
@media (prefers-color-scheme: dark) {
body { background: #0f1219; color: #e8ecf5; }
.card { background: #1a1f2b !important; }
th { color: #9aa4bd !important; }
input[type="text"] { background: #0f1219; color: #e8ecf5; border-color: #333c50 !important; }
}
h1 { font-size: 22px; margin: 0 0 4px; }
.sub { color: #6b7590; font-size: 14px; margin: 0 0 24px; }
.card {
background: #fff; border-radius: 12px; padding: 20px; margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,.08); max-width: 860px;
}
.card h2 { font-size: 16px; margin: 0 0 14px; }
form { display: flex; flex-wrap: wrap; gap: 12px; align-items: end; }
label { display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: #6b7590; }
input[type="text"] { padding: 8px 10px; border: 1px solid #d4dae6; border-radius: 8px; font-size: 14px; width: 180px; }
input[type="file"] { font-size: 13px; max-width: 230px; }
button {
padding: 9px 20px; border: none; border-radius: 8px; font-size: 14px; font-weight: 600;
cursor: pointer; background: #2b6cf5; color: #fff;
}
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; }
td { padding: 8px; border-top: 1px solid rgba(128,128,128,.15); vertical-align: middle; }
td img { width: 72px; height: 54px; object-fit: cover; border-radius: 6px; display: block; }
.badge { display: inline-block; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.badge.warn { background: #fff3d6; color: #8a6100; }
.badge.ok { background: #d9f2e2; color: #12703c; }
@media (prefers-color-scheme: dark) {
.badge.warn { background: #443a14; color: #ffd45e; }
.badge.ok { background: #10331f; color: #5fd68f; }
}
#msg { font-size: 14px; margin-top: 10px; min-height: 1.4em; }
#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>
<h1>動態相片管理</h1>
<p class="sub">照片=key、影片=value。新增或刪除配對後,記得按「重新編譯」產生辨識特徵檔。掃描頁:<a id="viewerLink" href="#">載入中…</a></p>
<div class="card">
<h2>新增配對</h2>
<form id="addForm">
<label>照片(jpg / png,上傳時自動壓縮)<input type="file" name="photo" accept=".jpg,.jpeg,.png" required></label>
<label>影片(mp4 建議 H.264<input type="file" name="video" accept=".mp4,.m4v,.mov,.webm" required></label>
<button type="submit" id="addBtn">上傳</button>
</form>
<div id="msg"></div>
</div>
<div class="card">
<h2>辨識特徵檔 <span id="compileBadge"></span></h2>
<p class="muted">編譯在你目前的瀏覽器執行(建議用桌機,每張照片約 10~30 秒),完成後自動上傳到伺服器。</p>
<button id="compileBtn" class="big">重新編譯全部照片</button>
<span id="compileStatus" class="muted" style="margin-left:12px"></span>
<div><progress id="compileProgress" max="100" value="0" hidden></progress></div>
</div>
<div class="card">
<h2>已登錄配對(<span id="count">0</span></h2>
<table>
<thead><tr><th>照片</th><th>名稱</th><th>影片</th><th>建立時間</th><th></th></tr></thead>
<tbody id="rows"></tbody>
</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;
async function refresh() {
state = await (await fetch('/api/state')).json();
document.getElementById('count').textContent = state.pairs.length;
const viewerUrl = `https://${location.hostname}:${state.ports.viewer}/`;
const viewerLink = document.getElementById('viewerLink');
viewerLink.href = viewerUrl;
viewerLink.textContent = viewerUrl;
const badge = document.getElementById('compileBadge');
if (state.needsCompile) {
badge.className = 'badge warn';
badge.textContent = '有變更尚未編譯';
} else if (state.targets && state.targets.targets.length) {
badge.className = 'badge ok';
badge.textContent = '已是最新(' + new Date(state.targets.compiledAt).toLocaleString() + '';
} else {
badge.className = 'badge warn';
badge.textContent = '尚未編譯';
}
const tbody = document.getElementById('rows');
tbody.textContent = '';
for (const p of state.pairs) {
const tr = document.createElement('tr');
const tdImg = document.createElement('td');
const img = document.createElement('img');
img.src = '/data/photos/' + encodeURIComponent(p.photo);
tdImg.appendChild(img);
const tdName = document.createElement('td');
tdName.textContent = p.name;
const tdVideo = document.createElement('td');
const a = document.createElement('a');
a.href = '/data/videos/' + encodeURIComponent(p.video);
a.target = '_blank';
a.textContent = p.video;
tdVideo.appendChild(a);
const tdTime = document.createElement('td');
tdTime.className = 'muted';
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 = '刪除';
del.onclick = async () => {
if (!confirm('確定刪除「' + p.name + '」?照片與影片檔案將一併移除。')) return;
await fetch('/api/pairs/' + p.id, { method: 'DELETE' });
refresh();
};
tdDel.appendChild(del);
tr.append(tdImg, tdName, tdVideo, tdTime, tdDel);
tbody.appendChild(tr);
}
}
// 照片統一縮到這個長邊尺寸:對 MindAR 特徵品質足夠,
// 且掃描端(尤其 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);
const img = new Image();
img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('照片檔案無法讀取')); };
img.src = url;
});
}
// 上傳前先在瀏覽器縮圖壓縮(長邊 MAX_EDGE、JPEG 90%),
// 伺服器不需裝任何影像處理套件。canvas 重新編碼時
// 瀏覽器會把 EXIF 方向烘進像素,直式照片不會轉向
async function optimizePhoto(file) {
const img = await loadImageFromFile(file);
const scale = Math.min(1, MAX_EDGE / Math.max(img.naturalWidth, img.naturalHeight));
if (scale === 1) return file;
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);
const blob = await new Promise((r) => c.toBlob(r, 'image/jpeg', 0.9));
if (!blob) throw new Error('照片壓縮失敗');
return new File([blob], file.name.replace(/\.\w+$/, '') + '.jpg', { type: 'image/jpeg' });
}
document.getElementById('addForm').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('addBtn');
const msg = document.getElementById('msg');
btn.disabled = true;
msg.className = '';
try {
const rawPhoto = e.target.photo.files[0];
msg.textContent = '照片壓縮中…';
const photo = await optimizePhoto(rawPhoto);
const mb = (n) => (n / 1024 / 1024).toFixed(1) + 'MB';
msg.textContent = photo === rawPhoto
? '上傳中…'
: `上傳中…(照片 ${mb(rawPhoto.size)}${mb(photo.size)}`;
const fd = new FormData();
fd.append('photo', photo, photo.name);
fd.append('video', e.target.video.files[0]);
const res = await fetch('/api/pairs', { method: 'POST', body: fd });
const json = await res.json();
if (!res.ok) throw new Error(json.error || res.statusText);
msg.textContent = '已新增,請記得重新編譯。';
e.target.reset();
refresh();
} catch (err) {
msg.className = 'err';
msg.textContent = '上傳失敗:' + err.message;
} finally {
btn.disabled = false;
}
});
function loadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('讀取照片失敗:' + url));
img.src = url;
});
}
let compilerLoaded = false;
function loadCompilerScript() {
if (compilerLoaded) return Promise.resolve();
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.type = 'module'; // mind-ar 編譯器是 ES module,會自行掛上 window.MINDAR
s.src = '/vendor/mindar-image.prod.js';
s.onload = () => { compilerLoaded = true; resolve(); };
s.onerror = () => reject(new Error('無法載入 MindAR 編譯器'));
document.head.appendChild(s);
});
}
document.getElementById('compileBtn').addEventListener('click', async () => {
const btn = document.getElementById('compileBtn');
const status = document.getElementById('compileStatus');
const bar = document.getElementById('compileProgress');
if (!state || !state.pairs.length) {
status.textContent = '沒有任何配對可編譯。';
return;
}
btn.disabled = true;
bar.hidden = false;
bar.value = 0;
try {
status.textContent = '載入編譯器…';
await loadCompilerScript();
status.textContent = '載入照片…';
const pairs = state.pairs;
const images = await Promise.all(
pairs.map((p) => loadImage('/data/photos/' + encodeURIComponent(p.photo)))
);
// 有框選主體的配對只用框內區域編譯(背景相似的照片才不會互相認錯)。
// 大圖先縮到長邊 MAX_EDGE 再編譯(雙保險:新上傳的照片已在上傳時縮圖,
// 這裡再保護縮圖修正前上傳的舊照片)。
// 曾發生 4 張 2244x4131 原圖直接編譯後,iPhone 掃任何照片都沒反應
// (桌面 Chrome 正常,iOS WebGL 資源上限較低,辨識引擎無聲掛掉)
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(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;
});
status.textContent = '編譯特徵中(勿關閉頁面)…';
const compiler = new window.MINDAR.IMAGE.Compiler();
await compiler.compileImageTargets(compileInputs, (progress) => {
bar.value = progress;
status.textContent = '編譯特徵中 ' + progress.toFixed(0) + '%(勿關閉頁面)…';
});
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();
fd.append('mapping', JSON.stringify(mapping));
fd.append('mind', new Blob([buffer]), 'targets.mind');
const res = await fetch('/api/targets', { method: 'POST', body: fd });
const json = await res.json();
if (!res.ok) throw new Error(json.error || res.statusText);
status.textContent = '編譯完成!';
refresh();
} catch (err) {
status.textContent = '編譯失敗:' + err.message;
} finally {
btn.disabled = false;
bar.hidden = true;
}
});
refresh();
</script>
</body>
</html>