Spaces:
Running
Running
Publish NMM data preprocessing v0.3.0 with Chinese tutorials
Browse files- .dockerignore +8 -0
- .gitignore +10 -0
- README.md +275 -4
- assets/app.js +94 -0
- assets/style.css +1 -0
- configs/above_256p.json +7 -0
- configs/aspect_loose.json +7 -0
- configs/aspect_strict.json +7 -0
- configs/audit.json +10 -0
- configs/exact_256p.json +7 -0
- configs/generation_example.json +14 -0
- configs/statistics_only.json +7 -0
- docker/Dockerfile +9 -0
- docs/ARCHITECTURE.md +106 -0
- docs/DEPLOYMENT.md +198 -0
- docs/PARAMETERS.md +201 -0
- docs/QUICKSTART.md +139 -0
- docs/USAGE.md +504 -0
- downloads/NMM-data-preprocess-0.3.0.zip +3 -0
- examples/make_demo.py +38 -0
- examples/make_resolution_demo.py +26 -0
- image_cleaner/__init__.py +3 -0
- image_cleaner/__main__.py +5 -0
- image_cleaner/aspect.py +26 -0
- image_cleaner/cli.py +135 -0
- image_cleaner/config.py +148 -0
- image_cleaner/images.py +137 -0
- image_cleaner/io.py +192 -0
- image_cleaner/jobs.py +382 -0
- image_cleaner/metrics.py +91 -0
- image_cleaner/stats.py +141 -0
- index.html +15 -19
- manual/ARCHITECTURE.html +222 -0
- manual/DEPLOYMENT.html +206 -0
- manual/PARAMETERS.html +549 -0
- manual/QUICKSTART.html +100 -0
- manual/USAGE.html +816 -0
- pyproject.toml +19 -0
- tests/test_aspect.py +103 -0
- tests/test_pipeline.py +275 -0
- tools/build_space.py +125 -0
- tools/check_release.py +64 -0
- tools/check_web.js +22 -0
- tools/publish_space.py +73 -0
- web/app.js +94 -0
- web/index.template.html +15 -0
- web/style.css +1 -0
.dockerignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv
|
| 2 |
+
__pycache__
|
| 3 |
+
*.egg-info
|
| 4 |
+
runs
|
| 5 |
+
release
|
| 6 |
+
.git
|
| 7 |
+
.env*
|
| 8 |
+
*.token
|
.gitignore
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv/
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*.egg-info/
|
| 5 |
+
runs/
|
| 6 |
+
.env
|
| 7 |
+
.env.*
|
| 8 |
+
*.token
|
| 9 |
+
release/
|
| 10 |
+
*.log
|
README.md
CHANGED
|
@@ -1,10 +1,281 @@
|
|
| 1 |
---
|
| 2 |
-
title: NMM
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: blue
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
|
|
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: NMM-data-preprocess
|
| 3 |
+
emoji: 🧹
|
| 4 |
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: static
|
| 7 |
+
app_file: index.html
|
| 8 |
pinned: false
|
| 9 |
+
short_description: 图片文本初筛:代码、参数、部署、分布统计与清洗教程
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# NMM-data-preprocess:图片—文本数据初筛
|
| 13 |
+
|
| 14 |
+
版本 **0.3.0**。面向原生图片—文本模型训练前的数据初筛。Space 提供中文教程、参数查询、命令生成和源码下载;数据处理在本地或服务器通过 CLI 执行。静态 Space 不接收图片、不运行后台清洗作业。
|
| 15 |
+
|
| 16 |
+
| 入口 | 内容 |
|
| 17 |
+
| --- | --- |
|
| 18 |
+
| [新手逐步教程](docs/QUICKSTART.md) | 下载、安装、合成示例、先统计再筛选再导出 |
|
| 19 |
+
| [完整使用与评测手册](docs/USAGE.md) | 每个阶段的操作、指标公式、宽高比规则、报告解读 |
|
| 20 |
+
| [参数参考](docs/PARAMETERS.md) | 所有 CLI 参数与 JSON 配置字段、默认值、边界和优先级 |
|
| 21 |
+
| [部署指南](docs/DEPLOYMENT.md) | Windows、Linux、Docker、多机、断点续跑、HF Space 发布 |
|
| 22 |
+
| [代码与功能说明](docs/ARCHITECTURE.md) | 模块职责、数据流、文件格式、功能矩阵、能力边界 |
|
| 23 |
+
|
| 24 |
+
下载:`git clone https://huggingface.co/spaces/HanWangTJU/NMM-data-preprocess multimodal_training`,或在 Space 首页下载源码 ZIP。所有命令均在下载后的项目根目录执行。
|
| 25 |
+
|
| 26 |
+
Python 3.10+,Windows / Linux。支持文件夹递归导入和 JSONL 图文清单;单机多进程和多机静态分片;SQLite 断点续跑、全局去重、原始/保留分布报告,以及标准化图片导出。原始图片不修改、不删除。
|
| 27 |
+
|
| 28 |
+
详细的逐阶段指标、公式、配置字段、单机/多机命令和评测方法见 **[完整使用手册](docs/USAGE.md)**。
|
| 29 |
+
|
| 30 |
+
## 统计后按宽高比桶筛选
|
| 31 |
+
|
| 32 |
+
支持八个宽:高预设桶 `1:1,2:3,3:2,3:4,4:3,9:16,16:9,21:9`,可多选或使用 `all`。默认不启用桶筛选;启用后默认严格匹配。宽松模式偏差默认 5%,用 `--aspect-tolerance 0.03` 等参数调整。完整规则和评测指标见 [宽高比筛选说明](docs/USAGE.md#36-宽高比桶筛选)。
|
| 33 |
+
|
| 34 |
+
```powershell
|
| 35 |
+
# 严格筛选三个桶,同时要求短边至少 256 像素。
|
| 36 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/statistics/shards --output runs/aspect_strict --resolution 256p --aspect-buckets "1:1,2:3,3:2" --aspect-mode strict
|
| 37 |
+
|
| 38 |
+
# 全部八个桶,允许包含边界的 5% 对称偏差;无须重新解码图片。
|
| 39 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/statistics/shards --output runs/aspect_loose --aspect-buckets all --aspect-mode loose --aspect-tolerance 0.05
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
## 统计后按分辨率筛选
|
| 43 |
+
|
| 44 |
+
```powershell
|
| 45 |
+
# 第一次全量统计,不启用可选阈值和去重。
|
| 46 |
+
.\.venv\Scripts\python.exe -m image_cleaner run --input "D:\datasets\images" --output runs/statistics --config configs/statistics_only.json --workers 8
|
| 47 |
+
|
| 48 |
+
# 查看 runs/statistics/curated/report.html 后,保留短边大于或等于 256 像素的图片。
|
| 49 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/statistics/shards --output runs/only_256p --resolution 256p
|
| 50 |
+
|
| 51 |
+
# 或只保留方向校正后的宽=256、高=256;其他尺寸全部进入 rejected.jsonl。
|
| 52 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/statistics/shards --output runs/only_256_square --resolution 256x256
|
| 53 |
+
|
| 54 |
+
# 导出筛选后的图片与文本。
|
| 55 |
+
.\.venv\Scripts\python.exe -m image_cleaner export --curated runs/only_256p --output runs/export_256p --workers 8
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
`filter` 是 `merge` 的别名;它从全部分析分片重新筛选,复用已保存的尺寸与质量指标,不重新读图,也不会把不匹配的图片缩放到目标尺寸。
|
| 59 |
+
|
| 60 |
+
`256p` 在本工具中明确指 **短边大于或等于 256**:256×256、384×256、256×384、257×384、512×512 均符合;255×384 不符合。256 本身包含在内。`384x256` 指精确宽高,与 `256x384` 不同。尺寸取 EXIF 校正后的方向,筛选在导出缩放之前执行。`--resolution any` 关闭分辨率条件。
|
| 61 |
+
|
| 62 |
+
配置文件也可写 `"resolution": "256p"`;命令行覆盖该字段。所有其他启用规则继续取交集。使用 `statistics_only.json` 做第一轮后,单独传 `--resolution` 就只做尺寸筛选;需要加去重时同时指定 `--config configs/audit.json`。
|
| 63 |
+
|
| 64 |
+
`configs/above_256p.json` 可直接一键筛选。旧文件名 `configs/exact_256p.json` 保留为兼容配置,但其中的 `256p` 也采用大于或等于的新规则。
|
| 65 |
+
|
| 66 |
+
```powershell
|
| 67 |
+
.\.venv\Scripts\python.exe -m image_cleaner run --input "D:\datasets\images" --output runs/direct_256p --config configs/above_256p.json --workers 8 --export
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
## 安装与第一次运行
|
| 71 |
+
|
| 72 |
+
当前 Windows 工作区已建立 `.venv`。首次在另一台机器使用时:
|
| 73 |
+
|
| 74 |
+
```powershell
|
| 75 |
+
cd C:\Users\Administrator\.codex\tmp\multimodal_training
|
| 76 |
+
python -m venv .venv
|
| 77 |
+
.\.venv\Scripts\python.exe -m pip install -e .
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
推荐第一次使用审计配置:先了解分布、查看重复样本,再设置质量阈值。输出必须在输入图片目录之外。
|
| 81 |
+
|
| 82 |
+
```powershell
|
| 83 |
+
.\.venv\Scripts\python.exe -m image_cleaner run --input "D:\datasets\images" --output "runs\first_audit" --config configs/audit.json --workers 8
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
如果每张图片有同名 UTF-8 文本,如 `001.jpg` 与 `001.txt`,加 `--sidecar-text`。同一目录递归支持 JPG、PNG、WebP、BMP、TIFF、GIF、AVIF;实际解码格式支持取决于 Pillow。默认拒绝动画和多页文件,不会悄悄把所有帧当成一张图。
|
| 87 |
+
|
| 88 |
+
若需要输出 RGB 标准化图片,加 `--export`。只导出保留样本,默认无损 PNG;默认不缩放、不裁剪、不去噪。配置 `export_max_edge` 后保持宽高比缩小,不放大。固定方形裁剪和图像张量均值/标准差归一化应由模型训练的数据加载器负责。
|
| 89 |
+
|
| 90 |
+
```powershell
|
| 91 |
+
.\.venv\Scripts\python.exe -m image_cleaner run --input "D:\datasets\images" --output "runs\first_audit" --config configs/audit.json --workers 8 --export --resume
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
`--resume` 必须使用相同输入、配置和分片规模。清单固定后请保持原始文件不变;分析续跑会跳过已记录样本,导出时另行校验源文件 SHA-256,发现变更会报告失败。
|
| 95 |
+
|
| 96 |
+
## 图文 JSONL 导入
|
| 97 |
+
|
| 98 |
+
大规模多机处理最适合使用固定 JSONL 清单。每行至少有 `path`;`text`、原始 `id` 和任意元数据可选:
|
| 99 |
+
|
| 100 |
+
```json
|
| 101 |
+
{"id":"origin-001","path":"animals/001.jpg","text":"一只猫坐在窗边。","metadata":{"source":"dataset-a","license":"your-source-license"}}
|
| 102 |
+
{"id":"origin-002","path":"charts/002.png","text":"柱状图显示不同月份的销量。"}
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
`path` 可以是绝对路径,或相对于 `--root` 的路径。为了跨机器迁移,推荐相对路径。同图多个描述/问答可使用多条记录;工具生成内部唯一 ID,原始 ID 存入 `source_id`,其他输入字段放入 `metadata`。
|
| 106 |
+
|
| 107 |
+
```powershell
|
| 108 |
+
.\.venv\Scripts\python.exe -m image_cleaner run --input "D:\datasets\pairs.jsonl" --root "D:\datasets\images" --output runs/pairs --config configs/audit.json --workers 8
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
不自动下载 URL、不解压任意压缩包;请先将图片落到本地磁盘、共享文件系统或对象存储挂载目录。
|
| 112 |
+
|
| 113 |
+
## 已实现的方法
|
| 114 |
+
|
| 115 |
+
| 方法 | 实现与输出 |
|
| 116 |
+
|---|---|
|
| 117 |
+
| 分辨率分布 | 宽、高、短边、长边、总像素数的直方图、均值、标准差、分位数;清洗前后对比 |
|
| 118 |
+
| 宽高比分布 | W/H 分桶,以及横图/竖图/近方图占比;可设置最小/最大比例 |
|
| 119 |
+
| 图片规范化 | EXIF 方向校正、ICC 转 sRGB(有配置文件时)、RGB 转换、透明背景合成;可选等比例缩小 |
|
| 120 |
+
| 文本规范化 | NFC 或 NFKC Unicode 规范化、换行统一、清除行末及首尾空白;保留段落;字符数和正则表达式筛选 |
|
| 121 |
+
| 解码与容量检查 | 损坏、截断、缺失文件、动画/多页图片,以及最大解码像素数检查 |
|
| 122 |
+
| 清晰度 | 原生像素区域上的拉普拉斯方差 `blur_score`;越低通常越模糊,但依赖图片内容 |
|
| 123 |
+
| 噪声估计 | 低梯度区域高通残差 MAD 估计 `noise_sigma`,像素范围 0–255 |
|
| 124 |
+
| 信噪比代理 | `snr_proxy_db = 20 log10(contrast / noise_sigma)`,无参考估计,不是真实 SNR 或 PSNR |
|
| 125 |
+
| 压缩块效应 | 在 8 像素网格不同相位上测量边界与非边界的梯度差 `blockiness`;是代理指标 |
|
| 126 |
+
| 完全重复 | 文件 SHA-256,以及方向/颜色/透明度规范化后的 RGB 像素 SHA-256 |
|
| 127 |
+
| 近似重复 | 64 位 pHash 候选检索 + dHash + 宽高比 + RGB 缩略图 RMSE 复核 |
|
| 128 |
+
| 可追溯筛选 | 每条记录保存指标、筛选原因、重复类型、代表样本 ID;配置及版本写入报告 |
|
| 129 |
+
| 标准化输出 | 独立 PNG/JPEG 图片及对应文本清单;支持导出续跑、源文件与输出完整性校验 |
|
| 130 |
+
|
| 131 |
+
此处“正则化”实施为数据规范化和文本正则筛选。Dropout、weight decay 等模型正则化属于训练代码,未混入清洗过程。
|
| 132 |
+
|
| 133 |
+
这版实现的是图片技术质量与基础文本清洗,**未实现 CLIP 图文语义对齐、VLM 事实核验、审美模型、OCR、水印分类器和语义 embedding 去重**。统计/感知指标不能替代这些模型的判断。
|
| 134 |
+
|
| 135 |
+
## 指标解释与阈值配置
|
| 136 |
+
|
| 137 |
+
`configs/audit.json` 默认只统计技术质量,开启去重与格式有效性检查;不设置模糊、噪声、信噪比和块效应阈值。`configs/generation_example.json` 是示例,采用短边至少 512、宽高比 0.25–4、文本非空;不要将示例当成适用于所有领域的标准。
|
| 138 |
+
|
| 139 |
+
可选字段如下,未列出的字段使用代码默认值;未知字段和非法范围会报错:
|
| 140 |
+
|
| 141 |
+
```json
|
| 142 |
+
{
|
| 143 |
+
"min_short_edge": 512,
|
| 144 |
+
"max_long_edge": null,
|
| 145 |
+
"min_pixels": null,
|
| 146 |
+
"max_pixels": null,
|
| 147 |
+
"min_aspect_ratio": 0.25,
|
| 148 |
+
"max_aspect_ratio": 4.0,
|
| 149 |
+
"min_blur_score": null,
|
| 150 |
+
"max_noise_sigma": null,
|
| 151 |
+
"min_snr_proxy_db": null,
|
| 152 |
+
"max_blockiness": null,
|
| 153 |
+
"min_text_chars": 1,
|
| 154 |
+
"max_text_chars": null,
|
| 155 |
+
"forbidden_text_patterns": [],
|
| 156 |
+
"duplicate_scope": "image_text",
|
| 157 |
+
"deduplicate": true,
|
| 158 |
+
"near_deduplicate": true,
|
| 159 |
+
"phash_distance": 4,
|
| 160 |
+
"dhash_distance": 4,
|
| 161 |
+
"near_rgb_rmse": 8.0,
|
| 162 |
+
"near_aspect_log_tolerance": 0.03,
|
| 163 |
+
"metric_patch_size": 256,
|
| 164 |
+
"max_decode_pixels": 100000000,
|
| 165 |
+
"reject_animated": true,
|
| 166 |
+
"alpha_background": [255, 255, 255],
|
| 167 |
+
"convert_icc": true,
|
| 168 |
+
"text_unicode_form": "NFC",
|
| 169 |
+
"export_max_edge": null,
|
| 170 |
+
"export_format": "png",
|
| 171 |
+
"export_jpeg_quality": 95
|
| 172 |
+
}
|
| 173 |
+
```
|
| 174 |
+
|
| 175 |
+
质量指标使用最多五个原生像素区域(四角和中心,默认 256×256),取区域指标中位数。不会先缩图再估计噪声,但采样可能漏掉局部缺陷。高通噪声估计仍会受纹理、边缘、非高斯噪声影响;锐化、噪声也可能抬高清晰度分数。
|
| 176 |
+
|
| 177 |
+
信噪比代理的“信号”是局部亮度标准差。纯色、低对比图或噪声估计为零时结果为 `null`,附带 `snr_proxy_undefined`;缺失指标不触发对应阈值筛选。极小图片的部分质量指标也为 `null`。报告包含各指标缺失数,避免将未测到的指标误当成高质量。
|
| 178 |
+
|
| 179 |
+
ICC 转换失败会记录处理错误;16 位/浮点等输入最终转为 8 位 RGB,工具主要面向常规网页图像,不适合需要保留原始动态范围的科学影像。
|
| 180 |
+
|
| 181 |
+
建议查看 `report.html`,抽查保留和删除样本后再设置质量阈值。清洗前后直方图、计数、均值和标准差是全量计算;分位数在不超过 20,000 条时精确,更多时使用固定随机种子的蓄水池抽样,报告明确标记近似。
|
| 182 |
+
|
| 183 |
+
## 去重策略
|
| 184 |
+
|
| 185 |
+
默认 `duplicate_scope=image_text`:仅当规范化文本也相同时删除重复记录,保留同图的不同有效描述/问答;只导入图片时文本为空,等价于按图片去重。改为 `image` 则不区分文本,图片重复记录都会归并。
|
| 186 |
+
|
| 187 |
+
先应用质量阈值,再从合格样本选择代表,按 `像素数降序 → 噪声升序 → 清晰度降序 → 清单序号升序` 排序。被质量规则拒绝的样本不会压掉合格样本。每个近重复都直接匹配一个保留代表,不通过长链相似关系无限扩大分组。
|
| 188 |
+
|
| 189 |
+
近重复候选通过 SQLite 多索引哈希分带检索:将 64 位 pHash 分成 r+1 段,汉明距离不超过 r 的候选必然共享至少一个段。之后严格复核 pHash、dHash、宽高比和 RGB 缩略图误差。允许 pHash 半径 0–8。pHash 抑制相对于最大 AC 系数极小的系数,减少平滑/纯色图片重编码后的数值波动;它与普通 pHash 分数不可直接互换。
|
| 190 |
+
|
| 191 |
+
近重复仍是启发式,可能误合并细节仅有微小差异的图片,也可能漏掉大裁剪、翻转、文字覆盖或强颜色变化。先抽查 `duplicate.jsonl`;如只需要确定重复,设置 `near_deduplicate=false`。重复清单不会被删除,方便重新评估。
|
| 192 |
+
|
| 193 |
+
若后续划分训练/验证集,需要把同图及其变体放在同一侧;本工具尚未自动做数据集划分。
|
| 194 |
+
|
| 195 |
+
## 多机分布式处理
|
| 196 |
+
|
| 197 |
+
采用静态 map/reduce 分片,无需 Ray、Redis 或 GPU。每台机器独立运行多进程 worker;每条清单按 `seq % world_size` 分配到唯一 rank。所有机器必须使用同一份冻结后的清单、元数据和配置。
|
| 198 |
+
|
| 199 |
+
1. 在协调节点准备清单:
|
| 200 |
+
|
| 201 |
+
```bash
|
| 202 |
+
python -m image_cleaner prepare --input /data/images --manifest /jobs/input.jsonl --sidecar-text
|
| 203 |
+
```
|
| 204 |
+
|
| 205 |
+
将 `input.jsonl` 与 `input.jsonl.meta.json` 分发到节点。
|
| 206 |
+
|
| 207 |
+
2. 以下两条命令分别在两台机器上运行(也可用于同一机器模拟两节点):
|
| 208 |
+
|
| 209 |
+
```bash
|
| 210 |
+
# 节点 0,本机目录可以不同;相对路径须对应同一份图片内容。
|
| 211 |
+
python -m image_cleaner analyze --manifest /jobs/input.jsonl --root /node0/images --output /jobs/shards --config configs/audit.json --workers 8 --world-size 2 --rank 0
|
| 212 |
+
|
| 213 |
+
# 节点 1
|
| 214 |
+
python -m image_cleaner analyze --manifest /jobs/input.jsonl --root /node1/images --output /jobs/shards --config configs/audit.json --workers 8 --world-size 2 --rank 1
|
| 215 |
+
```
|
| 216 |
+
|
| 217 |
+
每个节点也可以写本地目录。任务完成、进程退出后,把各自 `shard-*.sqlite` 收集到一个协调节点的目录。不要让多个机器写同一个 SQLite 文件;共享文件系统上不同 rank 的文件互不重叠,但更推荐节点本地磁盘后收集。
|
| 218 |
+
|
| 219 |
+
3. 在协调节点执行全局合并:
|
| 220 |
+
|
| 221 |
+
```bash
|
| 222 |
+
python -m image_cleaner merge --shards /jobs/shards --output /jobs/curated
|
| 223 |
+
```
|
| 224 |
+
|
| 225 |
+
缺少任一 rank、未完成分片、不同清单/配置/版本、重复序号都会报错。去重是在所有分片合并后完成,因此能找到跨节点重复。
|
| 226 |
+
|
| 227 |
+
4. 如需要物理输出标准化图片:
|
| 228 |
+
|
| 229 |
+
```bash
|
| 230 |
+
python -m image_cleaner export --curated /jobs/curated --root /data/images --output /jobs/normalized --workers 8
|
| 231 |
+
```
|
| 232 |
+
|
| 233 |
+
分析和导出均支持 `--resume`。中断后如果遗留 `.lock`,只有确认相关进程已经退出,才可删除对应锁文件后续跑。分析每 100 条提交 SQLite,最多重新分析尚未提交的记录。全局合并暂不支持中途续跑;合并失败时在新的输出目录重新执行,分析结果可复用。
|
| 234 |
+
|
| 235 |
+
静态分片不包含自动调度、节点故障迁移或动态负载均衡。多机分析可并行,最终全局合并去重由单个协调节点执行;索引、排序与中间结果落磁盘,不在内存保存全部样本,也不做全量两两比较。大量相似/纯色图片或过宽的哈希半径会增加候选复核成本。每个节点需读取整份清单来选择自己的 rank;超大清单可预先拆分调度,但当前 CLI 不接受不同清单直接合并。
|
| 236 |
+
|
| 237 |
+
并发任务队列最多约 `2 × workers`;统计抽样大小固定;SQLite 页缓存约 32 MiB。图片解码仍需原图大小的内存,因此高分辨率数据请适当降低 `--workers` 与 `max_decode_pixels`。这里提供的是规模化处理机制,没有宣称已完成百万/亿级性能基准。
|
| 238 |
+
|
| 239 |
+
## 输出文件
|
| 240 |
+
|
| 241 |
+
```text
|
| 242 |
+
runs/first_audit/
|
| 243 |
+
input.jsonl # 固定清单
|
| 244 |
+
input.jsonl.meta.json # 清单 SHA-256、数量、默认数据根目录
|
| 245 |
+
shards/
|
| 246 |
+
shard-00000-of-00001.sqlite # 分析结果与续跑检查点
|
| 247 |
+
shard-00000-of-00001.jsonl # 分片分析结果,方便查看
|
| 248 |
+
curated/
|
| 249 |
+
curation.sqlite # 全局最终结果与哈希索引
|
| 250 |
+
kept.jsonl # 保留记录,包含源路径与文本
|
| 251 |
+
rejected.jsonl # 技术质量/文本规则拒绝记录
|
| 252 |
+
duplicate.jsonl # 重复类型、代表 ID、近重复距离
|
| 253 |
+
error.jsonl # 解码或文件处理错误
|
| 254 |
+
report.json # 配置、计数、前后分布
|
| 255 |
+
report.html # 无外部依赖的 SVG 分布图
|
| 256 |
+
normalized/ # 仅 --export 时生成
|
| 257 |
+
images/000000/sample-....png
|
| 258 |
+
exported.jsonl # 图片相对路径与文本,可用于后续训练
|
| 259 |
+
export_errors.jsonl
|
| 260 |
+
export.meta.json
|
| 261 |
+
```
|
| 262 |
+
|
| 263 |
+
导出 `exported.jsonl` 的顺序可能随 worker 完成时间变化;每条 ID 和输出路径稳定。最终筛选清单均按固定输入序号输出。导出失败会写错误清单并返回非零退出码;单张图片分析失败记录到错误清单,不中止整批分析。
|
| 264 |
+
|
| 265 |
+
修改阈值后不必重复解码:
|
| 266 |
+
|
| 267 |
+
```bash
|
| 268 |
+
python -m image_cleaner merge --shards runs/first_audit/shards --output runs/filtered_v2 --config configs/generation_example.json
|
| 269 |
+
```
|
| 270 |
+
|
| 271 |
+
必须输出到新目录,图片预处理与质量测量设置不能更改;更改这些设置需要重新分析。
|
| 272 |
+
|
| 273 |
+
## 验证
|
| 274 |
+
|
| 275 |
+
```powershell
|
| 276 |
+
.\.venv\Scripts\python.exe -m unittest discover -s tests -v
|
| 277 |
+
```
|
| 278 |
+
|
| 279 |
+
测试涵盖多进程/两分片与单机一致性、跨分片重复、缺失分片拒绝、断点续跑、源文件变更、像素重复与近重复、同图多文本、EXIF/透明度、噪声估计、模糊指标、筛选后再统计,以及超过一批次的全局去重。
|
| 280 |
+
|
| 281 |
+
技术参考:[Pillow ImageOps](https://pillow.readthedocs.io/en/stable/reference/ImageOps.html)、[Python ProcessPoolExecutor](https://docs.python.org/3/library/concurrent.futures.html)。本工具的噪声实现采用原生像素高通残差估计,未调用 `skimage.estimate_sigma`,两者分数不可直接互换。
|
assets/app.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
// Use integer rational comparisons in the browser calculator, like the CLI.
|
| 3 |
+
function decimalFraction(value) {
|
| 4 |
+
const parts = String(value).toLowerCase().split("e");
|
| 5 |
+
const mantissa = parts[0].split(".");
|
| 6 |
+
let numerator = BigInt(mantissa.join(""));
|
| 7 |
+
const exponent = Number(parts[1] || 0) - (mantissa[1] || "").length;
|
| 8 |
+
let denominator = 1n;
|
| 9 |
+
if (exponent >= 0) numerator *= 10n ** BigInt(exponent);
|
| 10 |
+
else denominator = 10n ** BigInt(-exponent);
|
| 11 |
+
return [numerator, denominator];
|
| 12 |
+
}
|
| 13 |
+
function bucketAssessment(width, height, buckets, mode, percent) {
|
| 14 |
+
let best = null;
|
| 15 |
+
const [tn, td] = decimalFraction(percent);
|
| 16 |
+
for (const bucket of buckets) {
|
| 17 |
+
const [n, d] = bucket.split(":").map(BigInt);
|
| 18 |
+
const a = BigInt(width) * d, b = BigInt(height) * n;
|
| 19 |
+
const difference = a >= b ? a - b : b - a, base = a <= b ? a : b;
|
| 20 |
+
const matched = mode === "strict" ? difference === 0n : difference * td * 100n <= base * tn;
|
| 21 |
+
if (best === null || difference * best.base < best.difference * base) {
|
| 22 |
+
best = {bucket, difference, base, matched};
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
return best;
|
| 26 |
+
}
|
| 27 |
+
function buildFilterCommand(platform, resolution, buckets, mode, percent) {
|
| 28 |
+
if (!/^(?:[1-9][0-9]*p|[1-9][0-9]*x[1-9][0-9]*|any|none)$/.test(resolution)) {
|
| 29 |
+
throw new Error("分辨率请填写 256p、384x256 或 any;数字需为正整数。");
|
| 30 |
+
}
|
| 31 |
+
if (mode === "loose" && (!Number.isFinite(Number(percent)) || Number(percent) < 0 || String(percent).trim() === "")) {
|
| 32 |
+
throw new Error("宽松偏差需为有限非负数,例如 5 表示 5%。");
|
| 33 |
+
}
|
| 34 |
+
const python = platform === "windows" ? ".\\.venv\\Scripts\\python.exe" : ".venv/bin/python";
|
| 35 |
+
const selected = buckets.length === 8 ? "all" : buckets.length ? '"' + buckets.join(",") + '"' : "none";
|
| 36 |
+
let command = `${python} -m image_cleaner filter --shards runs/statistics/shards --output runs/filtered --resolution ${resolution} --aspect-buckets ${selected}`;
|
| 37 |
+
if (buckets.length) {
|
| 38 |
+
command += ` --aspect-mode ${mode}`;
|
| 39 |
+
if (mode === "loose") command += ` --aspect-tolerance ${Number(percent) / 100}`;
|
| 40 |
+
}
|
| 41 |
+
return command;
|
| 42 |
+
}
|
| 43 |
+
if (typeof module !== "undefined") module.exports = {bucketAssessment, buildFilterCommand};
|
| 44 |
+
if (typeof document !== "undefined") {
|
| 45 |
+
document.querySelectorAll("table").forEach(table => {
|
| 46 |
+
const wrapper = document.createElement("div"); wrapper.className = "table-scroll";
|
| 47 |
+
table.parentNode.insertBefore(wrapper, table); wrapper.appendChild(table);
|
| 48 |
+
});
|
| 49 |
+
document.querySelectorAll("pre").forEach(pre => {
|
| 50 |
+
const button = document.createElement("button"); button.className = "copy-code";
|
| 51 |
+
button.type = "button"; button.textContent = "复制";
|
| 52 |
+
button.addEventListener("click", async () => {
|
| 53 |
+
const value = pre.querySelector("code") ? pre.querySelector("code").textContent : pre.textContent.replace(/复制$/, "");
|
| 54 |
+
try {
|
| 55 |
+
await navigator.clipboard.writeText(value); button.textContent = "已复制";
|
| 56 |
+
} catch (_) { button.textContent = "请选中后复制"; }
|
| 57 |
+
setTimeout(() => {button.textContent = "复制";}, 2000);
|
| 58 |
+
});
|
| 59 |
+
pre.appendChild(button);
|
| 60 |
+
});
|
| 61 |
+
if (document.getElementById("command")) {
|
| 62 |
+
const ids = ["platform", "resolution", "mode", "tolerance", "width", "height"];
|
| 63 |
+
const controls = Object.fromEntries(ids.map(id => [id, document.getElementById(id)]));
|
| 64 |
+
const buckets = [...document.querySelectorAll('input[name="bucket"]')];
|
| 65 |
+
const update = () => {
|
| 66 |
+
const selected = buckets.filter(x => x.checked).map(x => x.value);
|
| 67 |
+
const mode = controls.mode.value, percent = controls.tolerance.value;
|
| 68 |
+
controls.tolerance.disabled = mode === "strict";
|
| 69 |
+
const error = document.getElementById("command-error"), output = document.getElementById("command");
|
| 70 |
+
try {
|
| 71 |
+
output.textContent = buildFilterCommand(controls.platform.value, controls.resolution.value.trim().toLowerCase(), selected, mode, percent);
|
| 72 |
+
error.textContent = "";
|
| 73 |
+
} catch (e) { output.textContent = "请先修正参数。"; error.textContent = e.message; }
|
| 74 |
+
const result = document.getElementById("ratio-result"), w = Number(controls.width.value), h = Number(controls.height.value);
|
| 75 |
+
if (!Number.isSafeInteger(w) || !Number.isSafeInteger(h) || w < 1 || h < 1) {
|
| 76 |
+
result.textContent = "请输入大于零的整数宽高。"; return;
|
| 77 |
+
}
|
| 78 |
+
if (error.textContent) { result.textContent = "请先修正筛选参数。"; return; }
|
| 79 |
+
const best = bucketAssessment(w, h, selected, mode, mode === "strict" ? "0" : percent);
|
| 80 |
+
const resolution = controls.resolution.value.trim().toLowerCase();
|
| 81 |
+
let sizeMatched = true;
|
| 82 |
+
if (resolution.endsWith("p")) sizeMatched = Math.min(w, h) >= Number(resolution.slice(0, -1));
|
| 83 |
+
else if (resolution.includes("x")) {
|
| 84 |
+
const [rw, rh] = resolution.split("x").map(Number); sizeMatched = w === rw && h === rh;
|
| 85 |
+
}
|
| 86 |
+
const bucketMessage = best ? `${best.matched ? "桶条件通过" : "桶条件未通过"};最近桶 ${best.bucket},偏差 ${(Number(best.difference) / Number(best.base) * 100).toFixed(3)}%` : "桶条件已关闭";
|
| 87 |
+
result.textContent = `${w}×${h}:${bucketMessage};分辨率条件${sizeMatched ? "通过" : "未通过"}。`;
|
| 88 |
+
};
|
| 89 |
+
[...Object.values(controls), ...buckets].forEach(x => x.addEventListener("input", update));
|
| 90 |
+
document.getElementById("select-all").addEventListener("click", () => {buckets.forEach(x => x.checked = true); update();});
|
| 91 |
+
document.getElementById("select-none").addEventListener("click", () => {buckets.forEach(x => x.checked = false); update();});
|
| 92 |
+
update();
|
| 93 |
+
}
|
| 94 |
+
}
|
assets/style.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
:root{--ink:#12273b;--muted:#596c7a;--teal:#087d70;--line:#d9e3e8;--surface:#f3f7f9;--paper:#fff}*{box-sizing:border-box}html{scroll-behavior:smooth;scroll-padding-top:88px}body{margin:0;background:var(--paper);color:var(--ink);font-family:Inter,"Microsoft YaHei",system-ui,sans-serif;line-height:1.8}a{color:var(--teal);text-decoration:none}a:hover{text-decoration:underline}.topbar{position:sticky;top:0;z-index:20;min-height:72px;display:flex;justify-content:space-between;gap:20px;align-items:center;padding:15px max(24px,calc((100vw - 1180px)/2));border-bottom:1px solid var(--line);background:rgba(255,255,255,.97)}.brand{font-weight:750;letter-spacing:-.5px;color:var(--ink);white-space:nowrap}.brand span{color:var(--teal)}nav{display:flex;gap:24px;font-size:14px}nav a{color:var(--muted)}.landing{max-width:1180px;margin:auto;padding:0 28px}.hero{padding:75px 0 58px;max-width:900px}.eyebrow{font-size:12px;letter-spacing:1.5px;font-weight:700;color:var(--teal)}h1{font-size:clamp(36px,5.2vw,64px);line-height:1.24;letter-spacing:-1.8px;margin:22px 0}h2{font-size:26px;line-height:1.45;letter-spacing:-.5px}h3{font-size:19px;line-height:1.5}.lead{font-size:19px;max-width:740px;color:var(--muted)}.actions{display:flex;gap:12px;flex-wrap:wrap;margin:30px 0 17px}.button{display:inline-block;padding:11px 22px;border:1px solid var(--line);border-radius:7px;font-weight:600;color:var(--ink);font-size:14px}.primary{background:var(--teal);border-color:var(--teal);color:#fff}.button:hover{text-decoration:none;filter:brightness(.95)}.note{font-size:13px;color:var(--muted)}.pipeline{display:grid;grid-template-columns:repeat(4,1fr);border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:23px 0;gap:20px}.pipeline div{display:grid;grid-template-columns:36px 1fr;column-gap:12px}.pipeline b{grid-row:span 2;color:var(--teal);font-size:14px}.pipeline strong{font-size:17px}.pipeline span{font-size:12px;color:var(--muted)}.intro,.features{padding:48px 0 20px}.intro>p,.generator>p{color:var(--muted)}.guide-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-top:24px}.guide{border:1px solid var(--line);border-radius:9px;padding:23px;color:var(--ink);transition:background .15s}.guide:hover{background:var(--surface);text-decoration:none}.guide small{color:var(--teal);font-size:12px}.guide h3{margin:8px 0}.guide p{font-size:14px;color:var(--muted);margin:8px 0 20px}.guide span{font-size:13px;font-weight:600;color:var(--teal)}.generator{margin-top:38px;padding:30px;background:var(--surface);border:1px solid var(--line);border-radius:10px}.section-heading{display:flex;justify-content:space-between;gap:16px;align-items:center}.section-heading h2{margin:5px 0}.section-heading .eyebrow{margin:0}.tag{font-size:11px;border:1px solid #bbd9d2;border-radius:40px;padding:4px 11px;color:var(--teal)}.form-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin:24px 0}.form-grid label{display:flex;flex-direction:column;font-size:13px;gap:7px;font-weight:600}input,select,button{font:inherit}input[type=text],input[type=number],select{width:100%;border:1px solid #bdcdd6;background:white;padding:10px;border-radius:5px;color:var(--ink);font-size:14px}input:focus,select:focus,button:focus-visible,a:focus-visible{outline:2px solid var(--teal);outline-offset:3px}.buckets{padding:14px 0;border:0;display:flex;flex-wrap:wrap;gap:12px}.buckets legend{font-size:13px;font-weight:600}.buckets label{padding:6px 10px;border:1px solid var(--line);border-radius:5px;background:white;font-size:13px;cursor:pointer}.buckets input{accent-color:var(--teal);margin-right:6px}.buckets button{border:0;color:var(--teal);background:transparent;cursor:pointer;font-size:12px;padding:5px}pre{background:#142a3b;color:#e5edf2;border-radius:7px;padding:22px 24px;white-space:pre-wrap;overflow-wrap:anywhere;overflow-x:auto;position:relative;font-size:13px;line-height:1.75}pre code{background:transparent;color:inherit;padding:0}code{font-family:"Cascadia Code",Consolas,monospace;font-size:.88em;background:#eaf0f3;padding:2px 5px;border-radius:4px}.copy-code{position:absolute;right:7px;top:6px;cursor:pointer;color:#d6eee8;background:#294759;border:1px solid #537180;border-radius:4px;font-size:11px;padding:3px 8px}.error{color:#aa302b;font-size:13px}.error:empty{display:none}details{margin-top:20px;border-top:1px solid var(--line);padding-top:16px}summary{cursor:pointer;font-size:14px;font-weight:600}details .form-grid{grid-template-columns:repeat(2,minmax(120px,200px))}#ratio-result{font-size:14px;font-weight:600;color:var(--teal)}.feature-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:20px}.feature-grid article{border-top:2px solid var(--line);padding:12px 0}.feature-grid p{color:var(--muted);font-size:14px}.callout{border-left:3px solid var(--teal);padding:5px 26px;margin:30px 0 64px;background:var(--surface)}.callout h2{font-size:22px;padding-top:18px}.callout p{font-size:15px;max-width:900px}.callout a{display:inline-block;font-size:14px;margin-bottom:20px}footer{max-width:1180px;margin:auto;padding:24px 28px;border-top:1px solid var(--line);color:var(--muted);font-size:12px;display:flex;justify-content:space-between;gap:20px}.manual-layout{display:grid;grid-template-columns:240px minmax(0,1fr);gap:48px;max-width:1230px;margin:0 auto;padding:38px 28px 65px}.sidebar{position:sticky;top:108px;align-self:start;font-size:13px;max-height:calc(100vh - 135px);overflow:auto}.sidebar>a{display:block;margin-bottom:10px;padding:7px 11px;border-left:2px solid transparent;color:var(--muted)}.sidebar>a.active{color:var(--teal);border-color:var(--teal);background:var(--surface)}.sidebar h2{font-size:13px;color:var(--muted);margin:25px 0 10px}.toc ul{list-style:none;padding-left:0}.toc ul ul{padding-left:13px}.toc a{color:var(--muted);font-size:12px;display:block;line-height:1.6;margin-bottom:9px}.manual{min-width:0;font-size:15px}.manual h1{font-size:34px;margin:8px 0 27px;letter-spacing:-1px}.manual h2{margin:45px 0 18px;padding-top:12px;border-top:1px solid var(--line);font-size:24px}.manual h3{font-size:18px;margin-top:28px}.manual li{margin-bottom:5px}.manual pre{font-size:12px}.table-scroll{overflow:auto;margin:22px 0}.manual table{border-collapse:collapse;width:100%;font-size:13px}.manual td,.manual th{padding:12px;text-align:left;vertical-align:top;border:1px solid var(--line)}.manual th{background:var(--surface);font-weight:600}.manual blockquote{padding:5px 20px;border-left:3px solid var(--teal);margin:20px 0;color:var(--muted)}@media(max-width:900px){.guide-grid,.feature-grid{grid-template-columns:repeat(2,1fr)}.form-grid{grid-template-columns:repeat(2,1fr)}.tag{display:none}.topbar{padding:15px 20px}nav{gap:14px}.manual-layout{grid-template-columns:185px minmax(0,1fr);gap:24px}}@media(max-width:620px){.topbar{position:relative;flex-direction:column;align-items:flex-start;gap:6px}.brand{font-size:16px}nav{font-size:12px;flex-wrap:wrap}.landing{padding:0 20px}.hero{padding:40px 0 35px}h1{font-size:40px}.lead{font-size:16px}.pipeline{grid-template-columns:repeat(2,1fr);gap:20px}.guide-grid,.feature-grid{grid-template-columns:1fr}.generator{padding:22px 16px}.form-grid{gap:12px}.form-grid label{font-size:12px}.manual-layout{display:block;padding:22px 16px}.sidebar{position:relative;top:0;max-height:none;border-bottom:1px solid var(--line);margin-bottom:28px}.sidebar .toc,.sidebar h2{display:none}.sidebar>a{display:inline-block;font-size:12px;margin:3px;padding:4px 8px}.manual h1{font-size:29px}.manual h2{font-size:22px}footer{flex-direction:column;gap:4px}.section-heading h2{font-size:23px}}
|
configs/above_256p.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"resolution": "256p",
|
| 3 |
+
"deduplicate": false,
|
| 4 |
+
"near_deduplicate": false,
|
| 5 |
+
"text_unicode_form": "NFC",
|
| 6 |
+
"reject_animated": true
|
| 7 |
+
}
|
configs/aspect_loose.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"aspect_buckets": ["1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9", "21:9"],
|
| 3 |
+
"aspect_mode": "loose",
|
| 4 |
+
"aspect_tolerance": 0.05,
|
| 5 |
+
"deduplicate": false,
|
| 6 |
+
"near_deduplicate": false
|
| 7 |
+
}
|
configs/aspect_strict.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"aspect_buckets": ["1:1", "2:3", "3:2"],
|
| 3 |
+
"aspect_mode": "strict",
|
| 4 |
+
"aspect_tolerance": 0.05,
|
| 5 |
+
"deduplicate": false,
|
| 6 |
+
"near_deduplicate": false
|
| 7 |
+
}
|
configs/audit.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"duplicate_scope": "image_text",
|
| 3 |
+
"phash_distance": 4,
|
| 4 |
+
"dhash_distance": 4,
|
| 5 |
+
"near_rgb_rmse": 8.0,
|
| 6 |
+
"near_aspect_log_tolerance": 0.03,
|
| 7 |
+
"reject_animated": true,
|
| 8 |
+
"text_unicode_form": "NFC",
|
| 9 |
+
"export_format": "png"
|
| 10 |
+
}
|
configs/exact_256p.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"resolution": "256p",
|
| 3 |
+
"deduplicate": false,
|
| 4 |
+
"near_deduplicate": false,
|
| 5 |
+
"text_unicode_form": "NFC",
|
| 6 |
+
"reject_animated": true
|
| 7 |
+
}
|
configs/generation_example.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"min_short_edge": 512,
|
| 3 |
+
"min_aspect_ratio": 0.25,
|
| 4 |
+
"max_aspect_ratio": 4.0,
|
| 5 |
+
"min_blur_score": null,
|
| 6 |
+
"max_noise_sigma": null,
|
| 7 |
+
"min_snr_proxy_db": null,
|
| 8 |
+
"max_blockiness": null,
|
| 9 |
+
"min_text_chars": 1,
|
| 10 |
+
"forbidden_text_patterns": [],
|
| 11 |
+
"duplicate_scope": "image_text",
|
| 12 |
+
"export_max_edge": 2048,
|
| 13 |
+
"export_format": "png"
|
| 14 |
+
}
|
configs/statistics_only.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"resolution": null,
|
| 3 |
+
"deduplicate": false,
|
| 4 |
+
"near_deduplicate": false,
|
| 5 |
+
"text_unicode_form": "NFC",
|
| 6 |
+
"reject_animated": true
|
| 7 |
+
}
|
docker/Dockerfile
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
RUN useradd --create-home --uid 1000 curator
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
COPY pyproject.toml README.md ./
|
| 5 |
+
COPY image_cleaner ./image_cleaner
|
| 6 |
+
RUN python -m pip install --no-cache-dir .
|
| 7 |
+
USER curator
|
| 8 |
+
ENTRYPOINT ["python", "-m", "image_cleaner"]
|
| 9 |
+
CMD ["--help"]
|
docs/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 代码结构、功能与实现边界
|
| 2 |
+
|
| 3 |
+
## 1. 当前版本与数据流
|
| 4 |
+
|
| 5 |
+
当前清洗库版本 0.3.0,面向图片—文本对。主要流程:
|
| 6 |
+
|
| 7 |
+
```text
|
| 8 |
+
图片文件夹 / 图文 JSONL
|
| 9 |
+
│ prepare:稳定编号、固定清单与 SHA-256
|
| 10 |
+
▼
|
| 11 |
+
input.jsonl + input.jsonl.meta.json
|
| 12 |
+
│ analyze:rank 分片、本机进程池、规范化、测量
|
| 13 |
+
▼
|
| 14 |
+
各 rank 的 SQLite + JSONL
|
| 15 |
+
│ merge/filter:重算规则、全局去重、流式统计
|
| 16 |
+
▼
|
| 17 |
+
kept / rejected / duplicate / error + HTML/JSON 报告
|
| 18 |
+
│ export:源哈希校验、输出规范化 RGB
|
| 19 |
+
▼
|
| 20 |
+
标准化图片 + exported.jsonl → 训练数据加载器
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
`run` 将单机 prepare、analyze、merge 顺序串联,可选 export。静态 Space 仅展示文档与命令,算法在用户机器运行。
|
| 24 |
+
|
| 25 |
+
## 2. 功能矩阵
|
| 26 |
+
|
| 27 |
+
| 环节 | 已实现 | 输出/限制 |
|
| 28 |
+
| --- | --- | --- |
|
| 29 |
+
| 批量导入 | 递归图片目录、同名 txt、图文 JSONL | 固定清单,路径和原始 ID 可追溯 |
|
| 30 |
+
| 解码检查 | 坏文件、截断、像素容量、动画/多页 | 单图失败进入 error,不删除源图 |
|
| 31 |
+
| 标准化 | EXIF、ICC→sRGB、RGB、透明合成、Unicode/空白 | 无模型推理,无默认裁剪 |
|
| 32 |
+
| 尺寸统计 | 宽高、短/长边、像素、比例、方向分类 | 全量直方图,最多 20000 样本估计分位数 |
|
| 33 |
+
| 尺寸筛选 | 短边 >=Np、精确 WxH、其他尺寸阈值 | 条件取交集,包含边界 |
|
| 34 |
+
| 宽高比桶 | 八桶、多选/全选、严格/宽松、可调容差 | 固定桶,不做桶均衡采样 |
|
| 35 |
+
| 技术质量 | 模糊、噪声、SNR、块效应代理 | 最多五个原生像素区域,不是人工质量结论 |
|
| 36 |
+
| 文本检查 | 长度、Unicode、广告等正则 | 不评估图文语义一致性 |
|
| 37 |
+
| 精确去重 | 原字节、规范化像素 | 跨全部分片全局处理 |
|
| 38 |
+
| 近重复 | pHash、dHash、比例、RGB RMSE 联合 | 启发式,不是 embedding 语义去重 |
|
| 39 |
+
| 并行/续跑 | 本机进程池、多机静态 rank、SQLite 检查点 | 合并由单协调节点,无自动调度 |
|
| 40 |
+
| 导出 | 独立 RGB PNG/JPEG、缩小长边、清单、续跑校验 | 原图不改动,固定裁剪由训练端完成 |
|
| 41 |
+
|
| 42 |
+
用户提到的“正则化”在此实现中是数据规范化和规则校验;不包含训练损失正则化、数据增强或张量标准化。
|
| 43 |
+
|
| 44 |
+
## 3. 源码文件说明
|
| 45 |
+
|
| 46 |
+
| 文件 | 主要职责 |
|
| 47 |
+
| --- | --- |
|
| 48 |
+
| `image_cleaner/cli.py` | argparse 子命令、CLI 覆盖、run 串联与返回码 |
|
| 49 |
+
| `image_cleaner/config.py` | 不可变 Config、JSON 加载、字段与数值校验、配置指纹 |
|
| 50 |
+
| `image_cleaner/io.py` | 目录导入、JSONL、清单身份、原子写入、锁、SQLite、限流进程池 |
|
| 51 |
+
| `image_cleaner/images.py` | EXIF/色彩/透明度/文本规范化、单图分析与筛选原因 |
|
| 52 |
+
| `image_cleaner/aspect.py` | 精确分数比较、对称偏差、确定性桶分配 |
|
| 53 |
+
| `image_cleaner/metrics.py` | 原生区域质量测量、pHash/dHash、缩略图与 RMSE |
|
| 54 |
+
| `image_cleaner/jobs.py` | 分片分析、全局过滤、落盘去重索引、导出与续跑 |
|
| 55 |
+
| `image_cleaner/stats.py` | 增量统计、蓄水池分位数、内嵌 SVG HTML 报告 |
|
| 56 |
+
| `tests/test_pipeline.py` | 导入/清洗/去重/导出/分片/续跑/容量等测试 |
|
| 57 |
+
| `tests/test_aspect.py` | 容差边界、横竖桶、重叠分配、CLI、缓存重筛选 |
|
| 58 |
+
| `configs/` | 统计、尺寸、桶、去重、生成数据示例配置 |
|
| 59 |
+
| `examples/` | 生成合成演示样本 |
|
| 60 |
+
| `docker/Dockerfile` | CLI 容器部署,不启动 Web 服务 |
|
| 61 |
+
| `tools/build_space.py` | 文档转换、静态页面、源码 ZIP、发布文件校验清单 |
|
| 62 |
+
| `tools/publish_space.py` | 从环境变量读取 Token,检查账号并发布白名单文件 |
|
| 63 |
+
| `web/` | Space 首页模板、样式与交互脚本 |
|
| 64 |
+
|
| 65 |
+
`index.html`、`manual/*.html` 与 `downloads/*.zip` 是发布构建产物。运行结果在 runs,虚拟环境在 .venv,两者不发布。
|
| 66 |
+
|
| 67 |
+
## 4. 记录格式与状态
|
| 68 |
+
|
| 69 |
+
外部 JSONL 每条必须有字符串 path;text 为字符串或 null,metadata 为对象。输入 id 会保存为 source_id,程序生成稳定 `sample-000000000000` 等 ID 与 seq。其他外部字段放入 metadata,避免覆盖计算指标。
|
| 70 |
+
|
| 71 |
+
分析记录包含源路径、规范化文本、原始/校正宽高、文件/像素哈希、质量指标、预处理元数据、警告和筛选原因。最终状态:
|
| 72 |
+
|
| 73 |
+
| 状态 | 含义 |
|
| 74 |
+
| --- | --- |
|
| 75 |
+
| kept | 所有规则通过,且没有归并到其他代表 |
|
| 76 |
+
| rejected | 至少一个尺寸/桶/质量/文本规则不通过 |
|
| 77 |
+
| duplicate | 规则通过,但精确或近重复判定归并到代表 |
|
| 78 |
+
| error | 无法成功解码、规范化或测量 |
|
| 79 |
+
|
| 80 |
+
最终满足 `input_count=kept+rejected+duplicate+error`。同一拒绝记录可以有多个原因,原因计数不可当成样本数相加。
|
| 81 |
+
|
| 82 |
+
## 5. 为什么统计后能反复筛选
|
| 83 |
+
|
| 84 |
+
analyze 保存成功处理的完整指标,即使当时被阈值拒绝也写入 SQLite。filter 从全部记录重新计算规则,而不是仅读���上一次 kept。因此放宽阈值能恢复以前被拒绝的记录,收紧阈值也无需读源图片。
|
| 85 |
+
|
| 86 |
+
六项测量设置(reject_animated、max_decode_pixels、metric_patch_size、alpha_background、convert_icc、text_unicode_form)不允许在重筛选中改变。变化会使缓存指标不再对应新处理逻辑,需要重新分析。
|
| 87 |
+
|
| 88 |
+
## 6. 并行、去重和内存
|
| 89 |
+
|
| 90 |
+
进程池并发队列约为 workers 的两倍,结果逐条写入 SQLite。rank 使用 `seq % world_size`,每个 rank 写单独数据库。全局 merge 校验完整分片并按稳定优先级处理候选。
|
| 91 |
+
|
| 92 |
+
精确去重索引基于哈希,近重复使用 pHash 的 r+1 分段索引召回候选,再复核联合条件。索引和排序主要落磁盘,不将全量图片或全量指标载入内存;没有全数据两两比较。
|
| 93 |
+
|
| 94 |
+
合并批量处理 500 条候选,SQLite 缓存约 32 MiB,分位数抽样上限 20000。图片解码内存仍随原图像素与 workers 增长。大量相似图片、纯色图或高半径设置会增加复核成本,不能据此承诺百万/亿级吞吐量。
|
| 95 |
+
|
| 96 |
+
## 7. 当前没有覆盖的训练前审核
|
| 97 |
+
|
| 98 |
+
当前不包含 OCR、水印/人脸/隐私识别、NSFW/版权判定、CLIP 语义一致性、美学模型、语义 embedding 去重、训练/验证集防泄漏、WebDataset/Parquet 输入、视频/音频、训练采样配额、动态集群调度。
|
| 99 |
+
|
| 100 |
+
初筛保留仅代表通过已配置规则;训练前仍应按来源、内容类别和任务需求抽查,确认图文对齐及样本可用性。若后续扩展,应保留现有 source_id、稳定清单和每条决策原因便于追溯。
|
| 101 |
+
|
| 102 |
+
## 8. 验证与版本
|
| 103 |
+
|
| 104 |
+
现有 21 项清洗测试覆盖正好 5% 边界、零容差、桶重叠、源文件删除后的缓存筛选、多进程/双分片一致性、去重、多文本、断点续跑、导出校验、分辨率包含边界等。发布时另核验文档参数与代码、ZIP 白名单和页面链接。
|
| 105 |
+
|
| 106 |
+
恢复分析要求软件版本与配置指纹一致。合并旧分片需要同一批分片身份一致且当前代码能够读取其字段;不要混用不同软件版本、不同清单或不同 world-size 的分片。版本号不代表数据清洗标准或模型效果保证。
|
docs/DEPLOYMENT.md
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 部署、分布式运行与发布指南
|
| 2 |
+
|
| 3 |
+
## 1. 部署形式如何选择
|
| 4 |
+
|
| 5 |
+
| 场景 | 方式 |
|
| 6 |
+
| --- | --- |
|
| 7 |
+
| 初次使用、少量验证 | Windows / Linux 虚拟环境 + CLI |
|
| 8 |
+
| 单台服务器大批量 | Linux CLI,多进程 analyze,磁盘上的 SQLite |
|
| 9 |
+
| 环境一致性与隔离 | Docker CLI 容器,挂载原图与任务目录 |
|
| 10 |
+
| 多机数据分析 | 同一 manifest,静态 rank/world-size,汇总完整分片 |
|
| 11 |
+
| 在线共享代码和教程 | Hugging Face 静态 Space;不执行清洗后台 |
|
| 12 |
+
|
| 13 |
+
清洗不依赖 GPU,也不加载大模型。核心依赖 Python>=3.10、NumPy>=1.26,<3、Pillow>=10.4,<13。实际解码格式取决于 Pillow 构建与可用编解码器,文件扩展名支持不保证所有格式均能解码。
|
| 14 |
+
|
| 15 |
+
## 2. Windows 部署
|
| 16 |
+
|
| 17 |
+
```powershell
|
| 18 |
+
git clone https://huggingface.co/spaces/HanWangTJU/NMM-data-preprocess multimodal_training
|
| 19 |
+
cd multimodal_training
|
| 20 |
+
py -3.12 -m venv .venv
|
| 21 |
+
.\.venv\Scripts\python.exe -m pip install --upgrade pip
|
| 22 |
+
.\.venv\Scripts\python.exe -m pip install -e .
|
| 23 |
+
.\.venv\Scripts\python.exe -m image_cleaner --help
|
| 24 |
+
.\.venv\Scripts\python.exe -m unittest discover -s tests -v
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
不需要激活虚拟环境或修改 PowerShell 执行策略。路径有空格时加双引号。不要把 output 放到图片输入树中,以免后续重新导入导出文件。
|
| 28 |
+
|
| 29 |
+
默认 workers 为 min(8,CPU 数量),大图先设 2 或 4,观察内存与吞吐量再上调。配置 max_decode_pixels 控制解码容量;降低上限会把超限图片记入 error,不能替代缩图。
|
| 30 |
+
|
| 31 |
+
## 3. Linux 部署
|
| 32 |
+
|
| 33 |
+
```bash
|
| 34 |
+
git clone https://huggingface.co/spaces/HanWangTJU/NMM-data-preprocess multimodal_training
|
| 35 |
+
cd multimodal_training
|
| 36 |
+
python3 -m venv .venv
|
| 37 |
+
.venv/bin/python -m pip install --upgrade pip
|
| 38 |
+
.venv/bin/python -m pip install -e .
|
| 39 |
+
.venv/bin/python -m image_cleaner --help
|
| 40 |
+
.venv/bin/python -m unittest discover -s tests -v
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
```bash
|
| 44 |
+
.venv/bin/python -m image_cleaner run --input /data/images --output /jobs/statistics --config configs/statistics_only.json --workers 8 --sidecar-text
|
| 45 |
+
.venv/bin/python -m image_cleaner filter --shards /jobs/statistics/shards --output /jobs/filtered --config configs/audit.json --resolution 256p --aspect-buckets all --aspect-mode loose --aspect-tolerance 0.05
|
| 46 |
+
.venv/bin/python -m image_cleaner export --curated /jobs/filtered --output /jobs/normalized --workers 8
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
离线运行前应在对应平台准备 Python、包安装文件和代码。可以执行 `pip download . --dest wheels` 收集核心依赖;打包方式和平台不同,应在目标环境验证。原图、任务文件建议放在可靠本地盘或受控共享存储,优先使用快盘承载 SQLite。
|
| 50 |
+
|
| 51 |
+
## 4. Docker CLI 部署
|
| 52 |
+
|
| 53 |
+
从项目根目录构建:
|
| 54 |
+
|
| 55 |
+
```bash
|
| 56 |
+
docker build -f docker/Dockerfile -t nmm-data-preprocess:0.3.0 .
|
| 57 |
+
docker run --rm nmm-data-preprocess:0.3.0 --help
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
以下为 Linux 主机路径示例。先创建输出目录,并确保容器 UID 1000 对其有写权限;数据目录只读挂载:
|
| 61 |
+
|
| 62 |
+
```bash
|
| 63 |
+
mkdir -p /jobs/nmm
|
| 64 |
+
docker run --rm \
|
| 65 |
+
-v /data/images:/data/images:ro \
|
| 66 |
+
-v /jobs/nmm:/jobs \
|
| 67 |
+
-v "$PWD/configs:/configs:ro" \
|
| 68 |
+
nmm-data-preprocess:0.3.0 \
|
| 69 |
+
run --input /data/images --output /jobs/statistics --config /configs/statistics_only.json --workers 4 --sidecar-text
|
| 70 |
+
|
| 71 |
+
docker run --rm \
|
| 72 |
+
-v /jobs/nmm:/jobs \
|
| 73 |
+
-v "$PWD/configs:/configs:ro" \
|
| 74 |
+
nmm-data-preprocess:0.3.0 \
|
| 75 |
+
filter --shards /jobs/statistics/shards --output /jobs/filtered --config /configs/audit.json --resolution 256p --aspect-buckets all --aspect-mode loose
|
| 76 |
+
|
| 77 |
+
docker run --rm \
|
| 78 |
+
-v /data/images:/data/images:ro \
|
| 79 |
+
-v /jobs/nmm:/jobs \
|
| 80 |
+
nmm-data-preprocess:0.3.0 \
|
| 81 |
+
export --curated /jobs/filtered --output /jobs/normalized --workers 4
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
第二条仅使用缓存分片,无须挂载源图;第三条 export 必须能访问原图,路径保持与分析时相同,或通过 `--root` 改写。容器不是 HF Docker Space 应用,没有监听 Web 端口。
|
| 85 |
+
|
| 86 |
+
Windows Docker Desktop 中将 Linux 主机路径替换为实际 Windows 路径;PowerShell 多行续行用反引号,或把命令写成一行,不使用 Bash 的反斜线续行。Dockerfile 是提供的部署方案,Docker 是否可用需在用户环境实际构建验证。
|
| 87 |
+
|
| 88 |
+
## 5. 两台服务器的完整流程
|
| 89 |
+
|
| 90 |
+
### 5.1 制作一份全局清单
|
| 91 |
+
|
| 92 |
+
在协调机器运行:
|
| 93 |
+
|
| 94 |
+
```bash
|
| 95 |
+
.venv/bin/python -m image_cleaner prepare --input /data/images --manifest /jobs/input.jsonl --sidecar-text
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
把 `input.jsonl`、`input.jsonl.meta.json`、同一代码版本和统计配置分发到所有分析节点。每台节点都要能访问属于其 rank 的图片,图片根目录可以不同;相对路径结构必须保持一致。
|
| 99 |
+
|
| 100 |
+
### 5.2 各节点分析
|
| 101 |
+
|
| 102 |
+
节点 0:
|
| 103 |
+
|
| 104 |
+
```bash
|
| 105 |
+
.venv/bin/python -m image_cleaner analyze --manifest /jobs/input.jsonl --root /mnt/images --output /jobs/node0 --config configs/statistics_only.json --rank 0 --world-size 2 --workers 8
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
节点 1:
|
| 109 |
+
|
| 110 |
+
```bash
|
| 111 |
+
.venv/bin/python -m image_cleaner analyze --manifest /jobs/input.jsonl --root /dataset/images --output /jobs/node1 --config configs/statistics_only.json --rank 1 --world-size 2 --workers 8
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
每条记录按 `seq % 2` 分配。不要让两个节点使用同一 rank,也不要向同一个 SQLite 并发写入。节点失败后在原节点增加 `--resume`,其余参数不变。
|
| 115 |
+
|
| 116 |
+
### 5.3 收集分片
|
| 117 |
+
|
| 118 |
+
分析进程正常退出后,将各节点的 `shard-00000-of-00002.sqlite` 与 `shard-00001-of-00002.sqlite` 放到协调节点 `/jobs/all_shards`;JSONL 可同时保留用于检查,但 merge 读取 SQLite。必须等分析完成再复制,避免只复制仍依赖 WAL 的数据库。
|
| 119 |
+
|
| 120 |
+
先形成统计报告,然后全局筛选去重:
|
| 121 |
+
|
| 122 |
+
```bash
|
| 123 |
+
.venv/bin/python -m image_cleaner merge --shards /jobs/all_shards --output /jobs/global_statistics
|
| 124 |
+
.venv/bin/python -m image_cleaner filter --shards /jobs/all_shards --output /jobs/global_filtered --config configs/audit.json --resolution 256p --aspect-buckets all --aspect-mode loose --aspect-tolerance 0.05
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
两轮都不需要原图。协调节点会验证所有 rank 完整、配置/清单/身份一致、总记录数匹配;缺失 rank 时拒绝合并,不能把局部去重当全局去重。
|
| 128 |
+
|
| 129 |
+
### 5.4 统一导出
|
| 130 |
+
|
| 131 |
+
协调节点必须能访问全部保留原图,以统一根目录覆盖各节点不同的绝对 source_path:
|
| 132 |
+
|
| 133 |
+
```bash
|
| 134 |
+
.venv/bin/python -m image_cleaner export --curated /jobs/global_filtered --root /coordinator/images --output /jobs/global_export --workers 8
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
相对图片路径依据原清单保持不变。当前 CLI 不把导出任务再分配到多个 rank,多机能力主要在 analyze 阶段。
|
| 138 |
+
|
| 139 |
+
## 6. 续跑、重筛选与故障处理
|
| 140 |
+
|
| 141 |
+
| 情况 | 操作 |
|
| 142 |
+
| --- | --- |
|
| 143 |
+
| analyze 正常中断 | 相同命令加 --resume;同 manifest/config/version/rank |
|
| 144 |
+
| run 需要继续 | 相同命令加 --resume,不改输入、配置、root、sidecar 设置 |
|
| 145 |
+
| export 中断 | 相同命令加 --resume;会检查源和已有输出哈希 |
|
| 146 |
+
| 想调整尺寸、桶或质量阈值 | 从原 shards filter 到新的输出目录 |
|
| 147 |
+
| 想调整颜色、测量区域等 | 新输出目录重新 analyze,再 merge |
|
| 148 |
+
| merge 中途失败 | 无中途续跑支持;选新输出目录重新 merge,复用 shards |
|
| 149 |
+
| 出现 .lock | 先确认对应进程已退出,才删除该特定锁;运行中不能删 |
|
| 150 |
+
| 源文件在分析后变更 | export 拒绝哈希不一致的样本;重新制作/分析数据 |
|
| 151 |
+
| 缺少 rank | 补齐原任务的分片,不修改 world-size 去掩盖缺失 |
|
| 152 |
+
|
| 153 |
+
analyze 每 100 条提交一次检查点,异常退出最多重算尚未提交的记录。改变软件版本会影响 resume 身份,生产作业应固定版本与配置,保存元数据。
|
| 154 |
+
|
| 155 |
+
代码读取整个 manifest 再挑选本 rank,不是自动调度器;超大清单、偏斜大图、共享盘拥塞可能限制速度。建议先测 1–10 万代表性图片,记录图片/秒、CPU、峰值内存、磁盘占用、错误率和各规则保留率,再确定节点与进程数。已有测试不等于百万级吞吐量基准。
|
| 156 |
+
|
| 157 |
+
## 7. Hugging Face Space 的发布方式
|
| 158 |
+
|
| 159 |
+
本项目使用静态 Space:根 README YAML 指定 `sdk: static` 与 `app_file: index.html`。页面包含教程、参数、代码下载、命令生成器和比例匹配演示;不连接用户文件系统、不提交训练图片、不运行服务器清洗。
|
| 160 |
+
|
| 161 |
+
选择静态托管使文档入口无需清洗计算资源。官方规则参见 [Static HTML Spaces](https://huggingface.co/docs/hub/spaces-sdks-static) 和 [配置参考](https://huggingface.co/docs/hub/spaces-config-reference)。
|
| 162 |
+
|
| 163 |
+
构建发布文件:
|
| 164 |
+
|
| 165 |
+
```powershell
|
| 166 |
+
.\.venv\Scripts\python.exe -m pip install -e ".[publish]"
|
| 167 |
+
.\.venv\Scripts\python.exe tools/build_space.py
|
| 168 |
+
```
|
| 169 |
+
|
| 170 |
+
本地预览:
|
| 171 |
+
|
| 172 |
+
```powershell
|
| 173 |
+
.\.venv\Scripts\python.exe -m http.server 8000 --bind 127.0.0.1
|
| 174 |
+
```
|
| 175 |
+
|
| 176 |
+
打开 `http://127.0.0.1:8000/index.html`。这是预览服务器,不是清洗 API。终端 Ctrl+C 停止。
|
| 177 |
+
|
| 178 |
+
发布脚本仅从当前进程的 `HF_TOKEN` 读取认证,不使用账号密码,不调用持久化 login,不把 Token 写到任何文件。可在安全终端设置环境变量,或从秘密管理器注入;不要在脚本和配置中硬编码真实 Token。
|
| 179 |
+
|
| 180 |
+
```bash
|
| 181 |
+
# HF_TOKEN 已通过环境或秘密管理器注入。
|
| 182 |
+
.venv/bin/python tools/publish_space.py --repo-id HanWangTJU/NMM-data-preprocess
|
| 183 |
+
```
|
| 184 |
+
|
| 185 |
+
脚本检查登录身份与仓库命名空间;创建不存在的 public static Space,或更新已有 static Space。它不删除远端文件、不变更现有私有性,也不申请付费硬件。先构建再发布,使用 release/publish-manifest.json 指定白名单与 SHA-256;发布前校验哈希、路径和凭据模式。
|
| 186 |
+
|
| 187 |
+
发布内容包括库、测试、配置、示例、教程、页面、Dockerfile 和源码 ZIP;排除 .venv、runs、个人原图、运行日志、认证信息与系统路径。README 和 ZIP 保持同一版本,发布收据保存 repo ID、提交 ID、文件数量和页面地址。
|
| 188 |
+
|
| 189 |
+
如果网络直连失败,可按实际网络环境设置 HTTPS_PROXY 连接官方 huggingface.co;不要为上传将认证 Token 发给未经确认的镜像站。权限不足时需要具有目标 Space 写入/创建权限的 Token。
|
| 190 |
+
|
| 191 |
+
## 8. 部署后的验收
|
| 192 |
+
|
| 193 |
+
1. `--help` 可用,测试通过,合成样例能统计/筛选/导出。
|
| 194 |
+
2. 报告四类数量守恒,错误清单包含合成坏文件。
|
| 195 |
+
3. 256p 和宽高比 5% 临界行为符合说明。
|
| 196 |
+
4. 双 rank 与单机结果一致,源图删除后的缓存 filter 正常。
|
| 197 |
+
5. 在线文档和源码 ZIP 可打开,远端文件没有凭据或个人数据。
|
| 198 |
+
6. 实际数据上再做人工抽查与性能试运行,确认阈值和资源设置合适。
|
docs/PARAMETERS.md
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 参数参考与设置方法
|
| 2 |
+
|
| 3 |
+
## 1. 如何设置参数
|
| 4 |
+
|
| 5 |
+
运行形式是 `python -m image_cleaner <子命令> ...`;安装后也可用 `image-cleaner <子命令> ...`。Windows 下建议直接使用虚拟环境的 `.\.venv\Scripts\python.exe`,Linux 使用 `.venv/bin/python`。
|
| 6 |
+
|
| 7 |
+
CLI 提供流程与常用尺寸条件;质量、文本、去重、导出等设置集中在 JSON 配置文件中,由 `--config` 加载。配置采用 UTF-8,不能写注释;布尔值用 `true/false`,关闭可选阈值用 `null`。所有未知字段会报错,避免拼写错误被静默忽略。
|
| 8 |
+
|
| 9 |
+
优先级:
|
| 10 |
+
|
| 11 |
+
1. `analyze/run`:Config 默认值 → JSON 配置 → 显式 CLI 尺寸/宽高比参数。
|
| 12 |
+
2. `filter/merge` 未传配置:继承分析分片完整配置 → 显式 CLI 参数。
|
| 13 |
+
3. `filter/merge` 传配置:Config 默认值 → 新 JSON 配置 → 显式 CLI 参数;不与分片字段逐项合并。
|
| 14 |
+
4. `export`:使用 curated/report.json 中的配置;不接受另一个 `--config`。
|
| 15 |
+
|
| 16 |
+
例如统计分片使用 `statistics_only.json` 时去重关闭,仅加 `--resolution 256p` 不会开启去重;添加 `--config configs/audit.json` 才切换到该配置中的去重设置。
|
| 17 |
+
|
| 18 |
+
## 2. 所有 CLI 参数
|
| 19 |
+
|
| 20 |
+
### prepare:固定输入清单
|
| 21 |
+
|
| 22 |
+
| 参数 | 必填/默认 | 说明 |
|
| 23 |
+
| --- | --- | --- |
|
| 24 |
+
| `--input` | 必填 | 图片目录或 UTF-8 JSONL |
|
| 25 |
+
| `--manifest` | 必填 | 写入固定清单,同时生成 `.meta.json`;不能覆盖已有清单 |
|
| 26 |
+
| `--root` | 自动推断 | 相对图片路径的根目录;目录输入默认自身,JSONL 默认其父目录 |
|
| 27 |
+
| `--sidecar-text` | false | 文件夹输入时读取同名 UTF-8 `.txt`;JSONL 文本直接来自 text |
|
| 28 |
+
|
| 29 |
+
### analyze:分析一个分片
|
| 30 |
+
|
| 31 |
+
| 参数 | 必填/默认 | 说明 |
|
| 32 |
+
| --- | --- | --- |
|
| 33 |
+
| `--manifest` | 必填 | prepare 生成的清单,必须有配套 `.meta.json` |
|
| 34 |
+
| `--output` | 必填 | 分片输出目录 |
|
| 35 |
+
| `--config` | Config 默认值 | JSON 配置文件 |
|
| 36 |
+
| `--root` | 清单元数据 root | 当前节点的图片根目录,可与制作清单的机器不同 |
|
| 37 |
+
| `--workers` | min(8, CPU 数量) | 本机分析进程数,正整数 |
|
| 38 |
+
| `--rank` | 0 | 当前分片编号,0 开始 |
|
| 39 |
+
| `--world-size` | 1 | 总分片数,正整数,rank 必须小于该值 |
|
| 40 |
+
| `--resume` | false | 使用相同清单、配置、版本和分片参数恢复已有 SQLite |
|
| 41 |
+
| 尺寸/桶参数 | 见下表 | 支持 resolution 与三个 aspect 参数 |
|
| 42 |
+
|
| 43 |
+
### filter / merge:重筛选与全局去重
|
| 44 |
+
|
| 45 |
+
两个名称调用同一实现。
|
| 46 |
+
|
| 47 |
+
| 参数 | 必填/默认 | 说明 |
|
| 48 |
+
| --- | --- | --- |
|
| 49 |
+
| `--shards` | 必填 | 同一 manifest、版本和分析配置的全部 rank SQLite 所在目录 |
|
| 50 |
+
| `--output` | 必填 | 一个新的输出目录,不接受覆盖已有合并结果 |
|
| 51 |
+
| `--config` | 继承分析配置 | 新筛选配置;不能改变预处理/测量设置 |
|
| 52 |
+
| 尺寸/桶参数 | 见下表 | 覆盖有效配置对应字段 |
|
| 53 |
+
|
| 54 |
+
### 共用尺寸与宽高比参数
|
| 55 |
+
|
| 56 |
+
适用于 `analyze`、`run`、`filter/merge`。
|
| 57 |
+
|
| 58 |
+
| 参数 | Config 默认值 | 取值与规则 |
|
| 59 |
+
| --- | --- | --- |
|
| 60 |
+
| `--resolution` | null | `256p` 短边 >=256;`384x256` 精确宽高;`any/none` 关闭;必须为正整数尺寸 |
|
| 61 |
+
| `--aspect-buckets` | null | 多选例如 `"1:1,2:3"`;`all` 八桶并集;`none` 关闭;空选择报错 |
|
| 62 |
+
| `--aspect-mode` | strict | strict 精确整数比例;loose 容差模式 |
|
| 63 |
+
| `--aspect-tolerance` | 0.05 | 有限非负数,只作用于 loose;0.05=5%,0=不允许偏差 |
|
| 64 |
+
|
| 65 |
+
八桶固定为 `1:1,2:3,3:2,3:4,4:3,9:16,16:9,21:9`,不支持自定义。所有尺寸条件基于 EXIF 校正后的宽和高;不自动互换横竖桶。严格模式忽略容差参数;未选择桶时模式和容差不会启用过滤。
|
| 66 |
+
|
| 67 |
+
### export:标准化输出
|
| 68 |
+
|
| 69 |
+
| 参数 | 必填/默认 | 说明 |
|
| 70 |
+
| --- | --- | --- |
|
| 71 |
+
| `--curated` | 必填 | filter/merge 输出目录,或 run 的 curated 子目录 |
|
| 72 |
+
| `--output` | 必填 | 独立导出目录 |
|
| 73 |
+
| `--root` | 记录 source_path | 图片移动后覆盖源图片根目录 |
|
| 74 |
+
| `--workers` | min(8, CPU 数量) | 本机导出进程数,正整数 |
|
| 75 |
+
| `--resume` | false | 使用相同配置和输出目录续跑,校验源文件和已导出文件哈希 |
|
| 76 |
+
|
| 77 |
+
### run:单机完整流程
|
| 78 |
+
|
| 79 |
+
| 参数 | 必填/默认 | 说明 |
|
| 80 |
+
| --- | --- | --- |
|
| 81 |
+
| `--input` | 必填 | 图片文件夹或 JSONL |
|
| 82 |
+
| `--output` | 必填 | 工作目录,下设 input、shards、curated 等 |
|
| 83 |
+
| `--root` | 自动推断 | 图片根目录 |
|
| 84 |
+
| `--config` | Config 默认值 | 分析与筛选配置 |
|
| 85 |
+
| `--workers` | min(8, CPU 数量) | 本机多进程数量 |
|
| 86 |
+
| `--sidecar-text` | false | 文件夹输入读取同名文本 |
|
| 87 |
+
| `--export` | false | 合并后导出到 normalized 子目录 |
|
| 88 |
+
| `--resume` | false | 恢复同输入、配置、根目录和 sidecar 设置的已有任务 |
|
| 89 |
+
| 尺寸/桶参数 | 见上表 | CLI 覆盖相应 JSON 字段 |
|
| 90 |
+
|
| 91 |
+
每个子命令均支持 `--help`。没有 CLI 参数直接设置质量阈值,也没有 `--workers` 控制 merge:最终合并由单进程协调。
|
| 92 |
+
|
| 93 |
+
## 3. 全部 JSON 配置字段
|
| 94 |
+
|
| 95 |
+
### 尺寸与比例
|
| 96 |
+
|
| 97 |
+
| 字段 | 默认值 | 单位/范围与行为 |
|
| 98 |
+
| --- | --- | --- |
|
| 99 |
+
| resolution | null | Np / WxH / any / none;语义同 CLI |
|
| 100 |
+
| aspect_buckets | null | 数组、逗号字符串、all、none 或 null;规范化成固定顺序,重复值合并 |
|
| 101 |
+
| aspect_mode | strict | strict / loose |
|
| 102 |
+
| aspect_tolerance | 0.05 | 非负有限数,包含边界;不得为 null;设置很大将显著放宽筛选 |
|
| 103 |
+
| min_short_edge | null | 非负整数像素,下限 |
|
| 104 |
+
| max_long_edge | null | 非负整数像素,上限 |
|
| 105 |
+
| min_pixels | null | 非负整数像素总数,下限 |
|
| 106 |
+
| max_pixels | null | 非负整数像素总数,上限,不小于 min_pixels |
|
| 107 |
+
| min_aspect_ratio | null | 非负有限数 W/H,下限 |
|
| 108 |
+
| max_aspect_ratio | null | 非负有限数 W/H,上限,不小于 min_aspect_ratio |
|
| 109 |
+
|
| 110 |
+
所有下限用 `>=`、上限用 `<=`,条件取交集。选择多个桶只在桶之间取并集,不会放宽其他规则。
|
| 111 |
+
|
| 112 |
+
### 质量与文字
|
| 113 |
+
|
| 114 |
+
| 字段 | 默认值 | 单位/范围与行为 |
|
| 115 |
+
| --- | --- | --- |
|
| 116 |
+
| min_blur_score | null | 非负有限数;拉普拉斯方差下限 |
|
| 117 |
+
| max_noise_sigma | null | 非负有限数;灰度 0–255 尺度噪声估计上限 |
|
| 118 |
+
| min_snr_proxy_db | null | 有限数,允许负数;SNR 代理 dB 下限 |
|
| 119 |
+
| max_blockiness | null | 非负有限数;块效应代理上限 |
|
| 120 |
+
| min_text_chars | null | 非负整数,规范化文本字符数量下限 |
|
| 121 |
+
| max_text_chars | null | 非负整数,字符上限,不小于 min_text_chars |
|
| 122 |
+
| forbidden_text_patterns | [] | 字符串数组;任意 Python 正则命中则拒绝;忽略大小写可用 (?i) |
|
| 123 |
+
|
| 124 |
+
质量指标缺失时不会触发对应阈值,应另行审计报告 missing 与 warnings。所有质量阈值都需要在目标数据域校准,不能将默认 null 理解为“自动判断质量”。SNR 是代理指标,不是真实 SNR/PSNR。
|
| 125 |
+
|
| 126 |
+
### 预处理与测量
|
| 127 |
+
|
| 128 |
+
| 字段 | 默认值 | 单位/范围与行为 |
|
| 129 |
+
| --- | --- | --- |
|
| 130 |
+
| reject_animated | true | 布尔值;拒绝动画/多页;false 时只处理所读首帧/第一页 |
|
| 131 |
+
| max_decode_pixels | 100000000 | 正整数;单图解码像素上限,超限记录为错误 |
|
| 132 |
+
| metric_patch_size | 256 | 整数 >=16;角落和中心的原生像素测量区域边长上限 |
|
| 133 |
+
| alpha_background | [255,255,255] | 三个 0–255 整数;透明区域合成背景 |
|
| 134 |
+
| convert_icc | true | 有 ICC 时转 sRGB;无 ICC 时按 Pillow 的 RGB 转换处理 |
|
| 135 |
+
| text_unicode_form | NFC | NFC / NFKC / none;仍执行换行与首尾空白规范化 |
|
| 136 |
+
|
| 137 |
+
这六项会改变解码、标准化或指标,重筛选时必须与源分片一致;改变它们需重新 analyze。EXIF 校正与 RGB 转换始终执行。设置 max_decode_pixels 是容量限制,不会缩小超限原图。
|
| 138 |
+
|
| 139 |
+
### 去重
|
| 140 |
+
|
| 141 |
+
| 字段 | 默认值 | 范围与行为 |
|
| 142 |
+
| --- | --- | --- |
|
| 143 |
+
| deduplicate | true | 去重总开关,关闭后所有去重均关闭 |
|
| 144 |
+
| near_deduplicate | true | 开启近重复,须同时开启 deduplicate;false 仍做精确去重 |
|
| 145 |
+
| duplicate_scope | image_text | image_text:图片与规范化文字都满足;image:忽略文字差异 |
|
| 146 |
+
| phash_distance | 4 | 整数 0–8,64 位 pHash 汉明距离上限 |
|
| 147 |
+
| dhash_distance | 4 | 整数 0–64,64 位 dHash 汉明距离上限 |
|
| 148 |
+
| near_rgb_rmse | 8.0 | 非负有限数;16×16 RGB 缩略图 RMSE 上限 |
|
| 149 |
+
| near_aspect_log_tolerance | 0.03 | 非负有限数;abs(log(r1/r2)) 上限,与桶容差是不同参数 |
|
| 150 |
+
|
| 151 |
+
近重复条件全部满足才归并。代表优先级为像素多、噪声低、清晰度高、清单 seq 小;不依赖 worker 完成顺序。精确去重检查原文件哈希和标准化像素哈希,作用域包含文本规则。
|
| 152 |
+
|
| 153 |
+
### 导出
|
| 154 |
+
|
| 155 |
+
| 字段 | 默认值 | 范围与行为 |
|
| 156 |
+
| --- | --- | --- |
|
| 157 |
+
| export_max_edge | null | 正整数像素;导出时限制长边,只缩小,不放大 |
|
| 158 |
+
| export_format | png | png / jpeg |
|
| 159 |
+
| export_jpeg_quality | 95 | 整数 1–100;JPEG 无色度子采样,仍为有损输出 |
|
| 160 |
+
|
| 161 |
+
导出参数不修改统计阶段尺寸。缩图可能因整数取整产生微小比例变化,报告桶归属针对导出前的规范化原尺寸。修改导出设置先重新 filter 到新 curated 目录再 export。
|
| 162 |
+
|
| 163 |
+
## 4. 典型配置组合
|
| 164 |
+
|
| 165 |
+
### 只做第一轮统计
|
| 166 |
+
|
| 167 |
+
```json
|
| 168 |
+
{"deduplicate":false,"near_deduplicate":false}
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
### 尺寸、比例、文字与去重
|
| 172 |
+
|
| 173 |
+
```json
|
| 174 |
+
{
|
| 175 |
+
"resolution": "256p",
|
| 176 |
+
"aspect_buckets": ["1:1","2:3","3:2","9:16","16:9"],
|
| 177 |
+
"aspect_mode": "loose",
|
| 178 |
+
"aspect_tolerance": 0.05,
|
| 179 |
+
"min_text_chars": 1,
|
| 180 |
+
"deduplicate": true,
|
| 181 |
+
"near_deduplicate": true,
|
| 182 |
+
"duplicate_scope": "image_text",
|
| 183 |
+
"export_format": "png"
|
| 184 |
+
}
|
| 185 |
+
```
|
| 186 |
+
|
| 187 |
+
### 原图规范化并限制导出长边
|
| 188 |
+
|
| 189 |
+
```json
|
| 190 |
+
{"resolution":"256p","export_max_edge":1024,"export_format":"jpeg","export_jpeg_quality":95}
|
| 191 |
+
```
|
| 192 |
+
|
| 193 |
+
`generation_example.json` 的 512 下限和比例范围是演示配置,不是通用训练推荐。`exact_256p.json` 是兼容旧文件名,内容实际为短边 >=256,请优先使用含义清晰的 `above_256p.json`。
|
| 194 |
+
|
| 195 |
+
## 5. 无效设置与返回码
|
| 196 |
+
|
| 197 |
+
常见非法值:`resolution=256`、`aspect_buckets=[]`、`aspect_tolerance=5%`、数值 NaN/Infinity、负容差、拼错字段、质量阈值写成字符串、正则语法错误。容差 CLI 只能写数值,5% 写 0.05。
|
| 198 |
+
|
| 199 |
+
正常完成返回 0;CLI 捕获的配置/文件/SQLite 错误返回 2;export 存在导出失败,或 run 的导出环节存在失败,返回 1。单图分析失败会记入 error,通常整个分析仍返回 0,应核对报告。非法正则等未统一捕获的异常可能显示 Python traceback 并返回非零。
|
| 200 |
+
|
| 201 |
+
完整算法公式与各字段的评测解释见 [使用手册](USAGE.md),故障恢复见 [部署指南](DEPLOYMENT.md)。
|
docs/QUICKSTART.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 从下载到第一轮图片—文本初筛
|
| 2 |
+
|
| 3 |
+
## 1. 下载代码
|
| 4 |
+
|
| 5 |
+
安装 Python 3.10 或更新版本,建议 Python 3.12。使用 Git 下载,或者在 Space 首页下载源码 ZIP,解压到 `multimodal_training`。
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
git clone https://huggingface.co/spaces/HanWangTJU/NMM-data-preprocess multimodal_training
|
| 9 |
+
cd multimodal_training
|
| 10 |
+
```
|
| 11 |
+
|
| 12 |
+
后续命令都在此目录运行。安装过程需要联网下载 NumPy 和 Pillow;实际清洗不需要联网或 GPU。
|
| 13 |
+
|
| 14 |
+
## 2. Windows 安装
|
| 15 |
+
|
| 16 |
+
在 PowerShell 执行,无需激活环境或修改执行策略:
|
| 17 |
+
|
| 18 |
+
```powershell
|
| 19 |
+
py -3.12 -m venv .venv
|
| 20 |
+
.\.venv\Scripts\python.exe -m pip install --upgrade pip
|
| 21 |
+
.\.venv\Scripts\python.exe -m pip install -e .
|
| 22 |
+
.\.venv\Scripts\python.exe -m image_cleaner --help
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
如果没有 Python 3.12,将第一条改为 `python -m venv .venv`,并先确认 `python --version` 至少为 3.10。
|
| 26 |
+
|
| 27 |
+
## 3. Linux 安装
|
| 28 |
+
|
| 29 |
+
```bash
|
| 30 |
+
python3 -m venv .venv
|
| 31 |
+
.venv/bin/python -m pip install --upgrade pip
|
| 32 |
+
.venv/bin/python -m pip install -e .
|
| 33 |
+
.venv/bin/python -m image_cleaner --help
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
后文 Windows 命令中的 `.\.venv\Scripts\python.exe` 在 Linux 替换为 `.venv/bin/python`,路径也换成实际 Linux 路径。
|
| 37 |
+
|
| 38 |
+
## 4. 先用合成样例跑通
|
| 39 |
+
|
| 40 |
+
```powershell
|
| 41 |
+
.\.venv\Scripts\python.exe examples/make_demo.py
|
| 42 |
+
.\.venv\Scripts\python.exe -m image_cleaner run --input runs/demo_input --output runs/tutorial_statistics --config configs/statistics_only.json --workers 2 --sidecar-text
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
合成示例包括重压缩、同像素不同格式、模糊、噪声、低分辨率、透明图、极端宽高比和坏文件。示例不是真实训练数据。生成脚本发现目标目录已存在时会停止,避免覆盖。
|
| 46 |
+
|
| 47 |
+
预期输入 12 条,成功处理 11 条、错误 1 条;统计配置关闭可选阈值和去重,所以 11 条成功记录全部保留。打开 `runs/tutorial_statistics/curated/report.html`,查看原始分布与质量代理指标。坏文件应出现在 `error.jsonl`,不会使全批分析终止。
|
| 48 |
+
|
| 49 |
+
## 5. 实际数据怎样输入
|
| 50 |
+
|
| 51 |
+
最直接的方式是递归扫描图片文件夹:
|
| 52 |
+
|
| 53 |
+
```text
|
| 54 |
+
D:/datasets/images/
|
| 55 |
+
animal/cat.jpg
|
| 56 |
+
animal/cat.txt
|
| 57 |
+
scene/road.png
|
| 58 |
+
scene/road.txt
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
添加 `--sidecar-text` 会读取同名 `.txt` 文本(UTF-8)。没有文本的图片仍可导入;后续可用 `min_text_chars` 条件筛掉。
|
| 62 |
+
|
| 63 |
+
已有图文配对时更推荐 JSONL,一行一条,路径相对 `--root`:
|
| 64 |
+
|
| 65 |
+
```json
|
| 66 |
+
{"id":"cat-001","path":"animal/cat.jpg","text":"一只猫坐在窗边。","metadata":{"source":"my_corpus"}}
|
| 67 |
+
{"id":"road-002","path":"scene/road.png","text":"雨后的城市道路。"}
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
```powershell
|
| 71 |
+
.\.venv\Scripts\python.exe -m image_cleaner run --input "D:\datasets\pairs.jsonl" --root "D:\datasets\images" --output runs/my_statistics --config configs/statistics_only.json --workers 8
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
输出目录应位于原始图片文件夹之外。工具不修改或删除原始图片。文件夹导入未显式启用 `--sidecar-text` 时,不会自动配对文本。
|
| 75 |
+
|
| 76 |
+
## 6. 在统计后筛选尺寸与宽高比
|
| 77 |
+
|
| 78 |
+
```powershell
|
| 79 |
+
# 短边 >=256;宽高比在八桶内,允许 5% 对称偏差。
|
| 80 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/tutorial_statistics/shards --output runs/tutorial_filtered --resolution 256p --aspect-buckets all --aspect-mode loose --aspect-tolerance 0.05
|
| 81 |
+
|
| 82 |
+
# 严格只留方图或 3:2 横图,输出到另一个目录。
|
| 83 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/tutorial_statistics/shards --output runs/tutorial_strict --resolution 256p --aspect-buckets "1:1,3:2" --aspect-mode strict
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
`256p` 的定义是 `min(width,height)>=256`,包括正好 256;不要求短边必须等于 256。`256x256` 才是精确尺寸。宽高比始终按方向校正后的宽:高计算。
|
| 87 |
+
|
| 88 |
+
宽松偏差为 `max(r/b,b/r)-1`,`0.05` 表示 5%。参数说明和完整边界例子见 [完整手册](USAGE.md)。重筛选读取原分析分片,不重复解码;规则取交集。
|
| 89 |
+
|
| 90 |
+
## 7. 再加入文本与去重规则
|
| 91 |
+
|
| 92 |
+
在项目根目录新建 `my_rules.json`:
|
| 93 |
+
|
| 94 |
+
```json
|
| 95 |
+
{
|
| 96 |
+
"resolution": "256p",
|
| 97 |
+
"aspect_buckets": "all",
|
| 98 |
+
"aspect_mode": "loose",
|
| 99 |
+
"aspect_tolerance": 0.05,
|
| 100 |
+
"min_text_chars": 1,
|
| 101 |
+
"deduplicate": true,
|
| 102 |
+
"near_deduplicate": true,
|
| 103 |
+
"duplicate_scope": "image_text"
|
| 104 |
+
}
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
```powershell
|
| 108 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/tutorial_statistics/shards --output runs/tutorial_dedup --config my_rules.json
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
显式提供 JSON 配置文件时,未写字段使用 Config 默认值,而不是与分析分片逐项合并。默认开启精确与近重复去重;纯统计配置特意关闭了它们。同图不同规范化文本默认保留;改为 `duplicate_scope=image` 会忽略文本差异。
|
| 112 |
+
|
| 113 |
+
质量阈值如 `min_blur_score`、`max_noise_sigma`、`min_snr_proxy_db`、`max_blockiness` 应先在代表性样本上校准,再写入配置;没有跨数据域通用的推荐数值。
|
| 114 |
+
|
| 115 |
+
## 8. 查看报告、导出训练清单
|
| 116 |
+
|
| 117 |
+
```powershell
|
| 118 |
+
.\.venv\Scripts\python.exe -m image_cleaner export --curated runs/tutorial_dedup --output runs/tutorial_export --workers 2
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
清洗目录有 `kept/rejected/duplicate/error.jsonl`、`report.json`、`report.html`、`curation.sqlite`。导出目录有 RGB 图片、相对路径与文本组成的 `exported.jsonl`、失败清单和续跑元数据。
|
| 122 |
+
|
| 123 |
+
优先核对以下项目:
|
| 124 |
+
|
| 125 |
+
1. 输入数等于保留、拒绝、重复、错误四类数量之和。
|
| 126 |
+
2. 错误数及错误原因;质量缺失指标并不等于通过人工质量检验。
|
| 127 |
+
3. 分辨率与桶的独立通过率,以及全部规则通过率。
|
| 128 |
+
4. 各桶前后样本数量;抽查被拒绝的临界图片和近重复判定。
|
| 129 |
+
5. 最终图文是否对应。当前工具没有图文语义一致性模型。
|
| 130 |
+
|
| 131 |
+
导出的文本已做 Unicode 与空白规范化;图片做 EXIF、色彩、透明度与 RGB 标准化。训练时的张量归一化、裁剪和多模态对齐仍由训练数据加载器负责。
|
| 132 |
+
|
| 133 |
+
## 9. 测试和后续阅读
|
| 134 |
+
|
| 135 |
+
```powershell
|
| 136 |
+
.\.venv\Scripts\python.exe -m unittest discover -s tests -v
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
基础版本包含 21 项清洗测试。详细参数见 [参数参考](PARAMETERS.md),多机与续跑见 [部署指南](DEPLOYMENT.md),逐阶段指标见 [完整手册](USAGE.md),实现细节见 [代码说明](ARCHITECTURE.md)。
|
docs/USAGE.md
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 图片—文本数据清洗:完整操作与指标手册
|
| 2 |
+
|
| 3 |
+
## 1. 工具范围和运行环境
|
| 4 |
+
|
| 5 |
+
本工具处理本地图片与可选文本,完成导入、规范化测量、分布统计、分辨率/质量筛选、去重和独立导出。源文件不修改、不删除。保留/拒绝/重复/错误的判断通过输出清单表达。
|
| 6 |
+
|
| 7 |
+
Python 3.10+;Windows 与 Linux;基础处理不需要 GPU。先下载仓库,以下以安装目录为例:
|
| 8 |
+
|
| 9 |
+
```text
|
| 10 |
+
D:\projects\multimodal_training
|
| 11 |
+
```
|
| 12 |
+
|
| 13 |
+
Windows 首次安装需要建立 `.venv`,完成后进入项目目录使用:
|
| 14 |
+
|
| 15 |
+
```powershell
|
| 16 |
+
cd D:\projects\multimodal_training
|
| 17 |
+
.\.venv\Scripts\python.exe -m image_cleaner --help
|
| 18 |
+
```
|
| 19 |
+
|
| 20 |
+
新机器首次安装:
|
| 21 |
+
|
| 22 |
+
```powershell
|
| 23 |
+
python -m venv .venv
|
| 24 |
+
.\.venv\Scripts\python.exe -m pip install -e .
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
Linux 对应安装与命令前缀:
|
| 28 |
+
|
| 29 |
+
```bash
|
| 30 |
+
python3 -m venv .venv
|
| 31 |
+
.venv/bin/python -m pip install -e .
|
| 32 |
+
.venv/bin/python -m image_cleaner --help
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
下文 Windows 命令均在项目根目录运行。Linux 将前缀改为 `.venv/bin/python` 并使用相应路径即可。每次筛选使用新的输出目录。
|
| 36 |
+
|
| 37 |
+
## 2. 输入方式
|
| 38 |
+
|
| 39 |
+
### 2.1 文件夹:适合直接批量导入
|
| 40 |
+
|
| 41 |
+
```text
|
| 42 |
+
D:\datasets\images\
|
| 43 |
+
animals\001.jpg
|
| 44 |
+
animals\001.txt
|
| 45 |
+
charts\002.png
|
| 46 |
+
charts\002.txt
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
递归发现 `.jpg/.jpeg/.png/.webp/.bmp/.tif/.tiff/.gif/.avif`。图片有同名 UTF-8 `.txt` 时加 `--sidecar-text`,否则只导入图片。默认不跟随目录符号链接;扩展名发现列表与实际可解码格式不同,具体解码能力取决于 Pillow。
|
| 50 |
+
|
| 51 |
+
### 2.2 JSONL:适合图文配对与多机迁移
|
| 52 |
+
|
| 53 |
+
每行至少一个字符串 `path`,可以附带 `text`、`id`、`metadata`:
|
| 54 |
+
|
| 55 |
+
```json
|
| 56 |
+
{"id":"original-001","path":"animals/001.jpg","text":"一只猫坐在窗边。","metadata":{"source":"dataset-a"}}
|
| 57 |
+
{"id":"original-002","path":"charts/002.png","text":"一个展示月份销量的柱状图。"}
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
相对路径以 `--root` 为基准;省略时以输入 JSONL 所在目录为基准。原始 ID 保存为 `source_id`,其他额外字段归入 `metadata`。同一图片允许多条不同文本记录。跨节点推荐相对路径;绝对路径不受 `--root` 重映射。
|
| 61 |
+
|
| 62 |
+
```powershell
|
| 63 |
+
.\.venv\Scripts\python.exe -m image_cleaner run --input "D:\datasets\pairs.jsonl" --root "D:\datasets\images" --output runs/pairs_statistics --config configs/statistics_only.json --workers 8
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
格式错误的输入 JSONL、非字符串文本或无法读取的 sidecar 会使导入报错;单张图片缺失或损坏在分析阶段记录为错误。工具不直接下载 URL 或读取压缩包内的图片。
|
| 67 |
+
|
| 68 |
+
## 3. 推荐流程:先统计,再筛选,再导出
|
| 69 |
+
|
| 70 |
+
### 3.1 全量分布统计
|
| 71 |
+
|
| 72 |
+
```powershell
|
| 73 |
+
.\.venv\Scripts\python.exe -m image_cleaner run --input "D:\datasets\images" --output runs/statistics --config configs/statistics_only.json --workers 8 --sidecar-text
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
只处理图片且没有文本时移除 `--sidecar-text`。统计配置关闭尺寸/质量/文本可选阈值和去重,成功处理的静态图片全部保留;损坏、超过容量限制、动画/多页等仍作为错误记录。
|
| 77 |
+
|
| 78 |
+
查看报告:
|
| 79 |
+
|
| 80 |
+
```powershell
|
| 81 |
+
Start-Process -FilePath "runs/statistics/curated/report.html"
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
`report.json` 适合程序读取,`report.html` 包含无外部依赖的分布图。先检查分辨率、宽高比、噪声与清晰度分布,再确定规则。
|
| 85 |
+
|
| 86 |
+
### 3.2 保留 256p 及以上的图片
|
| 87 |
+
|
| 88 |
+
```powershell
|
| 89 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/statistics/shards --output runs/only_256p --resolution 256p
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
`256p` 的定义是 `min(width, height) >= 256`,表示短边大于或等于 256 像素,256 本身保留。它不表示缩放图片,与 `min_short_edge=256` 的尺寸边界相同。
|
| 93 |
+
|
| 94 |
+
| 图片尺寸(宽×高) | `256p` | `256x256` | `384x256` |
|
| 95 |
+
|---|---|---|---|
|
| 96 |
+
| 256×256 | 保留 | 保留 | 拒绝 |
|
| 97 |
+
| 384×256 | 保留 | 拒绝 | 保留 |
|
| 98 |
+
| 256×384 | 保留 | 拒绝 | 拒绝 |
|
| 99 |
+
| 255×384 | 拒绝 | 拒绝 | 拒绝 |
|
| 100 |
+
| 257×384 | 保留 | 拒绝 | 拒绝 |
|
| 101 |
+
| 512×512 | 保留 | 拒绝 | 拒绝 |
|
| 102 |
+
|
| 103 |
+
其他选择:
|
| 104 |
+
|
| 105 |
+
```powershell
|
| 106 |
+
# 仅保留 256×256。
|
| 107 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/statistics/shards --output runs/only_square --resolution 256x256
|
| 108 |
+
|
| 109 |
+
# 仅保留宽 384、高 256。
|
| 110 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/statistics/shards --output runs/only_landscape --resolution 384x256
|
| 111 |
+
|
| 112 |
+
# 改为短边大于或等于 512。
|
| 113 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/statistics/shards --output runs/only_512p --resolution 512p
|
| 114 |
+
|
| 115 |
+
# 关闭分辨率条件。
|
| 116 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/statistics/shards --output runs/all_resolutions --resolution any
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
尺寸以 EXIF 方向校正后的宽高为准:编码为 256×384、EXIF 要求旋转 90° 的图片会以 384×256 判断。��彩转换不改变尺寸;导出缩放发生在筛选之后。
|
| 120 |
+
|
| 121 |
+
`filter` 等价于 `merge`,始终从分析分片里的全部记录重新判定,不能把它的 `--shards` 指向上一轮 `curated` 目录。即使第一轮某图片被阈值或去重排除,分析结果仍在分片中,可以再次参与筛选。原图不会被重新解码;物理导出仍需要原图存在。
|
| 122 |
+
|
| 123 |
+
### 3.3 分辨率与其他筛选一起执行
|
| 124 |
+
|
| 125 |
+
使用配置文件可同时加入宽高比、质量、文本条件或去重:
|
| 126 |
+
|
| 127 |
+
```powershell
|
| 128 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/statistics/shards --output runs/256p_deduplicated --config configs/audit.json --resolution 256p
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
上例额外开启去重;符合尺寸的重复记录仍会进入 `duplicate.jsonl`。如果只希望剔除短边小于 256 的图片,沿用统计配置或指定 `--config configs/above_256p.json`,其去重关闭。
|
| 132 |
+
|
| 133 |
+
配置优先级:未传 `--config` 时继承分析时的完整配置;传了 `--config` 时从该 JSON 和代码默认值构造新配置,不是对原配置做部分补丁;`--resolution` 最后覆盖分辨率字段。所有启用规则取交集。若同时设置 `min_short_edge=512` 与 `resolution=256p`,最终仍要求短边至少 512;如需允许所有大于或等于 256 的尺寸,先关闭更严格的其他尺寸规则。
|
| 134 |
+
|
| 135 |
+
阈值修改和精确分辨率筛选可以复用分析结果。`reject_animated/max_decode_pixels/metric_patch_size/alpha_background/convert_icc/text_unicode_form` 属于测量与预处理设置,改变这些设置需要重新分析。
|
| 136 |
+
|
| 137 |
+
### 3.4 导出标准化图片与文本
|
| 138 |
+
|
| 139 |
+
```powershell
|
| 140 |
+
.\.venv\Scripts\python.exe -m image_cleaner export --curated runs/only_256p --output runs/export_256p --workers 8
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
只导出 `kept.jsonl` 中的记录,默认 PNG、RGB、保留已匹配尺寸,不做裁剪或放大。文件放在 `images` 子目录,`exported.jsonl` 的 `path` 为导出目录下的相对路径。导出会校验源文件 SHA-256;源文件在分析后发生变化会记录失败。
|
| 144 |
+
|
| 145 |
+
`export_max_edge` 如被启用,可能改变筛选后图片尺寸;需要导出文件仍满足筛选条件时保持 `export_max_edge=null`。例如 1024×512 符合 256p 及以上,但若导出最长边限制为 384,则会缩成 384×192,输出不再满足短边至少 256 的条件。
|
| 146 |
+
|
| 147 |
+
### 3.5 一键流程
|
| 148 |
+
|
| 149 |
+
已经明确目标时可以直接执行:
|
| 150 |
+
|
| 151 |
+
```powershell
|
| 152 |
+
.\.venv\Scripts\python.exe -m image_cleaner run --input "D:\datasets\images" --output runs/direct_256p --config configs/above_256p.json --workers 8 --export
|
| 153 |
+
```
|
| 154 |
+
|
| 155 |
+
也可以在 `run` 或 `analyze` 上直接传 `--resolution`;推荐第一次先完成全量统计。
|
| 156 |
+
|
| 157 |
+
### 3.6 宽高比桶筛选
|
| 158 |
+
|
| 159 |
+
比例始终为 EXIF 方向校正后的 **宽:高**。八个固定桶为 `1:1,2:3,3:2,3:4,4:3,9:16,16:9,21:9`。竖图与横图分别选择,例如选择 `2:3` 不会自动选择 `3:2`。
|
| 160 |
+
|
| 161 |
+
```powershell
|
| 162 |
+
# 在项目根目录执行。先运行 3.1 的统计任务,再用缓存分片筛选。
|
| 163 |
+
# 严格多选:实际尺寸必须与任意一个选中桶完全同比分配。
|
| 164 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/statistics/shards --output runs/aspect_strict --aspect-buckets "1:1,2:3,3:2" --aspect-mode strict
|
| 165 |
+
|
| 166 |
+
# 宽松全选:5% 偏差,也可以省略 tolerance 参数使用默认值。
|
| 167 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/statistics/shards --output runs/aspect_loose --aspect-buckets all --aspect-mode loose --aspect-tolerance 0.05
|
| 168 |
+
|
| 169 |
+
# 更严格的 3% 容差,同时保留短边 >=256 的图片。
|
| 170 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/statistics/shards --output runs/aspect_3percent_256 --resolution 256p --aspect-buckets "3:4,4:3,9:16,16:9" --aspect-mode loose --aspect-tolerance 0.03
|
| 171 |
+
|
| 172 |
+
# 覆盖已有配置、关闭桶条件。其他筛选条件继续生效。
|
| 173 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/statistics/shards --output runs/aspect_disabled --aspect-buckets none
|
| 174 |
+
|
| 175 |
+
# 从文件夹直接运行,支持多进程。
|
| 176 |
+
.\.venv\Scripts\python.exe -m image_cleaner run --input "D:\datasets\images" --output runs/aspect_run --workers 8 --aspect-buckets all --aspect-mode loose
|
| 177 |
+
|
| 178 |
+
# 使用示例配置。示例关闭去重以便单独观察桶筛选效果。
|
| 179 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/statistics/shards --output runs/aspect_config --config configs/aspect_loose.json
|
| 180 |
+
```
|
| 181 |
+
|
| 182 |
+
每次重筛选使用一个新的输出目录。`filter` 读取全部分析记录,包括此前被规则拒绝的图片;因此既能收紧也能放宽条件,不需要再次解码。解码失败的记录仍归入错误。
|
| 183 |
+
|
| 184 |
+
| 参数 | 默认值 | 含义 |
|
| 185 |
+
| --- | --- | --- |
|
| 186 |
+
| `--aspect-buckets` | 不启用,或继承配置/分片 | 英文逗号分隔多选;`all` 是八桶并集;`none` 关闭;空值或非预设桶报错 |
|
| 187 |
+
| `--aspect-mode` | `strict`,或继承配置/分片 | `strict` 精确匹配;`loose` 容差匹配 |
|
| 188 |
+
| `--aspect-tolerance` | `0.05`,或继承配置/分片 | 有限非负小数,仅宽松模式生效;`0.05` 表示 5%,`0` 表示不允许偏差 |
|
| 189 |
+
|
| 190 |
+
三个参数均支持 `analyze`、`filter`/`merge`、`run`。显式命令行参数优先于配置;未传参数保留配置值。没有传 `--config` 的 `filter` 继承分析分片配置;传入配置文件则按该文件及 Config 默认值构建整套筛选规则。
|
| 191 |
+
|
| 192 |
+
设实际比例 `r=width/height`,目标桶比例 `b`。严格模式使用整数交叉相乘:例如 `2:3` 要求 `width×3 == height×2`。宽松模式要求 `δ=max(r/b,b/r)-1 <= aspect_tolerance`,包含边界。实现使用精确分数比较。例如选 `1:1`、偏差 5% 时,`1050×1000` 和 `1000×1050` 均通过,`1051×1000` 不通过。此对称定义下允许区间为 `[b/1.05, b×1.05]`,并非普通的 `[b×0.95,b×1.05]`。
|
| 193 |
+
|
| 194 |
+
任意一个选中桶匹配即可通过。如果匹配多个桶,则只分配给偏差最小的桶;同偏差按上述固定八桶顺序选取,因此每张图片只计数一次。重复选择会合并。`all` 仍会过滤超出所有桶范围的图片。桶条件与分辨率、`min_aspect_ratio/max_aspect_ratio`、质量、文本条件取交集;不会裁剪或缩放图片。
|
| 195 |
+
|
| 196 |
+
配置文件新增字段示例:
|
| 197 |
+
|
| 198 |
+
```json
|
| 199 |
+
{
|
| 200 |
+
"aspect_buckets": ["1:1", "2:3", "3:2"],
|
| 201 |
+
"aspect_mode": "loose",
|
| 202 |
+
"aspect_tolerance": 0.05
|
| 203 |
+
}
|
| 204 |
+
```
|
| 205 |
+
|
| 206 |
+
`aspect_buckets` 也接受 `"all"`、`"none"` 或 `null`。多机仍使用第 6 节的同一 manifest 和 rank/world-size 流程;所有节点分析配置一致,收集全部分片后统一执行上面的 `filter` 命令,桶筛选无需各节点读取源图片。
|
| 207 |
+
|
| 208 |
+
## 4. 各环节指标和判定规则
|
| 209 |
+
|
| 210 |
+
### 4.1 导入、解码和容量检查
|
| 211 |
+
|
| 212 |
+
| 字段/指标 | 意义 | 判定 |
|
| 213 |
+
|---|---|---|
|
| 214 |
+
| `input_count` | 清单记录数量,包含同图多文本记录 | 不是去重后的独立图片数 |
|
| 215 |
+
| `original_width/original_height` | 文件编码中的原始尺寸 | 与 EXIF 校正后的尺寸分别保存 |
|
| 216 |
+
| `original_format/original_mode` | 解码格式和像素模式 | 用于来源与格式审计 |
|
| 217 |
+
| `frame_count` | 帧数或页数 | 默认 `reject_animated=true` 时,多帧/多页报处理错误 |
|
| 218 |
+
| `max_decode_pixels` | 单图最大解码像素数 | 默认 100,000,000;超过限制报错 |
|
| 219 |
+
| `error_count`、`error.jsonl` | 文件缺失、损坏、截断或处理异常 | 不进入有效分布统计与导出 |
|
| 220 |
+
| `processing_success_rate` | 完成整套图片处理数 ÷ 输入记录数 | 是处理成功率,不是人工质量准确率 |
|
| 221 |
+
|
| 222 |
+
### 4.2 标准化
|
| 223 |
+
|
| 224 |
+
| 字段/设置 | 行为和验证方法 |
|
| 225 |
+
|---|---|
|
| 226 |
+
| `exif_transposed` | 是否按 EXIF 方向进行转置;查看标准化宽高并抽查方向 |
|
| 227 |
+
| `icc_converted` | 有 ICC 且开启转换时转为 sRGB;失败记错误 |
|
| 228 |
+
| `alpha_flattened` | 是否存在透明通道并按 `alpha_background` 合成;默认白色 |
|
| 229 |
+
| `text_unicode_form` | NFC 默认;可选 NFKC 或 none;同时统一换行并去首尾/行尾空白 |
|
| 230 |
+
| 导出的 `width/height`、像素模式 | 检查最终尺寸与 RGB 模式;导出可选等比例缩小 |
|
| 231 |
+
|
| 232 |
+
标准化属于数据规范化,未实现模型训练的 dropout/weight decay,也不会执行默认去噪或锐化。16 位/浮点图片最终转为 8 位 RGB,本工具主要面向常规网页图像。
|
| 233 |
+
|
| 234 |
+
### 4.3 分布统计、分辨率和宽高比
|
| 235 |
+
|
| 236 |
+
| 字段 | 定义/单位 | 筛选配置 |
|
| 237 |
+
|---|---|---|
|
| 238 |
+
| `width` | EXIF 校正后的宽度,像素 | `resolution=WxH` 可精确限定 |
|
| 239 |
+
| `height` | EXIF 校正后的高度,像素 | `resolution=WxH` 可精确限定 |
|
| 240 |
+
| `short_edge` | min(width,height),像素 | `resolution=Np` 要求大于或等于 N;`min_short_edge` 为包含边界的下限 |
|
| 241 |
+
| `long_edge` | max(width,height),像素 | `max_long_edge` 为上限 |
|
| 242 |
+
| `pixels` | width×height | `min_pixels/max_pixels` |
|
| 243 |
+
| `aspect_ratio` | width/height | `min_aspect_ratio/max_aspect_ratio` |
|
| 244 |
+
| `orientations` | 横、竖、近方图数量 | 0.95≤W/H≤1.05 视为近方图;只是统计类别 |
|
| 245 |
+
|
| 246 |
+
分辨率筛选不匹配时,原因是 `resolution_mismatch`。`min_short_edge=256` 与 `resolution=256p` 均包含 256,因此 255 不合格,256 和 257 合格。`resolution_filter.mode=short_edge_gte` 与 `comparison=">="` 在报告中记录这一判定;`WxH` 仍为精确宽高匹配。
|
| 247 |
+
|
| 248 |
+
每个分布提供有效数量、缺失数量、最小值、最大值、均值、总体标准差、p1/p5/p25/p50/p75/p95/p99,以及固定桶直方图。桶为左闭右开,最后覆盖到无穷大;直方图是全量精确计数。分位数最多使用固定种子 20,000 条蓄水池样本;更大规模时 `quantiles_approximate=true`。
|
| 249 |
+
|
| 250 |
+
`before` 覆盖成功处理的全部记录,`after` 覆盖最终保留记录;不代表按独立图片去重后的来源统计。分辨率匹配数另外直接按每条记录统计,避免粗直方图桶无法区分 255/256/257 的问题。
|
| 251 |
+
|
| 252 |
+
### 4.4 图片技术质量
|
| 253 |
+
|
| 254 |
+
默认在四角和中心最多五个原生像素区域测量,区域大小最多 `metric_patch_size=256`,相同区域去重,各指标取有效区域的中位数。不会先缩图再测量;可能漏掉未采样区域的局部缺陷。
|
| 255 |
+
|
| 256 |
+
| 字段 | 计算方式、解释 | 筛选条件 |
|
| 257 |
+
|---|---|---|
|
| 258 |
+
| `blur_score` | 灰度区域上 Var(up+down+left+right−4×center),拉普拉斯方差;通常越低越模糊 | 低于 `min_blur_score` 拒绝 |
|
| 259 |
+
| `noise_sigma` | 在梯度最低约 30% 区域,用 3×3 高通残差计算 MAD/(0.67448975×6),像素幅度范围 0–255 | 高于 `max_noise_sigma` 拒绝 |
|
| 260 |
+
| `snr_proxy_db` | 每区域 20log10(灰度标准差/noise_sigma),然后取区域中位数 | 低于 `min_snr_proxy_db` 拒绝 |
|
| 261 |
+
| `blockiness` | 对水平/垂直 8 像素网格各相位求边界梯度与非边界梯度差;每轴取最大差,再平均并截断到非负 | 高于 `max_blockiness` 拒绝 |
|
| 262 |
+
| `luminance` | 灰度均值,0–255 | 当前仅记录,无直接阈值 |
|
| 263 |
+
| `contrast` | 灰度总体标准差 | 当前仅记录,无直接阈值 |
|
| 264 |
+
| `metric_patch_count` | 实际采样区域数 | 当前仅记录,方便审计 |
|
| 265 |
+
|
| 266 |
+
这些是代理指标:拉普拉斯方差可能因噪声升高,纹理会影响噪声估计,规则图案也会产生块效应分数。信噪比的“信号”采用局部亮度标准差,不是真实 SNR,也不是 PSNR。
|
| 267 |
+
|
| 268 |
+
噪声或对比度接近零时,SNR 返回 `null` 并写入 `snr_proxy_undefined`;极小图片部分指标为 `null`。缺失值不会触发对应质量阈值。报告的 `missing` 表示未得到指标值,不表示质量合格。
|
| 269 |
+
|
| 270 |
+
所有质量阈值默认 null(关闭);先按照片/截图/图表/插画等类型抽查,再设置阈值。修改 `metric_patch_size` 会改变指标分布,必须重新分析并重新校准。
|
| 271 |
+
|
| 272 |
+
### 4.5 文本筛选
|
| 273 |
+
|
| 274 |
+
| 指标/设置 | 判定 |
|
| 275 |
+
|---|---|
|
| 276 |
+
| `text_chars` | 规范化后 Python 字符数量;缺失文本按 0 统计,不是 token 数或单词数 |
|
| 277 |
+
| `min_text_chars` | 低于下限拒绝;设置为 1 可排除没有文字的样本 |
|
| 278 |
+
| `max_text_chars` | 超过上限拒绝 |
|
| 279 |
+
| `forbidden_text_patterns` | 任一 Python 正则表达式 `re.search` 命中即拒绝,默认区分大小写 |
|
| 280 |
+
|
| 281 |
+
例如配置 `"forbidden_text_patterns": ["(?i)buy\\s+now", "点击购买"]` 可匹配部分广告文本。这是启发式文本规则,不能判断图片与文本是否语义对应。
|
| 282 |
+
|
| 283 |
+
### 4.6 去重
|
| 284 |
+
|
| 285 |
+
质量规则通过后再进行全局去重,以避免不合格图片挤掉合格代表。
|
| 286 |
+
|
| 287 |
+
| 指标/类型 | 判定 | 配置 |
|
| 288 |
+
|---|---|---|
|
| 289 |
+
| `file_sha256`、`exact_file` | 原文件字节 SHA-256 一致 | `deduplicate=true` |
|
| 290 |
+
| `pixel_sha256`、`exact_pixels` | 标准化 RGB 尺寸和像素一致 | `deduplicate=true` |
|
| 291 |
+
| pHash 汉明距离 | 64 位低频 DCT 特征哈希差异 | 不超过 `phash_distance`,默认 4,可设 0–8 |
|
| 292 |
+
| dHash 汉明距离 | 64 位相邻灰度梯度特征差异 | 不超过 `dhash_distance`,默认 4,可设 0–64 |
|
| 293 |
+
| RGB 缩略图 RMSE | 16×16 RGB 图像 sqrt(mean((a−b)²)) | 不超过 `near_rgb_rmse`,默认 8.0 |
|
| 294 |
+
| 宽高比差异 | abs(log(ratio_a/ratio_b)) | 不超过 `near_aspect_log_tolerance`,默认 0.03 |
|
| 295 |
+
| `near_perceptual` | 上述近重复条件均满足 | `near_deduplicate=true` 且总去重开启 |
|
| 296 |
+
|
| 297 |
+
pHash 使用相对 AC 系数容差抑制极小系数,与普通 pHash 数值不完全相同。所有近重复复核是启发式,不能代替语义 embedding 去重。
|
| 298 |
+
|
| 299 |
+
默认 `duplicate_scope=image_text`:规范化文本也相同才归并,保留同图不同描述/问答;没有文本时按图片归并。`image` 会忽略文本差异。代表优先级为像素数降序、噪声升序、清晰度降序、清单序号升序,不是人工审美排序。
|
| 300 |
+
|
| 301 |
+
`duplicate.jsonl` 给出 `duplicate_of/duplicate_kind/duplicate_metrics`。近重复记录目前保存 pHash 距离和 RGB RMSE,dHash 与比例条件用于判断但未单独写入距离字段。每个重复直接匹配一个保留代表,不通过无限传递链扩大分组。
|
| 302 |
+
|
| 303 |
+
### 4.7 阶段评测指标
|
| 304 |
+
|
| 305 |
+
令 N 为输入条数、D 为处理成功条数、M 为指定分辨率匹配条数、E 为全部规则通过条数、U 为重复条数、K 为最终保留条数:
|
| 306 |
+
|
| 307 |
+
| `report.json` 字段 | 定义 |
|
| 308 |
+
|---|---|
|
| 309 |
+
| `input_count` | N |
|
| 310 |
+
| `error_count` | N−D |
|
| 311 |
+
| `stage_metrics.processing_success_count/rate` | D、D/N |
|
| 312 |
+
| `stage_metrics.resolution_matched_count` | M,单独按尺寸条件计算,不含其他质量限制 |
|
| 313 |
+
| `stage_metrics.resolution_mismatched_count` | D−M |
|
| 314 |
+
| `stage_metrics.resolution_pass_rate` | M/D;分辨率条件关闭时为 1(D>0) |
|
| 315 |
+
| `stage_metrics.rules_eligible_count/rules_pass_rate` | E=K+U、E/D |
|
| 316 |
+
| `rejected_count` | D−E,至少一个规则不合格 |
|
| 317 |
+
| `duplicate_count` | U |
|
| 318 |
+
| `stage_metrics.duplicate_rate_among_eligible` | U/E |
|
| 319 |
+
| `kept_count/stage_metrics.final_keep_rate` | K、K/N |
|
| 320 |
+
| `before.reasons` | 最终各原因的出现次数;一条拒绝记录可有多个原因,不可直接加总当成样本数 |
|
| 321 |
+
|
| 322 |
+
分母为零时��率为 null。比率是 0–1 的数值;乘 100 得百分比。每次完整输出满足 `N=K+rejected_count+U+error_count`。关闭去重时 U=0。
|
| 323 |
+
|
| 324 |
+
这些指标衡量清洗流程和数据组成,不是模型性能或人工标注准确率。若要评估误删/漏删,按图片类型和规则分层抽取人工审核样本,统计“被拒绝但实际可用”的比例和“保留但实际不合格”的比例。后续训练收益需要固定训练预算、随机种子与验证集做对照实验。
|
| 325 |
+
|
| 326 |
+
### 4.8 宽高比桶筛选评测指标
|
| 327 |
+
|
| 328 |
+
打开输出目录的 `report.html` 查看新增宽高比桶部分;`report.json` 提供机器可读指标,`kept.jsonl`、`rejected.jsonl` 等提供逐条结果。
|
| 329 |
+
|
| 330 |
+
| 指标/字段 | 定义与用途 |
|
| 331 |
+
| --- | --- |
|
| 332 |
+
| `stage_metrics.aspect_bucket_matched_count` | 成功解码记录中,单独满足桶条件的数量;不受其他阈值和去重影响 |
|
| 333 |
+
| `aspect_bucket_mismatched_count` | 成功解码数量减去桶匹配数量 |
|
| 334 |
+
| `aspect_bucket_pass_rate` | 桶匹配数 / 成功解码数;无成功解码记录为 null;关闭桶条件时成功解码记录全部通过 |
|
| 335 |
+
| `aspect_bucket_filter.counts_before` | 所有成功解码图片的匹配桶归属计数,包含因其他条件拒绝或重复的图片 |
|
| 336 |
+
| `aspect_bucket_filter.counts_after` | 最终 kept 图片的桶归属计数,已应用全部规则及去重 |
|
| 337 |
+
| `aspect_bucket_filter` 其余字段 | 是否启用、选中桶、模式、容差、尺寸计算依据、偏差公式 |
|
| 338 |
+
| 每条记录 `aspect_bucket` | 匹配且分配的桶;没有通过桶条件或关闭条件时为 null |
|
| 339 |
+
| `aspect_bucket_nearest` | 最近的选中桶,即使不通过也可用来排查;关闭条件时为 null |
|
| 340 |
+
| `aspect_bucket_deviation` | 到最近桶的对称相对偏差;例如 0.05 是 5%;关闭条件时为 null |
|
| 341 |
+
| `aspect_bucket_match` | true/false 表示通过/未通过;关闭条件时为 null |
|
| 342 |
+
| 拒绝原因 `aspect_bucket_mismatch` | 不满足任何选中桶;同一记录可同时包含多个拒绝原因 |
|
| 343 |
+
|
| 344 |
+
上述逐条字段针对成功解码记录;错误记录可能没有尺寸与桶字段。`counts_before` 总和等于独立桶匹配数(条件启用时),`counts_after` 总和等于最终保留数。选中但没有图片的桶也输出 0;关闭条件时桶计数字典为空。质量评测可同时检查桶通过率、各桶覆盖量、最终保留率,并人工抽查 rejected 中临界偏差图片;各桶不执行均衡采样或配额。
|
| 345 |
+
|
| 346 |
+
## 5. 配置文件和参数
|
| 347 |
+
|
| 348 |
+
项目已有:
|
| 349 |
+
|
| 350 |
+
| 配置 | 用途 |
|
| 351 |
+
|---|---|
|
| 352 |
+
| `configs/statistics_only.json` | 全量统计,关闭可选阈值与去重 |
|
| 353 |
+
| `configs/above_256p.json` | 短边大于或等于 256,关闭去重 |
|
| 354 |
+
| `configs/exact_256p.json` | 兼容旧文件名,内容与 above_256p 相同,采用大于或等于的新规则 |
|
| 355 |
+
| `configs/audit.json` | 无可选技术质量阈值,开启默认去重 |
|
| 356 |
+
| `configs/aspect_strict.json` | 严格选择 1:1、2:3、3:2,关闭去重 |
|
| 357 |
+
| `configs/aspect_loose.json` | 宽松选择八桶,默认容差 5%,关闭去重 |
|
| 358 |
+
| `configs/generation_example.json` | 示例:短边至少 512、比例 0.25–4、文本非空,并开启默认去重 |
|
| 359 |
+
|
| 360 |
+
以下为可自定义的完整字段表,数值是默认值,不是推荐质量阈值:
|
| 361 |
+
|
| 362 |
+
```json
|
| 363 |
+
{
|
| 364 |
+
"resolution": null,
|
| 365 |
+
"min_short_edge": null,
|
| 366 |
+
"max_long_edge": null,
|
| 367 |
+
"min_pixels": null,
|
| 368 |
+
"max_pixels": null,
|
| 369 |
+
"min_aspect_ratio": null,
|
| 370 |
+
"aspect_buckets": null,
|
| 371 |
+
"aspect_mode": "strict",
|
| 372 |
+
"aspect_tolerance": 0.05,
|
| 373 |
+
"max_aspect_ratio": null,
|
| 374 |
+
"min_blur_score": null,
|
| 375 |
+
"max_noise_sigma": null,
|
| 376 |
+
"min_snr_proxy_db": null,
|
| 377 |
+
"max_blockiness": null,
|
| 378 |
+
"min_text_chars": null,
|
| 379 |
+
"max_text_chars": null,
|
| 380 |
+
"forbidden_text_patterns": [],
|
| 381 |
+
"reject_animated": true,
|
| 382 |
+
"max_decode_pixels": 100000000,
|
| 383 |
+
"metric_patch_size": 256,
|
| 384 |
+
"alpha_background": [255, 255, 255],
|
| 385 |
+
"convert_icc": true,
|
| 386 |
+
"text_unicode_form": "NFC",
|
| 387 |
+
"deduplicate": true,
|
| 388 |
+
"near_deduplicate": true,
|
| 389 |
+
"duplicate_scope": "image_text",
|
| 390 |
+
"phash_distance": 4,
|
| 391 |
+
"dhash_distance": 4,
|
| 392 |
+
"near_rgb_rmse": 8.0,
|
| 393 |
+
"near_aspect_log_tolerance": 0.03,
|
| 394 |
+
"export_max_edge": null,
|
| 395 |
+
"export_format": "png",
|
| 396 |
+
"export_jpeg_quality": 95
|
| 397 |
+
}
|
| 398 |
+
```
|
| 399 |
+
|
| 400 |
+
`export_format` 可选 png/jpeg;JPEG `export_jpeg_quality` 为 1–100,采用无色度子采样,仍是有损输出。图片规范化不会自动统一为方形,固定裁剪和张量归一化由训练数据加载器执行。
|
| 401 |
+
|
| 402 |
+
`resolution` 支持 `256p`(短边>=256)、`256x256`(精确宽高)等正整数格式;大小写与首尾空白会规范化,`256×256` 也接受。`any/none/null` 关闭分辨率条件。无后缀的 `256`、`0p`、负数、逗号多个值都非法;多目标请分别筛选到不同输出目录。
|
| 403 |
+
|
| 404 |
+
各子命令参数:
|
| 405 |
+
|
| 406 |
+
| 命令 | 主要必填参数 | 可选参数 |
|
| 407 |
+
|---|---|---|
|
| 408 |
+
| `prepare` | `--input --manifest` | `--root --sidecar-text` |
|
| 409 |
+
| `analyze` | `--manifest --output` | `--root --config --resolution --aspect-buckets --aspect-mode --aspect-tolerance --workers --rank --world-size --resume` |
|
| 410 |
+
| `filter/merge` | `--shards --output` | `--config --resolution --aspect-buckets --aspect-mode --aspect-tolerance` |
|
| 411 |
+
| `export` | `--curated --output` | `--root --workers --resume` |
|
| 412 |
+
| `run` | `--input --output` | `--root --config --resolution --aspect-buckets --aspect-mode --aspect-tolerance --workers --sidecar-text --export --resume` |
|
| 413 |
+
|
| 414 |
+
`workers` 默认 min(8,CPU数量)。配置未知字段、非法类型、范围错误会报错;各子命令的 `--help` 可查看在线说明。
|
| 415 |
+
|
| 416 |
+
## 6. 多机分片流程
|
| 417 |
+
|
| 418 |
+
先创建全局固定清单:
|
| 419 |
+
|
| 420 |
+
```powershell
|
| 421 |
+
.\.venv\Scripts\python.exe -m image_cleaner prepare --input "D:\datasets\images" --manifest runs/distributed/input.jsonl --sidecar-text
|
| 422 |
+
```
|
| 423 |
+
|
| 424 |
+
将 `input.jsonl`、`input.jsonl.meta.json`、相同代码和配置分发到节点。每台机器都必须能访问对应原图,可以使用不同本地根目录。
|
| 425 |
+
|
| 426 |
+
分别在两台机器执行:
|
| 427 |
+
|
| 428 |
+
```powershell
|
| 429 |
+
# 节点 0
|
| 430 |
+
.\.venv\Scripts\python.exe -m image_cleaner analyze --manifest runs/distributed/input.jsonl --root "D:\node0\images" --output runs/distributed/shards --config configs/statistics_only.json --workers 8 --world-size 2 --rank 0
|
| 431 |
+
|
| 432 |
+
# 节点 1
|
| 433 |
+
.\.venv\Scripts\python.exe -m image_cleaner analyze --manifest runs/distributed/input.jsonl --root "E:\node1\images" --output runs/distributed/shards --config configs/statistics_only.json --workers 8 --world-size 2 --rank 1
|
| 434 |
+
```
|
| 435 |
+
|
| 436 |
+
每条清单按 `seq % world_size` 分配。每个节点本地写不同 rank 的 SQLite,任务退出后收集全部 `shard-*.sqlite` 到协调节点同一目录;不用让不同机器写同一个 SQLite。
|
| 437 |
+
|
| 438 |
+
协调节点先汇总统计,然后按分辨率条件筛选:
|
| 439 |
+
|
| 440 |
+
```powershell
|
| 441 |
+
.\.venv\Scripts\python.exe -m image_cleaner merge --shards runs/distributed/shards --output runs/distributed/statistics
|
| 442 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/distributed/shards --output runs/distributed/only_256p --resolution 256p
|
| 443 |
+
```
|
| 444 |
+
|
| 445 |
+
若希望跨分片去重,在第二条命令增加 `--config configs/audit.json`。合并会验证全部 rank 完整、清单/配置/软件版本一致、序号不重复。筛选结果与使用同一清单的单机处理一致,去重代表顺序不依赖 worker 完成顺序。
|
| 446 |
+
|
| 447 |
+
这是静态分片处理,没有自动节点调度或故障迁移。分析并行,最终筛选和全局去重由一个协调节点完成;数据和索引落磁盘,没有百万级运行性能保证。大图处理的解码内存与图片尺寸有关,可降低 workers 或最大解码像素限制。
|
| 448 |
+
|
| 449 |
+
## 7. 输出、断点续跑和复核
|
| 450 |
+
|
| 451 |
+
统计 `run` 输出清单、分片数据库,以及 `curated` 报告;`filter/merge` 的报告直接在其 `--output` 目录内,**不会再额外套一层 curated**。
|
| 452 |
+
|
| 453 |
+
| 文件 | 用途 |
|
| 454 |
+
|---|---|
|
| 455 |
+
| `input.jsonl/.meta.json` | 固定输入与 SHA-256、条数、数据根目录 |
|
| 456 |
+
| `shard-*.sqlite` | 完整分析记录与断点检查点,是重复筛选的数据源 |
|
| 457 |
+
| `shard-*.jsonl` | 方便检查的分片记录 |
|
| 458 |
+
| `kept.jsonl` | 最终保留的路径、文本、指标 |
|
| 459 |
+
| `rejected.jsonl` | 各规则拒绝原因,包括 `resolution_mismatch` |
|
| 460 |
+
| `duplicate.jsonl` | 重复类型、代表 ID 和近重复评分 |
|
| 461 |
+
| `error.jsonl` | 单图处理失败及异常信息 |
|
| 462 |
+
| `report.json/.html` | 配置、分辨率定义、阶段指标和前后分布 |
|
| 463 |
+
| `exported.jsonl` | 标准化图片相对路径及文本 |
|
| 464 |
+
| `export_errors.jsonl` | 导出失败清单 |
|
| 465 |
+
| `export.meta.json` | 导出完成状态、计数及身份校验 |
|
| 466 |
+
|
| 467 |
+
分析续跑:
|
| 468 |
+
|
| 469 |
+
```powershell
|
| 470 |
+
.\.venv\Scripts\python.exe -m image_cleaner analyze --manifest runs/distributed/input.jsonl --root "D:\node0\images" --output runs/distributed/shards --config configs/statistics_only.json --workers 8 --world-size 2 --rank 0 --resume
|
| 471 |
+
```
|
| 472 |
+
|
| 473 |
+
导出续跑:
|
| 474 |
+
|
| 475 |
+
```powershell
|
| 476 |
+
.\.venv\Scripts\python.exe -m image_cleaner export --curated runs/only_256p --output runs/export_256p --workers 8 --resume
|
| 477 |
+
```
|
| 478 |
+
|
| 479 |
+
`run --resume` 要求相同输入、配置、分辨率等身份设置;修改规则应使用 `filter` 输出到新目录。分析每 100 条提交一次;已提交项跳过。清单固定后保持原始文件不变。残留锁文件只能在确认任务退出后移除。
|
| 480 |
+
|
| 481 |
+
合并/筛选暂不支持中途续跑,失败时用新输出目录重跑,不必重新分析。导出失败会记录清单并返回退出码 1;参数/文件操作等任务错误返回 2;分析阶段的个别图片错误不使整批命令失败,需检查 `error_count`。
|
| 482 |
+
|
| 483 |
+
复核建议:检查总数守恒、目标尺寸匹配率、各原因抽样、清洗前后图片类别/宽高比分布变化,以及近重复误合并。为每轮保存独立目录及配置;后续训练/验证划分应让同图及其变体落在同一侧,本工具未自动划分数据集。
|
| 484 |
+
|
| 485 |
+
## 8. 验证与功能边界
|
| 486 |
+
|
| 487 |
+
在全新示例目录运行以下命令,可复现分辨率筛选,不依赖外部数据。示例是六张合成图片和一条损坏图片;256p 及以上预期保留五张(256×256、384×256、256×384、257×384、512×512)、尺寸拒绝一张、错误一张。重复执行请复用已生成输入并为任务更换新的输出目录。旧版筛选报告不会自动改写;重新 filter 到新目录,旧分析分片仍可复用。
|
| 488 |
+
|
| 489 |
+
```powershell
|
| 490 |
+
.\.venv\Scripts\python.exe examples/make_resolution_demo.py
|
| 491 |
+
.\.venv\Scripts\python.exe -m image_cleaner run --input runs/resolution_demo_input --output runs/resolution_demo_statistics --config configs/statistics_only.json --workers 2 --sidecar-text
|
| 492 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/resolution_demo_statistics/shards --output runs/resolution_demo_256p --resolution 256p
|
| 493 |
+
.\.venv\Scripts\python.exe -m image_cleaner export --curated runs/resolution_demo_256p --output runs/resolution_demo_export --workers 2
|
| 494 |
+
```
|
| 495 |
+
|
| 496 |
+
单元与集成测试:
|
| 497 |
+
|
| 498 |
+
```powershell
|
| 499 |
+
.\.venv\Scripts\python.exe -m unittest discover -s tests -v
|
| 500 |
+
```
|
| 501 |
+
|
| 502 |
+
测试包括包含边界的短边下限、精确宽高、255/256/257 边界、EXIF 后方向、配置覆盖、禁用尺寸条件、规则交集、无原图重复筛选,以及既有多进程/跨分片去重与导出测试。
|
| 503 |
+
|
| 504 |
+
未包含 CLIP 图文语义对齐、VLM 事实核验、审美模型、OCR、水印分类器、语义 embedding 去重或训练模型性能评测。当前技术质量指标无法判断图片里的描述是否事实正确。
|
downloads/NMM-data-preprocess-0.3.0.zip
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:161f65c55e0728b8e71c92f9ac70ac03e5cee942ac871afb6f8b5fc9e8e28c1e
|
| 3 |
+
size 85450
|
examples/make_demo.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Create small synthetic fixtures, not real training data, under this project only."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import shutil
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
from PIL import Image, ImageFilter
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def main():
|
| 10 |
+
root = Path(__file__).resolve().parents[1]/"runs"/"demo_input"
|
| 11 |
+
if root.exists():
|
| 12 |
+
raise SystemExit(f"Demo input already exists: {root}")
|
| 13 |
+
root.mkdir(parents=True)
|
| 14 |
+
rng = np.random.default_rng(42)
|
| 15 |
+
coarse = Image.fromarray(rng.integers(20, 235, (32, 32, 3), dtype=np.uint8))
|
| 16 |
+
image = coarse.resize((768, 512), Image.Resampling.BICUBIC)
|
| 17 |
+
image.save(root/"landscape.png")
|
| 18 |
+
shutil.copyfile(root/"landscape.png", root/"same_file.png")
|
| 19 |
+
image.save(root/"same_pixels.bmp")
|
| 20 |
+
image.save(root/"recompressed.jpg", quality=92)
|
| 21 |
+
image.transpose(Image.Transpose.ROTATE_90).save(root/"portrait.png")
|
| 22 |
+
image.filter(ImageFilter.GaussianBlur(6)).save(root/"blurred.png")
|
| 23 |
+
raw = np.asarray(image).astype(np.float64)
|
| 24 |
+
noisy = np.clip(raw + rng.normal(0, 15, raw.shape), 0, 255).astype(np.uint8)
|
| 25 |
+
Image.fromarray(noisy).save(root/"noisy.png")
|
| 26 |
+
image.resize((80, 60)).save(root/"small.png")
|
| 27 |
+
Image.new("RGB", (512, 512), "white").save(root/"flat.png")
|
| 28 |
+
Image.new("RGBA", (512, 512), (0, 255, 0, 0)).save(root/"transparent.png")
|
| 29 |
+
Image.new("RGB", (32, 1024), "blue").save(root/"extreme_aspect.png")
|
| 30 |
+
(root/"broken.jpg").write_bytes(b"invalid-image-fixture")
|
| 31 |
+
for path in root.iterdir():
|
| 32 |
+
if path.suffix.lower() in {".png", ".jpg", ".bmp"}:
|
| 33 |
+
path.with_suffix(".txt").write_text("合成测试图片,用于验证清洗工具。", encoding="utf-8")
|
| 34 |
+
print(root)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
if __name__ == "__main__":
|
| 38 |
+
main()
|
examples/make_resolution_demo.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Create six synthetic size cases and one corrupt fixture inside the project."""
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
from PIL import Image, ImageDraw
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def main():
|
| 8 |
+
root = Path(__file__).resolve().parents[1]/"runs"/"resolution_demo_input"
|
| 9 |
+
if root.exists():
|
| 10 |
+
raise SystemExit(f"Demo input already exists: {root}")
|
| 11 |
+
root.mkdir(parents=True)
|
| 12 |
+
for i, (width, height) in enumerate([(256, 256), (384, 256), (256, 384),
|
| 13 |
+
(255, 384), (257, 384), (512, 512)]):
|
| 14 |
+
image = Image.new("RGB", (width, height), (35+i*25, 60, 100))
|
| 15 |
+
draw = ImageDraw.Draw(image)
|
| 16 |
+
draw.rectangle((15, 15, width-15, height-15), outline="white", width=3)
|
| 17 |
+
draw.text((25, 25), f"{width}x{height}", fill="white")
|
| 18 |
+
path = root/f"{i:02d}_{width}x{height}.png"
|
| 19 |
+
image.save(path)
|
| 20 |
+
path.with_suffix(".txt").write_text(f"合成尺寸测试图:{width}×{height}。", encoding="utf-8")
|
| 21 |
+
(root/"broken.jpg").write_bytes(b"corrupt-fixture")
|
| 22 |
+
print(root)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
if __name__ == "__main__":
|
| 26 |
+
main()
|
image_cleaner/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Image-text dataset curation without changing source files."""
|
| 2 |
+
|
| 3 |
+
__version__ = "0.3.0"
|
image_cleaner/__main__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .cli import main
|
| 2 |
+
import sys
|
| 3 |
+
|
| 4 |
+
if __name__ == "__main__":
|
| 5 |
+
sys.exit(main())
|
image_cleaner/aspect.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic width:height matching with exact inclusive tolerance boundaries."""
|
| 2 |
+
from fractions import Fraction
|
| 3 |
+
from functools import lru_cache
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@lru_cache(maxsize=128)
|
| 7 |
+
def tolerance_fraction(tolerance):
|
| 8 |
+
return Fraction(str(tolerance))
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def assess_aspect(row, config):
|
| 12 |
+
result = {"aspect_bucket": None, "aspect_bucket_nearest": None,
|
| 13 |
+
"aspect_bucket_deviation": None, "aspect_bucket_match": None}
|
| 14 |
+
if not config.aspect_buckets:
|
| 15 |
+
return result
|
| 16 |
+
distances = []
|
| 17 |
+
for bucket in config.aspect_buckets:
|
| 18 |
+
numerator, denominator = map(int, bucket.split(":"))
|
| 19 |
+
a, b = row["width"] * denominator, row["height"] * numerator
|
| 20 |
+
distances.append((Fraction(abs(a-b), min(a, b)), bucket))
|
| 21 |
+
# min is stable: ties follow the fixed preset order normalized by Config.
|
| 22 |
+
deviation, nearest = min(distances, key=lambda item: item[0])
|
| 23 |
+
matched = deviation == 0 if config.aspect_mode == "strict" else deviation <= tolerance_fraction(config.aspect_tolerance)
|
| 24 |
+
result.update(aspect_bucket=nearest if matched else None, aspect_bucket_nearest=nearest,
|
| 25 |
+
aspect_bucket_deviation=float(deviation), aspect_bucket_match=matched)
|
| 26 |
+
return result
|
image_cleaner/cli.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
import sys
|
| 6 |
+
import sqlite3
|
| 7 |
+
from dataclasses import replace
|
| 8 |
+
|
| 9 |
+
from .config import load_config
|
| 10 |
+
from .io import prepare
|
| 11 |
+
from .jobs import analyze, export_images, merge
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def add_aspect_arguments(p):
|
| 15 |
+
p.add_argument("--aspect-buckets", help="Width:height buckets, e.g. 1:1,2:3,3:2; all selects eight presets; none disables")
|
| 16 |
+
p.add_argument("--aspect-mode", choices=["strict", "loose"], help="Exact ratio or approximate ratio (default: strict)")
|
| 17 |
+
p.add_argument("--aspect-tolerance", type=float, help="Symmetric relative deviation, inclusive; default 0.05 (5%%), loose mode only")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def parser():
|
| 21 |
+
p = argparse.ArgumentParser(description="Batch image-text curation: inspect, shard, globally deduplicate, export")
|
| 22 |
+
sub = p.add_subparsers(dest="command", required=True)
|
| 23 |
+
prep = sub.add_parser("prepare", help="Freeze a recursive folder or JSONL input into a manifest")
|
| 24 |
+
prep.add_argument("--input", required=True)
|
| 25 |
+
prep.add_argument("--manifest", required=True)
|
| 26 |
+
prep.add_argument("--root", help="Base directory for relative image paths")
|
| 27 |
+
prep.add_argument("--sidecar-text", action="store_true", help="Read sibling UTF-8 .txt captions")
|
| 28 |
+
inspection = sub.add_parser("analyze", help="Analyze one deterministic rank, resume using SQLite checkpoints")
|
| 29 |
+
inspection.add_argument("--manifest", required=True)
|
| 30 |
+
inspection.add_argument("--output", required=True)
|
| 31 |
+
inspection.add_argument("--config")
|
| 32 |
+
inspection.add_argument("--resolution", help="Keep short edge >=256 (256p), exact size (256x256), or any")
|
| 33 |
+
inspection.add_argument("--root", help="Override dataset root on this node")
|
| 34 |
+
inspection.add_argument("--workers", type=int, default=min(8, os.cpu_count() or 1))
|
| 35 |
+
inspection.add_argument("--rank", type=int, default=0)
|
| 36 |
+
inspection.add_argument("--world-size", type=int, default=1)
|
| 37 |
+
inspection.add_argument("--resume", action="store_true")
|
| 38 |
+
combined = sub.add_parser("merge", aliases=["filter"], help="Re-filter all analyzed shards, globally deduplicate and report")
|
| 39 |
+
combined.add_argument("--shards", required=True)
|
| 40 |
+
combined.add_argument("--output", required=True)
|
| 41 |
+
combined.add_argument("--config", help="Optional new filtering thresholds; preprocessing must remain identical")
|
| 42 |
+
combined.add_argument("--resolution", help="Keep short edge >=256 (256p), exact size (256x256), or any; overrides config")
|
| 43 |
+
add_aspect_arguments(inspection)
|
| 44 |
+
add_aspect_arguments(combined)
|
| 45 |
+
export = sub.add_parser("export", help="Write normalized retained images to a separate directory")
|
| 46 |
+
export.add_argument("--curated", required=True)
|
| 47 |
+
export.add_argument("--output", required=True)
|
| 48 |
+
export.add_argument("--root", help="Override root if images have moved to another node")
|
| 49 |
+
export.add_argument("--workers", type=int, default=min(8, os.cpu_count() or 1))
|
| 50 |
+
export.add_argument("--resume", action="store_true")
|
| 51 |
+
run = sub.add_parser("run", help="Single-machine prepare + multiprocess analyze + global merge")
|
| 52 |
+
run.add_argument("--input", required=True)
|
| 53 |
+
run.add_argument("--output", required=True)
|
| 54 |
+
run.add_argument("--root")
|
| 55 |
+
run.add_argument("--config")
|
| 56 |
+
run.add_argument("--resolution", help="Keep short edge >=256 (256p), exact size (256x256), or any; overrides config")
|
| 57 |
+
run.add_argument("--workers", type=int, default=min(8, os.cpu_count() or 1))
|
| 58 |
+
run.add_argument("--sidecar-text", action="store_true")
|
| 59 |
+
run.add_argument("--export", action="store_true")
|
| 60 |
+
run.add_argument("--resume", action="store_true")
|
| 61 |
+
add_aspect_arguments(run)
|
| 62 |
+
return p
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def cli_config(args):
|
| 66 |
+
config = load_config(args.config)
|
| 67 |
+
return replace(config, **cli_overrides(args))
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def cli_overrides(args):
|
| 71 |
+
return {name: getattr(args, name) for name in
|
| 72 |
+
("resolution", "aspect_buckets", "aspect_mode", "aspect_tolerance")
|
| 73 |
+
if getattr(args, name) is not None}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def dispatch(args):
|
| 77 |
+
if args.command == "prepare":
|
| 78 |
+
return prepare(args.input, args.manifest, args.root, args.sidecar_text)
|
| 79 |
+
if args.command == "analyze":
|
| 80 |
+
return analyze(args.manifest, args.output, cli_config(args), args.workers,
|
| 81 |
+
args.rank, args.world_size, args.root, args.resume)
|
| 82 |
+
if args.command in {"merge", "filter"}:
|
| 83 |
+
return merge(args.shards, args.output, load_config(args.config) if args.config else None, **cli_overrides(args))
|
| 84 |
+
if args.command == "export":
|
| 85 |
+
return export_images(args.curated, args.output, args.workers, args.root, args.resume)
|
| 86 |
+
output = Path(args.output).resolve()
|
| 87 |
+
input_path = Path(args.input).resolve()
|
| 88 |
+
if input_path.is_dir() and output.is_relative_to(input_path):
|
| 89 |
+
raise ValueError("Output must be outside the source image directory")
|
| 90 |
+
config = cli_config(args)
|
| 91 |
+
manifest = output/"input.jsonl"
|
| 92 |
+
run_identity = {"input": str(input_path), "config": config.fingerprint(),
|
| 93 |
+
"root": str(Path(args.root).resolve()) if args.root else None,
|
| 94 |
+
"sidecar_text": args.sidecar_text}
|
| 95 |
+
from .io import write_json, exclusive_lock
|
| 96 |
+
with exclusive_lock(output/"run.lock"):
|
| 97 |
+
identity_path = output/"run.meta.json"
|
| 98 |
+
if identity_path.exists():
|
| 99 |
+
existing = json.loads(identity_path.read_text(encoding="utf-8"))
|
| 100 |
+
if not args.resume or existing != run_identity:
|
| 101 |
+
raise ValueError("Run exists or identity changed; use --resume with the same input/config")
|
| 102 |
+
else:
|
| 103 |
+
write_json(identity_path, run_identity)
|
| 104 |
+
if not manifest.exists():
|
| 105 |
+
prepare(args.input, manifest, args.root, args.sidecar_text)
|
| 106 |
+
analyze(manifest, output/"shards", config, args.workers, root=args.root, resume=args.resume)
|
| 107 |
+
if (output/"curated"/"curation.sqlite").exists():
|
| 108 |
+
if not args.resume:
|
| 109 |
+
raise ValueError("Run already merged; use --resume")
|
| 110 |
+
result = json.loads((output/"curated"/"report.json").read_text(encoding="utf-8"))
|
| 111 |
+
result = {k: result[k] for k in ["input_count", "kept_count", "rejected_count", "duplicate_count", "error_count"]}
|
| 112 |
+
else:
|
| 113 |
+
result = merge(output/"shards", output/"curated")
|
| 114 |
+
if args.export:
|
| 115 |
+
result["export"] = export_images(output/"curated", output/"normalized", args.workers, args.root, args.resume)
|
| 116 |
+
return result
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def main(argv=None):
|
| 120 |
+
args = parser().parse_args(argv)
|
| 121 |
+
try:
|
| 122 |
+
result = dispatch(args)
|
| 123 |
+
except (ValueError, OSError, sqlite3.Error) as exc:
|
| 124 |
+
print(f"Error: {exc}", file=sys.stderr)
|
| 125 |
+
return 2
|
| 126 |
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
| 127 |
+
if args.command == "export" and result.get("error", 0):
|
| 128 |
+
return 1
|
| 129 |
+
if args.command == "run" and result.get("export", {}).get("error", 0):
|
| 130 |
+
return 1
|
| 131 |
+
return 0
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
if __name__ == "__main__":
|
| 135 |
+
sys.exit(main())
|
image_cleaner/config.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import asdict, dataclass, fields
|
| 2 |
+
import hashlib
|
| 3 |
+
import json
|
| 4 |
+
import math
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
import re
|
| 7 |
+
|
| 8 |
+
ASPECT_BUCKETS = ("1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9", "21:9")
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def normalize_aspect_buckets(value):
|
| 12 |
+
if value is None:
|
| 13 |
+
return None
|
| 14 |
+
if isinstance(value, str):
|
| 15 |
+
value = value.strip().lower()
|
| 16 |
+
if value == "none":
|
| 17 |
+
return None
|
| 18 |
+
if value == "all":
|
| 19 |
+
return ASPECT_BUCKETS
|
| 20 |
+
value = [part.strip() for part in value.split(",")]
|
| 21 |
+
if not isinstance(value, (tuple, list)) or not value:
|
| 22 |
+
raise ValueError("aspect_buckets must be a nonempty list, comma-separated buckets, all, or none")
|
| 23 |
+
if any(not isinstance(bucket, str) or bucket not in ASPECT_BUCKETS for bucket in value):
|
| 24 |
+
raise ValueError(f"Unsupported aspect bucket; choose from {ASPECT_BUCKETS}")
|
| 25 |
+
return tuple(bucket for bucket in ASPECT_BUCKETS if bucket in value)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def normalize_resolution(value):
|
| 29 |
+
"""Np is an inclusive short-edge lower bound; WxH is exact oriented width and height."""
|
| 30 |
+
if value is None:
|
| 31 |
+
return None
|
| 32 |
+
if not isinstance(value, str):
|
| 33 |
+
raise ValueError("resolution must be a string such as 256p or 256x256, or null")
|
| 34 |
+
value = value.strip().lower().replace("\u00d7", "x")
|
| 35 |
+
if value in {"any", "none"}:
|
| 36 |
+
return None
|
| 37 |
+
if re.fullmatch(r"[1-9][0-9]*p", value) or re.fullmatch(r"[1-9][0-9]*x[1-9][0-9]*", value):
|
| 38 |
+
return value
|
| 39 |
+
raise ValueError("Invalid resolution: use 256p (short edge >=256), 256x256 (exact width/height), or any")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@dataclass(frozen=True)
|
| 43 |
+
class Config:
|
| 44 |
+
# None means measure only. Thresholds must be calibrated on the target corpus.
|
| 45 |
+
resolution: str | None = None
|
| 46 |
+
aspect_buckets: tuple[str, ...] | None = None
|
| 47 |
+
aspect_mode: str = "strict"
|
| 48 |
+
aspect_tolerance: float = 0.05
|
| 49 |
+
min_short_edge: int | None = None
|
| 50 |
+
max_long_edge: int | None = None
|
| 51 |
+
min_pixels: int | None = None
|
| 52 |
+
max_pixels: int | None = None
|
| 53 |
+
min_aspect_ratio: float | None = None
|
| 54 |
+
max_aspect_ratio: float | None = None
|
| 55 |
+
min_blur_score: float | None = None
|
| 56 |
+
max_noise_sigma: float | None = None
|
| 57 |
+
min_snr_proxy_db: float | None = None
|
| 58 |
+
max_blockiness: float | None = None
|
| 59 |
+
min_text_chars: int | None = None
|
| 60 |
+
max_text_chars: int | None = None
|
| 61 |
+
forbidden_text_patterns: tuple[str, ...] = ()
|
| 62 |
+
reject_animated: bool = True
|
| 63 |
+
max_decode_pixels: int = 100_000_000
|
| 64 |
+
metric_patch_size: int = 256
|
| 65 |
+
alpha_background: tuple[int, int, int] = (255, 255, 255)
|
| 66 |
+
convert_icc: bool = True
|
| 67 |
+
text_unicode_form: str = "NFC"
|
| 68 |
+
phash_distance: int = 4
|
| 69 |
+
dhash_distance: int = 4
|
| 70 |
+
near_rgb_rmse: float = 8.0
|
| 71 |
+
near_aspect_log_tolerance: float = 0.03
|
| 72 |
+
deduplicate: bool = True
|
| 73 |
+
near_deduplicate: bool = True
|
| 74 |
+
duplicate_scope: str = "image_text"
|
| 75 |
+
export_max_edge: int | None = None
|
| 76 |
+
export_format: str = "png"
|
| 77 |
+
export_jpeg_quality: int = 95
|
| 78 |
+
|
| 79 |
+
def __post_init__(self):
|
| 80 |
+
object.__setattr__(self, "resolution", normalize_resolution(self.resolution))
|
| 81 |
+
object.__setattr__(self, "aspect_buckets", normalize_aspect_buckets(self.aspect_buckets))
|
| 82 |
+
if self.aspect_mode not in {"strict", "loose"}:
|
| 83 |
+
raise ValueError("aspect_mode must be strict or loose")
|
| 84 |
+
numeric = [f.name for f in fields(self) if f.name.startswith(("min_", "max_"))]
|
| 85 |
+
numeric += ["near_rgb_rmse", "near_aspect_log_tolerance", "aspect_tolerance"]
|
| 86 |
+
for name in numeric:
|
| 87 |
+
value = getattr(self, name)
|
| 88 |
+
if value is not None and (isinstance(value, bool) or not isinstance(value, (int, float))
|
| 89 |
+
or not math.isfinite(value)):
|
| 90 |
+
raise ValueError(f"{name} must be a finite number or null")
|
| 91 |
+
if value is not None and name != "min_snr_proxy_db" and value < 0:
|
| 92 |
+
raise ValueError(f"{name} must be non-negative")
|
| 93 |
+
if self.aspect_tolerance is None:
|
| 94 |
+
raise ValueError("aspect_tolerance must be a finite non-negative number")
|
| 95 |
+
for lo, hi in [("min_aspect_ratio", "max_aspect_ratio"), ("min_pixels", "max_pixels"),
|
| 96 |
+
("min_text_chars", "max_text_chars")]:
|
| 97 |
+
if getattr(self, lo) is not None and getattr(self, hi) is not None:
|
| 98 |
+
if getattr(self, lo) > getattr(self, hi):
|
| 99 |
+
raise ValueError(f"{lo} must not exceed {hi}")
|
| 100 |
+
for name in ["min_short_edge", "max_long_edge", "min_pixels", "max_pixels",
|
| 101 |
+
"min_text_chars", "max_text_chars", "export_max_edge", "max_decode_pixels",
|
| 102 |
+
"metric_patch_size", "phash_distance", "dhash_distance", "export_jpeg_quality"]:
|
| 103 |
+
value = getattr(self, name)
|
| 104 |
+
if value is not None and (type(value) is not int or value < 0):
|
| 105 |
+
raise ValueError(f"{name} must be a non-negative integer")
|
| 106 |
+
if not 0 <= self.phash_distance <= 8 or not 0 <= self.dhash_distance <= 64:
|
| 107 |
+
raise ValueError("phash_distance must be 0..8; dhash_distance must be 0..64")
|
| 108 |
+
if self.metric_patch_size < 16 or self.max_decode_pixels < 1:
|
| 109 |
+
raise ValueError("metric_patch_size >= 16 and max_decode_pixels >= 1 required")
|
| 110 |
+
if self.export_max_edge is not None and self.export_max_edge < 1:
|
| 111 |
+
raise ValueError("export_max_edge must be positive")
|
| 112 |
+
if self.export_format not in {"png", "jpeg"} or not 1 <= self.export_jpeg_quality <= 100:
|
| 113 |
+
raise ValueError("export_format must be png/jpeg and JPEG quality 1..100")
|
| 114 |
+
if self.text_unicode_form not in {"NFC", "NFKC", "none"}:
|
| 115 |
+
raise ValueError("text_unicode_form must be NFC, NFKC or none")
|
| 116 |
+
if self.duplicate_scope not in {"image", "image_text"}:
|
| 117 |
+
raise ValueError("duplicate_scope must be image or image_text")
|
| 118 |
+
if len(self.alpha_background) != 3 or any(type(v) is not int or not 0 <= v <= 255
|
| 119 |
+
for v in self.alpha_background):
|
| 120 |
+
raise ValueError("alpha_background must contain three integers in 0..255")
|
| 121 |
+
for name in ["reject_animated", "convert_icc", "deduplicate", "near_deduplicate"]:
|
| 122 |
+
if type(getattr(self, name)) is not bool:
|
| 123 |
+
raise ValueError(f"{name} must be boolean")
|
| 124 |
+
for pattern in self.forbidden_text_patterns:
|
| 125 |
+
if not isinstance(pattern, str):
|
| 126 |
+
raise ValueError("forbidden_text_patterns must contain strings")
|
| 127 |
+
re.compile(pattern)
|
| 128 |
+
|
| 129 |
+
def to_dict(self):
|
| 130 |
+
return asdict(self)
|
| 131 |
+
|
| 132 |
+
def fingerprint(self):
|
| 133 |
+
return hashlib.sha256(json.dumps(self.to_dict(), sort_keys=True).encode()).hexdigest()
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def load_config(path=None):
|
| 137 |
+
values = {} if path is None else json.loads(Path(path).read_text(encoding="utf-8-sig"))
|
| 138 |
+
if not isinstance(values, dict):
|
| 139 |
+
raise ValueError("Configuration must be a JSON object")
|
| 140 |
+
unknown = set(values) - {f.name for f in fields(Config)}
|
| 141 |
+
if unknown:
|
| 142 |
+
raise ValueError(f"Unknown configuration fields: {sorted(unknown)}")
|
| 143 |
+
for key in ["alpha_background", "forbidden_text_patterns"]:
|
| 144 |
+
if key in values:
|
| 145 |
+
if not isinstance(values[key], list):
|
| 146 |
+
raise ValueError(f"{key} must be a JSON array")
|
| 147 |
+
values[key] = tuple(values[key])
|
| 148 |
+
return Config(**values)
|
image_cleaner/images.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from io import BytesIO
|
| 2 |
+
import hashlib
|
| 3 |
+
import re
|
| 4 |
+
import unicodedata
|
| 5 |
+
import warnings
|
| 6 |
+
|
| 7 |
+
from PIL import Image, ImageCms, ImageOps
|
| 8 |
+
|
| 9 |
+
from .config import Config
|
| 10 |
+
from .metrics import difference_hash, perceptual_hash, quality_metrics, thumbnail_signature
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def normalize_text(text, form):
|
| 14 |
+
if text is None:
|
| 15 |
+
return None
|
| 16 |
+
if not isinstance(text, str):
|
| 17 |
+
raise ValueError("text must be a string or null")
|
| 18 |
+
if form != "none":
|
| 19 |
+
text = unicodedata.normalize(form, text)
|
| 20 |
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
| 21 |
+
return "\n".join(line.rstrip() for line in text.split("\n")).strip()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def canonical_image(path, config):
|
| 25 |
+
"""Load one static image, transpose EXIF, color-manage, flatten alpha, return RGB."""
|
| 26 |
+
Image.MAX_IMAGE_PIXELS = config.max_decode_pixels
|
| 27 |
+
with warnings.catch_warnings():
|
| 28 |
+
warnings.simplefilter("error", Image.DecompressionBombWarning)
|
| 29 |
+
with Image.open(path) as source:
|
| 30 |
+
w, h = source.size
|
| 31 |
+
if w * h > config.max_decode_pixels:
|
| 32 |
+
raise ValueError("decode_pixel_limit")
|
| 33 |
+
metadata = {"original_width": w, "original_height": h, "original_mode": source.mode,
|
| 34 |
+
"original_format": source.format, "frame_count": getattr(source, "n_frames", 1)}
|
| 35 |
+
if metadata["frame_count"] > 1 and config.reject_animated:
|
| 36 |
+
raise ValueError("animated_or_multipage")
|
| 37 |
+
source.load() # Detect truncated data even if metadata parsing succeeded.
|
| 38 |
+
oriented = ImageOps.exif_transpose(source)
|
| 39 |
+
metadata["exif_transposed"] = source.getexif().get(274, 1) not in (1, None)
|
| 40 |
+
alpha = None
|
| 41 |
+
if "A" in oriented.getbands() or "transparency" in oriented.info:
|
| 42 |
+
alpha = oriented.convert("RGBA").getchannel("A")
|
| 43 |
+
metadata["alpha_flattened"] = alpha is not None
|
| 44 |
+
icc = oriented.info.get("icc_profile")
|
| 45 |
+
metadata["icc_converted"] = False
|
| 46 |
+
if icc and config.convert_icc:
|
| 47 |
+
# RGB profiles cannot consume RGBA/P directly; CMYK profiles require CMYK input.
|
| 48 |
+
mode = oriented.mode if oriented.mode in {"RGB", "CMYK", "LAB", "L"} else "RGB"
|
| 49 |
+
oriented = ImageCms.profileToProfile(oriented.convert(mode),
|
| 50 |
+
ImageCms.ImageCmsProfile(BytesIO(icc)), ImageCms.createProfile("sRGB"), outputMode="RGB")
|
| 51 |
+
metadata["icc_converted"] = True
|
| 52 |
+
rgb = oriented.convert("RGB")
|
| 53 |
+
if alpha is not None:
|
| 54 |
+
background = Image.new("RGB", rgb.size, config.alpha_background)
|
| 55 |
+
background.paste(rgb, mask=alpha)
|
| 56 |
+
rgb = background
|
| 57 |
+
return rgb.copy(), metadata
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def resolution_matches(row, resolution):
|
| 61 |
+
if resolution is None:
|
| 62 |
+
return True
|
| 63 |
+
if resolution.endswith("p"):
|
| 64 |
+
return row["short_edge"] >= int(resolution[:-1])
|
| 65 |
+
width, height = map(int, resolution.split("x"))
|
| 66 |
+
return row["width"] == width and row["height"] == height
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def filter_reasons(row, config):
|
| 70 |
+
from .aspect import assess_aspect
|
| 71 |
+
row.update(assess_aspect(row, config))
|
| 72 |
+
checks = [("short_edge", config.min_short_edge, "min_short_edge", "min"),
|
| 73 |
+
("long_edge", config.max_long_edge, "max_long_edge", "max"),
|
| 74 |
+
("pixels", config.min_pixels, "min_pixels", "min"),
|
| 75 |
+
("pixels", config.max_pixels, "max_pixels", "max"),
|
| 76 |
+
("aspect_ratio", config.min_aspect_ratio, "min_aspect_ratio", "min"),
|
| 77 |
+
("aspect_ratio", config.max_aspect_ratio, "max_aspect_ratio", "max"),
|
| 78 |
+
("blur_score", config.min_blur_score, "min_blur_score", "min"),
|
| 79 |
+
("noise_sigma", config.max_noise_sigma, "max_noise_sigma", "max"),
|
| 80 |
+
("snr_proxy_db", config.min_snr_proxy_db, "min_snr_proxy_db", "min"),
|
| 81 |
+
("blockiness", config.max_blockiness, "max_blockiness", "max")]
|
| 82 |
+
reasons = []
|
| 83 |
+
if not resolution_matches(row, config.resolution):
|
| 84 |
+
reasons.append("resolution_mismatch")
|
| 85 |
+
if row["aspect_bucket_match"] is False:
|
| 86 |
+
reasons.append("aspect_bucket_mismatch")
|
| 87 |
+
for key, threshold, name, direction in checks:
|
| 88 |
+
value = row.get(key)
|
| 89 |
+
if threshold is not None and value is not None:
|
| 90 |
+
if (direction == "min" and value < threshold) or (direction == "max" and value > threshold):
|
| 91 |
+
reasons.append(name)
|
| 92 |
+
text = row.get("text")
|
| 93 |
+
if config.min_text_chars is not None and len(text or "") < config.min_text_chars:
|
| 94 |
+
reasons.append("min_text_chars")
|
| 95 |
+
if config.max_text_chars is not None and len(text or "") > config.max_text_chars:
|
| 96 |
+
reasons.append("max_text_chars")
|
| 97 |
+
if any(re.search(p, text or "") for p in config.forbidden_text_patterns):
|
| 98 |
+
reasons.append("forbidden_text_pattern")
|
| 99 |
+
return reasons
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def analyze_one(task):
|
| 103 |
+
item, path, config_dict = task
|
| 104 |
+
config = Config(**config_dict)
|
| 105 |
+
row = dict(item)
|
| 106 |
+
row.update({"source_path": str(path), "status": "error", "reasons": [], "warnings": []})
|
| 107 |
+
try:
|
| 108 |
+
row["text"] = normalize_text(item.get("text"), config.text_unicode_form)
|
| 109 |
+
digest = hashlib.sha256()
|
| 110 |
+
with open(path, "rb") as handle:
|
| 111 |
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
| 112 |
+
digest.update(chunk)
|
| 113 |
+
row["file_sha256"] = digest.hexdigest()
|
| 114 |
+
rgb, metadata = canonical_image(path, config)
|
| 115 |
+
row.update(metadata)
|
| 116 |
+
w, h = rgb.size
|
| 117 |
+
row.update(width=w, height=h, short_edge=min(w, h), long_edge=max(w, h),
|
| 118 |
+
pixels=w*h, aspect_ratio=w/h)
|
| 119 |
+
pixel_hash = hashlib.sha256(f"RGB:{w}:{h}:".encode())
|
| 120 |
+
# Hash in strips to avoid making another full-size image bytes allocation.
|
| 121 |
+
for y in range(0, h, 128):
|
| 122 |
+
pixel_hash.update(rgb.crop((0, y, w, min(y+128, h))).tobytes())
|
| 123 |
+
row["pixel_sha256"] = pixel_hash.hexdigest()
|
| 124 |
+
row["phash"] = perceptual_hash(rgb)
|
| 125 |
+
row["dhash"] = difference_hash(rgb)
|
| 126 |
+
row["thumbnail"] = thumbnail_signature(rgb)
|
| 127 |
+
row.update(quality_metrics(rgb, config.metric_patch_size))
|
| 128 |
+
if row["snr_proxy_db"] is None:
|
| 129 |
+
row["warnings"].append("snr_proxy_undefined")
|
| 130 |
+
row["reasons"] = filter_reasons(row, config)
|
| 131 |
+
row["status"] = "rejected" if row["reasons"] else "candidate"
|
| 132 |
+
rgb.close()
|
| 133 |
+
except Exception as exc:
|
| 134 |
+
row["status"] = "error"
|
| 135 |
+
row["reasons"] = ["decode_or_processing_error"]
|
| 136 |
+
row["error"] = f"{type(exc).__name__}: {exc}"
|
| 137 |
+
return row
|
image_cleaner/io.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from concurrent.futures import FIRST_COMPLETED, ProcessPoolExecutor, wait
|
| 2 |
+
from contextlib import contextmanager
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
import sqlite3
|
| 8 |
+
import tempfile
|
| 9 |
+
|
| 10 |
+
EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff", ".gif", ".avif"}
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def json_line(value):
|
| 14 |
+
return json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":")) + "\n"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def sha256_file(path):
|
| 18 |
+
digest = hashlib.sha256()
|
| 19 |
+
with open(path, "rb") as handle:
|
| 20 |
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
| 21 |
+
digest.update(chunk)
|
| 22 |
+
return digest.hexdigest()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@contextmanager
|
| 26 |
+
def atomic_text(path):
|
| 27 |
+
path = Path(path)
|
| 28 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 29 |
+
fd, temporary = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=path.parent)
|
| 30 |
+
try:
|
| 31 |
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
| 32 |
+
yield handle
|
| 33 |
+
handle.flush()
|
| 34 |
+
os.fsync(handle.fileno())
|
| 35 |
+
os.replace(temporary, path)
|
| 36 |
+
finally:
|
| 37 |
+
if os.path.exists(temporary):
|
| 38 |
+
os.unlink(temporary)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def write_json(path, value):
|
| 42 |
+
with atomic_text(path) as handle:
|
| 43 |
+
json.dump(value, handle, ensure_ascii=False, allow_nan=False, indent=2)
|
| 44 |
+
handle.write("\n")
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def walk_images(root):
|
| 48 |
+
# Stable traversal independent of filesystem enumeration order; directory lists only.
|
| 49 |
+
for directory, dirs, files in os.walk(root, followlinks=False):
|
| 50 |
+
dirs[:] = sorted(d for d in dirs if not (Path(directory)/d).is_symlink())
|
| 51 |
+
for name in sorted(files):
|
| 52 |
+
path = Path(directory)/name
|
| 53 |
+
if path.suffix.lower() in EXTENSIONS:
|
| 54 |
+
yield path
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def prepare(input_path, manifest, root=None, sidecar_text=False):
|
| 58 |
+
input_path, manifest = Path(input_path).resolve(), Path(manifest).resolve()
|
| 59 |
+
if not input_path.exists():
|
| 60 |
+
raise ValueError(f"Input does not exist: {input_path}")
|
| 61 |
+
if input_path == manifest:
|
| 62 |
+
raise ValueError("Input and output manifest must differ")
|
| 63 |
+
if manifest.exists():
|
| 64 |
+
raise ValueError(f"Manifest already exists: {manifest}; choose a new path")
|
| 65 |
+
base = Path(root).resolve() if root else (input_path if input_path.is_dir() else input_path.parent)
|
| 66 |
+
# Output inside the input tree can be accidentally re-imported on subsequent runs.
|
| 67 |
+
if input_path.is_dir() and manifest.is_relative_to(input_path):
|
| 68 |
+
raise ValueError("Place the manifest outside the input image directory")
|
| 69 |
+
count = 0
|
| 70 |
+
if input_path.is_dir():
|
| 71 |
+
def entries():
|
| 72 |
+
for path in walk_images(input_path):
|
| 73 |
+
try:
|
| 74 |
+
relative = path.relative_to(base).as_posix()
|
| 75 |
+
except ValueError as exc:
|
| 76 |
+
raise ValueError("All discovered images must be beneath --root") from exc
|
| 77 |
+
item = {"path": relative}
|
| 78 |
+
txt = path.with_suffix(".txt")
|
| 79 |
+
if sidecar_text and txt.exists():
|
| 80 |
+
item["text"] = txt.read_text(encoding="utf-8-sig")
|
| 81 |
+
yield item
|
| 82 |
+
else:
|
| 83 |
+
def entries():
|
| 84 |
+
with input_path.open(encoding="utf-8-sig") as handle:
|
| 85 |
+
for line_no, line in enumerate(handle, 1):
|
| 86 |
+
if not line.strip():
|
| 87 |
+
continue
|
| 88 |
+
try:
|
| 89 |
+
item = json.loads(line)
|
| 90 |
+
except json.JSONDecodeError as exc:
|
| 91 |
+
raise ValueError(f"Invalid JSON at input line {line_no}") from exc
|
| 92 |
+
if not isinstance(item, dict) or not isinstance(item.get("path"), str):
|
| 93 |
+
raise ValueError(f"Input line {line_no} requires a string path")
|
| 94 |
+
if item.get("text") is not None and not isinstance(item["text"], str):
|
| 95 |
+
raise ValueError(f"Input line {line_no}: text must be string/null")
|
| 96 |
+
if "metadata" in item and not isinstance(item["metadata"], dict):
|
| 97 |
+
raise ValueError(f"Input line {line_no}: metadata must be an object")
|
| 98 |
+
# Preserve caption identity and arbitrary fields inside metadata to avoid
|
| 99 |
+
# collisions with computed metrics (e.g. an input field named width).
|
| 100 |
+
known = {"path", "text", "id", "metadata"}
|
| 101 |
+
metadata = dict(item.get("metadata", {}))
|
| 102 |
+
metadata.update({k: v for k, v in item.items() if k not in known})
|
| 103 |
+
yield {"path": item["path"], "text": item.get("text"),
|
| 104 |
+
"source_id": item.get("id"), "metadata": metadata}
|
| 105 |
+
with atomic_text(manifest) as handle:
|
| 106 |
+
for item in entries():
|
| 107 |
+
item["seq"] = count
|
| 108 |
+
item["id"] = f"sample-{count:012d}"
|
| 109 |
+
handle.write(json_line(item))
|
| 110 |
+
count += 1
|
| 111 |
+
if count == 0:
|
| 112 |
+
manifest.unlink()
|
| 113 |
+
raise ValueError("No images or manifest records found")
|
| 114 |
+
metadata = {"schema_version": 1, "count": count, "root": str(base),
|
| 115 |
+
"sha256": sha256_file(manifest)}
|
| 116 |
+
write_json(str(manifest) + ".meta.json", metadata)
|
| 117 |
+
return metadata
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def manifest_info(manifest, root=None):
|
| 121 |
+
manifest = Path(manifest).resolve()
|
| 122 |
+
metadata = json.loads(Path(str(manifest) + ".meta.json").read_text(encoding="utf-8"))
|
| 123 |
+
if metadata["sha256"] != sha256_file(manifest):
|
| 124 |
+
raise ValueError("Manifest has changed after prepare; prepare a new manifest")
|
| 125 |
+
base = Path(root or metadata["root"]).resolve()
|
| 126 |
+
return metadata, base
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def manifest_items(manifest):
|
| 130 |
+
with Path(manifest).open(encoding="utf-8") as handle:
|
| 131 |
+
for line in handle:
|
| 132 |
+
yield json.loads(line)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def resolve_source(base, item):
|
| 136 |
+
path = Path(item["path"])
|
| 137 |
+
return path if path.is_absolute() else base/path
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
@contextmanager
|
| 141 |
+
def exclusive_lock(path):
|
| 142 |
+
path = Path(path)
|
| 143 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 144 |
+
try:
|
| 145 |
+
descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
| 146 |
+
except FileExistsError as exc:
|
| 147 |
+
raise ValueError(f"Job is locked: {path}. If no job is running, remove its stale lock.") from exc
|
| 148 |
+
try:
|
| 149 |
+
os.write(descriptor, str(os.getpid()).encode())
|
| 150 |
+
os.close(descriptor)
|
| 151 |
+
yield
|
| 152 |
+
finally:
|
| 153 |
+
path.unlink(missing_ok=True)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def connect(path):
|
| 157 |
+
db = sqlite3.connect(path)
|
| 158 |
+
db.execute("PRAGMA journal_mode=WAL")
|
| 159 |
+
db.execute("PRAGMA synchronous=NORMAL")
|
| 160 |
+
db.execute("PRAGMA cache_size=-32768") # 32 MiB bounded page cache.
|
| 161 |
+
db.execute("PRAGMA temp_store=FILE")
|
| 162 |
+
return db
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def get_meta(db, key):
|
| 166 |
+
row = db.execute("SELECT value FROM metadata WHERE key=?", (key,)).fetchone()
|
| 167 |
+
return json.loads(row[0]) if row else None
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def set_meta(db, key, value):
|
| 171 |
+
db.execute("INSERT OR REPLACE INTO metadata VALUES (?,?)", (key, json.dumps(value)))
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def parallel_bounded(function, iterable, workers):
|
| 175 |
+
"""At most 2*workers futures in memory, unlike eager Executor.map in Python <=3.13."""
|
| 176 |
+
if workers == 1:
|
| 177 |
+
yield from map(function, iterable)
|
| 178 |
+
return
|
| 179 |
+
iterator = iter(iterable)
|
| 180 |
+
with ProcessPoolExecutor(max_workers=workers) as pool:
|
| 181 |
+
pending = set()
|
| 182 |
+
exhausted = False
|
| 183 |
+
while pending or not exhausted:
|
| 184 |
+
while not exhausted and len(pending) < 2 * workers:
|
| 185 |
+
try:
|
| 186 |
+
pending.add(pool.submit(function, next(iterator)))
|
| 187 |
+
except StopIteration:
|
| 188 |
+
exhausted = True
|
| 189 |
+
if pending:
|
| 190 |
+
done, pending = wait(pending, return_when=FIRST_COMPLETED)
|
| 191 |
+
for future in done:
|
| 192 |
+
yield future.result()
|
image_cleaner/jobs.py
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from collections import Counter
|
| 2 |
+
from contextlib import closing
|
| 3 |
+
from dataclasses import replace
|
| 4 |
+
import hashlib
|
| 5 |
+
import json
|
| 6 |
+
import math
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
import sqlite3
|
| 9 |
+
import time
|
| 10 |
+
from PIL import Image
|
| 11 |
+
|
| 12 |
+
from . import __version__
|
| 13 |
+
from .config import Config
|
| 14 |
+
from .images import analyze_one, canonical_image, filter_reasons, resolution_matches
|
| 15 |
+
from .io import (atomic_text, connect, exclusive_lock, get_meta, json_line, manifest_info,
|
| 16 |
+
manifest_items, parallel_bounded, resolve_source, set_meta, sha256_file, write_json)
|
| 17 |
+
from .metrics import thumbnail_rmse
|
| 18 |
+
from .stats import DatasetStats, write_html
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def analyze(manifest, output, config, workers=1, rank=0, world_size=1, root=None, resume=False):
|
| 22 |
+
if workers < 1 or world_size < 1 or not 0 <= rank < world_size:
|
| 23 |
+
raise ValueError("workers/world-size must be positive and 0 <= rank < world-size")
|
| 24 |
+
info, base = manifest_info(manifest, root)
|
| 25 |
+
output = Path(output).resolve()
|
| 26 |
+
output.mkdir(parents=True, exist_ok=True)
|
| 27 |
+
path = output/f"shard-{rank:05d}-of-{world_size:05d}.sqlite"
|
| 28 |
+
expected_count = max(0, (info["count"] - 1 - rank) // world_size + 1)
|
| 29 |
+
identity = {"schema_version": 1, "software_version": __version__, "manifest_sha256": info["sha256"],
|
| 30 |
+
"input_count": info["count"], "rank": rank, "world_size": world_size,
|
| 31 |
+
"config_sha256": config.fingerprint(), "config": config.to_dict()}
|
| 32 |
+
# JSON normalizes tuple/list values for consistent equality after restart.
|
| 33 |
+
identity = json.loads(json.dumps(identity))
|
| 34 |
+
with exclusive_lock(str(path) + ".lock"):
|
| 35 |
+
exists = path.exists()
|
| 36 |
+
if exists and not resume:
|
| 37 |
+
raise ValueError(f"Shard already exists: {path}; use --resume")
|
| 38 |
+
db = connect(path)
|
| 39 |
+
try:
|
| 40 |
+
db.execute("CREATE TABLE IF NOT EXISTS metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
|
| 41 |
+
db.execute("CREATE TABLE IF NOT EXISTS records (seq INTEGER PRIMARY KEY, payload TEXT NOT NULL)")
|
| 42 |
+
stored = get_meta(db, "identity")
|
| 43 |
+
if exists and stored != identity:
|
| 44 |
+
raise ValueError("Resume identity mismatch (manifest, config, ranks or software changed)")
|
| 45 |
+
set_meta(db, "identity", identity)
|
| 46 |
+
set_meta(db, "complete", False)
|
| 47 |
+
db.commit()
|
| 48 |
+
config_dict = config.to_dict()
|
| 49 |
+
def tasks():
|
| 50 |
+
for item in manifest_items(manifest):
|
| 51 |
+
if item["seq"] % world_size != rank:
|
| 52 |
+
continue
|
| 53 |
+
if db.execute("SELECT 1 FROM records WHERE seq=?", (item["seq"],)).fetchone():
|
| 54 |
+
continue
|
| 55 |
+
yield item, str(resolve_source(base, item)), config_dict
|
| 56 |
+
count = 0
|
| 57 |
+
started = time.monotonic()
|
| 58 |
+
for row in parallel_bounded(analyze_one, tasks(), workers):
|
| 59 |
+
db.execute("INSERT INTO records VALUES (?,?)", (row["seq"], json_line(row)))
|
| 60 |
+
count += 1
|
| 61 |
+
if count % 100 == 0:
|
| 62 |
+
db.commit()
|
| 63 |
+
print(f"rank {rank}: analyzed {count} new images ({count/max(.01, time.monotonic()-started):.1f}/s)", flush=True)
|
| 64 |
+
db.commit()
|
| 65 |
+
total = db.execute("SELECT COUNT(*) FROM records").fetchone()[0]
|
| 66 |
+
if total != expected_count:
|
| 67 |
+
raise ValueError(f"Shard incomplete: expected {expected_count}, got {total}")
|
| 68 |
+
set_meta(db, "complete", True)
|
| 69 |
+
db.commit()
|
| 70 |
+
with atomic_text(path.with_suffix(".jsonl")) as handle:
|
| 71 |
+
for (payload,) in db.execute("SELECT payload FROM records ORDER BY seq"):
|
| 72 |
+
handle.write(payload)
|
| 73 |
+
db.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
| 74 |
+
finally:
|
| 75 |
+
db.close()
|
| 76 |
+
return {"shard": str(path), "records": total, "new_records": count}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def hash_bands(value, radius):
|
| 80 |
+
"""Pigeonhole guarantee: hashes at distance <=r share one of r+1 exact bands."""
|
| 81 |
+
integer = int(value, 16)
|
| 82 |
+
parts = radius+1
|
| 83 |
+
offset = 0
|
| 84 |
+
for part in range(parts):
|
| 85 |
+
width = 64//parts + (1 if part < 64 % parts else 0)
|
| 86 |
+
yield part, f"{(integer >> offset) & ((1 << width)-1):x}"
|
| 87 |
+
offset += width
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class DuplicateIndex:
|
| 91 |
+
"""Disk-backed global index; only eligible retained representatives enter it."""
|
| 92 |
+
def __init__(self, db, config):
|
| 93 |
+
self.db, self.config = db, config
|
| 94 |
+
db.execute("CREATE TABLE hashes (kind TEXT, hash TEXT, owner INTEGER, PRIMARY KEY(kind,hash))")
|
| 95 |
+
db.execute("CREATE TABLE bands (part INTEGER, value TEXT, owner INTEGER)")
|
| 96 |
+
db.execute("CREATE INDEX band_lookup ON bands(part,value)")
|
| 97 |
+
db.execute("CREATE TABLE representatives (seq INTEGER PRIMARY KEY, payload TEXT NOT NULL)")
|
| 98 |
+
|
| 99 |
+
def find(self, row):
|
| 100 |
+
if not self.config.deduplicate:
|
| 101 |
+
return None
|
| 102 |
+
for kind, key in [("file", "file_sha256"), ("pixels", "pixel_sha256")]:
|
| 103 |
+
result = self.db.execute("SELECT owner FROM hashes WHERE kind=? AND hash=?", (kind, self.key(row, key))).fetchone()
|
| 104 |
+
if result:
|
| 105 |
+
return result[0], "exact_file" if kind == "file" else "exact_pixels", None
|
| 106 |
+
if not self.config.near_deduplicate:
|
| 107 |
+
return None
|
| 108 |
+
bands = list(hash_bands(row["phash"], self.config.phash_distance))
|
| 109 |
+
predicates = " OR ".join("(part=? AND value=?)" for _ in bands)
|
| 110 |
+
args = [v for band in bands for v in band]
|
| 111 |
+
query = ("SELECT r.seq,r.payload FROM representatives r JOIN "
|
| 112 |
+
f"(SELECT DISTINCT owner FROM bands WHERE {predicates}) b ON r.seq=b.owner ORDER BY r.seq")
|
| 113 |
+
# Stream candidates from SQLite; repetitive corpora can produce large candidate sets.
|
| 114 |
+
for seq, payload in self.db.execute(query, args):
|
| 115 |
+
other = json.loads(payload)
|
| 116 |
+
if self.config.duplicate_scope == "image_text" and (row.get("text") or "") != (other.get("text") or ""):
|
| 117 |
+
continue
|
| 118 |
+
distance = (int(row["phash"], 16) ^ int(other["phash"], 16)).bit_count()
|
| 119 |
+
if distance > self.config.phash_distance:
|
| 120 |
+
continue
|
| 121 |
+
if (int(row["dhash"], 16) ^ int(other["dhash"], 16)).bit_count() > self.config.dhash_distance:
|
| 122 |
+
continue
|
| 123 |
+
if abs(math.log(row["aspect_ratio"]/other["aspect_ratio"])) > self.config.near_aspect_log_tolerance:
|
| 124 |
+
continue
|
| 125 |
+
rmse = thumbnail_rmse(row["thumbnail"], other["thumbnail"])
|
| 126 |
+
if rmse <= self.config.near_rgb_rmse:
|
| 127 |
+
return seq, "near_perceptual", {"phash_distance": distance, "thumbnail_rgb_rmse": rmse}
|
| 128 |
+
return None
|
| 129 |
+
|
| 130 |
+
def insert(self, row):
|
| 131 |
+
if not self.config.deduplicate:
|
| 132 |
+
return
|
| 133 |
+
for kind, key in [("file", "file_sha256"), ("pixels", "pixel_sha256")]:
|
| 134 |
+
self.db.execute("INSERT INTO hashes VALUES (?,?,?)", (kind, self.key(row, key), row["seq"]))
|
| 135 |
+
if self.config.near_deduplicate:
|
| 136 |
+
self.db.execute("INSERT INTO representatives VALUES (?,?)", (row["seq"], json_line(row)))
|
| 137 |
+
self.db.executemany("INSERT INTO bands VALUES (?,?,?)", [(p, value, row["seq"])
|
| 138 |
+
for p, value in hash_bands(row["phash"], self.config.phash_distance)])
|
| 139 |
+
|
| 140 |
+
def key(self, row, key):
|
| 141 |
+
if self.config.duplicate_scope == "image":
|
| 142 |
+
return row[key]
|
| 143 |
+
text_hash = hashlib.sha256((row.get("text") or "").encode("utf-8")).hexdigest()
|
| 144 |
+
return row[key] + ":" + text_hash
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
ANALYSIS_FIELDS = ["reject_animated", "max_decode_pixels", "metric_patch_size", "alpha_background",
|
| 148 |
+
"convert_icc", "text_unicode_form"]
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def merge(shard_dir, output, config=None, resolution=None, aspect_buckets=None, aspect_mode=None, aspect_tolerance=None):
|
| 152 |
+
shard_dir, output = Path(shard_dir).resolve(), Path(output).resolve()
|
| 153 |
+
paths = sorted(shard_dir.glob("shard-*-of-*.sqlite"))
|
| 154 |
+
if not paths:
|
| 155 |
+
raise ValueError("No shard SQLite databases found")
|
| 156 |
+
output.mkdir(parents=True, exist_ok=True)
|
| 157 |
+
destination = output/"curation.sqlite"
|
| 158 |
+
with exclusive_lock(output/"merge.lock"):
|
| 159 |
+
if destination.exists():
|
| 160 |
+
raise ValueError("Merge output already exists; choose a new output directory to re-filter")
|
| 161 |
+
identities = []
|
| 162 |
+
for path in paths:
|
| 163 |
+
with closing(sqlite3.connect(path.as_uri() + "?mode=ro", uri=True)) as source:
|
| 164 |
+
identity = get_meta(source, "identity")
|
| 165 |
+
if not get_meta(source, "complete"):
|
| 166 |
+
raise ValueError(f"Shard is incomplete: {path}")
|
| 167 |
+
identities.append(identity)
|
| 168 |
+
first = identities[0]
|
| 169 |
+
if any({k: v for k, v in identity.items() if k != "rank"} !=
|
| 170 |
+
{k: v for k, v in first.items() if k != "rank"} for identity in identities):
|
| 171 |
+
raise ValueError("Shard identities do not match")
|
| 172 |
+
if sorted(i["rank"] for i in identities) != list(range(first["world_size"])):
|
| 173 |
+
raise ValueError("Missing or duplicate shard ranks; all ranks are required for global deduplication")
|
| 174 |
+
source_config = Config(**first["config"])
|
| 175 |
+
config = config or source_config
|
| 176 |
+
overrides = {name: value for name, value in
|
| 177 |
+
(("resolution", resolution), ("aspect_buckets", aspect_buckets),
|
| 178 |
+
("aspect_mode", aspect_mode), ("aspect_tolerance", aspect_tolerance)) if value is not None}
|
| 179 |
+
config = replace(config, **overrides)
|
| 180 |
+
for key in ANALYSIS_FIELDS:
|
| 181 |
+
if json.dumps(getattr(config, key)) != json.dumps(getattr(source_config, key)):
|
| 182 |
+
raise ValueError(f"Cannot re-filter with changed analysis setting {key}; analyze again")
|
| 183 |
+
temporary = output/"curation.building.sqlite"
|
| 184 |
+
if temporary.exists():
|
| 185 |
+
raise ValueError("Previous incomplete merge exists; choose a new output directory")
|
| 186 |
+
db = connect(temporary)
|
| 187 |
+
try:
|
| 188 |
+
db.execute("CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
|
| 189 |
+
db.execute("CREATE TABLE records (seq INTEGER PRIMARY KEY, payload TEXT NOT NULL, status TEXT NOT NULL,"
|
| 190 |
+
"pixels INTEGER NOT NULL, noise REAL NOT NULL, blur REAL NOT NULL)")
|
| 191 |
+
for path, identity in zip(paths, identities):
|
| 192 |
+
shard_count = 0
|
| 193 |
+
with closing(sqlite3.connect(path.as_uri() + "?mode=ro", uri=True)) as source:
|
| 194 |
+
for seq, payload in source.execute("SELECT seq,payload FROM records ORDER BY seq"):
|
| 195 |
+
row = json.loads(payload)
|
| 196 |
+
if seq != row["seq"] or seq % first["world_size"] != identity["rank"] or not 0 <= seq < first["input_count"]:
|
| 197 |
+
raise ValueError(f"Invalid shard sequence: {path}, {seq}")
|
| 198 |
+
if row["status"] != "error":
|
| 199 |
+
row["reasons"] = filter_reasons(row, config)
|
| 200 |
+
row["status"] = "rejected" if row["reasons"] else "candidate"
|
| 201 |
+
db.execute("INSERT INTO records VALUES (?,?,?,?,?,?)", (seq, json_line(row), row["status"],
|
| 202 |
+
row.get("pixels", 0), row.get("noise_sigma") or 0, row.get("blur_score") or 0))
|
| 203 |
+
shard_count += 1
|
| 204 |
+
if shard_count % 1000 == 0:
|
| 205 |
+
db.commit()
|
| 206 |
+
expected = max(0, (first["input_count"]-1-identity["rank"])//first["world_size"]+1)
|
| 207 |
+
if shard_count != expected:
|
| 208 |
+
raise ValueError(f"Wrong record count in {path}: {shard_count}, expected {expected}")
|
| 209 |
+
total = db.execute("SELECT COUNT(*) FROM records").fetchone()[0]
|
| 210 |
+
if total != first["input_count"]:
|
| 211 |
+
raise ValueError("Global record count does not match manifest")
|
| 212 |
+
db.execute("CREATE INDEX candidate_order ON records(status,pixels DESC,noise,blur DESC,seq)")
|
| 213 |
+
db.commit()
|
| 214 |
+
index = DuplicateIndex(db, config)
|
| 215 |
+
processed = 0
|
| 216 |
+
# Fetch bounded batches, then update status. The indexed candidate set shrinks.
|
| 217 |
+
while True:
|
| 218 |
+
batch = db.execute("SELECT seq,payload,pixels,noise,blur FROM records WHERE status='candidate' "
|
| 219 |
+
"ORDER BY pixels DESC,noise,blur DESC,seq LIMIT 500").fetchall()
|
| 220 |
+
if not batch:
|
| 221 |
+
break
|
| 222 |
+
for seq, payload, pixels, noise, blur in batch:
|
| 223 |
+
row = json.loads(payload)
|
| 224 |
+
duplicate = index.find(row)
|
| 225 |
+
if duplicate:
|
| 226 |
+
owner, kind, metrics = duplicate
|
| 227 |
+
row.update(status="duplicate", duplicate_of=f"sample-{owner:012d}",
|
| 228 |
+
duplicate_kind=kind, duplicate_metrics=metrics, reasons=[kind])
|
| 229 |
+
else:
|
| 230 |
+
row["status"] = "kept"
|
| 231 |
+
index.insert(row)
|
| 232 |
+
db.execute("UPDATE records SET payload=?,status=? WHERE seq=?", (json_line(row), row["status"], seq))
|
| 233 |
+
processed += 1
|
| 234 |
+
db.commit()
|
| 235 |
+
print(f"global dedup: {processed} eligible images processed", flush=True)
|
| 236 |
+
before, after = DatasetStats(), DatasetStats()
|
| 237 |
+
resolution_matched = 0
|
| 238 |
+
aspect_matched = 0
|
| 239 |
+
bucket_before = dict.fromkeys(config.aspect_buckets or (), 0)
|
| 240 |
+
bucket_after = dict.fromkeys(config.aspect_buckets or (), 0)
|
| 241 |
+
destinations = {status: output/f"{status}.jsonl" for status in ["kept", "rejected", "duplicate", "error"]}
|
| 242 |
+
# SQLite is the authoritative output. JSONL and reports are streamed from it.
|
| 243 |
+
from contextlib import ExitStack
|
| 244 |
+
with ExitStack() as stack:
|
| 245 |
+
handles = {status: stack.enter_context(atomic_text(path)) for status, path in destinations.items()}
|
| 246 |
+
for (payload,) in db.execute("SELECT payload FROM records ORDER BY seq"):
|
| 247 |
+
row = json.loads(payload)
|
| 248 |
+
before.add(row)
|
| 249 |
+
if row["status"] != "error":
|
| 250 |
+
if row["aspect_bucket_match"] is not False:
|
| 251 |
+
aspect_matched += 1
|
| 252 |
+
if row["aspect_bucket"] is not None:
|
| 253 |
+
bucket_before[row["aspect_bucket"]] += 1
|
| 254 |
+
if row["status"] == "kept":
|
| 255 |
+
bucket_after[row["aspect_bucket"]] += 1
|
| 256 |
+
if row["status"] != "error" and resolution_matches(row, config.resolution):
|
| 257 |
+
resolution_matched += 1
|
| 258 |
+
if row["status"] == "kept":
|
| 259 |
+
after.add(row)
|
| 260 |
+
handles[row["status"]].write(payload)
|
| 261 |
+
counts = before.statuses
|
| 262 |
+
decoded = before.decoded
|
| 263 |
+
eligible = counts["kept"] + counts["duplicate"]
|
| 264 |
+
def rate(numerator, denominator):
|
| 265 |
+
return numerator / denominator if denominator else None
|
| 266 |
+
stage_metrics = {
|
| 267 |
+
"processing_success_count": decoded,
|
| 268 |
+
"processing_success_rate": rate(decoded, total),
|
| 269 |
+
"resolution_matched_count": resolution_matched,
|
| 270 |
+
"resolution_mismatched_count": decoded-resolution_matched,
|
| 271 |
+
"resolution_pass_rate": rate(resolution_matched, decoded),
|
| 272 |
+
"aspect_bucket_matched_count": aspect_matched,
|
| 273 |
+
"aspect_bucket_mismatched_count": decoded-aspect_matched,
|
| 274 |
+
"aspect_bucket_pass_rate": rate(aspect_matched, decoded),
|
| 275 |
+
"rules_eligible_count": eligible,
|
| 276 |
+
"rules_pass_rate": rate(eligible, decoded),
|
| 277 |
+
"duplicate_rate_among_eligible": rate(counts["duplicate"], eligible),
|
| 278 |
+
"final_keep_rate": rate(counts["kept"], total),
|
| 279 |
+
}
|
| 280 |
+
report = {"schema_version": 1, "software_version": __version__, "manifest_sha256": first["manifest_sha256"],
|
| 281 |
+
"config": config.to_dict(), "input_count": total, "kept_count": counts["kept"],
|
| 282 |
+
"rejected_count": counts["rejected"], "duplicate_count": counts["duplicate"], "error_count": counts["error"],
|
| 283 |
+
"dedup_representative_order": "pixels DESC, noise_sigma ASC, blur_score DESC, seq ASC",
|
| 284 |
+
"resolution_filter": {"requested": config.resolution, "basis": "dimensions_after_exif_transpose",
|
| 285 |
+
"mode": "short_edge_gte" if config.resolution and config.resolution.endswith("p")
|
| 286 |
+
else "width_height" if config.resolution else "disabled",
|
| 287 |
+
"comparison": ">=" if config.resolution and config.resolution.endswith("p")
|
| 288 |
+
else "==" if config.resolution else None},
|
| 289 |
+
"stage_metrics": stage_metrics,
|
| 290 |
+
"aspect_bucket_filter": {"enabled": config.aspect_buckets is not None,
|
| 291 |
+
"selected_buckets": config.aspect_buckets or (),
|
| 292 |
+
"mode": config.aspect_mode, "tolerance": config.aspect_tolerance,
|
| 293 |
+
"basis": "dimensions_after_exif_transpose",
|
| 294 |
+
"deviation": "max(r/b,b/r)-1",
|
| 295 |
+
"counts_before": bucket_before, "counts_after": bucket_after},
|
| 296 |
+
"before": before.result(), "after": after.result()}
|
| 297 |
+
set_meta(db, "report", report)
|
| 298 |
+
set_meta(db, "complete", True)
|
| 299 |
+
db.commit()
|
| 300 |
+
db.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
| 301 |
+
# Leave no WAL dependency when publishing the database.
|
| 302 |
+
db.execute("PRAGMA journal_mode=DELETE")
|
| 303 |
+
finally:
|
| 304 |
+
db.close()
|
| 305 |
+
temporary.replace(destination)
|
| 306 |
+
write_json(output/"report.json", report)
|
| 307 |
+
write_html(output/"report.html", report)
|
| 308 |
+
return {k: report[k] for k in ["input_count", "kept_count", "rejected_count", "duplicate_count", "error_count"]}
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def export_images(curated_dir, output, workers=1, root=None, resume=False):
|
| 312 |
+
curated_dir, output = Path(curated_dir).resolve(), Path(output).resolve()
|
| 313 |
+
if workers < 1:
|
| 314 |
+
raise ValueError("workers must be positive")
|
| 315 |
+
output.mkdir(parents=True, exist_ok=True)
|
| 316 |
+
report = json.loads((curated_dir/"report.json").read_text(encoding="utf-8"))
|
| 317 |
+
config = Config(**report["config"])
|
| 318 |
+
fingerprint = hashlib.sha256(json.dumps({"manifest": report["manifest_sha256"], "config": config.to_dict()},
|
| 319 |
+
sort_keys=True).encode()).hexdigest()
|
| 320 |
+
with exclusive_lock(output/"export.lock"):
|
| 321 |
+
meta_path = output/"export.meta.json"
|
| 322 |
+
if meta_path.exists():
|
| 323 |
+
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
| 324 |
+
if not resume or meta["fingerprint"] != fingerprint:
|
| 325 |
+
raise ValueError("Export already exists or configuration differs; choose a new output directory")
|
| 326 |
+
elif any(output.iterdir()):
|
| 327 |
+
# Only the lock is present for a new destination.
|
| 328 |
+
if any(p.name != "export.lock" for p in output.iterdir()):
|
| 329 |
+
raise ValueError("Export destination must be empty")
|
| 330 |
+
write_json(meta_path, {"fingerprint": fingerprint, "complete": False})
|
| 331 |
+
def tasks():
|
| 332 |
+
for item in manifest_items(curated_dir/"kept.jsonl"):
|
| 333 |
+
path = resolve_source(Path(root).resolve(), item) if root else Path(item["source_path"])
|
| 334 |
+
target = output/"images"/f'{item["seq"]//10000:06d}'/(item["id"] + "." + ("jpg" if config.export_format == "jpeg" else "png"))
|
| 335 |
+
yield item, str(path), str(target), config.to_dict(), resume
|
| 336 |
+
counts = Counter()
|
| 337 |
+
with atomic_text(output/"exported.jsonl") as retained, atomic_text(output/"export_errors.jsonl") as errors:
|
| 338 |
+
for result in parallel_bounded(export_one, tasks(), workers):
|
| 339 |
+
status = result.pop("export_status")
|
| 340 |
+
counts[status] += 1
|
| 341 |
+
if status == "ok":
|
| 342 |
+
result["source_path"] = result.pop("path")
|
| 343 |
+
result["path"] = Path(result.pop("output_path")).relative_to(output).as_posix()
|
| 344 |
+
retained.write(json_line(result))
|
| 345 |
+
else:
|
| 346 |
+
errors.write(json_line(result))
|
| 347 |
+
write_json(meta_path, {"fingerprint": fingerprint, "complete": counts["error"] == 0,
|
| 348 |
+
"exported": counts["ok"], "errors": counts["error"]})
|
| 349 |
+
return dict(counts)
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def export_one(task):
|
| 353 |
+
item, source, target, config_dict, resume = task
|
| 354 |
+
result = {"id": item["id"], "path": source, "text": item.get("text"),
|
| 355 |
+
"metadata": item.get("metadata", {}), "source_id": item.get("source_id")}
|
| 356 |
+
config = Config(**config_dict)
|
| 357 |
+
target = Path(target)
|
| 358 |
+
receipt = target.with_suffix(target.suffix + ".receipt.json")
|
| 359 |
+
try:
|
| 360 |
+
# Resume assumes the frozen manifest but verifies the actual source bytes.
|
| 361 |
+
if sha256_file(source) != item["file_sha256"]:
|
| 362 |
+
raise ValueError("Source changed after analysis")
|
| 363 |
+
if resume and target.exists() and receipt.exists():
|
| 364 |
+
saved = json.loads(receipt.read_text(encoding="utf-8"))
|
| 365 |
+
if saved["source_sha256"] == item["file_sha256"] and saved["output_sha256"] == sha256_file(target):
|
| 366 |
+
result.update(export_status="ok", output_path=str(target), width=saved["width"], height=saved["height"])
|
| 367 |
+
return result
|
| 368 |
+
rgb, _ = canonical_image(source, config)
|
| 369 |
+
if config.export_max_edge:
|
| 370 |
+
rgb.thumbnail((config.export_max_edge, config.export_max_edge), resample=Image.Resampling.LANCZOS)
|
| 371 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 372 |
+
temporary = target.with_suffix(target.suffix + ".tmp")
|
| 373 |
+
save_options = {"quality": config.export_jpeg_quality, "subsampling": 0} if config.export_format == "jpeg" else {}
|
| 374 |
+
rgb.save(temporary, format=config.export_format.upper(), **save_options)
|
| 375 |
+
temporary.replace(target)
|
| 376 |
+
write_json(receipt, {"source_sha256": item["file_sha256"], "output_sha256": sha256_file(target),
|
| 377 |
+
"width": rgb.width, "height": rgb.height})
|
| 378 |
+
result.update(export_status="ok", output_path=str(target), width=rgb.width, height=rgb.height)
|
| 379 |
+
rgb.close()
|
| 380 |
+
except Exception as exc:
|
| 381 |
+
result.update(export_status="error", error=f"{type(exc).__name__}: {exc}")
|
| 382 |
+
return result
|
image_cleaner/metrics.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Native-pixel patch metrics; noise and SNR are proxies, not reference-based PSNR."""
|
| 2 |
+
import base64
|
| 3 |
+
from functools import lru_cache
|
| 4 |
+
import math
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
from PIL import Image
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@lru_cache(maxsize=1)
|
| 11 |
+
def dct_basis():
|
| 12 |
+
x = np.arange(32)
|
| 13 |
+
k = np.arange(8)[:, None]
|
| 14 |
+
return np.cos(np.pi * (2 * x + 1) * k / 64)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def perceptual_hash(image):
|
| 18 |
+
gray = np.asarray(image.convert("L").resize((32, 32), Image.Resampling.LANCZOS), dtype=np.float64)
|
| 19 |
+
dct = dct_basis() @ gray @ dct_basis().T
|
| 20 |
+
values = dct.flatten()
|
| 21 |
+
# Sparse/flat images contain coefficients near numerical zero. Their signs are
|
| 22 |
+
# unstable after JPEG recompression, so ignore values negligible relative to AC.
|
| 23 |
+
tolerance = max(1e-6, float(np.max(np.abs(values[1:]))) * 1e-3)
|
| 24 |
+
values[np.abs(values) <= tolerance] = 0
|
| 25 |
+
bits = values > np.median(values[1:]) + tolerance
|
| 26 |
+
bits[0] = False # DC is brightness, not spatial content.
|
| 27 |
+
return f"{int.from_bytes(np.packbits(bits).tobytes(), 'big'):016x}"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def difference_hash(image):
|
| 31 |
+
gray = np.asarray(image.convert("L").resize((9, 8), Image.Resampling.LANCZOS))
|
| 32 |
+
return f"{int.from_bytes(np.packbits(gray[:, 1:] > gray[:, :-1]).tobytes(), 'big'):016x}"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def thumbnail_signature(image):
|
| 36 |
+
data = np.asarray(image.resize((16, 16), Image.Resampling.LANCZOS), dtype=np.uint8)
|
| 37 |
+
return base64.b64encode(data.tobytes()).decode("ascii")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def thumbnail_rmse(a, b):
|
| 41 |
+
x = np.frombuffer(base64.b64decode(a), dtype=np.uint8).astype(np.float64)
|
| 42 |
+
y = np.frombuffer(base64.b64decode(b), dtype=np.uint8).astype(np.float64)
|
| 43 |
+
return float(np.sqrt(np.mean((x - y) ** 2)))
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def patch_metrics(gray):
|
| 47 |
+
if min(gray.shape) < 3:
|
| 48 |
+
return {"blur_score": None, "noise_sigma": None, "snr_proxy_db": None,
|
| 49 |
+
"blockiness": None, "luminance": float(gray.mean()), "contrast": float(gray.std())}
|
| 50 |
+
center = gray[1:-1, 1:-1]
|
| 51 |
+
up, down = gray[:-2, 1:-1], gray[2:, 1:-1]
|
| 52 |
+
left, right = gray[1:-1, :-2], gray[1:-1, 2:]
|
| 53 |
+
lap = up + down + left + right - 4 * center
|
| 54 |
+
# High-pass kernel has L2 norm 6. Restrict estimation to low-gradient regions.
|
| 55 |
+
high = (gray[:-2, :-2] + gray[:-2, 2:] + gray[2:, :-2] + gray[2:, 2:]
|
| 56 |
+
- 2 * (up + down + left + right) + 4 * center)
|
| 57 |
+
gradient = np.abs(right - left) + np.abs(down - up)
|
| 58 |
+
flat = high[gradient <= np.quantile(gradient, 0.3)]
|
| 59 |
+
sigma = float(np.median(np.abs(flat - np.median(flat))) / (0.6744897501960817 * 6))
|
| 60 |
+
contrast = float(gray.std())
|
| 61 |
+
snr = 20 * math.log10(contrast / sigma) if sigma > 1e-8 and contrast > 1e-8 else None
|
| 62 |
+
# JPEG boundaries are tested at every phase because source crops may shift the 8px grid.
|
| 63 |
+
block_scores = []
|
| 64 |
+
for axis in [0, 1]:
|
| 65 |
+
diff = np.abs(np.diff(gray, axis=axis))
|
| 66 |
+
n = diff.shape[axis]
|
| 67 |
+
if n >= 16:
|
| 68 |
+
phase_scores = []
|
| 69 |
+
indices = np.arange(n)
|
| 70 |
+
for phase in range(8):
|
| 71 |
+
border = indices % 8 == phase
|
| 72 |
+
phase_scores.append(float(np.take(diff, indices[border], axis=axis).mean()
|
| 73 |
+
- np.take(diff, indices[~border], axis=axis).mean()))
|
| 74 |
+
block_scores.append(max(phase_scores))
|
| 75 |
+
return {"blur_score": float(lap.var()), "noise_sigma": sigma, "snr_proxy_db": snr,
|
| 76 |
+
"blockiness": float(max(0, np.mean(block_scores))) if block_scores else None,
|
| 77 |
+
"luminance": float(gray.mean()), "contrast": contrast}
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def quality_metrics(image, patch_size=256):
|
| 81 |
+
w, h = image.size
|
| 82 |
+
pw, ph = min(w, patch_size), min(h, patch_size)
|
| 83 |
+
positions = sorted({(0, 0), (w-pw, 0), (0, h-ph), (w-pw, h-ph), ((w-pw)//2, (h-ph)//2)})
|
| 84 |
+
patches = [patch_metrics(np.asarray(image.crop((x, y, x+pw, y+ph)).convert("L"),
|
| 85 |
+
dtype=np.float64)) for x, y in positions]
|
| 86 |
+
result = {}
|
| 87 |
+
for key in patches[0]:
|
| 88 |
+
values = [p[key] for p in patches if p[key] is not None]
|
| 89 |
+
result[key] = float(np.median(values)) if values else None
|
| 90 |
+
result["metric_patch_count"] = len(patches)
|
| 91 |
+
return result
|
image_cleaner/stats.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from collections import Counter
|
| 2 |
+
import html
|
| 3 |
+
import json
|
| 4 |
+
import math
|
| 5 |
+
import random
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
from .io import atomic_text
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
BINS = {
|
| 13 |
+
"width": [0, 224, 512, 768, 1024, 2048, 4096, 8192, math.inf],
|
| 14 |
+
"height": [0, 224, 512, 768, 1024, 2048, 4096, 8192, math.inf],
|
| 15 |
+
"short_edge": [0, 224, 512, 768, 1024, 2048, 4096, 8192, math.inf],
|
| 16 |
+
"long_edge": [0, 224, 512, 768, 1024, 2048, 4096, 8192, math.inf],
|
| 17 |
+
"pixels": [0, 224**2, 512**2, 1024**2, 2048**2, 4096**2, 8192**2, math.inf],
|
| 18 |
+
"aspect_ratio": [0, .25, .5, .75, .95, 1.05, 1.33, 2, 4, math.inf],
|
| 19 |
+
"blur_score": [0, 5, 20, 50, 100, 200, 500, 1000, 5000, math.inf],
|
| 20 |
+
"noise_sigma": [0, .5, 1, 2, 4, 8, 16, 32, math.inf],
|
| 21 |
+
"snr_proxy_db": [-math.inf, 0, 10, 20, 30, 40, 50, 60, math.inf],
|
| 22 |
+
"blockiness": [0, .5, 1, 2, 4, 8, 16, 32, math.inf],
|
| 23 |
+
"text_chars": [0, 1, 10, 30, 100, 300, 1000, 3000, math.inf],
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class Distribution:
|
| 28 |
+
def __init__(self, bins, seed=17, capacity=20_000):
|
| 29 |
+
self.bins = bins
|
| 30 |
+
self.hist = [0] * (len(bins)-1)
|
| 31 |
+
self.n = 0
|
| 32 |
+
self.mean = self.m2 = 0.0
|
| 33 |
+
self.low = math.inf
|
| 34 |
+
self.high = -math.inf
|
| 35 |
+
self.sample = []
|
| 36 |
+
self.random = random.Random(seed)
|
| 37 |
+
self.capacity = capacity
|
| 38 |
+
self.missing = 0
|
| 39 |
+
|
| 40 |
+
def add(self, value):
|
| 41 |
+
if value is None or not math.isfinite(value):
|
| 42 |
+
self.missing += 1
|
| 43 |
+
return
|
| 44 |
+
self.n += 1
|
| 45 |
+
delta = value - self.mean
|
| 46 |
+
self.mean += delta / self.n
|
| 47 |
+
self.m2 += delta * (value - self.mean)
|
| 48 |
+
self.low, self.high = min(self.low, value), max(self.high, value)
|
| 49 |
+
slot = int(np.searchsorted(self.bins, value, side="right")) - 1
|
| 50 |
+
self.hist[max(0, min(slot, len(self.hist)-1))] += 1
|
| 51 |
+
if len(self.sample) < self.capacity:
|
| 52 |
+
self.sample.append(value)
|
| 53 |
+
else:
|
| 54 |
+
slot = self.random.randrange(self.n)
|
| 55 |
+
if slot < self.capacity:
|
| 56 |
+
self.sample[slot] = value
|
| 57 |
+
|
| 58 |
+
def result(self):
|
| 59 |
+
def boundary(value):
|
| 60 |
+
return "-inf" if value == -math.inf else "inf" if value == math.inf else value
|
| 61 |
+
percentiles = [1, 5, 25, 50, 75, 95, 99]
|
| 62 |
+
quantiles = np.percentile(self.sample, percentiles).tolist() if self.sample else [None]*7
|
| 63 |
+
return {"count": self.n, "missing": self.missing, "min": self.low if self.n else None,
|
| 64 |
+
"max": self.high if self.n else None, "mean": self.mean if self.n else None,
|
| 65 |
+
"std": math.sqrt(self.m2/self.n) if self.n else None,
|
| 66 |
+
"quantiles": dict(zip([f"p{p}" for p in percentiles], quantiles)),
|
| 67 |
+
"quantiles_approximate": self.n > self.capacity, "quantile_sample_size": len(self.sample),
|
| 68 |
+
"histogram": [{"left": boundary(a), "right": boundary(b), "count": c}
|
| 69 |
+
for a, b, c in zip(self.bins[:-1], self.bins[1:], self.hist)]}
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class DatasetStats:
|
| 73 |
+
def __init__(self):
|
| 74 |
+
self.distributions = {name: Distribution(bins) for name, bins in BINS.items()}
|
| 75 |
+
self.total = self.decoded = 0
|
| 76 |
+
self.statuses, self.reasons, self.formats, self.orientations = [Counter() for _ in range(4)]
|
| 77 |
+
|
| 78 |
+
def add(self, row):
|
| 79 |
+
self.total += 1
|
| 80 |
+
self.statuses[row["status"]] += 1
|
| 81 |
+
self.reasons.update(row.get("reasons", []))
|
| 82 |
+
if "width" not in row or row["status"] == "error":
|
| 83 |
+
return
|
| 84 |
+
self.decoded += 1
|
| 85 |
+
self.formats[row["original_format"]] += 1
|
| 86 |
+
ratio = row["aspect_ratio"]
|
| 87 |
+
self.orientations["square" if .95 <= ratio <= 1.05 else "landscape" if ratio > 1 else "portrait"] += 1
|
| 88 |
+
for name, distribution in self.distributions.items():
|
| 89 |
+
value = len(row.get("text") or "") if name == "text_chars" else row.get(name)
|
| 90 |
+
distribution.add(value)
|
| 91 |
+
|
| 92 |
+
def result(self):
|
| 93 |
+
return {"total": self.total, "decoded": self.decoded, "statuses": dict(self.statuses),
|
| 94 |
+
"reasons": dict(self.reasons), "formats": dict(self.formats),
|
| 95 |
+
"orientations": dict(self.orientations),
|
| 96 |
+
"distributions": {name: d.result() for name, d in self.distributions.items()}}
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def write_html(path, report):
|
| 100 |
+
labels = {"width": "宽度", "height": "高度", "short_edge": "短边", "long_edge": "长边",
|
| 101 |
+
"pixels": "总像素数", "aspect_ratio": "宽高比 W/H", "blur_score": "清晰度代理(拉普拉斯方差)",
|
| 102 |
+
"noise_sigma": "噪声估计 σ", "snr_proxy_db": "信噪比代理 dB", "blockiness": "块效应代理",
|
| 103 |
+
"text_chars": "文本字符数"}
|
| 104 |
+
with atomic_text(path) as handle:
|
| 105 |
+
handle.write('<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>图片清洗报告</title>'
|
| 106 |
+
'<style>body{font:16px system-ui;max-width:1200px;margin:32px auto;padding:0 20px;'
|
| 107 |
+
'color:#1e293b;background:#f8fafc}section{background:white;padding:20px;margin:20px 0;'
|
| 108 |
+
'border-radius:12px}svg{width:100%;max-height:260px}pre{white-space:pre-wrap}'
|
| 109 |
+
'.legend{color:#2563eb}text{font:11px system-ui}</style><h1>图片清洗报告</h1>')
|
| 110 |
+
handle.write('<p>原始分布为成功解码后的标准化方向;蓝色为原始数据,绿色为筛选保留数据。'
|
| 111 |
+
'直方图为精确计数,分位数最多使用 20,000 条蓄水池样本。噪声、信噪比和块效应为代理指标。</p>')
|
| 112 |
+
summary = {k: report[k] for k in ["input_count", "kept_count", "rejected_count", "duplicate_count", "error_count"]}
|
| 113 |
+
handle.write('<section><pre>' + html.escape(json.dumps(summary, ensure_ascii=False, indent=2)) + '</pre></section>')
|
| 114 |
+
if "stage_metrics" in report:
|
| 115 |
+
handle.write('<section><h2>分辨率筛选与阶段指标</h2><pre>' + html.escape(json.dumps(
|
| 116 |
+
{"resolution_filter": report["resolution_filter"], "stage_metrics": report["stage_metrics"]},
|
| 117 |
+
ensure_ascii=False, indent=2)) + '</pre></section>')
|
| 118 |
+
if "aspect_bucket_filter" in report:
|
| 119 |
+
handle.write('<section><h2>宽高比桶筛选</h2><pre>' + html.escape(json.dumps(
|
| 120 |
+
report["aspect_bucket_filter"], ensure_ascii=False, indent=2)) + '</pre></section>')
|
| 121 |
+
for name, label in labels.items():
|
| 122 |
+
original = report["before"]["distributions"][name]
|
| 123 |
+
retained = report["after"]["distributions"][name]
|
| 124 |
+
bins, kept = original["histogram"], retained["histogram"]
|
| 125 |
+
peak = max(1, max(b["count"] for b in bins))
|
| 126 |
+
width = 900 / len(bins)
|
| 127 |
+
handle.write('<section><h2>' + label + '</h2><svg viewBox="0 0 960 260" role="img" aria-label="' + label + '分布">')
|
| 128 |
+
for i, (b, k) in enumerate(zip(bins, kept)):
|
| 129 |
+
x = 30 + width*i
|
| 130 |
+
for offset, count, color in [(0, b["count"], "#2563eb"), (.4, k["count"], "#16a34a")]:
|
| 131 |
+
height = 180*count/peak
|
| 132 |
+
handle.write(f'<rect x="{x+width*offset:.1f}" y="{205-height:.1f}" width="{width*.35:.1f}" '
|
| 133 |
+
f'height="{height:.1f}" fill="{color}"><title>{count}</title></rect>')
|
| 134 |
+
text = html.escape(f'{b["left"]}–{b["right"]}')
|
| 135 |
+
handle.write(f'<text x="{x:.1f}" y="228" transform="rotate(12 {x:.1f} 228)">{text}</text>')
|
| 136 |
+
handle.write('</svg><p class="legend">蓝:原始 绿:保留</p><pre>')
|
| 137 |
+
handle.write(html.escape(json.dumps({"before": original["quantiles"], "after": retained["quantiles"]},
|
| 138 |
+
ensure_ascii=False, indent=2)))
|
| 139 |
+
handle.write('</pre></section>')
|
| 140 |
+
handle.write('<section><h2>筛选原因</h2><pre>' + html.escape(json.dumps(report["before"]["reasons"],
|
| 141 |
+
ensure_ascii=False, indent=2)) + '</pre></section></html>')
|
index.html
CHANGED
|
@@ -1,19 +1,15 @@
|
|
| 1 |
-
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
</p>
|
| 17 |
-
</div>
|
| 18 |
-
</body>
|
| 19 |
-
</html>
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="description" content="图片文本数据初筛代码、分布统计、尺寸与宽高比筛选、质量指标、去重和多机部署教程。"><title>NMM-data-preprocess · 图片文本数据初筛</title><link rel="stylesheet" href="assets/style.css"><script defer src="assets/app.js"></script></head>
|
| 4 |
+
<body>
|
| 5 |
+
<header class="topbar"><a class="brand" href="index.html">NMM<span> / </span>data-preprocess</a><nav aria-label="主要导航"><a href="manual/QUICKSTART.html">开始使用</a><a href="manual/PARAMETERS.html">参数参考</a><a href="manual/DEPLOYMENT.html">部署</a><a href="https://huggingface.co/spaces/HanWangTJU/NMM-data-preprocess/tree/main" target="_blank" rel="noopener">源代码 ↗</a></nav></header>
|
| 6 |
+
<main class="landing">
|
| 7 |
+
<section class="hero"><p class="eyebrow">原生图片—文本模型 / 训练前数据初筛</p><h1>先看清数据,<br>再决定保留什么。</h1><p class="lead">从图片文件夹或图文清单开始,统计尺寸与质量分布,按分辨率和宽高比筛选,再完成全局去重与标准化导出。</p><div class="actions"><a class="button primary" href="manual/QUICKSTART.html">阅读逐步教程 →</a><a class="button" href="downloads/NMM-data-preprocess-0.3.0.zip" download>下载源码 v0.3.0 ↓</a></div><p class="note">Python 3.10+ · CPU 多进程 · 多机静态分片 · Windows / Linux / Docker</p></section>
|
| 8 |
+
<section class="pipeline" aria-label="清洗流程"><div><b>01</b><strong>导入</strong><span>文件夹 / JSONL</span></div><div><b>02</b><strong>统计</strong><span>尺寸 / 技术质量</span></div><div><b>03</b><strong>筛选</strong><span>规则 / 全局去重</span></div><div><b>04</b><strong>导出</strong><span>RGB 图片 / 图文清单</span></div></section>
|
| 9 |
+
<section class="intro"><h2>完整教程与代码说明</h2><p>这个 Space 提供文档、命令设置和代码下载。清洗任务通过 CLI 在你的本地机器或服务器运行。</p><div class="guide-grid"><a class="guide" href="manual/QUICKSTART.html"><small>从零开始</small><h3>逐步使用教程</h3><p>下载、安装、合成样例、第一轮统计、筛选与导出。</p><span>阅读教程 →</span></a><a class="guide" href="manual/USAGE.html"><small>评测与规则</small><h3>完整操作手册</h3><p>逐阶段指标、公式、报告字段、质量校准和误删评估。</p><span>查看手册 →</span></a><a class="guide" href="manual/PARAMETERS.html"><small>全部设置</small><h3>参数参考</h3><p>每个 CLI 与 JSON 字段的默认值、约束、优先级和示例。</p><span>查询参数 →</span></a><a class="guide" href="manual/DEPLOYMENT.html"><small>本地与多机</small><h3>部署指南</h3><p>Windows、Linux、Docker、rank 分片、续跑和 Space 发布。</p><span>部署运行 →</span></a><a class="guide" href="manual/ARCHITECTURE.html"><small>实现细节</small><h3>代码与功能说明</h3><p>模块职责、数据流、文件格式、去重机制与当前边界。</p><span>了解实现 →</span></a></div></section>
|
| 10 |
+
<section class="generator" id="commands"><div class="section-heading"><div><p class="eyebrow">配置尺寸与比例</p><h2>生成一条筛选命令</h2></div><span class="tag">复用统计缓存,无需重新解码</span></div><p>先按教程完成统计,将分片放在 <code>runs/statistics/shards</code>。以下设置只生成命令,可直接复制到终端。</p><div class="form-grid"><label>终端环境<select id="platform"><option value="windows">Windows PowerShell</option><option value="linux">Linux Bash</option></select></label><label>分辨率下限<input id="resolution" type="text" value="256p" placeholder="256p / 384x256 / any"></label><label>匹配策略<select id="mode"><option value="loose">宽松过滤</option><option value="strict">严格过滤</option></select></label><label>宽松偏差(%)<input id="tolerance" type="number" min="0" step="0.1" value="5"></label></div><fieldset class="buckets"><legend>宽:高桶(多选)</legend><label><input type="checkbox" name="bucket" value="1:1" checked>1:1</label><label><input type="checkbox" name="bucket" value="2:3" checked>2:3</label><label><input type="checkbox" name="bucket" value="3:2" checked>3:2</label><label><input type="checkbox" name="bucket" value="3:4" checked>3:4</label><label><input type="checkbox" name="bucket" value="4:3" checked>4:3</label><label><input type="checkbox" name="bucket" value="9:16" checked>9:16</label><label><input type="checkbox" name="bucket" value="16:9" checked>16:9</label><label><input type="checkbox" name="bucket" value="21:9" checked>21:9</label><button type="button" id="select-all">全选</button><button type="button" id="select-none">关闭桶条件</button></fieldset><p id="command-error" class="error" role="alert"></p><pre><code id="command"></code></pre><p class="note">输出到 <code>runs/filtered</code>;已有结果时请改用新目录。命令继承分析配置中的质量与去重设置。256p 表示短边 ≥256,包含 256。</p>
|
| 11 |
+
<details><summary>用实际尺寸检查比例规则</summary><div class="form-grid"><label>图片宽(像素)<input id="width" type="number" min="1" step="1" value="1050"></label><label>图片高(像素)<input id="height" type="number" min="1" step="1" value="1000"></label></div><p id="ratio-result" aria-live="polite"></p><p class="note">偏差 δ=max(r/b,b/r)−1,包含阈值边界。实际 CLI 用精确分数比较;这里只展示所选桶和分辨率条件,不判断图片质量、文字和重复。</p></details></section>
|
| 12 |
+
<section class="features"><h2>当前初筛覆盖</h2><div class="feature-grid"><article><h3>分布统计</h3><p>分辨率、短边、长边、像素数、比例和质量分布;全量直方图与抽样分位数。</p></article><article><h3>分辨率与八桶筛选</h3><p>短边下限、精确宽高、严格比例和默认 5% 可调宽松偏差。</p></article><article><h3>规范化与文本规则</h3><p>EXIF、ICC、RGB、透明背景、Unicode、文本长度与正则检查。</p></article><article><h3>技术质量代理</h3><p>模糊、噪声、信噪比与块效应指标;阈值需针对你的数据校准。</p></article><article><h3>全局去重</h3><p>文件和像素精确去重,pHash / dHash / RMSE 联合近重复复核。</p></article><article><h3>多进程与多机</h3><p>SQLite 检查点与静态 rank;汇总分片后统一筛选、去重和报告。</p></article></div></section>
|
| 13 |
+
<section class="callout"><h2>看报告时,先核对这些数字</h2><p>输入总数 = 保留 + 拒绝 + 重复 + 错误。比较分辨率独立通过率、桶独立通过率、最终保留率,以及各桶筛选前后数量。初筛结果仍需人工抽查图文对应关系与质量。</p><a href="manual/USAGE.html">阅读所有指标的定义 →</a></section>
|
| 14 |
+
</main><footer>HanWangTJU / NMM-data-preprocess · v0.3.0<span>静态教程与代码入口 · 图片在本地处理</span></footer>
|
| 15 |
+
</body></html>
|
|
|
|
|
|
|
|
|
|
|
|
manual/ARCHITECTURE.html
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>代码说明 · NMM-data-preprocess</title><link rel="stylesheet" href="../assets/style.css"><script defer src="../assets/app.js"></script></head><body><header class="topbar"><a class="brand" href="../index.html">NMM<span> / </span>data-preprocess</a><nav><a href="https://huggingface.co/spaces/HanWangTJU/NMM-data-preprocess/tree/main">源代码 ↗</a><a href="../downloads/NMM-data-preprocess-0.3.0.zip" download>下载源码 ↓</a></nav></header><div class="manual-layout"><aside class="sidebar"><a href="../index.html">← 返回首页</a><a class="" href="QUICKSTART.html">逐步教程</a><a class="" href="USAGE.html">完整手册</a><a class="" href="PARAMETERS.html">参数参考</a><a class="" href="DEPLOYMENT.html">部署指南</a><a class="active" href="ARCHITECTURE.html">代码说明</a><h2>本页目录</h2><div class="toc">
|
| 2 |
+
<ul>
|
| 3 |
+
<li><a href="#1-当前版本与数据流">1. 当前版本与数据流</a></li>
|
| 4 |
+
<li><a href="#2-功能矩阵">2. 功能矩阵</a></li>
|
| 5 |
+
<li><a href="#3-源码文件说明">3. 源码文件说明</a></li>
|
| 6 |
+
<li><a href="#4-记录格式与状态">4. 记录格式与状态</a></li>
|
| 7 |
+
<li><a href="#5-为什么统计后能反复筛选">5. 为什么统计后能反复筛选</a></li>
|
| 8 |
+
<li><a href="#6-并行去重和内存">6. 并行、去重和内存</a></li>
|
| 9 |
+
<li><a href="#7-当前没有覆盖的训练前审核">7. 当前没有覆盖的训练前审核</a></li>
|
| 10 |
+
<li><a href="#8-验证与版本">8. 验证与版本</a></li>
|
| 11 |
+
</ul>
|
| 12 |
+
</div>
|
| 13 |
+
</aside><main class="manual"><h1 id="代码结构功能与实现边界">代码结构、功能与实现边界</h1>
|
| 14 |
+
<h2 id="1-当前版本与数据流">1. 当前版本与数据流</h2>
|
| 15 |
+
<p>当前清洗库版本 0.3.0,面向图片—文本对。主要流程:</p>
|
| 16 |
+
<pre><code class="language-text">图片文件夹 / 图文 JSONL
|
| 17 |
+
│ prepare:稳定编号、固定清单与 SHA-256
|
| 18 |
+
▼
|
| 19 |
+
input.jsonl + input.jsonl.meta.json
|
| 20 |
+
│ analyze:rank 分片、本机进程池、规范化、测量
|
| 21 |
+
▼
|
| 22 |
+
各 rank 的 SQLite + JSONL
|
| 23 |
+
│ merge/filter:重算规则、全局去重、流式统计
|
| 24 |
+
▼
|
| 25 |
+
kept / rejected / duplicate / error + HTML/JSON 报告
|
| 26 |
+
│ export:源哈希校验、输出规范化 RGB
|
| 27 |
+
▼
|
| 28 |
+
标准化图片 + exported.jsonl → 训练数据加载器
|
| 29 |
+
</code></pre>
|
| 30 |
+
<p><code>run</code> 将单机 prepare、analyze、merge 顺序串联,可选 export。静态 Space 仅展示文档与命令,算法在用户机器运行。</p>
|
| 31 |
+
<h2 id="2-功能矩阵">2. 功能矩阵</h2>
|
| 32 |
+
<table>
|
| 33 |
+
<thead>
|
| 34 |
+
<tr>
|
| 35 |
+
<th>环节</th>
|
| 36 |
+
<th>已实现</th>
|
| 37 |
+
<th>输出/限制</th>
|
| 38 |
+
</tr>
|
| 39 |
+
</thead>
|
| 40 |
+
<tbody>
|
| 41 |
+
<tr>
|
| 42 |
+
<td>批量导入</td>
|
| 43 |
+
<td>递归图片目录、同名 txt、图文 JSONL</td>
|
| 44 |
+
<td>固定清单,路径和原始 ID 可追溯</td>
|
| 45 |
+
</tr>
|
| 46 |
+
<tr>
|
| 47 |
+
<td>解码检查</td>
|
| 48 |
+
<td>坏文件、截断、像素容量、动画/多页</td>
|
| 49 |
+
<td>单图失败进入 error,不删除源图</td>
|
| 50 |
+
</tr>
|
| 51 |
+
<tr>
|
| 52 |
+
<td>标准化</td>
|
| 53 |
+
<td>EXIF、ICC→sRGB、RGB、透明合成、Unicode/空白</td>
|
| 54 |
+
<td>无模型推理,无默认裁剪</td>
|
| 55 |
+
</tr>
|
| 56 |
+
<tr>
|
| 57 |
+
<td>尺寸统计</td>
|
| 58 |
+
<td>宽高、短/长边、像素、比例、方向分类</td>
|
| 59 |
+
<td>全量直方图,最多 20000 样本估计分位数</td>
|
| 60 |
+
</tr>
|
| 61 |
+
<tr>
|
| 62 |
+
<td>尺寸筛选</td>
|
| 63 |
+
<td>短边 >=Np、精确 WxH、其他尺寸阈值</td>
|
| 64 |
+
<td>条件取交集,包含边界</td>
|
| 65 |
+
</tr>
|
| 66 |
+
<tr>
|
| 67 |
+
<td>宽高比桶</td>
|
| 68 |
+
<td>八桶、多选/全选、严格/宽松、可调容差</td>
|
| 69 |
+
<td>固定桶,不做桶均衡采样</td>
|
| 70 |
+
</tr>
|
| 71 |
+
<tr>
|
| 72 |
+
<td>技术质量</td>
|
| 73 |
+
<td>模糊、噪声、SNR、块效应代理</td>
|
| 74 |
+
<td>最多五个原生像素区域,不是人工质量结论</td>
|
| 75 |
+
</tr>
|
| 76 |
+
<tr>
|
| 77 |
+
<td>文本检查</td>
|
| 78 |
+
<td>长度、Unicode、广告等正则</td>
|
| 79 |
+
<td>不评估图文语义一致性</td>
|
| 80 |
+
</tr>
|
| 81 |
+
<tr>
|
| 82 |
+
<td>精确去重</td>
|
| 83 |
+
<td>原字节、规范化像素</td>
|
| 84 |
+
<td>跨全部分片全局处理</td>
|
| 85 |
+
</tr>
|
| 86 |
+
<tr>
|
| 87 |
+
<td>近重复</td>
|
| 88 |
+
<td>pHash、dHash、比例、RGB RMSE 联合</td>
|
| 89 |
+
<td>启发式,不是 embedding 语义去重</td>
|
| 90 |
+
</tr>
|
| 91 |
+
<tr>
|
| 92 |
+
<td>并行/续跑</td>
|
| 93 |
+
<td>本机进程池、多机静态 rank、SQLite 检查点</td>
|
| 94 |
+
<td>合并由单协调节点,无自动调度</td>
|
| 95 |
+
</tr>
|
| 96 |
+
<tr>
|
| 97 |
+
<td>导出</td>
|
| 98 |
+
<td>独立 RGB PNG/JPEG、缩小长边、清单、续跑校验</td>
|
| 99 |
+
<td>原图不改动,固定裁剪由训练端完成</td>
|
| 100 |
+
</tr>
|
| 101 |
+
</tbody>
|
| 102 |
+
</table>
|
| 103 |
+
<p>用户提到的“正则化”在此实现中是数据规范化和规则校验;不包含训练损失正则化、数据增强或张量标准化。</p>
|
| 104 |
+
<h2 id="3-源码文件说明">3. 源码文件说明</h2>
|
| 105 |
+
<table>
|
| 106 |
+
<thead>
|
| 107 |
+
<tr>
|
| 108 |
+
<th>文件</th>
|
| 109 |
+
<th>主要职责</th>
|
| 110 |
+
</tr>
|
| 111 |
+
</thead>
|
| 112 |
+
<tbody>
|
| 113 |
+
<tr>
|
| 114 |
+
<td><code>image_cleaner/cli.py</code></td>
|
| 115 |
+
<td>argparse 子命令、CLI 覆盖、run 串联与返回码</td>
|
| 116 |
+
</tr>
|
| 117 |
+
<tr>
|
| 118 |
+
<td><code>image_cleaner/config.py</code></td>
|
| 119 |
+
<td>不可变 Config、JSON 加载、字段与数值校验、配置指纹</td>
|
| 120 |
+
</tr>
|
| 121 |
+
<tr>
|
| 122 |
+
<td><code>image_cleaner/io.py</code></td>
|
| 123 |
+
<td>目录导入、JSONL、清单身份、原子写入、锁、SQLite、限流进程池</td>
|
| 124 |
+
</tr>
|
| 125 |
+
<tr>
|
| 126 |
+
<td><code>image_cleaner/images.py</code></td>
|
| 127 |
+
<td>EXIF/色彩/透明度/文本规范化、单图分析与筛选原因</td>
|
| 128 |
+
</tr>
|
| 129 |
+
<tr>
|
| 130 |
+
<td><code>image_cleaner/aspect.py</code></td>
|
| 131 |
+
<td>精确分数比较、对称偏差、确定性桶分配</td>
|
| 132 |
+
</tr>
|
| 133 |
+
<tr>
|
| 134 |
+
<td><code>image_cleaner/metrics.py</code></td>
|
| 135 |
+
<td>原生区域质量测量、pHash/dHash、缩略图与 RMSE</td>
|
| 136 |
+
</tr>
|
| 137 |
+
<tr>
|
| 138 |
+
<td><code>image_cleaner/jobs.py</code></td>
|
| 139 |
+
<td>分片分析、全局过滤、落盘去重索引、导出与续跑</td>
|
| 140 |
+
</tr>
|
| 141 |
+
<tr>
|
| 142 |
+
<td><code>image_cleaner/stats.py</code></td>
|
| 143 |
+
<td>增量统计、蓄水池分位数、内嵌 SVG HTML 报告</td>
|
| 144 |
+
</tr>
|
| 145 |
+
<tr>
|
| 146 |
+
<td><code>tests/test_pipeline.py</code></td>
|
| 147 |
+
<td>导入/清洗/去重/导出/分片/续跑/容量等测试</td>
|
| 148 |
+
</tr>
|
| 149 |
+
<tr>
|
| 150 |
+
<td><code>tests/test_aspect.py</code></td>
|
| 151 |
+
<td>容差边界、横竖桶、重叠分配、CLI、缓存重筛选</td>
|
| 152 |
+
</tr>
|
| 153 |
+
<tr>
|
| 154 |
+
<td><code>configs/</code></td>
|
| 155 |
+
<td>统计、尺寸、桶、去重、生成数据示例配置</td>
|
| 156 |
+
</tr>
|
| 157 |
+
<tr>
|
| 158 |
+
<td><code>examples/</code></td>
|
| 159 |
+
<td>生成合成演示样本</td>
|
| 160 |
+
</tr>
|
| 161 |
+
<tr>
|
| 162 |
+
<td><code>docker/Dockerfile</code></td>
|
| 163 |
+
<td>CLI 容器部署,不启动 Web 服务</td>
|
| 164 |
+
</tr>
|
| 165 |
+
<tr>
|
| 166 |
+
<td><code>tools/build_space.py</code></td>
|
| 167 |
+
<td>文档转换、静态页面、源码 ZIP、发布文件校验清单</td>
|
| 168 |
+
</tr>
|
| 169 |
+
<tr>
|
| 170 |
+
<td><code>tools/publish_space.py</code></td>
|
| 171 |
+
<td>从环境变量读取 Token,检查账号并发布白名单文件</td>
|
| 172 |
+
</tr>
|
| 173 |
+
<tr>
|
| 174 |
+
<td><code>web/</code></td>
|
| 175 |
+
<td>Space 首页模板、样式与交互脚本</td>
|
| 176 |
+
</tr>
|
| 177 |
+
</tbody>
|
| 178 |
+
</table>
|
| 179 |
+
<p><code>index.html</code>、<code>manual/*.html</code> 与 <code>downloads/*.zip</code> 是发布构建产物。运行结果在 runs,虚拟环境在 .venv,两者不发布。</p>
|
| 180 |
+
<h2 id="4-记录格式与状态">4. 记录格式与状态</h2>
|
| 181 |
+
<p>外部 JSONL 每条必须有字符串 path;text 为字符串或 null,metadata 为对象。输入 id 会保存为 source_id,程序生成稳定 <code>sample-000000000000</code> 等 ID 与 seq。其他外部字段放入 metadata,避免覆盖计算指标。</p>
|
| 182 |
+
<p>分析记录包含源路径、规范化文本、原始/校正宽高、文件/像素哈希、质量指标、预处理元数据、警告和筛选原因。最终状态:</p>
|
| 183 |
+
<table>
|
| 184 |
+
<thead>
|
| 185 |
+
<tr>
|
| 186 |
+
<th>状态</th>
|
| 187 |
+
<th>含义</th>
|
| 188 |
+
</tr>
|
| 189 |
+
</thead>
|
| 190 |
+
<tbody>
|
| 191 |
+
<tr>
|
| 192 |
+
<td>kept</td>
|
| 193 |
+
<td>所有规则通过,且没有归并到其他代表</td>
|
| 194 |
+
</tr>
|
| 195 |
+
<tr>
|
| 196 |
+
<td>rejected</td>
|
| 197 |
+
<td>至少一个尺寸/桶/质量/文本规则不通过</td>
|
| 198 |
+
</tr>
|
| 199 |
+
<tr>
|
| 200 |
+
<td>duplicate</td>
|
| 201 |
+
<td>规则通过,但精确或近重复判定归并到代表</td>
|
| 202 |
+
</tr>
|
| 203 |
+
<tr>
|
| 204 |
+
<td>error</td>
|
| 205 |
+
<td>无法成功解码、规范化或测量</td>
|
| 206 |
+
</tr>
|
| 207 |
+
</tbody>
|
| 208 |
+
</table>
|
| 209 |
+
<p>最终满足 <code>input_count=kept+rejected+duplicate+error</code>。同一拒绝记录可以有多个原因,原因计数不可当成样本数相加。</p>
|
| 210 |
+
<h2 id="5-为什么统计后能反复筛选">5. 为什么统计后能反复筛选</h2>
|
| 211 |
+
<p>analyze 保存成功处理的完整指标,即使当时被阈值拒绝也写入 SQLite。filter 从全部记录重新计算规则,而不是仅读取上一次 kept。因此放宽阈值能恢复以前被拒绝的记录,收紧阈值也无需读源图片。</p>
|
| 212 |
+
<p>六项测量设置(reject_animated、max_decode_pixels、metric_patch_size、alpha_background、convert_icc、text_unicode_form)不允许在重筛选中改变。变化会使缓存指标不再对应新处理逻辑,需要重新分析。</p>
|
| 213 |
+
<h2 id="6-并行去重和内存">6. 并行、去重和内存</h2>
|
| 214 |
+
<p>进程池并发队列约为 workers 的两倍,结果逐条写入 SQLite。rank 使用 <code>seq % world_size</code>,每个 rank 写单独数据库。全局 merge 校验完整分片并按稳定优先级处理候选。</p>
|
| 215 |
+
<p>精确去重索引基于哈希,近重复使用 pHash 的 r+1 分段索引召回候选,再复核联合条件。索引和排序主要落磁盘,不将全量图片或全量指标载入内存;没有全数据两两比较。</p>
|
| 216 |
+
<p>合并批量处理 500 条候选,SQLite 缓存约 32 MiB,分位数抽样上限 20000。图片解码内存仍随原图像素与 workers 增长。大量相似图片、纯色图或高半径设置会增加复核成本,不能据此承诺百万/亿级吞吐量。</p>
|
| 217 |
+
<h2 id="7-当前没有覆盖的训练前审核">7. 当前没有覆盖的训练前审核</h2>
|
| 218 |
+
<p>当前不包含 OCR、水印/人脸/隐私识别、NSFW/版权判定、CLIP 语义一致性、美学模型、语义 embedding 去重、训练/验证集防泄漏、WebDataset/Parquet 输入、视频/音频、训练采样配额、动态集群调度。</p>
|
| 219 |
+
<p>初筛保留仅代表通过已配置规则;训练前仍应按来源、内容类别和任务需求抽查,确认图文对齐及样本可用性。若后续扩展,应保留现有 source_id、稳定清单和每条决策原因便于追溯。</p>
|
| 220 |
+
<h2 id="8-验证与版本">8. 验证与版本</h2>
|
| 221 |
+
<p>现有 21 项清洗测试覆盖正好 5% 边界、零容差、桶重叠、源文件删除后的缓存筛选、多进程/双分片一致性、去重、多文本、断点续跑、导出校验、分辨率包含边界等。发布时另核验文档参数与代码、ZIP 白名单和页面链接。</p>
|
| 222 |
+
<p>恢复分析要求软件版本与配置指纹一致。合并旧分片需要同一批分片身份一致且当前代码能够读取其字段;不要混用不同软件版本、不同清单或不同 world-size 的分片。版本号不代表数据清洗标准或模型效果保证。</p></main></div><footer>NMM-data-preprocess · v0.3.0<span>图片文本数据初筛教程</span></footer></body></html>
|
manual/DEPLOYMENT.html
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>部署指南 · NMM-data-preprocess</title><link rel="stylesheet" href="../assets/style.css"><script defer src="../assets/app.js"></script></head><body><header class="topbar"><a class="brand" href="../index.html">NMM<span> / </span>data-preprocess</a><nav><a href="https://huggingface.co/spaces/HanWangTJU/NMM-data-preprocess/tree/main">源代码 ↗</a><a href="../downloads/NMM-data-preprocess-0.3.0.zip" download>下载源码 ↓</a></nav></header><div class="manual-layout"><aside class="sidebar"><a href="../index.html">← 返回首页</a><a class="" href="QUICKSTART.html">逐步教程</a><a class="" href="USAGE.html">完整手册</a><a class="" href="PARAMETERS.html">参数参考</a><a class="active" href="DEPLOYMENT.html">部署指南</a><a class="" href="ARCHITECTURE.html">代码说明</a><h2>本页目录</h2><div class="toc">
|
| 2 |
+
<ul>
|
| 3 |
+
<li><a href="#1-部署形式如何选择">1. 部署形式如何选择</a></li>
|
| 4 |
+
<li><a href="#2-windows-部署">2. Windows 部署</a></li>
|
| 5 |
+
<li><a href="#3-linux-部署">3. Linux 部署</a></li>
|
| 6 |
+
<li><a href="#4-docker-cli-部署">4. Docker CLI 部署</a></li>
|
| 7 |
+
<li><a href="#5-两台服务器的完整流程">5. 两台服务器的完整流程</a><ul>
|
| 8 |
+
<li><a href="#51-制作一份全局清单">5.1 制作一份全局清单</a></li>
|
| 9 |
+
<li><a href="#52-各节点分析">5.2 各节点分析</a></li>
|
| 10 |
+
<li><a href="#53-收集分片">5.3 收集分片</a></li>
|
| 11 |
+
<li><a href="#54-统一导出">5.4 统一导出</a></li>
|
| 12 |
+
</ul>
|
| 13 |
+
</li>
|
| 14 |
+
<li><a href="#6-续跑重筛选与故障处理">6. 续跑、重筛选与故障处理</a></li>
|
| 15 |
+
<li><a href="#7-hugging-face-space-的发布方式">7. Hugging Face Space 的发布方式</a></li>
|
| 16 |
+
<li><a href="#8-部署后的验收">8. 部署后的验收</a></li>
|
| 17 |
+
</ul>
|
| 18 |
+
</div>
|
| 19 |
+
</aside><main class="manual"><h1 id="部署分布式运行与发布指南">部署、分布式运行与发布指南</h1>
|
| 20 |
+
<h2 id="1-部署形式如何选择">1. 部署形式如何选择</h2>
|
| 21 |
+
<table>
|
| 22 |
+
<thead>
|
| 23 |
+
<tr>
|
| 24 |
+
<th>场景</th>
|
| 25 |
+
<th>方式</th>
|
| 26 |
+
</tr>
|
| 27 |
+
</thead>
|
| 28 |
+
<tbody>
|
| 29 |
+
<tr>
|
| 30 |
+
<td>初次使用、少量验证</td>
|
| 31 |
+
<td>Windows / Linux 虚拟环境 + CLI</td>
|
| 32 |
+
</tr>
|
| 33 |
+
<tr>
|
| 34 |
+
<td>单台服务器大批量</td>
|
| 35 |
+
<td>Linux CLI,多进程 analyze,磁盘上的 SQLite</td>
|
| 36 |
+
</tr>
|
| 37 |
+
<tr>
|
| 38 |
+
<td>环境一致性与隔离</td>
|
| 39 |
+
<td>Docker CLI 容器,挂载原图与任务目录</td>
|
| 40 |
+
</tr>
|
| 41 |
+
<tr>
|
| 42 |
+
<td>多机数据分析</td>
|
| 43 |
+
<td>同一 manifest,静态 rank/world-size,汇总完整分片</td>
|
| 44 |
+
</tr>
|
| 45 |
+
<tr>
|
| 46 |
+
<td>在线共享代码和教程</td>
|
| 47 |
+
<td>Hugging Face 静态 Space;不执行清洗后台</td>
|
| 48 |
+
</tr>
|
| 49 |
+
</tbody>
|
| 50 |
+
</table>
|
| 51 |
+
<p>清洗不依赖 GPU,也不加载大模型。核心依赖 Python>=3.10、NumPy>=1.26,<3、Pillow>=10.4,<13。实际解码格式取决于 Pillow 构建与可用编解码器,文件扩展名支持不保证所有格式均能解码。</p>
|
| 52 |
+
<h2 id="2-windows-部署">2. Windows 部署</h2>
|
| 53 |
+
<pre><code class="language-powershell">git clone https://huggingface.co/spaces/HanWangTJU/NMM-data-preprocess multimodal_training
|
| 54 |
+
cd multimodal_training
|
| 55 |
+
py -3.12 -m venv .venv
|
| 56 |
+
.\.venv\Scripts\python.exe -m pip install --upgrade pip
|
| 57 |
+
.\.venv\Scripts\python.exe -m pip install -e .
|
| 58 |
+
.\.venv\Scripts\python.exe -m image_cleaner --help
|
| 59 |
+
.\.venv\Scripts\python.exe -m unittest discover -s tests -v
|
| 60 |
+
</code></pre>
|
| 61 |
+
<p>不需要激活虚拟环境或修改 PowerShell 执行策略。路径有空格时加双引号。不要把 output 放到图片输入树中,以免后续重新导入导出文件。</p>
|
| 62 |
+
<p>默认 workers 为 min(8,CPU 数量),大图先设 2 或 4,观察内存与吞吐量再上调。配置 max_decode_pixels 控制解码容量;降低上限会把超限图片记入 error,不能替代缩图。</p>
|
| 63 |
+
<h2 id="3-linux-部署">3. Linux 部署</h2>
|
| 64 |
+
<pre><code class="language-bash">git clone https://huggingface.co/spaces/HanWangTJU/NMM-data-preprocess multimodal_training
|
| 65 |
+
cd multimodal_training
|
| 66 |
+
python3 -m venv .venv
|
| 67 |
+
.venv/bin/python -m pip install --upgrade pip
|
| 68 |
+
.venv/bin/python -m pip install -e .
|
| 69 |
+
.venv/bin/python -m image_cleaner --help
|
| 70 |
+
.venv/bin/python -m unittest discover -s tests -v
|
| 71 |
+
</code></pre>
|
| 72 |
+
<pre><code class="language-bash">.venv/bin/python -m image_cleaner run --input /data/images --output /jobs/statistics --config configs/statistics_only.json --workers 8 --sidecar-text
|
| 73 |
+
.venv/bin/python -m image_cleaner filter --shards /jobs/statistics/shards --output /jobs/filtered --config configs/audit.json --resolution 256p --aspect-buckets all --aspect-mode loose --aspect-tolerance 0.05
|
| 74 |
+
.venv/bin/python -m image_cleaner export --curated /jobs/filtered --output /jobs/normalized --workers 8
|
| 75 |
+
</code></pre>
|
| 76 |
+
<p>离线运行前应在对应平台准备 Python、包安装文件和代码。可以执行 <code>pip download . --dest wheels</code> 收集核心依赖;打包方式和平台不同,应在目标环境验证。原图、任务文件建议放在可靠���地盘或受控共享存储,优先使用快盘承载 SQLite。</p>
|
| 77 |
+
<h2 id="4-docker-cli-部署">4. Docker CLI 部署</h2>
|
| 78 |
+
<p>从项目根目录构建:</p>
|
| 79 |
+
<pre><code class="language-bash">docker build -f docker/Dockerfile -t nmm-data-preprocess:0.3.0 .
|
| 80 |
+
docker run --rm nmm-data-preprocess:0.3.0 --help
|
| 81 |
+
</code></pre>
|
| 82 |
+
<p>以下为 Linux 主机路径示例。先创建输出目录,并确保容器 UID 1000 对其有写权限;数据目录只读挂载:</p>
|
| 83 |
+
<pre><code class="language-bash">mkdir -p /jobs/nmm
|
| 84 |
+
docker run --rm \
|
| 85 |
+
-v /data/images:/data/images:ro \
|
| 86 |
+
-v /jobs/nmm:/jobs \
|
| 87 |
+
-v "$PWD/configs:/configs:ro" \
|
| 88 |
+
nmm-data-preprocess:0.3.0 \
|
| 89 |
+
run --input /data/images --output /jobs/statistics --config /configs/statistics_only.json --workers 4 --sidecar-text
|
| 90 |
+
|
| 91 |
+
docker run --rm \
|
| 92 |
+
-v /jobs/nmm:/jobs \
|
| 93 |
+
-v "$PWD/configs:/configs:ro" \
|
| 94 |
+
nmm-data-preprocess:0.3.0 \
|
| 95 |
+
filter --shards /jobs/statistics/shards --output /jobs/filtered --config /configs/audit.json --resolution 256p --aspect-buckets all --aspect-mode loose
|
| 96 |
+
|
| 97 |
+
docker run --rm \
|
| 98 |
+
-v /data/images:/data/images:ro \
|
| 99 |
+
-v /jobs/nmm:/jobs \
|
| 100 |
+
nmm-data-preprocess:0.3.0 \
|
| 101 |
+
export --curated /jobs/filtered --output /jobs/normalized --workers 4
|
| 102 |
+
</code></pre>
|
| 103 |
+
<p>第二条仅使用缓存分片,无须挂载源图;第三条 export 必须能访问原图,路径保持与分析时相同,或通过 <code>--root</code> 改写。容器不是 HF Docker Space 应用,没有监听 Web 端口。</p>
|
| 104 |
+
<p>Windows Docker Desktop 中将 Linux 主机路径替换为实际 Windows 路径;PowerShell 多行续行用反引号,或把命令写成一行,不使用 Bash 的反斜线续行。Dockerfile 是提供的部署方案,Docker 是否可用需在用户环境实际构建验证。</p>
|
| 105 |
+
<h2 id="5-两台服务器的完整流程">5. 两台服务器的完整流程</h2>
|
| 106 |
+
<h3 id="51-制作一份全局清单">5.1 制作一份全局清单</h3>
|
| 107 |
+
<p>在协调机器运行:</p>
|
| 108 |
+
<pre><code class="language-bash">.venv/bin/python -m image_cleaner prepare --input /data/images --manifest /jobs/input.jsonl --sidecar-text
|
| 109 |
+
</code></pre>
|
| 110 |
+
<p>把 <code>input.jsonl</code>、<code>input.jsonl.meta.json</code>、同一代码版本和统计配置分发到所有分析节点。每台节点都要能访问属于其 rank 的图片,图片根目录可以不同;相对路径结构必须保持一致。</p>
|
| 111 |
+
<h3 id="52-各节点分析">5.2 各节点分析</h3>
|
| 112 |
+
<p>节点 0:</p>
|
| 113 |
+
<pre><code class="language-bash">.venv/bin/python -m image_cleaner analyze --manifest /jobs/input.jsonl --root /mnt/images --output /jobs/node0 --config configs/statistics_only.json --rank 0 --world-size 2 --workers 8
|
| 114 |
+
</code></pre>
|
| 115 |
+
<p>节点 1:</p>
|
| 116 |
+
<pre><code class="language-bash">.venv/bin/python -m image_cleaner analyze --manifest /jobs/input.jsonl --root /dataset/images --output /jobs/node1 --config configs/statistics_only.json --rank 1 --world-size 2 --workers 8
|
| 117 |
+
</code></pre>
|
| 118 |
+
<p>每条记录按 <code>seq % 2</code> 分配。不要让两个节点使用同一 rank,也不要向同一个 SQLite 并发写入。节点失败后在原节点增加 <code>--resume</code>,其余参数不变。</p>
|
| 119 |
+
<h3 id="53-收集分片">5.3 收集分片</h3>
|
| 120 |
+
<p>分析进程正常退出后,将各节点的 <code>shard-00000-of-00002.sqlite</code> 与 <code>shard-00001-of-00002.sqlite</code> 放到协调节点 <code>/jobs/all_shards</code>;JSONL 可同时保留用于检查,但 merge 读取 SQLite。必须等分析完成再复制,避免只复制仍依赖 WAL 的数据库。</p>
|
| 121 |
+
<p>先形成统计报告,然后全局筛选去重:</p>
|
| 122 |
+
<pre><code class="language-bash">.venv/bin/python -m image_cleaner merge --shards /jobs/all_shards --output /jobs/global_statistics
|
| 123 |
+
.venv/bin/python -m image_cleaner filter --shards /jobs/all_shards --output /jobs/global_filtered --config configs/audit.json --resolution 256p --aspect-buckets all --aspect-mode loose --aspect-tolerance 0.05
|
| 124 |
+
</code></pre>
|
| 125 |
+
<p>两轮都不需要原图。协调节点会验证所有 rank 完整、配置/清单/身份一致、总记录数匹配;缺失 rank 时拒绝合并,不能把局部去重当全局去重。</p>
|
| 126 |
+
<h3 id="54-统一导出">5.4 统一导出</h3>
|
| 127 |
+
<p>协调节点必须能访问全部保留原图,以统一根目录覆盖各节点不同的绝对 source_path:</p>
|
| 128 |
+
<pre><code class="language-bash">.venv/bin/python -m image_cleaner export --curated /jobs/global_filtered --root /coordinator/images --output /jobs/global_export --workers 8
|
| 129 |
+
</code></pre>
|
| 130 |
+
<p>相对图片路径依据原清单保持不变。当前 CLI 不把导出任务再分配到多个 rank,多机能力主要在 analyze 阶段。</p>
|
| 131 |
+
<h2 id="6-续跑重筛选与故障处理">6. 续跑、重筛选与故障处理</h2>
|
| 132 |
+
<table>
|
| 133 |
+
<thead>
|
| 134 |
+
<tr>
|
| 135 |
+
<th>情况</th>
|
| 136 |
+
<th>操作</th>
|
| 137 |
+
</tr>
|
| 138 |
+
</thead>
|
| 139 |
+
<tbody>
|
| 140 |
+
<tr>
|
| 141 |
+
<td>analyze 正常中断</td>
|
| 142 |
+
<td>相同命令加 --resume;同 manifest/config/version/rank</td>
|
| 143 |
+
</tr>
|
| 144 |
+
<tr>
|
| 145 |
+
<td>run 需要继续</td>
|
| 146 |
+
<td>相同命令加 --resume,不��输入、配置、root、sidecar 设置</td>
|
| 147 |
+
</tr>
|
| 148 |
+
<tr>
|
| 149 |
+
<td>export 中断</td>
|
| 150 |
+
<td>相同命令加 --resume;会检查源和已有输出哈希</td>
|
| 151 |
+
</tr>
|
| 152 |
+
<tr>
|
| 153 |
+
<td>想调整尺寸、桶或质量阈值</td>
|
| 154 |
+
<td>从原 shards filter 到新的输出目录</td>
|
| 155 |
+
</tr>
|
| 156 |
+
<tr>
|
| 157 |
+
<td>想调整颜色、测量区域等</td>
|
| 158 |
+
<td>新输出目录重新 analyze,再 merge</td>
|
| 159 |
+
</tr>
|
| 160 |
+
<tr>
|
| 161 |
+
<td>merge 中途失败</td>
|
| 162 |
+
<td>无中途续跑支持;选新输出目录重新 merge,复用 shards</td>
|
| 163 |
+
</tr>
|
| 164 |
+
<tr>
|
| 165 |
+
<td>出现 .lock</td>
|
| 166 |
+
<td>先确认对应进程已退出,才删除该特定锁;运行中不能删</td>
|
| 167 |
+
</tr>
|
| 168 |
+
<tr>
|
| 169 |
+
<td>源文件在分析后变更</td>
|
| 170 |
+
<td>export 拒绝哈希不一致的样本;重新制作/分析数据</td>
|
| 171 |
+
</tr>
|
| 172 |
+
<tr>
|
| 173 |
+
<td>缺少 rank</td>
|
| 174 |
+
<td>补齐原任务的分片,不修改 world-size 去掩盖缺失</td>
|
| 175 |
+
</tr>
|
| 176 |
+
</tbody>
|
| 177 |
+
</table>
|
| 178 |
+
<p>analyze 每 100 条提交一次检查点,异常退出最多重算尚未提交的记录。改变软件版本会影响 resume 身份,生产作业应固定版本与配置,保存元数据。</p>
|
| 179 |
+
<p>代码读取整个 manifest 再挑选本 rank,不是自动调度器;超大清单、偏斜大图、共享盘拥塞可能限制速度。建议先测 1–10 万代表性图片,记录图片/秒、CPU、峰值内存、磁盘占用、错误率和各规则保留率,再确定节点与进程数。已有测试不等于百万级吞吐量基准。</p>
|
| 180 |
+
<h2 id="7-hugging-face-space-的发布方式">7. Hugging Face Space 的发布方式</h2>
|
| 181 |
+
<p>本项目使用静态 Space:根 README YAML 指定 <code>sdk: static</code> 与 <code>app_file: index.html</code>。页面包含教程、参数、代码下载、命令生成器和比例匹配演示;不连接用户文件系统、不提交训练图片、不运行服务器清洗。</p>
|
| 182 |
+
<p>选择静态托管使文档入口无需清洗计算资源。官方规则参见 <a href="https://huggingface.co/docs/hub/spaces-sdks-static">Static HTML Spaces</a> 和 <a href="https://huggingface.co/docs/hub/spaces-config-reference">配置参考</a>。</p>
|
| 183 |
+
<p>构建发布文件:</p>
|
| 184 |
+
<pre><code class="language-powershell">.\.venv\Scripts\python.exe -m pip install -e ".[publish]"
|
| 185 |
+
.\.venv\Scripts\python.exe tools/build_space.py
|
| 186 |
+
</code></pre>
|
| 187 |
+
<p>本地预览:</p>
|
| 188 |
+
<pre><code class="language-powershell">.\.venv\Scripts\python.exe -m http.server 8000 --bind 127.0.0.1
|
| 189 |
+
</code></pre>
|
| 190 |
+
<p>打开 <code>http://127.0.0.1:8000/index.html</code>。这是预览服务器,不是清洗 API。终端 Ctrl+C 停止。</p>
|
| 191 |
+
<p>发布脚本仅从当前进程的 <code>HF_TOKEN</code> 读取认证,不使用账号密码,不调用持久化 login,不把 Token 写到任何文件。可在安全终端设置环境变量,或从秘密管理器注入;不要在脚本和配置中硬编码真实 Token。</p>
|
| 192 |
+
<pre><code class="language-bash"># HF_TOKEN 已通过环境或秘密管理器注入。
|
| 193 |
+
.venv/bin/python tools/publish_space.py --repo-id HanWangTJU/NMM-data-preprocess
|
| 194 |
+
</code></pre>
|
| 195 |
+
<p>脚本检查登录身份与仓库命名空间;创建不存在的 public static Space,或更新已有 static Space。它不删除远端文件、不变更现有私有性,也不申请付费硬件。先构建再发布,使用 release/publish-manifest.json 指定白名单与 SHA-256;发布前校验哈希、路径和凭据模式。</p>
|
| 196 |
+
<p>发布内容包括库、测试、配置、示例、教程、页面、Dockerfile 和源码 ZIP;排除 .venv、runs、个人原图、运行日志、认证信息与系统路径。README 和 ZIP 保持同一版本,发布收据保存 repo ID、提交 ID、文件数量和页面地址。</p>
|
| 197 |
+
<p>如果网络直连失败,可按实际网络环境设置 HTTPS_PROXY 连接官方 huggingface.co;不要为上传将认证 Token 发给未经确认的镜像站。权限不足时需要具有目标 Space 写入/创建权限的 Token。</p>
|
| 198 |
+
<h2 id="8-部署后的验收">8. 部署后的验收</h2>
|
| 199 |
+
<ol>
|
| 200 |
+
<li><code>--help</code> 可用,测试通过,合成样例能统计/筛选/导出。</li>
|
| 201 |
+
<li>报告四类数量守恒,错误清单包含合成坏文件。</li>
|
| 202 |
+
<li>256p 和宽高比 5% 临界行为符合说明。</li>
|
| 203 |
+
<li>双 rank 与单机结果一致,源图删除后的缓存 filter 正常。</li>
|
| 204 |
+
<li>在线文档和源码 ZIP 可打开,远端文件没有凭据或个人数据。</li>
|
| 205 |
+
<li>实际数据上再做人工抽查与性能试运行,确认阈值和资源设置合适。</li>
|
| 206 |
+
</ol></main></div><footer>NMM-data-preprocess · v0.3.0<span>图片文本数据初筛教程</span></footer></body></html>
|
manual/PARAMETERS.html
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>参数参考 · NMM-data-preprocess</title><link rel="stylesheet" href="../assets/style.css"><script defer src="../assets/app.js"></script></head><body><header class="topbar"><a class="brand" href="../index.html">NMM<span> / </span>data-preprocess</a><nav><a href="https://huggingface.co/spaces/HanWangTJU/NMM-data-preprocess/tree/main">源代码 ↗</a><a href="../downloads/NMM-data-preprocess-0.3.0.zip" download>下载源码 ↓</a></nav></header><div class="manual-layout"><aside class="sidebar"><a href="../index.html">← 返回首页</a><a class="" href="QUICKSTART.html">逐步教程</a><a class="" href="USAGE.html">完整手册</a><a class="active" href="PARAMETERS.html">参数参考</a><a class="" href="DEPLOYMENT.html">部署指南</a><a class="" href="ARCHITECTURE.html">代码说明</a><h2>本页目录</h2><div class="toc">
|
| 2 |
+
<ul>
|
| 3 |
+
<li><a href="#1-如何设置参数">1. 如何设置参数</a></li>
|
| 4 |
+
<li><a href="#2-所有-cli-参数">2. 所有 CLI 参数</a><ul>
|
| 5 |
+
<li><a href="#prepare固定输入清单">prepare:固定输入清单</a></li>
|
| 6 |
+
<li><a href="#analyze分析一个分片">analyze:分析一个分片</a></li>
|
| 7 |
+
<li><a href="#filter-merge重筛选与全局去重">filter / merge:重筛选与全局去重</a></li>
|
| 8 |
+
<li><a href="#共用尺寸与宽高比参数">共用尺寸与宽高比参数</a></li>
|
| 9 |
+
<li><a href="#export标准化输出">export:标准化输出</a></li>
|
| 10 |
+
<li><a href="#run单机完整流程">run:单机完整流程</a></li>
|
| 11 |
+
</ul>
|
| 12 |
+
</li>
|
| 13 |
+
<li><a href="#3-全部-json-配置字段">3. 全部 JSON 配置字段</a><ul>
|
| 14 |
+
<li><a href="#尺寸与比例">尺寸与比例</a></li>
|
| 15 |
+
<li><a href="#质量与文字">质量与文字</a></li>
|
| 16 |
+
<li><a href="#预处理与测量">预处理与测量</a></li>
|
| 17 |
+
<li><a href="#去重">去重</a></li>
|
| 18 |
+
<li><a href="#导出">导出</a></li>
|
| 19 |
+
</ul>
|
| 20 |
+
</li>
|
| 21 |
+
<li><a href="#4-典型配置组合">4. 典型配置组合</a><ul>
|
| 22 |
+
<li><a href="#只做第一轮统计">只做第一轮统计</a></li>
|
| 23 |
+
<li><a href="#尺寸比例文字与去重">尺寸、比例、文字与去重</a></li>
|
| 24 |
+
<li><a href="#原图规范化并限制导出长边">原图规范化并限制导出长边</a></li>
|
| 25 |
+
</ul>
|
| 26 |
+
</li>
|
| 27 |
+
<li><a href="#5-无效设置与返回码">5. 无效设置与返回码</a></li>
|
| 28 |
+
</ul>
|
| 29 |
+
</div>
|
| 30 |
+
</aside><main class="manual"><h1 id="参数参考与设置方法">参数参考与设置方法</h1>
|
| 31 |
+
<h2 id="1-如何设置参数">1. 如何设置参数</h2>
|
| 32 |
+
<p>运行形式是 <code>python -m image_cleaner <子命令> ...</code>;安装后也可用 <code>image-cleaner <子命令> ...</code>。Windows 下建议直接使用虚拟环境的 <code>.\.venv\Scripts\python.exe</code>,Linux 使用 <code>.venv/bin/python</code>。</p>
|
| 33 |
+
<p>CLI 提供流程与常用尺寸条件;质量、文本、去重、导出等设置集中在 JSON 配置文件中,由 <code>--config</code> 加载。配置采用 UTF-8,不能写注释;布尔值用 <code>true/false</code>,关闭可选阈值用 <code>null</code>。所有未知字段会报错,避免拼写错误被静默忽略。</p>
|
| 34 |
+
<p>优先级:</p>
|
| 35 |
+
<ol>
|
| 36 |
+
<li><code>analyze/run</code>:Config 默认值 → JSON 配置 → 显式 CLI 尺寸/宽高比参数。</li>
|
| 37 |
+
<li><code>filter/merge</code> 未传配置:继承分析分片完整配置 → 显式 CLI 参数。</li>
|
| 38 |
+
<li><code>filter/merge</code> 传配置:Config 默认值 → 新 JSON 配置 → 显式 CLI 参数;不与分片字段逐项合并。</li>
|
| 39 |
+
<li><code>export</code>:使用 curated/report.json 中的配置;不接受另一个 <code>--config</code>。</li>
|
| 40 |
+
</ol>
|
| 41 |
+
<p>例如统计分片使用 <code>statistics_only.json</code> 时去重关闭,仅加 <code>--resolution 256p</code> 不会开启去重;添加 <code>--config configs/audit.json</code> 才切换到该配置中的去重设置。</p>
|
| 42 |
+
<h2 id="2-所有-cli-参数">2. 所有 CLI 参数</h2>
|
| 43 |
+
<h3 id="prepare固定输入清单">prepare:固定输入清单</h3>
|
| 44 |
+
<table>
|
| 45 |
+
<thead>
|
| 46 |
+
<tr>
|
| 47 |
+
<th>参数</th>
|
| 48 |
+
<th>必填/默认</th>
|
| 49 |
+
<th>说明</th>
|
| 50 |
+
</tr>
|
| 51 |
+
</thead>
|
| 52 |
+
<tbody>
|
| 53 |
+
<tr>
|
| 54 |
+
<td><code>--input</code></td>
|
| 55 |
+
<td>必填</td>
|
| 56 |
+
<td>图片目录或 UTF-8 JSONL</td>
|
| 57 |
+
</tr>
|
| 58 |
+
<tr>
|
| 59 |
+
<td><code>--manifest</code></td>
|
| 60 |
+
<td>必填</td>
|
| 61 |
+
<td>写入固定清单,同时生成 <code>.meta.json</code>;不能覆盖已有清单</td>
|
| 62 |
+
</tr>
|
| 63 |
+
<tr>
|
| 64 |
+
<td><code>--root</code></td>
|
| 65 |
+
<td>自动推断</td>
|
| 66 |
+
<td>相对图片路径的根目录;目录输入默认自身,JSONL 默认其父目录</td>
|
| 67 |
+
</tr>
|
| 68 |
+
<tr>
|
| 69 |
+
<td><code>--sidecar-text</code></td>
|
| 70 |
+
<td>false</td>
|
| 71 |
+
<td>文件夹输入时读取同名 UTF-8 <code>.txt</code>;JSONL 文本直接来自 text</td>
|
| 72 |
+
</tr>
|
| 73 |
+
</tbody>
|
| 74 |
+
</table>
|
| 75 |
+
<h3 id="analyze分析一个分片">analyze:分析一个分片</h3>
|
| 76 |
+
<table>
|
| 77 |
+
<thead>
|
| 78 |
+
<tr>
|
| 79 |
+
<th>参数</th>
|
| 80 |
+
<th>必填/默认</th>
|
| 81 |
+
<th>说明</th>
|
| 82 |
+
</tr>
|
| 83 |
+
</thead>
|
| 84 |
+
<tbody>
|
| 85 |
+
<tr>
|
| 86 |
+
<td><code>--manifest</code></td>
|
| 87 |
+
<td>必填</td>
|
| 88 |
+
<td>prepare 生成的清单,必须有配套 <code>.meta.json</code></td>
|
| 89 |
+
</tr>
|
| 90 |
+
<tr>
|
| 91 |
+
<td><code>--output</code></td>
|
| 92 |
+
<td>必填</td>
|
| 93 |
+
<td>分片输出目录</td>
|
| 94 |
+
</tr>
|
| 95 |
+
<tr>
|
| 96 |
+
<td><code>--config</code></td>
|
| 97 |
+
<td>Config 默认值</td>
|
| 98 |
+
<td>JSON 配置文件</td>
|
| 99 |
+
</tr>
|
| 100 |
+
<tr>
|
| 101 |
+
<td><code>--root</code></td>
|
| 102 |
+
<td>清单元数据 root</td>
|
| 103 |
+
<td>当前节点的图片根目录,可与制作清单的机器不同</td>
|
| 104 |
+
</tr>
|
| 105 |
+
<tr>
|
| 106 |
+
<td><code>--workers</code></td>
|
| 107 |
+
<td>min(8, CPU 数量)</td>
|
| 108 |
+
<td>本机分析进程数,正整数</td>
|
| 109 |
+
</tr>
|
| 110 |
+
<tr>
|
| 111 |
+
<td><code>--rank</code></td>
|
| 112 |
+
<td>0</td>
|
| 113 |
+
<td>当前分片编号,0 开始</td>
|
| 114 |
+
</tr>
|
| 115 |
+
<tr>
|
| 116 |
+
<td><code>--world-size</code></td>
|
| 117 |
+
<td>1</td>
|
| 118 |
+
<td>总分片数,正整数,rank 必须小于该值</td>
|
| 119 |
+
</tr>
|
| 120 |
+
<tr>
|
| 121 |
+
<td><code>--resume</code></td>
|
| 122 |
+
<td>false</td>
|
| 123 |
+
<td>使用相同清单、配置、版本和分片参数恢复已有 SQLite</td>
|
| 124 |
+
</tr>
|
| 125 |
+
<tr>
|
| 126 |
+
<td>尺寸/桶参数</td>
|
| 127 |
+
<td>见下表</td>
|
| 128 |
+
<td>支持 resolution 与三个 aspect 参数</td>
|
| 129 |
+
</tr>
|
| 130 |
+
</tbody>
|
| 131 |
+
</table>
|
| 132 |
+
<h3 id="filter-merge重筛选与全局去重">filter / merge:重筛选与全局去重</h3>
|
| 133 |
+
<p>两个名称调用同一实现。</p>
|
| 134 |
+
<table>
|
| 135 |
+
<thead>
|
| 136 |
+
<tr>
|
| 137 |
+
<th>参数</th>
|
| 138 |
+
<th>必填/默认</th>
|
| 139 |
+
<th>说明</th>
|
| 140 |
+
</tr>
|
| 141 |
+
</thead>
|
| 142 |
+
<tbody>
|
| 143 |
+
<tr>
|
| 144 |
+
<td><code>--shards</code></td>
|
| 145 |
+
<td>必填</td>
|
| 146 |
+
<td>同一 manifest、版本和分析配置的全部 rank SQLite 所在目录</td>
|
| 147 |
+
</tr>
|
| 148 |
+
<tr>
|
| 149 |
+
<td><code>--output</code></td>
|
| 150 |
+
<td>必填</td>
|
| 151 |
+
<td>一个新的输出目录,不接受覆盖已有合并结果</td>
|
| 152 |
+
</tr>
|
| 153 |
+
<tr>
|
| 154 |
+
<td><code>--config</code></td>
|
| 155 |
+
<td>继承分析配置</td>
|
| 156 |
+
<td>新筛选配置;不能改变预处理/测量设置</td>
|
| 157 |
+
</tr>
|
| 158 |
+
<tr>
|
| 159 |
+
<td>尺寸/桶参数</td>
|
| 160 |
+
<td>见下表</td>
|
| 161 |
+
<td>覆盖有效配置对应字段</td>
|
| 162 |
+
</tr>
|
| 163 |
+
</tbody>
|
| 164 |
+
</table>
|
| 165 |
+
<h3 id="共用尺寸与宽高比参数">共用尺寸与宽高比参数</h3>
|
| 166 |
+
<p>适用于 <code>analyze</code>、<code>run</code>、<code>filter/merge</code>。</p>
|
| 167 |
+
<table>
|
| 168 |
+
<thead>
|
| 169 |
+
<tr>
|
| 170 |
+
<th>参数</th>
|
| 171 |
+
<th>Config 默认值</th>
|
| 172 |
+
<th>取值与规则</th>
|
| 173 |
+
</tr>
|
| 174 |
+
</thead>
|
| 175 |
+
<tbody>
|
| 176 |
+
<tr>
|
| 177 |
+
<td><code>--resolution</code></td>
|
| 178 |
+
<td>null</td>
|
| 179 |
+
<td><code>256p</code> 短边 >=256;<code>384x256</code> 精确宽高;<code>any/none</code> 关闭;必须为正整数尺寸</td>
|
| 180 |
+
</tr>
|
| 181 |
+
<tr>
|
| 182 |
+
<td><code>--aspect-buckets</code></td>
|
| 183 |
+
<td>null</td>
|
| 184 |
+
<td>多选例如 <code>"1:1,2:3"</code>;<code>all</code> 八桶并集;<code>none</code> 关闭;空选择报错</td>
|
| 185 |
+
</tr>
|
| 186 |
+
<tr>
|
| 187 |
+
<td><code>--aspect-mode</code></td>
|
| 188 |
+
<td>strict</td>
|
| 189 |
+
<td>strict 精确整数比例;loose 容差模式</td>
|
| 190 |
+
</tr>
|
| 191 |
+
<tr>
|
| 192 |
+
<td><code>--aspect-tolerance</code></td>
|
| 193 |
+
<td>0.05</td>
|
| 194 |
+
<td>有限非负数,只作用于 loose;0.05=5%,0=不允许偏差</td>
|
| 195 |
+
</tr>
|
| 196 |
+
</tbody>
|
| 197 |
+
</table>
|
| 198 |
+
<p>八桶固定为 <code>1:1,2:3,3:2,3:4,4:3,9:16,16:9,21:9</code>,不支持自定义。所有尺寸条件基于 EXIF 校正后的宽和高;不自动互换横竖桶。严格模式忽略容差参数;未选择桶时模式和容差不会启用过滤。</p>
|
| 199 |
+
<h3 id="export标准化输出">export:标准化输出</h3>
|
| 200 |
+
<table>
|
| 201 |
+
<thead>
|
| 202 |
+
<tr>
|
| 203 |
+
<th>参数</th>
|
| 204 |
+
<th>必填/默认</th>
|
| 205 |
+
<th>说明</th>
|
| 206 |
+
</tr>
|
| 207 |
+
</thead>
|
| 208 |
+
<tbody>
|
| 209 |
+
<tr>
|
| 210 |
+
<td><code>--curated</code></td>
|
| 211 |
+
<td>必填</td>
|
| 212 |
+
<td>filter/merge 输出目录,或 run 的 curated 子目录</td>
|
| 213 |
+
</tr>
|
| 214 |
+
<tr>
|
| 215 |
+
<td><code>--output</code></td>
|
| 216 |
+
<td>必填</td>
|
| 217 |
+
<td>独立导出目录</td>
|
| 218 |
+
</tr>
|
| 219 |
+
<tr>
|
| 220 |
+
<td><code>--root</code></td>
|
| 221 |
+
<td>记录 source_path</td>
|
| 222 |
+
<td>图片移动后覆盖源图片根目录</td>
|
| 223 |
+
</tr>
|
| 224 |
+
<tr>
|
| 225 |
+
<td><code>--workers</code></td>
|
| 226 |
+
<td>min(8, CPU 数量)</td>
|
| 227 |
+
<td>本机导出进程数,正整数</td>
|
| 228 |
+
</tr>
|
| 229 |
+
<tr>
|
| 230 |
+
<td><code>--resume</code></td>
|
| 231 |
+
<td>false</td>
|
| 232 |
+
<td>使用相同配置和输出目录续跑,校验源文件和已导出文件哈希</td>
|
| 233 |
+
</tr>
|
| 234 |
+
</tbody>
|
| 235 |
+
</table>
|
| 236 |
+
<h3 id="run单机完整流程">run:单机完整流程</h3>
|
| 237 |
+
<table>
|
| 238 |
+
<thead>
|
| 239 |
+
<tr>
|
| 240 |
+
<th>参数</th>
|
| 241 |
+
<th>必填/默认</th>
|
| 242 |
+
<th>说明</th>
|
| 243 |
+
</tr>
|
| 244 |
+
</thead>
|
| 245 |
+
<tbody>
|
| 246 |
+
<tr>
|
| 247 |
+
<td><code>--input</code></td>
|
| 248 |
+
<td>必填</td>
|
| 249 |
+
<td>图片文件夹或 JSONL</td>
|
| 250 |
+
</tr>
|
| 251 |
+
<tr>
|
| 252 |
+
<td><code>--output</code></td>
|
| 253 |
+
<td>必填</td>
|
| 254 |
+
<td>工作目录,下设 input、shards、curated 等</td>
|
| 255 |
+
</tr>
|
| 256 |
+
<tr>
|
| 257 |
+
<td><code>--root</code></td>
|
| 258 |
+
<td>自动推断</td>
|
| 259 |
+
<td>图片根目录</td>
|
| 260 |
+
</tr>
|
| 261 |
+
<tr>
|
| 262 |
+
<td><code>--config</code></td>
|
| 263 |
+
<td>Config 默认值</td>
|
| 264 |
+
<td>分析与筛选配置</td>
|
| 265 |
+
</tr>
|
| 266 |
+
<tr>
|
| 267 |
+
<td><code>--workers</code></td>
|
| 268 |
+
<td>min(8, CPU 数量)</td>
|
| 269 |
+
<td>本机多进程数量</td>
|
| 270 |
+
</tr>
|
| 271 |
+
<tr>
|
| 272 |
+
<td><code>--sidecar-text</code></td>
|
| 273 |
+
<td>false</td>
|
| 274 |
+
<td>文件夹输入读取同名文本</td>
|
| 275 |
+
</tr>
|
| 276 |
+
<tr>
|
| 277 |
+
<td><code>--export</code></td>
|
| 278 |
+
<td>false</td>
|
| 279 |
+
<td>合并后导出到 normalized 子目录</td>
|
| 280 |
+
</tr>
|
| 281 |
+
<tr>
|
| 282 |
+
<td><code>--resume</code></td>
|
| 283 |
+
<td>false</td>
|
| 284 |
+
<td>恢复同输入、配置、根目录和 sidecar 设置的已有任务</td>
|
| 285 |
+
</tr>
|
| 286 |
+
<tr>
|
| 287 |
+
<td>尺寸/桶参数</td>
|
| 288 |
+
<td>见上表</td>
|
| 289 |
+
<td>CLI 覆盖相应 JSON 字段</td>
|
| 290 |
+
</tr>
|
| 291 |
+
</tbody>
|
| 292 |
+
</table>
|
| 293 |
+
<p>每个子命令均支持 <code>--help</code>。没有 CLI 参数直接设置质量阈值,也没有 <code>--workers</code> 控制 merge:最终合并由单进程协调。</p>
|
| 294 |
+
<h2 id="3-全部-json-配置字段">3. 全部 JSON 配置字段</h2>
|
| 295 |
+
<h3 id="尺寸与比例">尺寸与比例</h3>
|
| 296 |
+
<table>
|
| 297 |
+
<thead>
|
| 298 |
+
<tr>
|
| 299 |
+
<th>字段</th>
|
| 300 |
+
<th>默认值</th>
|
| 301 |
+
<th>单位/范围与行为</th>
|
| 302 |
+
</tr>
|
| 303 |
+
</thead>
|
| 304 |
+
<tbody>
|
| 305 |
+
<tr>
|
| 306 |
+
<td>resolution</td>
|
| 307 |
+
<td>null</td>
|
| 308 |
+
<td>Np / WxH / any / none;语义同 CLI</td>
|
| 309 |
+
</tr>
|
| 310 |
+
<tr>
|
| 311 |
+
<td>aspect_buckets</td>
|
| 312 |
+
<td>null</td>
|
| 313 |
+
<td>数组、逗号字符串、all、none 或 null;规范化成固定顺序,重复值合并</td>
|
| 314 |
+
</tr>
|
| 315 |
+
<tr>
|
| 316 |
+
<td>aspect_mode</td>
|
| 317 |
+
<td>strict</td>
|
| 318 |
+
<td>strict / loose</td>
|
| 319 |
+
</tr>
|
| 320 |
+
<tr>
|
| 321 |
+
<td>aspect_tolerance</td>
|
| 322 |
+
<td>0.05</td>
|
| 323 |
+
<td>非负有限数,包含边界;不得为 null;设置很大将显著放宽筛选</td>
|
| 324 |
+
</tr>
|
| 325 |
+
<tr>
|
| 326 |
+
<td>min_short_edge</td>
|
| 327 |
+
<td>null</td>
|
| 328 |
+
<td>非负整数像素,下限</td>
|
| 329 |
+
</tr>
|
| 330 |
+
<tr>
|
| 331 |
+
<td>max_long_edge</td>
|
| 332 |
+
<td>null</td>
|
| 333 |
+
<td>非负整数像素,上限</td>
|
| 334 |
+
</tr>
|
| 335 |
+
<tr>
|
| 336 |
+
<td>min_pixels</td>
|
| 337 |
+
<td>null</td>
|
| 338 |
+
<td>非负整数像素总数,下限</td>
|
| 339 |
+
</tr>
|
| 340 |
+
<tr>
|
| 341 |
+
<td>max_pixels</td>
|
| 342 |
+
<td>null</td>
|
| 343 |
+
<td>非负整数像素总数,上限,不小于 min_pixels</td>
|
| 344 |
+
</tr>
|
| 345 |
+
<tr>
|
| 346 |
+
<td>min_aspect_ratio</td>
|
| 347 |
+
<td>null</td>
|
| 348 |
+
<td>非负有限数 W/H,下限</td>
|
| 349 |
+
</tr>
|
| 350 |
+
<tr>
|
| 351 |
+
<td>max_aspect_ratio</td>
|
| 352 |
+
<td>null</td>
|
| 353 |
+
<td>非负有限数 W/H,上限,不小于 min_aspect_ratio</td>
|
| 354 |
+
</tr>
|
| 355 |
+
</tbody>
|
| 356 |
+
</table>
|
| 357 |
+
<p>所有下限用 <code>>=</code>、上限用 <code><=</code>,条件取交集。选择多个桶只在桶之间取并集,不会放宽其他规则。</p>
|
| 358 |
+
<h3 id="质量与文字">质量与文字</h3>
|
| 359 |
+
<table>
|
| 360 |
+
<thead>
|
| 361 |
+
<tr>
|
| 362 |
+
<th>字段</th>
|
| 363 |
+
<th>默认值</th>
|
| 364 |
+
<th>单位/范围与行为</th>
|
| 365 |
+
</tr>
|
| 366 |
+
</thead>
|
| 367 |
+
<tbody>
|
| 368 |
+
<tr>
|
| 369 |
+
<td>min_blur_score</td>
|
| 370 |
+
<td>null</td>
|
| 371 |
+
<td>非负有限数;拉普拉斯方差下限</td>
|
| 372 |
+
</tr>
|
| 373 |
+
<tr>
|
| 374 |
+
<td>max_noise_sigma</td>
|
| 375 |
+
<td>null</td>
|
| 376 |
+
<td>非负有限数;灰度 0–255 尺度噪声估计上限</td>
|
| 377 |
+
</tr>
|
| 378 |
+
<tr>
|
| 379 |
+
<td>min_snr_proxy_db</td>
|
| 380 |
+
<td>null</td>
|
| 381 |
+
<td>有限数,允许负数;SNR 代理 dB 下限</td>
|
| 382 |
+
</tr>
|
| 383 |
+
<tr>
|
| 384 |
+
<td>max_blockiness</td>
|
| 385 |
+
<td>null</td>
|
| 386 |
+
<td>非负有限数;块效应代理上限</td>
|
| 387 |
+
</tr>
|
| 388 |
+
<tr>
|
| 389 |
+
<td>min_text_chars</td>
|
| 390 |
+
<td>null</td>
|
| 391 |
+
<td>非负整数,规范化文本字符数量下限</td>
|
| 392 |
+
</tr>
|
| 393 |
+
<tr>
|
| 394 |
+
<td>max_text_chars</td>
|
| 395 |
+
<td>null</td>
|
| 396 |
+
<td>非负整数,字符上限,不小于 min_text_chars</td>
|
| 397 |
+
</tr>
|
| 398 |
+
<tr>
|
| 399 |
+
<td>forbidden_text_patterns</td>
|
| 400 |
+
<td>[]</td>
|
| 401 |
+
<td>字符串数组;任意 Python 正则命中则拒绝;忽略大小写可用 (?i)</td>
|
| 402 |
+
</tr>
|
| 403 |
+
</tbody>
|
| 404 |
+
</table>
|
| 405 |
+
<p>质量指标缺失时不会触发对应阈值,应另行审计报告 missing 与 warnings。所有质量阈值都需要在目标数据域校准,不能将默认 null 理解为“自动判断质量”。SNR 是代理指标,不是真实 SNR/PSNR。</p>
|
| 406 |
+
<h3 id="预处理与测量">预处理与测量</h3>
|
| 407 |
+
<table>
|
| 408 |
+
<thead>
|
| 409 |
+
<tr>
|
| 410 |
+
<th>字段</th>
|
| 411 |
+
<th>默认值</th>
|
| 412 |
+
<th>单位/范围与行为</th>
|
| 413 |
+
</tr>
|
| 414 |
+
</thead>
|
| 415 |
+
<tbody>
|
| 416 |
+
<tr>
|
| 417 |
+
<td>reject_animated</td>
|
| 418 |
+
<td>true</td>
|
| 419 |
+
<td>布尔值;拒绝动画/多页;false 时只处理所读首帧/第一页</td>
|
| 420 |
+
</tr>
|
| 421 |
+
<tr>
|
| 422 |
+
<td>max_decode_pixels</td>
|
| 423 |
+
<td>100000000</td>
|
| 424 |
+
<td>正整数;单图解码像素上限,超限记录为错误</td>
|
| 425 |
+
</tr>
|
| 426 |
+
<tr>
|
| 427 |
+
<td>metric_patch_size</td>
|
| 428 |
+
<td>256</td>
|
| 429 |
+
<td>整数 >=16;角落和中心的原生像素测量区域边长上限</td>
|
| 430 |
+
</tr>
|
| 431 |
+
<tr>
|
| 432 |
+
<td>alpha_background</td>
|
| 433 |
+
<td>[255,255,255]</td>
|
| 434 |
+
<td>三个 0–255 整数;透明区域合成背景</td>
|
| 435 |
+
</tr>
|
| 436 |
+
<tr>
|
| 437 |
+
<td>convert_icc</td>
|
| 438 |
+
<td>true</td>
|
| 439 |
+
<td>有 ICC 时转 sRGB;无 ICC 时按 Pillow 的 RGB 转换处理</td>
|
| 440 |
+
</tr>
|
| 441 |
+
<tr>
|
| 442 |
+
<td>text_unicode_form</td>
|
| 443 |
+
<td>NFC</td>
|
| 444 |
+
<td>NFC / NFKC / none;仍执行换行与首尾空白规范化</td>
|
| 445 |
+
</tr>
|
| 446 |
+
</tbody>
|
| 447 |
+
</table>
|
| 448 |
+
<p>这六项会改变解码、标准化或指标,重筛选时必须与源分片一致;改变它们需重新 analyze。EXIF 校正与 RGB 转换始终执行。设置 max_decode_pixels 是容量限制,不会缩小超限原图。</p>
|
| 449 |
+
<h3 id="去重">去重</h3>
|
| 450 |
+
<table>
|
| 451 |
+
<thead>
|
| 452 |
+
<tr>
|
| 453 |
+
<th>字段</th>
|
| 454 |
+
<th>默认值</th>
|
| 455 |
+
<th>范围与行为</th>
|
| 456 |
+
</tr>
|
| 457 |
+
</thead>
|
| 458 |
+
<tbody>
|
| 459 |
+
<tr>
|
| 460 |
+
<td>deduplicate</td>
|
| 461 |
+
<td>true</td>
|
| 462 |
+
<td>去重总开关,关闭后所有去重均关闭</td>
|
| 463 |
+
</tr>
|
| 464 |
+
<tr>
|
| 465 |
+
<td>near_deduplicate</td>
|
| 466 |
+
<td>true</td>
|
| 467 |
+
<td>开启近重复,须同时开启 deduplicate;false 仍做精确去重</td>
|
| 468 |
+
</tr>
|
| 469 |
+
<tr>
|
| 470 |
+
<td>duplicate_scope</td>
|
| 471 |
+
<td>image_text</td>
|
| 472 |
+
<td>image_text:图片与规范化文字都满足;image:忽略文字差异</td>
|
| 473 |
+
</tr>
|
| 474 |
+
<tr>
|
| 475 |
+
<td>phash_distance</td>
|
| 476 |
+
<td>4</td>
|
| 477 |
+
<td>整数 0–8,64 位 pHash 汉明距离上限</td>
|
| 478 |
+
</tr>
|
| 479 |
+
<tr>
|
| 480 |
+
<td>dhash_distance</td>
|
| 481 |
+
<td>4</td>
|
| 482 |
+
<td>整数 0–64,64 位 dHash 汉明距离上限</td>
|
| 483 |
+
</tr>
|
| 484 |
+
<tr>
|
| 485 |
+
<td>near_rgb_rmse</td>
|
| 486 |
+
<td>8.0</td>
|
| 487 |
+
<td>非负有限数;16×16 RGB 缩略图 RMSE 上限</td>
|
| 488 |
+
</tr>
|
| 489 |
+
<tr>
|
| 490 |
+
<td>near_aspect_log_tolerance</td>
|
| 491 |
+
<td>0.03</td>
|
| 492 |
+
<td>非负有限数;abs(log(r1/r2)) 上限,与桶容差是不同参数</td>
|
| 493 |
+
</tr>
|
| 494 |
+
</tbody>
|
| 495 |
+
</table>
|
| 496 |
+
<p>近重复条件全部满足才归并。代表优先级为像素多、噪声低、清晰度高、清单 seq 小;不依赖 worker 完成顺序。精确去重检查原文件哈希和标准化像素哈希,作用域包含文本规则。</p>
|
| 497 |
+
<h3 id="导出">导出</h3>
|
| 498 |
+
<table>
|
| 499 |
+
<thead>
|
| 500 |
+
<tr>
|
| 501 |
+
<th>字段</th>
|
| 502 |
+
<th>默认值</th>
|
| 503 |
+
<th>范围与行为</th>
|
| 504 |
+
</tr>
|
| 505 |
+
</thead>
|
| 506 |
+
<tbody>
|
| 507 |
+
<tr>
|
| 508 |
+
<td>export_max_edge</td>
|
| 509 |
+
<td>null</td>
|
| 510 |
+
<td>正整数像素;导出时限制长边,只缩小,不放大</td>
|
| 511 |
+
</tr>
|
| 512 |
+
<tr>
|
| 513 |
+
<td>export_format</td>
|
| 514 |
+
<td>png</td>
|
| 515 |
+
<td>png / jpeg</td>
|
| 516 |
+
</tr>
|
| 517 |
+
<tr>
|
| 518 |
+
<td>export_jpeg_quality</td>
|
| 519 |
+
<td>95</td>
|
| 520 |
+
<td>整数 1–100;JPEG 无色度子采样,仍为有损输出</td>
|
| 521 |
+
</tr>
|
| 522 |
+
</tbody>
|
| 523 |
+
</table>
|
| 524 |
+
<p>导出参数不修改统计阶段尺寸。缩图可能因整数取整产生微小比例变化,报告桶归属针对导出前的规范化原尺寸。修改导出设置先重新 filter 到新 curated 目录再 export。</p>
|
| 525 |
+
<h2 id="4-典型配置组合">4. 典型配置组合</h2>
|
| 526 |
+
<h3 id="只做第一轮统计">只做第一轮统计</h3>
|
| 527 |
+
<pre><code class="language-json">{"deduplicate":false,"near_deduplicate":false}
|
| 528 |
+
</code></pre>
|
| 529 |
+
<h3 id="尺寸比例文字与去重">尺寸、比例、文字与去重</h3>
|
| 530 |
+
<pre><code class="language-json">{
|
| 531 |
+
"resolution": "256p",
|
| 532 |
+
"aspect_buckets": ["1:1","2:3","3:2","9:16","16:9"],
|
| 533 |
+
"aspect_mode": "loose",
|
| 534 |
+
"aspect_tolerance": 0.05,
|
| 535 |
+
"min_text_chars": 1,
|
| 536 |
+
"deduplicate": true,
|
| 537 |
+
"near_deduplicate": true,
|
| 538 |
+
"duplicate_scope": "image_text",
|
| 539 |
+
"export_format": "png"
|
| 540 |
+
}
|
| 541 |
+
</code></pre>
|
| 542 |
+
<h3 id="原图规范化并限制导出长边">原图规范化并限制导出长边</h3>
|
| 543 |
+
<pre><code class="language-json">{"resolution":"256p","export_max_edge":1024,"export_format":"jpeg","export_jpeg_quality":95}
|
| 544 |
+
</code></pre>
|
| 545 |
+
<p><code>generation_example.json</code> 的 512 下限和比例范围是演示配置,不是通用训练推荐。<code>exact_256p.json</code> 是兼容旧文件名,内容实际为短边 >=256,请优先使用含义清晰的 <code>above_256p.json</code>。</p>
|
| 546 |
+
<h2 id="5-无效设置与返回码">5. 无效设置与返回码</h2>
|
| 547 |
+
<p>常见非法值:<code>resolution=256</code>、<code>aspect_buckets=[]</code>、<code>aspect_tolerance=5%</code>、数值 NaN/Infinity、负容差、拼错字段、质量阈值写成字符串、正则语法错误。容差 CLI 只能写数值,5% 写 0.05。</p>
|
| 548 |
+
<p>正常完成返回 0;CLI 捕获的配置/文件/SQLite 错误返回 2;export 存在导出失败,或 run 的导出环节存在失败,返回 1。单图分析失败会记入 error,通常整个分析仍返回 0,应核对报告。非法正则等未统一捕获的异常可能显示 Python traceback 并返回非零。</p>
|
| 549 |
+
<p>完整算法公式与各字段的评测解释见 <a href="USAGE.html">使用手册</a>,故障恢复见 <a href="DEPLOYMENT.html">部署指南</a>。</p></main></div><footer>NMM-data-preprocess · v0.3.0<span>图片文本数据初筛教程</span></footer></body></html>
|
manual/QUICKSTART.html
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>逐步教程 · NMM-data-preprocess</title><link rel="stylesheet" href="../assets/style.css"><script defer src="../assets/app.js"></script></head><body><header class="topbar"><a class="brand" href="../index.html">NMM<span> / </span>data-preprocess</a><nav><a href="https://huggingface.co/spaces/HanWangTJU/NMM-data-preprocess/tree/main">源代码 ↗</a><a href="../downloads/NMM-data-preprocess-0.3.0.zip" download>下载源码 ↓</a></nav></header><div class="manual-layout"><aside class="sidebar"><a href="../index.html">← 返回首页</a><a class="active" href="QUICKSTART.html">逐步教程</a><a class="" href="USAGE.html">完整手册</a><a class="" href="PARAMETERS.html">参数参考</a><a class="" href="DEPLOYMENT.html">部署指南</a><a class="" href="ARCHITECTURE.html">代码说明</a><h2>本页目录</h2><div class="toc">
|
| 2 |
+
<ul>
|
| 3 |
+
<li><a href="#1-下载代码">1. 下载代码</a></li>
|
| 4 |
+
<li><a href="#2-windows-安装">2. Windows 安装</a></li>
|
| 5 |
+
<li><a href="#3-linux-安装">3. Linux 安装</a></li>
|
| 6 |
+
<li><a href="#4-先用合成样例跑通">4. 先用合成样例跑通</a></li>
|
| 7 |
+
<li><a href="#5-实际数据怎样输入">5. 实际数据怎样输入</a></li>
|
| 8 |
+
<li><a href="#6-在统计后筛选尺寸与宽高比">6. 在统计后筛选尺寸与宽高比</a></li>
|
| 9 |
+
<li><a href="#7-再加入文本与去重规则">7. 再加入文本与去重规则</a></li>
|
| 10 |
+
<li><a href="#8-查看报告导出训练清单">8. 查看报告、导出训练清单</a></li>
|
| 11 |
+
<li><a href="#9-测试和后续阅读">9. 测试和后续阅读</a></li>
|
| 12 |
+
</ul>
|
| 13 |
+
</div>
|
| 14 |
+
</aside><main class="manual"><h1 id="从下载到第一轮图片文本初筛">从下载到第一轮图片—文本初筛</h1>
|
| 15 |
+
<h2 id="1-下载代码">1. 下载代码</h2>
|
| 16 |
+
<p>安装 Python 3.10 或更新版本,建议 Python 3.12。使用 Git 下载,或者在 Space 首页下载源码 ZIP,解压到 <code>multimodal_training</code>。</p>
|
| 17 |
+
<pre><code class="language-bash">git clone https://huggingface.co/spaces/HanWangTJU/NMM-data-preprocess multimodal_training
|
| 18 |
+
cd multimodal_training
|
| 19 |
+
</code></pre>
|
| 20 |
+
<p>后续命令都在此目录运行。安装过程需要联网下载 NumPy 和 Pillow;实际清洗不需要联网或 GPU。</p>
|
| 21 |
+
<h2 id="2-windows-安装">2. Windows 安装</h2>
|
| 22 |
+
<p>在 PowerShell 执行,无需激活环境或修改执行策略:</p>
|
| 23 |
+
<pre><code class="language-powershell">py -3.12 -m venv .venv
|
| 24 |
+
.\.venv\Scripts\python.exe -m pip install --upgrade pip
|
| 25 |
+
.\.venv\Scripts\python.exe -m pip install -e .
|
| 26 |
+
.\.venv\Scripts\python.exe -m image_cleaner --help
|
| 27 |
+
</code></pre>
|
| 28 |
+
<p>如果没有 Python 3.12,将第一条改为 <code>python -m venv .venv</code>,并先确认 <code>python --version</code> 至少为 3.10。</p>
|
| 29 |
+
<h2 id="3-linux-安装">3. Linux 安装</h2>
|
| 30 |
+
<pre><code class="language-bash">python3 -m venv .venv
|
| 31 |
+
.venv/bin/python -m pip install --upgrade pip
|
| 32 |
+
.venv/bin/python -m pip install -e .
|
| 33 |
+
.venv/bin/python -m image_cleaner --help
|
| 34 |
+
</code></pre>
|
| 35 |
+
<p>后文 Windows 命令中的 <code>.\.venv\Scripts\python.exe</code> 在 Linux 替换为 <code>.venv/bin/python</code>,路径也换成实际 Linux 路径。</p>
|
| 36 |
+
<h2 id="4-先用合成样例跑通">4. 先用合成样例跑通</h2>
|
| 37 |
+
<pre><code class="language-powershell">.\.venv\Scripts\python.exe examples/make_demo.py
|
| 38 |
+
.\.venv\Scripts\python.exe -m image_cleaner run --input runs/demo_input --output runs/tutorial_statistics --config configs/statistics_only.json --workers 2 --sidecar-text
|
| 39 |
+
</code></pre>
|
| 40 |
+
<p>合成示例包括重压缩、同像素不同格式、模糊、噪声、低分辨率、透明图、极端宽高比和坏文件。示例不是真实训练数据。生成脚本发现目标目录已存在时会停止,避免覆盖。</p>
|
| 41 |
+
<p>预期输入 12 条,成功处理 11 条、错误 1 条;统计配置关闭可选阈值和去重,所以 11 条成功记录全部保留。打开 <code>runs/tutorial_statistics/curated/report.html</code>,查看原始分布与质量代理指标。坏文件应出现在 <code>error.jsonl</code>,不会使全批分析终止。</p>
|
| 42 |
+
<h2 id="5-实际数据怎样输入">5. 实际数据怎样输入</h2>
|
| 43 |
+
<p>最直接的方式是递归扫描图片文件夹:</p>
|
| 44 |
+
<pre><code class="language-text">D:/datasets/images/
|
| 45 |
+
animal/cat.jpg
|
| 46 |
+
animal/cat.txt
|
| 47 |
+
scene/road.png
|
| 48 |
+
scene/road.txt
|
| 49 |
+
</code></pre>
|
| 50 |
+
<p>添加 <code>--sidecar-text</code> 会读取同名 <code>.txt</code> 文本(UTF-8)。没有文本的图片仍可导入;后续可用 <code>min_text_chars</code> 条件筛掉。</p>
|
| 51 |
+
<p>已有图文配对时更推荐 JSONL,一行一条,路径相对 <code>--root</code>:</p>
|
| 52 |
+
<pre><code class="language-json">{"id":"cat-001","path":"animal/cat.jpg","text":"一只猫坐在窗边。","metadata":{"source":"my_corpus"}}
|
| 53 |
+
{"id":"road-002","path":"scene/road.png","text":"雨后的城市道路。"}
|
| 54 |
+
</code></pre>
|
| 55 |
+
<pre><code class="language-powershell">.\.venv\Scripts\python.exe -m image_cleaner run --input "D:\datasets\pairs.jsonl" --root "D:\datasets\images" --output runs/my_statistics --config configs/statistics_only.json --workers 8
|
| 56 |
+
</code></pre>
|
| 57 |
+
<p>输出目录应位于原始图片文件夹之外。工具不修改或删除原始图片。文件夹导入未显式启用 <code>--sidecar-text</code> 时,不会自动配对文本。</p>
|
| 58 |
+
<h2 id="6-在统计后筛选尺寸与宽高比">6. 在统计后筛选尺寸与宽高比</h2>
|
| 59 |
+
<pre><code class="language-powershell"># 短边 >=256;宽高比在八桶内,允许 5% 对称偏差。
|
| 60 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/tutorial_statistics/shards --output runs/tutorial_filtered --resolution 256p --aspect-buckets all --aspect-mode loose --aspect-tolerance 0.05
|
| 61 |
+
|
| 62 |
+
# 严格只留方图或 3:2 横图,输出到另一个目录。
|
| 63 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/tutorial_statistics/shards --output runs/tutorial_strict --resolution 256p --aspect-buckets "1:1,3:2" --aspect-mode strict
|
| 64 |
+
</code></pre>
|
| 65 |
+
<p><code>256p</code> 的定义是 <code>min(width,height)>=256</code>,包括正好 256;不要求短边必须等于 256。<code>256x256</code> 才是精确尺寸。宽高比始终按方向校正后的宽:高计算。</p>
|
| 66 |
+
<p>宽松偏差为 <code>max(r/b,b/r)-1</code>,<code>0.05</code> 表示 5%。参数说明和完整边界例子见 <a href="USAGE.html">完整手册</a>。重筛选读取原分析分片,不重复解码;规则取交集。</p>
|
| 67 |
+
<h2 id="7-再加入文本与去重规则">7. 再加入文本与去重规则</h2>
|
| 68 |
+
<p>在项目根目录新建 <code>my_rules.json</code>:</p>
|
| 69 |
+
<pre><code class="language-json">{
|
| 70 |
+
"resolution": "256p",
|
| 71 |
+
"aspect_buckets": "all",
|
| 72 |
+
"aspect_mode": "loose",
|
| 73 |
+
"aspect_tolerance": 0.05,
|
| 74 |
+
"min_text_chars": 1,
|
| 75 |
+
"deduplicate": true,
|
| 76 |
+
"near_deduplicate": true,
|
| 77 |
+
"duplicate_scope": "image_text"
|
| 78 |
+
}
|
| 79 |
+
</code></pre>
|
| 80 |
+
<pre><code class="language-powershell">.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/tutorial_statistics/shards --output runs/tutorial_dedup --config my_rules.json
|
| 81 |
+
</code></pre>
|
| 82 |
+
<p>显式提供 JSON 配置文件时,未写字段使用 Config 默认值,而不是与分析分片逐项合并。默认开启精确与近重复去重;纯统计配置特意关闭了它们。同图不同规范化文本默认保留;改为 <code>duplicate_scope=image</code> 会忽略文本差异。</p>
|
| 83 |
+
<p>质量阈值如 <code>min_blur_score</code>、<code>max_noise_sigma</code>、<code>min_snr_proxy_db</code>、<code>max_blockiness</code> 应先在代表性样本上校准,再写入配置;没有跨数据域通用的推荐数值。</p>
|
| 84 |
+
<h2 id="8-查看报告导出训练清单">8. 查看报告、导出训练清单</h2>
|
| 85 |
+
<pre><code class="language-powershell">.\.venv\Scripts\python.exe -m image_cleaner export --curated runs/tutorial_dedup --output runs/tutorial_export --workers 2
|
| 86 |
+
</code></pre>
|
| 87 |
+
<p>清洗目录有 <code>kept/rejected/duplicate/error.jsonl</code>、<code>report.json</code>、<code>report.html</code>、<code>curation.sqlite</code>。导出目录有 RGB 图片、相对路径与文本组成的 <code>exported.jsonl</code>、失败清单和续跑元数据。</p>
|
| 88 |
+
<p>优先核对以下项目:</p>
|
| 89 |
+
<ol>
|
| 90 |
+
<li>输入数等于保留、拒绝、重复、错误四类数量之和。</li>
|
| 91 |
+
<li>错误数及错误原因;质量缺失指标并不等于通过人工质量检验。</li>
|
| 92 |
+
<li>分辨率与桶的独立通过率,以及全部规则通过率。</li>
|
| 93 |
+
<li>各桶前后样本数量;抽查被拒绝的临界图片和近重复判定。</li>
|
| 94 |
+
<li>最终图文是否对应。当前工具没有图文语义一致性模型。</li>
|
| 95 |
+
</ol>
|
| 96 |
+
<p>导出的文本已做 Unicode 与空白规范化;图片做 EXIF、色彩、透明度与 RGB 标准化。训练时的张量归一化、裁剪和多模态对齐仍由训练数据加载器负责。</p>
|
| 97 |
+
<h2 id="9-测试和后续阅读">9. 测试和后续阅读</h2>
|
| 98 |
+
<pre><code class="language-powershell">.\.venv\Scripts\python.exe -m unittest discover -s tests -v
|
| 99 |
+
</code></pre>
|
| 100 |
+
<p>基础版本包含 21 项清洗测试。详细参数见 <a href="PARAMETERS.html">参数参考</a>,多机与续跑见 <a href="DEPLOYMENT.html">部署指南</a>,逐阶段指标见 <a href="USAGE.html">完整手册</a>,实现细节见 <a href="ARCHITECTURE.html">代码说明</a>。</p></main></div><footer>NMM-data-preprocess · v0.3.0<span>图片文本数据初筛教程</span></footer></body></html>
|
manual/USAGE.html
ADDED
|
@@ -0,0 +1,816 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>完整手册 · NMM-data-preprocess</title><link rel="stylesheet" href="../assets/style.css"><script defer src="../assets/app.js"></script></head><body><header class="topbar"><a class="brand" href="../index.html">NMM<span> / </span>data-preprocess</a><nav><a href="https://huggingface.co/spaces/HanWangTJU/NMM-data-preprocess/tree/main">源代码 ↗</a><a href="../downloads/NMM-data-preprocess-0.3.0.zip" download>下载源码 ↓</a></nav></header><div class="manual-layout"><aside class="sidebar"><a href="../index.html">← 返回首页</a><a class="" href="QUICKSTART.html">逐步教程</a><a class="active" href="USAGE.html">完整手册</a><a class="" href="PARAMETERS.html">参数参考</a><a class="" href="DEPLOYMENT.html">部署指南</a><a class="" href="ARCHITECTURE.html">代码说明</a><h2>本页目录</h2><div class="toc">
|
| 2 |
+
<ul>
|
| 3 |
+
<li><a href="#1-工具范围和运行环境">1. 工具范围和运行环境</a></li>
|
| 4 |
+
<li><a href="#2-输入方式">2. 输入方式</a><ul>
|
| 5 |
+
<li><a href="#21-文件夹适合直接批量导入">2.1 文件夹:适合直接批量导入</a></li>
|
| 6 |
+
<li><a href="#22-jsonl适合图文配对与多机迁移">2.2 JSONL:适合图文配对与多机迁移</a></li>
|
| 7 |
+
</ul>
|
| 8 |
+
</li>
|
| 9 |
+
<li><a href="#3-推荐流程先统计再筛选再导出">3. 推荐流程:先统计,再筛选,再导出</a><ul>
|
| 10 |
+
<li><a href="#31-全量分布统计">3.1 全量分布统计</a></li>
|
| 11 |
+
<li><a href="#32-保留-256p-及以上的图片">3.2 保留 256p 及以上的图片</a></li>
|
| 12 |
+
<li><a href="#33-分辨率与其他筛选一起执行">3.3 分辨率与其他筛选一起执行</a></li>
|
| 13 |
+
<li><a href="#34-导出标准化图片与文本">3.4 导出标准化图片与文本</a></li>
|
| 14 |
+
<li><a href="#35-一键流程">3.5 一键流程</a></li>
|
| 15 |
+
<li><a href="#36-宽高比桶筛选">3.6 宽高比桶筛选</a></li>
|
| 16 |
+
</ul>
|
| 17 |
+
</li>
|
| 18 |
+
<li><a href="#4-各环节指标和判定规则">4. 各环节指标和判定规则</a><ul>
|
| 19 |
+
<li><a href="#41-导入解码和容量检查">4.1 导入、解码和容量检查</a></li>
|
| 20 |
+
<li><a href="#42-标准化">4.2 标准化</a></li>
|
| 21 |
+
<li><a href="#43-分布统计分辨率和宽高比">4.3 分布统计、分辨率和宽高比</a></li>
|
| 22 |
+
<li><a href="#44-图片技术质量">4.4 图片技术质量</a></li>
|
| 23 |
+
<li><a href="#45-文本筛选">4.5 文本筛选</a></li>
|
| 24 |
+
<li><a href="#46-去重">4.6 去重</a></li>
|
| 25 |
+
<li><a href="#47-阶段评测指标">4.7 阶段评测指标</a></li>
|
| 26 |
+
<li><a href="#48-宽高比桶筛选评测指标">4.8 宽高比桶筛选评测指标</a></li>
|
| 27 |
+
</ul>
|
| 28 |
+
</li>
|
| 29 |
+
<li><a href="#5-配置文件和参数">5. 配置文件和参数</a></li>
|
| 30 |
+
<li><a href="#6-多机分片流程">6. 多机分片流程</a></li>
|
| 31 |
+
<li><a href="#7-输出断点续跑和复核">7. 输出、断点续跑和复核</a></li>
|
| 32 |
+
<li><a href="#8-验证与功能边界">8. 验证与功能边界</a></li>
|
| 33 |
+
</ul>
|
| 34 |
+
</div>
|
| 35 |
+
</aside><main class="manual"><h1 id="图片文本数据清洗完整操作与指标手册">图片—文本数据清洗:完整操作与指标手册</h1>
|
| 36 |
+
<h2 id="1-工具范围和运行环境">1. 工具范围和运行环境</h2>
|
| 37 |
+
<p>本工具处理本地图片与可选文本,完成导入、规范化测量、分布统计、分辨率/质量筛选、去重和独立导出。源文件不修改、不删除。保留/拒绝/重复/错误的判断通过输出清单表达。</p>
|
| 38 |
+
<p>Python 3.10+;Windows 与 Linux;基础处理不需要 GPU。先下载仓库,以下以安装目录为例:</p>
|
| 39 |
+
<pre><code class="language-text">D:\projects\multimodal_training
|
| 40 |
+
</code></pre>
|
| 41 |
+
<p>Windows 首次安装需要建立 <code>.venv</code>,完成后进入项目目录使用:</p>
|
| 42 |
+
<pre><code class="language-powershell">cd D:\projects\multimodal_training
|
| 43 |
+
.\.venv\Scripts\python.exe -m image_cleaner --help
|
| 44 |
+
</code></pre>
|
| 45 |
+
<p>新机器首次安装:</p>
|
| 46 |
+
<pre><code class="language-powershell">python -m venv .venv
|
| 47 |
+
.\.venv\Scripts\python.exe -m pip install -e .
|
| 48 |
+
</code></pre>
|
| 49 |
+
<p>Linux 对应安装与命令前缀:</p>
|
| 50 |
+
<pre><code class="language-bash">python3 -m venv .venv
|
| 51 |
+
.venv/bin/python -m pip install -e .
|
| 52 |
+
.venv/bin/python -m image_cleaner --help
|
| 53 |
+
</code></pre>
|
| 54 |
+
<p>下文 Windows 命令均在项目根目录运行。Linux 将前缀改为 <code>.venv/bin/python</code> 并使用相应路径即可。每次筛选使用新的输出目录。</p>
|
| 55 |
+
<h2 id="2-输入方式">2. 输入方式</h2>
|
| 56 |
+
<h3 id="21-文件夹适合直接批量导入">2.1 文件夹:适合直接批量导入</h3>
|
| 57 |
+
<pre><code class="language-text">D:\datasets\images\
|
| 58 |
+
animals\001.jpg
|
| 59 |
+
animals\001.txt
|
| 60 |
+
charts\002.png
|
| 61 |
+
charts\002.txt
|
| 62 |
+
</code></pre>
|
| 63 |
+
<p>递归发现 <code>.jpg/.jpeg/.png/.webp/.bmp/.tif/.tiff/.gif/.avif</code>。图片有同名 UTF-8 <code>.txt</code> 时加 <code>--sidecar-text</code>,否则只导入图片。默认不跟随目录符号链接;扩展名发现列表与实际可解码格式不同,具体解码能力取决于 Pillow。</p>
|
| 64 |
+
<h3 id="22-jsonl适合图文配对与多机迁移">2.2 JSONL:适合图文配对与多机迁移</h3>
|
| 65 |
+
<p>每行至少一个字符串 <code>path</code>,可以附带 <code>text</code>、<code>id</code>、<code>metadata</code>:</p>
|
| 66 |
+
<pre><code class="language-json">{"id":"original-001","path":"animals/001.jpg","text":"一只猫坐在窗边。","metadata":{"source":"dataset-a"}}
|
| 67 |
+
{"id":"original-002","path":"charts/002.png","text":"一个展示月份销量的柱状图。"}
|
| 68 |
+
</code></pre>
|
| 69 |
+
<p>相对路径以 <code>--root</code> 为基准;省略时以输入 JSONL 所在目录为基准。原始 ID 保存为 <code>source_id</code>,其他额外字段归入 <code>metadata</code>。同一图片允许多条不同文本记录。跨节点推荐相对路径;绝对路径不受 <code>--root</code> 重映射。</p>
|
| 70 |
+
<pre><code class="language-powershell">.\.venv\Scripts\python.exe -m image_cleaner run --input "D:\datasets\pairs.jsonl" --root "D:\datasets\images" --output runs/pairs_statistics --config configs/statistics_only.json --workers 8
|
| 71 |
+
</code></pre>
|
| 72 |
+
<p>格式错误的输入 JSONL、非字符串文本或无法读取的 sidecar 会使导入报错;单张图片缺失或损坏在分析阶段记录为错误。工具不直接下载 URL 或读取压缩包内的图片。</p>
|
| 73 |
+
<h2 id="3-推荐流程先统计再筛选再导出">3. 推荐流程:先统计,再筛选,再导出</h2>
|
| 74 |
+
<h3 id="31-全量分布统计">3.1 全量分布统计</h3>
|
| 75 |
+
<pre><code class="language-powershell">.\.venv\Scripts\python.exe -m image_cleaner run --input "D:\datasets\images" --output runs/statistics --config configs/statistics_only.json --workers 8 --sidecar-text
|
| 76 |
+
</code></pre>
|
| 77 |
+
<p>只处理图片且没有文本时移除 <code>--sidecar-text</code>。统计配置关闭尺寸/质量/文本可选阈值和去重,成功处理的静态图片全部保留;损坏、超过容量限制、动画/多页等仍作为错误记录。</p>
|
| 78 |
+
<p>查看报告:</p>
|
| 79 |
+
<pre><code class="language-powershell">Start-Process -FilePath "runs/statistics/curated/report.html"
|
| 80 |
+
</code></pre>
|
| 81 |
+
<p><code>report.json</code> 适合程序读取,<code>report.html</code> 包含无外部依赖的分布图。先检查分辨率、宽高比、噪声与清晰度分布,再确定规则。</p>
|
| 82 |
+
<h3 id="32-保留-256p-及以上的图片">3.2 保留 256p 及以上的图片</h3>
|
| 83 |
+
<pre><code class="language-powershell">.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/statistics/shards --output runs/only_256p --resolution 256p
|
| 84 |
+
</code></pre>
|
| 85 |
+
<p><code>256p</code> 的定义是 <code>min(width, height) >= 256</code>,表示短边大于或等于 256 像素,256 本身保留。它不表示缩放图片,与 <code>min_short_edge=256</code> 的尺寸边界相同。</p>
|
| 86 |
+
<table>
|
| 87 |
+
<thead>
|
| 88 |
+
<tr>
|
| 89 |
+
<th>图片尺寸(宽×高)</th>
|
| 90 |
+
<th><code>256p</code></th>
|
| 91 |
+
<th><code>256x256</code></th>
|
| 92 |
+
<th><code>384x256</code></th>
|
| 93 |
+
</tr>
|
| 94 |
+
</thead>
|
| 95 |
+
<tbody>
|
| 96 |
+
<tr>
|
| 97 |
+
<td>256×256</td>
|
| 98 |
+
<td>保留</td>
|
| 99 |
+
<td>保留</td>
|
| 100 |
+
<td>拒绝</td>
|
| 101 |
+
</tr>
|
| 102 |
+
<tr>
|
| 103 |
+
<td>384×256</td>
|
| 104 |
+
<td>保留</td>
|
| 105 |
+
<td>拒绝</td>
|
| 106 |
+
<td>保留</td>
|
| 107 |
+
</tr>
|
| 108 |
+
<tr>
|
| 109 |
+
<td>256×384</td>
|
| 110 |
+
<td>保留</td>
|
| 111 |
+
<td>拒绝</td>
|
| 112 |
+
<td>拒绝</td>
|
| 113 |
+
</tr>
|
| 114 |
+
<tr>
|
| 115 |
+
<td>255×384</td>
|
| 116 |
+
<td>拒绝</td>
|
| 117 |
+
<td>拒绝</td>
|
| 118 |
+
<td>拒绝</td>
|
| 119 |
+
</tr>
|
| 120 |
+
<tr>
|
| 121 |
+
<td>257×384</td>
|
| 122 |
+
<td>保留</td>
|
| 123 |
+
<td>拒绝</td>
|
| 124 |
+
<td>拒绝</td>
|
| 125 |
+
</tr>
|
| 126 |
+
<tr>
|
| 127 |
+
<td>512×512</td>
|
| 128 |
+
<td>保留</td>
|
| 129 |
+
<td>拒绝</td>
|
| 130 |
+
<td>拒绝</td>
|
| 131 |
+
</tr>
|
| 132 |
+
</tbody>
|
| 133 |
+
</table>
|
| 134 |
+
<p>其他选择:</p>
|
| 135 |
+
<pre><code class="language-powershell"># 仅保留 256×256。
|
| 136 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/statistics/shards --output runs/only_square --resolution 256x256
|
| 137 |
+
|
| 138 |
+
# 仅保留宽 384、高 256。
|
| 139 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/statistics/shards --output runs/only_landscape --resolution 384x256
|
| 140 |
+
|
| 141 |
+
# 改为短边大于或等于 512。
|
| 142 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/statistics/shards --output runs/only_512p --resolution 512p
|
| 143 |
+
|
| 144 |
+
# 关闭分辨率条件。
|
| 145 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/statistics/shards --output runs/all_resolutions --resolution any
|
| 146 |
+
</code></pre>
|
| 147 |
+
<p>尺寸以 EXIF 方向校正后的宽高为准:编码为 256×384、EXIF 要求旋转 90° 的图片会以 384×256 判断。色彩转换不改变尺寸;导出缩放发生在筛选之后。</p>
|
| 148 |
+
<p><code>filter</code> 等价于 <code>merge</code>,始终从分析分片里的全部记录重新判定,不能把它的 <code>--shards</code> 指向上一轮 <code>curated</code> 目录。即使第一轮某图片被阈值或去重排除,分析结果仍在分片中,可以再次参与筛选。原图不会被重新解码;物理导出仍需要原图存在。</p>
|
| 149 |
+
<h3 id="33-分辨率与其他筛选一起执行">3.3 分辨率与其他筛选一��执行</h3>
|
| 150 |
+
<p>使用配置文件可同时加入宽高比、质量、文本条件或去重:</p>
|
| 151 |
+
<pre><code class="language-powershell">.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/statistics/shards --output runs/256p_deduplicated --config configs/audit.json --resolution 256p
|
| 152 |
+
</code></pre>
|
| 153 |
+
<p>上例额外开启去重;符合尺寸的重复记录仍会进入 <code>duplicate.jsonl</code>。如果只希望剔除短边小于 256 的图片,沿用统计配置或指定 <code>--config configs/above_256p.json</code>,其去重关闭。</p>
|
| 154 |
+
<p>配置优先级:未传 <code>--config</code> 时继承分析时的完整配置;传了 <code>--config</code> 时从该 JSON 和代码默认值构造新配置,不是对原配置做部分补丁;<code>--resolution</code> 最后覆盖分辨率字段。所有启用规则取交集。若同时设置 <code>min_short_edge=512</code> 与 <code>resolution=256p</code>,最终仍要求短边至少 512;如需允许所有大于或等于 256 的尺寸,先关闭更严格的其他尺寸规则。</p>
|
| 155 |
+
<p>阈值修改和精确分辨率筛选可以复用分析结果。<code>reject_animated/max_decode_pixels/metric_patch_size/alpha_background/convert_icc/text_unicode_form</code> 属于测量与预处理设置,改变这些设置需要重新分析。</p>
|
| 156 |
+
<h3 id="34-导出标准化图片与文本">3.4 导出标准化图片与文本</h3>
|
| 157 |
+
<pre><code class="language-powershell">.\.venv\Scripts\python.exe -m image_cleaner export --curated runs/only_256p --output runs/export_256p --workers 8
|
| 158 |
+
</code></pre>
|
| 159 |
+
<p>只导出 <code>kept.jsonl</code> 中的记录,默认 PNG、RGB、保留已匹配尺寸,不做裁剪或放大。文件放在 <code>images</code> 子目录,<code>exported.jsonl</code> 的 <code>path</code> 为导出目录下的相对路径。导出会校验源文件 SHA-256;源文件在分析后发生变化会记录失败。</p>
|
| 160 |
+
<p><code>export_max_edge</code> 如被启用,可能改变筛选后图片尺寸;需要导出文件仍满足筛选条件时保持 <code>export_max_edge=null</code>。例如 1024×512 符合 256p 及以上,但若导出最长边限制为 384,则会缩成 384×192,输出不再满足短边至少 256 的条件。</p>
|
| 161 |
+
<h3 id="35-一键流程">3.5 一键流程</h3>
|
| 162 |
+
<p>已经明确目标时可以直接执行:</p>
|
| 163 |
+
<pre><code class="language-powershell">.\.venv\Scripts\python.exe -m image_cleaner run --input "D:\datasets\images" --output runs/direct_256p --config configs/above_256p.json --workers 8 --export
|
| 164 |
+
</code></pre>
|
| 165 |
+
<p>也可以在 <code>run</code> 或 <code>analyze</code> 上直接传 <code>--resolution</code>;推荐第一次先完成全量统计。</p>
|
| 166 |
+
<h3 id="36-宽高比桶筛选">3.6 宽高比桶筛选</h3>
|
| 167 |
+
<p>比例始终为 EXIF 方向校正后的 <strong>宽:高</strong>。八个固定桶为 <code>1:1,2:3,3:2,3:4,4:3,9:16,16:9,21:9</code>。竖图与横图分别选择,例如选择 <code>2:3</code> 不会自动选择 <code>3:2</code>。</p>
|
| 168 |
+
<pre><code class="language-powershell"># 在项目根目录执行。先运行 3.1 的统计任务,再用缓存分片筛选。
|
| 169 |
+
# 严格多选:实际尺寸必须与任意一个选中桶完全同比分配。
|
| 170 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/statistics/shards --output runs/aspect_strict --aspect-buckets "1:1,2:3,3:2" --aspect-mode strict
|
| 171 |
+
|
| 172 |
+
# 宽松全选:5% 偏差,也可以省略 tolerance 参数使用默认值。
|
| 173 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/statistics/shards --output runs/aspect_loose --aspect-buckets all --aspect-mode loose --aspect-tolerance 0.05
|
| 174 |
+
|
| 175 |
+
# 更严格的 3% 容差,同时保留短边 >=256 的图片。
|
| 176 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/statistics/shards --output runs/aspect_3percent_256 --resolution 256p --aspect-buckets "3:4,4:3,9:16,16:9" --aspect-mode loose --aspect-tolerance 0.03
|
| 177 |
+
|
| 178 |
+
# 覆盖已有配置、关闭桶条件。其他筛选条件继续生效。
|
| 179 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/statistics/shards --output runs/aspect_disabled --aspect-buckets none
|
| 180 |
+
|
| 181 |
+
# 从文件夹直接运行,支持多进程。
|
| 182 |
+
.\.venv\Scripts\python.exe -m image_cleaner run --input "D:\datasets\images" --output runs/aspect_run --workers 8 --aspect-buckets all --aspect-mode loose
|
| 183 |
+
|
| 184 |
+
# 使用示例配置。示例关闭去重以便单独观察桶筛选效果。
|
| 185 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/statistics/shards --output runs/aspect_config --config configs/aspect_loose.json
|
| 186 |
+
</code></pre>
|
| 187 |
+
<p>每次重筛选使用一个新的输出目录。<code>filter</code> 读取全部分析记录,包括此前被规则拒绝的图片;因此既能收紧也能放宽条件,不需要再次解码。解码失败的记录仍归入错误。</p>
|
| 188 |
+
<table>
|
| 189 |
+
<thead>
|
| 190 |
+
<tr>
|
| 191 |
+
<th>参数</th>
|
| 192 |
+
<th>默认值</th>
|
| 193 |
+
<th>含义</th>
|
| 194 |
+
</tr>
|
| 195 |
+
</thead>
|
| 196 |
+
<tbody>
|
| 197 |
+
<tr>
|
| 198 |
+
<td><code>--aspect-buckets</code></td>
|
| 199 |
+
<td>不启用,或继承配置/分片</td>
|
| 200 |
+
<td>英文逗号分隔多选;<code>all</code> 是八桶并集;<code>none</code> 关闭;空值或非预设桶报错</td>
|
| 201 |
+
</tr>
|
| 202 |
+
<tr>
|
| 203 |
+
<td><code>--aspect-mode</code></td>
|
| 204 |
+
<td><code>strict</code>,或继承配置/分片</td>
|
| 205 |
+
<td><code>strict</code> 精确匹配;<code>loose</code> 容差匹配</td>
|
| 206 |
+
</tr>
|
| 207 |
+
<tr>
|
| 208 |
+
<td><code>--aspect-tolerance</code></td>
|
| 209 |
+
<td><code>0.05</code>,或继承配置/分片</td>
|
| 210 |
+
<td>有限非负小数,仅宽松模式生效;<code>0.05</code> 表示 5%,<code>0</code> 表示不允许偏差</td>
|
| 211 |
+
</tr>
|
| 212 |
+
</tbody>
|
| 213 |
+
</table>
|
| 214 |
+
<p>三个参数均支持 <code>analyze</code>、<code>filter</code>/<code>merge</code>、<code>run</code>。显式命令行参数优先于配置;未传参数保留配置值。没有传 <code>--config</code> 的 <code>filter</code> 继承分析分片配置;传入配置文件则按该文件及 Config 默认值构建整套筛选规则。</p>
|
| 215 |
+
<p>设实际比例 <code>r=width/height</code>,目标桶比例 <code>b</code>。严格模式使用整数交叉相乘:例如 <code>2:3</code> 要求 <code>width×3 == height×2</code>。宽松模式要求 <code>δ=max(r/b,b/r)-1 <= aspect_tolerance</code>,包含边界。实现使用精确分数比较。例如选 <code>1:1</code>、偏差 5% 时,<code>1050×1000</code> 和 <code>1000×1050</code> 均通过,<code>1051×1000</code> 不通过。此对称定义下允许区间为 <code>[b/1.05, b×1.05]</code>,并非普通的 <code>[b×0.95,b×1.05]</code>。</p>
|
| 216 |
+
<p>任意一个选中桶匹配即可通过。如果匹配多个桶,则只分配给偏差最小的桶;同偏差按上述固定八桶顺序选取,因此每张图片只计数一次。重复选择会合并。<code>all</code> 仍会过滤超出所有桶范围的图片。桶条件与分辨率、<code>min_aspect_ratio/max_aspect_ratio</code>、质量、文本条件取交集;不会裁剪或缩放图片。</p>
|
| 217 |
+
<p>配置文件新增字段示例:</p>
|
| 218 |
+
<pre><code class="language-json">{
|
| 219 |
+
"aspect_buckets": ["1:1", "2:3", "3:2"],
|
| 220 |
+
"aspect_mode": "loose",
|
| 221 |
+
"aspect_tolerance": 0.05
|
| 222 |
+
}
|
| 223 |
+
</code></pre>
|
| 224 |
+
<p><code>aspect_buckets</code> 也接受 <code>"all"</code>、<code>"none"</code> 或 <code>null</code>。多机仍使用第 6 节的同一 manifest 和 rank/world-size 流程;所有节点分析配置一致,收集全部分片后统一执行上面的 <code>filter</code> 命令,桶筛选无需各节点读取源图片。</p>
|
| 225 |
+
<h2 id="4-各环节指标和判定规则">4. 各环节指标和判定规则</h2>
|
| 226 |
+
<h3 id="41-导入解码和容量检查">4.1 导入、解码和容量检查</h3>
|
| 227 |
+
<table>
|
| 228 |
+
<thead>
|
| 229 |
+
<tr>
|
| 230 |
+
<th>字段/指标</th>
|
| 231 |
+
<th>意义</th>
|
| 232 |
+
<th>判定</th>
|
| 233 |
+
</tr>
|
| 234 |
+
</thead>
|
| 235 |
+
<tbody>
|
| 236 |
+
<tr>
|
| 237 |
+
<td><code>input_count</code></td>
|
| 238 |
+
<td>清单记录数量,包含同图多文本记录</td>
|
| 239 |
+
<td>不是去重后的独立图片数</td>
|
| 240 |
+
</tr>
|
| 241 |
+
<tr>
|
| 242 |
+
<td><code>original_width/original_height</code></td>
|
| 243 |
+
<td>文件编码中的原始尺寸</td>
|
| 244 |
+
<td>与 EXIF 校正后的尺寸分别保存</td>
|
| 245 |
+
</tr>
|
| 246 |
+
<tr>
|
| 247 |
+
<td><code>original_format/original_mode</code></td>
|
| 248 |
+
<td>解码格式和像素模式</td>
|
| 249 |
+
<td>用于来源与格式审计</td>
|
| 250 |
+
</tr>
|
| 251 |
+
<tr>
|
| 252 |
+
<td><code>frame_count</code></td>
|
| 253 |
+
<td>帧数或页数</td>
|
| 254 |
+
<td>默认 <code>reject_animated=true</code> 时,多帧/多页报处理错误</td>
|
| 255 |
+
</tr>
|
| 256 |
+
<tr>
|
| 257 |
+
<td><code>max_decode_pixels</code></td>
|
| 258 |
+
<td>单图最大解码像素数</td>
|
| 259 |
+
<td>默认 100,000,000;超过限制报错</td>
|
| 260 |
+
</tr>
|
| 261 |
+
<tr>
|
| 262 |
+
<td><code>error_count</code>、<code>error.jsonl</code></td>
|
| 263 |
+
<td>文件缺失、损坏、截断或处理异常</td>
|
| 264 |
+
<td>不进入有效分布统计与导出</td>
|
| 265 |
+
</tr>
|
| 266 |
+
<tr>
|
| 267 |
+
<td><code>processing_success_rate</code></td>
|
| 268 |
+
<td>完成整套图片处理数 ÷ 输入记录数</td>
|
| 269 |
+
<td>是处理成功率,不是人工质量准确率</td>
|
| 270 |
+
</tr>
|
| 271 |
+
</tbody>
|
| 272 |
+
</table>
|
| 273 |
+
<h3 id="42-标准化">4.2 标准化</h3>
|
| 274 |
+
<table>
|
| 275 |
+
<thead>
|
| 276 |
+
<tr>
|
| 277 |
+
<th>字段/设置</th>
|
| 278 |
+
<th>行为和验证方法</th>
|
| 279 |
+
</tr>
|
| 280 |
+
</thead>
|
| 281 |
+
<tbody>
|
| 282 |
+
<tr>
|
| 283 |
+
<td><code>exif_transposed</code></td>
|
| 284 |
+
<td>是否按 EXIF 方向进行转置;查看标准化宽高并抽查方向</td>
|
| 285 |
+
</tr>
|
| 286 |
+
<tr>
|
| 287 |
+
<td><code>icc_converted</code></td>
|
| 288 |
+
<td>有 ICC 且开启转换时转为 sRGB;失败记错误</td>
|
| 289 |
+
</tr>
|
| 290 |
+
<tr>
|
| 291 |
+
<td><code>alpha_flattened</code></td>
|
| 292 |
+
<td>是否存在透明通道并按 <code>alpha_background</code> 合成;默认白色</td>
|
| 293 |
+
</tr>
|
| 294 |
+
<tr>
|
| 295 |
+
<td><code>text_unicode_form</code></td>
|
| 296 |
+
<td>NFC 默认;可选 NFKC 或 none;同时统一换行并去首尾/行尾空白</td>
|
| 297 |
+
</tr>
|
| 298 |
+
<tr>
|
| 299 |
+
<td>导出的 <code>width/height</code>、像素模式</td>
|
| 300 |
+
<td>检查最终尺寸与 RGB 模式;导出可选等比例缩小</td>
|
| 301 |
+
</tr>
|
| 302 |
+
</tbody>
|
| 303 |
+
</table>
|
| 304 |
+
<p>标准化属于数据规范化,未实现模型训练的 dropout/weight decay,也不会执行默认去噪或锐化。16 位/浮点图片最终转为 8 位 RGB,本工具主要面向常规网页图像。</p>
|
| 305 |
+
<h3 id="43-分布统计分辨率和宽高比">4.3 分布统计、分辨率和宽高比</h3>
|
| 306 |
+
<table>
|
| 307 |
+
<thead>
|
| 308 |
+
<tr>
|
| 309 |
+
<th>字段</th>
|
| 310 |
+
<th>定义/单位</th>
|
| 311 |
+
<th>筛选配置</th>
|
| 312 |
+
</tr>
|
| 313 |
+
</thead>
|
| 314 |
+
<tbody>
|
| 315 |
+
<tr>
|
| 316 |
+
<td><code>width</code></td>
|
| 317 |
+
<td>EXIF 校正后的宽度,像素</td>
|
| 318 |
+
<td><code>resolution=WxH</code> 可精确限定</td>
|
| 319 |
+
</tr>
|
| 320 |
+
<tr>
|
| 321 |
+
<td><code>height</code></td>
|
| 322 |
+
<td>EXIF 校正后的高度,像素</td>
|
| 323 |
+
<td><code>resolution=WxH</code> 可精确限定</td>
|
| 324 |
+
</tr>
|
| 325 |
+
<tr>
|
| 326 |
+
<td><code>short_edge</code></td>
|
| 327 |
+
<td>min(width,height),像素</td>
|
| 328 |
+
<td><code>resolution=Np</code> 要求大于或等于 N;<code>min_short_edge</code> 为包含边界的下限</td>
|
| 329 |
+
</tr>
|
| 330 |
+
<tr>
|
| 331 |
+
<td><code>long_edge</code></td>
|
| 332 |
+
<td>max(width,height),像素</td>
|
| 333 |
+
<td><code>max_long_edge</code> 为上限</td>
|
| 334 |
+
</tr>
|
| 335 |
+
<tr>
|
| 336 |
+
<td><code>pixels</code></td>
|
| 337 |
+
<td>width×height</td>
|
| 338 |
+
<td><code>min_pixels/max_pixels</code></td>
|
| 339 |
+
</tr>
|
| 340 |
+
<tr>
|
| 341 |
+
<td><code>aspect_ratio</code></td>
|
| 342 |
+
<td>width/height</td>
|
| 343 |
+
<td><code>min_aspect_ratio/max_aspect_ratio</code></td>
|
| 344 |
+
</tr>
|
| 345 |
+
<tr>
|
| 346 |
+
<td><code>orientations</code></td>
|
| 347 |
+
<td>横、竖、近方图数量</td>
|
| 348 |
+
<td>0.95≤W/H≤1.05 视为近方图;只是统计类别</td>
|
| 349 |
+
</tr>
|
| 350 |
+
</tbody>
|
| 351 |
+
</table>
|
| 352 |
+
<p>分辨率筛选不匹配时,原因是 <code>resolution_mismatch</code>。<code>min_short_edge=256</code> 与 <code>resolution=256p</code> 均包含 256,因此 255 不合格,256 和 257 合格。<code>resolution_filter.mode=short_edge_gte</code> 与 <code>comparison=">="</code> 在报告中记录这一判定;<code>WxH</code> 仍为精确宽高匹配。</p>
|
| 353 |
+
<p>每个分布提供有效数量、缺失数量、最小值、最大值、均值、总体标准差、p1/p5/p25/p50/p75/p95/p99,以及固定桶直方图。桶为左闭右开,最后覆盖到无穷大;直方图是全量精确计数。分位数最多使用固定种子 20,000 条蓄水池样本;更大规模时 <code>quantiles_approximate=true</code>。</p>
|
| 354 |
+
<p><code>before</code> 覆盖成功处理的全部记录,<code>after</code> 覆盖最终保留记录;不代表按独立图片去重后的来源统计。分辨率匹配数另外直接按每条记录统计,避免粗直方图桶无法区分 255/256/257 的问题。</p>
|
| 355 |
+
<h3 id="44-图片技术质量">4.4 图片技术质量</h3>
|
| 356 |
+
<p>默认在四角和中心最多五个原生像素区域测量,区域大小最多 <code>metric_patch_size=256</code>,相同区域去重,各指标取有效区域的中位数。不会先缩图再测量;可能漏掉未采样区域的局部缺陷。</p>
|
| 357 |
+
<table>
|
| 358 |
+
<thead>
|
| 359 |
+
<tr>
|
| 360 |
+
<th>字段</th>
|
| 361 |
+
<th>计算方式、解释</th>
|
| 362 |
+
<th>筛选条件</th>
|
| 363 |
+
</tr>
|
| 364 |
+
</thead>
|
| 365 |
+
<tbody>
|
| 366 |
+
<tr>
|
| 367 |
+
<td><code>blur_score</code></td>
|
| 368 |
+
<td>灰度区域上 Var(up+down+left+right−4×center),拉普拉斯方差;通常越低越模糊</td>
|
| 369 |
+
<td>低于 <code>min_blur_score</code> 拒绝</td>
|
| 370 |
+
</tr>
|
| 371 |
+
<tr>
|
| 372 |
+
<td><code>noise_sigma</code></td>
|
| 373 |
+
<td>在梯度最低约 30% 区域,用 3×3 高通残差计算 MAD/(0.67448975×6),像素幅度范围 0–255</td>
|
| 374 |
+
<td>高于 <code>max_noise_sigma</code> 拒绝</td>
|
| 375 |
+
</tr>
|
| 376 |
+
<tr>
|
| 377 |
+
<td><code>snr_proxy_db</code></td>
|
| 378 |
+
<td>每区域 20log10(灰度标准差/noise_sigma),然后取区域中位数</td>
|
| 379 |
+
<td>低于 <code>min_snr_proxy_db</code> 拒绝</td>
|
| 380 |
+
</tr>
|
| 381 |
+
<tr>
|
| 382 |
+
<td><code>blockiness</code></td>
|
| 383 |
+
<td>对水平/垂直 8 像素网格各相位求边界梯度与非边界梯度差;每轴取最大差,再平均并截断到非负</td>
|
| 384 |
+
<td>高于 <code>max_blockiness</code> 拒绝</td>
|
| 385 |
+
</tr>
|
| 386 |
+
<tr>
|
| 387 |
+
<td><code>luminance</code></td>
|
| 388 |
+
<td>灰度均值,0–255</td>
|
| 389 |
+
<td>当前仅记录,无直接阈值</td>
|
| 390 |
+
</tr>
|
| 391 |
+
<tr>
|
| 392 |
+
<td><code>contrast</code></td>
|
| 393 |
+
<td>灰度总体标准差</td>
|
| 394 |
+
<td>当前仅记录,无直接阈值</td>
|
| 395 |
+
</tr>
|
| 396 |
+
<tr>
|
| 397 |
+
<td><code>metric_patch_count</code></td>
|
| 398 |
+
<td>实际采样区域数</td>
|
| 399 |
+
<td>当前仅记录,方便审计</td>
|
| 400 |
+
</tr>
|
| 401 |
+
</tbody>
|
| 402 |
+
</table>
|
| 403 |
+
<p>这些是代理指标:拉普拉斯方差可能因噪声升高,纹理会影响噪声估计,规则图案也会产生块效应分数。信噪比的“信号”采用局部亮度标准差,不是真实 SNR,也不是 PSNR。</p>
|
| 404 |
+
<p>噪声或对比度接近零时,SNR 返回 <code>null</code> 并写入 <code>snr_proxy_undefined</code>;极小图片部分指标为 <code>null</code>。缺失值不会触发对应质量阈值。报告的 <code>missing</code> 表示未得到指标值,不表示质量合格。</p>
|
| 405 |
+
<p>所有质量阈值默认 null(关闭);先按照片/截图/图表/插画等类型抽查,再设置阈值。修改 <code>metric_patch_size</code> 会改变指标分布,必须重新分析并重新校准。</p>
|
| 406 |
+
<h3 id="45-文本筛选">4.5 文本筛选</h3>
|
| 407 |
+
<table>
|
| 408 |
+
<thead>
|
| 409 |
+
<tr>
|
| 410 |
+
<th>指标/设置</th>
|
| 411 |
+
<th>判定</th>
|
| 412 |
+
</tr>
|
| 413 |
+
</thead>
|
| 414 |
+
<tbody>
|
| 415 |
+
<tr>
|
| 416 |
+
<td><code>text_chars</code></td>
|
| 417 |
+
<td>规范化后 Python 字符数量;缺失文本按 0 统计,不是 token 数或单词数</td>
|
| 418 |
+
</tr>
|
| 419 |
+
<tr>
|
| 420 |
+
<td><code>min_text_chars</code></td>
|
| 421 |
+
<td>低于下限拒绝;设置为 1 可排除没���文字的样本</td>
|
| 422 |
+
</tr>
|
| 423 |
+
<tr>
|
| 424 |
+
<td><code>max_text_chars</code></td>
|
| 425 |
+
<td>超过上限拒绝</td>
|
| 426 |
+
</tr>
|
| 427 |
+
<tr>
|
| 428 |
+
<td><code>forbidden_text_patterns</code></td>
|
| 429 |
+
<td>任一 Python 正则表达式 <code>re.search</code> 命中即拒绝,默认区分大小写</td>
|
| 430 |
+
</tr>
|
| 431 |
+
</tbody>
|
| 432 |
+
</table>
|
| 433 |
+
<p>例如配置 <code>"forbidden_text_patterns": ["(?i)buy\\s+now", "点击购买"]</code> 可匹配部分广告文本。这是启发式文本规则,不能判断图片与文本是否语义对应。</p>
|
| 434 |
+
<h3 id="46-去重">4.6 去重</h3>
|
| 435 |
+
<p>质量规则通过后再进行全局去重,以避免不合格图片挤掉合格代表。</p>
|
| 436 |
+
<table>
|
| 437 |
+
<thead>
|
| 438 |
+
<tr>
|
| 439 |
+
<th>指标/类型</th>
|
| 440 |
+
<th>判定</th>
|
| 441 |
+
<th>配置</th>
|
| 442 |
+
</tr>
|
| 443 |
+
</thead>
|
| 444 |
+
<tbody>
|
| 445 |
+
<tr>
|
| 446 |
+
<td><code>file_sha256</code>、<code>exact_file</code></td>
|
| 447 |
+
<td>原文件字节 SHA-256 一致</td>
|
| 448 |
+
<td><code>deduplicate=true</code></td>
|
| 449 |
+
</tr>
|
| 450 |
+
<tr>
|
| 451 |
+
<td><code>pixel_sha256</code>、<code>exact_pixels</code></td>
|
| 452 |
+
<td>标准化 RGB 尺寸和像素一致</td>
|
| 453 |
+
<td><code>deduplicate=true</code></td>
|
| 454 |
+
</tr>
|
| 455 |
+
<tr>
|
| 456 |
+
<td>pHash 汉明距离</td>
|
| 457 |
+
<td>64 位低频 DCT 特征哈希差异</td>
|
| 458 |
+
<td>不超过 <code>phash_distance</code>,默认 4,可设 0–8</td>
|
| 459 |
+
</tr>
|
| 460 |
+
<tr>
|
| 461 |
+
<td>dHash 汉明距离</td>
|
| 462 |
+
<td>64 位相邻灰度梯度特征差异</td>
|
| 463 |
+
<td>不超过 <code>dhash_distance</code>,默认 4,可设 0–64</td>
|
| 464 |
+
</tr>
|
| 465 |
+
<tr>
|
| 466 |
+
<td>RGB 缩略图 RMSE</td>
|
| 467 |
+
<td>16×16 RGB 图像 sqrt(mean((a−b)²))</td>
|
| 468 |
+
<td>不超过 <code>near_rgb_rmse</code>,默认 8.0</td>
|
| 469 |
+
</tr>
|
| 470 |
+
<tr>
|
| 471 |
+
<td>宽高比差异</td>
|
| 472 |
+
<td>abs(log(ratio_a/ratio_b))</td>
|
| 473 |
+
<td>不超过 <code>near_aspect_log_tolerance</code>,默认 0.03</td>
|
| 474 |
+
</tr>
|
| 475 |
+
<tr>
|
| 476 |
+
<td><code>near_perceptual</code></td>
|
| 477 |
+
<td>上述近重复条件均满足</td>
|
| 478 |
+
<td><code>near_deduplicate=true</code> 且总去重开启</td>
|
| 479 |
+
</tr>
|
| 480 |
+
</tbody>
|
| 481 |
+
</table>
|
| 482 |
+
<p>pHash 使用相对 AC 系数容差抑制极小系数,与普通 pHash 数值不完全相同。所有近重复复核是启发式,不能代替语义 embedding 去重。</p>
|
| 483 |
+
<p>默认 <code>duplicate_scope=image_text</code>:规范化文本也相同才归并,保留同图不同描述/问答;没有文本时按图片归并。<code>image</code> 会忽略文本差异。代表优先级为像素数降序、噪声升序、清晰度降序、清单序号升序,不是人工审美排序。</p>
|
| 484 |
+
<p><code>duplicate.jsonl</code> 给出 <code>duplicate_of/duplicate_kind/duplicate_metrics</code>。近重复记录目前保存 pHash 距离和 RGB RMSE,dHash 与比例条件用于判断但未单独写入距离字段。每个重复直接匹配一个保留代表,不通过无限传递链扩大分组。</p>
|
| 485 |
+
<h3 id="47-阶段评测指标">4.7 阶段评测指标</h3>
|
| 486 |
+
<p>令 N 为输入条数、D 为处理成功条数、M 为指定分辨率匹配条数、E 为全部规则通过条数、U 为重复条数、K 为最终保留条数:</p>
|
| 487 |
+
<table>
|
| 488 |
+
<thead>
|
| 489 |
+
<tr>
|
| 490 |
+
<th><code>report.json</code> 字段</th>
|
| 491 |
+
<th>定义</th>
|
| 492 |
+
</tr>
|
| 493 |
+
</thead>
|
| 494 |
+
<tbody>
|
| 495 |
+
<tr>
|
| 496 |
+
<td><code>input_count</code></td>
|
| 497 |
+
<td>N</td>
|
| 498 |
+
</tr>
|
| 499 |
+
<tr>
|
| 500 |
+
<td><code>error_count</code></td>
|
| 501 |
+
<td>N−D</td>
|
| 502 |
+
</tr>
|
| 503 |
+
<tr>
|
| 504 |
+
<td><code>stage_metrics.processing_success_count/rate</code></td>
|
| 505 |
+
<td>D、D/N</td>
|
| 506 |
+
</tr>
|
| 507 |
+
<tr>
|
| 508 |
+
<td><code>stage_metrics.resolution_matched_count</code></td>
|
| 509 |
+
<td>M,单独按尺寸条件计算,不含其他质量限制</td>
|
| 510 |
+
</tr>
|
| 511 |
+
<tr>
|
| 512 |
+
<td><code>stage_metrics.resolution_mismatched_count</code></td>
|
| 513 |
+
<td>D−M</td>
|
| 514 |
+
</tr>
|
| 515 |
+
<tr>
|
| 516 |
+
<td><code>stage_metrics.resolution_pass_rate</code></td>
|
| 517 |
+
<td>M/D;分辨率条件关闭时为 1(D>0)</td>
|
| 518 |
+
</tr>
|
| 519 |
+
<tr>
|
| 520 |
+
<td><code>stage_metrics.rules_eligible_count/rules_pass_rate</code></td>
|
| 521 |
+
<td>E=K+U、E/D</td>
|
| 522 |
+
</tr>
|
| 523 |
+
<tr>
|
| 524 |
+
<td><code>rejected_count</code></td>
|
| 525 |
+
<td>D−E,至少一个规则不合格</td>
|
| 526 |
+
</tr>
|
| 527 |
+
<tr>
|
| 528 |
+
<td><code>duplicate_count</code></td>
|
| 529 |
+
<td>U</td>
|
| 530 |
+
</tr>
|
| 531 |
+
<tr>
|
| 532 |
+
<td><code>stage_metrics.duplicate_rate_among_eligible</code></td>
|
| 533 |
+
<td>U/E</td>
|
| 534 |
+
</tr>
|
| 535 |
+
<tr>
|
| 536 |
+
<td><code>kept_count/stage_metrics.final_keep_rate</code></td>
|
| 537 |
+
<td>K、K/N</td>
|
| 538 |
+
</tr>
|
| 539 |
+
<tr>
|
| 540 |
+
<td><code>before.reasons</code></td>
|
| 541 |
+
<td>最终各原因的出现次数;一条拒绝记录可有多个原因,不可直接加总当成样本数</td>
|
| 542 |
+
</tr>
|
| 543 |
+
</tbody>
|
| 544 |
+
</table>
|
| 545 |
+
<p>分母为零时比率为 null。比率是 0–1 的数值;乘 100 得百分比。每次完整输出满足 <code>N=K+rejected_count+U+error_count</code>。关闭去重时 U=0。</p>
|
| 546 |
+
<p>这些指标衡量清洗流程和数据组成,不是模型性能或人工标注准确率。若要评估误删/漏删,按图片类型和规则分层抽取人工审核样本,统计“被拒绝但实际可用”的比例和“保留但实际不合格”的比例。后续训练收益需要固定训练预算、随机种子与验证集做对照实验。</p>
|
| 547 |
+
<h3 id="48-宽高比桶筛选评测指标">4.8 宽高比桶筛选评测指标</h3>
|
| 548 |
+
<p>打开输出目录的 <code>report.html</code> 查看新增宽高比桶部分;<code>report.json</code> ���供机器可读指标,<code>kept.jsonl</code>、<code>rejected.jsonl</code> 等提供逐条结果。</p>
|
| 549 |
+
<table>
|
| 550 |
+
<thead>
|
| 551 |
+
<tr>
|
| 552 |
+
<th>指标/字段</th>
|
| 553 |
+
<th>定义与用途</th>
|
| 554 |
+
</tr>
|
| 555 |
+
</thead>
|
| 556 |
+
<tbody>
|
| 557 |
+
<tr>
|
| 558 |
+
<td><code>stage_metrics.aspect_bucket_matched_count</code></td>
|
| 559 |
+
<td>成功解码记录中,单独满足桶条件的数量;不受其他阈值和去重影响</td>
|
| 560 |
+
</tr>
|
| 561 |
+
<tr>
|
| 562 |
+
<td><code>aspect_bucket_mismatched_count</code></td>
|
| 563 |
+
<td>成功解码数量减去桶匹配数量</td>
|
| 564 |
+
</tr>
|
| 565 |
+
<tr>
|
| 566 |
+
<td><code>aspect_bucket_pass_rate</code></td>
|
| 567 |
+
<td>桶匹配数 / 成功解码数;无成功解码记录为 null;关闭桶条件时成功解码记录全部通过</td>
|
| 568 |
+
</tr>
|
| 569 |
+
<tr>
|
| 570 |
+
<td><code>aspect_bucket_filter.counts_before</code></td>
|
| 571 |
+
<td>所有成功解码图片的匹配桶归属计数,包含因其他条件拒绝或重复的图片</td>
|
| 572 |
+
</tr>
|
| 573 |
+
<tr>
|
| 574 |
+
<td><code>aspect_bucket_filter.counts_after</code></td>
|
| 575 |
+
<td>最终 kept 图片的桶归属计数,已应用全部规则及去重</td>
|
| 576 |
+
</tr>
|
| 577 |
+
<tr>
|
| 578 |
+
<td><code>aspect_bucket_filter</code> 其余字段</td>
|
| 579 |
+
<td>是否启用、选中桶、模式、容差、尺寸计算依据、偏差公式</td>
|
| 580 |
+
</tr>
|
| 581 |
+
<tr>
|
| 582 |
+
<td>每条记录 <code>aspect_bucket</code></td>
|
| 583 |
+
<td>匹配且分配的桶;没有通过桶条件或关闭条件时为 null</td>
|
| 584 |
+
</tr>
|
| 585 |
+
<tr>
|
| 586 |
+
<td><code>aspect_bucket_nearest</code></td>
|
| 587 |
+
<td>最近的选中桶,即使不通过也可用来排查;关闭条件时为 null</td>
|
| 588 |
+
</tr>
|
| 589 |
+
<tr>
|
| 590 |
+
<td><code>aspect_bucket_deviation</code></td>
|
| 591 |
+
<td>到最近桶的对称相对偏差;例如 0.05 是 5%;关闭条件时为 null</td>
|
| 592 |
+
</tr>
|
| 593 |
+
<tr>
|
| 594 |
+
<td><code>aspect_bucket_match</code></td>
|
| 595 |
+
<td>true/false 表示通过/未通过;关闭条件时为 null</td>
|
| 596 |
+
</tr>
|
| 597 |
+
<tr>
|
| 598 |
+
<td>拒绝原因 <code>aspect_bucket_mismatch</code></td>
|
| 599 |
+
<td>不满足任何选中桶;同一记录可同时包含多个拒绝原因</td>
|
| 600 |
+
</tr>
|
| 601 |
+
</tbody>
|
| 602 |
+
</table>
|
| 603 |
+
<p>上述逐条字段针对成功解码记录;错误记录可能没有尺寸与桶字段。<code>counts_before</code> 总和等于独立桶匹配数(条件启用时),<code>counts_after</code> 总和等于最终保留数。选中但没有图片的桶也输出 0;关闭条件时桶计数字典为空。质量评测可同时检查桶通过率、各桶覆盖量、最终保留率,并人工抽查 rejected 中临界偏差图片;各桶不执行均衡采样或配额。</p>
|
| 604 |
+
<h2 id="5-配置文件和参数">5. 配置文件和参数</h2>
|
| 605 |
+
<p>项目已有:</p>
|
| 606 |
+
<table>
|
| 607 |
+
<thead>
|
| 608 |
+
<tr>
|
| 609 |
+
<th>配置</th>
|
| 610 |
+
<th>用途</th>
|
| 611 |
+
</tr>
|
| 612 |
+
</thead>
|
| 613 |
+
<tbody>
|
| 614 |
+
<tr>
|
| 615 |
+
<td><code>configs/statistics_only.json</code></td>
|
| 616 |
+
<td>全量统计,关闭可选阈值与去重</td>
|
| 617 |
+
</tr>
|
| 618 |
+
<tr>
|
| 619 |
+
<td><code>configs/above_256p.json</code></td>
|
| 620 |
+
<td>短边大于或等于 256,关闭去重</td>
|
| 621 |
+
</tr>
|
| 622 |
+
<tr>
|
| 623 |
+
<td><code>configs/exact_256p.json</code></td>
|
| 624 |
+
<td>兼容旧文件名,内容与 above_256p 相同,采用大于或等于的新规则</td>
|
| 625 |
+
</tr>
|
| 626 |
+
<tr>
|
| 627 |
+
<td><code>configs/audit.json</code></td>
|
| 628 |
+
<td>无可选技术质量阈值,开启默认去重</td>
|
| 629 |
+
</tr>
|
| 630 |
+
<tr>
|
| 631 |
+
<td><code>configs/aspect_strict.json</code></td>
|
| 632 |
+
<td>严格选择 1:1、2:3、3:2,关闭去重</td>
|
| 633 |
+
</tr>
|
| 634 |
+
<tr>
|
| 635 |
+
<td><code>configs/aspect_loose.json</code></td>
|
| 636 |
+
<td>宽松选择八桶,默认容差 5%,关闭去重</td>
|
| 637 |
+
</tr>
|
| 638 |
+
<tr>
|
| 639 |
+
<td><code>configs/generation_example.json</code></td>
|
| 640 |
+
<td>示例:短边至少 512、比例 0.25–4、文本非空,并开启默认去重</td>
|
| 641 |
+
</tr>
|
| 642 |
+
</tbody>
|
| 643 |
+
</table>
|
| 644 |
+
<p>以下为可自定义的完整字段表,数值是默认值,不是推荐质量阈值:</p>
|
| 645 |
+
<pre><code class="language-json">{
|
| 646 |
+
"resolution": null,
|
| 647 |
+
"min_short_edge": null,
|
| 648 |
+
"max_long_edge": null,
|
| 649 |
+
"min_pixels": null,
|
| 650 |
+
"max_pixels": null,
|
| 651 |
+
"min_aspect_ratio": null,
|
| 652 |
+
"aspect_buckets": null,
|
| 653 |
+
"aspect_mode": "strict",
|
| 654 |
+
"aspect_tolerance": 0.05,
|
| 655 |
+
"max_aspect_ratio": null,
|
| 656 |
+
"min_blur_score": null,
|
| 657 |
+
"max_noise_sigma": null,
|
| 658 |
+
"min_snr_proxy_db": null,
|
| 659 |
+
"max_blockiness": null,
|
| 660 |
+
"min_text_chars": null,
|
| 661 |
+
"max_text_chars": null,
|
| 662 |
+
"forbidden_text_patterns": [],
|
| 663 |
+
"reject_animated": true,
|
| 664 |
+
"max_decode_pixels": 100000000,
|
| 665 |
+
"metric_patch_size": 256,
|
| 666 |
+
"alpha_background": [255, 255, 255],
|
| 667 |
+
"convert_icc": true,
|
| 668 |
+
"text_unicode_form": "NFC",
|
| 669 |
+
"deduplicate": true,
|
| 670 |
+
"near_deduplicate": true,
|
| 671 |
+
"duplicate_scope": "image_text",
|
| 672 |
+
"phash_distance": 4,
|
| 673 |
+
"dhash_distance": 4,
|
| 674 |
+
"near_rgb_rmse": 8.0,
|
| 675 |
+
"near_aspect_log_tolerance": 0.03,
|
| 676 |
+
"export_max_edge": null,
|
| 677 |
+
"export_format": "png",
|
| 678 |
+
"export_jpeg_quality": 95
|
| 679 |
+
}
|
| 680 |
+
</code></pre>
|
| 681 |
+
<p><code>export_format</code> 可选 png/jpeg;JPEG <code>export_jpeg_quality</code> 为 1–100,采用无色度子采样,仍是有损输出。图片规范化不会自动统一为方形,固定裁剪和张量归一化由训练数据加载器执行。</p>
|
| 682 |
+
<p><code>resolution</code> 支持 <code>256p</code>(短边>=256)、<code>256x256</code>(精确宽高)等正整数格式;大小写与首尾空白会规范化,<code>256×256</code> 也接受。<code>any/none/null</code> 关闭分辨率条件。无后缀的 <code>256</code>、<code>0p</code>、负数、逗号多个值都非法;多目标请分别筛选到不同输出目录。</p>
|
| 683 |
+
<p>各子命令参数:</p>
|
| 684 |
+
<table>
|
| 685 |
+
<thead>
|
| 686 |
+
<tr>
|
| 687 |
+
<th>命令</th>
|
| 688 |
+
<th>主要必填参数</th>
|
| 689 |
+
<th>可选参数</th>
|
| 690 |
+
</tr>
|
| 691 |
+
</thead>
|
| 692 |
+
<tbody>
|
| 693 |
+
<tr>
|
| 694 |
+
<td><code>prepare</code></td>
|
| 695 |
+
<td><code>--input --manifest</code></td>
|
| 696 |
+
<td><code>--root --sidecar-text</code></td>
|
| 697 |
+
</tr>
|
| 698 |
+
<tr>
|
| 699 |
+
<td><code>analyze</code></td>
|
| 700 |
+
<td><code>--manifest --output</code></td>
|
| 701 |
+
<td><code>--root --config --resolution --aspect-buckets --aspect-mode --aspect-tolerance --workers --rank --world-size --resume</code></td>
|
| 702 |
+
</tr>
|
| 703 |
+
<tr>
|
| 704 |
+
<td><code>filter/merge</code></td>
|
| 705 |
+
<td><code>--shards --output</code></td>
|
| 706 |
+
<td><code>--config --resolution --aspect-buckets --aspect-mode --aspect-tolerance</code></td>
|
| 707 |
+
</tr>
|
| 708 |
+
<tr>
|
| 709 |
+
<td><code>export</code></td>
|
| 710 |
+
<td><code>--curated --output</code></td>
|
| 711 |
+
<td><code>--root --workers --resume</code></td>
|
| 712 |
+
</tr>
|
| 713 |
+
<tr>
|
| 714 |
+
<td><code>run</code></td>
|
| 715 |
+
<td><code>--input --output</code></td>
|
| 716 |
+
<td><code>--root --config --resolution --aspect-buckets --aspect-mode --aspect-tolerance --workers --sidecar-text --export --resume</code></td>
|
| 717 |
+
</tr>
|
| 718 |
+
</tbody>
|
| 719 |
+
</table>
|
| 720 |
+
<p><code>workers</code> 默认 min(8,CPU数量)。配置未知字段、非法类型、范围错误会报错;各子命令的 <code>--help</code> 可查看在线说明。</p>
|
| 721 |
+
<h2 id="6-多机分片流程">6. 多机分片流程</h2>
|
| 722 |
+
<p>先创建全局固定清单:</p>
|
| 723 |
+
<pre><code class="language-powershell">.\.venv\Scripts\python.exe -m image_cleaner prepare --input "D:\datasets\images" --manifest runs/distributed/input.jsonl --sidecar-text
|
| 724 |
+
</code></pre>
|
| 725 |
+
<p>将 <code>input.jsonl</code>、<code>input.jsonl.meta.json</code>、相同代码和配置分发到节点。每台机器都必须能访问对应原图,可以使用不同本地根目录。</p>
|
| 726 |
+
<p>分别在两台机器执行:</p>
|
| 727 |
+
<pre><code class="language-powershell"># 节点 0
|
| 728 |
+
.\.venv\Scripts\python.exe -m image_cleaner analyze --manifest runs/distributed/input.jsonl --root "D:\node0\images" --output runs/distributed/shards --config configs/statistics_only.json --workers 8 --world-size 2 --rank 0
|
| 729 |
+
|
| 730 |
+
# 节点 1
|
| 731 |
+
.\.venv\Scripts\python.exe -m image_cleaner analyze --manifest runs/distributed/input.jsonl --root "E:\node1\images" --output runs/distributed/shards --config configs/statistics_only.json --workers 8 --world-size 2 --rank 1
|
| 732 |
+
</code></pre>
|
| 733 |
+
<p>每条清单按 <code>seq % world_size</code> 分配。每个节点本地写不同 rank 的 SQLite,任务退出后收集全部 <code>shard-*.sqlite</code> 到协调节点同一目录;不用让不同机器写同一个 SQLite。</p>
|
| 734 |
+
<p>协调节点先汇总统计,然后按分辨率条件筛选:</p>
|
| 735 |
+
<pre><code class="language-powershell">.\.venv\Scripts\python.exe -m image_cleaner merge --shards runs/distributed/shards --output runs/distributed/statistics
|
| 736 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/distributed/shards --output runs/distributed/only_256p --resolution 256p
|
| 737 |
+
</code></pre>
|
| 738 |
+
<p>若希望跨分片去重,在第二条命令增加 <code>--config configs/audit.json</code>。合并会验证全部 rank 完整、清单/配置/软件版本一致、序号不重复。筛选结果与使用同一清单的单机处理一致,去重代表顺序不依赖 worker 完成顺序。</p>
|
| 739 |
+
<p>这是静态分片处理,没有自动节点调度或故障迁移。分析并行,最终筛选和全局去重由一个协调节点完成;数据和索引落磁盘,没有百万级运行性能保证。大图处理的解码内存与图片尺寸有关,可降低 workers 或最大解码像素限制。</p>
|
| 740 |
+
<h2 id="7-输出断点续跑和复核">7. 输出、断点续跑和复核</h2>
|
| 741 |
+
<p>统计 <code>run</code> 输出清单、分片数据库,以及 <code>curated</code> 报告;<code>filter/merge</code> 的报告直接在其 <code>--output</code> 目录内,<strong>不会再额外套一层 curated</strong>。</p>
|
| 742 |
+
<table>
|
| 743 |
+
<thead>
|
| 744 |
+
<tr>
|
| 745 |
+
<th>文件</th>
|
| 746 |
+
<th>用途</th>
|
| 747 |
+
</tr>
|
| 748 |
+
</thead>
|
| 749 |
+
<tbody>
|
| 750 |
+
<tr>
|
| 751 |
+
<td><code>input.jsonl/.meta.json</code></td>
|
| 752 |
+
<td>固定输入与 SHA-256、条数、数据根目录</td>
|
| 753 |
+
</tr>
|
| 754 |
+
<tr>
|
| 755 |
+
<td><code>shard-*.sqlite</code></td>
|
| 756 |
+
<td>完整分析记录与断点检查点,是重复筛选的数据源</td>
|
| 757 |
+
</tr>
|
| 758 |
+
<tr>
|
| 759 |
+
<td><code>shard-*.jsonl</code></td>
|
| 760 |
+
<td>方便检查的分片记录</td>
|
| 761 |
+
</tr>
|
| 762 |
+
<tr>
|
| 763 |
+
<td><code>kept.jsonl</code></td>
|
| 764 |
+
<td>最终保留的路径、文本、指标</td>
|
| 765 |
+
</tr>
|
| 766 |
+
<tr>
|
| 767 |
+
<td><code>rejected.jsonl</code></td>
|
| 768 |
+
<td>各规则拒绝原因,包括 <code>resolution_mismatch</code></td>
|
| 769 |
+
</tr>
|
| 770 |
+
<tr>
|
| 771 |
+
<td><code>duplicate.jsonl</code></td>
|
| 772 |
+
<td>重复类型、代表 ID 和近重复评分</td>
|
| 773 |
+
</tr>
|
| 774 |
+
<tr>
|
| 775 |
+
<td><code>error.jsonl</code></td>
|
| 776 |
+
<td>单图处理失败及异常信息</td>
|
| 777 |
+
</tr>
|
| 778 |
+
<tr>
|
| 779 |
+
<td><code>report.json/.html</code></td>
|
| 780 |
+
<td>配置、分辨率定义、阶段指标和前后分布</td>
|
| 781 |
+
</tr>
|
| 782 |
+
<tr>
|
| 783 |
+
<td><code>exported.jsonl</code></td>
|
| 784 |
+
<td>标准化图片相对路径及文本</td>
|
| 785 |
+
</tr>
|
| 786 |
+
<tr>
|
| 787 |
+
<td><code>export_errors.jsonl</code></td>
|
| 788 |
+
<td>导出失败清单</td>
|
| 789 |
+
</tr>
|
| 790 |
+
<tr>
|
| 791 |
+
<td><code>export.meta.json</code></td>
|
| 792 |
+
<td>导出完成状态、计数及身份校验</td>
|
| 793 |
+
</tr>
|
| 794 |
+
</tbody>
|
| 795 |
+
</table>
|
| 796 |
+
<p>分析续跑:</p>
|
| 797 |
+
<pre><code class="language-powershell">.\.venv\Scripts\python.exe -m image_cleaner analyze --manifest runs/distributed/input.jsonl --root "D:\node0\images" --output runs/distributed/shards --config configs/statistics_only.json --workers 8 --world-size 2 --rank 0 --resume
|
| 798 |
+
</code></pre>
|
| 799 |
+
<p>导出续跑:</p>
|
| 800 |
+
<pre><code class="language-powershell">.\.venv\Scripts\python.exe -m image_cleaner export --curated runs/only_256p --output runs/export_256p --workers 8 --resume
|
| 801 |
+
</code></pre>
|
| 802 |
+
<p><code>run --resume</code> 要求相同输入、配置、分辨率等身份设置;修改规则应使用 <code>filter</code> 输出到新目录。分析每 100 条提交一次;已提交项跳过。清单固定后保持原始文件不变。残留锁文件只能在确认任务退出后移除。</p>
|
| 803 |
+
<p>合并/筛选暂不支持中途续跑,失败时用新输出目录重跑,不必重新分析。导出失败会记录清单并返回退出码 1;参数/文件操作等任务错误返回 2;分析阶段的个别图片错误不使整批命令失败,需检查 <code>error_count</code>。</p>
|
| 804 |
+
<p>复核建议:检查总数守恒、目标尺寸匹配率、各原因抽样、清洗前后图片类别/宽高比分布变化,以及近重复误合并。为每轮保存独立目录及配置;后续训练/验证划分应让同图及其变体落在同一侧,本工具未自动划分数据集。</p>
|
| 805 |
+
<h2 id="8-验证与功能边界">8. 验证与功能边界</h2>
|
| 806 |
+
<p>在全新示例目录运行以下命令,可复现分辨率筛选,不依赖外部数据。示例是六张合成图片和一条损坏图片;256p 及以上预期保留五张(256×256、384×256、256×384、257×384、512×512)、尺寸拒绝一张、错误一张。重复执行请复用已生成输入并为任务更换新的输出目录。旧版筛选报告不会自动改写;重新 filter 到新目录,旧分析分片仍可复用。</p>
|
| 807 |
+
<pre><code class="language-powershell">.\.venv\Scripts\python.exe examples/make_resolution_demo.py
|
| 808 |
+
.\.venv\Scripts\python.exe -m image_cleaner run --input runs/resolution_demo_input --output runs/resolution_demo_statistics --config configs/statistics_only.json --workers 2 --sidecar-text
|
| 809 |
+
.\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/resolution_demo_statistics/shards --output runs/resolution_demo_256p --resolution 256p
|
| 810 |
+
.\.venv\Scripts\python.exe -m image_cleaner export --curated runs/resolution_demo_256p --output runs/resolution_demo_export --workers 2
|
| 811 |
+
</code></pre>
|
| 812 |
+
<p>单元与集成测试:</p>
|
| 813 |
+
<pre><code class="language-powershell">.\.venv\Scripts\python.exe -m unittest discover -s tests -v
|
| 814 |
+
</code></pre>
|
| 815 |
+
<p>测试包括包含边界的短边下限、精确宽高、255/256/257 边界、EXIF 后方向、配置覆盖、禁用尺寸条件、规则交集、无原图重复筛选,以及既有多进程/跨分片去重与导出测试。</p>
|
| 816 |
+
<p>未包含 CLIP 图文语义对齐、VLM 事实核验、审美模型、OCR、水印分类器、语义 embedding 去重或训练模型性能评测。当前技术质量指标无法判断图片里的描述是否事实正确。</p></main></div><footer>NMM-data-preprocess · v0.3.0<span>图片文本数据初筛教程</span></footer></body></html>
|
pyproject.toml
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["setuptools>=68"]
|
| 3 |
+
build-backend = "setuptools.build_meta"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "multimodal-image-cleaner"
|
| 7 |
+
version = "0.3.0"
|
| 8 |
+
description = "Streaming, sharded image-text dataset inspection and curation"
|
| 9 |
+
requires-python = ">=3.10"
|
| 10 |
+
dependencies = ["numpy>=1.26,<3", "Pillow>=10.4,<13"]
|
| 11 |
+
|
| 12 |
+
[project.optional-dependencies]
|
| 13 |
+
publish = ["huggingface_hub>=0.34,<2", "Markdown>=3.7,<4"]
|
| 14 |
+
|
| 15 |
+
[project.scripts]
|
| 16 |
+
image-cleaner = "image_cleaner.cli:main"
|
| 17 |
+
|
| 18 |
+
[tool.setuptools.packages.find]
|
| 19 |
+
include = ["image_cleaner*"]
|
tests/test_aspect.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import tempfile
|
| 4 |
+
import unittest
|
| 5 |
+
|
| 6 |
+
from PIL import Image
|
| 7 |
+
from image_cleaner.aspect import assess_aspect
|
| 8 |
+
from image_cleaner.config import ASPECT_BUCKETS, Config
|
| 9 |
+
from image_cleaner.cli import main
|
| 10 |
+
from image_cleaner.images import filter_reasons
|
| 11 |
+
from image_cleaner.io import prepare, manifest_items
|
| 12 |
+
from image_cleaner.jobs import analyze, merge
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class AspectTests(unittest.TestCase):
|
| 16 |
+
def match(self, width, height, **kwargs):
|
| 17 |
+
return assess_aspect({"width": width, "height": height}, Config(**kwargs))
|
| 18 |
+
|
| 19 |
+
def test_strict_presets_orientation_and_all(self):
|
| 20 |
+
for bucket in ASPECT_BUCKETS:
|
| 21 |
+
w, h = map(int, bucket.split(":"))
|
| 22 |
+
self.assertEqual(self.match(w*20, h*20, aspect_buckets="all")["aspect_bucket"], bucket)
|
| 23 |
+
self.assertFalse(self.match(60, 40, aspect_buckets="2:3")["aspect_bucket_match"])
|
| 24 |
+
self.assertFalse(self.match(9, 21, aspect_buckets="all")["aspect_bucket_match"])
|
| 25 |
+
self.assertFalse(self.match(1001, 1000, aspect_buckets="1:1")["aspect_bucket_match"])
|
| 26 |
+
|
| 27 |
+
def test_inclusive_symmetric_boundary_and_custom_tolerance(self):
|
| 28 |
+
for w, h in [(1050, 1000), (1000, 1050)]:
|
| 29 |
+
row = self.match(w, h, aspect_buckets="1:1", aspect_mode="loose")
|
| 30 |
+
self.assertTrue(row["aspect_bucket_match"])
|
| 31 |
+
self.assertEqual(row["aspect_bucket_deviation"], .05)
|
| 32 |
+
for w, h in [(1051, 1000), (1000, 1051)]:
|
| 33 |
+
self.assertFalse(self.match(w, h, aspect_buckets="1:1", aspect_mode="loose")["aspect_bucket_match"])
|
| 34 |
+
self.assertTrue(self.match(1100, 1000, aspect_buckets="1:1", aspect_mode="loose", aspect_tolerance=.1)["aspect_bucket_match"])
|
| 35 |
+
self.assertFalse(self.match(1001, 1000, aspect_buckets="1:1", aspect_mode="loose", aspect_tolerance=0)["aspect_bucket_match"])
|
| 36 |
+
|
| 37 |
+
def test_overlap_assignment_and_tie(self):
|
| 38 |
+
self.assertEqual(self.match(1400, 1000, aspect_buckets="3:2,4:3", aspect_mode="loose", aspect_tolerance=.2)["aspect_bucket"], "4:3")
|
| 39 |
+
self.assertEqual(self.match(100, 100, aspect_buckets="3:2,2:3", aspect_mode="loose", aspect_tolerance=.5)["aspect_bucket"], "2:3")
|
| 40 |
+
|
| 41 |
+
def test_validation_and_normalization(self):
|
| 42 |
+
for value in ["", "1:1,", [], "4:5", ["all"], 5]:
|
| 43 |
+
with self.assertRaises(ValueError):
|
| 44 |
+
Config(aspect_buckets=value)
|
| 45 |
+
for value in [None, True, -1, float("nan"), float("inf")]:
|
| 46 |
+
with self.assertRaises(ValueError):
|
| 47 |
+
Config(aspect_tolerance=value)
|
| 48 |
+
self.assertEqual(Config(aspect_buckets="3:2,1:1,1:1").aspect_buckets, ("1:1", "3:2"))
|
| 49 |
+
self.assertIsNone(Config(aspect_buckets="none").aspect_buckets)
|
| 50 |
+
with self.assertRaises(ValueError):
|
| 51 |
+
Config(aspect_mode="invalid")
|
| 52 |
+
|
| 53 |
+
def test_disabled_clears_stale_assignment(self):
|
| 54 |
+
row = {"width": 100, "height": 100, "short_edge": 100, "aspect_bucket": "1:1"}
|
| 55 |
+
self.assertEqual(filter_reasons(row, Config()), [])
|
| 56 |
+
self.assertIsNone(row["aspect_bucket"])
|
| 57 |
+
self.assertIsNone(row["aspect_bucket_match"])
|
| 58 |
+
|
| 59 |
+
def test_sharded_cached_filter_cli_and_reports(self):
|
| 60 |
+
temp_root = Path(__file__).resolve().parents[1]/"runs"/"test_tmp"
|
| 61 |
+
temp_root.mkdir(parents=True, exist_ok=True)
|
| 62 |
+
with tempfile.TemporaryDirectory(dir=temp_root) as temp:
|
| 63 |
+
root = Path(temp)
|
| 64 |
+
images = root/"images"
|
| 65 |
+
images.mkdir()
|
| 66 |
+
for i, size in enumerate([(256, 256), (315, 300), (316, 300), (300, 450), (128, 128)]):
|
| 67 |
+
Image.new("RGB", size, "blue").save(images/f"{i}.png")
|
| 68 |
+
(images/"broken.jpg").write_bytes(b"broken")
|
| 69 |
+
manifest = root/"input.jsonl"
|
| 70 |
+
prepare(images, manifest)
|
| 71 |
+
config = Config(deduplicate=False, near_deduplicate=False)
|
| 72 |
+
analyze(manifest, root/"single", config, workers=2)
|
| 73 |
+
for rank in range(2):
|
| 74 |
+
analyze(manifest, root/"shards", config, workers=2, rank=rank, world_size=2)
|
| 75 |
+
# Remove source bytes: repeated filtering must use cached measurements only.
|
| 76 |
+
for path in images.iterdir():
|
| 77 |
+
path.unlink()
|
| 78 |
+
args = ["filter", "--shards", str(root/"shards"), "--output", str(root/"loose"),
|
| 79 |
+
"--aspect-buckets", "1:1,2:3", "--aspect-mode", "loose", "--resolution", "256p"]
|
| 80 |
+
self.assertEqual(main(args), 0)
|
| 81 |
+
report = json.loads((root/"loose"/"report.json").read_text(encoding="utf-8"))
|
| 82 |
+
self.assertEqual(report["kept_count"], 3)
|
| 83 |
+
self.assertEqual(report["stage_metrics"]["aspect_bucket_matched_count"], 4)
|
| 84 |
+
self.assertEqual(report["stage_metrics"]["aspect_bucket_pass_rate"], .8)
|
| 85 |
+
self.assertEqual(report["aspect_bucket_filter"]["counts_before"], {"1:1": 3, "2:3": 1})
|
| 86 |
+
self.assertEqual(report["aspect_bucket_filter"]["counts_after"], {"1:1": 2, "2:3": 1})
|
| 87 |
+
merge(root/"single", root/"equivalent", resolution="256p", aspect_buckets="1:1,2:3", aspect_mode="loose")
|
| 88 |
+
for status in ["kept", "rejected", "error", "duplicate"]:
|
| 89 |
+
self.assertEqual((root/"loose"/f"{status}.jsonl").read_bytes(), (root/"equivalent"/f"{status}.jsonl").read_bytes())
|
| 90 |
+
merge(root/"shards", root/"strict", aspect_buckets="1:1,2:3")
|
| 91 |
+
strict = list(manifest_items(root/"strict"/"rejected.jsonl"))
|
| 92 |
+
self.assertEqual(len(strict), 2)
|
| 93 |
+
self.assertIn("aspect_bucket_mismatch", strict[0]["reasons"])
|
| 94 |
+
self.assertIsNone(strict[0]["aspect_bucket"])
|
| 95 |
+
self.assertIsNotNone(strict[0]["aspect_bucket_nearest"])
|
| 96 |
+
args[args.index(str(root/"loose"))] = str(root/"tighter")
|
| 97 |
+
self.assertEqual(main(args + ["--aspect-tolerance", "0.03"]), 0)
|
| 98 |
+
tighter = json.loads((root/"tighter"/"report.json").read_text(encoding="utf-8"))
|
| 99 |
+
self.assertEqual(tighter["kept_count"], 2)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
if __name__ == "__main__":
|
| 103 |
+
unittest.main()
|
tests/test_pipeline.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import random
|
| 4 |
+
import sqlite3
|
| 5 |
+
import tempfile
|
| 6 |
+
import unittest
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
from PIL import Image, ImageFilter
|
| 10 |
+
|
| 11 |
+
from image_cleaner.config import Config, load_config
|
| 12 |
+
from image_cleaner.images import analyze_one, canonical_image, normalize_text
|
| 13 |
+
from image_cleaner.io import manifest_items, prepare
|
| 14 |
+
from image_cleaner.jobs import analyze, export_images, hash_bands, merge
|
| 15 |
+
from image_cleaner.metrics import quality_metrics
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class PipelineTests(unittest.TestCase):
|
| 19 |
+
def setUp(self):
|
| 20 |
+
temp_root = Path(__file__).resolve().parents[1]/"runs"/"test_tmp"
|
| 21 |
+
temp_root.mkdir(parents=True, exist_ok=True)
|
| 22 |
+
self.temp = tempfile.TemporaryDirectory(dir=temp_root)
|
| 23 |
+
self.addCleanup(self.temp.cleanup)
|
| 24 |
+
self.root = Path(self.temp.name)
|
| 25 |
+
self.images = self.root/"input"
|
| 26 |
+
self.images.mkdir()
|
| 27 |
+
rng = np.random.default_rng(123)
|
| 28 |
+
self.rgb = Image.fromarray(rng.integers(0, 256, (96, 128, 3), dtype=np.uint8))
|
| 29 |
+
|
| 30 |
+
def save(self, name, image=None):
|
| 31 |
+
path = self.images/name
|
| 32 |
+
(image or self.rgb).save(path)
|
| 33 |
+
return path
|
| 34 |
+
|
| 35 |
+
def row(self, path, config=Config()):
|
| 36 |
+
return analyze_one(({"seq": 0, "id": "sample-000000000000", "path": path.name}, str(path), config.to_dict()))
|
| 37 |
+
|
| 38 |
+
def rows(self, directory, kind):
|
| 39 |
+
return list(manifest_items(directory/f"{kind}.jsonl"))
|
| 40 |
+
|
| 41 |
+
def test_pixel_duplicates_and_corrupt_files(self):
|
| 42 |
+
self.save("one.png")
|
| 43 |
+
self.save("two.bmp")
|
| 44 |
+
(self.images/"broken.jpg").write_bytes(b"not a jpeg")
|
| 45 |
+
manifest = self.root/"input.jsonl"
|
| 46 |
+
prepare(self.images, manifest)
|
| 47 |
+
analyze(manifest, self.root/"shards", Config())
|
| 48 |
+
output = self.root/"curated"
|
| 49 |
+
result = merge(self.root/"shards", output)
|
| 50 |
+
self.assertEqual(result, {"input_count": 3, "kept_count": 1, "rejected_count": 0,
|
| 51 |
+
"duplicate_count": 1, "error_count": 1})
|
| 52 |
+
self.assertEqual(self.rows(output, "duplicate")[0]["duplicate_kind"], "exact_pixels")
|
| 53 |
+
report = json.loads((output/"report.json").read_text(encoding="utf-8"))
|
| 54 |
+
self.assertEqual(report["before"]["distributions"]["width"]["count"], 2)
|
| 55 |
+
self.assertEqual(report["after"]["distributions"]["width"]["count"], 1)
|
| 56 |
+
self.assertTrue((output/"report.html").exists())
|
| 57 |
+
|
| 58 |
+
def test_single_and_two_shards_are_identical_and_resume(self):
|
| 59 |
+
self.save("a.png")
|
| 60 |
+
self.save("b.bmp")
|
| 61 |
+
self.save("c.png", Image.new("RGB", (60, 100), "blue"))
|
| 62 |
+
self.save("d.png", Image.new("RGB", (60, 100), "red"))
|
| 63 |
+
manifest = self.root/"input.jsonl"
|
| 64 |
+
prepare(self.images, manifest)
|
| 65 |
+
config = Config()
|
| 66 |
+
analyze(manifest, self.root/"single", config, workers=2)
|
| 67 |
+
analyze(manifest, self.root/"distributed", config, workers=2, rank=0, world_size=2)
|
| 68 |
+
with self.assertRaisesRegex(ValueError, "Missing"):
|
| 69 |
+
merge(self.root/"distributed", self.root/"incomplete")
|
| 70 |
+
analyze(manifest, self.root/"distributed", config, workers=2, rank=1, world_size=2)
|
| 71 |
+
result = analyze(manifest, self.root/"distributed", config, rank=1, world_size=2, resume=True)
|
| 72 |
+
self.assertEqual(result["new_records"], 0)
|
| 73 |
+
merge(self.root/"single", self.root/"single_out")
|
| 74 |
+
merge(self.root/"distributed", self.root/"dist_out")
|
| 75 |
+
for kind in ["kept", "duplicate", "rejected", "error"]:
|
| 76 |
+
self.assertEqual(self.rows(self.root/"single_out", kind), self.rows(self.root/"dist_out", kind))
|
| 77 |
+
self.assertEqual(len(self.rows(self.root/"dist_out", "kept")), 3) # Different flat colors survive.
|
| 78 |
+
|
| 79 |
+
def test_exif_and_alpha_normalization(self):
|
| 80 |
+
path = self.images/"rotated.jpg"
|
| 81 |
+
exif = Image.Exif()
|
| 82 |
+
exif[274] = 6
|
| 83 |
+
Image.new("RGB", (40, 20), "red").save(path, exif=exif)
|
| 84 |
+
rgb, metadata = canonical_image(path, Config())
|
| 85 |
+
self.assertEqual(rgb.size, (20, 40))
|
| 86 |
+
self.assertTrue(metadata["exif_transposed"])
|
| 87 |
+
alpha = self.save("alpha.png", Image.new("RGBA", (20, 20), (255, 0, 0, 0)))
|
| 88 |
+
rgb, metadata = canonical_image(alpha, Config())
|
| 89 |
+
self.assertEqual(rgb.getpixel((0, 0)), (255, 255, 255))
|
| 90 |
+
self.assertTrue(metadata["alpha_flattened"])
|
| 91 |
+
|
| 92 |
+
def test_noise_estimate_and_blur_are_separate(self):
|
| 93 |
+
rng = np.random.default_rng(13)
|
| 94 |
+
clean = np.full((256, 256), 128.0)
|
| 95 |
+
noisy = np.clip(clean + rng.normal(0, 8, clean.shape), 0, 255).astype(np.uint8)
|
| 96 |
+
metrics = quality_metrics(Image.fromarray(noisy).convert("RGB"))
|
| 97 |
+
self.assertGreater(metrics["noise_sigma"], 4)
|
| 98 |
+
self.assertLess(metrics["noise_sigma"], 12)
|
| 99 |
+
checker = ((np.indices((256, 256)).sum(axis=0)//8) % 2 * 255).astype(np.uint8)
|
| 100 |
+
sharp = Image.fromarray(checker).convert("RGB")
|
| 101 |
+
blurred = sharp.filter(ImageFilter.GaussianBlur(3))
|
| 102 |
+
self.assertGreater(quality_metrics(sharp)["blur_score"], quality_metrics(blurred)["blur_score"])
|
| 103 |
+
flat = quality_metrics(Image.new("RGB", (128, 128), "white"))
|
| 104 |
+
self.assertIsNone(flat["snr_proxy_db"])
|
| 105 |
+
|
| 106 |
+
def test_filters_and_refilter_without_redecode(self):
|
| 107 |
+
self.save("large.png")
|
| 108 |
+
self.save("small.png", Image.new("RGB", (20, 30), "green"))
|
| 109 |
+
manifest = self.root/"manifest.jsonl"
|
| 110 |
+
prepare(self.images, manifest)
|
| 111 |
+
analyze(manifest, self.root/"shards", Config())
|
| 112 |
+
result = merge(self.root/"shards", self.root/"out", Config(min_short_edge=50))
|
| 113 |
+
self.assertEqual(result["rejected_count"], 1)
|
| 114 |
+
self.assertIn("min_short_edge", self.rows(self.root/"out", "rejected")[0]["reasons"])
|
| 115 |
+
with self.assertRaisesRegex(ValueError, "analysis setting"):
|
| 116 |
+
merge(self.root/"shards", self.root/"invalid", Config(metric_patch_size=128))
|
| 117 |
+
|
| 118 |
+
def test_captions_preserved_and_image_scope_optional(self):
|
| 119 |
+
path = self.save("a.png")
|
| 120 |
+
input_manifest = self.root/"raw.jsonl"
|
| 121 |
+
records = [{"path": str(path), "id": "original1", "text": "first", "license": "example"},
|
| 122 |
+
{"path": str(path), "id": "original2", "text": "second"},
|
| 123 |
+
{"path": str(path), "id": "original3", "text": "first"}]
|
| 124 |
+
input_manifest.write_text("".join(json.dumps(r)+"\n" for r in records), encoding="utf-8")
|
| 125 |
+
manifest = self.root/"manifest.jsonl"
|
| 126 |
+
prepare(input_manifest, manifest)
|
| 127 |
+
analyze(manifest, self.root/"shards", Config())
|
| 128 |
+
result = merge(self.root/"shards", self.root/"out")
|
| 129 |
+
self.assertEqual(result["kept_count"], 2)
|
| 130 |
+
self.assertEqual(result["duplicate_count"], 1)
|
| 131 |
+
self.assertEqual(self.rows(self.root/"out", "kept")[0]["metadata"]["license"], "example")
|
| 132 |
+
result = merge(self.root/"shards", self.root/"image_scope", Config(duplicate_scope="image"))
|
| 133 |
+
self.assertEqual(result["kept_count"], 1)
|
| 134 |
+
|
| 135 |
+
def test_export_resume_and_changed_source(self):
|
| 136 |
+
path = self.save("a.png")
|
| 137 |
+
manifest = self.root/"manifest.jsonl"
|
| 138 |
+
prepare(self.images, manifest)
|
| 139 |
+
config = Config(export_max_edge=64)
|
| 140 |
+
analyze(manifest, self.root/"shards", config)
|
| 141 |
+
merge(self.root/"shards", self.root/"out")
|
| 142 |
+
result = export_images(self.root/"out", self.root/"export", workers=2)
|
| 143 |
+
self.assertEqual(result, {"ok": 1})
|
| 144 |
+
exported = self.rows(self.root/"export", "exported")[0]
|
| 145 |
+
with Image.open(self.root/"export"/exported["path"]) as image:
|
| 146 |
+
self.assertEqual(image.size, (64, 48))
|
| 147 |
+
self.assertEqual(image.mode, "RGB")
|
| 148 |
+
self.assertEqual(export_images(self.root/"out", self.root/"export", resume=True), {"ok": 1})
|
| 149 |
+
path.write_bytes(b"changed")
|
| 150 |
+
self.assertEqual(export_images(self.root/"out", self.root/"export", resume=True), {"error": 1})
|
| 151 |
+
|
| 152 |
+
def test_hash_band_candidate_recall(self):
|
| 153 |
+
rng = random.Random(34)
|
| 154 |
+
for radius in range(9):
|
| 155 |
+
for _ in range(20):
|
| 156 |
+
value = rng.getrandbits(64)
|
| 157 |
+
neighbor = value
|
| 158 |
+
for bit in rng.sample(range(64), radius):
|
| 159 |
+
neighbor ^= 1 << bit
|
| 160 |
+
original_bands = set(hash_bands(f"{value:016x}", radius))
|
| 161 |
+
near_bands = set(hash_bands(f"{neighbor:016x}", radius))
|
| 162 |
+
self.assertTrue(original_bands & near_bands)
|
| 163 |
+
|
| 164 |
+
def test_invalid_config_and_frozen_manifest(self):
|
| 165 |
+
with self.assertRaises(ValueError):
|
| 166 |
+
Config(min_aspect_ratio=2, max_aspect_ratio=1)
|
| 167 |
+
with self.assertRaises(ValueError):
|
| 168 |
+
Config(phash_distance=12)
|
| 169 |
+
self.save("one.png")
|
| 170 |
+
manifest = self.root/"manifest.jsonl"
|
| 171 |
+
prepare(self.images, manifest)
|
| 172 |
+
with self.assertRaisesRegex(ValueError, "identity"):
|
| 173 |
+
analyze(manifest, self.root/"shards", Config())
|
| 174 |
+
analyze(manifest, self.root/"shards", Config(min_short_edge=100), resume=True)
|
| 175 |
+
manifest.write_text(manifest.read_text(encoding="utf-8")+"\n", encoding="utf-8")
|
| 176 |
+
with self.assertRaisesRegex(ValueError, "changed"):
|
| 177 |
+
analyze(manifest, self.root/"new_shards", Config())
|
| 178 |
+
|
| 179 |
+
def test_more_than_one_dedup_batch(self):
|
| 180 |
+
path = self.save("one.png")
|
| 181 |
+
raw = self.root/"raw.jsonl"
|
| 182 |
+
raw.write_text("".join(json.dumps({"path": str(path), "text": str(i)})+"\n" for i in range(503)), encoding="utf-8")
|
| 183 |
+
manifest = self.root/"manifest.jsonl"
|
| 184 |
+
prepare(raw, manifest)
|
| 185 |
+
analyze(manifest, self.root/"shards", Config(near_deduplicate=False))
|
| 186 |
+
result = merge(self.root/"shards", self.root/"out")
|
| 187 |
+
self.assertEqual(result["kept_count"], 503)
|
| 188 |
+
|
| 189 |
+
def test_small_images_and_text_normalization(self):
|
| 190 |
+
path = self.save("tiny.png", Image.new("RGB", (1, 1), "red"))
|
| 191 |
+
row = self.row(path)
|
| 192 |
+
self.assertEqual(row["status"], "candidate")
|
| 193 |
+
self.assertIsNone(row["blur_score"])
|
| 194 |
+
self.assertEqual(normalize_text(" e\u0301 \r\nsecond ", "NFC"), "é\nsecond")
|
| 195 |
+
|
| 196 |
+
def test_near_duplicate_recompression(self):
|
| 197 |
+
# Smooth spatial content survives recompression with perceptual/color guards.
|
| 198 |
+
y, x = np.indices((256, 256))
|
| 199 |
+
data = np.stack([(x % 256), (y % 256), ((x+y)//2 % 256)], axis=-1).astype(np.uint8)
|
| 200 |
+
image = Image.fromarray(data)
|
| 201 |
+
self.save("one.png", image)
|
| 202 |
+
image.save(self.images/"two.jpg", quality=92)
|
| 203 |
+
manifest = self.root/"manifest.jsonl"
|
| 204 |
+
prepare(self.images, manifest)
|
| 205 |
+
analyze(manifest, self.root/"shards", Config())
|
| 206 |
+
result = merge(self.root/"shards", self.root/"out")
|
| 207 |
+
self.assertEqual(result["duplicate_count"], 1)
|
| 208 |
+
self.assertEqual(self.rows(self.root/"out", "duplicate")[0]["duplicate_kind"], "near_perceptual")
|
| 209 |
+
|
| 210 |
+
def test_resolution_lower_bound_after_statistics_and_cli_override(self):
|
| 211 |
+
from image_cleaner.cli import dispatch, parser
|
| 212 |
+
config = Config(deduplicate=False, near_deduplicate=False)
|
| 213 |
+
for i, size in enumerate([(256, 256), (384, 256), (256, 384), (255, 384), (257, 384), (512, 512)]):
|
| 214 |
+
self.save(f"{i}.png", Image.new("RGB", size, (10+i*20, 20, 30)))
|
| 215 |
+
manifest = self.root/"manifest.jsonl"
|
| 216 |
+
prepare(self.images, manifest)
|
| 217 |
+
analyze(manifest, self.root/"shards", config, workers=2)
|
| 218 |
+
merge(self.root/"shards", self.root/"statistics")
|
| 219 |
+
args = parser().parse_args(["filter", "--shards", str(self.root/"shards"),
|
| 220 |
+
"--output", str(self.root/"only_256p"), "--resolution", "256p"])
|
| 221 |
+
result = dispatch(args)
|
| 222 |
+
self.assertEqual(result["kept_count"], 5)
|
| 223 |
+
self.assertEqual(result["rejected_count"], 1)
|
| 224 |
+
self.assertTrue(all(r["short_edge"] >= 256 for r in self.rows(self.root/"only_256p", "kept")))
|
| 225 |
+
self.assertTrue(all("resolution_mismatch" in r["reasons"]
|
| 226 |
+
for r in self.rows(self.root/"only_256p", "rejected")))
|
| 227 |
+
report = json.loads((self.root/"only_256p"/"report.json").read_text(encoding="utf-8"))
|
| 228 |
+
self.assertEqual(report["stage_metrics"]["resolution_matched_count"], 5)
|
| 229 |
+
self.assertEqual(report["stage_metrics"]["resolution_pass_rate"], 5/6)
|
| 230 |
+
self.assertEqual(report["resolution_filter"]["mode"], "short_edge_gte")
|
| 231 |
+
self.assertEqual(report["resolution_filter"]["comparison"], ">=")
|
| 232 |
+
config_path = self.root/"resolution.json"
|
| 233 |
+
config_path.write_text(json.dumps({**config.to_dict(), "resolution": "512p"}), encoding="utf-8")
|
| 234 |
+
args = parser().parse_args(["merge", "--shards", str(self.root/"shards"),
|
| 235 |
+
"--output", str(self.root/"only_square"), "--config", str(config_path),
|
| 236 |
+
"--resolution", "256x256"])
|
| 237 |
+
self.assertEqual(dispatch(args)["kept_count"], 1)
|
| 238 |
+
self.assertEqual(self.rows(self.root/"only_square", "kept")[0]["width"], 256)
|
| 239 |
+
self.assertEqual(merge(self.root/"shards", self.root/"reset", Config(**{
|
| 240 |
+
**config.to_dict(), "resolution": "256p"}), resolution="any")["kept_count"], 6)
|
| 241 |
+
|
| 242 |
+
def test_resolution_validation_and_rule_intersection(self):
|
| 243 |
+
from image_cleaner.images import filter_reasons
|
| 244 |
+
for bad in ["256", "0p", "0x256", "256x0", "256.0p", "256p,512p", 256, "-256p"]:
|
| 245 |
+
with self.subTest(bad=bad), self.assertRaises(ValueError):
|
| 246 |
+
Config(resolution=bad)
|
| 247 |
+
self.assertEqual(Config(resolution=" 256P ").resolution, "256p")
|
| 248 |
+
self.assertEqual(Config(resolution="256\u00d7384").resolution, "256x384")
|
| 249 |
+
path = self.save("match.png", Image.new("RGB", (257, 384), "blue"))
|
| 250 |
+
row = self.row(path, Config(resolution="256p", min_short_edge=300))
|
| 251 |
+
self.assertEqual(row["status"], "rejected")
|
| 252 |
+
self.assertEqual(row["reasons"], ["min_short_edge"])
|
| 253 |
+
self.assertEqual(filter_reasons(row, Config(resolution="384x256")), ["resolution_mismatch"])
|
| 254 |
+
|
| 255 |
+
def test_resolution_refilter_requires_no_source_reads(self):
|
| 256 |
+
path = self.save("match.png", Image.new("RGB", (384, 256), "blue"))
|
| 257 |
+
exif = Image.Exif()
|
| 258 |
+
exif[274] = 6
|
| 259 |
+
Image.new("RGB", (256, 384), "red").save(self.images/"rotated.jpg", exif=exif)
|
| 260 |
+
manifest = self.root/"manifest.jsonl"
|
| 261 |
+
prepare(self.images, manifest)
|
| 262 |
+
config = Config(deduplicate=False)
|
| 263 |
+
analyze(manifest, self.root/"shards", config, rank=0, world_size=2)
|
| 264 |
+
analyze(manifest, self.root/"shards", config, rank=1, world_size=2)
|
| 265 |
+
# Filtering should use dimensions already stored in the analysis database.
|
| 266 |
+
for image in self.images.iterdir():
|
| 267 |
+
image.unlink()
|
| 268 |
+
result = merge(self.root/"shards", self.root/"out", resolution="384x256")
|
| 269 |
+
self.assertEqual(result["kept_count"], 2)
|
| 270 |
+
self.assertEqual(result["rejected_count"], 0)
|
| 271 |
+
self.assertTrue(all((r["width"], r["height"]) == (384, 256) for r in self.rows(self.root/"out", "kept")))
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
if __name__ == "__main__":
|
| 275 |
+
unittest.main()
|
tools/build_space.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build an offline static tutorial and a source-only release archive."""
|
| 2 |
+
import hashlib
|
| 3 |
+
import html
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
import re
|
| 7 |
+
import sys
|
| 8 |
+
import zipfile
|
| 9 |
+
|
| 10 |
+
import markdown
|
| 11 |
+
from markdown.extensions.toc import slugify_unicode
|
| 12 |
+
|
| 13 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 14 |
+
sys.path.insert(0, str(ROOT))
|
| 15 |
+
from image_cleaner import __version__
|
| 16 |
+
|
| 17 |
+
REPO = "HanWangTJU/NMM-data-preprocess"
|
| 18 |
+
DOCS = {"QUICKSTART": "逐步教程", "USAGE": "完整手册", "PARAMETERS": "参数参考",
|
| 19 |
+
"DEPLOYMENT": "部署指南", "ARCHITECTURE": "代码说明"}
|
| 20 |
+
SOURCE_DIRS = ("image_cleaner", "configs", "docs", "examples", "tests", "tools", "web", "docker")
|
| 21 |
+
ROOT_FILES = ("README.md", "pyproject.toml", ".gitignore", ".dockerignore")
|
| 22 |
+
ALLOWED_SUFFIXES = {".py", ".json", ".md", ".html", ".css", ".js"}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def source_files():
|
| 26 |
+
paths = [ROOT/name for name in ROOT_FILES]
|
| 27 |
+
for directory in SOURCE_DIRS:
|
| 28 |
+
for path in (ROOT/directory).rglob("*"):
|
| 29 |
+
if path.is_file() and (path.suffix in ALLOWED_SUFFIXES or path.name == "Dockerfile"):
|
| 30 |
+
if "__pycache__" not in path.parts:
|
| 31 |
+
paths.append(path)
|
| 32 |
+
for path in paths:
|
| 33 |
+
if path.is_symlink() or not path.resolve().is_relative_to(ROOT):
|
| 34 |
+
raise ValueError("Publish source must remain inside the project")
|
| 35 |
+
return sorted(set(paths))
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def check_content(content):
|
| 39 |
+
if re.search(rb"hf_[A-Za-z0-9]{20,}", content):
|
| 40 |
+
raise ValueError("A credential-like Hugging Face token was detected; publication stopped")
|
| 41 |
+
if re.search(rb"(?i)(?:password|passwd)\s*[:=]\s*['\"][^'\"]{4,}['\"]", content):
|
| 42 |
+
raise ValueError("A password assignment was detected; publication stopped")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def check_path(path):
|
| 46 |
+
if path.is_symlink() or not path.resolve().is_relative_to(ROOT):
|
| 47 |
+
raise ValueError("Publish path must remain inside the project")
|
| 48 |
+
if path.suffix == ".zip":
|
| 49 |
+
with zipfile.ZipFile(path) as archive:
|
| 50 |
+
expected = {p.relative_to(ROOT).as_posix() for p in source_files()}
|
| 51 |
+
if set(archive.namelist()) != expected:
|
| 52 |
+
raise ValueError("Archive contents differ from source whitelist")
|
| 53 |
+
for name in archive.namelist():
|
| 54 |
+
check_content(archive.read(name))
|
| 55 |
+
else:
|
| 56 |
+
check_content(path.read_bytes())
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def publish_files():
|
| 60 |
+
return source_files() + [ROOT/"index.html", ROOT/"assets/style.css", ROOT/"assets/app.js"] + [
|
| 61 |
+
ROOT/"manual"/(name + ".html") for name in DOCS
|
| 62 |
+
] + [ROOT/"downloads"/f"NMM-data-preprocess-{__version__}.zip"]
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def rewrite_links(body):
|
| 66 |
+
def rewrite(match):
|
| 67 |
+
target = html.unescape(match.group(1))
|
| 68 |
+
base, _, fragment = target.partition("#")
|
| 69 |
+
if base.endswith(".md"):
|
| 70 |
+
stem = Path(base).stem
|
| 71 |
+
if stem in DOCS:
|
| 72 |
+
return 'href="' + stem + ".html" + ("#"+fragment if fragment else "") + '"'
|
| 73 |
+
return 'href="https://huggingface.co/spaces/' + REPO + '/blob/main/' + html.escape(base) + '"'
|
| 74 |
+
return match.group(0)
|
| 75 |
+
return re.sub(r'href="([^"]+)"', rewrite, body)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def build():
|
| 79 |
+
for directory in ["manual", "assets", "downloads", "release"]:
|
| 80 |
+
(ROOT/directory).mkdir(exist_ok=True)
|
| 81 |
+
(ROOT/"index.html").write_text((ROOT/"web/index.template.html").read_text(encoding="utf-8").replace(
|
| 82 |
+
"@@VERSION@@", __version__), encoding="utf-8")
|
| 83 |
+
for filename in ["style.css", "app.js"]:
|
| 84 |
+
(ROOT/"assets"/filename).write_bytes((ROOT/"web"/filename).read_bytes())
|
| 85 |
+
for name, title in DOCS.items():
|
| 86 |
+
renderer = markdown.Markdown(extensions=["toc", "tables", "fenced_code", "sane_lists"],
|
| 87 |
+
extension_configs={"toc": {"toc_depth": "2-3", "slugify": slugify_unicode}})
|
| 88 |
+
body = rewrite_links(renderer.convert((ROOT/"docs"/(name+".md")).read_text(encoding="utf-8")))
|
| 89 |
+
nav = "".join(f'<a class="{"active" if key == name else ""}" href="{key}.html">{label}</a>'
|
| 90 |
+
for key, label in DOCS.items())
|
| 91 |
+
page = ('<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">'
|
| 92 |
+
'<meta name="viewport" content="width=device-width,initial-scale=1">'
|
| 93 |
+
f'<title>{title} · NMM-data-preprocess</title><link rel="stylesheet" href="../assets/style.css">'
|
| 94 |
+
'<script defer src="../assets/app.js"></script></head><body><header class="topbar">'
|
| 95 |
+
'<a class="brand" href="../index.html">NMM<span> / </span>data-preprocess</a>'
|
| 96 |
+
f'<nav><a href="https://huggingface.co/spaces/{REPO}/tree/main">源代码 ↗</a>'
|
| 97 |
+
f'<a href="../downloads/NMM-data-preprocess-{__version__}.zip" download>下载源码 ↓</a></nav></header>'
|
| 98 |
+
f'<div class="manual-layout"><aside class="sidebar"><a href="../index.html">← 返回首页</a>{nav}'
|
| 99 |
+
f'<h2>本页目录</h2>{renderer.toc}</aside><main class="manual">{body}</main></div>'
|
| 100 |
+
f'<footer>NMM-data-preprocess · v{__version__}<span>图片文本数据初���教程</span></footer></body></html>')
|
| 101 |
+
(ROOT/"manual"/(name+".html")).write_text(page, encoding="utf-8")
|
| 102 |
+
archive_path = ROOT/"downloads"/f"NMM-data-preprocess-{__version__}.zip"
|
| 103 |
+
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
| 104 |
+
for path in source_files():
|
| 105 |
+
check_path(path)
|
| 106 |
+
# Fixed metadata makes the archive reproducible across local runs.
|
| 107 |
+
entry = zipfile.ZipInfo(path.relative_to(ROOT).as_posix(), date_time=(2026, 1, 1, 0, 0, 0))
|
| 108 |
+
entry.compress_type = zipfile.ZIP_DEFLATED
|
| 109 |
+
entry.external_attr = 0o644 << 16
|
| 110 |
+
archive.writestr(entry, path.read_bytes())
|
| 111 |
+
files = []
|
| 112 |
+
for path in publish_files():
|
| 113 |
+
check_path(path)
|
| 114 |
+
data = path.read_bytes()
|
| 115 |
+
files.append({"path": path.relative_to(ROOT).as_posix(), "bytes": len(data),
|
| 116 |
+
"sha256": hashlib.sha256(data).hexdigest()})
|
| 117 |
+
manifest = {"repo_id": REPO, "version": __version__, "files": files,
|
| 118 |
+
"archive": archive_path.relative_to(ROOT).as_posix()}
|
| 119 |
+
(ROOT/"release/publish-manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2)+"\n", encoding="utf-8")
|
| 120 |
+
print(json.dumps({"version": __version__, "publish_files": len(files),
|
| 121 |
+
"publish_bytes": sum(item["bytes"] for item in files), "archive": manifest["archive"]}))
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
if __name__ == "__main__":
|
| 125 |
+
build()
|
tools/check_release.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Validate built local links, documented Config coverage, and source ZIP contents."""
|
| 2 |
+
from dataclasses import fields
|
| 3 |
+
from html.parser import HTMLParser
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
import re
|
| 7 |
+
import sys
|
| 8 |
+
from urllib.parse import unquote, urlsplit
|
| 9 |
+
import zipfile
|
| 10 |
+
|
| 11 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 12 |
+
sys.path.insert(0, str(ROOT))
|
| 13 |
+
from image_cleaner.config import Config
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class Page(HTMLParser):
|
| 17 |
+
def __init__(self, path):
|
| 18 |
+
super().__init__()
|
| 19 |
+
self.ids, self.links = set(), []
|
| 20 |
+
self.feed(path.read_text(encoding="utf-8"))
|
| 21 |
+
|
| 22 |
+
def handle_starttag(self, tag, attrs):
|
| 23 |
+
values = dict(attrs)
|
| 24 |
+
if "id" in values:
|
| 25 |
+
self.ids.add(values["id"])
|
| 26 |
+
if tag in {"a", "link", "script"}:
|
| 27 |
+
value = values.get("href", values.get("src"))
|
| 28 |
+
if value:
|
| 29 |
+
self.links.append(value)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def main():
|
| 33 |
+
pages = [ROOT/"index.html"] + sorted((ROOT/"manual").glob("*.html"))
|
| 34 |
+
parsed = {path.resolve(): Page(path) for path in pages}
|
| 35 |
+
checked = 0
|
| 36 |
+
for path, page in parsed.items():
|
| 37 |
+
for link in page.links:
|
| 38 |
+
target = urlsplit(link)
|
| 39 |
+
if target.scheme or target.netloc:
|
| 40 |
+
continue
|
| 41 |
+
resolved = (path.parent/unquote(target.path)).resolve() if target.path else path
|
| 42 |
+
if not resolved.is_relative_to(ROOT) or not resolved.is_file():
|
| 43 |
+
raise ValueError(f"Broken local link: {path.name} -> {link}")
|
| 44 |
+
if target.fragment and resolved.suffix == ".html":
|
| 45 |
+
if unquote(target.fragment) not in parsed[resolved].ids:
|
| 46 |
+
raise ValueError(f"Broken anchor: {path.name} -> {link}")
|
| 47 |
+
checked += 1
|
| 48 |
+
documented = set(re.findall(r"^\| ([a-z_]+) \|", (ROOT/"docs/PARAMETERS.md").read_text(encoding="utf-8"), re.M))
|
| 49 |
+
missing = {field.name for field in fields(Config)} - documented
|
| 50 |
+
if missing:
|
| 51 |
+
raise ValueError(f"Undocumented Config fields: {sorted(missing)}")
|
| 52 |
+
manifest = json.loads((ROOT/"release/publish-manifest.json").read_text(encoding="utf-8"))
|
| 53 |
+
with zipfile.ZipFile(ROOT/manifest["archive"]) as archive:
|
| 54 |
+
if archive.testzip() is not None:
|
| 55 |
+
raise ValueError("Corrupt source archive")
|
| 56 |
+
if any(part in {".venv", "runs", "release", "__pycache__"} for name in archive.namelist() for part in Path(name).parts):
|
| 57 |
+
raise ValueError("Excluded local artifacts were found in archive")
|
| 58 |
+
source_count = len(archive.namelist())
|
| 59 |
+
print(json.dumps({"html_pages": len(pages), "local_links_verified": checked,
|
| 60 |
+
"config_fields_documented": len(fields(Config)), "source_archive_files": source_count}))
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
if __name__ == "__main__":
|
| 64 |
+
main()
|
tools/check_web.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
const assert = require("node:assert/strict");
|
| 3 |
+
const {bucketAssessment, buildFilterCommand} = require("../web/app.js");
|
| 4 |
+
for (const [width, height] of [[1050,1000],[1000,1050]]) {
|
| 5 |
+
assert.equal(bucketAssessment(width,height,["1:1"],"loose",5).matched,true);
|
| 6 |
+
}
|
| 7 |
+
for (const [width, height] of [[1051,1000],[1000,1051]]) {
|
| 8 |
+
assert.equal(bucketAssessment(width,height,["1:1"],"loose",5).matched,false);
|
| 9 |
+
}
|
| 10 |
+
assert.equal(bucketAssessment(1000,1000,["2:3","3:2"],"loose",50).bucket,"2:3");
|
| 11 |
+
assert.equal(bucketAssessment(1400,1000,["3:2","4:3"],"loose",20).bucket,"4:3");
|
| 12 |
+
assert.equal(bucketAssessment(1001,1000,["1:1"],"strict",5).matched,false);
|
| 13 |
+
assert.equal(bucketAssessment(1000,1000,["1:1"],"loose",0).matched,true);
|
| 14 |
+
assert.equal(bucketAssessment(1001,1000,["1:1"],"loose",0).matched,false);
|
| 15 |
+
assert.equal(bucketAssessment(1001,1000,["1:1"],"loose",1e-8).matched,false);
|
| 16 |
+
assert.ok(buildFilterCommand("windows","256p",["1:1"],"loose",5).includes("--aspect-tolerance 0.05"));
|
| 17 |
+
assert.ok(buildFilterCommand("linux","384x256",["2:3","3:2"],"strict",5).includes('--aspect-buckets "2:3,3:2" --aspect-mode strict'));
|
| 18 |
+
assert.ok(buildFilterCommand("linux","any",[],"strict",5).endsWith("--aspect-buckets none"));
|
| 19 |
+
assert.throws(() => buildFilterCommand("linux","256p;echo injected",[],"loose",5));
|
| 20 |
+
assert.throws(() => buildFilterCommand("linux","256p",["1:1"],"loose",-1));
|
| 21 |
+
assert.throws(() => buildFilterCommand("linux","256p",["1:1"],"loose",Infinity));
|
| 22 |
+
console.log("Browser command generator and exact ratio boundary checks passed.");
|
tools/publish_space.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Publish an audited source/documentation whitelist using a process-only HF_TOKEN."""
|
| 2 |
+
import argparse
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import sys
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
|
| 9 |
+
from huggingface_hub import HfApi, CommitOperationAdd
|
| 10 |
+
from huggingface_hub.errors import RepositoryNotFoundError
|
| 11 |
+
from build_space import ROOT, check_path, publish_files
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def main():
|
| 15 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 16 |
+
parser.add_argument("--repo-id", default="HanWangTJU/NMM-data-preprocess")
|
| 17 |
+
args = parser.parse_args()
|
| 18 |
+
token = os.environ.get("HF_TOKEN")
|
| 19 |
+
if not token:
|
| 20 |
+
raise ValueError("Set HF_TOKEN in the current process environment before publishing")
|
| 21 |
+
manifest = json.loads((ROOT/"release/publish-manifest.json").read_text(encoding="utf-8"))
|
| 22 |
+
expected = {path.relative_to(ROOT).as_posix() for path in publish_files()}
|
| 23 |
+
actual = [entry["path"] for entry in manifest["files"]]
|
| 24 |
+
if set(actual) != expected or len(actual) != len(expected):
|
| 25 |
+
raise ValueError("Manifest differs from the publication whitelist; rebuild first")
|
| 26 |
+
operations = []
|
| 27 |
+
for entry in manifest["files"]:
|
| 28 |
+
path = ROOT/entry["path"]
|
| 29 |
+
check_path(path)
|
| 30 |
+
if hashlib.sha256(path.read_bytes()).hexdigest() != entry["sha256"]:
|
| 31 |
+
raise ValueError("A release file changed after build; rebuild before publishing")
|
| 32 |
+
operations.append(CommitOperationAdd(path_in_repo=entry["path"], path_or_fileobj=str(path)))
|
| 33 |
+
api = HfApi(token=token)
|
| 34 |
+
user = api.whoami()["name"]
|
| 35 |
+
if args.repo_id.split("/")[0] != user:
|
| 36 |
+
raise ValueError("Authenticated account does not match the requested Space namespace")
|
| 37 |
+
try:
|
| 38 |
+
info = api.repo_info(args.repo_id, repo_type="space")
|
| 39 |
+
if info.sdk != "static":
|
| 40 |
+
raise ValueError("Existing Space is not static; refusing to replace its SDK")
|
| 41 |
+
parent_commit = info.sha
|
| 42 |
+
except RepositoryNotFoundError as exc:
|
| 43 |
+
if getattr(exc.response, "status_code", None) != 404:
|
| 44 |
+
raise
|
| 45 |
+
api.create_repo(args.repo_id, repo_type="space", space_sdk="static", private=False)
|
| 46 |
+
parent_commit = api.repo_info(args.repo_id, repo_type="space").sha
|
| 47 |
+
commit = api.create_commit(repo_id=args.repo_id, repo_type="space", operations=operations,
|
| 48 |
+
parent_commit=parent_commit,
|
| 49 |
+
commit_message=f"Publish NMM data preprocessing v{manifest['version']} with Chinese tutorials")
|
| 50 |
+
info = api.repo_info(args.repo_id, repo_type="space", revision=commit.oid, files_metadata=True)
|
| 51 |
+
remote = {item.rfilename: item for item in info.siblings}
|
| 52 |
+
if not expected.issubset(remote):
|
| 53 |
+
raise ValueError("Commit completed but a published path is missing; inspect repository")
|
| 54 |
+
if any(remote[item["path"]].size != item["bytes"] for item in manifest["files"]):
|
| 55 |
+
raise ValueError("Committed file sizes do not match release manifest")
|
| 56 |
+
receipt = {"repo_id": args.repo_id, "version": manifest["version"], "commit": commit.oid,
|
| 57 |
+
"space_url": f"https://huggingface.co/spaces/{args.repo_id}",
|
| 58 |
+
"published_file_count": len(operations), "verified_remote_sizes": True,
|
| 59 |
+
"published_at_utc": datetime.now(timezone.utc).isoformat()}
|
| 60 |
+
(ROOT/"release/publish-receipt.json").write_text(json.dumps(receipt, indent=2)+"\n", encoding="utf-8")
|
| 61 |
+
print(json.dumps(receipt, indent=2))
|
| 62 |
+
return 0
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
if __name__ == "__main__":
|
| 66 |
+
try:
|
| 67 |
+
sys.exit(main())
|
| 68 |
+
except Exception as exc:
|
| 69 |
+
# Do not print HTTP request objects or environment values on authentication failures.
|
| 70 |
+
status = getattr(getattr(exc, "response", None), "status_code", None)
|
| 71 |
+
print(json.dumps({"ok": False, "error_type": type(exc).__name__, "http_status": status,
|
| 72 |
+
"reason": str(exc) if isinstance(exc, ValueError) else "Hugging Face request failed"}), file=sys.stderr)
|
| 73 |
+
sys.exit(2)
|
web/app.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use strict";
|
| 2 |
+
// Use integer rational comparisons in the browser calculator, like the CLI.
|
| 3 |
+
function decimalFraction(value) {
|
| 4 |
+
const parts = String(value).toLowerCase().split("e");
|
| 5 |
+
const mantissa = parts[0].split(".");
|
| 6 |
+
let numerator = BigInt(mantissa.join(""));
|
| 7 |
+
const exponent = Number(parts[1] || 0) - (mantissa[1] || "").length;
|
| 8 |
+
let denominator = 1n;
|
| 9 |
+
if (exponent >= 0) numerator *= 10n ** BigInt(exponent);
|
| 10 |
+
else denominator = 10n ** BigInt(-exponent);
|
| 11 |
+
return [numerator, denominator];
|
| 12 |
+
}
|
| 13 |
+
function bucketAssessment(width, height, buckets, mode, percent) {
|
| 14 |
+
let best = null;
|
| 15 |
+
const [tn, td] = decimalFraction(percent);
|
| 16 |
+
for (const bucket of buckets) {
|
| 17 |
+
const [n, d] = bucket.split(":").map(BigInt);
|
| 18 |
+
const a = BigInt(width) * d, b = BigInt(height) * n;
|
| 19 |
+
const difference = a >= b ? a - b : b - a, base = a <= b ? a : b;
|
| 20 |
+
const matched = mode === "strict" ? difference === 0n : difference * td * 100n <= base * tn;
|
| 21 |
+
if (best === null || difference * best.base < best.difference * base) {
|
| 22 |
+
best = {bucket, difference, base, matched};
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
return best;
|
| 26 |
+
}
|
| 27 |
+
function buildFilterCommand(platform, resolution, buckets, mode, percent) {
|
| 28 |
+
if (!/^(?:[1-9][0-9]*p|[1-9][0-9]*x[1-9][0-9]*|any|none)$/.test(resolution)) {
|
| 29 |
+
throw new Error("分辨率请填写 256p、384x256 或 any;数字需为正整数。");
|
| 30 |
+
}
|
| 31 |
+
if (mode === "loose" && (!Number.isFinite(Number(percent)) || Number(percent) < 0 || String(percent).trim() === "")) {
|
| 32 |
+
throw new Error("宽松偏差需为有限非负数,例如 5 表示 5%。");
|
| 33 |
+
}
|
| 34 |
+
const python = platform === "windows" ? ".\\.venv\\Scripts\\python.exe" : ".venv/bin/python";
|
| 35 |
+
const selected = buckets.length === 8 ? "all" : buckets.length ? '"' + buckets.join(",") + '"' : "none";
|
| 36 |
+
let command = `${python} -m image_cleaner filter --shards runs/statistics/shards --output runs/filtered --resolution ${resolution} --aspect-buckets ${selected}`;
|
| 37 |
+
if (buckets.length) {
|
| 38 |
+
command += ` --aspect-mode ${mode}`;
|
| 39 |
+
if (mode === "loose") command += ` --aspect-tolerance ${Number(percent) / 100}`;
|
| 40 |
+
}
|
| 41 |
+
return command;
|
| 42 |
+
}
|
| 43 |
+
if (typeof module !== "undefined") module.exports = {bucketAssessment, buildFilterCommand};
|
| 44 |
+
if (typeof document !== "undefined") {
|
| 45 |
+
document.querySelectorAll("table").forEach(table => {
|
| 46 |
+
const wrapper = document.createElement("div"); wrapper.className = "table-scroll";
|
| 47 |
+
table.parentNode.insertBefore(wrapper, table); wrapper.appendChild(table);
|
| 48 |
+
});
|
| 49 |
+
document.querySelectorAll("pre").forEach(pre => {
|
| 50 |
+
const button = document.createElement("button"); button.className = "copy-code";
|
| 51 |
+
button.type = "button"; button.textContent = "复制";
|
| 52 |
+
button.addEventListener("click", async () => {
|
| 53 |
+
const value = pre.querySelector("code") ? pre.querySelector("code").textContent : pre.textContent.replace(/复制$/, "");
|
| 54 |
+
try {
|
| 55 |
+
await navigator.clipboard.writeText(value); button.textContent = "已复制";
|
| 56 |
+
} catch (_) { button.textContent = "请选中后复制"; }
|
| 57 |
+
setTimeout(() => {button.textContent = "复制";}, 2000);
|
| 58 |
+
});
|
| 59 |
+
pre.appendChild(button);
|
| 60 |
+
});
|
| 61 |
+
if (document.getElementById("command")) {
|
| 62 |
+
const ids = ["platform", "resolution", "mode", "tolerance", "width", "height"];
|
| 63 |
+
const controls = Object.fromEntries(ids.map(id => [id, document.getElementById(id)]));
|
| 64 |
+
const buckets = [...document.querySelectorAll('input[name="bucket"]')];
|
| 65 |
+
const update = () => {
|
| 66 |
+
const selected = buckets.filter(x => x.checked).map(x => x.value);
|
| 67 |
+
const mode = controls.mode.value, percent = controls.tolerance.value;
|
| 68 |
+
controls.tolerance.disabled = mode === "strict";
|
| 69 |
+
const error = document.getElementById("command-error"), output = document.getElementById("command");
|
| 70 |
+
try {
|
| 71 |
+
output.textContent = buildFilterCommand(controls.platform.value, controls.resolution.value.trim().toLowerCase(), selected, mode, percent);
|
| 72 |
+
error.textContent = "";
|
| 73 |
+
} catch (e) { output.textContent = "请先修正参数。"; error.textContent = e.message; }
|
| 74 |
+
const result = document.getElementById("ratio-result"), w = Number(controls.width.value), h = Number(controls.height.value);
|
| 75 |
+
if (!Number.isSafeInteger(w) || !Number.isSafeInteger(h) || w < 1 || h < 1) {
|
| 76 |
+
result.textContent = "请输入大于零的整数宽高。"; return;
|
| 77 |
+
}
|
| 78 |
+
if (error.textContent) { result.textContent = "请先修正筛选参数。"; return; }
|
| 79 |
+
const best = bucketAssessment(w, h, selected, mode, mode === "strict" ? "0" : percent);
|
| 80 |
+
const resolution = controls.resolution.value.trim().toLowerCase();
|
| 81 |
+
let sizeMatched = true;
|
| 82 |
+
if (resolution.endsWith("p")) sizeMatched = Math.min(w, h) >= Number(resolution.slice(0, -1));
|
| 83 |
+
else if (resolution.includes("x")) {
|
| 84 |
+
const [rw, rh] = resolution.split("x").map(Number); sizeMatched = w === rw && h === rh;
|
| 85 |
+
}
|
| 86 |
+
const bucketMessage = best ? `${best.matched ? "桶条件通过" : "桶条件未通过"};最近桶 ${best.bucket},偏差 ${(Number(best.difference) / Number(best.base) * 100).toFixed(3)}%` : "桶条件已关闭";
|
| 87 |
+
result.textContent = `${w}×${h}:${bucketMessage};分辨率条件${sizeMatched ? "通过" : "未通过"}。`;
|
| 88 |
+
};
|
| 89 |
+
[...Object.values(controls), ...buckets].forEach(x => x.addEventListener("input", update));
|
| 90 |
+
document.getElementById("select-all").addEventListener("click", () => {buckets.forEach(x => x.checked = true); update();});
|
| 91 |
+
document.getElementById("select-none").addEventListener("click", () => {buckets.forEach(x => x.checked = false); update();});
|
| 92 |
+
update();
|
| 93 |
+
}
|
| 94 |
+
}
|
web/index.template.html
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="description" content="图片文本数据初筛代码、分布统计、尺寸与宽高比筛选、质量指标、去重和多机部署教程。"><title>NMM-data-preprocess · 图片文本数据初筛</title><link rel="stylesheet" href="assets/style.css"><script defer src="assets/app.js"></script></head>
|
| 4 |
+
<body>
|
| 5 |
+
<header class="topbar"><a class="brand" href="index.html">NMM<span> / </span>data-preprocess</a><nav aria-label="主要导航"><a href="manual/QUICKSTART.html">开始使用</a><a href="manual/PARAMETERS.html">参数参考</a><a href="manual/DEPLOYMENT.html">部署</a><a href="https://huggingface.co/spaces/HanWangTJU/NMM-data-preprocess/tree/main" target="_blank" rel="noopener">源代码 ↗</a></nav></header>
|
| 6 |
+
<main class="landing">
|
| 7 |
+
<section class="hero"><p class="eyebrow">原生图片—文本模型 / 训练前数据初筛</p><h1>先看清数据,<br>再决定保留什么。</h1><p class="lead">从图片文件夹或图文清单开始,统计尺寸与质量分布,按分辨率和宽高比筛选,再完成全局去重与标准化导出。</p><div class="actions"><a class="button primary" href="manual/QUICKSTART.html">阅读逐步教程 →</a><a class="button" href="downloads/NMM-data-preprocess-@@VERSION@@.zip" download>下载源码 v@@VERSION@@ ↓</a></div><p class="note">Python 3.10+ · CPU 多进程 · 多机静态分片 · Windows / Linux / Docker</p></section>
|
| 8 |
+
<section class="pipeline" aria-label="清洗流程"><div><b>01</b><strong>导入</strong><span>文件夹 / JSONL</span></div><div><b>02</b><strong>统计</strong><span>尺寸 / 技术质量</span></div><div><b>03</b><strong>筛选</strong><span>规则 / 全局去重</span></div><div><b>04</b><strong>导出</strong><span>RGB 图片 / 图文清单</span></div></section>
|
| 9 |
+
<section class="intro"><h2>完整教程与代码说明</h2><p>这个 Space 提供文档、命令设置和代码下载。清洗任务通过 CLI 在你的本地机器或服务器运行。</p><div class="guide-grid"><a class="guide" href="manual/QUICKSTART.html"><small>从零开始</small><h3>逐步使用教程</h3><p>下载、安装、合成样例、第一轮统计、筛选与导出。</p><span>阅读教程 →</span></a><a class="guide" href="manual/USAGE.html"><small>评测与规则</small><h3>完整操作手册</h3><p>逐阶段指标、公式、报告字段、质量校准和误删评估。</p><span>查看手册 →</span></a><a class="guide" href="manual/PARAMETERS.html"><small>全部设置</small><h3>参数参考</h3><p>每个 CLI 与 JSON 字段的默认值、约束、优先级和示例。</p><span>查询参数 →</span></a><a class="guide" href="manual/DEPLOYMENT.html"><small>本地与多机</small><h3>部署指南</h3><p>Windows、Linux、Docker、rank 分片、续跑和 Space 发布。</p><span>部署运行 →</span></a><a class="guide" href="manual/ARCHITECTURE.html"><small>实现细节</small><h3>代码与功能说明</h3><p>模块职责、数据流、文件格式、去重机制与当前边界。</p><span>了解实现 →</span></a></div></section>
|
| 10 |
+
<section class="generator" id="commands"><div class="section-heading"><div><p class="eyebrow">配置尺寸与比例</p><h2>生成一条筛选命令</h2></div><span class="tag">复用统计缓存,无需重新解码</span></div><p>先按教程完成统计,将分片放在 <code>runs/statistics/shards</code>。以下设置只生成命令,可直接复制到终端。</p><div class="form-grid"><label>终端环境<select id="platform"><option value="windows">Windows PowerShell</option><option value="linux">Linux Bash</option></select></label><label>分辨率下限<input id="resolution" type="text" value="256p" placeholder="256p / 384x256 / any"></label><label>匹配策略<select id="mode"><option value="loose">宽松过滤</option><option value="strict">严格过滤</option></select></label><label>宽松偏差(%)<input id="tolerance" type="number" min="0" step="0.1" value="5"></label></div><fieldset class="buckets"><legend>宽:高桶(多选)</legend><label><input type="checkbox" name="bucket" value="1:1" checked>1:1</label><label><input type="checkbox" name="bucket" value="2:3" checked>2:3</label><label><input type="checkbox" name="bucket" value="3:2" checked>3:2</label><label><input type="checkbox" name="bucket" value="3:4" checked>3:4</label><label><input type="checkbox" name="bucket" value="4:3" checked>4:3</label><label><input type="checkbox" name="bucket" value="9:16" checked>9:16</label><label><input type="checkbox" name="bucket" value="16:9" checked>16:9</label><label><input type="checkbox" name="bucket" value="21:9" checked>21:9</label><button type="button" id="select-all">全选</button><button type="button" id="select-none">关闭桶条件</button></fieldset><p id="command-error" class="error" role="alert"></p><pre><code id="command"></code></pre><p class="note">输出到 <code>runs/filtered</code>;已有结果时请改用新目录。命令继承分析���置中的质量与去重设置。256p 表示短边 ≥256,包含 256。</p>
|
| 11 |
+
<details><summary>用实际尺寸检查比例规则</summary><div class="form-grid"><label>图片宽(像素)<input id="width" type="number" min="1" step="1" value="1050"></label><label>图片高(像素)<input id="height" type="number" min="1" step="1" value="1000"></label></div><p id="ratio-result" aria-live="polite"></p><p class="note">偏差 δ=max(r/b,b/r)−1,包含阈值边界。实际 CLI 用精确分数比较;这里只展示所选桶和分辨率条件,不判断图片质量、文字和重复。</p></details></section>
|
| 12 |
+
<section class="features"><h2>当前初筛覆盖</h2><div class="feature-grid"><article><h3>分布统计</h3><p>分辨率、短边、长边、像素数、比例和质量分布;全量直方图与抽样分位数。</p></article><article><h3>分辨率与八桶筛选</h3><p>短边下限、精确宽高、严格比例和默认 5% 可调宽松偏差。</p></article><article><h3>规范化与文本规则</h3><p>EXIF、ICC、RGB、透明背景、Unicode、文本长度与正则检查。</p></article><article><h3>技术质量代理</h3><p>模糊、噪声、信噪比与块效应指标;阈值需针对你的数据校准。</p></article><article><h3>全局去重</h3><p>文件和像素精确去重,pHash / dHash / RMSE 联合近重复复核。</p></article><article><h3>多进程与多机</h3><p>SQLite 检查点与静态 rank;汇总分片后统一筛选、去重和报告。</p></article></div></section>
|
| 13 |
+
<section class="callout"><h2>看报告时,先核对这些数字</h2><p>输入总数 = 保留 + 拒绝 + 重复 + 错误。比较分辨率独立通过率、桶独立通过率、最终保留率,以及各桶筛选前后数量。初筛结果仍需人工抽查图文对应关系与质量。</p><a href="manual/USAGE.html">阅读所有指标的定义 →</a></section>
|
| 14 |
+
</main><footer>HanWangTJU / NMM-data-preprocess · v@@VERSION@@<span>静态教程与代码入口 · 图片在本地处理</span></footer>
|
| 15 |
+
</body></html>
|
web/style.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
:root{--ink:#12273b;--muted:#596c7a;--teal:#087d70;--line:#d9e3e8;--surface:#f3f7f9;--paper:#fff}*{box-sizing:border-box}html{scroll-behavior:smooth;scroll-padding-top:88px}body{margin:0;background:var(--paper);color:var(--ink);font-family:Inter,"Microsoft YaHei",system-ui,sans-serif;line-height:1.8}a{color:var(--teal);text-decoration:none}a:hover{text-decoration:underline}.topbar{position:sticky;top:0;z-index:20;min-height:72px;display:flex;justify-content:space-between;gap:20px;align-items:center;padding:15px max(24px,calc((100vw - 1180px)/2));border-bottom:1px solid var(--line);background:rgba(255,255,255,.97)}.brand{font-weight:750;letter-spacing:-.5px;color:var(--ink);white-space:nowrap}.brand span{color:var(--teal)}nav{display:flex;gap:24px;font-size:14px}nav a{color:var(--muted)}.landing{max-width:1180px;margin:auto;padding:0 28px}.hero{padding:75px 0 58px;max-width:900px}.eyebrow{font-size:12px;letter-spacing:1.5px;font-weight:700;color:var(--teal)}h1{font-size:clamp(36px,5.2vw,64px);line-height:1.24;letter-spacing:-1.8px;margin:22px 0}h2{font-size:26px;line-height:1.45;letter-spacing:-.5px}h3{font-size:19px;line-height:1.5}.lead{font-size:19px;max-width:740px;color:var(--muted)}.actions{display:flex;gap:12px;flex-wrap:wrap;margin:30px 0 17px}.button{display:inline-block;padding:11px 22px;border:1px solid var(--line);border-radius:7px;font-weight:600;color:var(--ink);font-size:14px}.primary{background:var(--teal);border-color:var(--teal);color:#fff}.button:hover{text-decoration:none;filter:brightness(.95)}.note{font-size:13px;color:var(--muted)}.pipeline{display:grid;grid-template-columns:repeat(4,1fr);border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:23px 0;gap:20px}.pipeline div{display:grid;grid-template-columns:36px 1fr;column-gap:12px}.pipeline b{grid-row:span 2;color:var(--teal);font-size:14px}.pipeline strong{font-size:17px}.pipeline span{font-size:12px;color:var(--muted)}.intro,.features{padding:48px 0 20px}.intro>p,.generator>p{color:var(--muted)}.guide-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-top:24px}.guide{border:1px solid var(--line);border-radius:9px;padding:23px;color:var(--ink);transition:background .15s}.guide:hover{background:var(--surface);text-decoration:none}.guide small{color:var(--teal);font-size:12px}.guide h3{margin:8px 0}.guide p{font-size:14px;color:var(--muted);margin:8px 0 20px}.guide span{font-size:13px;font-weight:600;color:var(--teal)}.generator{margin-top:38px;padding:30px;background:var(--surface);border:1px solid var(--line);border-radius:10px}.section-heading{display:flex;justify-content:space-between;gap:16px;align-items:center}.section-heading h2{margin:5px 0}.section-heading .eyebrow{margin:0}.tag{font-size:11px;border:1px solid #bbd9d2;border-radius:40px;padding:4px 11px;color:var(--teal)}.form-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin:24px 0}.form-grid label{display:flex;flex-direction:column;font-size:13px;gap:7px;font-weight:600}input,select,button{font:inherit}input[type=text],input[type=number],select{width:100%;border:1px solid #bdcdd6;background:white;padding:10px;border-radius:5px;color:var(--ink);font-size:14px}input:focus,select:focus,button:focus-visible,a:focus-visible{outline:2px solid var(--teal);outline-offset:3px}.buckets{padding:14px 0;border:0;display:flex;flex-wrap:wrap;gap:12px}.buckets legend{font-size:13px;font-weight:600}.buckets label{padding:6px 10px;border:1px solid var(--line);border-radius:5px;background:white;font-size:13px;cursor:pointer}.buckets input{accent-color:var(--teal);margin-right:6px}.buckets button{border:0;color:var(--teal);background:transparent;cursor:pointer;font-size:12px;padding:5px}pre{background:#142a3b;color:#e5edf2;border-radius:7px;padding:22px 24px;white-space:pre-wrap;overflow-wrap:anywhere;overflow-x:auto;position:relative;font-size:13px;line-height:1.75}pre code{background:transparent;color:inherit;padding:0}code{font-family:"Cascadia Code",Consolas,monospace;font-size:.88em;background:#eaf0f3;padding:2px 5px;border-radius:4px}.copy-code{position:absolute;right:7px;top:6px;cursor:pointer;color:#d6eee8;background:#294759;border:1px solid #537180;border-radius:4px;font-size:11px;padding:3px 8px}.error{color:#aa302b;font-size:13px}.error:empty{display:none}details{margin-top:20px;border-top:1px solid var(--line);padding-top:16px}summary{cursor:pointer;font-size:14px;font-weight:600}details .form-grid{grid-template-columns:repeat(2,minmax(120px,200px))}#ratio-result{font-size:14px;font-weight:600;color:var(--teal)}.feature-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:20px}.feature-grid article{border-top:2px solid var(--line);padding:12px 0}.feature-grid p{color:var(--muted);font-size:14px}.callout{border-left:3px solid var(--teal);padding:5px 26px;margin:30px 0 64px;background:var(--surface)}.callout h2{font-size:22px;padding-top:18px}.callout p{font-size:15px;max-width:900px}.callout a{display:inline-block;font-size:14px;margin-bottom:20px}footer{max-width:1180px;margin:auto;padding:24px 28px;border-top:1px solid var(--line);color:var(--muted);font-size:12px;display:flex;justify-content:space-between;gap:20px}.manual-layout{display:grid;grid-template-columns:240px minmax(0,1fr);gap:48px;max-width:1230px;margin:0 auto;padding:38px 28px 65px}.sidebar{position:sticky;top:108px;align-self:start;font-size:13px;max-height:calc(100vh - 135px);overflow:auto}.sidebar>a{display:block;margin-bottom:10px;padding:7px 11px;border-left:2px solid transparent;color:var(--muted)}.sidebar>a.active{color:var(--teal);border-color:var(--teal);background:var(--surface)}.sidebar h2{font-size:13px;color:var(--muted);margin:25px 0 10px}.toc ul{list-style:none;padding-left:0}.toc ul ul{padding-left:13px}.toc a{color:var(--muted);font-size:12px;display:block;line-height:1.6;margin-bottom:9px}.manual{min-width:0;font-size:15px}.manual h1{font-size:34px;margin:8px 0 27px;letter-spacing:-1px}.manual h2{margin:45px 0 18px;padding-top:12px;border-top:1px solid var(--line);font-size:24px}.manual h3{font-size:18px;margin-top:28px}.manual li{margin-bottom:5px}.manual pre{font-size:12px}.table-scroll{overflow:auto;margin:22px 0}.manual table{border-collapse:collapse;width:100%;font-size:13px}.manual td,.manual th{padding:12px;text-align:left;vertical-align:top;border:1px solid var(--line)}.manual th{background:var(--surface);font-weight:600}.manual blockquote{padding:5px 20px;border-left:3px solid var(--teal);margin:20px 0;color:var(--muted)}@media(max-width:900px){.guide-grid,.feature-grid{grid-template-columns:repeat(2,1fr)}.form-grid{grid-template-columns:repeat(2,1fr)}.tag{display:none}.topbar{padding:15px 20px}nav{gap:14px}.manual-layout{grid-template-columns:185px minmax(0,1fr);gap:24px}}@media(max-width:620px){.topbar{position:relative;flex-direction:column;align-items:flex-start;gap:6px}.brand{font-size:16px}nav{font-size:12px;flex-wrap:wrap}.landing{padding:0 20px}.hero{padding:40px 0 35px}h1{font-size:40px}.lead{font-size:16px}.pipeline{grid-template-columns:repeat(2,1fr);gap:20px}.guide-grid,.feature-grid{grid-template-columns:1fr}.generator{padding:22px 16px}.form-grid{gap:12px}.form-grid label{font-size:12px}.manual-layout{display:block;padding:22px 16px}.sidebar{position:relative;top:0;max-height:none;border-bottom:1px solid var(--line);margin-bottom:28px}.sidebar .toc,.sidebar h2{display:none}.sidebar>a{display:inline-block;font-size:12px;margin:3px;padding:4px 8px}.manual h1{font-size:29px}.manual h2{font-size:22px}footer{flex-direction:column;gap:4px}.section-heading h2{font-size:23px}}
|