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.
Files changed (65) hide show
  1. package/.claude/commands/adjust-length.md +21 -0
  2. package/.claude/commands/check-visual.md +27 -0
  3. package/.claude/commands/fix-layout.md +31 -0
  4. package/.claude/commands/migrate-template.md +23 -0
  5. package/.claude/commands/repair-table.md +21 -0
  6. package/.claude/commands/show-status.md +32 -0
  7. package/.claude-plugin/README.md +77 -0
  8. package/.claude-plugin/marketplace.json +41 -0
  9. package/.claude-plugin/plugin.json +39 -0
  10. package/CLAUDE.md +266 -0
  11. package/CONTRIBUTING.md +131 -0
  12. package/LICENSE +21 -0
  13. package/README.md +164 -0
  14. package/agents/code-surgeon-agent.md +214 -0
  15. package/agents/layout-detective-agent.md +229 -0
  16. package/agents/orchestrator-agent.md +254 -0
  17. package/agents/quality-gatekeeper-agent.md +270 -0
  18. package/agents/rule-engine-agent.md +224 -0
  19. package/agents/semantic-polish-agent.md +250 -0
  20. package/bin/paperfit.js +176 -0
  21. package/config/agent_roles.yaml +56 -0
  22. package/config/layout_rules.yaml +54 -0
  23. package/config/templates.yaml +241 -0
  24. package/config/vto_taxonomy.yaml +489 -0
  25. package/config/writing_rules.yaml +64 -0
  26. package/install.sh +30 -0
  27. package/package.json +52 -0
  28. package/requirements.txt +5 -0
  29. package/scripts/benchmark_runner.py +629 -0
  30. package/scripts/compile.sh +244 -0
  31. package/scripts/config_validator.py +339 -0
  32. package/scripts/cv_detector.py +600 -0
  33. package/scripts/evidence_collector.py +167 -0
  34. package/scripts/float_fixers.py +861 -0
  35. package/scripts/inject_defects.py +549 -0
  36. package/scripts/install-claude-global.js +148 -0
  37. package/scripts/install.js +66 -0
  38. package/scripts/install.sh +106 -0
  39. package/scripts/overflow_fixers.py +656 -0
  40. package/scripts/package-for-opensource.sh +138 -0
  41. package/scripts/parse_log.py +260 -0
  42. package/scripts/postinstall.js +38 -0
  43. package/scripts/pre_tool_use.py +265 -0
  44. package/scripts/render_pages.py +244 -0
  45. package/scripts/session_logger.py +329 -0
  46. package/scripts/space_util_fixers.py +773 -0
  47. package/scripts/state_manager.py +352 -0
  48. package/scripts/test_commands.py +187 -0
  49. package/scripts/test_cv_detector.py +214 -0
  50. package/scripts/test_integration.py +290 -0
  51. package/skills/consistency-polisher/SKILL.md +337 -0
  52. package/skills/float-optimizer/SKILL.md +284 -0
  53. package/skills/latex_fixers/__init__.py +82 -0
  54. package/skills/latex_fixers/float_fixers.py +392 -0
  55. package/skills/latex_fixers/fullwidth_fixers.py +375 -0
  56. package/skills/latex_fixers/overflow_fixers.py +250 -0
  57. package/skills/latex_fixers/semantic_micro_tuning.py +362 -0
  58. package/skills/latex_fixers/space_util_fixers.py +389 -0
  59. package/skills/latex_fixers/utils.py +55 -0
  60. package/skills/overflow-repair/SKILL.md +304 -0
  61. package/skills/space-util-fixer/SKILL.md +307 -0
  62. package/skills/taxonomy-vto/SKILL.md +486 -0
  63. package/skills/template-migrator/SKILL.md +251 -0
  64. package/skills/visual-inspector/SKILL.md +217 -0
  65. 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()