opencode/dimension_qc_checker.py

350 lines
14 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Quality Control проверка простановки размеров на чертежах.
Ищет нарушения ЕСКД / ГОСТ 2.307 по координатам OCR + геометрии:
- Пересечение размерных линий
- Уплотнение (размеры слишком близко)
- Наложение размеров на текст/штриховку
- Низкая читаемость (low confidence)
- Размер внутри контура объекта
- Пропуски цепочек размеров
Использование:
python dimension_qc_checker.py <output_folder>
Результат: <folder>/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()