opencode/dimension_qc_checker.py
Кирилл Блинов 95093736da Add dimension QC, DZI generator, web viewer, and fix RAG query bug
- dimension_qc_checker.py: rules-based QC for dimension chains, overlaps, crowding
- generate_dzi.py: Deep Zoom Image tile pyramid generator for OpenSeadragon
- generate_web_viewer.py: OpenSeadragon viewer with SVG overlays and issue feedback buttons
- rag_query.py: fix LightRAG remove_think_tags crash on None response from LLM
- .gitignore: add *.pdf, *.db, backend/uploads/, backend/outputs/
2026-06-01 12:30:07 +03:00

350 lines
14 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()