From f37c477a0a1d158f1132f2027a4e88c1331f7254 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=91=D0=BB=D0=B8?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2?= Date: Mon, 1 Jun 2026 12:29:41 +0300 Subject: [PATCH] Add FastAPI backend with DZI viewer and feedback system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FastAPI app with SQLite DB (projects, pages, issues, feedback) - OpenSeadragon DZI viewer with inline SVG overlays - Dashboard: upload, project list, tiling toggle, review mode - Pipeline integration: tiling OCR → layout → elements → rules QC → DZI → DB - Feedback collection: true_positive / false_positive / not_sure per issue --- backend/README.md | 122 +++++++++ backend/__init__.py | 1 + backend/app/__init__.py | 1 + backend/app/crud.py | 192 ++++++++++++++ backend/app/database.py | 23 ++ backend/app/main.py | 401 ++++++++++++++++++++++++++++++ backend/app/models.py | 95 +++++++ backend/app/processing.py | 217 ++++++++++++++++ backend/app/schemas.py | 137 ++++++++++ backend/backend.log | 223 +++++++++++++++++ backend/import_existing.py | 137 ++++++++++ backend/requirements.txt | 6 + backend/static/index.html | 329 ++++++++++++++++++++++++ backend/static/review.html | 495 +++++++++++++++++++++++++++++++++++++ 14 files changed, 2379 insertions(+) create mode 100644 backend/README.md create mode 100644 backend/__init__.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/crud.py create mode 100644 backend/app/database.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models.py create mode 100644 backend/app/processing.py create mode 100644 backend/app/schemas.py create mode 100644 backend/backend.log create mode 100644 backend/import_existing.py create mode 100644 backend/requirements.txt create mode 100644 backend/static/index.html create mode 100644 backend/static/review.html diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..567c282 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,122 @@ +# Blueprint QC Backend + +FastAPI backend для сбора, хранения и разметки замечаний QC по чертежам. + +## Архитектура + +``` +┌─────────────┐ ┌──────────────┐ ┌─────────────┐ +│ React UI │────▶│ FastAPI │────▶│ SQLite │ +│ (viewer) │◀────│ Backend │◀────│ Database │ +└─────────────┘ └──────────────┘ └─────────────┘ + │ + ▼ + ┌──────────────┐ + │ OCR + QC │ + │ Pipeline │ + └──────────────┘ +``` + +## Установка + +```bash +cd backend +pip install -r requirements.txt +``` + +## Запуск + +```bash +python -m uvicorn app.main:app --reload --port 8000 +``` + +API будет доступен на http://localhost:8000 + +Документация (Swagger): http://localhost:8000/docs + +## API Endpoints + +### Проекты + +| Method | Endpoint | Описание | +|--------|----------|----------| +| POST | `/api/projects/upload` | Загрузка PDF, запуск обработки | +| GET | `/api/projects` | Список проектов | +| GET | `/api/projects/{id}` | Детали проекта | + +### Замечания (Issues) + +| Method | Endpoint | Описание | +|--------|----------|----------| +| GET | `/api/projects/{id}/issues` | Замечания проекта | +| GET | `/api/issues/{id}` | Одно замечание | + +### Feedback (разметка) + +| Method | Endpoint | Описание | +|--------|----------|----------| +| POST | `/api/feedback` | Отметить TP/FP | +| GET | `/api/feedback/stats` | Статистика | + +### Обучение + +| Method | Endpoint | Описание | +|--------|----------|----------| +| GET | `/api/training/data` | Данные для обучения | +| POST | `/api/training/export` | Скачать JSON | +| GET | `/api/stats` | Общая статистика | + +### Viewer + +| Method | Endpoint | Описание | +|--------|----------|----------| +| GET | `/viewer/{project_id}/{page}` | HTML viewer | +| GET | `/viewer_tiles/{project_id}/{path}` | DZI тайлы | + +## Workflow + +1. **Загрузка**: `POST /api/projects/upload` с PDF +2. **Обработка**: Backend запускает OCR → QC → DZI (фоново) +3. **Просмотр**: `GET /viewer/{id}/{page}` — viewer с overlay +4. **Разметка**: Пользователь кликает замечания → `POST /api/feedback` (is_true_positive: true/false) +5. **Обучение**: `GET /api/training/data` — экспорт размеченных данных + +## Feedback Schema + +```json +{ + "issue_id": 123, + "is_true_positive": true, // true = реальная проблема, false = ложное срабатывание + "comment": "Размер действительно плохо читается", + "action_taken": "fixed" // fixed / ignored / not_sure +} +``` + +## Training Data + +Каждый размеченный пример содержит: +- `bbox`: координаты на PNG +- `issue_type`: тип проблемы +- `is_true_positive`: метка от пользователя +- `image_path`: путь к PNG страницы +- `dimension_text`: текст размера (если есть) +- `confidence`: OCR confidence + +Накопив 100-200 размеченных примеров, можно: +1. Fine-tune VLM (few-shot prompting) +2. Обучить YOLO-детектор под ваши типы чертежей +3. Дообучить правила QC (эвристики) + +## Модели БД + +### Project +- id, name, pdf_filename, status, created_at, output_folder + +### Page +- id, project_id, page_number, png_path, ocr_data, vlm_description + +### Issue +- id, project_id, page_id, issue_type, severity, message, bbox, dimension_text, confidence + +### Feedback +- id, issue_id, is_true_positive, comment, action_taken, created_at diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..a1de208 --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ +# backend/__init__.py diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..bd21cdf --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# app/__init__.py diff --git a/backend/app/crud.py b/backend/app/crud.py new file mode 100644 index 0000000..589de0f --- /dev/null +++ b/backend/app/crud.py @@ -0,0 +1,192 @@ +# app/crud.py +from sqlalchemy.orm import Session, joinedload +from sqlalchemy import func +from typing import List, Optional +import app.models as models +import app.schemas as schemas + + +# ---------- Project ---------- +def create_project(db: Session, pdf_filename: str, name: Optional[str] = None) -> models.Project: + project = models.Project( + name=name or pdf_filename, + pdf_filename=pdf_filename, + status="uploaded" + ) + db.add(project) + db.commit() + db.refresh(project) + return project + +def get_project(db: Session, project_id: int) -> Optional[models.Project]: + return db.query(models.Project).options( + joinedload(models.Project.pages), + joinedload(models.Project.issues).joinedload(models.Issue.feedback) + ).filter(models.Project.id == project_id).first() + +def get_projects(db: Session, skip: int = 0, limit: int = 100) -> List[models.Project]: + return db.query(models.Project).options( + joinedload(models.Project.pages), + joinedload(models.Project.issues).joinedload(models.Issue.feedback) + ).order_by(models.Project.created_at.desc()).offset(skip).limit(limit).all() + +def update_project_status(db: Session, project_id: int, status: str, error_message: str = None, output_folder: str = None): + project = get_project(db, project_id) + if project: + project.status = status + if error_message is not None: + project.error_message = error_message + # Очищаем ошибку при новом запуске или успехе + if status in ("processing", "completed") and error_message is None: + project.error_message = None + if output_folder: + project.output_folder = output_folder + if status == "completed": + from datetime import datetime + project.completed_at = datetime.utcnow() + db.commit() + db.refresh(project) + return project + + +# ---------- Page ---------- +def create_page(db: Session, project_id: int, page_number: int, **kwargs) -> models.Page: + page = models.Page(project_id=project_id, page_number=page_number, **kwargs) + db.add(page) + db.commit() + db.refresh(page) + return page + +def get_page_by_number(db: Session, project_id: int, page_number: int) -> Optional[models.Page]: + return db.query(models.Page).filter( + models.Page.project_id == project_id, + models.Page.page_number == page_number + ).first() + + +# ---------- Issue ---------- +def create_issue(db: Session, project_id: int, page_id: Optional[int], **kwargs) -> models.Issue: + issue = models.Issue(project_id=project_id, page_id=page_id, **kwargs) + db.add(issue) + db.commit() + db.refresh(issue) + return issue + +def get_issues(db: Session, project_id: Optional[int] = None, page_id: Optional[int] = None, + severity: Optional[str] = None, issue_type: Optional[str] = None, + has_feedback: Optional[bool] = None, skip: int = 0, limit: int = 1000): + query = db.query(models.Issue) + if project_id: + query = query.filter(models.Issue.project_id == project_id) + if page_id: + query = query.filter(models.Issue.page_id == page_id) + if severity: + query = query.filter(models.Issue.severity == severity) + if issue_type: + query = query.filter(models.Issue.issue_type == issue_type) + if has_feedback is not None: + if has_feedback: + query = query.filter(models.Issue.feedback != None) + else: + query = query.filter(models.Issue.feedback == None) + return query.order_by(models.Issue.created_at.desc()).offset(skip).limit(limit).all() + +def get_issue(db: Session, issue_id: int) -> Optional[models.Issue]: + return db.query(models.Issue).filter(models.Issue.id == issue_id).first() + + +# ---------- Feedback ---------- +def create_feedback(db: Session, feedback: schemas.FeedbackCreate) -> models.Feedback: + # Удалить старый feedback если есть + existing = db.query(models.Feedback).filter(models.Feedback.issue_id == feedback.issue_id).first() + if existing: + db.delete(existing) + db.commit() + + db_feedback = models.Feedback(**feedback.dict()) + db.add(db_feedback) + db.commit() + db.refresh(db_feedback) + return db_feedback + +def get_feedback_stats(db: Session, project_id: Optional[int] = None): + query = db.query(models.Feedback) + if project_id: + query = query.join(models.Issue).filter(models.Issue.project_id == project_id) + + total = query.count() + true_positive = query.filter(models.Feedback.is_true_positive == True).count() + false_positive = query.filter(models.Feedback.is_true_positive == False).count() + unreviewed = query.filter(models.Feedback.is_true_positive == None).count() + + accuracy = true_positive / (true_positive + false_positive) if (true_positive + false_positive) > 0 else None + + return { + "total": total, + "true_positive": true_positive, + "false_positive": false_positive, + "unreviewed": unreviewed, + "accuracy_estimate": round(accuracy, 3) if accuracy else None + } + + +# ---------- Stats ---------- +def get_stats(db: Session) -> schemas.StatsResponse: + total_projects = db.query(models.Project).count() + total_issues = db.query(models.Issue).count() + + # По типам + issue_types = db.query(models.Issue.issue_type, func.count(models.Issue.id)).group_by(models.Issue.issue_type).all() + issues_by_type = {t[0]: t[1] for t in issue_types} + + # Feedback + fb_stats = get_feedback_stats(db) + + return schemas.StatsResponse( + total_projects=total_projects, + total_issues=total_issues, + issues_by_type=issues_by_type, + feedback_stats={ + "true_positive": fb_stats["true_positive"], + "false_positive": fb_stats["false_positive"], + "unreviewed": fb_stats["unreviewed"] + }, + accuracy_estimate=fb_stats["accuracy_estimate"] + ) + + +# ---------- Training Data ---------- +def export_training_data(db: Session, project_id: Optional[int] = None, + only_labeled: bool = True) -> List[schemas.TrainingSample]: + """Экспорт данных для обучения ML-модели.""" + query = db.query(models.Issue) + if project_id: + query = query.filter(models.Issue.project_id == project_id) + if only_labeled: + query = query.join(models.Feedback) + + issues = query.all() + samples = [] + + for issue in issues: + sample = schemas.TrainingSample( + issue_id=issue.id, + issue_type=issue.issue_type, + severity=issue.severity, + message=issue.message, + bbox={ + "x1": issue.bbox_x1, + "y1": issue.bbox_y1, + "x2": issue.bbox_x2, + "y2": issue.bbox_y2 + }, + dimension_text=issue.dimension_text, + confidence=issue.confidence, + page_number=issue.page.page_number if issue.page else None, + is_true_positive=issue.feedback.is_true_positive if issue.feedback else None, + image_path=issue.page.png_path if issue.page else None, + label=f"{'good' if issue.feedback and issue.feedback.is_true_positive else 'bad'}_{issue.issue_type.lower()}" if issue.feedback else None + ) + samples.append(sample) + + return samples diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..d459fa1 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,23 @@ +# app/database.py +import os +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +SQLALCHEMY_DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./blueprint_qc.db") + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False} if SQLALCHEMY_DATABASE_URL.startswith("sqlite") else {} +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..1a9629d --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,401 @@ +# app/main.py +""" +FastAPI backend для Blueprint QC Service. + +API Endpoints: +- POST /api/projects/upload — загрузка PDF +- GET /api/projects — список проектов +- GET /api/projects/{id} — детали проекта +- GET /api/projects/{id}/issues — замечания проекта +- POST /api/feedback — отметить замечание (TP/FP) +- GET /api/feedback/stats — статистика feedback +- GET /api/training/data — экспорт данных для обучения +- GET /api/stats — общая статистика +- GET /viewer/{project_id}/{page} — HTML viewer (серверный, без file://) +""" + +import os +import sys +import re +import json +import shutil +from pathlib import Path +from typing import List, Optional +from datetime import datetime + +from fastapi import FastAPI, File, UploadFile, Depends, HTTPException, Query +from fastapi.responses import HTMLResponse, FileResponse, JSONResponse +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from sqlalchemy.orm import Session + +# Добавить пути +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) +sys.path.insert(0, str(Path(__file__).parent)) + +from app.database import engine, get_db, Base +from app import models, schemas, crud, processing + +# Создать таблицы +Base.metadata.create_all(bind=engine) + +# Создать папку static если нет +STATIC_DIR = Path(__file__).parent.parent / "static" +STATIC_DIR.mkdir(exist_ok=True) + +app = FastAPI( + title="Blueprint QC API", + description="Сервис автоматической проверки чертежей и сбора данных для обучения", + version="0.1.0" +) + +# CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Пути +# Абсолютные пути (backend запускается из разных директорий) +BASE_DIR = Path(__file__).parent.parent # backend/ +UPLOAD_DIR = BASE_DIR / "uploads" +OUTPUT_DIR = BASE_DIR / "outputs" +UPLOAD_DIR.mkdir(exist_ok=True) +OUTPUT_DIR.mkdir(exist_ok=True) + + +# ==================== PROJECTS ==================== + +@app.post("/api/projects/upload", response_model=schemas.ProjectResponse) +async def upload_pdf( + file: UploadFile = File(...), + name: Optional[str] = None, + db: Session = Depends(get_db) +): + """Загрузка PDF файла. Запускает фоновую обработку.""" + if not file.filename.endswith('.pdf'): + raise HTTPException(400, "Only PDF files allowed") + + # Сохранить файл + safe_name = Path(file.filename).name + pdf_path = UPLOAD_DIR / f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{safe_name}" + with open(pdf_path, "wb") as f: + shutil.copyfileobj(file.file, f) + + # Создать проект в БД (статус = uploaded, анализ запускается вручную) + project = crud.create_project(db, pdf_filename=safe_name, name=name or safe_name) + + # НЕ запускаем обработку автоматически — пользователь жмет "Анализировать" + # вручную, когда готов + + return project + + +@app.get("/api/projects", response_model=List[schemas.ProjectDetail]) +def list_projects(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): + """Список всех проектов со страницами и замечаниями.""" + return crud.get_projects(db, skip=skip, limit=limit) + + +@app.get("/api/projects/{project_id}", response_model=schemas.ProjectDetail) +def get_project(project_id: int, db: Session = Depends(get_db)): + """Детали проекта со страницами и замечаниями.""" + project = crud.get_project(db, project_id) + if not project: + raise HTTPException(404, "Project not found") + return project + + +@app.delete("/api/projects/{project_id}") +def delete_project(project_id: int, db: Session = Depends(get_db)): + """Удалить проект и все связанные данные.""" + project = crud.get_project(db, project_id) + if not project: + raise HTTPException(404, "Project not found") + + # Удалить файлы проекта + if project.output_folder: + import shutil + try: + shutil.rmtree(project.output_folder, ignore_errors=True) + except: + pass + + db.delete(project) + db.commit() + return {"status": "deleted", "project_id": project_id} + + +@app.post("/api/projects/{project_id}/analyze") +def analyze_project(project_id: int, use_tiling: bool = False, db: Session = Depends(get_db)): + """Запустить анализ (OCR + Layout + QC + DZI) для проекта.""" + project = crud.get_project(db, project_id) + if not project: + raise HTTPException(404, "Project not found") + + if project.status == "processing": + return {"status": "already_processing", "project_id": project_id} + + if project.status == "completed": + pass + + pdf_files = list(UPLOAD_DIR.glob(f"*_{project.pdf_filename}")) + if not pdf_files: + pdf_files = list(UPLOAD_DIR.glob(project.pdf_filename)) + + if not pdf_files: + raise HTTPException(404, f"PDF file not found in {UPLOAD_DIR}") + + pdf_path = pdf_files[0] + + crud.update_project_status(db, project_id, "processing") + + import threading + def run_in_background(): + db_local = next(get_db()) + try: + processing.run_pipeline(project.id, pdf_path, OUTPUT_DIR, db_local, use_tiling=use_tiling) + except Exception as e: + print(f"[ERROR] Pipeline failed for project {project.id}: {e}") + import traceback + traceback.print_exc() + crud.update_project_status(db_local, project.id, "error", error_message=str(e)) + finally: + db_local.close() + + thread = threading.Thread(target=run_in_background) + thread.start() + + return {"status": "processing_started", "project_id": project_id, "ocr_engine": "tiling" if use_tiling else "standard"} + + +# ==================== ISSUES ==================== + +@app.get("/api/projects/{project_id}/issues") +def get_issues( + project_id: int, + severity: Optional[str] = Query(None, description="error / warning / info"), + issue_type: Optional[str] = Query(None), + has_feedback: Optional[bool] = Query(None, description="Filter by feedback presence"), + db: Session = Depends(get_db) +): + """Получить замечания проекта.""" + issues = crud.get_issues( + db, project_id=project_id, severity=severity, + issue_type=issue_type, has_feedback=has_feedback + ) + + # Build response with page_number manually + result = [] + for issue in issues: + item = { + "id": issue.id, + "project_id": issue.project_id, + "page_id": issue.page_id, + "page_number": issue.page.page_number if issue.page else None, + "issue_type": issue.issue_type, + "severity": issue.severity, + "message": issue.message, + "bbox_x1": issue.bbox_x1, + "bbox_y1": issue.bbox_y1, + "bbox_x2": issue.bbox_x2, + "bbox_y2": issue.bbox_y2, + "dimension_text": issue.dimension_text, + "confidence": issue.confidence, + "created_at": issue.created_at.isoformat() if issue.created_at else None, + "feedback": { + "id": issue.feedback.id, + "issue_id": issue.feedback.issue_id, + "is_true_positive": issue.feedback.is_true_positive, + "comment": issue.feedback.comment, + "action_taken": issue.feedback.action_taken, + "created_at": issue.feedback.created_at.isoformat() if issue.feedback.created_at else None + } if issue.feedback else None + } + result.append(item) + + return result + + +@app.get("/api/issues/{issue_id}", response_model=schemas.IssueResponse) +def get_issue(issue_id: int, db: Session = Depends(get_db)): + """Одно замечание.""" + issue = crud.get_issue(db, issue_id) + if not issue: + raise HTTPException(404, "Issue not found") + return issue + + +# ==================== FEEDBACK ==================== + +@app.post("/api/feedback", response_model=schemas.FeedbackResponse) +def submit_feedback(feedback: schemas.FeedbackCreate, db: Session = Depends(get_db)): + """ + Отправить feedback по замечанию. + + is_true_positive: + - true = реальная проблема (правильное срабатывание) + - false = ложное срабатывание (false positive) + - null = не уверен + + action_taken: fixed / ignored / not_sure + """ + issue = crud.get_issue(db, feedback.issue_id) + if not issue: + raise HTTPException(404, "Issue not found") + + return crud.create_feedback(db, feedback) + + +@app.get("/api/feedback/stats") +def feedback_stats(project_id: Optional[int] = None, db: Session = Depends(get_db)): + """Статистика feedback.""" + return crud.get_feedback_stats(db, project_id=project_id) + + +# ==================== VIEWER ==================== + +@app.get("/viewer/{project_id}/{page_number}", response_class=HTMLResponse) +def get_viewer(project_id: int, page_number: int, db: Session = Depends(get_db)): + """HTML viewer с overlay замечаний + feedback buttons.""" + project = crud.get_project(db, project_id) + if not project or not project.output_folder: + raise HTTPException(404, "Project or output not found") + + # ВСЕГДА перегенерировать viewer для нужной страницы + # (иначе тайлы указывают на другую страницу) + result = processing.generate_viewer_html(db, project_id, page_number) + if not result: + raise HTTPException(404, "Viewer not available") + + viewer_path = Path(result) + content = viewer_path.read_text(encoding="utf-8") + + # Подменить пути тайлов — теперь они будут правильные + # (generate_web_viewer.py уже сгенерировал для page_{page_number:03d}_files/) + content = content.replace( + 'Url: "./page_', + f'Url: "/viewer_tiles/{project_id}/page_' + ) + + # Внедрить project_id и page info для навигации + total_pages = len(project.pages) if project.pages else page_number + content = content.replace('const PROJECT_ID = null;', f'const PROJECT_ID = {project_id};') + content = content.replace('const TOTAL_PAGES = null;', f'const TOTAL_PAGES = {total_pages};') + content = content.replace('const CURRENT_PAGE = null;', f'const CURRENT_PAGE = {page_number};') + + # Обновить счётчик страниц в навбаре + content = content.replace( + f'?', + f'{total_pages}' + ) + + # Получить issue IDs из БД для этой страницы + page = crud.get_page_by_number(db, project_id, page_number) + if page: + issues = crud.get_issues(db, project_id=project_id, page_id=page.id) + issue_db_ids = [str(i.id) for i in issues] + + # Внедрить DB IDs в HTML + content = content.replace('data-has-api="false"', 'data-has-api="true"') + + def inject_db_id(match): + idx = int(match.group(1)) - 1 + if idx < len(issue_db_ids): + return match.group(0) + f' data-db-id="{issue_db_ids[idx]}"' + return match.group(0) + + content = re.sub(r'data-id="(\d+)"', inject_db_id, content) + + return HTMLResponse(content=content) + + +@app.get("/viewer_tiles/{project_id}/{filename:path}") +def get_tile(project_id: int, filename: str, db: Session = Depends(get_db)): + """Отдаёт DZI тайлы.""" + project = crud.get_project(db, project_id) + if not project or not project.output_folder: + raise HTTPException(404) + + tile_path = Path(project.output_folder) / filename + if tile_path.exists() and str(tile_path).startswith(str(project.output_folder)): + return FileResponse(tile_path) + raise HTTPException(404) + + +# ==================== TRAINING DATA ==================== + +@app.get("/api/training/data", response_model=schemas.TrainingDataExport) +def export_training_data( + project_id: Optional[int] = Query(None), + only_labeled: bool = Query(True), + format: str = Query("json", description="json / yolo / csv"), + db: Session = Depends(get_db) +): + """ + Экспорт данных для обучения ML-модели. + + only_labeled=true — только размеченные feedback'ом замечания. + """ + samples = crud.export_training_data(db, project_id=project_id, only_labeled=only_labeled) + + if format == "json": + return schemas.TrainingDataExport( + total_samples=len(samples), + labeled_samples=len([s for s in samples if s.is_true_positive is not None]), + samples=samples, + export_format="json" + ) + + # TODO: YOLO / CSV форматы + raise HTTPException(400, f"Format {format} not yet implemented") + + +@app.post("/api/training/export") +def download_training_export( + project_id: Optional[int] = None, + only_labeled: bool = True, + db: Session = Depends(get_db) +): + """Скачать training data как JSON файл.""" + samples = crud.export_training_data(db, project_id=project_id, only_labeled=only_labeled) + + export = { + "generated_at": datetime.utcnow().isoformat(), + "total_samples": len(samples), + "samples": [s.dict() for s in samples] + } + + return JSONResponse(content=export, media_type="application/json") + + +# ==================== STATS ==================== + +@app.get("/api/stats", response_model=schemas.StatsResponse) +def get_stats(db: Session = Depends(get_db)): + """Общая статистика системы.""" + return crud.get_stats(db) + + +# ==================== HEALTH ==================== + +@app.get("/api/health") +def health_check(): + return {"status": "ok", "version": "0.1.0"} + + +# ==================== STATIC (для демо) ==================== + +# Подключить статику +try: + app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="static") +except RuntimeError: + pass # Already mounted + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..b580f4f --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,95 @@ +# app/models.py +from sqlalchemy import Column, Integer, String, Float, DateTime, Text, ForeignKey, JSON, Boolean +from sqlalchemy.orm import relationship +from datetime import datetime +from app.database import Base + + +class Project(Base): + """Проект — один загруженный PDF.""" + __tablename__ = "projects" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, index=True) + pdf_filename = Column(String, nullable=False) + status = Column(String, default="uploaded") # uploaded / processing / completed / error + created_at = Column(DateTime, default=datetime.utcnow) + completed_at = Column(DateTime, nullable=True) + error_message = Column(Text, nullable=True) + + # Пути к файлам + output_folder = Column(String, nullable=True) + + pages = relationship("Page", back_populates="project", cascade="all, delete-orphan") + issues = relationship("Issue", back_populates="project", cascade="all, delete-orphan") + + +class Page(Base): + """Страница PDF.""" + __tablename__ = "pages" + + id = Column(Integer, primary_key=True, index=True) + project_id = Column(Integer, ForeignKey("projects.id")) + page_number = Column(Integer, nullable=False) + png_path = Column(String, nullable=True) + dzi_path = Column(String, nullable=True) + ocr_data = Column(JSON, nullable=True) # full_ocr_results для этой страницы + vlm_description = Column(Text, nullable=True) + width = Column(Integer, nullable=True) + height = Column(Integer, nullable=True) + + project = relationship("Project", back_populates="pages") + issues = relationship("Issue", back_populates="page") + + +class Issue(Base): + """Замечание QC — одна проблема на чертеже.""" + __tablename__ = "issues" + + id = Column(Integer, primary_key=True, index=True) + project_id = Column(Integer, ForeignKey("projects.id")) + page_id = Column(Integer, ForeignKey("pages.id")) + + issue_type = Column(String, nullable=False) # DIMENSION_OVERLAP, LOW_CONFIDENCE, etc. + severity = Column(String, nullable=False) # error / warning / info + message = Column(Text, nullable=False) + + # Координаты bbox на PNG (в пикселях) + bbox_x1 = Column(Float, nullable=True) + bbox_y1 = Column(Float, nullable=True) + bbox_x2 = Column(Float, nullable=True) + bbox_y2 = Column(Float, nullable=True) + + # Дополнительные данные + dimension_text = Column(String, nullable=True) # Текст размера (если применимо) + confidence = Column(Float, nullable=True) # OCR confidence + extra_data = Column(JSON, nullable=True) # Всё остальное + source = Column(String, nullable=True) # "rules" или "vlm" + + created_at = Column(DateTime, default=datetime.utcnow) + + project = relationship("Project", back_populates="issues") + page = relationship("Page", back_populates="issues") + feedback = relationship("Feedback", back_populates="issue", uselist=False) + + +class Feedback(Base): + """Feedback проектировщика — правда ли это замечание.""" + __tablename__ = "feedback" + + id = Column(Integer, primary_key=True, index=True) + issue_id = Column(Integer, ForeignKey("issues.id"), unique=True) + + # True = реальная проблема, False = ложное срабатывание, None = не размечено + is_true_positive = Column(Boolean, nullable=True) + + # Почему (опционально) + comment = Column(Text, nullable=True) + + # Что сделал проектировщик + action_taken = Column(String, nullable=True) # fixed / ignored / not_sure + + created_at = Column(DateTime, default=datetime.utcnow) + user_id = Column(String, nullable=True) # Для многопользовательского режима + + issue = relationship("Issue", back_populates="feedback") diff --git a/backend/app/processing.py b/backend/app/processing.py new file mode 100644 index 0000000..df1ba3e --- /dev/null +++ b/backend/app/processing.py @@ -0,0 +1,217 @@ +# app/processing.py +""" +Интеграция с существующими скриптами OCR, QC, DZI. +Запускает pipeline в фоне и сохраняет результаты в БД. +""" + +import os +import sys +import json +import re +import subprocess +import shutil +from pathlib import Path +from typing import Optional +from sqlalchemy.orm import Session + +# Добавить корень проекта в path для импорта скриптов +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +import app.crud as crud +import app.models as models + + +def run_pipeline(project_id: int, pdf_path: Path, output_base: Path, db: Session, use_tiling: bool = False): + """Запускает полный pipeline: PDF → OCR → Layout → Elements → QC → DZI → БД.""" + + project = crud.get_project(db, project_id) + if not project: + return + + crud.update_project_status(db, project_id, "processing") + + try: + # 1. Создать output папку + output_folder = output_base / f"project_{project_id}" + output_folder.mkdir(parents=True, exist_ok=True) + + script_dir = Path(__file__).parent.parent.parent # backend/app -> backend -> opencode + + # 2. OCR + PNG (RapidOCR или Tiling OCR) + cmd = [sys.executable, str(script_dir / "process_any_pdf.py"), str(pdf_path), str(output_folder)] + if use_tiling: + cmd.append("--use-tiling") + print("[INFO] Tiling OCR enabled") + _run_command(cmd, cwd=str(script_dir)) + + # 3. Layout Detection для каждой оригинальной страницы + # Только page_NNN.png, исключая визуализации (_dims, _layout и т.д.) + page_pngs = sorted([p for p in output_folder.glob("page_*.png") + if not any(suffix in p.stem for suffix in ["_dims", "_layout", "_detected", "_preproc", "_ocr_compare"])]) + for png in page_pngs: + ocr_json = output_folder / "full_ocr_results.json" + if ocr_json.exists(): + try: + _run_command([ + sys.executable, str(script_dir / "layout_detector.py"), + str(png), str(ocr_json) + ], cwd=str(script_dir)) + print(f"[INFO] Layout detection done for {png.name}") + except Exception as e: + print(f"[WARN] Layout detection failed for {png.name}: {e}") + + # 4. Multi-Element Extraction (dimensions, positions, GOSTs, etc.) + for png in page_pngs: + ocr_json = output_folder / "full_ocr_results.json" + layout_json = output_folder / "layout.json" + if ocr_json.exists() and layout_json.exists(): + try: + _run_command([ + sys.executable, str(script_dir / "multi_element_extractor.py"), + str(png), str(ocr_json), str(layout_json) + ], cwd=str(script_dir)) + print(f"[INFO] Element extraction done for {png.name}") + except Exception as e: + print(f"[WARN] Element extraction failed for {png.name}: {e}") + + # 5. QC (dimension_qc_checker.py) — правила + _run_command([ + sys.executable, str(script_dir / "dimension_qc_checker.py"), + str(output_folder) + ], cwd=str(script_dir)) + + # 6. DZI для каждой страницы + for png in page_pngs: + _run_command([ + sys.executable, str(script_dir / "generate_dzi.py"), + str(png) + ], cwd=str(script_dir)) + + # 7. Проверить результаты + ocr_path = output_folder / "full_ocr_results.json" + if not ocr_path.exists(): + raise RuntimeError(f"OCR results not generated: {ocr_path}") + + # 8. Загрузить в БД + _import_results(db, project_id, output_folder) + + crud.update_project_status(db, project_id, "completed", output_folder=str(output_folder)) + + except Exception as e: + crud.update_project_status(db, project_id, "error", error_message=str(e)) + raise + + +def _run_command(cmd: list, cwd: Optional[Path] = None): + """Запускает команду, проверяет exit code, выбрасывает исключение при ошибке.""" + result = subprocess.run( + cmd, + capture_output=True, + text=True, + cwd=cwd + ) + if result.returncode != 0: + stderr = result.stderr[:1000] + stdout = result.stdout[:500] + raise RuntimeError(f"Command failed ({result.returncode}): {' '.join(cmd)}\nSTDERR: {stderr}\nSTDOUT: {stdout}") + return result + + +def _import_results(db: Session, project_id: int, output_folder: Path): + """Импорт OCR и QC результатов в БД. Очищает старые данные проекта перед импортом.""" + + # Очистить старые страницы и замечания (cascade удалит issues и feedback) + db.query(models.Page).filter(models.Page.project_id == project_id).delete(synchronize_session=False) + db.query(models.Issue).filter(models.Issue.project_id == project_id).delete(synchronize_session=False) + db.commit() + + # Загрузить OCR + ocr_path = output_folder / "full_ocr_results.json" + if ocr_path.exists(): + ocr = json.loads(ocr_path.read_text(encoding="utf-8")) + for page_data in ocr.get("pages", []): + page_num = page_data["page_number"] + png_path = output_folder / f"page_{page_num:03d}.png" + + page = crud.create_page( + db, project_id=project_id, page_number=page_num, + png_path=str(png_path) if png_path.exists() else None, + ocr_data=page_data + ) + + # Загрузить VLM extraction descriptions в pages + vlm_path = output_folder / "vlm_extraction.json" + vlm_data = {} + if vlm_path.exists(): + vlm_data = json.loads(vlm_path.read_text(encoding="utf-8")) + + # Обновить vlm_description для каждой страницы + for img_name, extraction in vlm_data.items(): + page_num_match = re.search(r"page_(\d+)", img_name) + if page_num_match: + page_num = int(page_num_match.group(1)) + page = crud.get_page_by_number(db, project_id, page_num) + if page: + desc = extraction.get("description", "") + page.vlm_description = desc[:2000] if desc else None # ограничим длину + db.commit() + + # Загрузить QC issues (только rules — VLM issues удалены как ненадёжные) + qc_path = output_folder / "dimension_qc_report.json" + if qc_path.exists(): + qc = json.loads(qc_path.read_text(encoding="utf-8")) + + for severity in ["errors", "warnings", "infos"]: + for item in qc.get(severity, []): + page_num = item.get("page") + if not page_num: + continue + page = crud.get_page_by_number(db, project_id, page_num) + + # Извлечь bbox + bbox = item.get("bbox") or item.get("bbox1") or item.get("bbox_dim") + x1 = y1 = x2 = y2 = None + if bbox: + 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] + + crud.create_issue( + db, project_id=project_id, page_id=page.id if page else None, + issue_type=item.get("type", "UNKNOWN"), + severity=item.get("severity", "warning"), + message=item.get("message", ""), + bbox_x1=x1, bbox_y1=y1, bbox_x2=x2, bbox_y2=y2, + dimension_text=item.get("text"), + confidence=item.get("confidence"), + source="rules", + extra_data={k: v for k, v in item.items() if k not in ["type", "severity", "message", "page", "text", "confidence", "bbox", "bbox1", "bbox2", "bbox_dim", "source"]} + ) + + +def generate_viewer_html(db: Session, project_id: int, page_number: int) -> Optional[str]: + """Генерирует HTML viewer для конкретной страницы.""" + project = crud.get_project(db, project_id) + if not project or not project.output_folder: + return None + + output_folder = Path(project.output_folder) + + # Перегенерировать viewer для нужной страницы + # Запускаем из папки, где находится generate_web_viewer.py + script_dir = Path(__file__).parent.parent.parent # backend/app -> backend -> opencode + result = _run_command([ + sys.executable, str(script_dir / "generate_web_viewer.py"), str(output_folder), str(page_number) + ], cwd=str(script_dir)) + + if result.returncode != 0: + print(f"[ERROR] generate_web_viewer.py failed: {result.stderr[:500]}") + return None + + viewer_path = output_folder / "web_viewer" / "index.html" + if viewer_path.exists(): + return str(viewer_path) + return None diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..dff5580 --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,137 @@ +# app/schemas.py +from pydantic import BaseModel +from typing import List, Optional, Dict, Any +from datetime import datetime + + +# ---------- Project ---------- +class ProjectBase(BaseModel): + name: Optional[str] = None + +class ProjectCreate(ProjectBase): + pdf_filename: str + +class ProjectResponse(ProjectBase): + id: int + pdf_filename: str + status: str + created_at: datetime + completed_at: Optional[datetime] = None + error_message: Optional[str] = None + + class Config: + from_attributes = True + +class ProjectDetail(ProjectResponse): + pages: List["PageResponse"] = [] + issues: List["IssueResponse"] = [] + + class Config: + from_attributes = True + + +# ---------- Page ---------- +class PageBase(BaseModel): + page_number: int + width: Optional[int] = None + height: Optional[int] = None + +class PageResponse(PageBase): + id: int + png_path: Optional[str] = None + vlm_description: Optional[str] = None + ocr_data: Optional[Dict[str, Any]] = None + issue_count: int = 0 + + class Config: + from_attributes = True + + +# ---------- Issue ---------- +class IssueBase(BaseModel): + issue_type: str + severity: str + message: str + page_number: Optional[int] = None + +class IssueCreate(IssueBase): + bbox_x1: Optional[float] = None + bbox_y1: Optional[float] = None + bbox_x2: Optional[float] = None + bbox_y2: Optional[float] = None + dimension_text: Optional[str] = None + confidence: Optional[float] = None + extra_data: Optional[Dict[str, Any]] = None + +class IssueResponse(IssueBase): + id: int + project_id: int + bbox_x1: Optional[float] = None + bbox_y1: Optional[float] = None + bbox_x2: Optional[float] = None + bbox_y2: Optional[float] = None + dimension_text: Optional[str] = None + confidence: Optional[float] = None + source: Optional[str] = None + created_at: datetime + feedback: Optional["FeedbackResponse"] = None + page_id: Optional[int] = None + + class Config: + from_attributes = True + + +# ---------- Feedback ---------- +class FeedbackCreate(BaseModel): + issue_id: int + is_true_positive: Optional[bool] = None + comment: Optional[str] = None + action_taken: Optional[str] = None # fixed / ignored / not_sure + +class FeedbackResponse(BaseModel): + id: int + issue_id: int + is_true_positive: Optional[bool] = None + comment: Optional[str] = None + action_taken: Optional[str] = None + created_at: datetime + + class Config: + from_attributes = True + + +# ---------- Stats ---------- +class StatsResponse(BaseModel): + total_projects: int + total_issues: int + issues_by_type: Dict[str, int] + feedback_stats: Dict[str, int] # true_positive, false_positive, unreviewed + accuracy_estimate: Optional[float] = None # Доля правильных срабатываний + + +# ---------- Training Data ---------- +class TrainingSample(BaseModel): + """Один пример для обучения ML-модели.""" + issue_id: int + issue_type: str + severity: str + message: str + bbox: Dict[str, float] # x1, y1, x2, y2 + dimension_text: Optional[str] = None + confidence: Optional[float] = None + page_number: int + is_true_positive: Optional[bool] = None + + # Для обучения детектора (YOLO) + image_path: Optional[str] = None # Путь к PNG страницы + crop_path: Optional[str] = None # Вырезанный фрагмент bbox + label: Optional[str] = None # "good_dimension" / "bad_placement" / etc. + +class TrainingDataExport(BaseModel): + total_samples: int + labeled_samples: int # Есть is_true_positive + samples: List[TrainingSample] + export_format: str # json / yolo / csv + + class Config: + from_attributes = True diff --git a/backend/backend.log b/backend/backend.log new file mode 100644 index 0000000..786e1fe --- /dev/null +++ b/backend/backend.log @@ -0,0 +1,223 @@ +INFO: Started server process [49915] +INFO: Waiting for application startup. +INFO: Application startup complete. +INFO: Uvicorn running on http://0.0.0.0:8001 (Press CTRL+C to quit) +INFO: 127.0.0.1:59330 - "GET /api/health HTTP/1.1" 200 OK +[WARN] Command failed: /opt/homebrew/opt/python@3.11/bin/python3.11 generate_web_viewer.py /Users/kirillblinov/development/opencode/OCR/opencode/output_123 5 + stderr: /opt/homebrew/Cellar/python@3.11/3.11.15_1/Frameworks/Python.framework/Versions/3.11/Resources/Python.app/Contents/MacOS/Python: can't open file '/Users/kirillblinov/development/opencode/OCR/generate_web_viewer.py': [Errno 2] No such file or directory + +INFO: 127.0.0.1:59332 - "GET /viewer/1/5 HTTP/1.1" 500 Internal Server Error +ERROR: Exception in ASGI application +Traceback (most recent call last): + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/uvicorn/protocols/http/httptools_impl.py", line 421, in run_asgi + result = await app( # type: ignore[func-returns-value] + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/uvicorn/middleware/proxy_headers.py", line 56, in __call__ + return await self.app(scope, receive, send) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/fastapi/applications.py", line 1159, in __call__ + await super().__call__(scope, receive, send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/applications.py", line 90, in __call__ + await self.middleware_stack(scope, receive, send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/middleware/errors.py", line 186, in __call__ + raise exc + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/middleware/errors.py", line 164, in __call__ + await self.app(scope, receive, _send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/middleware/cors.py", line 88, in __call__ + await self.app(scope, receive, send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/middleware/exceptions.py", line 63, in __call__ + await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app + raise exc + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app + await app(scope, receive, sender) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/fastapi/middleware/asyncexitstack.py", line 18, in __call__ + await self.app(scope, receive, send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/routing.py", line 660, in __call__ + await self.middleware_stack(scope, receive, send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/routing.py", line 680, in app + await route.handle(scope, receive, send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/routing.py", line 276, in handle + await self.app(scope, receive, send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/fastapi/routing.py", line 134, in app + await wrap_app_handling_exceptions(app, request)(scope, receive, send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app + raise exc + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app + await app(scope, receive, sender) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/fastapi/routing.py", line 120, in app + response = await f(request) + ^^^^^^^^^^^^^^^^ + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/fastapi/routing.py", line 674, in app + raw_response = await run_endpoint_function( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/fastapi/routing.py", line 330, in run_endpoint_function + return await run_in_threadpool(dependant.call, **values) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/concurrency.py", line 32, in run_in_threadpool + return await anyio.to_thread.run_sync(func) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/anyio/to_thread.py", line 63, in run_sync + return await get_async_backend().run_sync_in_worker_thread( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/anyio/_backends/_asyncio.py", line 2518, in run_sync_in_worker_thread + return await future + ^^^^^^^^^^^^ + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/anyio/_backends/_asyncio.py", line 1002, in run + result = context.run(func, *args) + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/kirillblinov/development/opencode/OCR/opencode/backend/app/main.py", line 285, in get_viewer + content = re.sub(r'data-id="(\d+)"', inject_db_id, content) + ^^ +NameError: name 're' is not defined +INFO: 127.0.0.1:59355 - "GET /viewer_tiles/1/page_005_files/14/33_5.png HTTP/1.1" 200 OK +INFO: 127.0.0.1:59355 - "GET /viewer_tiles/1/page_005_files/14/33_4.png HTTP/1.1" 200 OK +INFO: 127.0.0.1:59355 - "GET /viewer_tiles/1/page_005_files/13/17_2.png HTTP/1.1" 200 OK +INFO: 127.0.0.1:59355 - "GET /viewer_tiles/1/page_005_files/13/17_1.png HTTP/1.1" 200 OK +INFO: 127.0.0.1:59355 - "GET /viewer_tiles/1/page_005_files/13/17_3.png HTTP/1.1" 200 OK +INFO: 127.0.0.1:59355 - "GET /viewer_tiles/1/page_005_files/13/17_0.png HTTP/1.1" 200 OK +INFO: 127.0.0.1:59355 - "GET /viewer_tiles/1/page_005_files/13/17_4.png HTTP/1.1" 200 OK +[WARN] Command failed: /opt/homebrew/opt/python@3.11/bin/python3.11 generate_web_viewer.py /Users/kirillblinov/development/opencode/OCR/opencode/output_123 5 + stderr: /opt/homebrew/Cellar/python@3.11/3.11.15_1/Frameworks/Python.framework/Versions/3.11/Resources/Python.app/Contents/MacOS/Python: can't open file '/Users/kirillblinov/development/opencode/OCR/generate_web_viewer.py': [Errno 2] No such file or directory + +INFO: 127.0.0.1:59372 - "GET /viewer/1/5 HTTP/1.1" 500 Internal Server Error +ERROR: Exception in ASGI application +Traceback (most recent call last): + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/uvicorn/protocols/http/httptools_impl.py", line 421, in run_asgi + result = await app( # type: ignore[func-returns-value] + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/uvicorn/middleware/proxy_headers.py", line 56, in __call__ + return await self.app(scope, receive, send) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/fastapi/applications.py", line 1159, in __call__ + await super().__call__(scope, receive, send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/applications.py", line 90, in __call__ + await self.middleware_stack(scope, receive, send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/middleware/errors.py", line 186, in __call__ + raise exc + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/middleware/errors.py", line 164, in __call__ + await self.app(scope, receive, _send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/middleware/cors.py", line 88, in __call__ + await self.app(scope, receive, send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/middleware/exceptions.py", line 63, in __call__ + await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app + raise exc + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app + await app(scope, receive, sender) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/fastapi/middleware/asyncexitstack.py", line 18, in __call__ + await self.app(scope, receive, send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/routing.py", line 660, in __call__ + await self.middleware_stack(scope, receive, send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/routing.py", line 680, in app + await route.handle(scope, receive, send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/routing.py", line 276, in handle + await self.app(scope, receive, send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/fastapi/routing.py", line 134, in app + await wrap_app_handling_exceptions(app, request)(scope, receive, send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app + raise exc + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app + await app(scope, receive, sender) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/fastapi/routing.py", line 120, in app + response = await f(request) + ^^^^^^^^^^^^^^^^ + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/fastapi/routing.py", line 674, in app + raw_response = await run_endpoint_function( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/fastapi/routing.py", line 330, in run_endpoint_function + return await run_in_threadpool(dependant.call, **values) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/concurrency.py", line 32, in run_in_threadpool + return await anyio.to_thread.run_sync(func) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/anyio/to_thread.py", line 63, in run_sync + return await get_async_backend().run_sync_in_worker_thread( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/anyio/_backends/_asyncio.py", line 2518, in run_sync_in_worker_thread + return await future + ^^^^^^^^^^^^ + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/anyio/_backends/_asyncio.py", line 1002, in run + result = context.run(func, *args) + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/kirillblinov/development/opencode/OCR/opencode/backend/app/main.py", line 285, in get_viewer + content = re.sub(r'data-id="(\d+)"', inject_db_id, content) + ^^ +NameError: name 're' is not defined +INFO: 127.0.0.1:59355 - "GET /api/projects HTTP/1.1" 200 OK +INFO: 127.0.0.1:59406 - "GET /api/stats HTTP/1.1" 200 OK +INFO: 127.0.0.1:59355 - "GET /api/projects HTTP/1.1" 200 OK +INFO: 127.0.0.1:59408 - "GET /api/stats HTTP/1.1" 200 OK +[WARN] Command failed: /opt/homebrew/opt/python@3.11/bin/python3.11 generate_web_viewer.py /Users/kirillblinov/development/opencode/OCR/opencode/output_123 5 + stderr: /opt/homebrew/Cellar/python@3.11/3.11.15_1/Frameworks/Python.framework/Versions/3.11/Resources/Python.app/Contents/MacOS/Python: can't open file '/Users/kirillblinov/development/opencode/OCR/generate_web_viewer.py': [Errno 2] No such file or directory + +INFO: 127.0.0.1:59425 - "GET /viewer/1/5 HTTP/1.1" 500 Internal Server Error +ERROR: Exception in ASGI application +Traceback (most recent call last): + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/uvicorn/protocols/http/httptools_impl.py", line 421, in run_asgi + result = await app( # type: ignore[func-returns-value] + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/uvicorn/middleware/proxy_headers.py", line 56, in __call__ + return await self.app(scope, receive, send) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/fastapi/applications.py", line 1159, in __call__ + await super().__call__(scope, receive, send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/applications.py", line 90, in __call__ + await self.middleware_stack(scope, receive, send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/middleware/errors.py", line 186, in __call__ + raise exc + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/middleware/errors.py", line 164, in __call__ + await self.app(scope, receive, _send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/middleware/cors.py", line 88, in __call__ + await self.app(scope, receive, send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/middleware/exceptions.py", line 63, in __call__ + await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app + raise exc + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app + await app(scope, receive, sender) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/fastapi/middleware/asyncexitstack.py", line 18, in __call__ + await self.app(scope, receive, send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/routing.py", line 660, in __call__ + await self.middleware_stack(scope, receive, send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/routing.py", line 680, in app + await route.handle(scope, receive, send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/routing.py", line 276, in handle + await self.app(scope, receive, send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/fastapi/routing.py", line 134, in app + await wrap_app_handling_exceptions(app, request)(scope, receive, send) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app + raise exc + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app + await app(scope, receive, sender) + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/fastapi/routing.py", line 120, in app + response = await f(request) + ^^^^^^^^^^^^^^^^ + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/fastapi/routing.py", line 674, in app + raw_response = await run_endpoint_function( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/fastapi/routing.py", line 330, in run_endpoint_function + return await run_in_threadpool(dependant.call, **values) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/starlette/concurrency.py", line 32, in run_in_threadpool + return await anyio.to_thread.run_sync(func) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/anyio/to_thread.py", line 63, in run_sync + return await get_async_backend().run_sync_in_worker_thread( + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/anyio/_backends/_asyncio.py", line 2518, in run_sync_in_worker_thread + return await future + ^^^^^^^^^^^^ + File "/Users/kirillblinov/Library/Python/3.11/lib/python/site-packages/anyio/_backends/_asyncio.py", line 1002, in run + result = context.run(func, *args) + ^^^^^^^^^^^^^^^^^^^^^^^^ + File "/Users/kirillblinov/development/opencode/OCR/opencode/backend/app/main.py", line 285, in get_viewer + content = re.sub(r'data-id="(\d+)"', inject_db_id, content) + ^^ +NameError: name 're' is not defined +INFO: 127.0.0.1:59553 - "GET /viewer_tiles/1/page_005_files/14/18_1.png HTTP/1.1" 200 OK +INFO: 127.0.0.1:59553 - "GET /viewer_tiles/1/page_005_files/14/18_0.png HTTP/1.1" 200 OK +INFO: Shutting down +INFO: Waiting for application shutdown. +INFO: Application shutdown complete. +INFO: Finished server process [49915] diff --git a/backend/import_existing.py b/backend/import_existing.py new file mode 100644 index 0000000..9e52271 --- /dev/null +++ b/backend/import_existing.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Импорт существующего output_123 в backend БД. + +Использование: + python import_existing.py +""" + +import sys +import json +from pathlib import Path + +# Добавить backend в path +sys.path.insert(0, str(Path(__file__).parent.parent / "backend")) + +from app.database import SessionLocal, Base, engine +from app import models, crud, schemas + + +def import_output_123(): + """Импорт существующего проекта output_123 в БД.""" + + output_folder = Path("/Users/kirillblinov/development/opencode/OCR/opencode/output_123") + if not output_folder.exists(): + print(f"[ERR] Не найден: {output_folder}") + return + + db = SessionLocal() + + try: + # Создать проект + project = crud.create_project( + db, + pdf_filename="123.pdf", + name="Test Project 123" + ) + print(f"[INFO] Создан проект: ID={project.id}") + + # Обновить статус + crud.update_project_status(db, project.id, "completed", output_folder=str(output_folder)) + + # Загрузить OCR + ocr_path = output_folder / "full_ocr_results.json" + if ocr_path.exists(): + ocr = json.loads(ocr_path.read_text(encoding="utf-8")) + + # Создать страницы + for page_data in ocr.get("pages", []): + page_num = page_data["page_number"] + png_path = output_folder / f"page_{page_num:03d}.png" + + from PIL import Image + width = height = None + if png_path.exists(): + with Image.open(png_path) as img: + width, height = img.size + + page = crud.create_page( + db, + project_id=project.id, + page_number=page_num, + png_path=str(png_path) if png_path.exists() else None, + ocr_data=page_data, + width=width, + height=height + ) + print(f" [OK] Страница {page_num}: {width}x{height}") + + # Загрузить QC issues + qc_path = output_folder / "dimension_qc_report.json" + if qc_path.exists(): + qc = json.loads(qc_path.read_text(encoding="utf-8")) + + total_imported = 0 + for severity in ["errors", "warnings", "infos"]: + for item in qc.get(severity, []): + page_num = item["page"] + page = crud.get_page_by_number(db, project.id, page_num) + + # Извлечь bbox + bbox = item.get("bbox") or item.get("bbox1") or item.get("bbox_dim") + x1 = y1 = x2 = y2 = None + if bbox: + 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] + + crud.create_issue( + db, + project_id=project.id, + page_id=page.id if page else None, + issue_type=item["type"], + severity=item["severity"], + message=item["message"], + bbox_x1=x1, bbox_y1=y1, bbox_x2=x2, bbox_y2=y2, + dimension_text=item.get("text"), + confidence=item.get("confidence"), + extra_data={k: v for k, v in item.items() + if k not in ["type", "severity", "message", "page", "text", "confidence", "bbox", "bbox1", "bbox2", "bbox_dim"]} + ) + total_imported += 1 + + print(f"[OK] Импортировано замечаний: {total_imported}") + + # Загрузить VLM descriptions + vlm_path = output_folder / "vlm_descriptions.json" + if vlm_path.exists(): + vlm = json.loads(vlm_path.read_text(encoding="utf-8")) + for page in project.pages: + for item in vlm.get("descriptions", []): + if item.get("page") == page.page_number: + page.vlm_description = item.get("description") + db.commit() + break + print(f"[OK] VLM описания загружены") + + print(f"\n[INFO] Проект #{project.id} готов!") + print(f" Dashboard: http://localhost:8001/") + print(f" Viewer: http://localhost:8001/viewer/{project.id}/5") + print(f" Review: http://localhost:8001/review.html?project={project.id}") + + return project.id + + except Exception as e: + print(f"[ERR] {e}") + import traceback + traceback.print_exc() + finally: + db.close() + + +if __name__ == "__main__": + import_output_123() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..57a3226 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,6 @@ +fastapi>=0.104.0 +uvicorn[standard]>=0.24.0 +sqlalchemy>=2.0.0 +pydantic>=2.5.0 +python-multipart>=0.0.6 +pillow>=10.0.0 diff --git a/backend/static/index.html b/backend/static/index.html new file mode 100644 index 0000000..f50449a --- /dev/null +++ b/backend/static/index.html @@ -0,0 +1,329 @@ + + + + + + Blueprint QC Dashboard + + + +
+

Blueprint QC Dashboard

+ +
+
+
-
+
Проектов
+
+
+
-
+
Замечаний
+
+
+
-
+
Размечено
+
+
+
-
+
Точность
+
+
+ +
+

Перетащите PDF сюда или кликните для выбора

+

Максимальный размер: 100 МБ

+ + +
+
+
+ +

Проекты

+ + + + + + + + + + + + + +
IDНазваниеФайлСтатусСозданЗамечанийДействия
+
+ + + + diff --git a/backend/static/review.html b/backend/static/review.html new file mode 100644 index 0000000..d5b3883 --- /dev/null +++ b/backend/static/review.html @@ -0,0 +1,495 @@ + + + + + + Review Issues — Blueprint QC + + + +
+
+
+

Review Issues

+
Loading...
+
+ ← Dashboard +
+ +
+ +
0% reviewed
+
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+ + +
+ +
+ + + +