HanWangTJU commited on
Commit
cf87e14
·
verified ·
1 Parent(s): 3346286

Publish NMM data preprocessing v0.3.0 with Chinese tutorials

Browse files
.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 Data Preprocess
3
- emoji: 👁
4
  colorFrom: blue
5
- colorTo: indigo
6
  sdk: static
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
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>短边 &gt;=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&gt;=3.10、NumPy&gt;=1.26,&lt;3、Pillow&gt;=10.4,&lt;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 &quot;$PWD/configs:/configs:ro&quot; \
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 &quot;$PWD/configs:/configs:ro&quot; \
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 &quot;.[publish]&quot;
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 &lt;子命令&gt; ...</code>;安装后也可用 <code>image-cleaner &lt;子命令&gt; ...</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> 短边 &gt;=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>&gt;=</code>、上限用 <code>&lt;=</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>整数 &gt;=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">{&quot;deduplicate&quot;:false,&quot;near_deduplicate&quot;:false}
528
+ </code></pre>
529
+ <h3 id="尺寸比例文字与去重">尺寸、比例、文字与去重</h3>
530
+ <pre><code class="language-json">{
531
+ &quot;resolution&quot;: &quot;256p&quot;,
532
+ &quot;aspect_buckets&quot;: [&quot;1:1&quot;,&quot;2:3&quot;,&quot;3:2&quot;,&quot;9:16&quot;,&quot;16:9&quot;],
533
+ &quot;aspect_mode&quot;: &quot;loose&quot;,
534
+ &quot;aspect_tolerance&quot;: 0.05,
535
+ &quot;min_text_chars&quot;: 1,
536
+ &quot;deduplicate&quot;: true,
537
+ &quot;near_deduplicate&quot;: true,
538
+ &quot;duplicate_scope&quot;: &quot;image_text&quot;,
539
+ &quot;export_format&quot;: &quot;png&quot;
540
+ }
541
+ </code></pre>
542
+ <h3 id="原图规范化并限制导出长边">原图规范化并限制导出长边</h3>
543
+ <pre><code class="language-json">{&quot;resolution&quot;:&quot;256p&quot;,&quot;export_max_edge&quot;:1024,&quot;export_format&quot;:&quot;jpeg&quot;,&quot;export_jpeg_quality&quot;:95}
544
+ </code></pre>
545
+ <p><code>generation_example.json</code> 的 512 下限和比例范围是演示配置,不是通用训练推荐。<code>exact_256p.json</code> 是兼容旧文件名,内容实际为短边 &gt;=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">{&quot;id&quot;:&quot;cat-001&quot;,&quot;path&quot;:&quot;animal/cat.jpg&quot;,&quot;text&quot;:&quot;一只猫坐在窗边。&quot;,&quot;metadata&quot;:{&quot;source&quot;:&quot;my_corpus&quot;}}
53
+ {&quot;id&quot;:&quot;road-002&quot;,&quot;path&quot;:&quot;scene/road.png&quot;,&quot;text&quot;:&quot;雨后的城市道路。&quot;}
54
+ </code></pre>
55
+ <pre><code class="language-powershell">.\.venv\Scripts\python.exe -m image_cleaner run --input &quot;D:\datasets\pairs.jsonl&quot; --root &quot;D:\datasets\images&quot; --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"># 短边 &gt;=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 &quot;1:1,3:2&quot; --aspect-mode strict
64
+ </code></pre>
65
+ <p><code>256p</code> 的定义是 <code>min(width,height)&gt;=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
+ &quot;resolution&quot;: &quot;256p&quot;,
71
+ &quot;aspect_buckets&quot;: &quot;all&quot;,
72
+ &quot;aspect_mode&quot;: &quot;loose&quot;,
73
+ &quot;aspect_tolerance&quot;: 0.05,
74
+ &quot;min_text_chars&quot;: 1,
75
+ &quot;deduplicate&quot;: true,
76
+ &quot;near_deduplicate&quot;: true,
77
+ &quot;duplicate_scope&quot;: &quot;image_text&quot;
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">{&quot;id&quot;:&quot;original-001&quot;,&quot;path&quot;:&quot;animals/001.jpg&quot;,&quot;text&quot;:&quot;一只猫坐在窗边。&quot;,&quot;metadata&quot;:{&quot;source&quot;:&quot;dataset-a&quot;}}
67
+ {&quot;id&quot;:&quot;original-002&quot;,&quot;path&quot;:&quot;charts/002.png&quot;,&quot;text&quot;:&quot;一个展示月份销量的柱状图。&quot;}
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 &quot;D:\datasets\pairs.jsonl&quot; --root &quot;D:\datasets\images&quot; --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 &quot;D:\datasets\images&quot; --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 &quot;runs/statistics/curated/report.html&quot;
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) &gt;= 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 &quot;D:\datasets\images&quot; --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 &quot;1:1,2:3,3:2&quot; --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% 容差,同时保留短边 &gt;=256 的图片。
176
+ .\.venv\Scripts\python.exe -m image_cleaner filter --shards runs/statistics/shards --output runs/aspect_3percent_256 --resolution 256p --aspect-buckets &quot;3:4,4:3,9:16,16:9&quot; --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 &quot;D:\datasets\images&quot; --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 &lt;= 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
+ &quot;aspect_buckets&quot;: [&quot;1:1&quot;, &quot;2:3&quot;, &quot;3:2&quot;],
220
+ &quot;aspect_mode&quot;: &quot;loose&quot;,
221
+ &quot;aspect_tolerance&quot;: 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="&gt;="</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&gt;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
+ &quot;resolution&quot;: null,
647
+ &quot;min_short_edge&quot;: null,
648
+ &quot;max_long_edge&quot;: null,
649
+ &quot;min_pixels&quot;: null,
650
+ &quot;max_pixels&quot;: null,
651
+ &quot;min_aspect_ratio&quot;: null,
652
+ &quot;aspect_buckets&quot;: null,
653
+ &quot;aspect_mode&quot;: &quot;strict&quot;,
654
+ &quot;aspect_tolerance&quot;: 0.05,
655
+ &quot;max_aspect_ratio&quot;: null,
656
+ &quot;min_blur_score&quot;: null,
657
+ &quot;max_noise_sigma&quot;: null,
658
+ &quot;min_snr_proxy_db&quot;: null,
659
+ &quot;max_blockiness&quot;: null,
660
+ &quot;min_text_chars&quot;: null,
661
+ &quot;max_text_chars&quot;: null,
662
+ &quot;forbidden_text_patterns&quot;: [],
663
+ &quot;reject_animated&quot;: true,
664
+ &quot;max_decode_pixels&quot;: 100000000,
665
+ &quot;metric_patch_size&quot;: 256,
666
+ &quot;alpha_background&quot;: [255, 255, 255],
667
+ &quot;convert_icc&quot;: true,
668
+ &quot;text_unicode_form&quot;: &quot;NFC&quot;,
669
+ &quot;deduplicate&quot;: true,
670
+ &quot;near_deduplicate&quot;: true,
671
+ &quot;duplicate_scope&quot;: &quot;image_text&quot;,
672
+ &quot;phash_distance&quot;: 4,
673
+ &quot;dhash_distance&quot;: 4,
674
+ &quot;near_rgb_rmse&quot;: 8.0,
675
+ &quot;near_aspect_log_tolerance&quot;: 0.03,
676
+ &quot;export_max_edge&quot;: null,
677
+ &quot;export_format&quot;: &quot;png&quot;,
678
+ &quot;export_jpeg_quality&quot;: 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>(短边&gt;=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 &quot;D:\datasets\images&quot; --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 &quot;D:\node0\images&quot; --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 &quot;E:\node1\images&quot; --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 &quot;D:\node0\images&quot; --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}}