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

277 lines
9.8 KiB
JavaScript
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.
const express = require('express');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const http = require('http');
const https = require('https');
const multer = require('multer');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const PHOTO_DIR = path.join(DATA_DIR, 'photos');
const VIDEO_DIR = path.join(DATA_DIR, 'videos');
const SELF_CERT_DIR = path.join(DATA_DIR, 'certs'); // 自簽憑證存放處(找不到外部憑證時的備援)
const EXTERNAL_CERT_DIR = process.env.CERT_DIR || ''; // 外掛正式憑證目錄(例如掛載 /certs)
const TMP_DIR = path.join(DATA_DIR, 'tmp');
for (const d of [DATA_DIR, PHOTO_DIR, VIDEO_DIR, SELF_CERT_DIR, TMP_DIR]) {
fs.mkdirSync(d, { recursive: true });
}
const DB_FILE = path.join(DATA_DIR, 'pairs.json');
const TARGETS_META_FILE = path.join(DATA_DIR, 'targets.json');
const TARGETS_MIND_FILE = path.join(DATA_DIR, 'targets.mind');
const ADMIN_PORT = Number(process.env.ADMIN_PORT) || 3510; // 管理頁(HTTP
const VIEWER_PORT = Number(process.env.VIEWER_PORT) || 3511; // 掃描頁(HTTPS,相機需要安全連線)
const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png']);
const VIDEO_EXTS = new Set(['.mp4', '.m4v', '.mov', '.webm']);
function timestampName(d = new Date()) {
const p = (n) => String(n).padStart(2, '0');
return (
`${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}` +
`_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
);
}
function loadJson(file, fallback) {
try {
return JSON.parse(fs.readFileSync(file, 'utf8'));
} catch {
return fallback;
}
}
function saveJson(file, obj) {
fs.writeFileSync(file, JSON.stringify(obj, null, 2));
}
let db = loadJson(DB_FILE, { pairs: [], updatedAt: 0 });
function persistDb() {
db.updatedAt = Date.now();
saveJson(DB_FILE, db);
}
const upload = multer({
dest: TMP_DIR,
limits: { fileSize: 2 * 1024 * 1024 * 1024 },
});
// 共用路由(API 與資料檔),掛在管理與掃描兩個 app 上
const shared = express.Router();
shared.use('/vendor', express.static(path.join(__dirname, 'public', 'vendor')));
shared.use('/data/photos', express.static(PHOTO_DIR));
shared.use('/data/videos', express.static(VIDEO_DIR));
shared.get('/data/targets.mind', (req, res) => {
if (!fs.existsSync(TARGETS_MIND_FILE)) return res.status(404).json({ error: 'no targets' });
res.sendFile(TARGETS_MIND_FILE);
});
function getState() {
const targets = loadJson(TARGETS_META_FILE, null);
const needsCompile =
db.pairs.length > 0
? !targets || (targets.compiledAt || 0) < db.updatedAt
: !!(targets && targets.targets.length > 0);
return {
pairs: db.pairs,
targets,
needsCompile,
ports: { admin: ADMIN_PORT, viewer: VIEWER_PORT },
};
}
shared.get('/api/state', (req, res) => {
res.json(getState());
});
function cleanupTmp(req) {
const files = [];
if (req.file) files.push(req.file);
if (req.files) for (const list of Object.values(req.files)) files.push(...list);
for (const f of files) fs.rm(f.path, { force: true }, () => {});
}
shared.post(
'/api/pairs',
upload.fields([
{ name: 'photo', maxCount: 1 },
{ name: 'video', maxCount: 1 },
]),
(req, res) => {
const photo = req.files && req.files.photo && req.files.photo[0];
const video = req.files && req.files.video && req.files.video[0];
if (!photo || !video) {
cleanupTmp(req);
return res.status(400).json({ error: '需要同時上傳照片與影片' });
}
const photoExt = path.extname(photo.originalname).toLowerCase();
const videoExt = path.extname(video.originalname).toLowerCase();
if (!PHOTO_EXTS.has(photoExt)) {
cleanupTmp(req);
return res.status(400).json({ error: `照片格式僅支援 ${[...PHOTO_EXTS].join(' / ')}` });
}
if (!VIDEO_EXTS.has(videoExt)) {
cleanupTmp(req);
return res.status(400).json({ error: `影片格式僅支援 ${[...VIDEO_EXTS].join(' / ')}` });
}
const id = Date.now().toString(36) + crypto.randomBytes(3).toString('hex');
const photoFile = id + photoExt;
const videoFile = id + videoExt;
fs.renameSync(photo.path, path.join(PHOTO_DIR, photoFile));
fs.renameSync(video.path, path.join(VIDEO_DIR, videoFile));
const pair = {
id,
name: timestampName(),
photo: photoFile,
video: videoFile,
createdAt: Date.now(),
};
db.pairs.push(pair);
persistDb();
res.json({ ok: true, pair });
}
);
// 裁切框(相對座標 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: '找不到這組配對' });
const [pair] = db.pairs.splice(idx, 1);
fs.rm(path.join(PHOTO_DIR, pair.photo), { force: true }, () => {});
fs.rm(path.join(VIDEO_DIR, pair.video), { force: true }, () => {});
persistDb();
res.json({ ok: true });
});
// 管理頁在瀏覽器端把所有照片編譯成 .mind 特徵檔後上傳到這裡
shared.post('/api/targets', upload.single('mind'), (req, res) => {
let mapping;
try {
mapping = JSON.parse(req.body.mapping);
if (!Array.isArray(mapping.targets)) throw new Error('bad mapping');
} catch {
cleanupTmp(req);
return res.status(400).json({ error: 'mapping 格式錯誤' });
}
if (!req.file) return res.status(400).json({ error: '缺少 .mind 檔' });
const known = new Set(db.pairs.map((p) => p.id));
for (const t of mapping.targets) {
if (!known.has(t.pairId)) {
cleanupTmp(req);
return res.status(400).json({ error: `pairId ${t.pairId} 不存在,請重新整理後再編譯` });
}
}
fs.renameSync(req.file.path, TARGETS_MIND_FILE);
saveJson(TARGETS_META_FILE, { compiledAt: Date.now(), targets: mapping.targets });
res.json({ ok: true, state: getState() });
});
function errorHandler(err, req, res, next) {
console.error(err);
res.status(500).json({ error: err.message || 'server error' });
}
// HTML 一律不快取,避免手機拿到舊版頁面
function sendPage(file) {
return (req, res) =>
res.sendFile(path.join(__dirname, 'public', file), {
headers: { 'Cache-Control': 'no-store' },
});
}
// 管理 app3510HTTP):root 是管理頁
const adminApp = express();
adminApp.get('/', sendPage('admin.html'));
adminApp.get('/admin', (req, res) => res.redirect('/'));
adminApp.use(shared);
adminApp.use(errorHandler);
// 掃描 app3511HTTPS):root 是掃描頁
const viewerApp = express();
viewerApp.get('/', sendPage('index.html'));
viewerApp.use(shared);
viewerApp.use(errorHandler);
function ensureCerts() {
// 1. 明確指定的憑證檔(環境變數 SSL_KEY / SSL_CERT 為完整路徑)
if (process.env.SSL_KEY && process.env.SSL_CERT) {
console.log('使用指定憑證:', process.env.SSL_CERT);
return { key: fs.readFileSync(process.env.SSL_KEY), cert: fs.readFileSync(process.env.SSL_CERT) };
}
// 2. 外掛憑證目錄(CERT_DIR),依常見檔名組合搜尋
if (EXTERNAL_CERT_DIR) {
const candidates = [
['privkey.pem', 'fullchain.pem'], // Let's Encrypt / Synology 匯出
['privkey.pem', 'cert.pem'],
['server.key', 'server.crt'],
['key.pem', 'cert.pem'],
['ssl.key', 'ssl.crt'],
];
for (const [k, c] of candidates) {
const keyFile = path.join(EXTERNAL_CERT_DIR, k);
const certFile = path.join(EXTERNAL_CERT_DIR, c);
if (fs.existsSync(keyFile) && fs.existsSync(certFile)) {
console.log(`使用外掛憑證: ${certFile} + ${keyFile}`);
return { key: fs.readFileSync(keyFile), cert: fs.readFileSync(certFile) };
}
}
console.warn(`在 ${EXTERNAL_CERT_DIR} 找不到憑證(試過: ${candidates.map((p) => p.join('+')).join(', ')}),改用自簽憑證`);
}
// 3. 備援:自動產生自簽憑證
const keyFile = path.join(SELF_CERT_DIR, 'server.key');
const certFile = path.join(SELF_CERT_DIR, 'server.crt');
if (fs.existsSync(keyFile) && fs.existsSync(certFile)) {
return { key: fs.readFileSync(keyFile), cert: fs.readFileSync(certFile) };
}
const selfsigned = require('selfsigned');
const pems = selfsigned.generate([{ name: 'commonName', value: 'photo-live' }], {
days: 3650,
keySize: 2048,
extensions: [
{
name: 'subjectAltName',
altNames: [{ type: 2, value: 'localhost' }, { type: 2, value: 'photo-live' }],
},
],
});
fs.writeFileSync(keyFile, pems.private);
fs.writeFileSync(certFile, pems.cert);
console.log('已產生自簽 HTTPS 憑證於', SELF_CERT_DIR);
return { key: pems.private, cert: pems.cert };
}
http.createServer(adminApp).listen(ADMIN_PORT, () => {
console.log(`管理頁 (HTTP): http://0.0.0.0:${ADMIN_PORT}`);
});
https.createServer(ensureCerts(), viewerApp).listen(VIEWER_PORT, () => {
console.log(`掃描頁 (HTTPS): https://0.0.0.0:${VIEWER_PORT} (手機請走這個)`);
});