Spaces:
Sleeping
Sleeping
jslmmfboom-coder commited on
Commit ·
7347923
1
Parent(s): 4d9b0d5
Add cover bg, scene mode selector, single image upload, demo hides upload, about page format
Browse files- app.py +20 -2
- cover.png +3 -0
- index.html +108 -37
- module/demo_images.py +18 -11
- test_imgs/text/text_1.jpg +3 -0
- test_imgs/text/text_2.jpg +3 -0
app.py
CHANGED
|
@@ -263,7 +263,8 @@ async def delete_image(username: str = Form(...), filename: str = Form(...)):
|
|
| 263 |
# ==================== 检测 API ====================
|
| 264 |
|
| 265 |
def _run_detect_pipeline(uploaded_paths, uname, ocr_engine, bge_tokenizer,
|
| 266 |
-
bge_model, dinov2_extractor, matcher, history_before
|
|
|
|
| 267 |
scene_type_q = 'complex'
|
| 268 |
ocr_result_q = None
|
| 269 |
precomputed_text = None
|
|
@@ -271,7 +272,22 @@ def _run_detect_pipeline(uploaded_paths, uname, ocr_engine, bge_tokenizer,
|
|
| 271 |
precomputed_dinov2 = None
|
| 272 |
|
| 273 |
_t0 = time.perf_counter()
|
| 274 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
scene_type_q, _, _, ocr_result_q = classify_scene(uploaded_paths[0], ocr_engine)
|
| 276 |
_t1 = time.perf_counter()
|
| 277 |
print(f' [TIME] classify_scene: {_t1-_t0:.3f}s')
|
|
@@ -366,6 +382,7 @@ def _run_detect_pipeline(uploaded_paths, uname, ocr_engine, bge_tokenizer,
|
|
| 366 |
@app.post("/api/detect")
|
| 367 |
async def detect(
|
| 368 |
username: str = Form(...),
|
|
|
|
| 369 |
images: list[UploadFile] = File(default=[]),
|
| 370 |
):
|
| 371 |
global session_data, _qd_caches
|
|
@@ -430,6 +447,7 @@ async def detect(
|
|
| 430 |
executor, _run_detect_pipeline,
|
| 431 |
uploaded_paths, uname, ocr_engine, bge_tokenizer,
|
| 432 |
bge_model, dinov2_extractor, matcher, history_before,
|
|
|
|
| 433 |
)
|
| 434 |
|
| 435 |
peak = resource_monitor.stop_monitoring()
|
|
|
|
| 263 |
# ==================== 检测 API ====================
|
| 264 |
|
| 265 |
def _run_detect_pipeline(uploaded_paths, uname, ocr_engine, bge_tokenizer,
|
| 266 |
+
bge_model, dinov2_extractor, matcher, history_before,
|
| 267 |
+
scene_mode='auto'):
|
| 268 |
scene_type_q = 'complex'
|
| 269 |
ocr_result_q = None
|
| 270 |
precomputed_text = None
|
|
|
|
| 272 |
precomputed_dinov2 = None
|
| 273 |
|
| 274 |
_t0 = time.perf_counter()
|
| 275 |
+
if scene_mode in ('text', 'complex'):
|
| 276 |
+
# 用户手动指定场景,跳过 classify_scene
|
| 277 |
+
scene_type_q = scene_mode
|
| 278 |
+
print(f' [SKIP] classify_scene (用户指定场景: {scene_mode})')
|
| 279 |
+
if scene_mode == 'text' and ocr_engine is not None and uploaded_paths:
|
| 280 |
+
# 文本场景仍需 OCR 提取文本
|
| 281 |
+
from module.text_classifier import classify_scene
|
| 282 |
+
_, detail = classify_scene(uploaded_paths[0], ocr_engine)
|
| 283 |
+
ocr_result_q = detail.get('ocr_result')
|
| 284 |
+
precomputed_text = detail.get('full_text', '')
|
| 285 |
+
if precomputed_text and precomputed_text.strip():
|
| 286 |
+
from module.qdrant_manager import _bge_encode
|
| 287 |
+
precomputed_bge = _bge_encode(precomputed_text, bge_tokenizer, bge_model)
|
| 288 |
+
_t1 = time.perf_counter()
|
| 289 |
+
print(f' [TIME] ocr+bge (skip classify): {_t1-_t0:.3f}s')
|
| 290 |
+
elif ocr_engine is not None and uploaded_paths:
|
| 291 |
scene_type_q, _, _, ocr_result_q = classify_scene(uploaded_paths[0], ocr_engine)
|
| 292 |
_t1 = time.perf_counter()
|
| 293 |
print(f' [TIME] classify_scene: {_t1-_t0:.3f}s')
|
|
|
|
| 382 |
@app.post("/api/detect")
|
| 383 |
async def detect(
|
| 384 |
username: str = Form(...),
|
| 385 |
+
scene_mode: str = Form(default='auto'),
|
| 386 |
images: list[UploadFile] = File(default=[]),
|
| 387 |
):
|
| 388 |
global session_data, _qd_caches
|
|
|
|
| 447 |
executor, _run_detect_pipeline,
|
| 448 |
uploaded_paths, uname, ocr_engine, bge_tokenizer,
|
| 449 |
bge_model, dinov2_extractor, matcher, history_before,
|
| 450 |
+
scene_mode,
|
| 451 |
)
|
| 452 |
|
| 453 |
peak = resource_monitor.stop_monitoring()
|
cover.png
ADDED
|
Git LFS Details
|
index.html
CHANGED
|
@@ -29,10 +29,18 @@ body{
|
|
| 29 |
background:#0a0e27;
|
| 30 |
}
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
/* === 动态弥散光背景 === */
|
| 33 |
.bg-lights{position:fixed;inset:0;z-index:-2;overflow:hidden;pointer-events:none}
|
| 34 |
.bg-lights::before,.bg-lights::after{
|
| 35 |
-
content:'';position:absolute;border-radius:50%;filter:blur(80px);opacity:0.
|
| 36 |
}
|
| 37 |
.bg-lights::before{
|
| 38 |
width:600px;height:600px;
|
|
@@ -65,19 +73,14 @@ body{
|
|
| 65 |
|
| 66 |
/* === Parallax 背景层(滚动速度为前景50%) === */
|
| 67 |
.parallax-bg{
|
| 68 |
-
position:fixed;inset:0;z-index:-
|
| 69 |
-
transform:translateY(0);
|
| 70 |
-
will-change:transform;
|
| 71 |
}
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
.
|
| 75 |
-
background:
|
| 76 |
-
|
| 77 |
-
-webkit-backdrop-filter:blur(var(--glass-blur)) saturate(180%);
|
| 78 |
-
border:1px solid var(--glass-border);
|
| 79 |
-
border-radius:16px;
|
| 80 |
-
box-shadow:0 8px 32px rgba(0,0,0,0.2);
|
| 81 |
}
|
| 82 |
|
| 83 |
.container{max-width:960px;margin:0 auto;padding:20px;position:relative;z-index:1}
|
|
@@ -123,6 +126,13 @@ body{
|
|
| 123 |
.btn-warning{background:linear-gradient(135deg,#ffa726,#f57c00);border:none;color:#fff}
|
| 124 |
.btn-sm{padding:6px 12px;font-size:12px}
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
/* === Cards === */
|
| 127 |
.card{
|
| 128 |
padding:20px;margin-bottom:16px;
|
|
@@ -233,7 +243,7 @@ tr.row-fail td{color:#ef9a9a}
|
|
| 233 |
.about-page{display:none;padding:20px;max-width:900px;margin:0 auto}
|
| 234 |
.about-page.active{display:block}
|
| 235 |
.about-section{margin-bottom:24px}
|
| 236 |
-
.about-section h2{font-size:
|
| 237 |
.about-section h3{font-size:16px;margin:12px 0 8px;color:#fff}
|
| 238 |
.about-section p{font-size:14px;line-height:1.7;color:var(--text-secondary);margin-bottom:8px}
|
| 239 |
.about-section ul{margin-left:20px;color:var(--text-secondary);font-size:14px;line-height:1.8}
|
|
@@ -242,16 +252,24 @@ tr.row-fail td{color:#ef9a9a}
|
|
| 242 |
.compare-table td{padding:10px 12px;border-bottom:1px solid rgba(255,255,255,0.08)}
|
| 243 |
.compare-table tr:hover{background:rgba(255,255,255,0.03)}
|
| 244 |
.tech-badge{display:inline-block;padding:4px 12px;margin:4px;border-radius:20px;font-size:12px;background:rgba(124,77,255,0.2);border:1px solid rgba(124,77,255,0.4);color:#b388ff}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
|
| 246 |
.hidden{display:none!important}
|
| 247 |
</style>
|
| 248 |
</head>
|
| 249 |
<body>
|
| 250 |
|
| 251 |
-
<!--
|
|
|
|
| 252 |
<div class="bg-lights"></div>
|
| 253 |
<div class="noise-overlay"></div>
|
| 254 |
-
<div class="parallax-bg" id="parallaxBg"></div>
|
| 255 |
|
| 256 |
<!-- ========== 主应用视图 ========== -->
|
| 257 |
<div id="appView" class="container">
|
|
@@ -298,14 +316,23 @@ tr.row-fail td{color:#ef9a9a}
|
|
| 298 |
</div>
|
| 299 |
</div>
|
| 300 |
|
| 301 |
-
<
|
| 302 |
-
|
|
|
|
| 303 |
<div class="upload-zone" id="queryZone">
|
| 304 |
-
<input type="file" accept="image/*"
|
| 305 |
<div class="icon">🖼️</div>
|
| 306 |
-
<div class="hint">点击选择待检测图片(
|
| 307 |
</div>
|
| 308 |
<div id="queryFileList" class="file-list"></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
</div>
|
| 310 |
|
| 311 |
<div style="text-align:center;margin:20px 0">
|
|
@@ -323,13 +350,39 @@ tr.row-fail td{color:#ef9a9a}
|
|
| 323 |
<button class="btn btn-sm" onclick="hideAbout()">← 返回</button>
|
| 324 |
</div>
|
| 325 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
<div class="card">
|
| 327 |
-
<h2 style="color:var(--accent-light);margin-bottom:12px">
|
| 328 |
-
<p>
|
| 329 |
-
<p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
<p style="margin-top:10px"><strong style="color:#fff">🏆 本项目全程由 TRAE AI 制作</strong></p>
|
| 331 |
</div>
|
| 332 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
<div class="card">
|
| 334 |
<h2 style="color:var(--accent-light);margin-bottom:12px">核心技术栈</h2>
|
| 335 |
<div>
|
|
@@ -355,7 +408,7 @@ tr.row-fail td{color:#ef9a9a}
|
|
| 355 |
<p>DINOv2 的 reg4 版本通过 register tokens 消除了 CLS token 中的 artifact,特征质量更高,能更准确地识别不同角度拍摄的同一场景。</p>
|
| 356 |
|
| 357 |
<h3>🔀 场景自动分类</h3>
|
| 358 |
-
<p>系统通过 4 个加权信号自动判断图片类型:文本面积占比(0.15)、背景白度(0.50)、颜色方差倒数(0.25)、文本行密度(0.10)。综合得分 ≥ 0.45 判为文本场景,否则为复杂场景。</p>
|
| 359 |
|
| 360 |
<h3>⚡ Qdrant 向量检索</h3>
|
| 361 |
<p>历史图片特征预存储到 Qdrant 向量数据库,使用 HNSW 算法实现近似最近邻(ANN)搜索,将 O(N) 暴力比对优化为 O(log N),配合内存缓存实现毫秒级候选筛选。</p>
|
|
@@ -468,6 +521,8 @@ tr.row-fail td{color:#ef9a9a}
|
|
| 468 |
<script>
|
| 469 |
let currentUser = '';
|
| 470 |
let historyUsers = [];
|
|
|
|
|
|
|
| 471 |
|
| 472 |
document.getElementById('usernameInput').addEventListener('keydown', e => {
|
| 473 |
if (e.key === 'Enter') doLogin();
|
|
@@ -479,6 +534,13 @@ window.addEventListener('scroll', () => {
|
|
| 479 |
document.getElementById('parallaxBg').style.transform = `translateY(${scrolled * 0.5}px)`;
|
| 480 |
});
|
| 481 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
// === About Page ===
|
| 483 |
function showAbout() {
|
| 484 |
document.getElementById('appView').classList.add('hidden');
|
|
@@ -502,6 +564,7 @@ async function doLogin() {
|
|
| 502 |
if (!res.ok) { const d = await res.json(); errEl.textContent = d.detail || '登录失败'; return; }
|
| 503 |
const data = await res.json();
|
| 504 |
currentUser = data.username;
|
|
|
|
| 505 |
showMain(data);
|
| 506 |
} catch (e) { errEl.textContent = '网络错误: ' + e.message; }
|
| 507 |
}
|
|
@@ -509,8 +572,10 @@ async function doLogin() {
|
|
| 509 |
function showMain(data) {
|
| 510 |
document.getElementById('loginView').classList.add('hidden');
|
| 511 |
document.getElementById('mainView').classList.remove('hidden');
|
|
|
|
| 512 |
document.getElementById('dispUser').textContent = data.username;
|
| 513 |
document.getElementById('dispCount').textContent = data.history_count;
|
|
|
|
| 514 |
}
|
| 515 |
|
| 516 |
function switchUser() {
|
|
@@ -518,6 +583,7 @@ function switchUser() {
|
|
| 518 |
document.getElementById('loginView').classList.remove('hidden');
|
| 519 |
document.getElementById('usernameInput').value = '';
|
| 520 |
document.getElementById('resultArea').innerHTML = '';
|
|
|
|
| 521 |
}
|
| 522 |
|
| 523 |
// === Upload ===
|
|
@@ -525,8 +591,16 @@ function onQueryChange() {
|
|
| 525 |
const input = document.getElementById('queryInput');
|
| 526 |
const list = document.getElementById('queryFileList');
|
| 527 |
list.innerHTML = '';
|
| 528 |
-
|
| 529 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 530 |
}
|
| 531 |
}
|
| 532 |
|
|
@@ -544,7 +618,8 @@ async function doDetect() {
|
|
| 544 |
try {
|
| 545 |
const fd = new FormData();
|
| 546 |
fd.append('username', currentUser);
|
| 547 |
-
|
|
|
|
| 548 |
const res = await fetch('/api/detect', { method: 'POST', body: fd });
|
| 549 |
const data = await res.json();
|
| 550 |
if (data.error) { alert(data.error); return; }
|
|
@@ -580,7 +655,7 @@ function renderResult(data) {
|
|
| 580 |
html += `</tbody></table></div>`;
|
| 581 |
}
|
| 582 |
|
| 583 |
-
// 可视化
|
| 584 |
if (data.visualizations && data.visualizations.length) {
|
| 585 |
for (const vis of data.visualizations) {
|
| 586 |
const visType = vis.scene_type === 'text' ? '文本场景' : '复杂场景';
|
|
@@ -598,7 +673,7 @@ function renderResult(data) {
|
|
| 598 |
}
|
| 599 |
|
| 600 |
// 再测一张按钮
|
| 601 |
-
html += `<div style="text-align:center;margin:20px 0"><button class="btn btn-primary" onclick="document.getElementById('resultArea').innerHTML='';document.getElementById('queryInput').value='';document.getElementById('queryFileList').innerHTML=''">🔄 再测一张</button></div>`;
|
| 602 |
|
| 603 |
resultArea.innerHTML = html;
|
| 604 |
}
|
|
@@ -606,6 +681,7 @@ function renderResult(data) {
|
|
| 606 |
// === Demo ===
|
| 607 |
async function runDemo(sceneType) {
|
| 608 |
showLoading('正在运行示例场景...');
|
|
|
|
| 609 |
const resultArea = document.getElementById('resultArea');
|
| 610 |
if (resultArea) resultArea.innerHTML = '';
|
| 611 |
|
|
@@ -626,6 +702,8 @@ async function runDemo(sceneType) {
|
|
| 626 |
function renderDemoResult(data) {
|
| 627 |
document.getElementById('loginView').classList.add('hidden');
|
| 628 |
document.getElementById('mainView').classList.remove('hidden');
|
|
|
|
|
|
|
| 629 |
document.getElementById('dispUser').textContent = '示例演示';
|
| 630 |
document.getElementById('dispCount').textContent = '0';
|
| 631 |
|
|
@@ -639,11 +717,7 @@ function renderDemoResult(data) {
|
|
| 639 |
html += `<h2>${isSame ? '✅ 同一场景' : '❌ 不同场景'}</h2>`;
|
| 640 |
html += `<p>相似度: ${simPercent}% | 场景类型: ${sceneLabel}</p></div>`;
|
| 641 |
|
| 642 |
-
|
| 643 |
-
html += `<div style="flex:1;text-align:center"><p style="font-size:12px;color:var(--text-muted);margin-bottom:4px">Query 图</p>${data.query_image_b64 ? `<img src="${data.query_image_b64}" style="max-width:100%;border-radius:8px;border:1px solid rgba(255,255,255,0.1)">` : '<span style="color:var(--text-muted)">无</span>'}</div>`;
|
| 644 |
-
html += `<div style="flex:1;text-align:center"><p style="font-size:12px;color:var(--text-muted);margin-bottom:4px">History 图</p>${data.history_image_b64 ? `<img src="${data.history_image_b64}" style="max-width:100%;border-radius:8px;border:1px solid rgba(255,255,255,0.1)">` : '<span style="color:var(--text-muted)">无</span>'}</div>`;
|
| 645 |
-
html += `</div>`;
|
| 646 |
-
|
| 647 |
if (data.visualizations && data.visualizations.length) {
|
| 648 |
for (const vis of data.visualizations) {
|
| 649 |
const visType = vis.scene_type === 'text' ? '文本场景' : '复杂场景';
|
|
@@ -748,9 +822,6 @@ function hideLoading() {
|
|
| 748 |
document.getElementById('historyPanel').addEventListener('click', function(e) {
|
| 749 |
if (e.target === this) closeHistory();
|
| 750 |
});
|
| 751 |
-
|
| 752 |
-
// ========== 示例场景演示 ==========
|
| 753 |
-
// (已合并到上面的 runDemo / renderDemoResult 函数中)
|
| 754 |
</script>
|
| 755 |
</body>
|
| 756 |
</html>
|
|
|
|
| 29 |
background:#0a0e27;
|
| 30 |
}
|
| 31 |
|
| 32 |
+
/* === 背景图 cover.png === */
|
| 33 |
+
.bg-cover{
|
| 34 |
+
position:fixed;inset:0;z-index:-3;
|
| 35 |
+
background-image:url('/cover.png');
|
| 36 |
+
background-size:cover;background-position:center;
|
| 37 |
+
opacity:0.35;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
/* === 动态弥散光背景 === */
|
| 41 |
.bg-lights{position:fixed;inset:0;z-index:-2;overflow:hidden;pointer-events:none}
|
| 42 |
.bg-lights::before,.bg-lights::after{
|
| 43 |
+
content:'';position:absolute;border-radius:50%;filter:blur(80px);opacity:0.5;
|
| 44 |
}
|
| 45 |
.bg-lights::before{
|
| 46 |
width:600px;height:600px;
|
|
|
|
| 73 |
|
| 74 |
/* === Parallax 背景层(滚动速度为前景50%) === */
|
| 75 |
.parallax-bg{
|
| 76 |
+
position:fixed;inset:0;z-index:-3;pointer-events:none;
|
| 77 |
+
transform:translateY(0);will-change:transform;
|
|
|
|
| 78 |
}
|
| 79 |
+
.parallax-bg .bg-cover-inner{
|
| 80 |
+
position:absolute;inset:0;
|
| 81 |
+
background-image:url('/cover.png');
|
| 82 |
+
background-size:cover;background-position:center;
|
| 83 |
+
opacity:0.3;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
}
|
| 85 |
|
| 86 |
.container{max-width:960px;margin:0 auto;padding:20px;position:relative;z-index:1}
|
|
|
|
| 126 |
.btn-warning{background:linear-gradient(135deg,#ffa726,#f57c00);border:none;color:#fff}
|
| 127 |
.btn-sm{padding:6px 12px;font-size:12px}
|
| 128 |
|
| 129 |
+
/* === Scene Mode Selector === */
|
| 130 |
+
.scene-selector{display:flex;gap:8px;align-items:center;margin-top:12px}
|
| 131 |
+
.scene-selector label{font-size:13px;color:var(--text-secondary);margin-right:4px}
|
| 132 |
+
.scene-option{padding:8px 16px;border-radius:8px;cursor:pointer;font-size:13px;border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.05);transition:all .2s;color:var(--text-secondary)}
|
| 133 |
+
.scene-option:hover{border-color:var(--accent-light);color:#fff}
|
| 134 |
+
.scene-option.active{background:linear-gradient(135deg,#7c4dff,#536dfe);color:#fff;border-color:transparent}
|
| 135 |
+
|
| 136 |
/* === Cards === */
|
| 137 |
.card{
|
| 138 |
padding:20px;margin-bottom:16px;
|
|
|
|
| 243 |
.about-page{display:none;padding:20px;max-width:900px;margin:0 auto}
|
| 244 |
.about-page.active{display:block}
|
| 245 |
.about-section{margin-bottom:24px}
|
| 246 |
+
.about-section h2{font-size:22px;margin-bottom:12px;color:var(--accent-light)}
|
| 247 |
.about-section h3{font-size:16px;margin:12px 0 8px;color:#fff}
|
| 248 |
.about-section p{font-size:14px;line-height:1.7;color:var(--text-secondary);margin-bottom:8px}
|
| 249 |
.about-section ul{margin-left:20px;color:var(--text-secondary);font-size:14px;line-height:1.8}
|
|
|
|
| 252 |
.compare-table td{padding:10px 12px;border-bottom:1px solid rgba(255,255,255,0.08)}
|
| 253 |
.compare-table tr:hover{background:rgba(255,255,255,0.03)}
|
| 254 |
.tech-badge{display:inline-block;padding:4px 12px;margin:4px;border-radius:20px;font-size:12px;background:rgba(124,77,255,0.2);border:1px solid rgba(124,77,255,0.4);color:#b388ff}
|
| 255 |
+
.about-header-card{
|
| 256 |
+
padding:28px;margin-bottom:20px;text-align:center;
|
| 257 |
+
background:linear-gradient(135deg,rgba(124,77,255,0.2),rgba(13,71,161,0.2));
|
| 258 |
+
border:1px solid var(--glass-border);border-radius:16px;
|
| 259 |
+
}
|
| 260 |
+
.about-header-card .track{font-size:13px;color:var(--accent-light);margin-bottom:6px;letter-spacing:1px}
|
| 261 |
+
.about-header-card h1{font-size:28px;margin-bottom:8px;background:linear-gradient(90deg,#b388ff,#7c4dff,#536dfe);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
|
| 262 |
+
.about-header-card .tagline{font-size:14px;color:var(--text-secondary)}
|
| 263 |
|
| 264 |
.hidden{display:none!important}
|
| 265 |
</style>
|
| 266 |
</head>
|
| 267 |
<body>
|
| 268 |
|
| 269 |
+
<!-- 背景层 -->
|
| 270 |
+
<div class="parallax-bg" id="parallaxBg"><div class="bg-cover-inner"></div></div>
|
| 271 |
<div class="bg-lights"></div>
|
| 272 |
<div class="noise-overlay"></div>
|
|
|
|
| 273 |
|
| 274 |
<!-- ========== 主应用视图 ========== -->
|
| 275 |
<div id="appView" class="container">
|
|
|
|
| 316 |
</div>
|
| 317 |
</div>
|
| 318 |
|
| 319 |
+
<!-- 上传区域(示例场景时隐藏) -->
|
| 320 |
+
<div id="uploadSection" class="card">
|
| 321 |
+
<h3>📷 上传待检测图片</h3>
|
| 322 |
<div class="upload-zone" id="queryZone">
|
| 323 |
+
<input type="file" accept="image/*" id="queryInput" onchange="onQueryChange()">
|
| 324 |
<div class="icon">🖼️</div>
|
| 325 |
+
<div class="hint">点击选择待检测图片(仅支持单张)</div>
|
| 326 |
</div>
|
| 327 |
<div id="queryFileList" class="file-list"></div>
|
| 328 |
+
|
| 329 |
+
<!-- 场景选择 -->
|
| 330 |
+
<div class="scene-selector">
|
| 331 |
+
<label>场景模式:</label>
|
| 332 |
+
<div class="scene-option active" data-mode="auto" onclick="selectSceneMode('auto')">默认</div>
|
| 333 |
+
<div class="scene-option" data-mode="text" onclick="selectSceneMode('text')">文本</div>
|
| 334 |
+
<div class="scene-option" data-mode="complex" onclick="selectSceneMode('complex')">复杂</div>
|
| 335 |
+
</div>
|
| 336 |
</div>
|
| 337 |
|
| 338 |
<div style="text-align:center;margin:20px 0">
|
|
|
|
| 350 |
<button class="btn btn-sm" onclick="hideAbout()">← 返回</button>
|
| 351 |
</div>
|
| 352 |
|
| 353 |
+
<!-- 报名信息头 -->
|
| 354 |
+
<div class="about-header-card">
|
| 355 |
+
<div class="track">报名赛道:造个新解法 — 聚焦新一代学习与工作方式</div>
|
| 356 |
+
<h1>慧眼同源</h1>
|
| 357 |
+
<div class="tagline">多模态文档去重与版本溯源系统</div>
|
| 358 |
+
</div>
|
| 359 |
+
|
| 360 |
<div class="card">
|
| 361 |
+
<h2 style="color:var(--accent-light);margin-bottom:12px">1. Demo 简介</h2>
|
| 362 |
+
<p><strong style="color:#fff">是什么:</strong>一个基于 Web 浏览器访问的多模态文档去重与版本溯源系统,用户上传图片即可自动检测是否与历史库中的图片为同一场景或同一文档。</p>
|
| 363 |
+
<p><strong style="color:#fff">面向谁:</strong>企业法务审核人员、财务报销审核岗、知识库运营人员、高校教师与教务人员、自媒体创作者、个人云盘用户。</p>
|
| 364 |
+
<p><strong style="color:#fff">主要功能:</strong></p>
|
| 365 |
+
<ul>
|
| 366 |
+
<li>📄 <strong>文本场景检测</strong>:合同、证书等文档通过 OCR + BGE 语义匹配,穿透措辞差异识别同源文档</li>
|
| 367 |
+
<li>🏞️ <strong>复杂场景检测</strong>:实景照片通过 DINOv2 视觉特征匹配,识别不同角度拍摄的同一场景</li>
|
| 368 |
+
<li>📊 <strong>可视化证据</strong>:检测结果附带 patch 匹配连线 / OCR 文本对比,提供可解释的判定依据</li>
|
| 369 |
+
</ul>
|
| 370 |
<p style="margin-top:10px"><strong style="color:#fff">🏆 本项目全程由 TRAE AI 制作</strong></p>
|
| 371 |
</div>
|
| 372 |
|
| 373 |
+
<div class="card">
|
| 374 |
+
<h2 style="color:var(--accent-light);margin-bottom:12px">2. Demo 创作思路</h2>
|
| 375 |
+
|
| 376 |
+
<h3>💡 灵感来源</h3>
|
| 377 |
+
<p>最初做多角度场景一致性检测时发现,DINOv2 的视觉特征匹配能力天然能识别"同一张纸不同角度拍的照片",BGE 的语义匹配能穿透表述差异找到内容本质。这两个能力组合起来,恰好是文档去重场景一直缺失的那块拼图——从视觉和语义两个维度理解文档的"同源性"。</p>
|
| 378 |
+
|
| 379 |
+
<h3>🎯 想解决的问题</h3>
|
| 380 |
+
<p>法务审核合同时,同一份合同被不同部门换了个标题重新提交审批,审核人靠肉眼和记忆根本发现不了;财务报销时,同一张发票手机拍一次、扫描仪扫一次,两次都能通过系统;学生作业抄袭只需改几个字就能绕过传统查重。这些"同源不同形"的重复问题,传统查重工具完全失效。</p>
|
| 381 |
+
|
| 382 |
+
<h3>🤔 为什么做这个方向</h3>
|
| 383 |
+
<p>现有查重工具只比对文字是否一样,换几个词就查不出来,更别说识别不同角度拍的同一张纸。我们判断,文档去重需要从"字面比对"升级到"语义+视觉"的多模态理解。DINOv2 reg4 的 register tokens 消除了 CLS token 中的 artifact,特征质量更高;BGE-small-zh 针对中文语义优化。两者结合,恰好能覆盖"换皮文档"和"多角度同场景"两大查重盲区。这是传统工具做不到的突破性解法。</p>
|
| 384 |
+
</div>
|
| 385 |
+
|
| 386 |
<div class="card">
|
| 387 |
<h2 style="color:var(--accent-light);margin-bottom:12px">核心技术栈</h2>
|
| 388 |
<div>
|
|
|
|
| 408 |
<p>DINOv2 的 reg4 版本通过 register tokens 消除了 CLS token 中的 artifact,特征质量更高,能更准确地识别不同角度拍摄的同一场景。</p>
|
| 409 |
|
| 410 |
<h3>🔀 场景自动分类</h3>
|
| 411 |
+
<p>系统通过 4 个加权信号自动判断图片类型:文本面积占比(0.15)、背景白度(0.50)、颜色方差倒数(0.25)、文本行密度(0.10)。综合得分 ≥ 0.45 判为文本场景,否则为复杂场景。用户也可手动指定场景模式以跳过分类步骤。</p>
|
| 412 |
|
| 413 |
<h3>⚡ Qdrant 向量检索</h3>
|
| 414 |
<p>历史图片特征预存储到 Qdrant 向量数据库,使用 HNSW 算法实现近似最近邻(ANN)搜索,将 O(N) 暴力比对优化为 O(log N),配合内存缓存实现毫秒级候选筛选。</p>
|
|
|
|
| 521 |
<script>
|
| 522 |
let currentUser = '';
|
| 523 |
let historyUsers = [];
|
| 524 |
+
let selectedSceneMode = 'auto';
|
| 525 |
+
let isDemoMode = false;
|
| 526 |
|
| 527 |
document.getElementById('usernameInput').addEventListener('keydown', e => {
|
| 528 |
if (e.key === 'Enter') doLogin();
|
|
|
|
| 534 |
document.getElementById('parallaxBg').style.transform = `translateY(${scrolled * 0.5}px)`;
|
| 535 |
});
|
| 536 |
|
| 537 |
+
// === Scene Mode Selector ===
|
| 538 |
+
function selectSceneMode(mode) {
|
| 539 |
+
selectedSceneMode = mode;
|
| 540 |
+
document.querySelectorAll('.scene-option').forEach(el => el.classList.remove('active'));
|
| 541 |
+
document.querySelector(`.scene-option[data-mode="${mode}"]`).classList.add('active');
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
// === About Page ===
|
| 545 |
function showAbout() {
|
| 546 |
document.getElementById('appView').classList.add('hidden');
|
|
|
|
| 564 |
if (!res.ok) { const d = await res.json(); errEl.textContent = d.detail || '登录失败'; return; }
|
| 565 |
const data = await res.json();
|
| 566 |
currentUser = data.username;
|
| 567 |
+
isDemoMode = false;
|
| 568 |
showMain(data);
|
| 569 |
} catch (e) { errEl.textContent = '网络错误: ' + e.message; }
|
| 570 |
}
|
|
|
|
| 572 |
function showMain(data) {
|
| 573 |
document.getElementById('loginView').classList.add('hidden');
|
| 574 |
document.getElementById('mainView').classList.remove('hidden');
|
| 575 |
+
document.getElementById('uploadSection').classList.remove('hidden');
|
| 576 |
document.getElementById('dispUser').textContent = data.username;
|
| 577 |
document.getElementById('dispCount').textContent = data.history_count;
|
| 578 |
+
isDemoMode = false;
|
| 579 |
}
|
| 580 |
|
| 581 |
function switchUser() {
|
|
|
|
| 583 |
document.getElementById('loginView').classList.remove('hidden');
|
| 584 |
document.getElementById('usernameInput').value = '';
|
| 585 |
document.getElementById('resultArea').innerHTML = '';
|
| 586 |
+
document.getElementById('uploadSection').classList.remove('hidden');
|
| 587 |
}
|
| 588 |
|
| 589 |
// === Upload ===
|
|
|
|
| 591 |
const input = document.getElementById('queryInput');
|
| 592 |
const list = document.getElementById('queryFileList');
|
| 593 |
list.innerHTML = '';
|
| 594 |
+
if (input.files.length > 1) {
|
| 595 |
+
alert('仅支持上传单张图片,已自动选择第一张');
|
| 596 |
+
const file = input.files[0];
|
| 597 |
+
const dt = new DataTransfer();
|
| 598 |
+
dt.items.add(file);
|
| 599 |
+
input.files = dt.files;
|
| 600 |
+
}
|
| 601 |
+
if (input.files.length === 1) {
|
| 602 |
+
const f = input.files[0];
|
| 603 |
+
list.innerHTML = `<div>📎 ${f.name} (${(f.size/1024).toFixed(1)} KB)</div>`;
|
| 604 |
}
|
| 605 |
}
|
| 606 |
|
|
|
|
| 618 |
try {
|
| 619 |
const fd = new FormData();
|
| 620 |
fd.append('username', currentUser);
|
| 621 |
+
fd.append('scene_mode', selectedSceneMode);
|
| 622 |
+
fd.append('files', input.files[0]);
|
| 623 |
const res = await fetch('/api/detect', { method: 'POST', body: fd });
|
| 624 |
const data = await res.json();
|
| 625 |
if (data.error) { alert(data.error); return; }
|
|
|
|
| 655 |
html += `</tbody></table></div>`;
|
| 656 |
}
|
| 657 |
|
| 658 |
+
// 可视化(点击展开)
|
| 659 |
if (data.visualizations && data.visualizations.length) {
|
| 660 |
for (const vis of data.visualizations) {
|
| 661 |
const visType = vis.scene_type === 'text' ? '文本场景' : '复杂场景';
|
|
|
|
| 673 |
}
|
| 674 |
|
| 675 |
// 再测一张按钮
|
| 676 |
+
html += `<div style="text-align:center;margin:20px 0"><button class="btn btn-primary" onclick="document.getElementById('resultArea').innerHTML='';document.getElementById('queryInput').value='';document.getElementById('queryFileList').innerHTML='';document.getElementById('uploadSection').classList.remove('hidden');isDemoMode=false;">🔄 再测一张</button></div>`;
|
| 677 |
|
| 678 |
resultArea.innerHTML = html;
|
| 679 |
}
|
|
|
|
| 681 |
// === Demo ===
|
| 682 |
async function runDemo(sceneType) {
|
| 683 |
showLoading('正在运行示例场景...');
|
| 684 |
+
isDemoMode = true;
|
| 685 |
const resultArea = document.getElementById('resultArea');
|
| 686 |
if (resultArea) resultArea.innerHTML = '';
|
| 687 |
|
|
|
|
| 702 |
function renderDemoResult(data) {
|
| 703 |
document.getElementById('loginView').classList.add('hidden');
|
| 704 |
document.getElementById('mainView').classList.remove('hidden');
|
| 705 |
+
// 示例模式下隐藏上传区域
|
| 706 |
+
document.getElementById('uploadSection').classList.add('hidden');
|
| 707 |
document.getElementById('dispUser').textContent = '示例演示';
|
| 708 |
document.getElementById('dispCount').textContent = '0';
|
| 709 |
|
|
|
|
| 717 |
html += `<h2>${isSame ? '✅ 同一场景' : '❌ 不同场景'}</h2>`;
|
| 718 |
html += `<p>相似度: ${simPercent}% | 场景类型: ${sceneLabel}</p></div>`;
|
| 719 |
|
| 720 |
+
// 不显示原图,直接展示可视化
|
|
|
|
|
|
|
|
|
|
|
|
|
| 721 |
if (data.visualizations && data.visualizations.length) {
|
| 722 |
for (const vis of data.visualizations) {
|
| 723 |
const visType = vis.scene_type === 'text' ? '文本场景' : '复杂场景';
|
|
|
|
| 822 |
document.getElementById('historyPanel').addEventListener('click', function(e) {
|
| 823 |
if (e.target === this) closeHistory();
|
| 824 |
});
|
|
|
|
|
|
|
|
|
|
| 825 |
</script>
|
| 826 |
</body>
|
| 827 |
</html>
|
module/demo_images.py
CHANGED
|
@@ -95,17 +95,24 @@ def ensure_demo_images(base_dir):
|
|
| 95 |
return None
|
| 96 |
complex_imgs = [c1, c2]
|
| 97 |
|
| 98 |
-
# === 文本场景:
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
return {
|
| 111 |
'text': text_imgs,
|
|
|
|
| 95 |
return None
|
| 96 |
complex_imgs = [c1, c2]
|
| 97 |
|
| 98 |
+
# === 文本场景:优先使用真实图片 ===
|
| 99 |
+
text_dir = os.path.join(demo_dir, 'text')
|
| 100 |
+
real_text = _scan_real_images(text_dir)
|
| 101 |
+
|
| 102 |
+
if len(real_text) >= 2:
|
| 103 |
+
text_imgs = real_text[:2]
|
| 104 |
+
else:
|
| 105 |
+
# Fallback:生成合成图片
|
| 106 |
+
t1 = os.path.join(demo_dir, 'demo_text_1.jpg')
|
| 107 |
+
t2 = os.path.join(demo_dir, 'demo_text_2.jpg')
|
| 108 |
+
if not os.path.exists(t1):
|
| 109 |
+
try:
|
| 110 |
+
_generate_text_scene(t1, t2)
|
| 111 |
+
print("[示例] 已生成合成文本场景图片")
|
| 112 |
+
except Exception as e:
|
| 113 |
+
print(f"[示例] 合成文本场景生成失败: {e}")
|
| 114 |
+
return None
|
| 115 |
+
text_imgs = [t1, t2]
|
| 116 |
|
| 117 |
return {
|
| 118 |
'text': text_imgs,
|
test_imgs/text/text_1.jpg
ADDED
|
Git LFS Details
|
test_imgs/text/text_2.jpg
ADDED
|
Git LFS Details
|