#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Quality Control проверка простановки размеров на чертежах. Ищет нарушения ЕСКД / ГОСТ 2.307 по координатам OCR + геометрии: - Пересечение размерных линий - Уплотнение (размеры слишком близко) - Наложение размеров на текст/штриховку - Низкая читаемость (low confidence) - Размер внутри контура объекта - Пропуски цепочек размеров Использование: python dimension_qc_checker.py Результат: /dimension_qc_report.json + текстовый отчёт """ import sys import json import re from pathlib import Path from typing import List, Dict, Tuple from dataclasses import dataclass # ------------------------------------------------------------------ # QC правила # ------------------------------------------------------------------ MIN_CONFIDENCE = 0.65 # ниже — подозрение на нечитаемость MIN_BBOX_OVERLAP = 0.15 # минимальное пересечение bbox для флага MIN_DIMENSION_SPACING = 8 # мин. пикселей между размерами (px @ 300 DPI ~ 0.7 мм) MAX_DIMENSION_CHAIN_GAP = 50 # макс. зазор между размерами в одной цепочке @dataclass class TextItem: text: str confidence: float bbox: list # [x1, y1, x2, y2] page: int is_dimension: bool = False @property def x1(self) -> float: return min(p[0] for p in self.bbox) @property def y1(self) -> float: return min(p[1] for p in self.bbox) @property def x2(self) -> float: return max(p[0] for p in self.bbox) @property def y2(self) -> float: return max(p[1] for p in self.bbox) @property def width(self) -> float: return self.x2 - self.x1 @property def height(self) -> float: return self.y2 - self.y1 @property def center_x(self) -> float: return (self.x1 + self.x2) / 2 @property def center_y(self) -> float: return (self.y1 + self.y2) / 2 @property def area(self) -> float: return self.width * self.height def is_dimension_text(text: str) -> bool: """Эвристика: похоже ли на размер.""" text = text.strip().replace(' ', '').replace(',', '.') # Чистые числа 50-50000 if re.match(r'^\d{2,5}(\.\d{1,2})?$', text): num = float(text) return 50 <= num <= 50000 # Числа с единицами if re.match(r'^\d{2,5}(\.\d{1,2})?[мmмм]?[мm]?$', text, re.I): return True return False def bbox_overlap(a: TextItem, b: TextItem) -> float: """IOU (Intersection over Union) двух bbox.""" x1 = max(a.x1, b.x1) y1 = max(a.y1, b.y1) x2 = min(a.x2, b.x2) y2 = min(a.y2, b.y2) if x2 <= x1 or y2 <= y1: return 0.0 inter = (x2 - x1) * (y2 - y1) union = a.area + b.area - inter return inter / union if union > 0 else 0.0 def bbox_distance(a: TextItem, b: TextItem) -> float: """Расстояние между центрами bbox.""" import math return math.sqrt((a.center_x - b.center_x)**2 + (a.center_y - b.center_y)**2) def analyze_dimensions(items: List[TextItem]) -> List[Dict]: """Находит проблемы с размерами.""" issues = [] dims = [it for it in items if it.is_dimension] non_dims = [it for it in items if not it.is_dimension] # --- 1. Низкий confidence (подозрение на нечитаемость) --- for d in dims: if d.confidence < MIN_CONFIDENCE: issues.append({ "type": "LOW_CONFIDENCE_DIMENSION", "severity": "warning", "page": d.page, "text": d.text, "confidence": d.confidence, "bbox": d.bbox, "message": f"Размер '{d.text}' имеет низкую уверенность OCR ({d.confidence:.2f}). Возможно, плохо читается на чертеже. Рекомендуется увеличить или перепроставить.", }) # --- 2. Пересечение размеров (наложение) --- for i, d1 in enumerate(dims): for d2 in dims[i+1:]: if d1.page != d2.page: continue overlap = bbox_overlap(d1, d2) if overlap > MIN_BBOX_OVERLAP: issues.append({ "type": "DIMENSION_OVERLAP", "severity": "error", "page": d1.page, "text1": d1.text, "text2": d2.text, "overlap": overlap, "bbox1": d1.bbox, "bbox2": d2.bbox, "message": f"Размеры '{d1.text}' и '{d2.text}' пересекаются ({overlap:.0%} наложения). Нарушение ЕСКД: размерные числа не должны пересекать друг друга.", }) # --- 3. Наложение размеров на другой текст/штриховку --- for d in dims: for nd in non_dims: if d.page != nd.page: continue # Проверяем, находится ли центр размера внутри bbox текста if (nd.x1 < d.center_x < nd.x2 and nd.y1 < d.center_y < nd.y2): issues.append({ "type": "DIMENSION_ON_TEXT", "severity": "error", "page": d.page, "dimension": d.text, "overlapped_text": nd.text, "bbox_dim": d.bbox, "bbox_text": nd.bbox, "message": f"Размер '{d.text}' наложен на текст '{nd.text}'. Нарушение ЕСКД: размерные числа не должны пересекать линии контура или другие надписи.", }) # --- 4. "Уплотнение" размеров (цепочка без отступов) --- # Группируем по страницам и по горизонтальным линиям (похожие Y) from collections import defaultdict page_dims = defaultdict(list) for d in dims: page_dims[d.page].append(d) for page, page_items in page_dims.items(): # Сортируем по Y page_items.sort(key=lambda x: x.center_y) chains = [] current_chain = [page_items[0]] if page_items else [] for d in page_items[1:]: prev = current_chain[-1] # Если Y близко — считаем одной цепочкой if abs(d.center_y - prev.center_y) < 25: # допуск по вертикали current_chain.append(d) else: if len(current_chain) >= 3: chains.append(current_chain) current_chain = [d] if len(current_chain) >= 3: chains.append(current_chain) # Проверяем цепочки на уплотнение for chain in chains: chain.sort(key=lambda x: x.center_x) for i in range(1, len(chain)): gap = chain[i].center_x - chain[i-1].center_x # Если размеры ближе чем ~2× их высота — это уплотнение avg_height = (chain[i].height + chain[i-1].height) / 2 if gap < avg_height * 1.5: issues.append({ "type": "DIMENSION_CROWDING", "severity": "warning", "page": page, "text1": chain[i-1].text, "text2": chain[i].text, "gap_px": gap, "bbox1": chain[i-1].bbox, "bbox2": chain[i].bbox, "message": f"Размеры '{chain[i-1].text}' и '{chain[i].text}' расположены слишком близко (зазор {gap:.0f} px). Возможно 'уплотнение' — размерные линии не отступают друг от друга. Рекомендуется разнести.", }) # --- 5. Пропуски в цепочке размеров (gaps) --- # Если в цепочке размеров есть большой зазор без размера — возможно пропущен for page, page_items in page_dims.items(): page_items.sort(key=lambda x: x.center_x) for i in range(1, len(page_items)): gap = page_items[i].center_x - page_items[i-1].center_x avg_width = (page_items[i].width + page_items[i-1].width) / 2 # Если зазор в 4+ раза больше среднего размера — подозрительно if gap > avg_width * 4: issues.append({ "type": "DIMENSION_CHAIN_GAP", "severity": "info", "page": page, "left": page_items[i-1].text, "right": page_items[i].text, "gap_px": gap, "message": f"Между размерами '{page_items[i-1].text}' и '{page_items[i].text}' большой зазор ({gap:.0f} px). Возможно, пропущен промежуточный размер в цепочке.", }) return issues def validate_folder(folder: Path): ocr_path = folder / "full_ocr_results.json" if not ocr_path.exists(): print(f"[ERR] Не найден {ocr_path}") sys.exit(1) data = json.loads(ocr_path.read_text(encoding="utf-8")) pages = data["pages"] all_items = [] for page in pages: page_num = page["page_number"] for entry in page.get("ocr_lines", []): item = TextItem( text=entry["text"], confidence=entry.get("confidence", 0), bbox=entry.get("bbox", [0,0,0,0]), page=page_num, is_dimension=is_dimension_text(entry["text"]), ) all_items.append(item) dimensions = [it for it in all_items if it.is_dimension] print(f"[INFO] Всего элементов: {len(all_items)}") print(f"[INFO] Размеров найдено: {len(dimensions)}") print(f"[INFO] Проверка...\n") issues = analyze_dimensions(all_items) # Группировка по серьёзности errors = [i for i in issues if i["severity"] == "error"] warnings = [i for i in issues if i["severity"] == "warning"] infos = [i for i in issues if i["severity"] == "info"] print("=" * 70) print("ОШИБКИ (требуют переделки)") print("=" * 70) if errors: for i, iss in enumerate(errors, 1): print(f"\n[{i}] Стр.{iss['page']}: {iss['type']}") print(f" {iss['message']}") else: print("\n✅ Критических ошибок не найдено") print("\n" + "=" * 70) print("ПРЕДУПРЕЖДЕНИЯ (рекомендуется исправить)") print("=" * 70) if warnings: for i, iss in enumerate(warnings[:20], 1): print(f"\n[{i}] Стр.{iss['page']}: {iss['type']}") print(f" {iss['message']}") if len(warnings) > 20: print(f"\n... и ещё {len(warnings) - 20} предупреждений") else: print("\n✅ Предупреждений не найдено") if infos: print(f"\nℹ️ Информационных замечаний: {len(infos)}") # Сохранение JSON report = { "summary": { "total_items": len(all_items), "dimensions_found": len(dimensions), "errors": len(errors), "warnings": len(warnings), "infos": len(infos), }, "errors": errors, "warnings": warnings, "infos": infos, } out_path = folder / "dimension_qc_report.json" with open(out_path, "w", encoding="utf-8") as f: json.dump(report, f, ensure_ascii=False, indent=2) print(f"\n[INFO] Отчёт сохранён: {out_path}") # Сгенерировать markdown-замечания для проектировщика md_lines = ["# Замечания по простановке размеров\n"] md_lines.append(f"**Документ:** {folder.name}\n") md_lines.append(f"**Всего размеров:** {len(dimensions)}\n") md_lines.append(f"**Ошибок:** {len(errors)} | **Предупреждений:** {len(warnings)}\n\n") if errors: md_lines.append("## Ошибки (обязательно к исправлению)\n\n") for i, iss in enumerate(errors, 1): md_lines.append(f"### {i}. {iss['type']} (стр. {iss['page']})\n\n") md_lines.append(f"{iss['message']}\n\n") if 'bbox1' in iss: md_lines.append(f"- Координаты 1: `{iss['bbox1']}`\n") if 'bbox2' in iss: md_lines.append(f"- Координаты 2: `{iss['bbox2']}`\n") md_lines.append("\n") if warnings: md_lines.append("## Предупреждения (рекомендуется исправить)\n\n") for i, iss in enumerate(warnings[:10], 1): md_lines.append(f"### {i}. {iss['type']} (стр. {iss['page']})\n\n") md_lines.append(f"{iss['message']}\n\n") md_path = folder / "dimension_qc_remarks.md" with open(md_path, "w", encoding="utf-8") as f: f.writelines(md_lines) print(f"[INFO] Замечания для проектировщика: {md_path}") def main(): folder = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("output_123") validate_folder(folder) if __name__ == "__main__": main()