根本原因: 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>
455 lines
18 KiB
HTML
455 lines
18 KiB
HTML
<!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>
|