update
This commit is contained in:
@ -0,0 +1,288 @@
|
||||
# 下线 pHash 去重 + FashionSigLIP「疑似同款」提示 — 设计文档
|
||||
|
||||
- 日期:2026-10-12
|
||||
- 状态:待实现
|
||||
- 相关代码:`internal/pkg/phash`、`internal/service/ingest_service.go`、`internal/repository/ingest_repository.go`、`internal/handler/backstage_handler.go`
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景
|
||||
|
||||
现有图片去重基于 `internal/pkg/phash` 的 dHash(64 位),存入图片表的 `phash vector(64)` 列,配HNSW 索引(`brand_runway_images_phash_hnsw` / `street_snap_images_phash_hnsw`,L2 距离),在 `IngestService.dedupImage` 中判定,命中则写 `is_duplicate` + `dup_of`,前台折叠、后台显示「重复」徽章与重复率统计。
|
||||
|
||||
dHash 只看像素,只能识别「同一文件被重复上传 / 裁剪 / 缩放 / 重压缩」,识别不了「同一件衣服被不同角度重新拍摄」。且这套机制带来持续的维护成本:`is_duplicate` / `dup_of` 是持久化关系,删图、删文章时都必须重算(`article_service.go`、`street_snap_service.go` 各有一段专门的重算逻辑)。
|
||||
|
||||
**决策:下线整套自动去重**,不再有任何自动折叠,重复图全部平铺,靠人工清理。
|
||||
|
||||
新项目 `D:\project\fashion-siglip` 已用 `marqo-fashionSigLIP`(open_clip,ViT-B/16 SigLIP)验证语义向量可行:图片编码为归一化向量后,余弦相似度可反映语义层面的相似性。**SigLIP 只用于后台提示「疑似同款」,不参与任何去重判定。**
|
||||
|
||||
## 2. 目标
|
||||
|
||||
1. 下线 pHash 去重能力(代码、schema、模板、统计、测试一并清理)。
|
||||
2. 新增独立 Python 视觉服务,常驻加载 FashionSigLIP,提供图片向量(embed)能力。
|
||||
3. Go worker 入库时获取向量落库;后台查看图集时展示「疑似同款」提示。
|
||||
|
||||
## 3. 非目标(YAGNI)
|
||||
|
||||
- **不做自动去重、不做自动折叠**(本次明确下线)。
|
||||
- 不做文本搜图 / 图文跨模态检索。
|
||||
- 不实现 `/classify` 分类接口(仅在服务边界预留)。
|
||||
- 不回填历史图片向量。
|
||||
- 不做阈值自动调优。
|
||||
|
||||
## 4. 架构总览
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
spider ──HTTP──▶ │ Go server (worker) │
|
||||
│ IngestService │
|
||||
│ 下载 → embed ──上传 │
|
||||
└───┬──────────────────┬───────┘
|
||||
│ 写/查向量 │ POST /embed (批量)
|
||||
▼ ▼
|
||||
┌───────────────────┐ ┌───────────────────────────┐
|
||||
│ PostgreSQL │ │ Python 视觉服务 (CPU) │
|
||||
│ image_embeddings │◀─│ FashionSigLIP 常驻 │
|
||||
│ (vector(768)+HNSW)│ │ /embed /health │
|
||||
└───────────────────┘ └───────────────────────────┘
|
||||
▲
|
||||
│ 后台看图集时按需 HNSW 查询
|
||||
│
|
||||
BackstageHandler
|
||||
```
|
||||
|
||||
**进程边界理由**:FashionSigLIP 是 PyTorch 模型,Go 无法直接执行。独立服务做到模型常驻一次、与 Go 发布解耦,并为将来分类能力复用同一进程。
|
||||
|
||||
## 5. 下线现有去重能力(删除清单)
|
||||
|
||||
### 5.1 代码
|
||||
|
||||
| 位置 | 删除内容 |
|
||||
|---|---|
|
||||
| `internal/pkg/phash/` | 整个包(`phash.go`、`phash_test.go`) |
|
||||
| `internal/model/runway_image.go`、`internal/model/street_snap.go` | `Phash` / `IsDuplicate` / `DupOf` 字段 |
|
||||
| `internal/service/ingest_service.go` | `dedupImage()` 整个函数;`concurrentFetch` 中 `res.phashBits` 计算;`timing` 的 `phash` / `dedup` 字段;落库处 `Phash` / `IsDuplicate` 赋值;`formatTiming` 中「phash / 近重复」两段 |
|
||||
| `internal/service/article_service.go:297-354` | 删图 / 下架时的 `dup_of` 重算逻辑 |
|
||||
| `internal/service/street_snap_service.go:398-449` | 同上 |
|
||||
| `internal/repository/ingest_repository.go` | `FindNearDuplicateImage`;写入处 `phash` / `is_duplicate` / `dup_of` 三列 |
|
||||
| `internal/repository/article_repository.go:430` | `phash <->` 近邻查询 |
|
||||
| `internal/repository/street_snap_repository.go:372` | 同上 |
|
||||
| `internal/repository/admin_repository.go` | 重复率统计查询 |
|
||||
| `internal/handler/backstage_handler.go` | 重复率统计(约 333/388 行)、徽章与折叠数据装配(约 683/702/713/732 等)、`buildDupSources`(约 961 行起) |
|
||||
| `internal/handler/templates/partials.html:62` | 「重复」徽章与折叠模板 |
|
||||
| `cmd/diagdup/` | 整个诊断命令 |
|
||||
|
||||
### 5.2 Schema(新增迁移)
|
||||
|
||||
- `brand_runway_images`:DROP 列 `phash` / `is_duplicate` / `dup_of`,DROP 索引 `brand_runway_images_phash_hnsw`
|
||||
- `street_snap_images`:DROP 列同上,DROP 索引 `street_snap_images_phash_hnsw`
|
||||
- DROP 死表 `image_duplicates`(2026-09-21 建表后全仓零引用,已确认的死物)
|
||||
|
||||
> 该迁移不可逆(**已确认接受**),执行前确认 `db/backups/` 里有备份。
|
||||
|
||||
### 5.3 测试
|
||||
|
||||
- 删除 `internal/repository/dedup_integration_test.go`
|
||||
- 清理 `editor_image_delete_integration_test.go`、`ingest_single_table_integration_test.go` 中依赖 `phash.ToVectorBits` / 重复标记的用例
|
||||
- 清理 `internal/service/ingest_image_count_test.go` 中 fake 仓储的 `FindNearDuplicateImage` 方法
|
||||
- 清理 `cmd/dbtool/main.go` 中相关引用
|
||||
- 更新 `README.md` 中 pHash / 去重的描述
|
||||
|
||||
### 5.4 保留
|
||||
|
||||
**`sha1` 批次内内容去重保留**——它是内容哈希(同一 URL / 同一文件在一次入库批次内只处理一次),与 pHash 无关,且几乎零成本。
|
||||
|
||||
## 6. Python 视觉服务
|
||||
|
||||
由 `D:\project\fashion-siglip` 改造而来(现有 `embed_images.py` / `compare_images.py` 是每次重新加载模型的脚本原型,不可直接用于生产)。
|
||||
|
||||
### 6.1 接口契约
|
||||
|
||||
| 接口 | 入参 | 出参 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `POST /embed` | `{"images": ["<图片字节的 base64 数组>"]}` | `{"dim": 768, "vectors": [[...], ...]}` | 批量编码;向量已 L2 归一化 |
|
||||
| `GET /health` | — | `{"ok": true, "dim": 768, "device": "cpu"}` | 探活与维度自检 |
|
||||
|
||||
- 单批上限由服务侧限制(建议 ≤ 32 张)。
|
||||
- 损坏 / 无法解码的图片跳过,不导致整批失败。
|
||||
- 维度**已由 M0 spike 实测确认为 768**(`marqo-fashionSigLIP` 输出 768 维,非 512)。Go 侧 `vision.dim` 默认值与 `image_embeddings.embedding` 列类型均按 768 处理。
|
||||
|
||||
### 6.2 部署
|
||||
|
||||
- 生产无 GPU,**CPU 推理**。日入库几百张的量级,CPU 足够。
|
||||
- 依赖本地模型缓存(沿用现有脚本的 `HF_HUB_OFFLINE=1` 策略),避免运行时网络请求。
|
||||
- 独立进程 / 容器,仅监听内网。
|
||||
|
||||
### 6.3 启动方式与端口
|
||||
|
||||
- **框架**:FastAPI + uvicorn(异步、批量接口顺手、自带 `/docs` 便于排查)。
|
||||
- **端口**:`8900`,与 Go 侧 `vision.endpoint` 默认值一致;生产仅监听 `127.0.0.1`,启动前确认端口未被占用。
|
||||
- **模型常驻单进程**:在应用 lifespan 启动阶段加载一次;加载完成前 `/health` 返回 `ready=false`,避免首个请求承担加载延迟并被 Go 侧超时打断。
|
||||
- **`--workers 1`**:模型常驻进程内,多 worker 会各加载一份模型导致内存翻倍;吞吐靠**批量**(单次 `/embed` 传多张)提升,不靠多进程。
|
||||
- **生产离线**:模型权重烤进镜像或挂载至 `HF_HOME`,并设 `HF_HUB_OFFLINE=1`,避免运行时访问 HuggingFace。
|
||||
- **配置项**(环境变量):`VISION_HOST` / `VISION_PORT` / `VISION_MODEL` / `HF_HOME`。
|
||||
- **部署**:独立 Dockerfile(`python:3.11-slim` + CPU 版 torch + open_clip + Pillow),健康检查打 `/health`。
|
||||
|
||||
启动命令:
|
||||
|
||||
```bash
|
||||
# 本地开发:用项目自带的 .venv(torch 2.14.1+cu132,走GPU)
|
||||
.venv/Scripts/python.exe -m uvicorn app:app --host 127.0.0.1 --port 8900 --workers 1
|
||||
|
||||
# 生产:CPU 版 torch 的容器
|
||||
uvicorn app:app --host 127.0.0.1 --port 8900 --workers 1
|
||||
```
|
||||
|
||||
> 本地 `.venv` 已具备 CUDA 版 torch,无需重复安装;`fastapi` / `uvicorn` 已装入该venv。
|
||||
> 服务会**自动探测** `cuda` 可用性并使用GPU,不可用时回退 CPU,无需配置区分。
|
||||
|
||||
## 7. Go 侧集成
|
||||
|
||||
在现有 `concurrentFetch` 的并发段中插入 embed 调用:
|
||||
|
||||
```
|
||||
并发段(每张图):下载 → embed → 上传
|
||||
串行段(按下标):组装 → 落库(同时写入图片记录与 image_embeddings)
|
||||
```
|
||||
|
||||
- 向量随图片记录一起写入 `image_embeddings`。
|
||||
- embed 失败 / 超时 → 不写 embedding 行,**不阻断入库**。
|
||||
- `timing` 结构新增 `embed` 字段,沿用既有各阶段的统计方式。
|
||||
|
||||
## 8. 数据模型——复用 `image_embeddings`
|
||||
|
||||
该表**已存在**(当前列类型 `vector(512)` + 已建好的 HNSW 余弦索引 `image_embeddings_vec_idx`),但全仓零代码引用、零数据,是死表。直接复用,不在两张图片表新增列。
|
||||
|
||||
```sql
|
||||
-- 目标结构
|
||||
image_embeddings (
|
||||
id bigint identity primary key,
|
||||
image_id integer not null,
|
||||
kind varchar(8) not null, -- runway | street,对齐 dto.IngestKind
|
||||
embedding vector(768), -- 实测维度,由 512 扩到 768
|
||||
created_at integer not null default 0
|
||||
)
|
||||
```
|
||||
|
||||
需要三处改造(同样在迁移中):
|
||||
|
||||
1. **`embedding` 由 `vector(512)` 改为 `vector(768)`**:M0 spike 实测模型输出 768 维。表中无数据,类型变更即时完成。
|
||||
2. **`kind` 由 `smallint` 改为 `varchar(8)`**,取值对齐既有的 `dto.IngestKind`(`"runway"` / `"street"`),与 `image_duplicates` 当年用 `varchar` 的做法一致。表中无数据、无代码引用,改动安全。
|
||||
3. **新增唯一索引 `(kind, image_id)`**:现表只有 `id` 主键、没有业务唯一约束,不加会导致同一张图重复写入多行。
|
||||
|
||||
> HNSW 索引 `image_embeddings_vec_idx` 建在 `embedding` 上,列类型变更后需重建索引。
|
||||
|
||||
**软删一致性**:图片表有 `is_deleted`,`image_embeddings` 没有。删图后向量会残留,因此**读侧必须 join 图片表过滤 `is_deleted = 0`**(沿用 `image_duplicates` 当年的处理约定,不做主动级联清理)。
|
||||
|
||||
**不存持久化的「同款关系」**——同款提示在后台查看时现算现展示,避免新增/删图导致的陈旧标记。
|
||||
|
||||
## 9. 「疑似同款」判定与展示
|
||||
|
||||
- 后台查看图集时,对每张有向量的图做一次 HNSW top-k 近邻查询(k 建议 5)。
|
||||
- 余弦相似度 ≥ `vision.similar_threshold` →挂「疑似同款」标签,显示「与 #\<id\> 相似 0.70」,可跳转对比。
|
||||
- **阈值必须实测校准,不可拍脑袋定**(见 §12)。初稿假设的 0.85 已被实测证伪。
|
||||
- **只排除图片自身**;同文章内的近邻同样参与提示——这能发现同一图集里的连拍糊片。
|
||||
- **仅后台可见**,不影响前台展示、不写入任何「重复」语义。
|
||||
|
||||
## 10. 配置项
|
||||
|
||||
| 配置 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `vision.enabled` | `false` | 总开关,关闭则完全跳过 embed |
|
||||
| `vision.endpoint` | `http://127.0.0.1:8900` | 服务地址 |
|
||||
| `vision.timeout` | `5s` | 单批超时 |
|
||||
| `vision.dim` | `768` | 向量维度(须与模型一致,已实测确认) |
|
||||
| `vision.similar_threshold` | `0.85` | 「疑似同款」相似度阈值 |
|
||||
| `vision.fail_threshold` | `3` | 连续失败多少次后熔断冷却 |
|
||||
|
||||
## 11. 降级与容错
|
||||
|
||||
核心原则:**视觉服务不可用绝不能影响入库。**
|
||||
|
||||
| 故障 | 行为 |
|
||||
|---|---|
|
||||
| 服务未启用 | 跳过 embed,不写 embedding 行 |
|
||||
| 连接失败 / 超时 | 该批向量为空,记日志,继续入库 |
|
||||
| 连续失败 ≥ `fail_threshold` | 熔断冷却 **5 分钟**,期间直接跳过 embed(避免每张图都卡超时) |
|
||||
| 返回维度与配置不符 | 拒绝写入并告警,防止写入脏数据 |
|
||||
|
||||
冷却结束后半开重试一次,成功则恢复。
|
||||
|
||||
## 12. 阈值校准(必须做,不可跳过)
|
||||
|
||||
**已实测到的关键反例**:`c1.jpg` 与 `c2.jpg` 是同一个人、同一地点、同一套穿搭(麂皮短外套 + 格纹百褶裙 + 麂皮短靴 + 托特包)的连拍,仅姿势不同——属于最典型的「同款不同拍」,但**余弦相似度只有 0.6965**。
|
||||
|
||||
初稿假设的 `0.85` 因此被证伪:按该阈值,这两张最该被认出来的图反而认不出来,会大量漏判。CLIP 类模型的归一化余弦相似度整体偏低,「同款不同拍」通常落在 **0.6~0.8**,「不同图」落在 0.3~0.5,真实分界线约在 **0.65~0.70**。
|
||||
|
||||
校准方法:取一批已知「同款不同拍」与「不同款」样本,统计相似度分布,确认两个分布的可分性,再定 `similar_threshold`。**至少需要覆盖:同套连拍、同款不同场次、不同款同风格**三类样本。
|
||||
|
||||
因该阈值只影响一个后台提示标签、不影响前台展示与任何去重逻辑,可以偏宽松(宁可多提示)。
|
||||
|
||||
## 13. 测试策略
|
||||
|
||||
**Go 侧**
|
||||
- 入库主流程回归:图片正常入库,删除 phash 后 `go build ./...` 与全部现有测试通过。
|
||||
- 降级路径:视觉服务返回错误 / 超时 / 维度不符时,入库正常完成且不产生 embedding 行。
|
||||
- `vision.enabled=false` 时不产生任何 embed 请求。
|
||||
- 同款查询:读侧正确过滤 `is_deleted = 1` 的图片。
|
||||
|
||||
**Python 侧**
|
||||
- `/embed` 返回维度正确、向量已 L2 归一化(模长≈1)。
|
||||
- 损坏图片被跳过而非整批失败。
|
||||
- `/health` 可用。
|
||||
|
||||
## 14. 里程碑
|
||||
|
||||
**已确认:下线去重与 SigLIP 集成合成一个大改动**(同分支、同一次交付),内部按下列顺序推进,每步保持可编译、可回归。
|
||||
|
||||
**M0 — spike(已完成)**
|
||||
把 `fashion-siglip` 包成常驻 `/embed` 服务(`app.py` + `smoke_test.py`),实测结论:
|
||||
|
||||
| 项 | 实测值 |
|
||||
|---|---|
|
||||
| 向量维度 | **768**(非设计初稿假设的 512,schema 与配置已按 768 修正) |
|
||||
| 模型加载 | CPU 1.9s / GPU 2.5s(启动时一次) |
|
||||
| CPU 推理 | 3 张图 217ms(约 **72ms/张**,`torch 2.14.0+cpu`) |
|
||||
| GPU 推理 | 3 张图 57ms(约 **19ms/张**,`torch 2.14.1+cu132`,RTX 5060 Ti) |
|
||||
| GPU/CPU 一致性 | 同一批图相似度 0.6965(GPU) vs 0.6964(CPU),**数值一致** |
|
||||
| 向量归一化 | 模长 1.0000,点积即余弦相似度 |
|
||||
|
||||
结论:
|
||||
|
||||
1. CPU 单图约 72ms,日入库几百张的量级完全无压力,**生产无 GPU 的部署假设成立**。
|
||||
2. 本地 GPU 比 CPU 快约 3.8 倍,且**两种设备算出的向量数值一致**——本地 GPU 调试、生产 CPU 部署不会产生数据不一致。
|
||||
|
||||
**M1 — 主改动(一个分支,一次交付)**,内部顺序:
|
||||
|
||||
| 步骤 | 内容 |
|
||||
|---|---|
|
||||
| ① Schema 迁移 | DROP 三列 + 两个 phash HNSW 索引 + 死表 `image_duplicates`;`image_embeddings` 的 `kind` 改 `varchar(8)` + 补 `(kind, image_id)` 唯一索引 |
|
||||
| ② 下线去重 | 按 §5 清单删除 `phash` / `is_duplicate` / `dup_of` 的全部代码、模板、统计、测试、文档 |
|
||||
| ③ 服务化 | Python 服务正式化(配置、批处理、错误处理、`/health`) |
|
||||
| ④ Go 集成 | `image_embeddings` 仓储读写 + `concurrentFetch` 挂钩 + 降级熔断 + 配置项 |
|
||||
| ⑤ 后台展示 | 图集页「疑似同款」标签与对比入口 |
|
||||
|
||||
每步结束时 `go build ./...` 与现有测试须通过;步骤②完成后系统进入「无自动去重」状态(前台不再折叠),需在交付说明中显式提示这一行为变化。
|
||||
|
||||
**M2 — 阈值校准**
|
||||
dry-run 诊断,定 `vision.similar_threshold`。
|
||||
|
||||
## 15. 后续(不在本次范围)
|
||||
|
||||
### 15.1 zero-shot 分类:已存向量可直接复用
|
||||
|
||||
FashionSigLIP 是图文对齐模型(CLIP 架构),文本与图像编码到同一向量空间,**点积即类别分数**。`D:\project\fashion-siglip\test_similarity_debug.py` 已用 8 组分类验证过该能力:Gender / Scene、Clothing、Color、Material、Pattern、Brand、Style、Shoes / Accessories。
|
||||
|
||||
关键性质:**文本向量是固定的,可在服务启动时算一次并缓存**;分类因此退化为「已存图像向量 · 预计算文本向量」的纯点积运算。于是:
|
||||
|
||||
- ✅ **性别 / 品类 / 颜色 / 品牌 / 风格 / 材质 / 图案 / 鞋包等 zero-shot 分类,可直接复用已存向量**,读时计算,无需重新编码任何图片、无需再调 GPU。
|
||||
- ❌ **若要超越 zero-shot 的准确率**(微调专用分类器或更换模型),则**必须重新编码全部图片**——不同模型的向量空间不通用,旧向量作废。
|
||||
|
||||
这条性质使「入库时存向量」成为**一次投入、两处收益**的选择:既买下同款检索,也买下可无限扩展的 zero-shot 分类能力。
|
||||
|
||||
注意:zero-shot 判定的是**造型风格**而非人物生理性别(模型主要依据服装)。中性 / oversize 穿搭、多人合影、背影、无人物图易误判,建议输出**分数而非硬标签**,允许人工覆盖。
|
||||
|
||||
### 15.2 其他
|
||||
|
||||
- 历史图片向量回填(本次确认不做)。
|
||||
- 阈值自动调优。
|
||||
Reference in New Issue
Block a user