新增「框選辨識主體」功能,降低相似照片互相認錯(掃描頁 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:
+157
-6
@@ -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
@@ -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;
|
||||
|
||||
@@ -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: '找不到這組配對' });
|
||||
|
||||
Reference in New Issue
Block a user