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 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 ocr_engine is not None and uploaded_paths:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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

  • SHA256: bf7cdb7fc9a845496dc7474f9b0ebec0f4673c8d3efe28bb9cf4a9965ea8df44
  • Pointer size: 132 Bytes
  • Size of remote file: 4.75 MB
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.6;
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:-1;pointer-events:none;
69
- transform:translateY(0);
70
- will-change:transform;
71
  }
72
-
73
- /* === 玻璃拟态卡片 === */
74
- .glass{
75
- background:var(--glass-bg);
76
- backdrop-filter:blur(var(--glass-blur)) saturate(180%);
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:20px;margin-bottom:12px;color:var(--accent-light)}
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
- <div class="card">
302
- <h3>📷 上传待检测图片 <span style="color:var(--text-muted)">(支持多选)</span></h3>
 
303
  <div class="upload-zone" id="queryZone">
304
- <input type="file" accept="image/*" multiple id="queryInput" onchange="onQueryChange()">
305
  <div class="icon">🖼️</div>
306
- <div class="hint">点击选择待检测图片(可多选)</div>
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">项目概述</h2>
328
- <p>慧眼同源是一个多模态文档去重与版本溯源系统,能够检测两张或多张不同角度拍摄的图片是否为同一场景或同一文档。</p>
329
- <p>系统自动识别图片类型并路由至专用检测管线文本场景(合同证书等文档)使 PaddleOCR + BGE 语义向量匹配;复杂场景(实景照片)使用 DINOv2 视觉特征匹配。</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
- for (const f of input.files) {
529
- list.innerHTML += `<div>📎 ${f.name} (${(f.size/1024).toFixed(1)} KB)</div>`;
 
 
 
 
 
 
 
 
530
  }
531
  }
532
 
@@ -544,7 +618,8 @@ async function doDetect() {
544
  try {
545
  const fd = new FormData();
546
  fd.append('username', currentUser);
547
- for (const f of input.files) fd.append('files', f);
 
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
- html += `<div style="display:flex;gap:12px;margin:12px 0">`;
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
- t1 = os.path.join(demo_dir, 'demo_text_1.jpg')
100
- t2 = os.path.join(demo_dir, 'demo_text_2.jpg')
101
- if not os.path.exists(t1):
102
- try:
103
- _generate_text_scene(t1, t2)
104
- print("[示例] 已生成文本场景图片")
105
- except Exception as e:
106
- print(f"[示例] 文本场景生成失败: {e}")
107
- return None
108
- text_imgs = [t1, t2]
 
 
 
 
 
 
 
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

  • SHA256: 24cb3d38b2ba4cf4819ffcb40abc51a5c8cec8a2c4f5330b933d672058a98845
  • Pointer size: 131 Bytes
  • Size of remote file: 439 kB
test_imgs/text/text_2.jpg ADDED

Git LFS Details

  • SHA256: 03c4582192186d466255e315117fbd698fc23f5f47e10912a40867f0d2a4c147
  • Pointer size: 130 Bytes
  • Size of remote file: 48.7 kB