- 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/
350 lines
14 KiB
Python
350 lines
14 KiB
Python
#!/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()
|