paperfit-cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/adjust-length.md +21 -0
- package/.claude/commands/check-visual.md +27 -0
- package/.claude/commands/fix-layout.md +31 -0
- package/.claude/commands/migrate-template.md +23 -0
- package/.claude/commands/repair-table.md +21 -0
- package/.claude/commands/show-status.md +32 -0
- package/.claude-plugin/README.md +77 -0
- package/.claude-plugin/marketplace.json +41 -0
- package/.claude-plugin/plugin.json +39 -0
- package/CLAUDE.md +266 -0
- package/CONTRIBUTING.md +131 -0
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/agents/code-surgeon-agent.md +214 -0
- package/agents/layout-detective-agent.md +229 -0
- package/agents/orchestrator-agent.md +254 -0
- package/agents/quality-gatekeeper-agent.md +270 -0
- package/agents/rule-engine-agent.md +224 -0
- package/agents/semantic-polish-agent.md +250 -0
- package/bin/paperfit.js +176 -0
- package/config/agent_roles.yaml +56 -0
- package/config/layout_rules.yaml +54 -0
- package/config/templates.yaml +241 -0
- package/config/vto_taxonomy.yaml +489 -0
- package/config/writing_rules.yaml +64 -0
- package/install.sh +30 -0
- package/package.json +52 -0
- package/requirements.txt +5 -0
- package/scripts/benchmark_runner.py +629 -0
- package/scripts/compile.sh +244 -0
- package/scripts/config_validator.py +339 -0
- package/scripts/cv_detector.py +600 -0
- package/scripts/evidence_collector.py +167 -0
- package/scripts/float_fixers.py +861 -0
- package/scripts/inject_defects.py +549 -0
- package/scripts/install-claude-global.js +148 -0
- package/scripts/install.js +66 -0
- package/scripts/install.sh +106 -0
- package/scripts/overflow_fixers.py +656 -0
- package/scripts/package-for-opensource.sh +138 -0
- package/scripts/parse_log.py +260 -0
- package/scripts/postinstall.js +38 -0
- package/scripts/pre_tool_use.py +265 -0
- package/scripts/render_pages.py +244 -0
- package/scripts/session_logger.py +329 -0
- package/scripts/space_util_fixers.py +773 -0
- package/scripts/state_manager.py +352 -0
- package/scripts/test_commands.py +187 -0
- package/scripts/test_cv_detector.py +214 -0
- package/scripts/test_integration.py +290 -0
- package/skills/consistency-polisher/SKILL.md +337 -0
- package/skills/float-optimizer/SKILL.md +284 -0
- package/skills/latex_fixers/__init__.py +82 -0
- package/skills/latex_fixers/float_fixers.py +392 -0
- package/skills/latex_fixers/fullwidth_fixers.py +375 -0
- package/skills/latex_fixers/overflow_fixers.py +250 -0
- package/skills/latex_fixers/semantic_micro_tuning.py +362 -0
- package/skills/latex_fixers/space_util_fixers.py +389 -0
- package/skills/latex_fixers/utils.py +55 -0
- package/skills/overflow-repair/SKILL.md +304 -0
- package/skills/space-util-fixer/SKILL.md +307 -0
- package/skills/taxonomy-vto/SKILL.md +486 -0
- package/skills/template-migrator/SKILL.md +251 -0
- package/skills/visual-inspector/SKILL.md +217 -0
- package/skills/writing-polish/SKILL.md +289 -0
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
CV-based Visual Defect Detector
|
|
4
|
+
|
|
5
|
+
基于 OpenCV 的自动化视觉缺陷检测,辅助 layout-detective-agent 进行视觉验收。
|
|
6
|
+
|
|
7
|
+
功能:
|
|
8
|
+
1. 留白检测 - 自动计算页面留白比例
|
|
9
|
+
2. 溢出检测 - 检测内容是否溢出页面边界
|
|
10
|
+
3. 对齐检测 - 检测双栏高度差、表格对齐等
|
|
11
|
+
4. 浮动体检测 - 识别图表位置与引用距离
|
|
12
|
+
|
|
13
|
+
依赖:
|
|
14
|
+
- opencv-python
|
|
15
|
+
- numpy
|
|
16
|
+
|
|
17
|
+
用法:
|
|
18
|
+
python cv_detector.py <page_image> [--defect-type TYPE] [--threshold VALUE]
|
|
19
|
+
|
|
20
|
+
示例:
|
|
21
|
+
python cv_detector.py data/pages/page_001.png --defect-type whitespace
|
|
22
|
+
python cv_detector.py data/pages/page_004.png --defect-type overflow
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import sys
|
|
26
|
+
import json
|
|
27
|
+
import argparse
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Dict, List, Optional, Tuple, Any
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
import cv2
|
|
34
|
+
import numpy as np
|
|
35
|
+
except ImportError as e:
|
|
36
|
+
print(f"Error: Missing required library. {e}")
|
|
37
|
+
print("Install with: pip install opencv-python numpy")
|
|
38
|
+
sys.exit(1)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ============================================================
|
|
42
|
+
# 检测结果定义
|
|
43
|
+
# ============================================================
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class DefectDetection:
|
|
47
|
+
"""缺陷检测结果"""
|
|
48
|
+
defect_id: str
|
|
49
|
+
category: str # A/B/C/D/E
|
|
50
|
+
severity: str # minor/major/critical
|
|
51
|
+
page: int
|
|
52
|
+
confidence: float
|
|
53
|
+
bbox: Optional[Tuple[int, int, int, int]] = None # (x1, y1, x2, y2)
|
|
54
|
+
description: str = ""
|
|
55
|
+
metrics: Dict[str, Any] = field(default_factory=dict)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ============================================================
|
|
59
|
+
# 核心检测器
|
|
60
|
+
# ============================================================
|
|
61
|
+
|
|
62
|
+
class CVDefectDetector:
|
|
63
|
+
"""基于 OpenCV 的缺陷检测器"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, image_path: str, page_number: int = 0):
|
|
66
|
+
self.image_path = Path(image_path)
|
|
67
|
+
self.page_number = page_number
|
|
68
|
+
self.image: Optional[np.ndarray] = None
|
|
69
|
+
self.gray: Optional[np.ndarray] = None
|
|
70
|
+
self.detections: List[DefectDetection] = []
|
|
71
|
+
|
|
72
|
+
# 页面尺寸(英寸)- A4 默认
|
|
73
|
+
self.page_width_inch = 8.27
|
|
74
|
+
self.page_height_inch = 11.69
|
|
75
|
+
self.dpi = 220 # 与 render_pages.py 保持一致
|
|
76
|
+
|
|
77
|
+
def load_image(self) -> bool:
|
|
78
|
+
"""加载并预处理图像"""
|
|
79
|
+
if not self.image_path.exists():
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
self.image = cv2.imread(str(self.image_path))
|
|
83
|
+
if self.image is None:
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
self.gray = cv2.cvtColor(self.image, cv2.COLOR_BGR2GRAY)
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
def detect_whitespace(self, threshold: float = 0.20) -> List[DefectDetection]:
|
|
90
|
+
"""
|
|
91
|
+
检测页面留白比例
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
threshold: 留白比例阈值,超过则报告
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
检测结果列表
|
|
98
|
+
"""
|
|
99
|
+
if self.gray is None and not self.load_image():
|
|
100
|
+
return []
|
|
101
|
+
|
|
102
|
+
# 计算留白像素比例(灰度值 > 240 视为留白)
|
|
103
|
+
white_pixels = np.sum(self.gray > 240)
|
|
104
|
+
total_pixels = self.gray.size
|
|
105
|
+
whitespace_ratio = white_pixels / total_pixels
|
|
106
|
+
|
|
107
|
+
detection = DefectDetection(
|
|
108
|
+
defect_id="A2-trailing-whitespace",
|
|
109
|
+
category="A",
|
|
110
|
+
severity="minor" if whitespace_ratio < 0.30 else "major",
|
|
111
|
+
page=self.page_number,
|
|
112
|
+
confidence=1.0,
|
|
113
|
+
description=f"页面留白比例:{whitespace_ratio:.2%}",
|
|
114
|
+
metrics={
|
|
115
|
+
"whitespace_ratio": whitespace_ratio,
|
|
116
|
+
"white_pixels": int(white_pixels),
|
|
117
|
+
"total_pixels": int(total_pixels),
|
|
118
|
+
"threshold": threshold,
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if whitespace_ratio > threshold:
|
|
123
|
+
self.detections.append(detection)
|
|
124
|
+
|
|
125
|
+
return [detection] if whitespace_ratio > threshold else []
|
|
126
|
+
|
|
127
|
+
def detect_trailing_whitespace_bottom(
|
|
128
|
+
self,
|
|
129
|
+
threshold: float = 0.15
|
|
130
|
+
) -> List[DefectDetection]:
|
|
131
|
+
"""
|
|
132
|
+
检测页面底部留白(末页留白专用)
|
|
133
|
+
|
|
134
|
+
分析页面底部 30% 区域的留白比例
|
|
135
|
+
"""
|
|
136
|
+
if self.gray is None and not self.load_image():
|
|
137
|
+
return []
|
|
138
|
+
|
|
139
|
+
h, w = self.gray.shape
|
|
140
|
+
bottom_region = self.gray[int(h * 0.7):, :]
|
|
141
|
+
|
|
142
|
+
white_pixels = np.sum(bottom_region > 240)
|
|
143
|
+
total_pixels = bottom_region.size
|
|
144
|
+
bottom_whitespace_ratio = white_pixels / total_pixels
|
|
145
|
+
|
|
146
|
+
detection = DefectDetection(
|
|
147
|
+
defect_id="A2-bottom-whitespace",
|
|
148
|
+
category="A",
|
|
149
|
+
severity="minor",
|
|
150
|
+
page=self.page_number,
|
|
151
|
+
confidence=1.0,
|
|
152
|
+
description=f"页面底部留白比例:{bottom_whitespace_ratio:.2%}",
|
|
153
|
+
metrics={
|
|
154
|
+
"bottom_whitespace_ratio": bottom_whitespace_ratio,
|
|
155
|
+
"region": "bottom_30_percent",
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if bottom_whitespace_ratio > threshold:
|
|
160
|
+
self.detections.append(detection)
|
|
161
|
+
return [detection]
|
|
162
|
+
return []
|
|
163
|
+
|
|
164
|
+
def detect_overflow(
|
|
165
|
+
self,
|
|
166
|
+
margin_bbox: Optional[Tuple[int, int, int, int]] = None
|
|
167
|
+
) -> List[DefectDetection]:
|
|
168
|
+
"""
|
|
169
|
+
检测内容是否溢出页面边界
|
|
170
|
+
|
|
171
|
+
使用边缘检测识别靠近页面边缘的文本/图形
|
|
172
|
+
"""
|
|
173
|
+
if self.image is None and not self.load_image():
|
|
174
|
+
return []
|
|
175
|
+
|
|
176
|
+
h, w, _ = self.image.shape
|
|
177
|
+
|
|
178
|
+
# 默认边距:页面边缘向内 20 像素
|
|
179
|
+
if margin_bbox is None:
|
|
180
|
+
margin = 20
|
|
181
|
+
margin_bbox = (margin, margin, w - margin, h - margin)
|
|
182
|
+
|
|
183
|
+
# 检测超出边界的黑色像素
|
|
184
|
+
# 上边缘
|
|
185
|
+
top_overflow = np.sum(self.image[:margin_bbox[1], :, :] < 50)
|
|
186
|
+
# 下边缘
|
|
187
|
+
bottom_overflow = np.sum(self.image[margin_bbox[3]:, :, :] < 50)
|
|
188
|
+
# 左边缘
|
|
189
|
+
left_overflow = np.sum(self.image[:, :margin_bbox[0], :] < 50)
|
|
190
|
+
# 右边缘
|
|
191
|
+
right_overflow = np.sum(self.image[:, margin_bbox[2]:, :] < 50)
|
|
192
|
+
|
|
193
|
+
detections = []
|
|
194
|
+
|
|
195
|
+
if right_overflow > 100: # 右侧溢出阈值
|
|
196
|
+
detections.append(DefectDetection(
|
|
197
|
+
defect_id="D1-overfull-hbox",
|
|
198
|
+
category="D",
|
|
199
|
+
severity="major",
|
|
200
|
+
page=self.page_number,
|
|
201
|
+
confidence=0.8,
|
|
202
|
+
bbox=(margin_bbox[2], 0, w, h),
|
|
203
|
+
description=f"右侧溢出检测:{right_overflow} 像素",
|
|
204
|
+
metrics={"right_overflow_pixels": int(right_overflow)}
|
|
205
|
+
))
|
|
206
|
+
|
|
207
|
+
if bottom_overflow > 100:
|
|
208
|
+
detections.append(DefectDetection(
|
|
209
|
+
defect_id="D1-bottom-overflow",
|
|
210
|
+
category="D",
|
|
211
|
+
severity="major",
|
|
212
|
+
page=self.page_number,
|
|
213
|
+
confidence=0.8,
|
|
214
|
+
bbox=(0, margin_bbox[3], w, h),
|
|
215
|
+
description=f"底部溢出检测:{bottom_overflow} 像素",
|
|
216
|
+
metrics={"bottom_overflow_pixels": int(bottom_overflow)}
|
|
217
|
+
))
|
|
218
|
+
|
|
219
|
+
self.detections.extend(detections)
|
|
220
|
+
return detections
|
|
221
|
+
|
|
222
|
+
def detect_double_column_imbalance(
|
|
223
|
+
self,
|
|
224
|
+
column_boundary: Optional[int] = None
|
|
225
|
+
) -> List[DefectDetection]:
|
|
226
|
+
"""
|
|
227
|
+
检测双栏页面高度不平衡
|
|
228
|
+
|
|
229
|
+
计算左右两栏的内容高度差
|
|
230
|
+
"""
|
|
231
|
+
if self.gray is None and not self.load_image():
|
|
232
|
+
return []
|
|
233
|
+
|
|
234
|
+
h, w = self.gray.shape
|
|
235
|
+
|
|
236
|
+
# 默认中线位置
|
|
237
|
+
if column_boundary is None:
|
|
238
|
+
column_boundary = w // 2
|
|
239
|
+
|
|
240
|
+
# 二值化,识别内容区域
|
|
241
|
+
_, binary = cv2.threshold(self.gray, 200, 255, cv2.THRESH_BINARY_INV)
|
|
242
|
+
|
|
243
|
+
# 左栏内容
|
|
244
|
+
left_col = binary[:, :column_boundary]
|
|
245
|
+
# 右栏内容
|
|
246
|
+
right_col = binary[:, column_boundary:]
|
|
247
|
+
|
|
248
|
+
# 计算每栏的内容高度(有内容的行数)
|
|
249
|
+
left_content_rows = np.any(left_col > 0, axis=1)
|
|
250
|
+
right_content_rows = np.any(right_col > 0, axis=1)
|
|
251
|
+
|
|
252
|
+
left_height = np.sum(left_content_rows)
|
|
253
|
+
right_height = np.sum(right_content_rows)
|
|
254
|
+
|
|
255
|
+
# 计算高度差比例
|
|
256
|
+
max_height = max(left_height, right_height)
|
|
257
|
+
if max_height > 0:
|
|
258
|
+
height_diff_ratio = abs(left_height - right_height) / max_height
|
|
259
|
+
else:
|
|
260
|
+
height_diff_ratio = 0
|
|
261
|
+
|
|
262
|
+
detection = DefectDetection(
|
|
263
|
+
defect_id="A4-column-imbalance",
|
|
264
|
+
category="A",
|
|
265
|
+
severity="minor" if height_diff_ratio < 0.15 else "major",
|
|
266
|
+
page=self.page_number,
|
|
267
|
+
confidence=0.9,
|
|
268
|
+
description=f"双栏高度差:{height_diff_ratio:.2%} (左={left_height}px, 右={right_height}px)",
|
|
269
|
+
metrics={
|
|
270
|
+
"left_column_height": int(left_height),
|
|
271
|
+
"right_column_height": int(right_height),
|
|
272
|
+
"height_diff_ratio": height_diff_ratio,
|
|
273
|
+
}
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
if height_diff_ratio > 0.10: # 10% 阈值
|
|
277
|
+
self.detections.append(detection)
|
|
278
|
+
return [detection]
|
|
279
|
+
return []
|
|
280
|
+
|
|
281
|
+
def detect_float_clustering(
|
|
282
|
+
self,
|
|
283
|
+
min_distance: int = 100
|
|
284
|
+
) -> List[DefectDetection]:
|
|
285
|
+
"""
|
|
286
|
+
检测浮动体(图/表)堆叠
|
|
287
|
+
|
|
288
|
+
识别连续出现且间距过小的浮动体
|
|
289
|
+
|
|
290
|
+
使用内容密度分析:浮动体通常是矩形块,内容密度均匀且高于周围空白
|
|
291
|
+
"""
|
|
292
|
+
if self.gray is None and not self.load_image():
|
|
293
|
+
return []
|
|
294
|
+
|
|
295
|
+
h, w = self.gray.shape
|
|
296
|
+
|
|
297
|
+
# 二值化:内容区域为白色,空白为黑色
|
|
298
|
+
_, binary = cv2.threshold(self.gray, 240, 255, cv2.THRESH_BINARY_INV)
|
|
299
|
+
|
|
300
|
+
# 形态学闭运算:连接浮动体内部的细小间隙
|
|
301
|
+
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 15))
|
|
302
|
+
closed = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)
|
|
303
|
+
|
|
304
|
+
# 查找轮廓
|
|
305
|
+
contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
|
306
|
+
|
|
307
|
+
# 筛选可能是图/表的轮廓
|
|
308
|
+
# 特征:宽度较大(接近栏宽),高度适中,位置居中
|
|
309
|
+
float_bboxes = []
|
|
310
|
+
page_width = w
|
|
311
|
+
min_float_width = page_width * 0.3 # 浮动体至少占页面宽度的 30%
|
|
312
|
+
|
|
313
|
+
for cnt in contours:
|
|
314
|
+
x, y, cw, ch = cv2.boundingRect(cnt)
|
|
315
|
+
# 过滤条件:
|
|
316
|
+
# 1. 宽度足够大
|
|
317
|
+
# 2. 高度足够大(区别于单行文本)
|
|
318
|
+
# 3. 不是整页内容
|
|
319
|
+
if (cw > min_float_width and ch > 150 and ch < h * 0.8):
|
|
320
|
+
# 进一步验证:检查内容密度(浮动体通常密度均匀)
|
|
321
|
+
roi = closed[y:y+ch, x:x+cw]
|
|
322
|
+
density = np.sum(roi > 0) / (cw * ch)
|
|
323
|
+
if density > 0.1: # 内容密度阈值
|
|
324
|
+
float_bboxes.append((x, y, cw, ch))
|
|
325
|
+
|
|
326
|
+
# 按 Y 坐标排序
|
|
327
|
+
float_bboxes.sort(key=lambda b: b[1])
|
|
328
|
+
|
|
329
|
+
# 检测堆叠(垂直距离过小)
|
|
330
|
+
clustered_detections = []
|
|
331
|
+
for i in range(len(float_bboxes) - 1):
|
|
332
|
+
curr_bbox = float_bboxes[i]
|
|
333
|
+
next_bbox = float_bboxes[i + 1]
|
|
334
|
+
|
|
335
|
+
# 计算垂直间距
|
|
336
|
+
curr_bottom = curr_bbox[1] + curr_bbox[3]
|
|
337
|
+
next_top = next_bbox[1]
|
|
338
|
+
vertical_gap = next_top - curr_bottom
|
|
339
|
+
|
|
340
|
+
# 检查 X 方向是否有重叠(确保是同一栏的浮动体)
|
|
341
|
+
x_overlap = (
|
|
342
|
+
max(curr_bbox[0], next_bbox[0]) <
|
|
343
|
+
min(curr_bbox[0] + curr_bbox[2], next_bbox[0] + next_bbox[2])
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
if vertical_gap < min_distance and x_overlap:
|
|
347
|
+
clustered_detections.append(DefectDetection(
|
|
348
|
+
defect_id="B3-float-clustering",
|
|
349
|
+
category="B",
|
|
350
|
+
severity="major",
|
|
351
|
+
page=self.page_number,
|
|
352
|
+
confidence=0.85,
|
|
353
|
+
bbox=(curr_bbox[0], curr_bbox[1],
|
|
354
|
+
next_bbox[0] + next_bbox[2], next_bbox[1] + next_bbox[3]),
|
|
355
|
+
description=f"浮动体堆叠:垂直间距 {vertical_gap}px < {min_distance}px",
|
|
356
|
+
metrics={
|
|
357
|
+
"vertical_gap": int(vertical_gap),
|
|
358
|
+
"float_count": 2,
|
|
359
|
+
}
|
|
360
|
+
))
|
|
361
|
+
|
|
362
|
+
self.detections.extend(clustered_detections)
|
|
363
|
+
return clustered_detections
|
|
364
|
+
|
|
365
|
+
def detect_lone_short_line(
|
|
366
|
+
self,
|
|
367
|
+
line_threshold: int = 50
|
|
368
|
+
) -> List[DefectDetection]:
|
|
369
|
+
"""
|
|
370
|
+
检测孤行寡行(段落末尾短行)
|
|
371
|
+
|
|
372
|
+
分析文本行长度,识别异常短的最后行
|
|
373
|
+
"""
|
|
374
|
+
if self.gray is None and not self.load_image():
|
|
375
|
+
return []
|
|
376
|
+
|
|
377
|
+
# 二值化
|
|
378
|
+
_, binary = cv2.threshold(self.gray, 200, 255, cv2.THRESH_BINARY_INV)
|
|
379
|
+
|
|
380
|
+
# 水平投影,识别文本行
|
|
381
|
+
h_proj = np.sum(binary > 0, axis=1)
|
|
382
|
+
|
|
383
|
+
# 识别文本行区间
|
|
384
|
+
lines = []
|
|
385
|
+
in_line = False
|
|
386
|
+
line_start = 0
|
|
387
|
+
|
|
388
|
+
for i, proj in enumerate(h_proj):
|
|
389
|
+
if proj > 10 and not in_line:
|
|
390
|
+
in_line = True
|
|
391
|
+
line_start = i
|
|
392
|
+
elif proj <= 10 and in_line:
|
|
393
|
+
in_line = False
|
|
394
|
+
lines.append((line_start, i))
|
|
395
|
+
|
|
396
|
+
# 分析每行宽度(简化:使用投影和作为代理)
|
|
397
|
+
short_lines = []
|
|
398
|
+
for i, (start, end) in enumerate(lines):
|
|
399
|
+
row = binary[start:end, :]
|
|
400
|
+
line_width = np.sum(np.any(row > 0, axis=0))
|
|
401
|
+
page_width = binary.shape[1]
|
|
402
|
+
|
|
403
|
+
if line_width < page_width * 0.3: # 小于页面宽度 30%
|
|
404
|
+
short_lines.append((i, line_width, page_width))
|
|
405
|
+
|
|
406
|
+
# 如果段落末尾有短行,报告孤行
|
|
407
|
+
if short_lines:
|
|
408
|
+
# 检查是否连续出现(孤行特征)
|
|
409
|
+
for idx, width, page_w in short_lines[-2:]: # 检查最后两行
|
|
410
|
+
if width < page_w * 0.3:
|
|
411
|
+
detection = DefectDetection(
|
|
412
|
+
defect_id="A1-widow-orphan",
|
|
413
|
+
category="A",
|
|
414
|
+
severity="major",
|
|
415
|
+
page=self.page_number,
|
|
416
|
+
confidence=0.75,
|
|
417
|
+
description=f"检测到短行:宽度 {width:.0f}px / 页面 {page_w:.0f}px = {width/page_w:.0%}",
|
|
418
|
+
metrics={
|
|
419
|
+
"line_width": int(width),
|
|
420
|
+
"page_width": int(page_w),
|
|
421
|
+
"ratio": width / page_w,
|
|
422
|
+
}
|
|
423
|
+
)
|
|
424
|
+
self.detections.append(detection)
|
|
425
|
+
return [detection]
|
|
426
|
+
|
|
427
|
+
return []
|
|
428
|
+
|
|
429
|
+
def run_all_detections(self) -> List[DefectDetection]:
|
|
430
|
+
"""运行所有检测"""
|
|
431
|
+
if not self.load_image():
|
|
432
|
+
return []
|
|
433
|
+
|
|
434
|
+
self.detections = []
|
|
435
|
+
|
|
436
|
+
# 执行所有检测
|
|
437
|
+
self.detect_whitespace()
|
|
438
|
+
self.detect_trailing_whitespace_bottom()
|
|
439
|
+
self.detect_overflow()
|
|
440
|
+
self.detect_float_clustering()
|
|
441
|
+
self.detect_lone_short_line()
|
|
442
|
+
self.detect_double_column_imbalance()
|
|
443
|
+
|
|
444
|
+
return self.detections
|
|
445
|
+
|
|
446
|
+
def to_report(self) -> Dict[str, Any]:
|
|
447
|
+
"""生成检测报告"""
|
|
448
|
+
return {
|
|
449
|
+
"status": "success" if self.detections else "clean",
|
|
450
|
+
"image_path": str(self.image_path),
|
|
451
|
+
"page_number": self.page_number,
|
|
452
|
+
"detection_count": len(self.detections),
|
|
453
|
+
"detections": [
|
|
454
|
+
{
|
|
455
|
+
"defect_id": d.defect_id,
|
|
456
|
+
"category": d.category,
|
|
457
|
+
"severity": d.severity,
|
|
458
|
+
"page": d.page,
|
|
459
|
+
"confidence": d.confidence,
|
|
460
|
+
"description": d.description,
|
|
461
|
+
"metrics": d.metrics,
|
|
462
|
+
"bbox": list(d.bbox) if d.bbox else None,
|
|
463
|
+
}
|
|
464
|
+
for d in self.detections
|
|
465
|
+
],
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
# ============================================================
|
|
470
|
+
# 批量检测器
|
|
471
|
+
# ============================================================
|
|
472
|
+
|
|
473
|
+
class BatchCVDetector:
|
|
474
|
+
"""批量 CV 检测器"""
|
|
475
|
+
|
|
476
|
+
def __init__(self, pages_dir: str):
|
|
477
|
+
self.pages_dir = Path(pages_dir)
|
|
478
|
+
self.results: List[Dict] = []
|
|
479
|
+
|
|
480
|
+
def run_batch(self) -> Dict[str, Any]:
|
|
481
|
+
"""批量检测所有页面"""
|
|
482
|
+
page_files = sorted(self.pages_dir.glob("page_*.png"))
|
|
483
|
+
|
|
484
|
+
for page_file in page_files:
|
|
485
|
+
# 从文件名解析页码
|
|
486
|
+
page_num = int(page_file.stem.split("_")[1])
|
|
487
|
+
|
|
488
|
+
detector = CVDefectDetector(str(page_file), page_number=page_num)
|
|
489
|
+
detector.run_all_detections()
|
|
490
|
+
self.results.append(detector.to_report())
|
|
491
|
+
|
|
492
|
+
# 汇总统计
|
|
493
|
+
total_detections = sum(r["detection_count"] for r in self.results)
|
|
494
|
+
category_counts = {}
|
|
495
|
+
|
|
496
|
+
for r in self.results:
|
|
497
|
+
for d in r["detections"]:
|
|
498
|
+
cat = d["category"]
|
|
499
|
+
category_counts[cat] = category_counts.get(cat, 0) + 1
|
|
500
|
+
|
|
501
|
+
return {
|
|
502
|
+
"status": "completed",
|
|
503
|
+
"pages_analyzed": len(page_files),
|
|
504
|
+
"total_detections": total_detections,
|
|
505
|
+
"category_breakdown": category_counts,
|
|
506
|
+
"page_results": self.results,
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
# ============================================================
|
|
511
|
+
# 命令行接口
|
|
512
|
+
# ============================================================
|
|
513
|
+
|
|
514
|
+
def main():
|
|
515
|
+
parser = argparse.ArgumentParser(
|
|
516
|
+
description="CV-based visual defect detector for LaTeX PDF pages"
|
|
517
|
+
)
|
|
518
|
+
parser.add_argument("page_image", help="Path to page image (PNG)")
|
|
519
|
+
parser.add_argument(
|
|
520
|
+
"--defect-type",
|
|
521
|
+
"-t",
|
|
522
|
+
choices=["whitespace", "overflow", "clustering", "short-line", "column-imbalance", "all"],
|
|
523
|
+
default="all",
|
|
524
|
+
help="Type of defect to detect"
|
|
525
|
+
)
|
|
526
|
+
parser.add_argument(
|
|
527
|
+
"--threshold",
|
|
528
|
+
type=float,
|
|
529
|
+
default=0.20,
|
|
530
|
+
help="Detection threshold"
|
|
531
|
+
)
|
|
532
|
+
parser.add_argument(
|
|
533
|
+
"--batch-dir",
|
|
534
|
+
help="Directory containing page images for batch processing"
|
|
535
|
+
)
|
|
536
|
+
parser.add_argument(
|
|
537
|
+
"--json",
|
|
538
|
+
"-j",
|
|
539
|
+
action="store_true",
|
|
540
|
+
help="Output JSON format only"
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
args = parser.parse_args()
|
|
544
|
+
|
|
545
|
+
# 批量模式
|
|
546
|
+
if args.batch_dir:
|
|
547
|
+
batch_detector = BatchCVDetector(args.batch_dir)
|
|
548
|
+
report = batch_detector.run_batch()
|
|
549
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
550
|
+
return
|
|
551
|
+
|
|
552
|
+
# 单页模式
|
|
553
|
+
detector = CVDefectDetector(args.page_image)
|
|
554
|
+
|
|
555
|
+
if not detector.load_image():
|
|
556
|
+
print(f"Error: Failed to load image: {args.page_image}")
|
|
557
|
+
sys.exit(1)
|
|
558
|
+
|
|
559
|
+
detections = []
|
|
560
|
+
|
|
561
|
+
if args.defect_type == "whitespace":
|
|
562
|
+
detections = detector.detect_whitespace(args.threshold)
|
|
563
|
+
elif args.defect_type == "overflow":
|
|
564
|
+
detections = detector.detect_overflow()
|
|
565
|
+
elif args.defect_type == "clustering":
|
|
566
|
+
detections = detector.detect_float_clustering()
|
|
567
|
+
elif args.defect_type == "short-line":
|
|
568
|
+
detections = detector.detect_lone_short_line()
|
|
569
|
+
elif args.defect_type == "column-imbalance":
|
|
570
|
+
detections = detector.detect_double_column_imbalance()
|
|
571
|
+
else:
|
|
572
|
+
detections = detector.run_all_detections()
|
|
573
|
+
|
|
574
|
+
report = detector.to_report()
|
|
575
|
+
|
|
576
|
+
if args.json:
|
|
577
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
578
|
+
else:
|
|
579
|
+
print(f"\nCV Defect Detection Report")
|
|
580
|
+
print("=" * 50)
|
|
581
|
+
print(f"Image: {args.page_image}")
|
|
582
|
+
print(f"Page: {detector.page_number}")
|
|
583
|
+
print(f"Detections: {len(detections)}")
|
|
584
|
+
print()
|
|
585
|
+
|
|
586
|
+
if detections:
|
|
587
|
+
for d in detections:
|
|
588
|
+
print(f" [{d.category}] {d.defect_id}")
|
|
589
|
+
print(f" Severity: {d.severity}")
|
|
590
|
+
print(f" Confidence: {d.confidence:.0%}")
|
|
591
|
+
print(f" {d.description}")
|
|
592
|
+
print()
|
|
593
|
+
else:
|
|
594
|
+
print(" No defects detected.")
|
|
595
|
+
|
|
596
|
+
sys.exit(0 if report["status"] == "clean" else 1)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
if __name__ == "__main__":
|
|
600
|
+
main()
|