diff --git a/.gitignore b/.gitignore index 6cbaf7e..3a80b08 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,16 @@ __pycache__/ # Secrets .env +# Database +*.db +*.sqlite +*.sqlite3 + +# PDFs and uploads +*.pdf +backend/uploads/ +backend/outputs/ + # OS .DS_Store Thumbs.db diff --git a/dimension_qc_checker.py b/dimension_qc_checker.py new file mode 100644 index 0000000..d49cc55 --- /dev/null +++ b/dimension_qc_checker.py @@ -0,0 +1,349 @@ +#!/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() diff --git a/generate_dzi.py b/generate_dzi.py new file mode 100644 index 0000000..64cb1f1 --- /dev/null +++ b/generate_dzi.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Генератор Deep Zoom Image (DZI) тайлов из PNG для быстрого просмотра больших чертежей. + +Использует PIL — не требует внешних зависимостей. + +Использование: + python generate_dzi.py [--tile-size 256] [--format png] + +Результат: + .dzi — XML-дескриптор + _files/ — папка с тайлами level/col_row.png +""" + +import sys +import os +import math +from pathlib import Path +from PIL import Image + + +def generate_dzi(png_path: Path, tile_size: int = 256, fmt: str = "png"): + """Генерирует DZI тайлы из PNG.""" + png_path = Path(png_path) + if not png_path.exists(): + print(f"[ERR] Файл не найден: {png_path}") + sys.exit(1) + + base = png_path.stem + files_dir = png_path.parent / f"{base}_files" + files_dir.mkdir(exist_ok=True) + + print(f"[INFO] Открываем {png_path}...") + img = Image.open(png_path) + orig_w, orig_h = img.size + print(f"[INFO] Размер: {orig_w}x{orig_h}") + + # DZI uses power-of-2 levels, starting from 1x1 at level 0 + max_dim = max(orig_w, orig_h) + max_level = math.ceil(math.log2(max_dim)) + + print(f"[INFO] Уровней: {max_level + 1} (0..{max_level})") + + # Process from largest to smallest (build pyramid top-down) + current = img.convert("RGBA" if fmt == "png" else "RGB") + + for level in range(max_level, -1, -1): + # Calculate size at this level + scale = 2 ** (max_level - level) + level_w = math.ceil(orig_w / scale) + level_h = math.ceil(orig_h / scale) + + # Resize current image to level size + if current.size != (level_w, level_h): + current = current.resize((level_w, level_h), Image.LANCZOS) + + # Save tiles + cols = math.ceil(level_w / tile_size) + rows = math.ceil(level_h / tile_size) + + level_dir = files_dir / str(level) + level_dir.mkdir(exist_ok=True) + + for row in range(rows): + for col in range(cols): + x = col * tile_size + y = row * tile_size + w = min(tile_size, level_w - x) + h = min(tile_size, level_h - y) + + tile = current.crop((x, y, x + w, y + h)) + tile_path = level_dir / f"{col}_{row}.{fmt}" + + if fmt == "png": + tile.save(tile_path, "PNG", compress_level=3) + else: + tile.save(tile_path, "JPEG", quality=85) + + print(f" Level {level}: {level_w}x{level_h}, {cols}x{rows} tiles") + + # Write DZI descriptor + dzi_path = png_path.parent / f"{base}.dzi" + dzi_xml = f''' + + +''' + dzi_path.write_text(dzi_xml, encoding="utf-8") + + print(f"[OK] DZI создан: {dzi_path}") + print(f" Тайлы: {files_dir}") + print(f" Уровней: {max_level + 1}, TileSize: {tile_size}") + + return dzi_path, files_dir + + +def main(): + if len(sys.argv) < 2: + print("Usage: python generate_dzi.py [tile_size] [format]") + sys.exit(1) + + png = Path(sys.argv[1]) + tile_size = int(sys.argv[2]) if len(sys.argv) > 2 else 256 + fmt = sys.argv[3] if len(sys.argv) > 3 else "png" + + generate_dzi(png, tile_size, fmt) + + +if __name__ == "__main__": + main() diff --git a/generate_web_viewer.py b/generate_web_viewer.py new file mode 100644 index 0000000..d6f5337 --- /dev/null +++ b/generate_web_viewer.py @@ -0,0 +1,857 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Генерация HTML-viewer с OpenSeadragon + DZI для просмотра замечаний QC прямо на чертеже. + +Использование: + python generate_web_viewer.py [--page N] [--dzi] + +Требует предварительного запуска generate_dzi.py для создания тайлов. +""" + +import sys +import json +import base64 +import shutil +from pathlib import Path + + +def generate_html(folder: Path, target_page: int = None, use_dzi: bool = True, + issue_db_ids: list = None, total_pages: int = None, + project_id: int = None, api_base: str = None): + """Генерирует HTML-viewer с OpenSeadragon + overlay.""" + + qc_path = folder / "dimension_qc_report.json" + if not qc_path.exists(): + print(f"[ERR] Сначала запустите dimension_qc_checker.py") + sys.exit(1) + + qc = json.loads(qc_path.read_text(encoding="utf-8")) + + # Добавить VLM QC issues если есть + vlm_path = folder / "vlm_qc_report.json" + if vlm_path.exists(): + vlm_qc = json.loads(vlm_path.read_text(encoding="utf-8")) + for severity in ["errors", "warnings", "infos"]: + qc.setdefault(severity, []) + qc[severity].extend(vlm_qc.get(severity, [])) + + ocr = json.loads((folder / "full_ocr_results.json").read_text(encoding="utf-8")) + + page_counts = {} + for severity in ["errors", "warnings", "infos"]: + for item in qc.get(severity, []): + p = item.get("page", 1) + page_counts[p] = page_counts.get(p, 0) + 1 + + if target_page is None: + target_page = max(page_counts, key=page_counts.get) if page_counts else 2 + + print(f"[INFO] Генерация viewer для страницы {target_page}") + + # Определить пути к изображению + png_path = folder / f"page_{target_page:03d}.png" + dzi_path = folder / f"page_{target_page:03d}.dzi" + + if use_dzi and not dzi_path.exists(): + print(f"[WARN] DZI не найден: {dzi_path}") + print(f" Запустите: python generate_dzi.py {png_path}") + use_dzi = False + + # Получить размеры PNG + from PIL import Image + with Image.open(png_path) as img: + img_width, img_height = img.size + + # Собрать элементы страницы + page_items = [] + for page in ocr["pages"]: + if page["page_number"] == target_page: + for line in page.get("ocr_lines", []): + page_items.append(line) + break + + # Собрать проблемы + issues = [] + colors = {"error": "#ff0000", "warning": "#ffaa00", "info": "#0099ff"} + for severity in ["errors", "warnings", "infos"]: + for item in qc.get(severity, []): + if item["page"] == target_page: + bboxes = [] + for key in ["bbox1", "bbox2", "bbox", "bbox_dim"]: + if key in item: + bboxes.append(item[key]) + + if not bboxes and "text" in item: + for line in page_items: + if line["text"] == item["text"]: + bboxes.append(line["bbox"]) + break + + issues.append({ + "type": item["type"], + "message": item["message"], + "severity": item["severity"], + "color": colors.get(item["severity"], "#999"), + "bboxes": bboxes, + "source": item.get("source", "rules"), + }) + + # Подготовить overlay-данные для JS + overlay_data = [] + issue_counter = 0 + for issue in issues: + issue_counter += 1 + for bbox in issue["bboxes"]: + if isinstance(bbox[0], list): + xs = [p[0] for p in bbox] + ys = [p[1] for p in bbox] + x1, y1, x2, y2 = min(xs), min(ys), max(xs), max(ys) + else: + x1, y1, x2, y2 = bbox[0], bbox[1], bbox[2], bbox[3] + + # Convert pixel to viewport (0-1) + vx = x1 / img_width + vy = y1 / img_height + vw = (x2 - x1) / img_width + vh = (y2 - y1) / img_height + + overlay_data.append({ + "id": f"issue-{issue_counter}", + "x": round(vx, 6), + "y": round(vy, 6), + "w": round(vw, 6), + "h": round(vh, 6), + "color": issue["color"], + "message": issue["message"], + "type": issue["type"], + "severity": issue["severity"], + "num": issue_counter, + }) + + # JSON для вставки в JS + overlays_json = json.dumps(overlay_data, ensure_ascii=False) + + # DZI или прямой PNG + if use_dzi: + # Inline DZI to avoid CORS issues with file:// protocol + dzi_xml = (folder / f"page_{target_page:03d}.dzi").read_text(encoding="utf-8") + # Parse key values + import re + w = re.search(r'Width="(\d+)"', dzi_xml).group(1) + h = re.search(r'Height="(\d+)"', dzi_xml).group(1) + ts = re.search(r'TileSize="(\d+)"', dzi_xml).group(1) + fmt = re.search(r'Format="(\w+)"', dzi_xml).group(1) + tile_source = f"""{{ + Image: {{ + xmlns: "http://schemas.microsoft.com/deepzoom/2008", + Url: "./page_{target_page:03d}_files/", + Format: "{fmt}", + Overlap: "0", + TileSize: "{ts}", + Size: {{ Width: "{w}", Height: "{h}" }} + }} + }}""" + dzi_note = "" + else: + png_data = base64.b64encode(png_path.read_bytes()).decode() + tile_source = f"{{ type: 'image', url: 'data:image/png;base64,{png_data}' }}" + dzi_note = "
⚠ DZI не найден — используется прямое PNG (медленно для больших чертежей)
" + + # Генерация HTML + html = f''' + + + + QC Viewer — Страница {target_page} + + + + +
+
+
+ + + + + +
+
+ + + +
+ + +
+ +
+
+ + + +''' + + out_dir = folder / "web_viewer" + out_dir.mkdir(exist_ok=True) + + # Copy DZI tiles into web_viewer folder so relative paths work + if use_dzi: + src_files = folder / f"page_{target_page:03d}_files" + dst_files = out_dir / f"page_{target_page:03d}_files" + if src_files.exists(): + if dst_files.exists(): + shutil.rmtree(dst_files) + shutil.copytree(src_files, dst_files) + print(f"[INFO] Скопировано тайлов: {dst_files}") + else: + print(f"[WARN] Тайлы не найдены: {src_files}") + use_dzi = False + + out_path = out_dir / "index.html" + out_path.write_text(html, encoding="utf-8") + + print(f"[OK] Viewer создан: {out_path}") + print(f" {'DZI tiles' if use_dzi else 'Direct PNG'}") + print(f" Откройте в браузере: file://{out_path}") + + +def main(): + folder = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("output_123") + page = int(sys.argv[2]) if len(sys.argv) > 2 else None + use_dzi = "--dzi" in sys.argv or "--no-dzi" not in sys.argv + generate_html(folder, page, use_dzi) + + +if __name__ == "__main__": + main() diff --git a/rag_query.py b/rag_query.py index 867bc8a..1c4d516 100644 --- a/rag_query.py +++ b/rag_query.py @@ -83,7 +83,8 @@ def get_lmstudio_backend(model: str = "qwen2.5:14b"): temperature=kwargs.get("temperature", 0.3), max_tokens=kwargs.get("max_tokens", 1024), ) - return response.choices[0].message.content + content = response.choices[0].message.content + return content if content is not None else "" embed_model = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2") @@ -117,7 +118,8 @@ def get_opencode_backend(model: str = "nemotron-3-super-free"): temperature=kwargs.get("temperature", 0.3), max_tokens=kwargs.get("max_tokens", 1024), ) - return response.choices[0].message.content + content = response.choices[0].message.content + return content if content is not None else "" embed_model = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")