jslmmfboom-coder commited on
Commit
405df18
·
1 Parent(s): 9a3a826

Fix visualization: build before delete, add text scene vis, click-to-show

Browse files
Files changed (3) hide show
  1. app.py +162 -88
  2. index.html +92 -113
  3. module/evaluator.py +2 -0
app.py CHANGED
@@ -1,12 +1,13 @@
1
  """FastAPI 主服务 — HF Spaces CPU 部署版
2
 
3
- 改动点(相比原项目)
4
- 1. 端口改为 7860(HF Spaces Docker 默认端口
5
- 2. MASt3R 模型加载返回 None,SceneMatcher 使用 DINOv2-only 模式
6
- 3. Qdrant 使用嵌入式本地文件模式
7
- 4. 新增历史图片管理 API(查看/删除)
8
- 5. 新增图片文件服务 API
9
- 6. detect 响应中包含 patch 匹配可视化数据
 
10
  """
11
  import os
12
  import asyncio
@@ -55,13 +56,11 @@ session_data = {
55
 
56
  resource_monitor = ResourceMonitor(sample_interval=0.5)
57
 
58
- # 用户 Qdrant 缓存: {username: {scene_type: {pid: entry}}}
59
  _qd_caches = {}
60
 
61
 
62
  @asynccontextmanager
63
  async def lifespan(app):
64
- """FastAPI 生命周期管理:启动时加载模型,关闭时写入日志"""
65
  global matcher, dinov2_extractor, ocr_engine, bge_tokenizer, bge_model, session_data
66
 
67
  startup_t0 = time.perf_counter()
@@ -78,7 +77,6 @@ async def lifespan(app):
78
  model_load_time = time.perf_counter() - startup_t0
79
  print(f'[启动] 模型加载完成: {model_load_time:.1f}s')
80
 
81
- # 启动时预加载所有用户 Qdrant 缓存
82
  qdrant_t0 = time.perf_counter()
83
  if os.path.isdir(HISTORY_BASE):
84
  for uname in os.listdir(HISTORY_BASE):
@@ -105,7 +103,7 @@ async def lifespan(app):
105
  qdrant_load_time = time.perf_counter() - qdrant_t0
106
  print(f"[qdrant] 缓存检查完成: {len(_qd_caches)} 个用户 ({qdrant_load_time:.1f}s)")
107
  total_startup = time.perf_counter() - startup_t0
108
- print(f"[启动] 总启动时间: {total_startup:.1f}s")
109
 
110
  yield
111
 
@@ -161,7 +159,6 @@ async def login(username: str = Form(...)):
161
 
162
  @app.get("/api/history")
163
  async def list_history(username: str):
164
- """获取指定用户的历史图片列表"""
165
  try:
166
  uname = validate_username(username)
167
  except ValueError as e:
@@ -184,7 +181,6 @@ async def list_history(username: str):
184
 
185
  @app.get("/api/all_users")
186
  async def list_all_users():
187
- """获取所有用户及其图片数量"""
188
  users = []
189
  if os.path.isdir(HISTORY_BASE):
190
  for uname in sorted(os.listdir(HISTORY_BASE)):
@@ -202,8 +198,6 @@ async def list_all_users():
202
 
203
  @app.get("/api/image/{username}/{filename}")
204
  async def serve_image(username: str, filename: str):
205
- """提供历史图片文件服务"""
206
- # 安全检查:防止路径遍历
207
  uname = validate_username(username)
208
  safe_name = os.path.basename(filename)
209
  user_dir = get_user_dir(uname)
@@ -212,7 +206,6 @@ async def serve_image(username: str, filename: str):
212
  if not os.path.exists(img_path):
213
  raise HTTPException(status_code=404, detail="图片不存在")
214
 
215
- # 验证文件在用户目录内
216
  if not os.path.abspath(img_path).startswith(os.path.abspath(user_dir)):
217
  raise HTTPException(status_code=403, detail="无权访问")
218
 
@@ -221,7 +214,6 @@ async def serve_image(username: str, filename: str):
221
 
222
  @app.post("/api/delete_image")
223
  async def delete_image(username: str = Form(...), filename: str = Form(...)):
224
- """删除指定用户的历史图片(同时删除 Qdrant 中的数据)"""
225
  global _qd_caches
226
  try:
227
  uname = validate_username(username)
@@ -238,10 +230,8 @@ async def delete_image(username: str = Form(...), filename: str = Form(...)):
238
  if not os.path.abspath(img_path).startswith(os.path.abspath(user_dir)):
239
  raise HTTPException(status_code=403, detail="无权操作")
240
 
241
- # 1. 从 Qdrant 中删除
242
  try:
243
  remove_from_qdrant(uname, img_path, scene_type=None)
244
- # 刷新内存缓存
245
  if uname in _qd_caches:
246
  _qd_caches[uname] = {
247
  'text': qdrant_query_scene(uname, 'text'),
@@ -250,10 +240,8 @@ async def delete_image(username: str = Form(...), filename: str = Form(...)):
250
  except Exception as e:
251
  print(f" [删除] Qdrant 清理失败: {e}")
252
 
253
- # 2. 删除文件
254
  delete_file(img_path)
255
 
256
- # 3. 更新 metadata
257
  from module.file_manager import load_metadata, save_metadata
258
  metadata = load_metadata(user_dir)
259
  metadata['images'] = [m for m in metadata['images']
@@ -301,7 +289,6 @@ def _run_detect_pipeline(uploaded_paths, uname, ocr_engine, bge_tokenizer,
301
 
302
  if has_cache:
303
  cache_used = True
304
- print(f"[qdrant] 使用 ANN 搜索管线 (用户 [{uname}], 场景={scene_type_q})")
305
  final_result, evaluation_logs, best_match = evaluate_scene_consistency_cached(
306
  matcher, uploaded_paths, uname,
307
  ocr_engine, bge_tokenizer, bge_model,
@@ -312,7 +299,6 @@ def _run_detect_pipeline(uploaded_paths, uname, ocr_engine, bge_tokenizer,
312
  )
313
 
314
  if not cache_used:
315
- print(f"[qdrant] 缓存不可用,回退到原始管线 (用户 [{uname}])")
316
  final_result, evaluation_logs, best_match = evaluate_scene_consistency(
317
  matcher, uploaded_paths, history_before,
318
  ocr_engine, bge_tokenizer, bge_model,
@@ -323,6 +309,33 @@ def _run_detect_pipeline(uploaded_paths, uname, ocr_engine, bge_tokenizer,
323
  if log['is_same_scene'] and log['query_path'] in uploaded_paths:
324
  same_scene_paths.add(log['query_path'])
325
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  for path in same_scene_paths:
327
  delete_file(path)
328
 
@@ -333,7 +346,7 @@ def _run_detect_pipeline(uploaded_paths, uname, ocr_engine, bge_tokenizer,
333
  else:
334
  per_image_results[path] = '通过' if final_result else '未通过'
335
 
336
- return final_result, evaluation_logs, best_match, scene_type_q, same_scene_paths, per_image_results, cache_used, precomputed_text, precomputed_bge, precomputed_dinov2
337
 
338
 
339
  @app.post("/api/detect")
@@ -390,7 +403,7 @@ async def detect(
390
  ],
391
  "evaluation_logs": [],
392
  "best_match": None,
393
- "gamma_warnings": [],
394
  }
395
 
396
  pre_stats = _snapshot()
@@ -398,7 +411,7 @@ async def detect(
398
  resource_monitor.start_monitoring()
399
 
400
  loop = asyncio.get_event_loop()
401
- final_result, evaluation_logs, best_match, scene_type_q, same_scene_paths, per_image_results, cache_used, precomp_text, precomp_bge, precomp_dino = \
402
  await loop.run_in_executor(
403
  executor, _run_detect_pipeline,
404
  uploaded_paths, uname, ocr_engine, bge_tokenizer,
@@ -408,19 +421,6 @@ async def detect(
408
  peak = resource_monitor.stop_monitoring()
409
  detect_duration = asyncio.get_event_loop().time() - detect_start_time
410
 
411
- n_query = len(uploaded_paths)
412
- n_pairs = len(evaluation_logs)
413
- if peak:
414
- print(f"检测完成 | 耗时: {detect_duration:.1f}s | 查询图: {n_query} | 比对数: {n_pairs} | 缓存: {'ON' if cache_used else 'OFF'}")
415
-
416
- detection_record = {
417
- 'pre_stats': pre_stats,
418
- 'peak': peak,
419
- 'duration_sec': round(detect_duration, 1),
420
- 'cache_used': cache_used,
421
- }
422
- session_data['detections'].append(detection_record)
423
-
424
  kept_saved_info = [(orig, p) for orig, p in saved_info if p not in same_scene_paths]
425
  update_metadata(user_dir, kept_saved_info, per_image_results)
426
 
@@ -450,18 +450,6 @@ async def detect(
450
 
451
  loop.run_in_executor(executor, _post_update_qdrant)
452
 
453
- gamma_warnings = []
454
-
455
- # 构建可视化数据:对判定为同一场景的日志,生成 patch 匹配可视化图
456
- visualization_data = []
457
- for log in evaluation_logs:
458
- if log.get('is_same_scene') and log.get('scene_type') == 'complex':
459
- patch_info = log.get('patch_match_info')
460
- if patch_info:
461
- vis = _build_visualization(log, patch_info, uname)
462
- if vis:
463
- visualization_data.append(vis)
464
-
465
  best_match_response = None
466
  if best_match:
467
  best_match_response = {
@@ -471,7 +459,6 @@ async def detect(
471
  'match_count': best_match['match_count'],
472
  'inlier_ratio': best_match['inlier_ratio'],
473
  'avg_confidence': best_match['avg_confidence'],
474
- 'gamma_info': best_match.get('gamma_info', []),
475
  }
476
 
477
  return {
@@ -485,15 +472,8 @@ async def detect(
485
  "query_image": log['query_image'],
486
  "history_image": log['history_image'],
487
  "scene_type": log.get('scene_type', 'complex'),
488
- "doc_score1": log.get('doc_score1', 0),
489
- "doc_score2": log.get('doc_score2', 0),
490
  "similarity_score": log['similarity_score'],
491
  "match_count": log['match_count'],
492
- "raw_match_count": log.get('raw_match_count', 0),
493
- "inlier_ratio": log['inlier_ratio'],
494
- "avg_confidence": log['avg_confidence'],
495
- "mast3r_is_same": log.get('mast3r_is_same'),
496
- "dinov2_is_same": log.get('dinov2_is_same'),
497
  "dinov2_similarity": log.get('dinov2_similarity'),
498
  "text_similarity": log.get('text_similarity'),
499
  "bge_search_score": log.get('bge_search_score'),
@@ -503,20 +483,15 @@ async def detect(
503
  for log in evaluation_logs
504
  ],
505
  "best_match": best_match_response,
506
- "gamma_warnings": gamma_warnings,
507
  "visualizations": visualization_data,
508
  }
509
 
510
 
511
- def _build_visualization(log_entry, patch_info, username):
512
- """根据 patch 匹配信息生成可视化图(base64 编码)
513
-
514
- 在两张图片上绘制 patch 匹配连线,展示匹配内点。
515
- """
516
  try:
517
  import cv2
518
  import numpy as np
519
- from PIL import Image
520
 
521
  q_path = log_entry.get('query_path', '')
522
  h_path = log_entry.get('history_path', '')
@@ -524,13 +499,11 @@ def _build_visualization(log_entry, patch_info, username):
524
  if not os.path.exists(q_path) or not os.path.exists(h_path):
525
  return None
526
 
527
- # 读取图片
528
- img1 = cv2.imread(q_path)
529
- img2 = cv2.imread(h_path)
530
  if img1 is None or img2 is None:
531
  return None
532
 
533
- # 缩放到统一高度
534
  max_h = 400
535
  scale1 = max_h / img1.shape[0]
536
  scale2 = max_h / img2.shape[0]
@@ -540,81 +513,68 @@ def _build_visualization(log_entry, patch_info, username):
540
  h1, w1 = img1.shape[:2]
541
  h2, w2 = img2.shape[:2]
542
 
543
- # 获取 patch 匹配信息
544
  matches = patch_info.get('matches', [])
545
  q_grid = patch_info.get('query_grid', (37, 37))
546
  h_grid = patch_info.get('hist_grid', (37, 37))
547
  q_orig_size = patch_info.get('query_image_size', (w1, h1))
548
  h_orig_size = patch_info.get('hist_image_size', (w2, h2))
549
 
550
- # 将 patch 索引转换为像素坐标(patch 中心点)
551
  q_n_h, q_n_w = q_grid
552
  h_n_h, h_n_w = h_grid
553
 
554
- # 原始图片中每个 patch 覆盖的像素区域
555
  q_pw = q_orig_size[0] / q_n_w
556
  q_ph = q_orig_size[1] / q_n_h
557
  h_pw = h_orig_size[0] / h_n_w
558
  h_ph = h_orig_size[1] / h_n_h
559
 
560
- # 缩放后的 patch 尺寸
561
  q_pw_s = q_pw * scale1
562
  q_ph_s = q_ph * scale1
563
  h_pw_s = h_pw * scale2
564
  h_ph_s = h_ph * scale2
565
 
566
- # 拼接两张图片(水平排列)
567
  gap = 20
568
- canvas = np.ones((max(max_h, max_h), w1 + gap + w2, 3), dtype=np.uint8) * 240
569
  canvas[:h1, :w1] = img1
570
  canvas[:h2, w1+gap:] = img2
571
 
572
- # 绘制匹配连线和 patch 区域
573
  for qi, hi, sim in matches:
574
- # patch 索引 → 行列
575
  q_row, q_col = qi // q_n_w, qi % q_n_w
576
  h_row, h_col = hi // h_n_w, hi % h_n_w
577
 
578
- # patch 中心坐标(缩放后)
579
  q_cx = int((q_col + 0.5) * q_pw_s)
580
  q_cy = int((q_row + 0.5) * q_ph_s)
581
  h_cx = int(w1 + gap + (h_col + 0.5) * h_pw_s)
582
  h_cy = int((h_row + 0.5) * h_ph_s)
583
 
584
- # 颜色:相似度越高越绿,越低越黄
585
  intensity = min(1.0, max(0.0, (sim - 0.3) / 0.7))
586
  color = (0, int(200 * intensity + 55), int(255 * (1 - intensity)))
587
 
588
- # 绘制连线
589
  cv2.line(canvas, (q_cx, q_cy), (h_cx, h_cy), color, 1, cv2.LINE_AA)
590
 
591
- # 绘制 patch 矩形(左图)
592
  q_x1 = int(q_col * q_pw_s)
593
  q_y1 = int(q_row * q_ph_s)
594
  cv2.rectangle(canvas, (q_x1, q_y1),
595
  (min(q_x1 + int(q_pw_s), w1-1), min(q_y1 + int(q_ph_s), h1-1)),
596
  color, 1)
597
 
598
- # 绘制 patch 矩形(右图)
599
  h_x1 = int(w1 + gap + h_col * h_pw_s)
600
  h_y1 = int(h_row * h_ph_s)
601
  cv2.rectangle(canvas, (h_x1, h_y1),
602
  (min(h_x1 + int(h_pw_s), w1+gap+w2-1), min(h_y1 + int(h_ph_s), h2-1)),
603
  color, 1)
604
 
605
- # 添加文字��注
606
  n_matches = len(matches)
607
  avg_sim = sum(s for _, _, s in matches) / max(n_matches, 1)
608
- cv2.putText(canvas, f'Query', (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0,0,0), 2)
609
- cv2.putText(canvas, f'History', (w1+gap+10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0,0,0), 2)
610
  cv2.putText(canvas, f'Patch matches: {n_matches} Avg sim: {avg_sim:.3f}',
611
  (10, max_h - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0,0,180), 1)
612
 
613
- # 编码为 base64
614
  _, buffer = cv2.imencode('.jpg', canvas, [cv2.IMWRITE_JPEG_QUALITY, 85])
615
  img_b64 = base64.b64encode(buffer).decode('utf-8')
616
 
617
  return {
 
618
  'query_image': log_entry['query_image'],
619
  'history_image': log_entry['history_image'],
620
  'dinov2_similarity': patch_info.get('cls_similarity', 0),
@@ -623,7 +583,121 @@ def _build_visualization(log_entry, patch_info, username):
623
  'image_base64': f'data:image/jpeg;base64,{img_b64}',
624
  }
625
  except Exception as e:
626
- print(f" [可视化] 生成失败: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
627
  return None
628
 
629
 
 
1
  """FastAPI 主服务 — HF Spaces CPU 部署版
2
 
3
+ 改动点:
4
+ 1. 端口 7860(HF Spaces)
5
+ 2. MASt3R 已禁用,SceneMatcher 使用 DINOv2-only
6
+ 3. Qdrant 嵌入式本地文件模式
7
+ 4. 历史图片管理 API
8
+ 5. 图片文件服务 API
9
+ 6. 可视化在删除文件之前生成,支持复杂+文本场景
10
+ 7. 可视化按需展示(前端点击按钮触发)
11
  """
12
  import os
13
  import asyncio
 
56
 
57
  resource_monitor = ResourceMonitor(sample_interval=0.5)
58
 
 
59
  _qd_caches = {}
60
 
61
 
62
  @asynccontextmanager
63
  async def lifespan(app):
 
64
  global matcher, dinov2_extractor, ocr_engine, bge_tokenizer, bge_model, session_data
65
 
66
  startup_t0 = time.perf_counter()
 
77
  model_load_time = time.perf_counter() - startup_t0
78
  print(f'[启动] 模型加载完成: {model_load_time:.1f}s')
79
 
 
80
  qdrant_t0 = time.perf_counter()
81
  if os.path.isdir(HISTORY_BASE):
82
  for uname in os.listdir(HISTORY_BASE):
 
103
  qdrant_load_time = time.perf_counter() - qdrant_t0
104
  print(f"[qdrant] 缓存检查完成: {len(_qd_caches)} 个用户 ({qdrant_load_time:.1f}s)")
105
  total_startup = time.perf_counter() - startup_t0
106
+ print(f'[启动] 总启动时间: {total_startup:.1f}s')
107
 
108
  yield
109
 
 
159
 
160
  @app.get("/api/history")
161
  async def list_history(username: str):
 
162
  try:
163
  uname = validate_username(username)
164
  except ValueError as e:
 
181
 
182
  @app.get("/api/all_users")
183
  async def list_all_users():
 
184
  users = []
185
  if os.path.isdir(HISTORY_BASE):
186
  for uname in sorted(os.listdir(HISTORY_BASE)):
 
198
 
199
  @app.get("/api/image/{username}/{filename}")
200
  async def serve_image(username: str, filename: str):
 
 
201
  uname = validate_username(username)
202
  safe_name = os.path.basename(filename)
203
  user_dir = get_user_dir(uname)
 
206
  if not os.path.exists(img_path):
207
  raise HTTPException(status_code=404, detail="图片不存在")
208
 
 
209
  if not os.path.abspath(img_path).startswith(os.path.abspath(user_dir)):
210
  raise HTTPException(status_code=403, detail="无权访问")
211
 
 
214
 
215
  @app.post("/api/delete_image")
216
  async def delete_image(username: str = Form(...), filename: str = Form(...)):
 
217
  global _qd_caches
218
  try:
219
  uname = validate_username(username)
 
230
  if not os.path.abspath(img_path).startswith(os.path.abspath(user_dir)):
231
  raise HTTPException(status_code=403, detail="无权操作")
232
 
 
233
  try:
234
  remove_from_qdrant(uname, img_path, scene_type=None)
 
235
  if uname in _qd_caches:
236
  _qd_caches[uname] = {
237
  'text': qdrant_query_scene(uname, 'text'),
 
240
  except Exception as e:
241
  print(f" [删除] Qdrant 清理失败: {e}")
242
 
 
243
  delete_file(img_path)
244
 
 
245
  from module.file_manager import load_metadata, save_metadata
246
  metadata = load_metadata(user_dir)
247
  metadata['images'] = [m for m in metadata['images']
 
289
 
290
  if has_cache:
291
  cache_used = True
 
292
  final_result, evaluation_logs, best_match = evaluate_scene_consistency_cached(
293
  matcher, uploaded_paths, uname,
294
  ocr_engine, bge_tokenizer, bge_model,
 
299
  )
300
 
301
  if not cache_used:
 
302
  final_result, evaluation_logs, best_match = evaluate_scene_consistency(
303
  matcher, uploaded_paths, history_before,
304
  ocr_engine, bge_tokenizer, bge_model,
 
309
  if log['is_same_scene'] and log['query_path'] in uploaded_paths:
310
  same_scene_paths.add(log['query_path'])
311
 
312
+ # ★★★ 关键修复:先构建可视化,再删除文件 ★★★
313
+ visualization_data = []
314
+ for log in evaluation_logs:
315
+ if log.get('is_same_scene'):
316
+ scene_type = log.get('scene_type', 'complex')
317
+ if scene_type == 'complex':
318
+ patch_info = log.get('patch_match_info')
319
+ if patch_info is None and dinov2_extractor is not None:
320
+ # patch_match_info 可能为 None(dinov2_sim_override 模式下)
321
+ # 重新用模型推理获取 patch 匹配
322
+ q_path = log.get('query_path', '')
323
+ h_path = log.get('history_path', '')
324
+ if os.path.exists(q_path) and os.path.exists(h_path):
325
+ try:
326
+ patch_info = dinov2_extractor.compute_patch_matches(q_path, h_path, top_k=50)
327
+ except Exception as e:
328
+ print(f" [可视化] 重新计算 patch 匹配失败: {e}")
329
+ if patch_info:
330
+ vis = _build_complex_visualization(log, patch_info)
331
+ if vis:
332
+ visualization_data.append(vis)
333
+ elif scene_type == 'text':
334
+ vis = _build_text_visualization(log)
335
+ if vis:
336
+ visualization_data.append(vis)
337
+
338
+ # 现在才删除同一场景的文件
339
  for path in same_scene_paths:
340
  delete_file(path)
341
 
 
346
  else:
347
  per_image_results[path] = '通过' if final_result else '未通过'
348
 
349
+ return final_result, evaluation_logs, best_match, scene_type_q, same_scene_paths, per_image_results, cache_used, precomputed_text, precomputed_bge, precomputed_dinov2, visualization_data
350
 
351
 
352
  @app.post("/api/detect")
 
403
  ],
404
  "evaluation_logs": [],
405
  "best_match": None,
406
+ "visualizations": [],
407
  }
408
 
409
  pre_stats = _snapshot()
 
411
  resource_monitor.start_monitoring()
412
 
413
  loop = asyncio.get_event_loop()
414
+ final_result, evaluation_logs, best_match, scene_type_q, same_scene_paths, per_image_results, cache_used, precomp_text, precomp_bge, precomp_dino, visualization_data = \
415
  await loop.run_in_executor(
416
  executor, _run_detect_pipeline,
417
  uploaded_paths, uname, ocr_engine, bge_tokenizer,
 
421
  peak = resource_monitor.stop_monitoring()
422
  detect_duration = asyncio.get_event_loop().time() - detect_start_time
423
 
 
 
 
 
 
 
 
 
 
 
 
 
 
424
  kept_saved_info = [(orig, p) for orig, p in saved_info if p not in same_scene_paths]
425
  update_metadata(user_dir, kept_saved_info, per_image_results)
426
 
 
450
 
451
  loop.run_in_executor(executor, _post_update_qdrant)
452
 
 
 
 
 
 
 
 
 
 
 
 
 
453
  best_match_response = None
454
  if best_match:
455
  best_match_response = {
 
459
  'match_count': best_match['match_count'],
460
  'inlier_ratio': best_match['inlier_ratio'],
461
  'avg_confidence': best_match['avg_confidence'],
 
462
  }
463
 
464
  return {
 
472
  "query_image": log['query_image'],
473
  "history_image": log['history_image'],
474
  "scene_type": log.get('scene_type', 'complex'),
 
 
475
  "similarity_score": log['similarity_score'],
476
  "match_count": log['match_count'],
 
 
 
 
 
477
  "dinov2_similarity": log.get('dinov2_similarity'),
478
  "text_similarity": log.get('text_similarity'),
479
  "bge_search_score": log.get('bge_search_score'),
 
483
  for log in evaluation_logs
484
  ],
485
  "best_match": best_match_response,
 
486
  "visualizations": visualization_data,
487
  }
488
 
489
 
490
+ def _build_complex_visualization(log_entry, patch_info):
491
+ """复杂场景可视化:DINOv2 patch 匹配连线和矩形框"""
 
 
 
492
  try:
493
  import cv2
494
  import numpy as np
 
495
 
496
  q_path = log_entry.get('query_path', '')
497
  h_path = log_entry.get('history_path', '')
 
499
  if not os.path.exists(q_path) or not os.path.exists(h_path):
500
  return None
501
 
502
+ img1 = cv2.imdecode(np.fromfile(q_path, dtype=np.uint8), cv2.IMREAD_COLOR)
503
+ img2 = cv2.imdecode(np.fromfile(h_path, dtype=np.uint8), cv2.IMREAD_COLOR)
 
504
  if img1 is None or img2 is None:
505
  return None
506
 
 
507
  max_h = 400
508
  scale1 = max_h / img1.shape[0]
509
  scale2 = max_h / img2.shape[0]
 
513
  h1, w1 = img1.shape[:2]
514
  h2, w2 = img2.shape[:2]
515
 
 
516
  matches = patch_info.get('matches', [])
517
  q_grid = patch_info.get('query_grid', (37, 37))
518
  h_grid = patch_info.get('hist_grid', (37, 37))
519
  q_orig_size = patch_info.get('query_image_size', (w1, h1))
520
  h_orig_size = patch_info.get('hist_image_size', (w2, h2))
521
 
 
522
  q_n_h, q_n_w = q_grid
523
  h_n_h, h_n_w = h_grid
524
 
 
525
  q_pw = q_orig_size[0] / q_n_w
526
  q_ph = q_orig_size[1] / q_n_h
527
  h_pw = h_orig_size[0] / h_n_w
528
  h_ph = h_orig_size[1] / h_n_h
529
 
 
530
  q_pw_s = q_pw * scale1
531
  q_ph_s = q_ph * scale1
532
  h_pw_s = h_pw * scale2
533
  h_ph_s = h_ph * scale2
534
 
 
535
  gap = 20
536
+ canvas = np.ones((max_h, w1 + gap + w2, 3), dtype=np.uint8) * 240
537
  canvas[:h1, :w1] = img1
538
  canvas[:h2, w1+gap:] = img2
539
 
 
540
  for qi, hi, sim in matches:
 
541
  q_row, q_col = qi // q_n_w, qi % q_n_w
542
  h_row, h_col = hi // h_n_w, hi % h_n_w
543
 
 
544
  q_cx = int((q_col + 0.5) * q_pw_s)
545
  q_cy = int((q_row + 0.5) * q_ph_s)
546
  h_cx = int(w1 + gap + (h_col + 0.5) * h_pw_s)
547
  h_cy = int((h_row + 0.5) * h_ph_s)
548
 
 
549
  intensity = min(1.0, max(0.0, (sim - 0.3) / 0.7))
550
  color = (0, int(200 * intensity + 55), int(255 * (1 - intensity)))
551
 
 
552
  cv2.line(canvas, (q_cx, q_cy), (h_cx, h_cy), color, 1, cv2.LINE_AA)
553
 
 
554
  q_x1 = int(q_col * q_pw_s)
555
  q_y1 = int(q_row * q_ph_s)
556
  cv2.rectangle(canvas, (q_x1, q_y1),
557
  (min(q_x1 + int(q_pw_s), w1-1), min(q_y1 + int(q_ph_s), h1-1)),
558
  color, 1)
559
 
 
560
  h_x1 = int(w1 + gap + h_col * h_pw_s)
561
  h_y1 = int(h_row * h_ph_s)
562
  cv2.rectangle(canvas, (h_x1, h_y1),
563
  (min(h_x1 + int(h_pw_s), w1+gap+w2-1), min(h_y1 + int(h_ph_s), h2-1)),
564
  color, 1)
565
 
 
566
  n_matches = len(matches)
567
  avg_sim = sum(s for _, _, s in matches) / max(n_matches, 1)
568
+ cv2.putText(canvas, 'Query', (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0,0,0), 2)
569
+ cv2.putText(canvas, 'History', (w1+gap+10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0,0,0), 2)
570
  cv2.putText(canvas, f'Patch matches: {n_matches} Avg sim: {avg_sim:.3f}',
571
  (10, max_h - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0,0,180), 1)
572
 
 
573
  _, buffer = cv2.imencode('.jpg', canvas, [cv2.IMWRITE_JPEG_QUALITY, 85])
574
  img_b64 = base64.b64encode(buffer).decode('utf-8')
575
 
576
  return {
577
+ 'scene_type': 'complex',
578
  'query_image': log_entry['query_image'],
579
  'history_image': log_entry['history_image'],
580
  'dinov2_similarity': patch_info.get('cls_similarity', 0),
 
583
  'image_base64': f'data:image/jpeg;base64,{img_b64}',
584
  }
585
  except Exception as e:
586
+ print(f" [可视化-复杂] 生成失败: {e}")
587
+ return None
588
+
589
+
590
+ def _build_text_visualization(log_entry):
591
+ """文本场景可视化:OCR 文本对比 + 关键词高亮
592
+
593
+ 展示两张图片的 OCR 文本,用颜色标注:
594
+ - 绿色:两张图都有的关键词(匹配词)
595
+ - 红色:仅在一张图中出现的关键词(差异词)
596
+ 同时展示两张图的缩略图并排。
597
+ """
598
+ try:
599
+ import cv2
600
+ import numpy as np
601
+
602
+ q_path = log_entry.get('query_path', '')
603
+ h_path = log_entry.get('history_path', '')
604
+
605
+ if not os.path.exists(q_path) or not os.path.exists(h_path):
606
+ return None
607
+
608
+ img1 = cv2.imdecode(np.fromfile(q_path, dtype=np.uint8), cv2.IMREAD_COLOR)
609
+ img2 = cv2.imdecode(np.fromfile(h_path, dtype=np.uint8), cv2.IMREAD_COLOR)
610
+ if img1 is None or img2 is None:
611
+ return None
612
+
613
+ text1 = log_entry.get('query_text', '')
614
+ text2 = log_entry.get('history_text', '')
615
+ text_sim = log_entry.get('text_similarity', 0)
616
+
617
+ # 提取关键词(按空格和标点分词)
618
+ import re
619
+ words1 = set(re.findall(r'[a-zA-Z\u4e00-\u9fff]{2,}', text1))
620
+ words2 = set(re.findall(r'[a-zA-Z\u4e00-\u9fff]{2,}', text2))
621
+ common_words = words1 & words2
622
+ diff_words1 = words1 - words2
623
+ diff_words2 = words2 - words1
624
+
625
+ # 缩放图片
626
+ max_h = 300
627
+ scale1 = max_h / img1.shape[0]
628
+ scale2 = max_h / img2.shape[0]
629
+ img1_s = cv2.resize(img1, (int(img1.shape[1]*scale1), max_h))
630
+ img2_s = cv2.resize(img2, (int(img2.shape[1]*scale2), max_h))
631
+
632
+ h1, w1 = img1_s.shape[:2]
633
+ h2, w2 = img2_s.shape[:2]
634
+
635
+ # 构建画布:上方两张图并排,下方文本对比
636
+ text_area_h = 200
637
+ gap = 10
638
+ total_w = max(w1 + gap + w2, 600)
639
+ canvas = np.ones((max_h + text_area_h, total_w, 3), dtype=np.uint8) * 240
640
+
641
+ # 上方放图片
642
+ canvas[:max_h, :w1] = img1_s
643
+ canvas[:max_h, w1+gap:w1+gap+w2] = img2_s
644
+
645
+ # 图片上方标注
646
+ cv2.putText(canvas, 'Query (OCR text)', (10, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0,0,0), 1)
647
+ cv2.putText(canvas, 'History (OCR text)', (w1+gap+10, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0,0,0), 1)
648
+
649
+ # 分隔线
650
+ cv2.line(canvas, (0, max_h), (total_w, max_h), (100,100,100), 2)
651
+
652
+ # 下方文本区域
653
+ y_off = max_h + 10
654
+
655
+ # BGE 相似度
656
+ cv2.putText(canvas, f'BGE Semantic Similarity: {text_sim:.4f} (threshold: 0.85)',
657
+ (10, y_off), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0,0,180), 2)
658
+ y_off += 25
659
+
660
+ # OCR 文本对比
661
+ cv2.putText(canvas, 'Query OCR:', (10, y_off), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0,100,255), 1)
662
+ y_off += 20
663
+ # 截断过长文本
664
+ disp_text1 = text1[:200] + '...' if len(text1) > 200 else text1
665
+ disp_text2 = text2[:200] + '...' if len(text2) > 200 else text2
666
+
667
+ cv2.putText(canvas, disp_text1, (10, y_off), cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0,0,0), 1)
668
+ y_off += 20
669
+
670
+ cv2.putText(canvas, 'History OCR:', (10, y_off), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0,100,255), 1)
671
+ y_off += 20
672
+ cv2.putText(canvas, disp_text2, (10, y_off), cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0,0,0), 1)
673
+ y_off += 20
674
+
675
+ # 关键词统计
676
+ cv2.putText(canvas, f'Common keywords: {len(common_words)} Unique(query): {len(diff_words1)} Unique(hist): {len(diff_words2)}',
677
+ (10, y_off), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0,150,0), 1)
678
+
679
+ # 显示部分共同关键词
680
+ y_off += 20
681
+ common_str = ' '.join(list(common_words)[:15])
682
+ if common_str:
683
+ cv2.putText(canvas, f'Match: {common_str}', (10, y_off), cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0,180,0), 1)
684
+
685
+ _, buffer = cv2.imencode('.jpg', canvas, [cv2.IMWRITE_JPEG_QUALITY, 85])
686
+ img_b64 = base64.b64encode(buffer).decode('utf-8')
687
+
688
+ return {
689
+ 'scene_type': 'text',
690
+ 'query_image': log_entry['query_image'],
691
+ 'history_image': log_entry['history_image'],
692
+ 'text_similarity': round(text_sim, 4),
693
+ 'common_keyword_count': len(common_words),
694
+ 'diff_keyword_count_query': len(diff_words1),
695
+ 'diff_keyword_count_history': len(diff_words2),
696
+ 'common_keywords': list(common_words)[:20],
697
+ 'image_base64': f'data:image/jpeg;base64,{img_b64}',
698
+ }
699
+ except Exception as e:
700
+ print(f" [可视化-文本] 生成失败: {e}")
701
  return None
702
 
703
 
index.html CHANGED
@@ -8,28 +8,22 @@
8
  *{margin:0;padding:0;box-sizing:border-box}
9
  body{font-family:'Segoe UI','Microsoft YaHei',sans-serif;background:#f0f2f5;min-height:100vh;color:#333}
10
  .container{max-width:960px;margin:0 auto;padding:20px}
11
-
12
  .header{background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;padding:20px 30px;border-radius:12px;margin-bottom:20px;text-align:center}
13
  .header h1{font-size:22px;margin-bottom:4px}
14
  .header p{font-size:13px;opacity:.85}
15
-
16
  .status-bar{background:#fff;border-radius:10px;padding:14px 20px;margin-bottom:16px;display:flex;align-items:center;justify-content:space-between;box-shadow:0 2px 8px rgba(0,0,0,.06);flex-wrap:wrap;gap:10px}
17
  .status-info{display:flex;gap:20px;flex-wrap:wrap;font-size:14px}
18
  .status-info span{display:flex;align-items:center;gap:4px}
19
  .status-actions{display:flex;gap:8px}
20
-
21
  .card{background:#fff;border-radius:10px;padding:20px;margin-bottom:16px;box-shadow:0 2px 8px rgba(0,0,0,.06)}
22
  .card h3{font-size:15px;margin-bottom:12px;color:#555}
23
-
24
  .upload-zone{border:2px dashed #d9d9d9;border-radius:8px;padding:24px;text-align:center;cursor:pointer;transition:all .2s;position:relative}
25
  .upload-zone:hover{border-color:#667eea;background:#f8f8ff}
26
  .upload-zone input{position:absolute;inset:0;opacity:0;cursor:pointer}
27
  .upload-zone .icon{font-size:32px;margin-bottom:8px}
28
  .upload-zone .hint{font-size:13px;color:#999}
29
-
30
  .file-list{margin-top:10px;font-size:13px;color:#666}
31
  .file-list div{padding:3px 0}
32
-
33
  .btn{padding:10px 20px;border:none;border-radius:8px;font-size:14px;cursor:pointer;transition:all .15s;font-weight:500}
34
  .btn:disabled{opacity:.5;cursor:not-allowed}
35
  .btn-primary{background:linear-gradient(135deg,#667eea,#764ba2);color:#fff}
@@ -41,38 +35,30 @@ body{font-family:'Segoe UI','Microsoft YaHei',sans-serif;background:#f0f2f5;min-
41
  .btn-sm{padding:6px 12px;font-size:12px}
42
  .btn-success{background:#28a745;color:#fff}
43
  .btn-success:hover:not(:disabled){background:#218838}
44
-
 
45
  .result-pass{background:#d4edda;border:2px solid #28a745;border-radius:10px;padding:20px;text-align:center;margin-bottom:16px}
46
  .result-pass h2{color:#155724;font-size:20px}
47
  .result-pass p{color:#155724;font-size:14px;margin-top:4px}
48
-
49
  .result-fail{background:#f8d7da;border:2px solid #dc3545;border-radius:10px;padding:20px;text-align:center;margin-bottom:16px}
50
  .result-fail h2{color:#721c24;font-size:20px}
51
  .result-fail p{color:#721c24;font-size:14px;margin-top:4px}
52
-
53
  table{width:100%;border-collapse:collapse;font-size:13px;margin-top:12px}
54
  th{background:#343a40;color:#fff;padding:10px 8px;text-align:center;font-weight:500}
55
  td{padding:8px;text-align:center;border-bottom:1px solid #eee}
56
  tr.row-fail{background:#fff5f5}
57
  tr.row-fail td{color:#721c24}
58
-
59
  .deleted-info{background:#fff5f5;border:1px solid #dc3545;border-radius:8px;padding:10px 16px;margin-bottom:12px;font-size:13px;color:#721c24}
60
-
61
  .loading-overlay{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:999}
62
  .loading-box{background:#fff;border-radius:12px;padding:30px 40px;text-align:center;box-shadow:0 8px 30px rgba(0,0,0,.2)}
63
  .spinner{width:40px;height:40px;border:4px solid #e0e0e0;border-top-color:#667eea;border-radius:50%;animation:spin .8s linear infinite;margin:0 auto 12px}
64
  @keyframes spin{to{transform:rotate(360deg)}}
65
-
66
  .login-card{background:#fff;border-radius:12px;padding:40px;max-width:400px;margin:80px auto;box-shadow:0 4px 20px rgba(0,0,0,.1);text-align:center}
67
  .login-card h2{margin-bottom:20px;color:#333}
68
  .login-card input{width:100%;padding:12px 16px;border:2px solid #e0e0e0;border-radius:8px;font-size:15px;margin-bottom:16px;outline:none;transition:border-color .2s}
69
  .login-card input:focus{border-color:#667eea}
70
-
71
  .error-msg{color:#dc3545;font-size:13px;margin-top:4px;min-height:18px}
72
-
73
  .hidden{display:none!important}
74
-
75
- /* 历史图片管理 */
76
  .history-panel{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;display:flex;align-items:center;justify-content:center}
77
  .history-modal{background:#fff;border-radius:12px;padding:24px;max-width:800px;width:95%;max-height:85vh;overflow-y:auto;box-shadow:0 8px 30px rgba(0,0,0,.3)}
78
  .history-modal h2{margin-bottom:16px;font-size:18px}
@@ -90,13 +76,17 @@ tr.row-fail td{color:#721c24}
90
  .img-card .del-btn:hover{background:#dc3545}
91
  .close-btn{float:right;background:none;border:none;font-size:24px;cursor:pointer;color:#999;line-height:1}
92
  .close-btn:hover{color:#333}
93
-
94
- /* 可视化 */
95
- .vis-card{background:#fff;border-radius:10px;padding:16px;margin-bottom:16px;box-shadow:0 2px 8px rgba(0,0,0,.06)}
96
- .vis-card h4{font-size:14px;color:#555;margin-bottom:8px}
97
- .vis-card img{width:100%;border-radius:6px;border:1px solid #eee}
98
- .vis-stats{display:flex;gap:16px;font-size:13px;color:#666;margin-top:8px}
 
99
  .vis-stats b{color:#333}
 
 
 
100
  </style>
101
  </head>
102
  <body>
@@ -146,7 +136,6 @@ tr.row-fail td{color:#721c24}
146
  <div id="resultArea"></div>
147
  </div>
148
 
149
- <!-- 历史图片管理面板 -->
150
  <div id="historyPanel" class="history-panel hidden">
151
  <div class="history-modal">
152
  <button class="close-btn" onclick="closeHistory()">&times;</button>
@@ -176,22 +165,15 @@ async function doLogin() {
176
  const errEl = document.getElementById('loginError');
177
  errEl.textContent = '';
178
  if (!name) { errEl.textContent = '用户名不能为空'; return; }
179
-
180
  try {
181
  const fd = new FormData();
182
  fd.append('username', name);
183
  const res = await fetch('/api/login', { method: 'POST', body: fd });
184
- if (!res.ok) {
185
- const d = await res.json();
186
- errEl.textContent = d.detail || '登录失败';
187
- return;
188
- }
189
  const data = await res.json();
190
  currentUser = data.username;
191
  showMain(data);
192
- } catch (e) {
193
- errEl.textContent = '网络错误: ' + e.message;
194
- }
195
  }
196
 
197
  function showMain(data) {
@@ -212,51 +194,31 @@ function onQueryChange() {
212
  const files = document.getElementById('queryInput').files;
213
  const list = document.getElementById('queryFileList');
214
  list.innerHTML = '';
215
- for (const f of files) {
216
- list.innerHTML += `<div>📄 ${f.name} (${(f.size/1024).toFixed(1)} KB)</div>`;
217
- }
218
  }
219
 
220
  async function doDetect() {
221
  const queryFiles = document.getElementById('queryInput').files;
222
- if (queryFiles.length === 0) {
223
- alert('请上传至少一张图片!');
224
- return;
225
- }
226
-
227
  const fd = new FormData();
228
  fd.append('username', currentUser);
229
  for (const f of queryFiles) fd.append('images', f);
230
-
231
  showLoading('正在进行场景比对,请稍候...');
232
  document.getElementById('detectBtn').disabled = true;
233
-
234
  try {
235
  const res = await fetch('/api/detect', { method: 'POST', body: fd });
236
- if (!res.ok) {
237
- const d = await res.json();
238
- alert('检测失败: ' + (d.detail || '未知错误'));
239
- return;
240
- }
241
  const data = await res.json();
242
  renderResult(data);
243
- // 更新历史图片数量
244
  refreshHistoryCount();
245
- } catch (e) {
246
- alert('网络错误: ' + e.message);
247
- } finally {
248
- hideLoading();
249
- document.getElementById('detectBtn').disabled = false;
250
- }
251
  }
252
 
253
  async function refreshHistoryCount() {
254
  try {
255
  const res = await fetch(`/api/history?username=${encodeURIComponent(currentUser)}`);
256
- if (res.ok) {
257
- const data = await res.json();
258
- document.getElementById('dispCount').textContent = data.count;
259
- }
260
  } catch (e) {}
261
  }
262
 
@@ -279,53 +241,89 @@ function renderResult(data) {
279
  html += `<div class="deleted-info">🗑️ <b>已删除同一场景图片:</b> ${deletedFiles.map(f => f.original_name).join('、')}</div>`;
280
  }
281
 
282
- // 可视化展示
283
- if (data.visualizations && data.visualizations.length > 0) {
284
- html += `<div class="card"><h3>🔍 匹配可视化证据</h3>`;
285
- for (const vis of data.visualizations) {
286
- html += `<div class="vis-card">`;
287
- html += `<h4>${vis.query_image} ↔ ${vis.history_image}</h4>`;
288
- html += `<img src="${vis.image_base64}" alt="patch匹配可视化">`;
289
- html += `<div class="vis-stats">`;
290
- html += `<span>DINOv2相似度: <b>${vis.dinov2_similarity}</b></span>`;
291
- html += `<span>Patch匹配数: <b>${vis.patch_match_count}</b></span>`;
292
- html += `<span>平均Patch相似度: <b>${vis.avg_patch_similarity}</b></span>`;
293
- html += `</div></div>`;
294
- }
295
- html += `</div>`;
296
- }
297
-
298
  if (data.evaluation_logs && data.evaluation_logs.length > 0) {
299
  html += `<div class="card"><h3>📋 比对详情日志</h3><table>`;
300
- html += `<thead><tr><th>待检测</th><th>历史</th><th>场景</th><th>DINOv2相似度</th><th>文本相似</th><th>BGE搜索</th><th>最终判定</th></tr></thead><tbody>`;
301
-
302
  for (const log of data.evaluation_logs) {
303
  const rowClass = log.is_same_scene ? ' class="row-fail"' : '';
304
- const truncName = (n, max=14) => n.length > max ? n.substring(0, max-2) + '..' : n;
305
- const qName = truncName(log.query_image);
306
- const hName = truncName(log.history_image);
307
- const sceneLabel = log.scene_type === 'text' ? '📄 文本' : '🖼️ 复杂';
308
- const dinov2Val = log.dinov2_similarity != null ? log.dinov2_similarity.toFixed(3) : '-';
309
- const textSim = log.text_similarity != null ? log.text_similarity.toFixed(3) : '-';
310
- const bgeSearchVal = log.bge_search_score != null ? log.bge_search_score.toFixed(4) : '-';
311
- const finalStatus = log.is_same_scene ? '❌ 同一' : '✅ 不同';
312
- const errNote = log.error ? ` <span style="color:red;font-size:11px">(${log.error.substring(0,30)})</span>` : '';
313
  html += `<tr${rowClass}>`;
314
- html += `<td title="${log.query_image}">${qName}</td>`;
315
- html += `<td title="${log.history_image}">${hName}</td>`;
316
- html += `<td style="font-size:12px">${sceneLabel}</td>`;
317
- html += `<td>${dinov2Val}</td>`;
318
- html += `<td>${textSim}</td>`;
319
- html += `<td>${bgeSearchVal}</td>`;
320
- html += `<td>${finalStatus}${errNote}</td>`;
321
  html += `</tr>`;
322
  }
323
  html += `</tbody></table></div>`;
324
  }
325
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  area.innerHTML = html;
327
  }
328
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  // ==================== 历史图片管理 ====================
330
 
331
  async function openHistory() {
@@ -342,19 +340,15 @@ async function loadAllUsers() {
342
  const res = await fetch('/api/all_users');
343
  const data = await res.json();
344
  historyUsers = data.users || [];
345
-
346
  const tabsEl = document.getElementById('userTabs');
347
  if (historyUsers.length === 0) {
348
  tabsEl.innerHTML = '<span style="color:#999;font-size:13px">暂无用户数据</span>';
349
  document.getElementById('historyContent').innerHTML = '';
350
  return;
351
  }
352
-
353
  tabsEl.innerHTML = historyUsers.map((u, i) =>
354
  `<div class="user-tab${i===0?' active':''}" onclick="selectUserTab(${i})" id="userTab${i}">${u.username} (${u.image_count})</div>`
355
  ).join('');
356
-
357
- // 默认显示第一个用户
358
  await loadUserHistory(historyUsers[0].username);
359
  } catch (e) {
360
  document.getElementById('historyContent').innerHTML = `<p style="color:red">加载失败: ${e.message}</p>`;
@@ -370,17 +364,14 @@ async function selectUserTab(index) {
370
  async function loadUserHistory(username) {
371
  const contentEl = document.getElementById('historyContent');
372
  contentEl.innerHTML = '<p style="color:#999;text-align:center;padding:20px">加载中...</p>';
373
-
374
  try {
375
  const res = await fetch(`/api/history?username=${encodeURIComponent(username)}`);
376
  const data = await res.json();
377
  const images = data.images || [];
378
-
379
  if (images.length === 0) {
380
  contentEl.innerHTML = '<p style="color:#999;text-align:center;padding:20px">该用户暂无历史图片</p>';
381
  return;
382
  }
383
-
384
  let html = `<div class="img-grid">`;
385
  for (const img of images) {
386
  html += `<div class="img-card">`;
@@ -398,25 +389,17 @@ async function loadUserHistory(username) {
398
 
399
  async function deleteImage(username, filename) {
400
  if (!confirm(`确定删除图片 "${filename}" 吗?\n将同时删除 Qdrant 中的向量数据。`)) return;
401
-
402
  try {
403
  const fd = new FormData();
404
  fd.append('username', username);
405
  fd.append('filename', filename);
406
  const res = await fetch('/api/delete_image', { method: 'POST', body: fd });
407
- if (!res.ok) {
408
- const d = await res.json();
409
- alert('删除失败: ' + (d.detail || '未知错误'));
410
- return;
411
- }
412
  const data = await res.json();
413
- // 刷新当前用户的图片列表
414
  const tabIndex = historyUsers.findIndex(u => u.username === username);
415
  if (tabIndex >= 0) {
416
  historyUsers[tabIndex].image_count = data.remaining_count;
417
- // 更新标签
418
  await loadAllUsers();
419
- // 重新选中该用户
420
  if (historyUsers[tabIndex]) {
421
  document.querySelectorAll('.user-tab').forEach(t => t.classList.remove('active'));
422
  const tab = document.getElementById(`userTab${tabIndex}`);
@@ -424,11 +407,8 @@ async function deleteImage(username, filename) {
424
  await loadUserHistory(username);
425
  }
426
  }
427
- // 更新主页面计数
428
  if (username === currentUser) refreshHistoryCount();
429
- } catch (e) {
430
- alert('删除失败: ' + e.message);
431
- }
432
  }
433
 
434
  function switchUser() {
@@ -449,7 +429,6 @@ function hideLoading() {
449
  document.getElementById('loadingOverlay').classList.add('hidden');
450
  }
451
 
452
- // 点击遮罩关闭历史面板
453
  document.getElementById('historyPanel').addEventListener('click', function(e) {
454
  if (e.target === this) closeHistory();
455
  });
 
8
  *{margin:0;padding:0;box-sizing:border-box}
9
  body{font-family:'Segoe UI','Microsoft YaHei',sans-serif;background:#f0f2f5;min-height:100vh;color:#333}
10
  .container{max-width:960px;margin:0 auto;padding:20px}
 
11
  .header{background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;padding:20px 30px;border-radius:12px;margin-bottom:20px;text-align:center}
12
  .header h1{font-size:22px;margin-bottom:4px}
13
  .header p{font-size:13px;opacity:.85}
 
14
  .status-bar{background:#fff;border-radius:10px;padding:14px 20px;margin-bottom:16px;display:flex;align-items:center;justify-content:space-between;box-shadow:0 2px 8px rgba(0,0,0,.06);flex-wrap:wrap;gap:10px}
15
  .status-info{display:flex;gap:20px;flex-wrap:wrap;font-size:14px}
16
  .status-info span{display:flex;align-items:center;gap:4px}
17
  .status-actions{display:flex;gap:8px}
 
18
  .card{background:#fff;border-radius:10px;padding:20px;margin-bottom:16px;box-shadow:0 2px 8px rgba(0,0,0,.06)}
19
  .card h3{font-size:15px;margin-bottom:12px;color:#555}
 
20
  .upload-zone{border:2px dashed #d9d9d9;border-radius:8px;padding:24px;text-align:center;cursor:pointer;transition:all .2s;position:relative}
21
  .upload-zone:hover{border-color:#667eea;background:#f8f8ff}
22
  .upload-zone input{position:absolute;inset:0;opacity:0;cursor:pointer}
23
  .upload-zone .icon{font-size:32px;margin-bottom:8px}
24
  .upload-zone .hint{font-size:13px;color:#999}
 
25
  .file-list{margin-top:10px;font-size:13px;color:#666}
26
  .file-list div{padding:3px 0}
 
27
  .btn{padding:10px 20px;border:none;border-radius:8px;font-size:14px;cursor:pointer;transition:all .15s;font-weight:500}
28
  .btn:disabled{opacity:.5;cursor:not-allowed}
29
  .btn-primary{background:linear-gradient(135deg,#667eea,#764ba2);color:#fff}
 
35
  .btn-sm{padding:6px 12px;font-size:12px}
36
  .btn-success{background:#28a745;color:#fff}
37
  .btn-success:hover:not(:disabled){background:#218838}
38
+ .btn-warning{background:#ffc107;color:#333}
39
+ .btn-warning:hover:not(:disabled){background:#e0a800}
40
  .result-pass{background:#d4edda;border:2px solid #28a745;border-radius:10px;padding:20px;text-align:center;margin-bottom:16px}
41
  .result-pass h2{color:#155724;font-size:20px}
42
  .result-pass p{color:#155724;font-size:14px;margin-top:4px}
 
43
  .result-fail{background:#f8d7da;border:2px solid #dc3545;border-radius:10px;padding:20px;text-align:center;margin-bottom:16px}
44
  .result-fail h2{color:#721c24;font-size:20px}
45
  .result-fail p{color:#721c24;font-size:14px;margin-top:4px}
 
46
  table{width:100%;border-collapse:collapse;font-size:13px;margin-top:12px}
47
  th{background:#343a40;color:#fff;padding:10px 8px;text-align:center;font-weight:500}
48
  td{padding:8px;text-align:center;border-bottom:1px solid #eee}
49
  tr.row-fail{background:#fff5f5}
50
  tr.row-fail td{color:#721c24}
 
51
  .deleted-info{background:#fff5f5;border:1px solid #dc3545;border-radius:8px;padding:10px 16px;margin-bottom:12px;font-size:13px;color:#721c24}
 
52
  .loading-overlay{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:999}
53
  .loading-box{background:#fff;border-radius:12px;padding:30px 40px;text-align:center;box-shadow:0 8px 30px rgba(0,0,0,.2)}
54
  .spinner{width:40px;height:40px;border:4px solid #e0e0e0;border-top-color:#667eea;border-radius:50%;animation:spin .8s linear infinite;margin:0 auto 12px}
55
  @keyframes spin{to{transform:rotate(360deg)}}
 
56
  .login-card{background:#fff;border-radius:12px;padding:40px;max-width:400px;margin:80px auto;box-shadow:0 4px 20px rgba(0,0,0,.1);text-align:center}
57
  .login-card h2{margin-bottom:20px;color:#333}
58
  .login-card input{width:100%;padding:12px 16px;border:2px solid #e0e0e0;border-radius:8px;font-size:15px;margin-bottom:16px;outline:none;transition:border-color .2s}
59
  .login-card input:focus{border-color:#667eea}
 
60
  .error-msg{color:#dc3545;font-size:13px;margin-top:4px;min-height:18px}
 
61
  .hidden{display:none!important}
 
 
62
  .history-panel{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;display:flex;align-items:center;justify-content:center}
63
  .history-modal{background:#fff;border-radius:12px;padding:24px;max-width:800px;width:95%;max-height:85vh;overflow-y:auto;box-shadow:0 8px 30px rgba(0,0,0,.3)}
64
  .history-modal h2{margin-bottom:16px;font-size:18px}
 
76
  .img-card .del-btn:hover{background:#dc3545}
77
  .close-btn{float:right;background:none;border:none;font-size:24px;cursor:pointer;color:#999;line-height:1}
78
  .close-btn:hover{color:#333}
79
+ .vis-toggle{display:inline-flex;align-items:center;gap:6px;margin:8px 0;padding:8px 16px;background:#fff3cd;border:1px solid #ffc107;border-radius:8px;cursor:pointer;font-size:13px;color:#856404;transition:all .2s}
80
+ .vis-toggle:hover{background:#ffc107;color:#333}
81
+ .vis-content{margin-top:10px;overflow:hidden;transition:max-height .3s ease}
82
+ .vis-content.collapsed{max-height:0;overflow:hidden}
83
+ .vis-content.expanded{max-height:2000px}
84
+ .vis-content img{width:100%;border-radius:6px;border:1px solid #eee}
85
+ .vis-stats{display:flex;gap:16px;font-size:13px;color:#666;margin-top:8px;flex-wrap:wrap}
86
  .vis-stats b{color:#333}
87
+ .scene-tag{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600}
88
+ .scene-tag.text{background:#e7f3ff;color:#0066cc}
89
+ .scene-tag.complex{background:#fff3e0;color:#cc6600}
90
  </style>
91
  </head>
92
  <body>
 
136
  <div id="resultArea"></div>
137
  </div>
138
 
 
139
  <div id="historyPanel" class="history-panel hidden">
140
  <div class="history-modal">
141
  <button class="close-btn" onclick="closeHistory()">&times;</button>
 
165
  const errEl = document.getElementById('loginError');
166
  errEl.textContent = '';
167
  if (!name) { errEl.textContent = '用户名不能为空'; return; }
 
168
  try {
169
  const fd = new FormData();
170
  fd.append('username', name);
171
  const res = await fetch('/api/login', { method: 'POST', body: fd });
172
+ if (!res.ok) { const d = await res.json(); errEl.textContent = d.detail || '登录失败'; return; }
 
 
 
 
173
  const data = await res.json();
174
  currentUser = data.username;
175
  showMain(data);
176
+ } catch (e) { errEl.textContent = '网络错误: ' + e.message; }
 
 
177
  }
178
 
179
  function showMain(data) {
 
194
  const files = document.getElementById('queryInput').files;
195
  const list = document.getElementById('queryFileList');
196
  list.innerHTML = '';
197
+ for (const f of files) list.innerHTML += `<div>📄 ${f.name} (${(f.size/1024).toFixed(1)} KB)</div>`;
 
 
198
  }
199
 
200
  async function doDetect() {
201
  const queryFiles = document.getElementById('queryInput').files;
202
+ if (queryFiles.length === 0) { alert('请上传至少一张图片!'); return; }
 
 
 
 
203
  const fd = new FormData();
204
  fd.append('username', currentUser);
205
  for (const f of queryFiles) fd.append('images', f);
 
206
  showLoading('正在进行场景比对,请稍候...');
207
  document.getElementById('detectBtn').disabled = true;
 
208
  try {
209
  const res = await fetch('/api/detect', { method: 'POST', body: fd });
210
+ if (!res.ok) { const d = await res.json(); alert('检测失败: ' + (d.detail || '未知错误')); return; }
 
 
 
 
211
  const data = await res.json();
212
  renderResult(data);
 
213
  refreshHistoryCount();
214
+ } catch (e) { alert('网络错误: ' + e.message); }
215
+ finally { hideLoading(); document.getElementById('detectBtn').disabled = false; }
 
 
 
 
216
  }
217
 
218
  async function refreshHistoryCount() {
219
  try {
220
  const res = await fetch(`/api/history?username=${encodeURIComponent(currentUser)}`);
221
+ if (res.ok) { const data = await res.json(); document.getElementById('dispCount').textContent = data.count; }
 
 
 
222
  } catch (e) {}
223
  }
224
 
 
241
  html += `<div class="deleted-info">🗑️ <b>已删除同一场景图片:</b> ${deletedFiles.map(f => f.original_name).join('、')}</div>`;
242
  }
243
 
244
+ // 比对日志
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  if (data.evaluation_logs && data.evaluation_logs.length > 0) {
246
  html += `<div class="card"><h3>📋 比对详情日志</h3><table>`;
247
+ html += `<thead><tr><th>待检测</th><th>历史</th><th>场景</th><th>相似度</th><th>最终判定</th></tr></thead><tbody>`;
 
248
  for (const log of data.evaluation_logs) {
249
  const rowClass = log.is_same_scene ? ' class="row-fail"' : '';
250
+ const truncName = (n, max=16) => n.length > max ? n.substring(0, max-2) + '..' : n;
251
+ const sceneTag = log.scene_type === 'text'
252
+ ? '<span class="scene-tag text">文本</span>'
253
+ : '<span class="scene-tag complex">复杂</span>';
254
+ const simLabel = log.scene_type === 'text'
255
+ ? (log.text_similarity != null ? `BGE ${log.text_similarity.toFixed(3)}` : '-')
256
+ : (log.dinov2_similarity != null ? `DINOv2 ${log.dinov2_similarity.toFixed(3)}` : '-');
257
+ const finalStatus = log.is_same_scene ? '❌ 同一场景' : '✅ 不同场景';
 
258
  html += `<tr${rowClass}>`;
259
+ html += `<td title="${log.query_image}">${truncName(log.query_image)}</td>`;
260
+ html += `<td title="${log.history_image}">${truncName(log.history_image)}</td>`;
261
+ html += `<td>${sceneTag}</td>`;
262
+ html += `<td>${simLabel}</td>`;
263
+ html += `<td>${finalStatus}</td>`;
 
 
264
  html += `</tr>`;
265
  }
266
  html += `</tbody></table></div>`;
267
  }
268
 
269
+ // 可视化区域(默认隐藏,点击按钮展示)
270
+ const vis = data.visualizations || [];
271
+ if (vis.length > 0) {
272
+ const visId = 'vis_' + Date.now();
273
+ html += `<div class="card"><h3>🔍 匹配可视化证据 <span style="font-size:12px;color:#999">(点击按钮查看)</span></h3>`;
274
+ for (let i = 0; i < vis.length; i++) {
275
+ const v = vis[i];
276
+ const sceneTag = v.scene_type === 'text'
277
+ ? '<span class="scene-tag text">文本场景</span>'
278
+ : '<span class="scene-tag complex">复杂场景</span>';
279
+ const vid = `${visId}_${i}`;
280
+ html += `<div style="margin-bottom:12px">`;
281
+ html += `<div style="display:flex;align-items:center;gap:10px;margin-bottom:6px">`;
282
+ html += `${sceneTag} <b>${v.query_image}</b> ↔ <b>${v.history_image}</b>`;
283
+ html += `</div>`;
284
+ // 摘要信息
285
+ html += `<div class="vis-stats">`;
286
+ if (v.scene_type === 'complex') {
287
+ html += `<span>DINOv2相似度: <b>${v.dinov2_similarity}</b></span>`;
288
+ html += `<span>Patch匹配数: <b>${v.patch_match_count}</b></span>`;
289
+ html += `<span>平均Patch相似度: <b>${v.avg_patch_similarity}</b></span>`;
290
+ } else {
291
+ html += `<span>BGE语义相似度: <b>${v.text_similarity}</b></span>`;
292
+ html += `<span>共同关键词: <b>${v.common_keyword_count}</b></span>`;
293
+ html += `<span>差异词(查询/历史): <b>${v.diff_keyword_count_query}/${v.diff_keyword_count_history}</b></span>`;
294
+ }
295
+ html += `</div>`;
296
+ // 展示/隐藏按钮
297
+ html += `<div class="vis-toggle" onclick="toggleVis('${vid}')">🖼️ 点击查看可视化图 ▶</div>`;
298
+ // 可视化图片(默认隐藏)
299
+ html += `<div id="${vid}" class="vis-content collapsed">`;
300
+ html += `<img src="${v.image_base64}" alt="可视化">`;
301
+ if (v.scene_type === 'text' && v.common_keywords && v.common_keywords.length > 0) {
302
+ html += `<div style="margin-top:8px;font-size:12px;color:#666"><b>共同关键词:</b> ${v.common_keywords.join('、')}</div>`;
303
+ }
304
+ html += `</div>`;
305
+ html += `</div>`;
306
+ }
307
+ html += `</div>`;
308
+ }
309
+
310
  area.innerHTML = html;
311
  }
312
 
313
+ function toggleVis(id) {
314
+ const el = document.getElementById(id);
315
+ const toggle = el.previousElementSibling;
316
+ if (el.classList.contains('collapsed')) {
317
+ el.classList.remove('collapsed');
318
+ el.classList.add('expanded');
319
+ toggle.innerHTML = '🖼️ 点击收起可视化图 ▼';
320
+ } else {
321
+ el.classList.remove('expanded');
322
+ el.classList.add('collapsed');
323
+ toggle.innerHTML = '🖼️ 点击查看可视化图 ▶';
324
+ }
325
+ }
326
+
327
  // ==================== 历史图片管理 ====================
328
 
329
  async function openHistory() {
 
340
  const res = await fetch('/api/all_users');
341
  const data = await res.json();
342
  historyUsers = data.users || [];
 
343
  const tabsEl = document.getElementById('userTabs');
344
  if (historyUsers.length === 0) {
345
  tabsEl.innerHTML = '<span style="color:#999;font-size:13px">暂无用户数据</span>';
346
  document.getElementById('historyContent').innerHTML = '';
347
  return;
348
  }
 
349
  tabsEl.innerHTML = historyUsers.map((u, i) =>
350
  `<div class="user-tab${i===0?' active':''}" onclick="selectUserTab(${i})" id="userTab${i}">${u.username} (${u.image_count})</div>`
351
  ).join('');
 
 
352
  await loadUserHistory(historyUsers[0].username);
353
  } catch (e) {
354
  document.getElementById('historyContent').innerHTML = `<p style="color:red">加载失败: ${e.message}</p>`;
 
364
  async function loadUserHistory(username) {
365
  const contentEl = document.getElementById('historyContent');
366
  contentEl.innerHTML = '<p style="color:#999;text-align:center;padding:20px">加载中...</p>';
 
367
  try {
368
  const res = await fetch(`/api/history?username=${encodeURIComponent(username)}`);
369
  const data = await res.json();
370
  const images = data.images || [];
 
371
  if (images.length === 0) {
372
  contentEl.innerHTML = '<p style="color:#999;text-align:center;padding:20px">该用户暂无历史图片</p>';
373
  return;
374
  }
 
375
  let html = `<div class="img-grid">`;
376
  for (const img of images) {
377
  html += `<div class="img-card">`;
 
389
 
390
  async function deleteImage(username, filename) {
391
  if (!confirm(`确定删除图片 "${filename}" 吗?\n将同时删除 Qdrant 中的向量数据。`)) return;
 
392
  try {
393
  const fd = new FormData();
394
  fd.append('username', username);
395
  fd.append('filename', filename);
396
  const res = await fetch('/api/delete_image', { method: 'POST', body: fd });
397
+ if (!res.ok) { const d = await res.json(); alert('删除失败: ' + (d.detail || '未知错误')); return; }
 
 
 
 
398
  const data = await res.json();
 
399
  const tabIndex = historyUsers.findIndex(u => u.username === username);
400
  if (tabIndex >= 0) {
401
  historyUsers[tabIndex].image_count = data.remaining_count;
 
402
  await loadAllUsers();
 
403
  if (historyUsers[tabIndex]) {
404
  document.querySelectorAll('.user-tab').forEach(t => t.classList.remove('active'));
405
  const tab = document.getElementById(`userTab${tabIndex}`);
 
407
  await loadUserHistory(username);
408
  }
409
  }
 
410
  if (username === currentUser) refreshHistoryCount();
411
+ } catch (e) { alert('删除失败: ' + e.message); }
 
 
412
  }
413
 
414
  function switchUser() {
 
429
  document.getElementById('loadingOverlay').classList.add('hidden');
430
  }
431
 
 
432
  document.getElementById('historyPanel').addEventListener('click', function(e) {
433
  if (e.target === this) closeHistory();
434
  });
module/evaluator.py CHANGED
@@ -101,6 +101,8 @@ def evaluate_scene_consistency_cached(matcher, query_paths, username,
101
  'is_same_scene': result['is_same_scene'],
102
  'gamma_info': [],
103
  'bge_search_score': round(score, 4),
 
 
104
  }
105
  except Exception as e:
106
  log_entry = {
 
101
  'is_same_scene': result['is_same_scene'],
102
  'gamma_info': [],
103
  'bge_search_score': round(score, 4),
104
+ 'query_text': full_text,
105
+ 'history_text': entry.get('ocr_text', ''),
106
  }
107
  except Exception as e:
108
  log_entry = {