jslmmfboom-coder commited on
Commit
e16aadc
·
0 Parent(s):

Fix paddlepaddle version to 2.6.2

Browse files
.dockerignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ __pycache__
2
+ *.pyc
3
+ *.pyo
4
+ .git
5
+ .gitignore
6
+ qdrant_data/
7
+ Logs/
8
+ *.log
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ qdrant_data/
2
+ Logs/
3
+ __pycache__/
4
+ *.pyc
Dockerfile ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================
2
+ # HF Spaces Dockerfile — 场景一致性检测 (CPU)
3
+ # ============================================
4
+ FROM python:3.10-slim
5
+
6
+ # 系统依赖(OpenCV 运行时 + 文件处理)
7
+ RUN apt-get update && apt-get install -y --no-install-recommends \
8
+ libgl1 \
9
+ libglib2.0-0 \
10
+ libsm6 \
11
+ libxext6 \
12
+ libxrender1 \
13
+ && rm -rf /var/lib/apt/lists/*
14
+
15
+ WORKDIR /app
16
+
17
+ # 1. 先安装 PyTorch CPU 版(与 paddlepaddle 分开安装避免冲突)
18
+ RUN pip install --no-cache-dir torch torchvision --index-url https://download.pytorch.org/whl/cpu
19
+
20
+ # 2. 安装其余 Python 依赖
21
+ COPY requirements.txt .
22
+ RUN pip install --no-cache-dir -r requirements.txt
23
+
24
+ # 3. 预下载 DINOv2 reg4 模型权重(约 85MB,构建时下载到镜像中)
25
+ RUN python -c "import timm; timm.create_model('vit_small_patch14_reg4_dinov2', pretrained=True)" || echo "DINOv2 pre-download warning"
26
+
27
+ # 4. 预下载 BGE-small-zh 模型权重(约 100MB)
28
+ RUN python -c "from transformers import AutoTokenizer, BertModel; AutoTokenizer.from_pretrained('BAAI/bge-small-zh-v1.5'); BertModel.from_pretrained('BAAI/bge-small-zh-v1.5')" || echo "BGE pre-download warning"
29
+
30
+ # 5. 预下载 PaddleOCR 模型(约 50MB,通过运行一次 OCR 触发下载)
31
+ RUN python -c "from paddleocr import PaddleOCR; from PIL import Image; ocr=PaddleOCR(use_angle_cls=True, lang='ch'); img=Image.new('RGB',(100,100),'white'); img.save('/tmp/d.png'); ocr.ocr('/tmp/d.png', cls=True)" || echo "PaddleOCR pre-download warning"
32
+
33
+ # 6. 复制应用代码
34
+ COPY . .
35
+
36
+ # 7. 创建运行时目录
37
+ RUN mkdir -p /app/History_imgs /app/qdrant_data /app/Logs
38
+
39
+ # HF Spaces Docker 默认端口 7860
40
+ EXPOSE 7860
41
+ ENV PORT=7860
42
+
43
+ # 启动服务
44
+ CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Scene Consistency Detection
3
+ emoji: 📷
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: mit
10
+ ---
11
+
12
+ # 场景一致性检测系统 (CPU)
13
+
14
+ 基于 **DINOv2 reg4 + BGE-small-zh + PaddleOCR** 的多角度场景一致性检测,部署在 Hugging Face Spaces (CPU)。
15
+
16
+ ## 功能
17
+
18
+ - 上传图片,自动与历史图片库比对是否为同一场景
19
+ - **文本场景**(合同/证书等文档):PaddleOCR + BGE 语义相似度 (≥0.85)
20
+ - **复杂场景**(实景照片):DINOv2 reg4 全局特征余弦相似度 (≥0.55)
21
+
22
+ ## 技术栈
23
+
24
+ | 组件 | 说明 |
25
+ |------|------|
26
+ | DINOv2 | vit_small_patch14_reg4_dinov2 (384维, timm) |
27
+ | BGE | bge-small-zh-v1.5 (512维, transformers) |
28
+ | OCR | PaddleOCR 2.x (CPU) |
29
+ | 向量库 | Qdrant 嵌入式本地模式 |
30
+ | Web | FastAPI + 原生 HTML |
app.py ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI 主服务 — HF Spaces CPU 部署版
2
+
3
+ 改动点(相比原项目):
4
+ 1. 端口改为 7860(HF Spaces Docker 默认端口)
5
+ 2. MASt3R 模型加载返回 None,SceneMatcher 使用 DINOv2-only 模式
6
+ 3. Qdrant 使用嵌入式本地文件模式
7
+ """
8
+ import os
9
+ import asyncio
10
+ from concurrent.futures import ThreadPoolExecutor
11
+ from contextlib import asynccontextmanager
12
+ import time
13
+
14
+ from fastapi import FastAPI, UploadFile, File, Form, HTTPException
15
+ from fastapi.middleware.cors import CORSMiddleware
16
+ from fastapi.responses import FileResponse, JSONResponse
17
+
18
+ from module.model_loader import load_model, load_dinov2, load_ocr, load_bge
19
+ from module.scene_matcher import SceneMatcher
20
+ from module.file_manager import (
21
+ validate_username, get_user_dir, get_history_images,
22
+ save_uploaded_file, delete_file
23
+ )
24
+ from module.evaluator import (
25
+ evaluate_scene_consistency, evaluate_scene_consistency_cached, update_metadata
26
+ )
27
+ from module.system_monitor import _snapshot, ResourceMonitor, write_session_log
28
+ from module.qdrant_manager import (
29
+ build_user_cache, qdrant_query_scene, has_qdrant_cache,
30
+ add_to_qdrant, remove_from_qdrant,
31
+ )
32
+ from module.qdrant_manager import qdrant_search_similar, pt_id
33
+ from module.text_classifier import classify_scene
34
+
35
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
36
+ FRONTEND_PATH = os.path.join(BASE_DIR, 'index.html')
37
+ HISTORY_BASE = os.path.join(BASE_DIR, 'History_imgs')
38
+
39
+ matcher = None
40
+ dinov2_extractor = None
41
+ ocr_engine = None
42
+ bge_tokenizer = None
43
+ bge_model = None
44
+ executor = ThreadPoolExecutor(max_workers=1)
45
+
46
+ session_data = {
47
+ 'startup_stats': None,
48
+ 'model_loaded_stats': None,
49
+ 'detections': [],
50
+ }
51
+
52
+ resource_monitor = ResourceMonitor(sample_interval=0.5)
53
+
54
+ # 用户 Qdrant 缓存: {username: {scene_type: {pid: entry}}}
55
+ _qd_caches = {}
56
+
57
+
58
+ @asynccontextmanager
59
+ async def lifespan(app):
60
+ """FastAPI 生命周期管理:启动时加载模型,关闭时写入日志"""
61
+ global matcher, dinov2_extractor, ocr_engine, bge_tokenizer, bge_model, session_data
62
+
63
+ startup_t0 = time.perf_counter()
64
+ session_data['startup_stats'] = _snapshot()
65
+
66
+ # MASt3R 返回 None(CPU 部署已禁用)
67
+ model = load_model()
68
+ dinov2_extractor = load_dinov2()
69
+ matcher = SceneMatcher(model, dinov2_extractor=dinov2_extractor)
70
+
71
+ ocr_engine = load_ocr()
72
+ bge_tokenizer, bge_model = load_bge()
73
+
74
+ session_data['model_loaded_stats'] = _snapshot()
75
+ model_load_time = time.perf_counter() - startup_t0
76
+ print(f'[启动] 模型加载完成: {model_load_time:.1f}s')
77
+
78
+ # 启动时预加载所有用户 Qdrant 缓存
79
+ qdrant_t0 = time.perf_counter()
80
+ if os.path.isdir(HISTORY_BASE):
81
+ for uname in os.listdir(HISTORY_BASE):
82
+ udir = os.path.join(HISTORY_BASE, uname)
83
+ if not os.path.isdir(udir):
84
+ continue
85
+ imgs = get_history_images(udir)
86
+ if not imgs:
87
+ continue
88
+ try:
89
+ if not has_qdrant_cache(uname):
90
+ print(f"[qdrant] 启动预构建: 用户 [{uname}]")
91
+ build_user_cache(uname, udir, ocr_engine, dinov2_extractor,
92
+ bge_tokenizer, bge_model)
93
+ _qd_caches[uname] = {
94
+ 'text': qdrant_query_scene(uname, 'text'),
95
+ 'complex': qdrant_query_scene(uname, 'complex'),
96
+ }
97
+ _t_n = len(_qd_caches[uname]['text'])
98
+ _c_n = len(_qd_caches[uname]['complex'])
99
+ print(f" [{uname}] 缓存已就绪 (text={_t_n}, complex={_c_n})")
100
+ except Exception as e:
101
+ print(f" [{uname}] 缓存加载失败: {e}")
102
+ qdrant_load_time = time.perf_counter() - qdrant_t0
103
+ print(f"[qdrant] 缓存检查完成: {len(_qd_caches)} 个用户 ({qdrant_load_time:.1f}s)")
104
+ total_startup = time.perf_counter() - startup_t0
105
+ print(f"[启动] 总启动时间: {total_startup:.1f}s")
106
+
107
+ yield
108
+
109
+ session_data['shutdown_stats'] = _snapshot()
110
+ try:
111
+ log_path = write_session_log(session_data)
112
+ print(f"日志已写入: {log_path}")
113
+ except Exception as e:
114
+ print(f"日志写入失败: {e}")
115
+
116
+
117
+ app = FastAPI(lifespan=lifespan)
118
+
119
+ app.add_middleware(
120
+ CORSMiddleware,
121
+ allow_origins=["*"],
122
+ allow_methods=["*"],
123
+ allow_headers=["*"],
124
+ )
125
+
126
+
127
+ @app.get("/")
128
+ async def serve_frontend():
129
+ return FileResponse(FRONTEND_PATH)
130
+
131
+
132
+ @app.post("/api/login")
133
+ async def login(username: str = Form(...)):
134
+ try:
135
+ uname = validate_username(username)
136
+ except ValueError as e:
137
+ raise HTTPException(status_code=400, detail=str(e))
138
+
139
+ user_dir = get_user_dir(uname)
140
+ history = get_history_images(user_dir)
141
+
142
+ cached = uname in _qd_caches
143
+ if not cached and history and has_qdrant_cache(uname):
144
+ _qd_caches[uname] = {
145
+ 'text': qdrant_query_scene(uname, 'text'),
146
+ 'complex': qdrant_query_scene(uname, 'complex'),
147
+ }
148
+ cached = True
149
+
150
+ return {
151
+ "username": uname,
152
+ "history_count": len(history),
153
+ "cached": cached,
154
+ }
155
+
156
+
157
+ def _run_detect_pipeline(uploaded_paths, uname, ocr_engine, bge_tokenizer,
158
+ bge_model, dinov2_extractor, matcher, history_before):
159
+ scene_type_q = 'complex'
160
+ ocr_result_q = None
161
+ precomputed_text = None
162
+ precomputed_bge = None
163
+ precomputed_dinov2 = None
164
+
165
+ _t0 = time.perf_counter()
166
+ if ocr_engine is not None and uploaded_paths:
167
+ scene_type_q, _, _, ocr_result_q = classify_scene(uploaded_paths[0], ocr_engine)
168
+ _t1 = time.perf_counter()
169
+ print(f' [TIME] classify_scene: {_t1-_t0:.3f}s')
170
+ if ocr_result_q is not None and ocr_result_q and ocr_result_q[0]:
171
+ precomputed_text = ' '.join(line[1][0] for line in ocr_result_q[0])
172
+ if precomputed_text and precomputed_text.strip():
173
+ from module.qdrant_manager import _bge_encode
174
+ precomputed_bge = _bge_encode(precomputed_text, bge_tokenizer, bge_model)
175
+ _t2 = time.perf_counter()
176
+ print(f' [TIME] bge_encode: {_t2-_t1:.3f}s')
177
+
178
+ user_qd = _qd_caches.get(uname, {})
179
+ has_cache = isinstance(user_qd, dict) and bool(user_qd)
180
+
181
+ cache_used = False
182
+ final_result = True
183
+ evaluation_logs = []
184
+ best_match = None
185
+
186
+ if has_cache:
187
+ cache_used = True
188
+ print(f"[qdrant] 使用 ANN 搜索管线 (用户 [{uname}], 场景={scene_type_q})")
189
+ final_result, evaluation_logs, best_match = evaluate_scene_consistency_cached(
190
+ matcher, uploaded_paths, uname,
191
+ ocr_engine, bge_tokenizer, bge_model,
192
+ scene_type_q, qdrant_search_similar, dinov2_extractor,
193
+ precomputed_ocr_result=ocr_result_q,
194
+ precomputed_bge_vec=precomputed_bge,
195
+ qd_cache=user_qd,
196
+ )
197
+
198
+ if not cache_used:
199
+ print(f"[qdrant] 缓存不可用,回退到原始管线 (用户 [{uname}])")
200
+ final_result, evaluation_logs, best_match = evaluate_scene_consistency(
201
+ matcher, uploaded_paths, history_before,
202
+ ocr_engine, bge_tokenizer, bge_model,
203
+ )
204
+
205
+ same_scene_paths = set()
206
+ for log in evaluation_logs:
207
+ if log['is_same_scene'] and log['query_path'] in uploaded_paths:
208
+ same_scene_paths.add(log['query_path'])
209
+
210
+ for path in same_scene_paths:
211
+ delete_file(path)
212
+
213
+ per_image_results = {}
214
+ for orig, path in [(os.path.basename(p), p) for p in uploaded_paths]:
215
+ if path in same_scene_paths:
216
+ per_image_results[path] = '未通过(同一场景,已删除)'
217
+ else:
218
+ per_image_results[path] = '通过' if final_result else '未通过'
219
+
220
+ return final_result, evaluation_logs, best_match, scene_type_q, same_scene_paths, per_image_results, cache_used, precomputed_text, precomputed_bge, precomputed_dinov2
221
+
222
+
223
+ @app.post("/api/detect")
224
+ async def detect(
225
+ username: str = Form(...),
226
+ images: list[UploadFile] = File(default=[]),
227
+ ):
228
+ global session_data, _qd_caches
229
+
230
+ try:
231
+ uname = validate_username(username)
232
+ except ValueError as e:
233
+ raise HTTPException(status_code=400, detail=str(e))
234
+
235
+ user_dir = get_user_dir(uname)
236
+
237
+ if len(images) == 0:
238
+ raise HTTPException(status_code=400, detail="请上传至少一张图片")
239
+
240
+ history_before = get_history_images(user_dir)
241
+
242
+ saved_info = []
243
+ for f in images:
244
+ data = await f.read()
245
+ if not data:
246
+ continue
247
+ path = save_uploaded_file(data, f.filename, user_dir)
248
+ saved_info.append((f.filename, path))
249
+
250
+ if not saved_info:
251
+ raise HTTPException(status_code=400, detail="没有有效的上传图片")
252
+
253
+ uploaded_paths = [p for _, p in saved_info]
254
+
255
+ if len(history_before) == 0:
256
+ per_image_results = {p: '通过(首次上传,跳过比对)' for _, p in saved_info}
257
+ update_metadata(user_dir, saved_info, per_image_results)
258
+ try:
259
+ for _, p in saved_info:
260
+ add_to_qdrant(uname, p, ocr_engine, dinov2_extractor,
261
+ bge_tokenizer, bge_model)
262
+ _qd_caches[uname] = {
263
+ 'text': qdrant_query_scene(uname, 'text'),
264
+ 'complex': qdrant_query_scene(uname, 'complex'),
265
+ }
266
+ except Exception:
267
+ pass
268
+ return {
269
+ "result": "pass",
270
+ "reason": "first_upload",
271
+ "saved_files": [
272
+ {"filename": os.path.basename(p), "original_name": orig, "deleted": False}
273
+ for orig, p in saved_info
274
+ ],
275
+ "evaluation_logs": [],
276
+ "best_match": None,
277
+ "gamma_warnings": [],
278
+ }
279
+
280
+ pre_stats = _snapshot()
281
+ detect_start_time = asyncio.get_event_loop().time()
282
+ resource_monitor.start_monitoring()
283
+
284
+ loop = asyncio.get_event_loop()
285
+ final_result, evaluation_logs, best_match, scene_type_q, same_scene_paths, per_image_results, cache_used, precomp_text, precomp_bge, precomp_dino = \
286
+ await loop.run_in_executor(
287
+ executor, _run_detect_pipeline,
288
+ uploaded_paths, uname, ocr_engine, bge_tokenizer,
289
+ bge_model, dinov2_extractor, matcher, history_before,
290
+ )
291
+
292
+ peak = resource_monitor.stop_monitoring()
293
+ detect_duration = asyncio.get_event_loop().time() - detect_start_time
294
+
295
+ n_query = len(uploaded_paths)
296
+ n_pairs = len(evaluation_logs)
297
+ if peak:
298
+ print(f"检测完成 | 耗时: {detect_duration:.1f}s | 查询图: {n_query} | 比对数: {n_pairs} | 缓存: {'ON' if cache_used else 'OFF'}")
299
+
300
+ detection_record = {
301
+ 'pre_stats': pre_stats,
302
+ 'peak': peak,
303
+ 'duration_sec': round(detect_duration, 1),
304
+ 'cache_used': cache_used,
305
+ }
306
+ session_data['detections'].append(detection_record)
307
+
308
+ kept_saved_info = [(orig, p) for orig, p in saved_info if p not in same_scene_paths]
309
+ update_metadata(user_dir, kept_saved_info, per_image_results)
310
+
311
+ kept_paths_qd = [p for _, p in kept_saved_info]
312
+ removed_paths_qd = list(same_scene_paths)
313
+ st_q = scene_type_q
314
+
315
+ def _post_update_qdrant():
316
+ for path in kept_paths_qd:
317
+ try:
318
+ add_to_qdrant(uname, path, ocr_engine, dinov2_extractor,
319
+ bge_tokenizer, bge_model, scene_type=st_q,
320
+ precomputed_text=precomp_text,
321
+ precomputed_bge=precomp_bge,
322
+ precomputed_dinov2=precomp_dino)
323
+ _qd_caches[uname] = {
324
+ 'text': qdrant_query_scene(uname, 'text'),
325
+ 'complex': qdrant_query_scene(uname, 'complex'),
326
+ }
327
+ except Exception:
328
+ pass
329
+ for path in removed_paths_qd:
330
+ try:
331
+ remove_from_qdrant(uname, path, scene_type=None)
332
+ except Exception:
333
+ pass
334
+
335
+ loop.run_in_executor(executor, _post_update_qdrant)
336
+
337
+ gamma_warnings = []
338
+
339
+ best_match_response = None
340
+ if best_match:
341
+ best_match_response = {
342
+ 'query_image': best_match['query_image'],
343
+ 'history_image': best_match['history_image'],
344
+ 'similarity_score': best_match['similarity_score'],
345
+ 'match_count': best_match['match_count'],
346
+ 'inlier_ratio': best_match['inlier_ratio'],
347
+ 'avg_confidence': best_match['avg_confidence'],
348
+ 'gamma_info': best_match.get('gamma_info', []),
349
+ }
350
+
351
+ return {
352
+ "result": "pass" if final_result else "fail",
353
+ "saved_files": [
354
+ {"filename": os.path.basename(p), "original_name": orig, "deleted": p in same_scene_paths}
355
+ for orig, p in saved_info
356
+ ],
357
+ "evaluation_logs": [
358
+ {
359
+ "query_image": log['query_image'],
360
+ "history_image": log['history_image'],
361
+ "scene_type": log.get('scene_type', 'complex'),
362
+ "doc_score1": log.get('doc_score1', 0),
363
+ "doc_score2": log.get('doc_score2', 0),
364
+ "similarity_score": log['similarity_score'],
365
+ "match_count": log['match_count'],
366
+ "raw_match_count": log.get('raw_match_count', 0),
367
+ "inlier_ratio": log['inlier_ratio'],
368
+ "avg_confidence": log['avg_confidence'],
369
+ "mast3r_is_same": log.get('mast3r_is_same'),
370
+ "dinov2_is_same": log.get('dinov2_is_same'),
371
+ "dinov2_similarity": log.get('dinov2_similarity'),
372
+ "text_similarity": log.get('text_similarity'),
373
+ "bge_search_score": log.get('bge_search_score'),
374
+ "is_same_scene": log['is_same_scene'],
375
+ "error": log.get('error'),
376
+ }
377
+ for log in evaluation_logs
378
+ ],
379
+ "best_match": best_match_response,
380
+ "gamma_warnings": gamma_warnings,
381
+ }
382
+
383
+
384
+ @app.get("/api/status")
385
+ async def status():
386
+ return {
387
+ "model_loaded": matcher is not None,
388
+ "device": matcher.device if matcher else None,
389
+ "ocr_available": ocr_engine is not None,
390
+ "bge_available": bge_tokenizer is not None,
391
+ "qdrant_cached_users": len(_qd_caches),
392
+ }
393
+
394
+
395
+ if __name__ == "__main__":
396
+ import uvicorn
397
+ # HF Spaces Docker 默认端口 7860
398
+ port = int(os.environ.get("PORT", 7860))
399
+ uvicorn.run("app:app", host="0.0.0.0", port=port, reload=False)
index.html ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>场景一致性检测系统 (DINOv2 + BGE)</title>
7
+ <style>
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}
36
+ .btn-primary:hover:not(:disabled){transform:translateY(-1px);box-shadow:0 4px 12px rgba(102,126,234,.4)}
37
+ .btn-info{background:#17a2b8;color:#fff}
38
+ .btn-info:hover:not(:disabled){background:#138496}
39
+ .btn-danger{background:#dc3545;color:#fff}
40
+ .btn-danger:hover:not(:disabled){background:#c82333}
41
+
42
+ .result-pass{background:#d4edda;border:2px solid #28a745;border-radius:10px;padding:20px;text-align:center;margin-bottom:16px}
43
+ .result-pass h2{color:#155724;font-size:20px}
44
+ .result-pass p{color:#155724;font-size:14px;margin-top:4px}
45
+
46
+ .result-fail{background:#f8d7da;border:2px solid #dc3545;border-radius:10px;padding:20px;text-align:center;margin-bottom:16px}
47
+ .result-fail h2{color:#721c24;font-size:20px}
48
+ .result-fail p{color:#721c24;font-size:14px;margin-top:4px}
49
+
50
+ table{width:100%;border-collapse:collapse;font-size:13px;margin-top:12px}
51
+ th{background:#343a40;color:#fff;padding:10px 8px;text-align:center;font-weight:500}
52
+ td{padding:8px;text-align:center;border-bottom:1px solid #eee}
53
+ tr.row-fail{background:#fff5f5}
54
+ tr.row-fail td{color:#721c24}
55
+
56
+ .deleted-info{background:#fff5f5;border:1px solid #dc3545;border-radius:8px;padding:10px 16px;margin-bottom:12px;font-size:13px;color:#721c24}
57
+
58
+ .loading-overlay{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:999}
59
+ .loading-box{background:#fff;border-radius:12px;padding:30px 40px;text-align:center;box-shadow:0 8px 30px rgba(0,0,0,.2)}
60
+ .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}
61
+ @keyframes spin{to{transform:rotate(360deg)}}
62
+
63
+ .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}
64
+ .login-card h2{margin-bottom:20px;color:#333}
65
+ .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}
66
+ .login-card input:focus{border-color:#667eea}
67
+
68
+ .error-msg{color:#dc3545;font-size:13px;margin-top:4px;min-height:18px}
69
+
70
+ .hidden{display:none!important}
71
+ </style>
72
+ </head>
73
+ <body>
74
+
75
+ <div id="loginView" class="container">
76
+ <div class="login-card">
77
+ <div style="font-size:48px;margin-bottom:12px">🚀</div>
78
+ <h2>场景一致性检测系统</h2>
79
+ <p style="color:#999;font-size:13px;margin-bottom:16px">DINOv2 reg4 + BGE + PaddleOCR (CPU)</p>
80
+ <input type="text" id="usernameInput" placeholder="请输入用户名" maxlength="50">
81
+ <div id="loginError" class="error-msg"></div>
82
+ <button class="btn btn-primary" style="width:100%;margin-top:8px" onclick="doLogin()">确认登录</button>
83
+ </div>
84
+ </div>
85
+
86
+ <div id="mainView" class="container hidden">
87
+ <div class="header">
88
+ <h1>场景一致性检测系统</h1>
89
+ <p>基于 DINOv2 reg4 + BGE 的多角度场景一致性评估</p>
90
+ </div>
91
+
92
+ <div class="status-bar">
93
+ <div class="status-info">
94
+ <span>👤 用户: <b id="dispUser">-</b></span>
95
+ <span>📁 历史图片: <b id="dispCount">0</b> 张</span>
96
+ </div>
97
+ <div class="status-actions">
98
+ <button class="btn btn-info" onclick="switchUser()">🔄 更换用户</button>
99
+ </div>
100
+ </div>
101
+
102
+ <div class="card">
103
+ <h3>📷 上传待检测图片 <span style="color:#666">(支持多选)</span></h3>
104
+ <div class="upload-zone" id="queryZone">
105
+ <input type="file" accept="image/*" multiple id="queryInput" onchange="onQueryChange()">
106
+ <div class="icon">🖼️</div>
107
+ <div class="hint">点击选择待检测图片(可多选)</div>
108
+ </div>
109
+ <div id="queryFileList" class="file-list"></div>
110
+ </div>
111
+
112
+ <div style="text-align:center;margin:20px 0">
113
+ <button class="btn btn-primary" style="padding:12px 40px;font-size:16px" id="detectBtn" onclick="doDetect()">🚀 开始检测</button>
114
+ </div>
115
+
116
+ <div id="resultArea"></div>
117
+ </div>
118
+
119
+ <div id="loadingOverlay" class="loading-overlay hidden">
120
+ <div class="loading-box">
121
+ <div class="spinner"></div>
122
+ <div id="loadingText">正在检测中,请稍候...</div>
123
+ </div>
124
+ </div>
125
+
126
+ <script>
127
+ let currentUser = '';
128
+
129
+ document.getElementById('usernameInput').addEventListener('keydown', e => {
130
+ if (e.key === 'Enter') doLogin();
131
+ });
132
+
133
+ async function doLogin() {
134
+ const name = document.getElementById('usernameInput').value.trim();
135
+ const errEl = document.getElementById('loginError');
136
+ errEl.textContent = '';
137
+ if (!name) { errEl.textContent = '用户名不能为空'; return; }
138
+
139
+ try {
140
+ const fd = new FormData();
141
+ fd.append('username', name);
142
+ const res = await fetch('/api/login', { method: 'POST', body: fd });
143
+ if (!res.ok) {
144
+ const d = await res.json();
145
+ errEl.textContent = d.detail || '登录失败';
146
+ return;
147
+ }
148
+ const data = await res.json();
149
+ currentUser = data.username;
150
+ showMain(data);
151
+ } catch (e) {
152
+ errEl.textContent = '网络错误: ' + e.message;
153
+ }
154
+ }
155
+
156
+ function showMain(data) {
157
+ document.getElementById('loginView').classList.add('hidden');
158
+ document.getElementById('mainView').classList.remove('hidden');
159
+ document.getElementById('dispUser').textContent = data.username;
160
+ document.getElementById('dispCount').textContent = data.history_count;
161
+ clearUploads();
162
+ document.getElementById('resultArea').innerHTML = '';
163
+ }
164
+
165
+ function clearUploads() {
166
+ document.getElementById('queryInput').value = '';
167
+ document.getElementById('queryFileList').innerHTML = '';
168
+ }
169
+
170
+ function onQueryChange() {
171
+ const files = document.getElementById('queryInput').files;
172
+ const list = document.getElementById('queryFileList');
173
+ list.innerHTML = '';
174
+ for (const f of files) {
175
+ list.innerHTML += `<div>📄 ${f.name} (${(f.size/1024).toFixed(1)} KB)</div>`;
176
+ }
177
+ }
178
+
179
+ async function doDetect() {
180
+ const queryFiles = document.getElementById('queryInput').files;
181
+ if (queryFiles.length === 0) {
182
+ alert('请上传至少一张图片!');
183
+ return;
184
+ }
185
+
186
+ const fd = new FormData();
187
+ fd.append('username', currentUser);
188
+ for (const f of queryFiles) fd.append('images', f);
189
+
190
+ showLoading('正在进行场景比对,请稍候...');
191
+ document.getElementById('detectBtn').disabled = true;
192
+
193
+ try {
194
+ const res = await fetch('/api/detect', { method: 'POST', body: fd });
195
+ if (!res.ok) {
196
+ const d = await res.json();
197
+ alert('检测失败: ' + (d.detail || '未知错误'));
198
+ return;
199
+ }
200
+ const data = await res.json();
201
+ renderResult(data);
202
+ } catch (e) {
203
+ alert('网络错误: ' + e.message);
204
+ } finally {
205
+ hideLoading();
206
+ document.getElementById('detectBtn').disabled = false;
207
+ }
208
+ }
209
+
210
+ function renderResult(data) {
211
+ const area = document.getElementById('resultArea');
212
+ let html = '';
213
+
214
+ if (data.result === 'pass') {
215
+ if (data.reason === 'first_upload') {
216
+ html += `<div class="result-pass"><h2>✅ 评估通过</h2><p>首次上传图片已保存,跳过比对</p></div>`;
217
+ } else {
218
+ html += `<div class="result-pass"><h2>✅ 评估通过</h2><p>所有上传图片与历史图片均不构成同一场景</p></div>`;
219
+ }
220
+ } else {
221
+ html += `<div class="result-fail"><h2>❌ 评估未通过</h2><p>存在上传图片与历史图片被判定为同一场景</p></div>`;
222
+ }
223
+
224
+ const deletedFiles = (data.saved_files || []).filter(f => f.deleted);
225
+ if (deletedFiles.length > 0) {
226
+ html += `<div class="deleted-info">🗑️ <b>已删除同一场景图片:</b> ${deletedFiles.map(f => f.original_name).join('、')}</div>`;
227
+ }
228
+
229
+ if (data.evaluation_logs && data.evaluation_logs.length > 0) {
230
+ html += `<div class="card"><h3>📋 比对详情日志</h3><table>`;
231
+ html += `<thead><tr><th>待检测</th><th>历史</th><th>场景</th><th>DINOv2相似度</th><th>文本相似</th><th>BGE搜索</th><th>最终判定</th></tr></thead><tbody>`;
232
+
233
+ for (const log of data.evaluation_logs) {
234
+ const rowClass = log.is_same_scene ? ' class="row-fail"' : '';
235
+ const truncName = (n, max=14) => n.length > max ? n.substring(0, max-2) + '..' : n;
236
+ const qName = truncName(log.query_image);
237
+ const hName = truncName(log.history_image);
238
+ const sceneLabel = log.scene_type === 'text' ? '📄 文本' : '🖼️ 复杂';
239
+ const dinov2Val = log.dinov2_similarity != null ? log.dinov2_similarity.toFixed(3) : '-';
240
+ const textSim = log.text_similarity != null ? log.text_similarity.toFixed(3) : '-';
241
+ const bgeSearchVal = log.bge_search_score != null ? log.bge_search_score.toFixed(4) : '-';
242
+ const finalStatus = log.is_same_scene ? '❌ 同一' : '✅ 不同';
243
+ const errNote = log.error ? ` <span style="color:red;font-size:11px">(${log.error.substring(0,30)})</span>` : '';
244
+ html += `<tr${rowClass}>`;
245
+ html += `<td title="${log.query_image}">${qName}</td>`;
246
+ html += `<td title="${log.history_image}">${hName}</td>`;
247
+ html += `<td style="font-size:12px">${sceneLabel}</td>`;
248
+ html += `<td>${dinov2Val}</td>`;
249
+ html += `<td>${textSim}</td>`;
250
+ html += `<td>${bgeSearchVal}</td>`;
251
+ html += `<td>${finalStatus}${errNote}</td>`;
252
+ html += `</tr>`;
253
+ }
254
+ html += `</tbody></table></div>`;
255
+ }
256
+
257
+ area.innerHTML = html;
258
+ }
259
+
260
+ function switchUser() {
261
+ currentUser = '';
262
+ document.getElementById('mainView').classList.add('hidden');
263
+ document.getElementById('loginView').classList.remove('hidden');
264
+ document.getElementById('usernameInput').value = '';
265
+ document.getElementById('loginError').textContent = '';
266
+ document.getElementById('usernameInput').focus();
267
+ }
268
+
269
+ function showLoading(text) {
270
+ document.getElementById('loadingText').textContent = text || '处理中...';
271
+ document.getElementById('loadingOverlay').classList.remove('hidden');
272
+ }
273
+
274
+ function hideLoading() {
275
+ document.getElementById('loadingOverlay').classList.add('hidden');
276
+ }
277
+ </script>
278
+ </body>
279
+ </html>
module/__init__.py ADDED
File without changes
module/config.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HF Spaces CPU 部署配置 — 无 MASt3R,DINOv2 reg4 模型,Qdrant 嵌入式"""
2
+ import os
3
+ import torch
4
+
5
+ PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
6
+
7
+ # 强制 CPU 模式
8
+ DEVICE = "cpu"
9
+ USE_HALF = False
10
+ IMAGE_SIZE = 512
11
+
12
+ # MASt3R 已禁用(CPU 部署,耗时过长)
13
+ MAST3R_ROOT = None
14
+ LOCAL_WEIGHTS = None
15
+
16
+ # 历史图片存储
17
+ HISTORY_BASE_DIR = os.path.join(PROJECT_ROOT, 'History_imgs')
18
+ SUPPORTED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif', '.webp', '.jfif'}
19
+
20
+ # Qdrant 嵌入式模式(无需独立服务进程)
21
+ QDRANT_PATH = os.path.join(PROJECT_ROOT, 'qdrant_data')
22
+
23
+ # --- DINOv2 阈值(保留兼容性) ---
24
+ MIN_MATCHES = 20
25
+ MIN_INLIER_RATIO = 0.15
26
+ EDGE_MARGIN = 3
27
+ DINOV2_SIM_THRESHOLD = 0.5
28
+ DINOV2_HIGH_CONF_THRESHOLD = 0.7
29
+ STRICT_MIN_MATCHES = 100
30
+ STRICT_MIN_INLIER_RATIO = 0.50
31
+ MODERATE_MIN_MATCHES = 350
32
+ MODERATE_MIN_INLIER_RATIO = 0.55
33
+
34
+ # DINOv2-only 模式阈值(无 MASt3R):相似度 ≥ 此值 → 判定为同一场景
35
+ # reg4 模型的 CLS 特征质量更好,0.55 为推荐起始值,可按业务调优
36
+ DINOV2_ONLY_SAME_THRESHOLD = 0.55
37
+
38
+ # 日志目录
39
+ LOGS_DIR = os.path.join(PROJECT_ROOT, 'Logs')
40
+
41
+ # --- 场景分类器 ---
42
+ TEXT_AREA_WEIGHT = 0.15
43
+ WHITENESS_WEIGHT = 0.50
44
+ LOW_VARIANCE_WEIGHT = 0.25
45
+ LINE_DENSITY_WEIGHT = 0.10
46
+ DOC_SCORE_THRESHOLD = 0.45
47
+
48
+ # --- 文本场景比对 ---
49
+ TEXT_SIM_WEIGHT = 0.6
50
+ SSIM_WEIGHT = 0.3
51
+ SEAL_WEIGHT = 0.1
52
+ TEXT_SCENE_THRESHOLD = 0.7
53
+ TEXT_SCENE_BGE_THRESHOLD = 0.85
54
+
55
+ # BGE 模型:使用 HuggingFace Hub 在线加载(Dockerfile 中预下载)
56
+ BGE_MODEL_PATH = 'BAAI/bge-small-zh-v1.5'
module/dinov2_utils.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DINOv2 特征提取器 — vit_small_patch14_reg4_dinov2 (带寄存器令牌)"""
2
+ import torch
3
+ import torch.nn.functional as F
4
+ from torchvision import transforms
5
+ from PIL import Image
6
+
7
+ from module.config import DEVICE
8
+
9
+
10
+ class DINOv2Extractor:
11
+ """DINOv2 全局语义特征提取器
12
+
13
+ 使用 vit_small_patch14_reg4_dinov2 模型(4个寄存器令牌),
14
+ 相比无寄存器版本,CLS token 特征质量更高,对背景噪声更鲁棒。
15
+ 输出 384 维归一化特征向量。
16
+ """
17
+
18
+ TIMM_MODEL_NAME = 'vit_small_patch14_reg4_dinov2'
19
+
20
+ def __init__(self, device=None):
21
+ self.device = device or DEVICE
22
+ self.model = None
23
+ self._use_timm = False
24
+ # DINOv2 标准预处理:518x518,ImageNet 归一化
25
+ self.transform = transforms.Compose([
26
+ transforms.Resize(518, interpolation=transforms.InterpolationMode.BICUBIC),
27
+ transforms.CenterCrop(518),
28
+ transforms.ToTensor(),
29
+ transforms.Normalize(mean=[0.485, 0.456, 0.406],
30
+ std=[0.229, 0.224, 0.225]),
31
+ ])
32
+
33
+ try:
34
+ import timm
35
+ print(f"通过 timm 加载 DINOv2 (reg4): {self.TIMM_MODEL_NAME}")
36
+ self.model = timm.create_model(self.TIMM_MODEL_NAME, pretrained=True)
37
+ self._use_timm = True
38
+ self.model = self.model.to(self.device).eval()
39
+ print("DINOv2 reg4 模型加载完成")
40
+ except Exception as e:
41
+ print(f"timm 加载 DINOv2 失败: {e}")
42
+ self.model = None
43
+
44
+ @property
45
+ def is_available(self):
46
+ return self.model is not None
47
+
48
+ @torch.no_grad()
49
+ def _get_cls_feature(self, img_tensor):
50
+ """从 forward_features 提取 CLS token,适配 timm 的 dict 返回格式"""
51
+ features = self.model.forward_features(img_tensor)
52
+ # timm 0.9+ 返回 dict
53
+ if isinstance(features, dict):
54
+ if 'x_norm_clstoken' in features:
55
+ return features['x_norm_clstoken']
56
+ # 兜底:取第一个值的 CLS 位置
57
+ for v in features.values():
58
+ if isinstance(v, torch.Tensor) and v.dim() >= 2:
59
+ return v[:, 0]
60
+ # 旧版 timm 返回 tensor (B, N, C)
61
+ if isinstance(features, torch.Tensor):
62
+ return features[:, 0]
63
+ raise ValueError(f"无法从 forward_features 提取 CLS token: {type(features)}")
64
+
65
+ @torch.no_grad()
66
+ def extract_feature(self, img_path):
67
+ """提取单张图片的 DINOv2 CLS 全局特征向量(384维,已归一化)"""
68
+ if self.model is None:
69
+ return None
70
+ img = Image.open(img_path).convert('RGB')
71
+ tensor = self.transform(img).unsqueeze(0).to(self.device)
72
+ cls_token = self._get_cls_feature(tensor)
73
+ return F.normalize(cls_token, dim=-1).squeeze(0)
74
+
75
+ @torch.no_grad()
76
+ def compute_similarity(self, img_path1, img_path2):
77
+ """计算两张图片的 DINOv2 CLS 特征余弦相似度(返回 0~1)"""
78
+ feat1 = self.extract_feature(img_path1)
79
+ feat2 = self.extract_feature(img_path2)
80
+ if feat1 is None or feat2 is None:
81
+ return None
82
+ sim = F.cosine_similarity(feat1.unsqueeze(0), feat2.unsqueeze(0))
83
+ return float(sim.item())
module/evaluator.py ADDED
@@ -0,0 +1,338 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import traceback
3
+ import numpy as np
4
+ from datetime import datetime
5
+
6
+ from module.file_manager import get_history_images, load_metadata, save_metadata
7
+ from module.text_classifier import classify_scene
8
+ from module.text_matcher import compare_text_scene, compare_text_scene_cached, compare_text_scene_pure_bge
9
+
10
+
11
+ DINO_PRESCREEN_THRESHOLD = 0.3
12
+
13
+
14
+ def evaluate_scene_consistency_cached(matcher, query_paths, username,
15
+ ocr_engine=None, bge_tokenizer=None,
16
+ bge_model=None, scene_type_q='complex',
17
+ qd_search_fn=None, dinov2_extractor=None,
18
+ precomputed_ocr_result=None,
19
+ precomputed_bge_vec=None,
20
+ qd_cache=None):
21
+ """Qdrant ANN 搜索管线: DINOv2/BGE 预筛 + MASt3R 精确判定 + 早停
22
+
23
+ 流程 (与 test_5.ipynb 一致):
24
+ 文本场景:
25
+ 1. 提取查询图 OCR 文本 -> BGE 编码
26
+ 2. Qdrant ANN 搜索 (BGE 向量) -> top-K 候选
27
+ 3. 对候选逐一 compare_text_scene_pure_bge (纯BGE语义向量)
28
+ 4. 早停: 匹配到同场景即跳过剩余
29
+ 复杂场景:
30
+ 1. 提取查询图 DINOv2 特征
31
+ 2. Qdrant ANN 搜索 (DINOv2 向量) -> top-K 候选
32
+ 3. DINOv2 预筛: 相似度 < 0.3 直接跳过 MASt3R
33
+ 4. 通过预筛的候选 -> matcher.compare() (MASt3R 联合推理)
34
+ 5. 早停: 匹配到同场景即跳过剩余
35
+ """
36
+ evaluation_logs = []
37
+ best_match_result = None
38
+ best_match_score = -1
39
+
40
+ import time as _t
41
+ for q_path in query_paths:
42
+ if scene_type_q == 'text' and bge_tokenizer is not None:
43
+ _et0 = _t.perf_counter()
44
+ if precomputed_ocr_result is not None:
45
+ full_text = ' '.join(line[1][0] for line in precomputed_ocr_result[0]) if (precomputed_ocr_result and precomputed_ocr_result[0]) else ''
46
+ else:
47
+ from module.text_matcher import extract_text
48
+ full_text, _ = extract_text(q_path, ocr_engine)
49
+ _et1 = _t.perf_counter()
50
+ if precomputed_bge_vec is not None:
51
+ q_vec = precomputed_bge_vec
52
+ else:
53
+ from module.qdrant_manager import _bge_encode
54
+ q_vec = _bge_encode(full_text, bge_tokenizer, bge_model)
55
+ _et2 = _t.perf_counter()
56
+ _qd_cache = qd_cache.get('text', {}) if (qd_cache and isinstance(qd_cache, dict)) else {}
57
+ if not _qd_cache:
58
+ from module.qdrant_manager import qdrant_query_scene
59
+ _qd_cache = qdrant_query_scene(username, 'text')
60
+ _et3 = _t.perf_counter()
61
+ hits = []
62
+ for _pid, _entry in _qd_cache.items():
63
+ _h_path = _entry.get('path', '')
64
+ if os.path.abspath(q_path) == os.path.abspath(_h_path):
65
+ continue
66
+ if not os.path.exists(_h_path):
67
+ continue
68
+ _h_vec = _entry.get('_vector', _entry.get('bge_vector', np.zeros(512, dtype=np.float32)))
69
+ _sim = float(np.dot(q_vec, _h_vec))
70
+ if _sim >= 0.3:
71
+ hits.append((_sim, _pid, _entry))
72
+ hits.sort(key=lambda x: x[0], reverse=True)
73
+ hits = hits[:3]
74
+ print(f' [TIME-EVAL] text_extract: {_et1-_et0:.3f}s bge_encode: {_et2-_et1:.3f}s qdrant_scroll: {_et3-_et2:.3f}s python_search: {_t.perf_counter()-_et3:.3f}s n_hits: {len(hits)}')
75
+
76
+ for score, pid, entry in hits:
77
+ h_path = entry.get('path', '')
78
+ if os.path.abspath(q_path) == os.path.abspath(h_path):
79
+ continue
80
+ try:
81
+ result = compare_text_scene_pure_bge(
82
+ q_path, entry, ocr_engine, bge_tokenizer, bge_model,
83
+ precomputed_text=full_text, precomputed_bge=q_vec
84
+ )
85
+ log_entry = {
86
+ 'query_image': os.path.basename(q_path),
87
+ 'history_image': entry.get('filename', ''),
88
+ 'query_path': q_path,
89
+ 'history_path': h_path,
90
+ 'scene_type': 'text',
91
+ 'doc_score1': 0.0,
92
+ 'doc_score2': 0.0,
93
+ 'similarity_score': result['similarity_score'],
94
+ 'match_count': result.get('match_count', 0),
95
+ 'raw_match_count': 0,
96
+ 'inlier_ratio': result.get('inlier_ratio', 0.0),
97
+ 'avg_confidence': result.get('avg_confidence', 0.0),
98
+ 'text_similarity': result.get('text_similarity'),
99
+ 'ssim_score': result.get('ssim_score'),
100
+ 'seal_bonus': result.get('seal_bonus'),
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 = {
107
+ 'query_image': os.path.basename(q_path),
108
+ 'history_image': entry.get('filename', ''),
109
+ 'query_path': q_path,
110
+ 'history_path': h_path,
111
+ 'scene_type': 'text',
112
+ 'doc_score1': 0.0, 'doc_score2': 0.0,
113
+ 'similarity_score': 0.0,
114
+ 'match_count': 0, 'raw_match_count': 0,
115
+ 'inlier_ratio': 0.0, 'avg_confidence': 0.0,
116
+ 'is_same_scene': False, 'gamma_info': [],
117
+ 'error': str(e),
118
+ }
119
+ evaluation_logs.append(log_entry)
120
+
121
+ if log_entry['is_same_scene']:
122
+ if log_entry['similarity_score'] > best_match_score:
123
+ best_match_score = log_entry['similarity_score']
124
+ best_match_result = log_entry.copy()
125
+ print(f" [早停] {os.path.basename(q_path)} 与 {log_entry['history_image']} 匹配为同场景, 跳过剩余比对")
126
+ break
127
+ elif log_entry['similarity_score'] > best_match_score:
128
+ best_match_score = log_entry['similarity_score']
129
+ best_match_result = log_entry.copy()
130
+
131
+ elif scene_type_q == 'complex' and dinov2_extractor is not None:
132
+ from module.qdrant_manager import _dinov2_encode
133
+ q_vec = _dinov2_encode(q_path, dinov2_extractor)
134
+ if not isinstance(q_vec, np.ndarray):
135
+ q_vec = q_vec.cpu().numpy() if hasattr(q_vec, 'cpu') else np.array(q_vec, dtype=np.float32)
136
+ _qd_cache_c = qd_cache.get('complex', {}) if (qd_cache and isinstance(qd_cache, dict)) else {}
137
+ if not _qd_cache_c:
138
+ from module.qdrant_manager import qdrant_query_scene
139
+ _qd_cache_c = qdrant_query_scene(username, 'complex')
140
+ hits = []
141
+ for _pid, _entry in _qd_cache_c.items():
142
+ _h_path = _entry.get('path', '')
143
+ if os.path.abspath(q_path) == os.path.abspath(_h_path):
144
+ continue
145
+ if not os.path.exists(_h_path):
146
+ continue
147
+ _h_vec = _entry.get('_vector', np.zeros(384, dtype=np.float32))
148
+ _sim = float(np.dot(q_vec, _h_vec))
149
+ if _sim >= DINO_PRESCREEN_THRESHOLD:
150
+ hits.append((_sim, _pid, _entry))
151
+ hits.sort(key=lambda x: x[0], reverse=True)
152
+ hits = hits[:3]
153
+
154
+ for score, pid, entry in hits:
155
+ h_path = entry.get('path', '')
156
+ if os.path.abspath(q_path) == os.path.abspath(h_path):
157
+ continue
158
+ dino_sim = score
159
+
160
+ if dino_sim < DINO_PRESCREEN_THRESHOLD:
161
+ log_entry = {
162
+ 'query_image': os.path.basename(q_path),
163
+ 'history_image': entry.get('filename', ''),
164
+ 'query_path': q_path,
165
+ 'history_path': h_path,
166
+ 'scene_type': 'complex',
167
+ 'doc_score1': 0.0, 'doc_score2': 0.0,
168
+ 'similarity_score': round(dino_sim, 4),
169
+ 'match_count': 0, 'raw_match_count': 0,
170
+ 'inlier_ratio': 0.0, 'avg_confidence': 0.0,
171
+ 'dinov2_similarity': round(dino_sim, 4),
172
+ 'is_same_scene': False, 'gamma_info': [],
173
+ 'mast3r_skipped': True,
174
+ 'dino_search_score': round(dino_sim, 4),
175
+ }
176
+ else:
177
+ try:
178
+ result = matcher.compare(q_path, h_path, dinov2_sim_override=dino_sim)
179
+ log_entry = {
180
+ 'query_image': os.path.basename(q_path),
181
+ 'history_image': entry.get('filename', ''),
182
+ 'query_path': q_path,
183
+ 'history_path': h_path,
184
+ 'scene_type': 'complex',
185
+ 'doc_score1': 0.0, 'doc_score2': 0.0,
186
+ 'similarity_score': result['similarity_score'],
187
+ 'match_count': result['match_count'],
188
+ 'raw_match_count': result['raw_match_count'],
189
+ 'inlier_ratio': result['inlier_ratio'],
190
+ 'avg_confidence': result['avg_confidence'],
191
+ 'mast3r_is_same': result.get('mast3r_is_same'),
192
+ 'dinov2_is_same': result.get('dinov2_is_same'),
193
+ 'dinov2_similarity': result.get('dinov2_similarity'),
194
+ 'is_same_scene': result['is_same_scene'],
195
+ 'gamma_info': result.get('gamma_info', []),
196
+ 'mast3r_skipped': False,
197
+ 'dino_search_score': round(dino_sim, 4),
198
+ }
199
+ except Exception as e:
200
+ log_entry = {
201
+ 'query_image': os.path.basename(q_path),
202
+ 'history_image': entry.get('filename', ''),
203
+ 'query_path': q_path,
204
+ 'history_path': h_path,
205
+ 'scene_type': 'complex',
206
+ 'doc_score1': 0.0, 'doc_score2': 0.0,
207
+ 'similarity_score': 0.0,
208
+ 'match_count': 0, 'raw_match_count': 0,
209
+ 'inlier_ratio': 0.0, 'avg_confidence': 0.0,
210
+ 'is_same_scene': False, 'gamma_info': [],
211
+ 'error': str(e),
212
+ }
213
+ evaluation_logs.append(log_entry)
214
+
215
+ if log_entry['is_same_scene']:
216
+ if log_entry['similarity_score'] > best_match_score:
217
+ best_match_score = log_entry['similarity_score']
218
+ best_match_result = log_entry.copy()
219
+ print(f" [早停] {os.path.basename(q_path)} 与 {log_entry['history_image']} 匹配为同场景, 跳过剩余比对")
220
+ break
221
+ elif log_entry['similarity_score'] > best_match_score:
222
+ best_match_score = log_entry['similarity_score']
223
+ best_match_result = log_entry.copy()
224
+
225
+ any_same = any(log['is_same_scene'] for log in evaluation_logs)
226
+ final_result = not any_same
227
+
228
+ return final_result, evaluation_logs, best_match_result
229
+
230
+
231
+ def evaluate_scene_consistency(matcher, query_paths, history_paths,
232
+ ocr_engine=None, bge_tokenizer=None, bge_model=None):
233
+ evaluation_logs = []
234
+ best_match_result = None
235
+ best_match_score = -1
236
+
237
+ for q_path in query_paths:
238
+ for h_path in history_paths:
239
+ if os.path.abspath(q_path) == os.path.abspath(h_path):
240
+ continue
241
+
242
+ try:
243
+ scene_type1, doc_score1, detail1 = 'complex', 0.0, {}
244
+ scene_type2, doc_score2, detail2 = 'complex', 0.0, {}
245
+ if ocr_engine is not None:
246
+ scene_type1, doc_score1, detail1, _ = classify_scene(q_path, ocr_engine)
247
+ scene_type2, doc_score2, detail2, _ = classify_scene(h_path, ocr_engine)
248
+
249
+ if scene_type1 == 'text' and scene_type2 == 'text' and bge_tokenizer is not None:
250
+ result = compare_text_scene(
251
+ q_path, h_path, ocr_engine, bge_tokenizer, bge_model
252
+ )
253
+ log_entry = {
254
+ 'query_image': os.path.basename(q_path),
255
+ 'history_image': os.path.basename(h_path),
256
+ 'query_path': q_path,
257
+ 'history_path': h_path,
258
+ 'scene_type': 'text',
259
+ 'doc_score1': doc_score1,
260
+ 'doc_score2': doc_score2,
261
+ 'similarity_score': result['similarity_score'],
262
+ 'match_count': result.get('match_count', 0),
263
+ 'raw_match_count': 0,
264
+ 'inlier_ratio': result.get('inlier_ratio', 0.0),
265
+ 'avg_confidence': result.get('avg_confidence', 0.0),
266
+ 'text_similarity': result.get('text_similarity'),
267
+ 'ssim_score': result.get('ssim_score'),
268
+ 'seal_bonus': result.get('seal_bonus'),
269
+ 'is_same_scene': result['is_same_scene'],
270
+ 'gamma_info': [],
271
+ }
272
+ else:
273
+ result = matcher.compare(q_path, h_path)
274
+ log_entry = {
275
+ 'query_image': os.path.basename(q_path),
276
+ 'history_image': os.path.basename(h_path),
277
+ 'query_path': q_path,
278
+ 'history_path': h_path,
279
+ 'scene_type': 'complex',
280
+ 'doc_score1': doc_score1,
281
+ 'doc_score2': doc_score2,
282
+ 'similarity_score': result['similarity_score'],
283
+ 'match_count': result['match_count'],
284
+ 'raw_match_count': result['raw_match_count'],
285
+ 'inlier_ratio': result['inlier_ratio'],
286
+ 'avg_confidence': result['avg_confidence'],
287
+ 'mast3r_is_same': result.get('mast3r_is_same'),
288
+ 'dinov2_is_same': result.get('dinov2_is_same'),
289
+ 'dinov2_similarity': result.get('dinov2_similarity'),
290
+ 'is_same_scene': result['is_same_scene'],
291
+ 'gamma_info': result.get('gamma_info', []),
292
+ }
293
+ except Exception as e:
294
+ log_entry = {
295
+ 'query_image': os.path.basename(q_path),
296
+ 'history_image': os.path.basename(h_path),
297
+ 'query_path': q_path,
298
+ 'history_path': h_path,
299
+ 'scene_type': 'unknown',
300
+ 'doc_score1': 0.0, 'doc_score2': 0.0,
301
+ 'similarity_score': 0.0,
302
+ 'match_count': 0, 'raw_match_count': 0,
303
+ 'inlier_ratio': 0.0, 'avg_confidence': 0.0,
304
+ 'is_same_scene': False, 'gamma_info': [],
305
+ 'error': str(e),
306
+ 'traceback': traceback.format_exc(),
307
+ }
308
+ evaluation_logs.append(log_entry)
309
+
310
+ if log_entry['is_same_scene']:
311
+ if log_entry['similarity_score'] > best_match_score:
312
+ best_match_score = log_entry['similarity_score']
313
+ best_match_result = log_entry.copy()
314
+ print(f" [早停] {os.path.basename(q_path)} 与 {log_entry['history_image']} 匹配为同场景, 跳过剩余比对")
315
+ break
316
+ elif log_entry['similarity_score'] > best_match_score:
317
+ best_match_score = log_entry['similarity_score']
318
+ best_match_result = log_entry.copy()
319
+
320
+ any_same = any(log['is_same_scene'] for log in evaluation_logs)
321
+ final_result = not any_same
322
+
323
+ return final_result, evaluation_logs, best_match_result
324
+
325
+
326
+ def update_metadata(user_dir, saved_info, per_image_results):
327
+ metadata = load_metadata(user_dir)
328
+ for original_name, save_path in saved_info:
329
+ img_result = per_image_results.get(save_path, '未知')
330
+ img_meta = {
331
+ 'filename': os.path.basename(save_path),
332
+ 'path': os.path.abspath(save_path),
333
+ 'upload_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
334
+ 'original_name': original_name,
335
+ 'evaluation_result': img_result,
336
+ }
337
+ metadata['images'].append(img_meta)
338
+ save_metadata(user_dir, metadata)
module/file_manager.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ from datetime import datetime
4
+
5
+ from module.config import HISTORY_BASE_DIR, SUPPORTED_EXTENSIONS
6
+
7
+
8
+ def validate_username(username):
9
+ """验证用户名合法性(非空、无非法字符、长度限制)"""
10
+ if not username or not username.strip():
11
+ raise ValueError('用户名不能为空')
12
+ username = username.strip()
13
+ invalid_chars = '<>:"/\\|?*'
14
+ for char in invalid_chars:
15
+ if char in username:
16
+ raise ValueError(f"用户名包含非法字符: '{char}'")
17
+ if len(username) > 50:
18
+ raise ValueError('用户名长度不能超过50个字符')
19
+ return username
20
+
21
+
22
+ def get_user_dir(username):
23
+ """获取用户目录路径,若不存在则自动创建"""
24
+ user_dir = os.path.join(HISTORY_BASE_DIR, username)
25
+ os.makedirs(user_dir, exist_ok=True)
26
+ return user_dir
27
+
28
+
29
+ def load_metadata(user_dir):
30
+ """从用户目录加载元数据JSON,文件不存在或损坏时返回空结构"""
31
+ metadata_path = os.path.join(user_dir, 'metadata.json')
32
+ if os.path.exists(metadata_path):
33
+ try:
34
+ with open(metadata_path, 'r', encoding='utf-8') as f:
35
+ return json.load(f)
36
+ except (json.JSONDecodeError, IOError):
37
+ pass
38
+ return {'images': []}
39
+
40
+
41
+ def save_metadata(user_dir, metadata):
42
+ """将元数据JSON写入用户目录"""
43
+ metadata_path = os.path.join(user_dir, 'metadata.json')
44
+ with open(metadata_path, 'w', encoding='utf-8') as f:
45
+ json.dump(metadata, f, ensure_ascii=False, indent=2)
46
+
47
+
48
+ def generate_image_filename(original_name):
49
+ """生成带时间戳的唯一文件名,格式:时间戳_原始文件名"""
50
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S_%f')
51
+ name, ext = os.path.splitext(original_name)
52
+ if ext.lower() not in SUPPORTED_EXTENSIONS:
53
+ ext = '.jpg'
54
+ return f'{timestamp}_{name}{ext}'
55
+
56
+
57
+ def save_uploaded_file(file_data, original_name, user_dir):
58
+ """将上传的文件数据保存到用户目录,返回保存路径"""
59
+ filename = generate_image_filename(original_name)
60
+ save_path = os.path.join(user_dir, filename)
61
+ with open(save_path, 'wb') as f:
62
+ f.write(file_data)
63
+ return save_path
64
+
65
+
66
+ def delete_file(path):
67
+ """删除指定路径的文件"""
68
+ if os.path.exists(path):
69
+ os.remove(path)
70
+
71
+
72
+ def get_history_images(user_dir):
73
+ """扫描用户目录下所有支持格式的图片,返回按文件名排序的路径列表"""
74
+ images = []
75
+ if not os.path.exists(user_dir):
76
+ return images
77
+ for fname in sorted(os.listdir(user_dir)):
78
+ ext = os.path.splitext(fname)[1].lower()
79
+ if ext in SUPPORTED_EXTENSIONS:
80
+ images.append(os.path.join(user_dir, fname))
81
+ return images
module/image_utils.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import torch
3
+
4
+
5
+ def apply_gamma_tensor(img_tensor, low=0.15, high=0.85, target=0.5):
6
+ """对图像张量进行Gamma校正,当亮度超出[low, high]范围时自动调整到target亮度"""
7
+ arr = img_tensor.detach().cpu().numpy()
8
+ arr = (arr * 0.5) + 0.5
9
+ arr = np.clip(arr.squeeze(0).transpose(1, 2, 0), 0.0, 1.0)
10
+
11
+ lum = float(np.dot(arr[..., :3], [0.299, 0.587, 0.114]).mean())
12
+
13
+ if low <= lum <= high:
14
+ return img_tensor, lum, False, 1.0
15
+
16
+ eps = 1e-6
17
+ gamma = np.log(target + eps) / np.log(lum + eps)
18
+ gamma = float(np.clip(gamma, 0.5, 2.5))
19
+
20
+ arr = np.clip(arr ** gamma, 0.0, 1.0)
21
+ tensor = torch.from_numpy(((arr - 0.5) / 0.5).transpose(2, 0, 1)).unsqueeze(0).float()
22
+ return tensor, lum, True, gamma
module/model_loader.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """模型加载器 — CPU 部署版(无 MASt3R)
2
+
3
+ 保留 DINOv2 / PaddleOCR / BGE 加载逻辑,
4
+ 移除 MASt3R 加载(load_model 返回 None)。
5
+ """
6
+ import os
7
+ import sys
8
+ import importlib
9
+ import importlib.util
10
+
11
+ # 屏蔽 tensorflow/jax 导入(transformers 间接依赖,与 numpy 1.x 不兼容)
12
+ _original_find_spec = importlib.util.find_spec
13
+
14
+
15
+ def _patched_find_spec(name, package=None):
16
+ _blocked = ('tensorflow', 'jax', 'jaxlib')
17
+ if name in _blocked or any(name.startswith(b + '.') for b in _blocked):
18
+ return None
19
+ return _original_find_spec(name, package)
20
+
21
+
22
+ importlib.util.find_spec = _patched_find_spec
23
+
24
+ import numpy as np
25
+
26
+ # numpy 2.x 兼容补丁(imgaug 依赖 np.sctypes)
27
+ if not hasattr(np, 'sctypes'):
28
+ np.sctypes = {
29
+ 'int': [np.int8, np.int16, np.int32, np.int64],
30
+ 'uint': [np.uint8, np.uint16, np.uint32, np.uint64],
31
+ 'float': [np.float16, np.float32, np.float64],
32
+ 'complex': [np.complex64, np.complex128],
33
+ 'others': [bool, object, bytes, str, np.void],
34
+ }
35
+
36
+ from module.config import DEVICE, BGE_MODEL_PATH
37
+
38
+ _model = None
39
+ _dinov2_extractor = None
40
+ _ocr_engine = None
41
+ _bge_tokenizer = None
42
+ _bge_model = None
43
+
44
+
45
+ def get_model():
46
+ return _model
47
+
48
+
49
+ def get_dinov2():
50
+ global _dinov2_extractor
51
+ if _dinov2_extractor is None:
52
+ load_dinov2()
53
+ return _dinov2_extractor
54
+
55
+
56
+ def get_ocr():
57
+ global _ocr_engine
58
+ if _ocr_engine is None:
59
+ load_ocr()
60
+ return _ocr_engine
61
+
62
+
63
+ def get_bge():
64
+ global _bge_tokenizer, _bge_model
65
+ if _bge_tokenizer is None or _bge_model is None:
66
+ load_bge()
67
+ return _bge_tokenizer, _bge_model
68
+
69
+
70
+ def load_model():
71
+ """MASt3R 已禁用(CPU 部署模式)"""
72
+ global _model
73
+ print("[INFO] MASt3R 已禁用(CPU 部署,使用 DINOv2-only 模式)")
74
+ _model = None
75
+ return _model
76
+
77
+
78
+ def load_dinov2():
79
+ """加载 DINOv2 模型(vit_small_patch14_reg4_dinov2)"""
80
+ global _dinov2_extractor
81
+ if _dinov2_extractor is not None:
82
+ return _dinov2_extractor
83
+
84
+ from module.dinov2_utils import DINOv2Extractor
85
+ print("加载 DINOv2 模型...")
86
+ try:
87
+ _dinov2_extractor = DINOv2Extractor()
88
+ if not _dinov2_extractor.is_available:
89
+ _dinov2_extractor = None
90
+ print("DINOv2 不可用,复杂场景检测将无法工作")
91
+ except Exception as e:
92
+ _dinov2_extractor = None
93
+ print(f"DINOv2 加载异常: {e}")
94
+ return _dinov2_extractor
95
+
96
+
97
+ def load_ocr():
98
+ """加载 PaddleOCR 引擎(CPU 模式)"""
99
+ global _ocr_engine
100
+ if _ocr_engine is not None:
101
+ return _ocr_engine
102
+
103
+ print("加载 PaddleOCR...")
104
+ try:
105
+ from paddleocr import PaddleOCR
106
+ _ocr_engine = PaddleOCR(
107
+ use_angle_cls=True,
108
+ lang='ch',
109
+ )
110
+ print("PaddleOCR 加载完成")
111
+ except Exception as e:
112
+ _ocr_engine = None
113
+ print(f"PaddleOCR 加载异常: {e}")
114
+ print("文本场景检测将不可用")
115
+ return _ocr_engine
116
+
117
+
118
+ def load_bge():
119
+ """加载 BGE-small-zh 语义嵌入模型(从 HuggingFace Hub 下载)"""
120
+ global _bge_tokenizer, _bge_model
121
+ if _bge_tokenizer is not None and _bge_model is not None:
122
+ return _bge_tokenizer, _bge_model
123
+
124
+ print("加载 BGE-small-zh 模型...")
125
+ try:
126
+ from transformers import AutoTokenizer
127
+ from transformers.models.bert.modeling_bert import BertModel
128
+
129
+ _bge_tokenizer = AutoTokenizer.from_pretrained(BGE_MODEL_PATH)
130
+ _bge_model = BertModel.from_pretrained(BGE_MODEL_PATH)
131
+ _bge_model.eval()
132
+ print("BGE-small-zh 加载完成")
133
+ except Exception as e:
134
+ _bge_tokenizer = None
135
+ _bge_model = None
136
+ print(f"BGE-small-zh 加载异常: {e}")
137
+ print("文本语义比对将不可用")
138
+ return _bge_tokenizer, _bge_model
module/qdrant_manager.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Qdrant 向量数据库管理器 — 嵌入式模式(无需独立服务进程)
2
+
3
+ 使用 QdrantClient(path=...) 在本地文件系统中运行 Qdrant,
4
+ 适合 HF Spaces 单容器部署,无需额外启动 Qdrant 服务。
5
+ """
6
+ import os
7
+ import hashlib
8
+ import uuid
9
+
10
+ import numpy as np
11
+ import torch
12
+ from qdrant_client import QdrantClient
13
+ from qdrant_client.models import (
14
+ Distance, VectorParams, PointStruct,
15
+ OptimizersConfigDiff, HnswConfigDiff,
16
+ )
17
+
18
+ from module.config import QDRANT_PATH
19
+ from module.text_classifier import classify_scene
20
+
21
+ COLL_PREFIX = 'user_'
22
+
23
+ _client_instance = None
24
+
25
+
26
+ def _get_client():
27
+ """获取 Qdrant 客户端单例(嵌入式本地文件模式)"""
28
+ global _client_instance
29
+ if _client_instance is None:
30
+ os.makedirs(QDRANT_PATH, exist_ok=True)
31
+ _client_instance = QdrantClient(path=QDRANT_PATH)
32
+ return _client_instance
33
+
34
+
35
+ def pt_id(img_path):
36
+ """根据图片绝对路径生成确定性 UUID"""
37
+ return str(uuid.UUID(hex=hashlib.md5(os.path.abspath(img_path).encode()).hexdigest()))
38
+
39
+
40
+ def _collection_name(username, scene_type):
41
+ return f'{COLL_PREFIX}{username}_{scene_type}'
42
+
43
+
44
+ def _bge_encode(text, bge_tokenizer, bge_model):
45
+ """编码文本为 numpy float32[512]"""
46
+ if not text.strip():
47
+ return np.zeros(512, dtype=np.float32)
48
+ encoded = bge_tokenizer(text, padding=True, truncation=True, return_tensors='pt', max_length=512)
49
+ # CPU 模式:不调用 .cuda()
50
+ with torch.no_grad():
51
+ outputs = bge_model(**encoded)
52
+ emb = outputs.last_hidden_state[:, 0]
53
+ emb = torch.nn.functional.normalize(emb, p=2, dim=1)
54
+ return emb.cpu().numpy()[0]
55
+
56
+
57
+ def _dinov2_encode(img_path, dinov2_extractor):
58
+ """提取 DINOv2 特征向量(384维)"""
59
+ if dinov2_extractor is None or not dinov2_extractor.is_available:
60
+ return np.zeros(384, dtype=np.float32)
61
+ feat = dinov2_extractor.extract_feature(img_path)
62
+ if feat is None:
63
+ return np.zeros(384, dtype=np.float32)
64
+ return feat.cpu().numpy()
65
+
66
+
67
+ # ==================== 预存储: 构建用户缓存 ====================
68
+
69
+ def build_user_cache(username, user_dir, ocr_engine, dinov2_extractor,
70
+ bge_tokenizer, bge_model):
71
+ """为用户目录下所有历史图片构建 Qdrant 缓存"""
72
+ from module.file_manager import get_history_images
73
+ client = _get_client()
74
+ images = get_history_images(user_dir)
75
+ if not images:
76
+ return 0, 0
77
+
78
+ text_pts, complex_pts = [], []
79
+
80
+ for img_path in images:
81
+ try:
82
+ scene_type, doc_score, detail, ocr_result = classify_scene(img_path, ocr_engine)
83
+ pid = pt_id(img_path)
84
+ payload = {'path': img_path, 'filename': os.path.basename(img_path)}
85
+
86
+ if scene_type == 'text':
87
+ full_text = detail.get('full_text', '') if detail else ''
88
+ payload['ocr_text'] = full_text
89
+ vector = _bge_encode(full_text, bge_tokenizer, bge_model)
90
+ text_pts.append(PointStruct(id=pid, vector=vector.tolist(), payload=payload))
91
+ else:
92
+ vector = _dinov2_encode(img_path, dinov2_extractor)
93
+ complex_pts.append(PointStruct(id=pid, vector=vector.tolist(), payload=payload))
94
+
95
+ print(f" [qdrant] {os.path.basename(img_path):<30s} {scene_type}")
96
+ except Exception as e:
97
+ print(f" [qdrant] {os.path.basename(img_path)} ERROR: {e}")
98
+
99
+ for suffix, pts, dim in [('text', text_pts, 512), ('complex', complex_pts, 384)]:
100
+ if not pts:
101
+ continue
102
+ cname = _collection_name(username, suffix)
103
+ if client.collection_exists(cname):
104
+ client.delete_collection(cname)
105
+ client.create_collection(
106
+ cname,
107
+ vectors_config=VectorParams(size=dim, distance=Distance.COSINE),
108
+ optimizers_config=OptimizersConfigDiff(indexing_threshold=0),
109
+ )
110
+ client.upsert(cname, points=pts, wait=True)
111
+
112
+ for suffix in ['text', 'complex']:
113
+ cname = _collection_name(username, suffix)
114
+ if client.collection_exists(cname):
115
+ client.update_collection(cname, hnsw_config=HnswConfigDiff(m=16, ef_construct=100))
116
+
117
+ return len(text_pts), len(complex_pts)
118
+
119
+
120
+ # ==================== 查询: 从 Qdrant 拉取缓存 ====================
121
+
122
+ def qdrant_query_scene(username, scene_type):
123
+ """从 Qdrant 拉取指定场景类型的用户缓存(scroll 一次,返回 dict)"""
124
+ client = _get_client()
125
+ cache = {}
126
+ cname = _collection_name(username, scene_type)
127
+ if not client.collection_exists(cname):
128
+ return cache
129
+ records, _ = client.scroll(cname, limit=1000, with_payload=True, with_vectors=True)
130
+ for rec in records:
131
+ entry = dict(rec.payload)
132
+ entry['_vector'] = np.array(rec.vector, dtype=np.float32)
133
+ if scene_type == 'text':
134
+ entry['bge_vector'] = entry['_vector']
135
+ cache[rec.id] = entry
136
+ return cache
137
+
138
+
139
+ # ==================== 向量相似度搜索 ====================
140
+
141
+ def qdrant_search_similar(username, scene_type, query_vector, top_k=3, score_threshold=0.0):
142
+ client = _get_client()
143
+ cname = _collection_name(username, scene_type)
144
+ if not client.collection_exists(cname):
145
+ return []
146
+ response = client.query_points(
147
+ collection_name=cname,
148
+ query=query_vector.tolist(),
149
+ limit=top_k,
150
+ score_threshold=score_threshold,
151
+ with_payload=True,
152
+ with_vectors=True,
153
+ )
154
+ results = []
155
+ for hit in response.points:
156
+ entry = dict(hit.payload)
157
+ entry['_vector'] = np.array(hit.vector, dtype=np.float32)
158
+ if scene_type == 'text':
159
+ entry['bge_vector'] = entry['_vector']
160
+ results.append((hit.score, hit.id, entry))
161
+ return results
162
+
163
+
164
+ # ==================== 增量更新 ====================
165
+
166
+ def add_to_qdrant(username, img_path, ocr_engine, dinov2_extractor,
167
+ bge_tokenizer, bge_model, scene_type=None,
168
+ precomputed_text=None, precomputed_bge=None,
169
+ precomputed_dinov2=None):
170
+ """将新图片添加到 Qdrant(增量插入)"""
171
+ client = _get_client()
172
+ if scene_type is None:
173
+ scene_type, _, _, _ = classify_scene(img_path, ocr_engine)
174
+ pid = pt_id(img_path)
175
+ payload = {'path': img_path, 'filename': os.path.basename(img_path)}
176
+
177
+ if scene_type == 'text':
178
+ if precomputed_text is not None:
179
+ full_text = precomputed_text
180
+ else:
181
+ from module.text_matcher import extract_text
182
+ full_text, _ = extract_text(img_path, ocr_engine)
183
+ payload['ocr_text'] = full_text
184
+ if precomputed_bge is not None:
185
+ vector = precomputed_bge
186
+ else:
187
+ vector = _bge_encode(full_text, bge_tokenizer, bge_model)
188
+ dim = 512
189
+ else:
190
+ if precomputed_dinov2 is not None:
191
+ vector = precomputed_dinov2
192
+ else:
193
+ vector = _dinov2_encode(img_path, dinov2_extractor)
194
+ dim = 384
195
+
196
+ cname = _collection_name(username, scene_type)
197
+ if not client.collection_exists(cname):
198
+ client.create_collection(
199
+ cname,
200
+ vectors_config=VectorParams(size=dim, distance=Distance.COSINE),
201
+ optimizers_config=OptimizersConfigDiff(indexing_threshold=0),
202
+ )
203
+ client.update_collection(cname, hnsw_config=HnswConfigDiff(m=16, ef_construct=100))
204
+
205
+ client.upsert(cname, points=[PointStruct(id=pid, vector=vector.tolist(), payload=payload)], wait=True)
206
+ return scene_type
207
+
208
+
209
+ def remove_from_qdrant(username, img_path, scene_type=None):
210
+ """从 Qdrant 中移除指定图片"""
211
+ client = _get_client()
212
+ pid = pt_id(img_path)
213
+
214
+ if scene_type:
215
+ cname = _collection_name(username, scene_type)
216
+ if client.collection_exists(cname):
217
+ try:
218
+ client.delete(cname, points=[pid])
219
+ except Exception:
220
+ pass
221
+ else:
222
+ for st in ['text', 'complex']:
223
+ cname = _collection_name(username, st)
224
+ if client.collection_exists(cname):
225
+ try:
226
+ client.delete(cname, points=[pid])
227
+ except Exception:
228
+ pass
229
+
230
+
231
+ def has_qdrant_cache(username):
232
+ """检查用户是否有 Qdrant 缓存"""
233
+ client = _get_client()
234
+ for st in ['text', 'complex']:
235
+ cname = _collection_name(username, st)
236
+ if client.collection_exists(cname):
237
+ count = client.count(cname).count
238
+ if count > 0:
239
+ return True
240
+ return False
module/scene_matcher.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """场景匹配器 — DINOv2-only 模式(无 MASt3R)
2
+
3
+ 复杂场景判定改为纯 DINOv2 余弦相似度,不调用 MASt3R 3D 几何匹配。
4
+ 阈值由 config.DINOV2_ONLY_SAME_THRESHOLD 控制。
5
+ """
6
+ import torch
7
+
8
+ from module.config import DEVICE, DINOV2_ONLY_SAME_THRESHOLD
9
+
10
+
11
+ class SceneMatcher:
12
+ """DINOv2-only 场景匹配器"""
13
+
14
+ def __init__(self, model=None, dinov2_extractor=None, device=None):
15
+ """初始化匹配器
16
+
17
+ Args:
18
+ model: 兼容参数,CPU 部署中始终为 None(无 MASt3R)
19
+ dinov2_extractor: DINOv2 特征提取器实例
20
+ device: 计算设备
21
+ """
22
+ self.model = model # None(MASt3R 已禁用)
23
+ self.dinov2_extractor = dinov2_extractor
24
+ self.device = device or DEVICE
25
+
26
+ def compare(self, img_path1, img_path2, dinov2_sim_override=None):
27
+ """比对两张图片是否为同一场景(DINOv2-only)
28
+
29
+ Args:
30
+ img_path1: 查询图片路径
31
+ img_path2: 历史图片路径
32
+ dinov2_sim_override: 预计算的 DINOv2 相似度(避免重复提取)
33
+
34
+ Returns:
35
+ dict: 与原始 SceneMatcher.compare() 兼容的结果格式
36
+ """
37
+ # 获取 DINOv2 相似度
38
+ if dinov2_sim_override is not None:
39
+ dinov2_sim = float(dinov2_sim_override)
40
+ elif self.dinov2_extractor is not None:
41
+ try:
42
+ dinov2_sim = self.dinov2_extractor.compute_similarity(img_path1, img_path2)
43
+ if dinov2_sim is None:
44
+ dinov2_sim = 0.0
45
+ except Exception:
46
+ dinov2_sim = 0.0
47
+ else:
48
+ dinov2_sim = 0.0
49
+
50
+ # DINOv2-only 判定
51
+ is_same = dinov2_sim >= DINOV2_ONLY_SAME_THRESHOLD
52
+
53
+ return {
54
+ 'is_same_scene': bool(is_same),
55
+ 'mast3r_is_same': None, # MASt3R 已禁用
56
+ 'dinov2_is_same': bool(is_same),
57
+ 'similarity_score': round(float(dinov2_sim), 4),
58
+ 'match_count': 0,
59
+ 'raw_match_count': 0,
60
+ 'inlier_ratio': 0.0,
61
+ 'avg_confidence': 0.0,
62
+ 'dinov2_similarity': round(float(dinov2_sim), 4),
63
+ 'gamma_info': [], # 无 Gamma 校正(MASt3R 专用)
64
+ }
module/system_monitor.py ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import threading
3
+ import time
4
+ import warnings
5
+ import psutil
6
+ import torch
7
+ from datetime import datetime
8
+
9
+ from module.config import LOGS_DIR
10
+
11
+
12
+ def _snapshot():
13
+ """采集当前时刻的CPU、内存和GPU占用率"""
14
+ stats = {
15
+ 'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3],
16
+ 'cpu_percent': psutil.cpu_percent(interval=0),
17
+ 'memory_percent': psutil.virtual_memory().percent,
18
+ 'memory_used_gb': round(psutil.virtual_memory().used / (1024**3), 2),
19
+ 'memory_total_gb': round(psutil.virtual_memory().total / (1024**3), 2),
20
+ }
21
+ if torch.cuda.is_available():
22
+ stats['gpu_memory_used_gb'] = round(torch.cuda.memory_allocated() / (1024**3), 2)
23
+ stats['gpu_memory_reserved_gb'] = round(torch.cuda.memory_reserved() / (1024**3), 2)
24
+ stats['gpu_memory_total_gb'] = round(torch.cuda.get_device_properties(0).total_memory / (1024**3), 2)
25
+ stats['gpu_memory_percent'] = round(
26
+ stats['gpu_memory_used_gb'] / stats['gpu_memory_total_gb'] * 100, 1
27
+ )
28
+ stats['gpu_util_percent'] = _get_gpu_util()
29
+ else:
30
+ stats['gpu_memory_percent'] = 0
31
+ stats['gpu_memory_used_gb'] = 0
32
+ stats['gpu_memory_reserved_gb'] = 0
33
+ stats['gpu_memory_total_gb'] = 0
34
+ stats['gpu_util_percent'] = 0
35
+ return stats
36
+
37
+
38
+ def _get_gpu_util():
39
+ """获取GPU计算利用率(非显存占用),单例模式避免反复init/shutdown"""
40
+ if not _get_gpu_util._initialized:
41
+ try:
42
+ with warnings.catch_warnings():
43
+ warnings.simplefilter("ignore")
44
+ import pynvml as nvml_mod
45
+ nvml_mod.nvmlInit()
46
+ _get_gpu_util._nvml = nvml_mod
47
+ _get_gpu_util._handle = nvml_mod.nvmlDeviceGetHandleByIndex(0)
48
+ _get_gpu_util._initialized = True
49
+ except Exception:
50
+ return -1
51
+ try:
52
+ nvml_mod = _get_gpu_util._nvml
53
+ handle = _get_gpu_util._handle
54
+ util = nvml_mod.nvmlDeviceGetUtilizationRates(handle)
55
+ return util.gpu
56
+ except Exception:
57
+ return -1
58
+
59
+ _get_gpu_util._initialized = False
60
+ _get_gpu_util._nvml = None
61
+ _get_gpu_util._handle = None
62
+
63
+
64
+ class ResourceMonitor:
65
+ """后台资源监控器:在检测期间持续采样,追踪峰值"""
66
+
67
+ def __init__(self, sample_interval=0.5):
68
+ """初始化监控器"""
69
+ self.sample_interval = sample_interval
70
+ self._thread = None
71
+ self._stop_event = threading.Event()
72
+ self._samples = []
73
+ self._lock = threading.Lock()
74
+ self._peak = None # 检测期间的峰值
75
+ self._monitoring = False
76
+
77
+ def start_monitoring(self):
78
+ """开始后台采样(检测开始前调用)"""
79
+ self._samples = []
80
+ self._peak = None
81
+ self._stop_event.clear()
82
+ self._monitoring = True
83
+ self._thread = threading.Thread(target=self._sample_loop, daemon=True)
84
+ self._thread.start()
85
+
86
+ def stop_monitoring(self):
87
+ """停止后台采样(检测结束后调用),返回检测期间的峰值统计"""
88
+ self._stop_event.set()
89
+ if self._thread:
90
+ self._thread.join(timeout=3)
91
+ self._monitoring = False
92
+
93
+ with self._lock:
94
+ if not self._samples:
95
+ return None
96
+ peak = self._compute_peak(self._samples)
97
+ self._peak = peak
98
+ return peak
99
+
100
+ @property
101
+ def is_monitoring(self):
102
+ return self._monitoring
103
+
104
+ def _sample_loop(self):
105
+ """后台采样循环"""
106
+ # 先做一次有interval的CPU采样来初始化psutil的基准值
107
+ psutil.cpu_percent(interval=0)
108
+ time.sleep(0.1)
109
+
110
+ while not self._stop_event.is_set():
111
+ sample = _snapshot()
112
+ with self._lock:
113
+ self._samples.append(sample)
114
+ self._stop_event.wait(self.sample_interval)
115
+
116
+ def _compute_peak(self, samples):
117
+ """从采样列表中计算峰值统计"""
118
+ if not samples:
119
+ return None
120
+
121
+ peak = {
122
+ 'timestamp_start': samples[0]['timestamp'],
123
+ 'timestamp_end': samples[-1]['timestamp'],
124
+ 'sample_count': len(samples),
125
+ 'duration_sec': round(len(samples) * self.sample_interval, 1),
126
+ 'cpu_peak': max(s['cpu_percent'] for s in samples),
127
+ 'cpu_avg': round(sum(s['cpu_percent'] for s in samples) / len(samples), 1),
128
+ 'memory_peak_percent': max(s['memory_percent'] for s in samples),
129
+ 'memory_peak_gb': max(s['memory_used_gb'] for s in samples),
130
+ 'memory_avg_percent': round(sum(s['memory_percent'] for s in samples) / len(samples), 1),
131
+ 'gpu_memory_peak_gb': max(s['gpu_memory_used_gb'] for s in samples),
132
+ 'gpu_memory_peak_percent': max(s['gpu_memory_percent'] for s in samples),
133
+ 'gpu_memory_avg_gb': round(sum(s['gpu_memory_used_gb'] for s in samples) / len(samples), 2),
134
+ 'gpu_util_peak': max(s['gpu_util_percent'] for s in samples),
135
+ 'gpu_util_avg': round(
136
+ sum(s['gpu_util_percent'] for s in samples if s['gpu_util_percent'] >= 0)
137
+ / max(1, sum(1 for s in samples if s['gpu_util_percent'] >= 0)),
138
+ 1
139
+ ),
140
+ }
141
+ return peak
142
+
143
+
144
+ def _compute_delta(before, after, label=""):
145
+ """计算两个快照之间的变化量"""
146
+ delta = {
147
+ 'label': label,
148
+ 'cpu_delta': round(after['cpu_percent'] - before['cpu_percent'], 1),
149
+ 'memory_delta_gb': round(after['memory_used_gb'] - before['memory_used_gb'], 2),
150
+ 'memory_delta_percent': round(after['memory_percent'] - before['memory_percent'], 1),
151
+ 'gpu_memory_delta_gb': round(after['gpu_memory_used_gb'] - before['gpu_memory_used_gb'], 2),
152
+ 'gpu_memory_delta_percent': round(after['gpu_memory_percent'] - before['gpu_memory_percent'], 1),
153
+ }
154
+ return delta
155
+
156
+
157
+ def _compute_delta_pre_peak(pre_stats, peak, label=""):
158
+ """计算检测前快照与检测中峰值之间的变化量(展示检测的实际资源需求)"""
159
+ delta = {
160
+ 'label': label,
161
+ 'cpu_delta': round(peak['cpu_peak'] - pre_stats['cpu_percent'], 1),
162
+ 'memory_delta_gb': round(peak['memory_peak_gb'] - pre_stats['memory_used_gb'], 2),
163
+ 'memory_delta_percent': round(peak['memory_peak_percent'] - pre_stats['memory_percent'], 1),
164
+ 'gpu_memory_delta_gb': round(peak['gpu_memory_peak_gb'] - pre_stats['gpu_memory_used_gb'], 2),
165
+ 'gpu_memory_delta_percent': round(peak['gpu_memory_peak_percent'] - pre_stats['gpu_memory_percent'], 1),
166
+ }
167
+ return delta
168
+
169
+
170
+ def format_stats_line(stats, label=""):
171
+ """将系统状态格式化为一行日志文本"""
172
+ parts = [
173
+ f"[{stats['timestamp']}]",
174
+ f"CPU: {stats['cpu_percent']}%",
175
+ f"内存: {stats['memory_used_gb']}/{stats['memory_total_gb']}GB ({stats['memory_percent']}%)",
176
+ f"GPU显存: {stats['gpu_memory_used_gb']}/{stats['gpu_memory_total_gb']}GB ({stats['gpu_memory_percent']}%)",
177
+ ]
178
+ if label:
179
+ parts.insert(1, f"[{label}]")
180
+ return " | ".join(parts)
181
+
182
+
183
+ def format_peak_line(peak, label=""):
184
+ """将峰值统计格式化为日志文本"""
185
+ gpu_util_str = f"{peak['gpu_util_peak']}%" if peak['gpu_util_peak'] >= 0 else "N/A"
186
+ gpu_util_avg_str = f"{peak['gpu_util_avg']}%" if peak['gpu_util_avg'] >= 0 else "N/A"
187
+ lines = [
188
+ f" 采样数: {peak['sample_count']}, 时长: {peak['duration_sec']}s",
189
+ f" CPU 峰值: {peak['cpu_peak']}% 均值: {peak['cpu_avg']}%",
190
+ f" 内存 峰值: {peak['memory_peak_gb']}GB ({peak['memory_peak_percent']}%) 均值: {peak['memory_avg_percent']}%",
191
+ f" 显存 峰值: {peak['gpu_memory_peak_gb']}GB ({peak['gpu_memory_peak_percent']}%) 均值: {peak['gpu_memory_avg_gb']}GB",
192
+ f" GPU利用率 峰值: {gpu_util_str} 均值: {gpu_util_avg_str}",
193
+ ]
194
+ if label:
195
+ lines.insert(0, f" [{label}]")
196
+ return "\n".join(lines)
197
+
198
+
199
+ def format_delta_line(delta):
200
+ """将变化量格式化为日志文本"""
201
+ sign = lambda v: f"+{v}" if v > 0 else f"{v}"
202
+ lines = [
203
+ f" [{delta['label']}]",
204
+ f" CPU: {sign(delta['cpu_delta'])}% "
205
+ f"内存: {sign(delta['memory_delta_gb'])}GB ({sign(delta['memory_delta_percent'])}%) "
206
+ f"显存: {sign(delta['gpu_memory_delta_gb'])}GB ({sign(delta['gpu_memory_delta_percent'])}%)",
207
+ ]
208
+ return "\n".join(lines)
209
+
210
+
211
+ def write_session_log(session_data):
212
+ """将会话日志写入Logs目录下的txt文件
213
+
214
+ session_data 结构:
215
+ startup_stats: 启动时快照
216
+ model_loaded_stats: 模型加载后快照
217
+ detections: 列表,每项包含:
218
+ pre_stats: 检测前快照
219
+ peak: 检测期间峰值
220
+ post_stats: 检测后快照
221
+ delta_pre_post: 检测前后变化量
222
+ shutdown_stats: 关闭时快照
223
+ delta_startup_shutdown: 启动到关闭总变化量
224
+ """
225
+ os.makedirs(LOGS_DIR, exist_ok=True)
226
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
227
+ log_path = os.path.join(LOGS_DIR, f'session_{timestamp}.txt')
228
+
229
+ with open(log_path, 'w', encoding='utf-8') as f:
230
+ f.write("=" * 70 + "\n")
231
+ f.write(" MASt3R 场景一致性检测系统 - 运行日志\n")
232
+ f.write("=" * 70 + "\n\n")
233
+
234
+ # 1. 启动时资源
235
+ startup = session_data.get('startup_stats')
236
+ if startup:
237
+ f.write("--- ① 服务启动时 ---\n")
238
+ f.write(format_stats_line(startup, "启动") + "\n\n")
239
+
240
+ # 2. 模型加载后
241
+ model_loaded = session_data.get('model_loaded_stats')
242
+ if model_loaded and startup:
243
+ f.write("--- ② 模型加载后 ---\n")
244
+ f.write(format_stats_line(model_loaded, "加载后") + "\n")
245
+ delta = _compute_delta(startup, model_loaded, "模型加载增量")
246
+ f.write(format_delta_line(delta) + "\n\n")
247
+ elif model_loaded:
248
+ f.write("--- ② 模型加载后 ---\n")
249
+ f.write(format_stats_line(model_loaded, "加载后") + "\n\n")
250
+
251
+ # 3. 每次检测的详细记录
252
+ detections = session_data.get('detections', [])
253
+ if detections:
254
+ f.write("--- ③ 检测过程资源变化 ---\n")
255
+ for i, det in enumerate(detections, 1):
256
+ f.write(f"\n ── 检测 #{i} ──\n")
257
+
258
+ pre = det.get('pre_stats')
259
+ peak = det.get('peak')
260
+ duration = det.get('duration_sec')
261
+
262
+ if duration is not None:
263
+ f.write(f" 耗时: {duration:.1f}s\n")
264
+
265
+ if pre:
266
+ f.write(f" 检测前: {format_stats_line(pre, '检测前').split('|', 1)[1].strip()}\n")
267
+
268
+ if peak:
269
+ f.write(format_peak_line(peak, "检测中峰值") + "\n")
270
+
271
+ if pre and peak:
272
+ delta = _compute_delta_pre_peak(pre, peak, f"检测#{i} 资源需求(前→峰值)")
273
+ f.write(format_delta_line(delta) + "\n")
274
+
275
+ f.write("\n")
276
+
277
+ # 4. 服务关闭时
278
+ shutdown = session_data.get('shutdown_stats')
279
+ if shutdown:
280
+ f.write("--- ④ 服务关闭时 ---\n")
281
+ f.write(format_stats_line(shutdown, "关闭") + "\n")
282
+
283
+ if startup:
284
+ delta = _compute_delta(startup, shutdown, "全程总变化")
285
+ f.write(format_delta_line(delta) + "\n\n")
286
+
287
+ f.write("=" * 70 + "\n")
288
+ f.write(f" 日志生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
289
+ f.write("=" * 70 + "\n")
290
+
291
+ f.flush()
292
+ os.fsync(f.fileno())
293
+
294
+ return log_path
module/text_classifier.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """场景分类器:多信号加权评分判断图片属于文本场景还是复杂场景
2
+
3
+ 综合以下4个信号:
4
+ 1. 文本面积占比(权重0.15)——OCR检测到的文本区域面积
5
+ 2. 背景白度占比(权重0.50)——浅色像素比例
6
+ 3. 颜色方差倒数(权重0.25)——颜色变化越小越可能是文档
7
+ 4. 文本行密度(权重0.10)——单位面积内文本行数
8
+ """
9
+
10
+ import cv2
11
+ import numpy as np
12
+
13
+ from module.config import (
14
+ TEXT_AREA_WEIGHT, WHITENESS_WEIGHT, LOW_VARIANCE_WEIGHT,
15
+ LINE_DENSITY_WEIGHT, DOC_SCORE_THRESHOLD,
16
+ )
17
+
18
+
19
+ def _cv_imread(path, flags=cv2.IMREAD_COLOR):
20
+ """支持中文路径的cv2.imread"""
21
+ return cv2.imdecode(np.fromfile(path, dtype=np.uint8), flags)
22
+
23
+
24
+ def _compute_whiteness(img, brightness_threshold=200):
25
+ """计算图片背景白度占比
26
+
27
+ 白底文档的浅色像素(亮度>200)占比通常>60%
28
+
29
+ Args:
30
+ img: BGR格式的numpy数组
31
+ brightness_threshold: 亮度阈值,高于此值视为'白'
32
+ Returns:
33
+ whiteness: 白色像素占比,范围[0,1]
34
+ """
35
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
36
+ white_pixels = np.sum(gray >= brightness_threshold)
37
+ total_pixels = gray.shape[0] * gray.shape[1]
38
+ return white_pixels / total_pixels if total_pixels > 0 else 0.0
39
+
40
+
41
+ def _compute_color_variance_norm(img):
42
+ """计算图片颜色方差的归一化值
43
+
44
+ 文档图片(白底黑字)颜色方差低,场景照片颜色方差高
45
+ 归一化到[0,1],值越大表示颜色越丰富(越不像文档)
46
+
47
+ Args:
48
+ img: BGR格式的numpy数组
49
+ Returns:
50
+ variance_norm: 归一化颜色方差,范围[0,1]
51
+ """
52
+ variances = [np.var(img[:, :, c]) for c in range(3)]
53
+ avg_var = np.mean(variances)
54
+ # sigmoid归一化:方差2000约为0.5(经验值)
55
+ # 文档方差通常<500,场景照片方差通常>3000
56
+ variance_norm = 1.0 / (1.0 + np.exp(-(avg_var - 2000) / 800))
57
+ return float(variance_norm)
58
+
59
+
60
+ def _compute_line_density(text_boxes, img_shape):
61
+ """计算文本行密度
62
+
63
+ 即使文字分散,如果行数多,行密度仍然高
64
+ 营业执照虽然文字分散,但行数多(10+行),密度不会太低
65
+
66
+ Args:
67
+ text_boxes: OCR检测到的文本框列表
68
+ img_shape: (h, w) 图片尺寸
69
+ Returns:
70
+ line_density: 归一化行密度,范围[0,1]
71
+ """
72
+ h, w = img_shape[:2]
73
+ if not text_boxes or h == 0 or w == 0:
74
+ return 0.0
75
+
76
+ # 行数 / 图片面积(以1000*1000为参考)
77
+ area_factor = (h * w) / (1000 * 1000)
78
+ if area_factor == 0:
79
+ return 0.0
80
+
81
+ raw_density = len(text_boxes) / area_factor
82
+ # sigmoid归一化:10行/百万像素约为0.5
83
+ density_norm = 1.0 / (1.0 + np.exp(-(raw_density - 10) / 5))
84
+ return float(density_norm)
85
+
86
+
87
+ def classify_scene(image_path, ocr_engine, doc_score_threshold=DOC_SCORE_THRESHOLD,
88
+ precomputed_ocr_result=None):
89
+ """多信号加权评分判断图片属于文本场景还是复杂场景
90
+
91
+ Args:
92
+ image_path: 图片路径
93
+ ocr_engine: PaddleOCR引擎实例
94
+ doc_score_threshold: 文档场景判定阈值,默认0.55
95
+ precomputed_ocr_result: 预计算的OCR结果,避免重复OCR
96
+
97
+ Returns:
98
+ scene_type: 'text'(文本场景) 或 'complex'(复杂场景)
99
+ doc_score: 文档评分
100
+ detail: 各信号的详细分数字典
101
+ ocr_result: OCR原始结果(可复用给下游)
102
+ """
103
+ img = _cv_imread(image_path)
104
+ if img is None:
105
+ return 'complex', 0.0, {}, None
106
+
107
+ h, w = img.shape[:2]
108
+
109
+ if precomputed_ocr_result is not None:
110
+ result = precomputed_ocr_result
111
+ else:
112
+ result = ocr_engine.ocr(image_path, cls=True)
113
+
114
+ text_area = 0
115
+ text_boxes = []
116
+ full_text = ''
117
+ if result and result[0]:
118
+ for line in result[0]:
119
+ box = line[0]
120
+ text_boxes.append(box)
121
+ n = len(box)
122
+ area = 0
123
+ for i in range(n):
124
+ j = (i + 1) % n
125
+ area += box[i][0] * box[j][1]
126
+ area -= box[j][0] * box[i][1]
127
+ text_area += abs(area) / 2
128
+ full_text = ' '.join(line[1][0] for line in result[0])
129
+
130
+ total_area = h * w
131
+ text_area_ratio = text_area / total_area if total_area > 0 else 0
132
+
133
+ whiteness = _compute_whiteness(img)
134
+
135
+ variance_norm = _compute_color_variance_norm(img)
136
+ low_variance = 1.0 - variance_norm
137
+
138
+ line_density = _compute_line_density(text_boxes, img.shape)
139
+
140
+ doc_score = (
141
+ TEXT_AREA_WEIGHT * text_area_ratio
142
+ + WHITENESS_WEIGHT * whiteness
143
+ + LOW_VARIANCE_WEIGHT * low_variance
144
+ + LINE_DENSITY_WEIGHT * line_density
145
+ )
146
+
147
+ scene_type = 'text' if doc_score >= doc_score_threshold else 'complex'
148
+
149
+ detail = {
150
+ 'text_area_ratio': round(text_area_ratio, 4),
151
+ 'whiteness': round(whiteness, 4),
152
+ 'low_variance': round(low_variance, 4),
153
+ 'line_density': round(line_density, 4),
154
+ 'text_box_count': len(text_boxes),
155
+ 'full_text': full_text,
156
+ }
157
+
158
+ return scene_type, round(doc_score, 4), detail, result
module/text_matcher.py ADDED
@@ -0,0 +1,458 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """文本场景比对管线:OCR提取 + 语义嵌入 + SSIM结构比对 + 印章/手写检测"""
2
+
3
+ import time
4
+ import cv2
5
+ import numpy as np
6
+ import torch
7
+ from skimage.metrics import structural_similarity as ssim
8
+
9
+ from module.config import (
10
+ TEXT_SIM_WEIGHT, SSIM_WEIGHT, SEAL_WEIGHT, TEXT_SCENE_THRESHOLD,
11
+ TEXT_SCENE_BGE_THRESHOLD,
12
+ )
13
+
14
+
15
+ def _cv_imread(path, flags=cv2.IMREAD_COLOR):
16
+ """支持中文路径的cv2.imread"""
17
+ return cv2.imdecode(np.fromfile(path, dtype=np.uint8), flags)
18
+
19
+
20
+ # ==================== OCR文字提取 ====================
21
+
22
+ def extract_text(image_path, ocr_engine):
23
+ """使用PaddleOCR提取图片中的全部文字
24
+
25
+ Args:
26
+ image_path: 图片路径
27
+ ocr_engine: PaddleOCR引擎实例
28
+
29
+ Returns:
30
+ full_text: 拼接后的完整文本
31
+ text_lines: 每行文字的列表(含置信度)
32
+ """
33
+ result = ocr_engine.ocr(image_path, cls=True)
34
+
35
+ text_lines = []
36
+ if result and result[0]:
37
+ for line in result[0]:
38
+ text = line[1][0]
39
+ conf = line[1][1]
40
+ text_lines.append({'text': text, 'confidence': float(conf)})
41
+
42
+ full_text = ' '.join([t['text'] for t in text_lines])
43
+ return full_text, text_lines
44
+
45
+
46
+ # ==================== 语义嵌入比对 ====================
47
+
48
+ def compute_text_similarity(text1, text2, bge_tokenizer, bge_model):
49
+ """使用BGE-small-zh计算两段文本的语义相似度
50
+
51
+ Args:
52
+ text1, text2: 待比较的两段文本
53
+ bge_tokenizer: BGE分词器实例
54
+ bge_model: BGE模型实例
55
+
56
+ Returns:
57
+ similarity: 余弦相似度,范围[0,1]
58
+ """
59
+ if not text1.strip() or not text2.strip():
60
+ return 0.0
61
+
62
+ def _encode(text):
63
+ """编码单段文本为归一化向量"""
64
+ encoded = bge_tokenizer(text, padding=True, truncation=True, return_tensors='pt', max_length=512)
65
+ if torch.cuda.is_available():
66
+ encoded = {k: v.cuda() for k, v in encoded.items()}
67
+ with torch.no_grad():
68
+ outputs = bge_model(**encoded)
69
+ cls_embedding = outputs.last_hidden_state[:, 0]
70
+ cls_embedding = torch.nn.functional.normalize(cls_embedding, p=2, dim=1)
71
+ return cls_embedding.cpu().numpy()
72
+
73
+ emb1 = _encode(text1)
74
+ emb2 = _encode(text2)
75
+
76
+ sim = float(np.dot(emb1[0], emb2[0]))
77
+ return sim
78
+
79
+
80
+ # ==================== SSIM结构比对 ====================
81
+
82
+ def compute_ssim(image_path1, image_path2):
83
+ """计算两张图片的SSIM结构相似度
84
+
85
+ 将图片缩放到相同尺寸后转为灰度计算SSIM
86
+
87
+ Returns:
88
+ ssim_score: SSIM值,范围[0,1]
89
+ """
90
+ img1 = _cv_imread(image_path1)
91
+ img2 = _cv_imread(image_path2)
92
+
93
+ if img1 is None or img2 is None:
94
+ return 0.0
95
+
96
+ h = min(img1.shape[0], img2.shape[0])
97
+ w = min(img1.shape[1], img2.shape[1])
98
+ img1 = cv2.resize(img1, (w, h))
99
+ img2 = cv2.resize(img2, (w, h))
100
+
101
+ gray1 = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY)
102
+ gray2 = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY)
103
+
104
+ score, _ = ssim(gray1, gray2, full=True)
105
+ return float(score)
106
+
107
+
108
+ # ==================== 印章/手写检测 ====================
109
+
110
+ def detect_seals_and_handwriting(image_path, min_area_ratio=0.001):
111
+ """检测图片中的红色印章和手写笔迹区域
112
+
113
+ 印章检测原理:HSV颜色空间中提取红色区域
114
+
115
+ Args:
116
+ image_path: 图片路径
117
+ min_area_ratio: 最小区域面积占比,过滤噪声
118
+
119
+ Returns:
120
+ seals: 印章区域列表,每项含 {'bbox', 'area', 'area_ratio'}
121
+ seal_total_ratio: 印章总面积占比
122
+ """
123
+ img = _cv_imread(image_path)
124
+ if img is None:
125
+ return [], 0.0
126
+
127
+ h, w = img.shape[:2]
128
+ total_area = h * w
129
+
130
+ # --- 印章检测:HSV红色区域 ---
131
+ hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
132
+
133
+ # 红色在HSV中有两段:0-10 和 160-180
134
+ mask1 = cv2.inRange(hsv, np.array([0, 70, 50]), np.array([10, 255, 255]))
135
+ mask2 = cv2.inRange(hsv, np.array([160, 70, 50]), np.array([180, 255, 255]))
136
+ red_mask = cv2.bitwise_or(mask1, mask2)
137
+
138
+ # 形态学操作去噪
139
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
140
+ red_mask = cv2.morphologyEx(red_mask, cv2.MORPH_CLOSE, kernel, iterations=2)
141
+ red_mask = cv2.morphologyEx(red_mask, cv2.MORPH_OPEN, kernel, iterations=1)
142
+
143
+ # 查找轮廓
144
+ contours, _ = cv2.findContours(red_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
145
+
146
+ seals = []
147
+ min_area = total_area * min_area_ratio
148
+ for cnt in contours:
149
+ area = cv2.contourArea(cnt)
150
+ if area >= min_area:
151
+ x, y, bw, bh = cv2.boundingRect(cnt)
152
+ seals.append({
153
+ 'bbox': (x, y, bw, bh),
154
+ 'area': int(area),
155
+ 'area_ratio': round(area / total_area, 4),
156
+ })
157
+
158
+ seal_total_ratio = sum(s['area_ratio'] for s in seals)
159
+ return seals, round(seal_total_ratio, 4)
160
+
161
+
162
+ def compare_seals(seals1, seals2):
163
+ """比对两张图的印章区域
164
+
165
+ 比较逻辑:
166
+ - 两张图都有印章 → 比较数量和面积占比差异
167
+ - 一张有一张无 → 印章不匹配
168
+ - 两张都无 → 印章项跳过
169
+
170
+ Returns:
171
+ match: bool或None,印章是否匹配(None表示两张都无印章)
172
+ detail: 详细信息字典
173
+ """
174
+ has_seal1 = len(seals1) > 0
175
+ has_seal2 = len(seals2) > 0
176
+ ratio1 = sum(s['area_ratio'] for s in seals1)
177
+ ratio2 = sum(s['area_ratio'] for s in seals2)
178
+
179
+ detail = {
180
+ 'img1_seal_count': len(seals1),
181
+ 'img2_seal_count': len(seals2),
182
+ 'img1_seal_ratio': round(ratio1, 4),
183
+ 'img2_seal_ratio': round(ratio2, 4),
184
+ }
185
+
186
+ if not has_seal1 and not has_seal2:
187
+ return None, detail
188
+
189
+ if has_seal1 != has_seal2:
190
+ detail['reason'] = '一张有印章一张无'
191
+ return False, detail
192
+
193
+ # 两张都有印章:比较数量和面积
194
+ count_match = len(seals1) == len(seals2)
195
+ ratio_diff = abs(ratio1 - ratio2)
196
+ area_match = ratio_diff < 0.02
197
+
198
+ match = count_match and area_match
199
+ detail['count_match'] = count_match
200
+ detail['area_diff'] = round(ratio_diff, 4)
201
+ detail['area_match'] = area_match
202
+
203
+ return match, detail
204
+
205
+
206
+ def compare_text_scene_cached(image_path, cached_h, ocr_engine,
207
+ bge_tokenizer, bge_model,
208
+ precomputed_text=None, precomputed_bge=None):
209
+ """文本场景缓存比对: 仅实时 OCR/BGE/Seal 查询图,历史图从缓存读取
210
+
211
+ Args:
212
+ image_path: 查询图片路径
213
+ cached_h: 预存储的历史图缓存 dict (ocr_text, bge_vector, seal_count, seal_ratio)
214
+ ocr_engine: PaddleOCR引擎
215
+ bge_tokenizer: BGE分词器
216
+ bge_model: BGE模型
217
+
218
+ Returns:
219
+ result dict (与 compare_text_scene() 兼容的格式)
220
+ """
221
+ start_time = time.time()
222
+
223
+ # 1. OCR 仅查询图 (优先使用预计算结果)
224
+ if precomputed_text is not None:
225
+ text1 = precomputed_text
226
+ lines1 = []
227
+ ocr_time = 0.0
228
+ else:
229
+ text1, lines1 = extract_text(image_path, ocr_engine)
230
+ ocr_time = time.time() - start_time
231
+ text2 = cached_h.get('ocr_text', '')
232
+
233
+ # 2. 语义嵌入 (优先使用预计算向量)
234
+ if precomputed_bge is not None:
235
+ emb_q = precomputed_bge
236
+ emb_h = cached_h.get('bge_vector', np.zeros(512, dtype=np.float32))
237
+ text_sim = float(np.dot(emb_q, emb_h)) if text1.strip() else 0.0
238
+ embed_time = 0.0
239
+ elif not text1.strip():
240
+ text_sim = 0.0
241
+ embed_time = 0.0
242
+ else:
243
+ t_embed = time.time()
244
+ def _encode_q(text):
245
+ encoded = bge_tokenizer(text, padding=True, truncation=True,
246
+ return_tensors='pt', max_length=512)
247
+ if torch.cuda.is_available():
248
+ encoded = {k: v.cuda() for k, v in encoded.items()}
249
+ with torch.no_grad():
250
+ outputs = bge_model(**encoded)
251
+ cls_embedding = outputs.last_hidden_state[:, 0]
252
+ cls_embedding = torch.nn.functional.normalize(cls_embedding, p=2, dim=1)
253
+ return cls_embedding.cpu().numpy()[0]
254
+
255
+ emb_q = _encode_q(text1)
256
+ emb_h = cached_h.get('bge_vector', np.zeros(512, dtype=np.float32))
257
+ text_sim = float(np.dot(emb_q, emb_h))
258
+ embed_time = time.time() - t_embed
259
+
260
+ # 3. SSIM 仍需两张图
261
+ t_ssim = time.time()
262
+ ssim_score = compute_ssim(image_path, cached_h['path'])
263
+ ssim_time = time.time() - t_ssim
264
+
265
+ # 4. 印章:仅查询图(历史图用缓存)
266
+ t_seal = time.time()
267
+ seals_q, ratio_q = detect_seals_and_handwriting(image_path)
268
+ cnt_h = cached_h.get('seal_count', 0)
269
+ ratio_h = cached_h.get('seal_ratio', 0.0)
270
+
271
+ has_q, has_h = len(seals_q) > 0, cnt_h > 0
272
+ if not has_q and not has_h:
273
+ seal_bonus = 0.5
274
+ elif has_q != has_h:
275
+ seal_bonus = 0.0
276
+ else:
277
+ count_match = len(seals_q) == cnt_h
278
+ area_match = abs(ratio_q - ratio_h) < 0.02
279
+ seal_bonus = 1.0 if (count_match and area_match) else 0.0
280
+ seal_time = time.time() - t_seal
281
+
282
+ # 5. 综合判定
283
+ final_score = text_sim * TEXT_SIM_WEIGHT + ssim_score * SSIM_WEIGHT + seal_bonus * SEAL_WEIGHT
284
+ is_same = final_score >= TEXT_SCENE_THRESHOLD
285
+
286
+ total_time = time.time() - start_time
287
+
288
+ result = {
289
+ 'is_same_scene': bool(is_same),
290
+ 'scene_type': 'text',
291
+ 'final_score': round(final_score, 4),
292
+ 'text_similarity': round(text_sim, 4),
293
+ 'ssim_score': round(ssim_score, 4),
294
+ 'seal_bonus': seal_bonus,
295
+ 'seal_match': None if (not has_q and not has_h) else (has_q and has_h),
296
+ 'seal_detail': {
297
+ 'img1_seal_count': len(seals_q), 'img2_seal_count': cnt_h,
298
+ 'img1_seal_ratio': ratio_q, 'img2_seal_ratio': ratio_h,
299
+ },
300
+ 'img1_text_length': len(text1), 'img2_text_length': len(text2),
301
+ 'img1_seal_count': len(seals_q), 'img2_seal_count': cnt_h,
302
+ 'img1_seal_ratio': ratio_q, 'img2_seal_ratio': ratio_h,
303
+ 'similarity_score': round(final_score, 4),
304
+ 'match_count': len(seals_q) + cnt_h,
305
+ 'inlier_ratio': round(text_sim, 4),
306
+ 'avg_confidence': round(ssim_score, 4),
307
+ 'time_breakdown': {
308
+ 'ocr': round(ocr_time, 2), 'embedding': round(embed_time, 2),
309
+ 'ssim': round(ssim_time, 2), 'seal': round(seal_time, 2),
310
+ 'total': round(total_time, 2),
311
+ },
312
+ }
313
+
314
+ return result
315
+
316
+
317
+
318
+
319
+
320
+ def compare_text_scene_pure_bge(image_path, cached_h, ocr_engine,
321
+ bge_tokenizer, bge_model,
322
+ precomputed_text=None, precomputed_bge=None):
323
+ """Pure BGE: only OCR->BGE cosine similarity, no SSIM/seal"""
324
+ start_time = time.time()
325
+ print(f' [TIME-PURE-BGE] enter, precomputed_text={precomputed_text is not None}, precomputed_bge={precomputed_bge is not None}')
326
+
327
+ if precomputed_text is not None:
328
+ text1 = precomputed_text
329
+ ocr_time = 0.0
330
+ else:
331
+ text1, _ = extract_text(image_path, ocr_engine)
332
+ ocr_time = time.time() - start_time
333
+
334
+ if precomputed_bge is not None:
335
+ emb_q = precomputed_bge
336
+ emb_h = cached_h.get('_vector', cached_h.get('bge_vector', np.zeros(512, dtype=np.float32)))
337
+ text_sim = float(np.dot(emb_q, emb_h)) if text1.strip() else 0.0
338
+ embed_time = 0.0
339
+ elif not text1.strip():
340
+ text_sim = 0.0
341
+ embed_time = 0.0
342
+ else:
343
+ t_embed = time.time()
344
+ def _encode_q(text):
345
+ encoded = bge_tokenizer(text, padding=True, truncation=True,
346
+ return_tensors='pt', max_length=512)
347
+ if torch.cuda.is_available():
348
+ encoded = {k: v.cuda() for k, v in encoded.items()}
349
+ with torch.no_grad():
350
+ outputs = bge_model(**encoded)
351
+ cls_embedding = outputs.last_hidden_state[:, 0]
352
+ cls_embedding = torch.nn.functional.normalize(cls_embedding, p=2, dim=1)
353
+ return cls_embedding.cpu().numpy()[0]
354
+ emb_q = _encode_q(text1)
355
+ emb_h = cached_h.get('_vector', cached_h.get('bge_vector', np.zeros(512, dtype=np.float32)))
356
+ text_sim = float(np.dot(emb_q, emb_h))
357
+ embed_time = time.time() - t_embed
358
+
359
+ is_same = text_sim >= TEXT_SCENE_BGE_THRESHOLD
360
+ total_time = time.time() - start_time
361
+ print(f' [TIME-PURE-BGE] text_sim={text_sim:.4f} is_same={is_same} total={total_time:.3f}s')
362
+
363
+ result = {
364
+ 'is_same_scene': bool(is_same),
365
+ 'scene_type': 'text',
366
+ 'final_score': round(text_sim, 4),
367
+ 'text_similarity': round(text_sim, 4),
368
+ 'ssim_score': 0.0,
369
+ 'seal_bonus': 0.0,
370
+ 'similarity_score': round(text_sim, 4),
371
+ 'match_count': 0,
372
+ 'inlier_ratio': round(text_sim, 4),
373
+ 'avg_confidence': 0.0,
374
+ 'time_breakdown': {
375
+ 'ocr': round(ocr_time, 2), 'embedding': round(embed_time, 2),
376
+ 'ssim': 0.0, 'seal': 0.0, 'total': round(total_time, 2),
377
+ },
378
+ }
379
+ return result
380
+
381
+ # ==================== 综合判定 ====================
382
+
383
+ def compare_text_scene(image_path1, image_path2, ocr_engine, bge_tokenizer, bge_model):
384
+ """文本场景比对主函数
385
+
386
+ Args:
387
+ image_path1: 第一张图片路径
388
+ image_path2: 第二张图片路径
389
+ ocr_engine: PaddleOCR引擎实例
390
+ bge_tokenizer: BGE分词器实例
391
+ bge_model: BGE模型实例
392
+
393
+ Returns:
394
+ result: dict,含各项分数和最终判定
395
+ """
396
+ start_time = time.time()
397
+
398
+ # 1. OCR文字提取
399
+ text1, lines1 = extract_text(image_path1, ocr_engine)
400
+ text2, lines2 = extract_text(image_path2, ocr_engine)
401
+ ocr_time = time.time() - start_time
402
+
403
+ # 2. 语义嵌入比对
404
+ text_sim = compute_text_similarity(text1, text2, bge_tokenizer, bge_model)
405
+ embed_time = time.time() - start_time - ocr_time
406
+
407
+ # 3. SSIM结构比对
408
+ ssim_score = compute_ssim(image_path1, image_path2)
409
+ ssim_time = time.time() - start_time - ocr_time - embed_time
410
+
411
+ # 4. 印章检测与比对
412
+ seals1, seal_ratio1 = detect_seals_and_handwriting(image_path1)
413
+ seals2, seal_ratio2 = detect_seals_and_handwriting(image_path2)
414
+ seal_match, seal_detail = compare_seals(seals1, seals2)
415
+ seal_time = time.time() - start_time - ocr_time - embed_time - ssim_time
416
+
417
+ # 5. 综合判定
418
+ if seal_match is True:
419
+ seal_bonus = 1.0
420
+ elif seal_match is False:
421
+ seal_bonus = 0.0
422
+ else:
423
+ seal_bonus = 0.5 # 两张图都没有印章,中性
424
+
425
+ final_score = text_sim * TEXT_SIM_WEIGHT + ssim_score * SSIM_WEIGHT + seal_bonus * SEAL_WEIGHT
426
+ is_same = final_score >= TEXT_SCENE_THRESHOLD
427
+
428
+ total_time = time.time() - start_time
429
+
430
+ result = {
431
+ 'is_same_scene': bool(is_same),
432
+ 'scene_type': 'text',
433
+ 'final_score': round(final_score, 4),
434
+ 'text_similarity': round(text_sim, 4),
435
+ 'ssim_score': round(ssim_score, 4),
436
+ 'seal_bonus': seal_bonus,
437
+ 'seal_match': seal_match,
438
+ 'seal_detail': seal_detail,
439
+ 'img1_text_length': len(text1),
440
+ 'img2_text_length': len(text2),
441
+ 'img1_seal_count': len(seals1),
442
+ 'img2_seal_count': len(seals2),
443
+ 'img1_seal_ratio': seal_ratio1,
444
+ 'img2_seal_ratio': seal_ratio2,
445
+ 'similarity_score': round(final_score, 4),
446
+ 'match_count': len(seals1) + len(seals2), # 文本场景用印章数代替
447
+ 'inlier_ratio': round(text_sim, 4),
448
+ 'avg_confidence': round(ssim_score, 4),
449
+ 'time_breakdown': {
450
+ 'ocr': round(ocr_time, 2),
451
+ 'embedding': round(embed_time, 2),
452
+ 'ssim': round(ssim_time, 2),
453
+ 'seal': round(seal_time, 2),
454
+ 'total': round(total_time, 2),
455
+ },
456
+ }
457
+
458
+ return result
requirements.txt ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================
2
+ # HF Spaces CPU 部署依赖(无 MASt3R)
3
+ # ============================================
4
+
5
+ # Web 服务
6
+ fastapi
7
+ uvicorn
8
+ python-multipart
9
+
10
+ # 向量数据库(嵌入式模式)
11
+ qdrant-client
12
+
13
+ # 图像处理
14
+ opencv-python-headless
15
+ Pillow
16
+ scikit-image
17
+
18
+ # DINOv2 (vit_small_patch14_reg4_dinov2)
19
+ timm
20
+
21
+ # OCR + 语义嵌入(CPU 版 paddlepaddle)
22
+ paddlepaddle==2.6.2
23
+ paddleocr<3
24
+ transformers
25
+
26
+ # 系统监控
27
+ psutil
28
+
29
+ # 数据处理(锁定 1.x 避免 2.x 兼容问题)
30
+ numpy<2