Spaces:
Sleeping
Sleeping
jslmmfboom-coder commited on
Commit ·
405df18
1
Parent(s): 9a3a826
Fix visualization: build before delete, add text scene vis, click-to-show
Browse files- app.py +162 -88
- index.html +92 -113
- module/evaluator.py +2 -0
app.py
CHANGED
|
@@ -1,12 +1,13 @@
|
|
| 1 |
"""FastAPI 主服务 — HF Spaces CPU 部署版
|
| 2 |
|
| 3 |
-
改动点
|
| 4 |
-
1. 端口
|
| 5 |
-
2. MASt3R
|
| 6 |
-
3. Qdrant
|
| 7 |
-
4.
|
| 8 |
-
5.
|
| 9 |
-
6.
|
|
|
|
| 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
|
| 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 |
-
"
|
| 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
|
| 512 |
-
"""
|
| 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 |
-
|
| 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((
|
| 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,
|
| 609 |
-
cv2.putText(canvas,
|
| 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-
|
| 96 |
-
.vis-
|
| 97 |
-
.vis-
|
| 98 |
-
.vis-
|
|
|
|
| 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()">×</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 |
-
|
| 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>
|
| 301 |
-
|
| 302 |
for (const log of data.evaluation_logs) {
|
| 303 |
const rowClass = log.is_same_scene ? ' class="row-fail"' : '';
|
| 304 |
-
const truncName = (n, max=
|
| 305 |
-
const
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
const
|
| 309 |
-
|
| 310 |
-
|
| 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}">${
|
| 315 |
-
html += `<td title="${log.history_image}">${
|
| 316 |
-
html += `<td
|
| 317 |
-
html += `<td>${
|
| 318 |
-
html += `<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()">×</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 = {
|