Spaces:
Sleeping
Sleeping
jslmmfboom-coder commited on
Commit ·
e16aadc
0
Parent(s):
Fix paddlepaddle version to 2.6.2
Browse files- .dockerignore +8 -0
- .gitattributes +35 -0
- .gitignore +4 -0
- Dockerfile +44 -0
- README.md +30 -0
- app.py +399 -0
- index.html +279 -0
- module/__init__.py +0 -0
- module/config.py +56 -0
- module/dinov2_utils.py +83 -0
- module/evaluator.py +338 -0
- module/file_manager.py +81 -0
- module/image_utils.py +22 -0
- module/model_loader.py +138 -0
- module/qdrant_manager.py +240 -0
- module/scene_matcher.py +64 -0
- module/system_monitor.py +294 -0
- module/text_classifier.py +158 -0
- module/text_matcher.py +458 -0
- requirements.txt +30 -0
.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
|