Files
photo-live/public/index.html
T
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

326 lines
13 KiB
HTML
Raw 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, maximum-scale=1, user-scalable=no">
<title>動態相片</title>
<script>
// iOS 27 Beta 的 WebKit 會讓 tfjs 打包著色器連結失敗
// Failed to link vertex and fragment shaders,辨識引擎無聲掛掉),
// 改用未打包著色器可正常運作。必須在 MindAR 載入前把 tfjsflags
// 補進網址,tfjs 是從 query string 讀取這個設定的;
// 網址已手動帶 tfjsflags 時不覆蓋,方便之後實驗其他參數
(() => {
const isIOS = /iPhone|iPad|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
if (isIOS && !location.search.includes('tfjsflags')) {
const q = location.search ? location.search + '&' : '?';
history.replaceState(null, '', q + 'tfjsflags=WEBGL_PACK:false' + location.hash);
}
})();
</script>
<script src="/vendor/aframe.min.js"></script>
<script src="/vendor/mindar-image-aframe.prod.js"></script>
<style>
/* 背景只能放在 htmlroot canvas),不能放在 body
MindAR 的相機 video 是 body 下 z-index:-2 的元素,body 自己的背景會蓋住它 */
html { background: #000; }
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; }
#overlay {
position: fixed; inset: 0; z-index: 1000;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 16px; padding: 24px; text-align: center;
background: radial-gradient(circle at 50% 30%, #1c2333, #0b0e16 70%);
color: #e8ecf5; font-family: system-ui, -apple-system, "Noto Sans TC", sans-serif;
}
#overlay.hidden { display: none; }
#overlay h1 { margin: 0; font-size: 28px; font-weight: 700; letter-spacing: 2px; }
#status { margin: 0; font-size: 15px; color: #9aa4bd; min-height: 1.4em; }
#startBtn {
padding: 14px 48px; font-size: 18px; font-weight: 600; letter-spacing: 4px;
color: #0b0e16; background: #e8ecf5; border: none; border-radius: 999px; cursor: pointer;
}
#startBtn:active { transform: scale(0.97); }
.hint { font-size: 13px; color: #6b7590; max-width: 320px; line-height: 1.7; }
#stopBtn {
position: fixed; top: 16px; right: 16px; z-index: 1001;
width: 40px; height: 40px; border-radius: 50%; border: none; cursor: pointer;
background: rgba(0,0,0,.5); color: #fff; font-size: 18px; display: none;
}
#dlBtn {
position: fixed; bottom: 24px; right: 16px; z-index: 1001;
width: 52px; height: 52px; border-radius: 50%;
background: rgba(0,0,0,.55); color: #fff; font-size: 24px;
display: none; align-items: center; justify-content: center;
text-decoration: none; box-shadow: 0 2px 8px rgba(0,0,0,.4);
}
#dlBtn:active { transform: scale(0.94); }
a { color: #8fb4ff; }
#errBox {
position: fixed; top: 0; left: 0; right: 0; z-index: 2000;
display: none; padding: 10px 14px;
background: rgba(170, 32, 32, .92); color: #fff;
font: 12px/1.6 monospace; white-space: pre-wrap; word-break: break-all;
max-height: 45%; overflow: auto;
}
#dbgBox {
position: fixed; bottom: 0; left: 0; z-index: 2000;
display: none; padding: 8px 12px; max-width: 100%;
background: rgba(0, 0, 0, .6); color: #9f9;
font: 11px/1.5 monospace; white-space: pre-wrap; word-break: break-all;
max-height: 40%; overflow: auto;
}
</style>
</head>
<body>
<div id="overlay">
<h1>動態相片</h1>
<p id="status">載入中…</p>
<button id="startBtn" hidden>開始掃描</button>
<p class="hint">按下開始後,將相機對準已登錄的照片,<br>對應的影片就會覆蓋在照片上播放。</p>
<p class="hint" style="font-size:11px">v9</p>
</div>
<button id="stopBtn" title="停止"></button>
<a id="dlBtn" title="下載影片" download></a>
<div id="errBox"></div>
<div id="dbgBox"></div>
<script>
const overlay = document.getElementById('overlay');
const statusEl = document.getElementById('status');
const startBtn = document.getElementById('startBtn');
const stopBtn = document.getElementById('stopBtn');
const dlBtn = document.getElementById('dlBtn');
const errBox = document.getElementById('errBox');
const dbgBox = document.getElementById('dbgBox');
// 掃描在某些裝置上「沒反應」時,錯誤常常只進 console 看不到。
// 這裡把所有未攔截的錯誤直接顯示在畫面頂端,行動裝置也能看到
function showErr(msg) {
errBox.style.display = 'block';
errBox.textContent += (errBox.textContent ? '\n' : '') + msg;
}
window.addEventListener('error', (e) => {
showErr('錯誤:' + (e.message || String(e.error || '未知')));
});
window.addEventListener('unhandledrejection', (e) => {
const r = e.reason;
showErr('錯誤:' + ((r && r.message) || String(r)));
});
// 網址加 ?debug 會在畫面底部顯示掃描過程(相機解析度、targetFound 等)
const DEBUG = new URLSearchParams(location.search).has('debug');
function dbg(msg) {
if (!DEBUG) return;
dbgBox.style.display = 'block';
const t = (performance.now() / 1000).toFixed(1);
dbgBox.textContent += `[${t}s] ${msg}\n`;
dbgBox.scrollTop = dbgBox.scrollHeight;
}
// debug 模式下把 console 訊息也收進畫面:
// tfjs 著色器編譯失敗的詳細原因只會 console.log,行動裝置上否則看不到
if (DEBUG) {
for (const level of ['log', 'warn', 'error']) {
const orig = console[level].bind(console);
console[level] = (...args) => {
orig(...args);
try {
const text = args.map((a) => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
if (text && !text.startsWith('%c')) dbg(`console.${level}: ${text.slice(0, 400)}`);
} catch {}
};
}
}
let sceneEl = null;
const videos = [];
async function init() {
let state;
try {
state = await (await fetch('/api/state')).json();
} catch (e) {
statusEl.textContent = '無法連線到伺服器';
return;
}
const adminUrl = `http://${location.hostname}:${state.ports.admin}/`;
if (!state.targets || !state.targets.targets.length) {
statusEl.innerHTML = `尚未建立任何動態相片。<br>請先到 <a href="${adminUrl}">管理頁</a> 新增照片與影片並編譯。`;
return;
}
if (!window.isSecureContext) {
statusEl.innerHTML = '瀏覽器要求安全連線才能開啟相機,<br>請改用 <b>https://</b> 連到本頁。';
return;
}
const pairById = Object.fromEntries(state.pairs.map(p => [p.id, p]));
const entries = state.targets.targets
.map((t, index) => ({ ...t, index, pair: pairById[t.pairId] }))
.filter(t => t.pair);
if (!entries.length) {
statusEl.innerHTML = `目標檔已過期,請到 <a href="${adminUrl}">管理頁</a> 重新編譯。`;
return;
}
dbg(`state 載入完成:${entries.length} 個目標,編譯於 ${new Date(state.targets.compiledAt).toLocaleString()}`);
buildScene(state.targets.compiledAt, entries);
statusEl.textContent = '';
startBtn.hidden = false;
}
function buildScene(version, entries) {
sceneEl = document.createElement('a-scene');
sceneEl.setAttribute('mindar-image',
`imageTargetSrc: /data/targets.mind?v=${version}; autoStart: false; maxTrack: 1; ` +
'filterMinCF: 0.0001; filterBeta: 0.001; uiLoading: no; uiScanning: no; uiError: yes;');
sceneEl.setAttribute('embedded', '');
sceneEl.setAttribute('color-space', 'sRGB');
sceneEl.setAttribute('renderer', 'colorManagement: true; physicallyCorrectLights: true');
sceneEl.setAttribute('vr-mode-ui', 'enabled: false');
sceneEl.setAttribute('device-orientation-permission-ui', 'enabled: false');
const assets = document.createElement('a-assets');
sceneEl.appendChild(assets);
const camera = document.createElement('a-camera');
camera.setAttribute('position', '0 0 0');
camera.setAttribute('look-controls', 'enabled: false');
sceneEl.appendChild(camera);
for (const t of entries) {
const video = document.createElement('video');
video.id = 'vid-' + t.index;
video.src = '/data/videos/' + encodeURIComponent(t.pair.video);
video.loop = true;
video.preload = 'auto';
video.crossOrigin = 'anonymous';
video.setAttribute('playsinline', '');
video.setAttribute('webkit-playsinline', '');
// 影片一律以靜音狀態建立,直到第一次掃到照片才解除靜音,
// 確保解鎖過程無論發生什麼載入競態都不可能出聲
video.muted = true;
video.defaultMuted = true;
video.setAttribute('muted', '');
assets.appendChild(video);
videos.push(video);
const anchor = document.createElement('a-entity');
anchor.setAttribute('mindar-image-target', 'targetIndex: ' + t.index);
// 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', String(pw));
plane.setAttribute('height', String(ph));
plane.setAttribute('position', `${px} ${py} 0`);
anchor.appendChild(plane);
let isFound = false;
// 保險:照片不在畫面上時若影片意外開始播放,立刻壓回去
video.addEventListener('playing', () => {
if (!isFound) {
video.pause();
video.currentTime = 0;
}
});
anchor.addEventListener('targetFound', () => {
dbg(`targetFound#${t.index}${t.pair.name}`);
isFound = true;
video.muted = false; // 掃到照片這一刻才解除靜音
video.defaultMuted = false;
video.removeAttribute('muted');
video.play().catch(() => {
video.muted = true; // 有聲播放被瀏覽器擋下時退回靜音播放
video.play().catch(() => {});
});
// 右下角出現下載按鈕,可下載最後掃到的影片
dlBtn.href = video.src;
dlBtn.setAttribute('download', t.pair.name + t.pair.video.slice(t.pair.video.lastIndexOf('.')));
dlBtn.style.display = 'flex';
});
anchor.addEventListener('targetLost', () => {
dbg(`targetLost#${t.index}`);
isFound = false;
video.pause();
});
sceneEl.appendChild(anchor);
}
document.body.appendChild(sceneEl);
}
startBtn.addEventListener('click', async () => {
startBtn.disabled = true;
statusEl.textContent = '啟動相機中…';
// 在使用者手勢中先 play/pause 一次,解鎖行動裝置的自動播放限制
// (解鎖許可綁定各個影片元素,之後掃到照片才能自動帶聲音播放)。
// 影片維持靜音,直到 targetFound 才解除,解鎖過程絕不出聲
for (const v of videos) {
// 重新靜音(之前掃到過照片的影片會處於未靜音狀態)
v.muted = true;
v.defaultMuted = true;
v.setAttribute('muted', '');
try {
await v.play();
v.pause();
v.currentTime = 0;
} catch {
// 靜音播放被擋下也無妨,掃到照片時會再嘗試播放
}
}
// 保險:AR 引擎就緒時把所有影片壓回暫停歸零,任何啟動期間的意外播放都會被停掉
sceneEl.addEventListener('arReady', () => {
for (const v of videos) {
if (!v.paused) v.pause();
v.currentTime = 0;
}
// MindAR 的相機 video 是掛在 body 下、沒有 id 的那個元素
const cam = document.querySelector('body > video');
dbg('arReady:辨識引擎啟動' + (cam ? `,相機 ${cam.videoWidth}x${cam.videoHeight}` : ''));
}, { once: true });
sceneEl.addEventListener('arError', () => {
showErr('AR 啟動失敗(arError):通常是相機無法開啟或 WebGL 資源不足');
}, { once: true });
const start = () => {
const sys = sceneEl.systems['mindar-image-system'];
sys.start();
overlay.classList.add('hidden');
stopBtn.style.display = 'block';
};
if (sceneEl.hasLoaded) start();
else sceneEl.addEventListener('loaded', start, { once: true });
});
stopBtn.addEventListener('click', () => {
try { sceneEl.systems['mindar-image-system'].stop(); } catch {}
for (const v of videos) v.pause();
stopBtn.style.display = 'none';
dlBtn.style.display = 'none';
overlay.classList.remove('hidden');
startBtn.disabled = false;
statusEl.textContent = '已停止,可再次開始掃描';
});
init();
</script>
</body>
</html>