Compare commits

..

5 Commits

Author SHA1 Message Date
Кирилл Блинов
95093736da Add dimension QC, DZI generator, web viewer, and fix RAG query bug
- dimension_qc_checker.py: rules-based QC for dimension chains, overlaps, crowding
- generate_dzi.py: Deep Zoom Image tile pyramid generator for OpenSeadragon
- generate_web_viewer.py: OpenSeadragon viewer with SVG overlays and issue feedback buttons
- rag_query.py: fix LightRAG remove_think_tags crash on None response from LLM
- .gitignore: add *.pdf, *.db, backend/uploads/, backend/outputs/
2026-06-01 12:30:07 +03:00
Кирилл Блинов
eaddf9f14b Add VLM tools: Describer, QC checker, and GOST validator
- vlm_describer.py: objective extraction (beams, positions, GOSTs, dimensions) via qwen-vl-plus API. No error detection — only factual observation.
- vlm_qc_checker.py: VLM-based QC (deprecated in favor of rules-only QC)
- gost_dimension_validator.py: validate GOST references and dimension chains against known standards
2026-06-01 12:29:58 +03:00
Кирилл Блинов
f37c477a0a Add FastAPI backend with DZI viewer and feedback system
- 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
2026-06-01 12:29:41 +03:00
Кирилл Блинов
feeb02242b Add layout detection and multi-element extraction
- layout_detector.py: zone classification (drawing/table/title_block/notes) using line detection and text density analysis
- multi_element_extractor.py: extract dimensions, positions (П-1, X-1), GOST refs, steel grades, elevations, beam labels per zone
2026-06-01 12:29:32 +03:00
Кирилл Блинов
b5f7c6327e Add tiling OCR, preprocess and visualization tools
- tiling_ocr.py: split large drawings into overlapping tiles for better small-text recognition
- preprocess_for_ocr.py: CLAHE + unsharp mask for enhancing blueprint contrast
- visualize_dimensions.py: draw bounding boxes around detected dimension numbers
- compare_ocr.py: side-by-side visualization of normal vs tiling OCR results
- dimension_extractor.py: line-based dimension detection with pixel verification
- ocr_qwen.py: Alibaba Cloud qwen-vl-ocr client with resize and regex fallback parser
- test_qwen_ocr.py: standalone test for qwen OCR
- process_any_pdf.py: add --use-tiling flag to switch between normal and tiling OCR
2026-06-01 12:29:26 +03:00
32 changed files with 5937 additions and 67 deletions

10
.gitignore vendored
View File

@ -12,6 +12,16 @@ __pycache__/
# Secrets # Secrets
.env .env
# Database
*.db
*.sqlite
*.sqlite3
# PDFs and uploads
*.pdf
backend/uploads/
backend/outputs/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db

122
backend/README.md Normal file
View File

@ -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

1
backend/__init__.py Normal file
View File

@ -0,0 +1 @@
# backend/__init__.py

1
backend/app/__init__.py Normal file
View File

@ -0,0 +1 @@
# app/__init__.py

192
backend/app/crud.py Normal file
View File

@ -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

23
backend/app/database.py Normal file
View File

@ -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()

401
backend/app/main.py Normal file
View File

@ -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'<span id="totalPages">?</span>',
f'<span id="totalPages">{total_pages}</span>'
)
# Получить 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)

95
backend/app/models.py Normal file
View File

@ -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")

217
backend/app/processing.py Normal file
View File

@ -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

137
backend/app/schemas.py Normal file
View File

@ -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

223
backend/backend.log Normal file
View File

@ -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]

137
backend/import_existing.py Normal file
View File

@ -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()

6
backend/requirements.txt Normal file
View File

@ -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

329
backend/static/index.html Normal file
View File

@ -0,0 +1,329 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blueprint QC Dashboard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: #0d0d1a;
color: #eee;
min-height: 100vh;
}
.container { max-width: 1200px; margin: 0 auto; padding: 40px 20px; }
h1 { color: #e94560; margin-bottom: 30px; }
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 40px;
}
.stat-card {
background: #16213e;
border: 1px solid #0f3460;
padding: 20px;
border-radius: 8px;
}
.stat-value { font-size: 32px; font-weight: bold; color: #e94560; }
.stat-label { font-size: 13px; opacity: 0.7; margin-top: 8px; }
.upload-zone {
border: 2px dashed #0f3460;
border-radius: 12px;
padding: 60px 40px;
text-align: center;
margin-bottom: 40px;
transition: all 0.3s;
}
.upload-zone:hover, .upload-zone.dragover {
border-color: #e94560;
background: rgba(233, 69, 96, 0.05);
}
.spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid rgba(255,170,0,0.3);
border-top-color: #ffaa00;
border-radius: 50%;
animation: spin 1s linear infinite;
vertical-align: middle;
margin-right: 6px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.processing-row { animation: pulse 2s infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } }
.delete-btn {
background: transparent;
border: none;
color: #ff4444;
cursor: pointer;
font-size: 16px;
margin-left: 10px;
opacity: 0.5;
}
.delete-btn:hover { opacity: 1; }
.upload-zone input { display: none; }
.upload-btn {
background: #e94560;
color: white;
border: none;
padding: 12px 32px;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
margin-top: 15px;
}
.projects-table {
width: 100%;
border-collapse: collapse;
}
.projects-table th {
text-align: left;
padding: 15px;
border-bottom: 2px solid #0f3460;
font-size: 13px;
text-transform: uppercase;
opacity: 0.7;
}
.projects-table td {
padding: 15px;
border-bottom: 1px solid #1a1a2e;
}
.projects-table tr:hover { background: rgba(255,255,255,0.03); }
.status {
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.status-completed { background: rgba(0,200,100,0.2); color: #00c864; }
.status-processing { background: rgba(255,170,0,0.2); color: #ffaa00; }
.status-error { background: rgba(255,0,0,0.2); color: #ff4444; }
.status-uploaded { background: rgba(100,100,255,0.2); color: #8888ff; }
.view-link { color: #44aaff; text-decoration: none; }
.view-link:hover { text-decoration: underline; }
#progress-bar {
display: none;
width: 100%;
height: 4px;
background: #1a1a2e;
border-radius: 2px;
margin-top: 15px;
overflow: hidden;
}
#progress-fill {
width: 0%;
height: 100%;
background: #e94560;
transition: width 0.3s;
}
.error-msg { color: #ff4444; margin-top: 10px; font-size: 14px; }
</style>
</head>
<body>
<div class="container">
<h1>Blueprint QC Dashboard</h1>
<div class="stats" id="stats">
<div class="stat-card">
<div class="stat-value" id="stat-projects">-</div>
<div class="stat-label">Проектов</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-issues">-</div>
<div class="stat-label">Замечаний</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-feedback">-</div>
<div class="stat-label">Размечено</div>
</div>
<div class="stat-card">
<div class="stat-value" id="stat-accuracy">-</div>
<div class="stat-label">Точность</div>
</div>
</div>
<div class="upload-zone" id="uploadZone">
<h3>Перетащите PDF сюда или кликните для выбора</h3>
<p style="opacity:0.6; margin-top:10px;">Максимальный размер: 100 МБ</p>
<input type="file" id="fileInput" accept=".pdf">
<button class="upload-btn" onclick="document.getElementById('fileInput').click()">Выбрать файл</button>
<div id="progress-bar"><div id="progress-fill"></div></div>
<div id="upload-error" class="error-msg"></div>
</div>
<h2 style="margin-bottom:20px;">Проекты</h2>
<table class="projects-table" id="projectsTable">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Файл</th>
<th>Статус</th>
<th>Создан</th>
<th>Замечаний</th>
<th>Действия</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<script>
const API = '/api';
// Stats
async function loadStats() {
try {
const res = await fetch(`${API}/stats`);
const data = await res.json();
document.getElementById('stat-projects').textContent = data.total_projects;
document.getElementById('stat-issues').textContent = data.total_issues;
document.getElementById('stat-feedback').textContent = data.feedback_stats.true_positive + data.feedback_stats.false_positive;
document.getElementById('stat-accuracy').textContent = data.accuracy_estimate ? (data.accuracy_estimate * 100).toFixed(1) + '%' : '-';
} catch (e) { console.error('Stats error:', e); }
}
// Track previous statuses for detecting completion
let prevStatuses = {};
// Projects
async function loadProjects() {
try {
const res = await fetch(`${API}/projects`);
const projects = await res.json();
const tbody = document.querySelector('#projectsTable tbody');
// Check for newly completed projects
projects.forEach(p => {
if (prevStatuses[p.id] === 'processing' && p.status === 'completed') {
showToast(`Project "${p.name || p.pdf_filename}" обработан!`, 'success');
}
prevStatuses[p.id] = p.status;
});
tbody.innerHTML = projects.map(p => {
const isProcessing = p.status === 'processing' || p.status === 'uploaded';
const statusHtml = isProcessing
? `<span class="status status-${p.status}"><span class="spinner"></span>${p.status}</span>`
: `<span class="status status-${p.status}">${p.status}</span>`;
let actions;
if (p.status === 'completed') {
actions = `<a href="/viewer/${p.id}/1" class="view-link" target="_blank">Viewer</a> | <a href="/review.html?project=${p.id}" class="view-link">Review</a> <button class="delete-btn" onclick="deleteProject(${p.id})" title="Удалить">🗑</button>`;
} else if (p.status === 'processing') {
actions = '<span style="opacity:0.5"><span class="spinner"></span> Обработка...</span>';
} else {
actions = `
<label style="font-size:11px; margin-right:6px; cursor:pointer;">
<input type="checkbox" id="tiling-${p.id}" style="vertical-align:middle;"> tiling
</label>
<button class="upload-btn" style="padding:4px 12px; font-size:12px;" onclick="analyzeProject(${p.id})">🔬 Анализировать</button>
<button class="delete-btn" onclick="deleteProject(${p.id})" title="Удалить">🗑</button>
`;
}
return `
<tr class="${isProcessing ? 'processing-row' : ''}">
<td>${p.id}</td>
<td>${p.name || '-'}</td>
<td>${p.pdf_filename}</td>
<td>${statusHtml}</td>
<td>${new Date(p.created_at).toLocaleString()}</td>
<td>${p.issues ? p.issues.length : (isProcessing ? '<span class="spinner"></span>' : '0')}</td>
<td>${actions}</td>
</tr>
`}).join('');
} catch (e) { console.error('Projects error:', e); }
}
async function deleteProject(id) {
if (!confirm('Удалить проект?')) return;
try {
const res = await fetch(`${API}/projects/${id}`, { method: 'DELETE' });
if (res.ok) {
loadProjects();
loadStats();
showToast('Проект удалён', 'success');
}
} catch(e) { showToast('Ошибка удаления', 'error'); }
}
// Upload
const uploadZone = document.getElementById('uploadZone');
const fileInput = document.getElementById('fileInput');
const progressBar = document.getElementById('progress-bar');
const progressFill = document.getElementById('progress-fill');
const uploadError = document.getElementById('upload-error');
uploadZone.addEventListener('dragover', (e) => { e.preventDefault(); uploadZone.classList.add('dragover'); });
uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('dragover'));
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('dragover');
if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]);
});
fileInput.addEventListener('change', (e) => { if (e.target.files.length) handleFile(e.target.files[0]); });
async function analyzeProject(id) {
try {
const useTiling = document.getElementById(`tiling-${id}`)?.checked || false;
const url = useTiling ? `${API}/projects/${id}/analyze?use_tiling=true` : `${API}/projects/${id}/analyze`;
const res = await fetch(url, { method: 'POST' });
if (res.ok) {
const data = await res.json();
showToast(`Анализ запущен (${data.ocr_engine})!`, 'success');
loadProjects();
} else {
const err = await res.text();
showToast('Ошибка запуска: ' + err, 'error');
}
} catch(e) { showToast('Ошибка сети', 'error'); }
}
async function handleFile(file) {
if (!file.name.endsWith('.pdf')) {
uploadError.textContent = 'Только PDF файлы';
return;
}
uploadError.textContent = '';
progressBar.style.display = 'block';
progressFill.style.width = '30%';
const form = new FormData();
form.append('file', file);
form.append('name', file.name);
try {
const res = await fetch(`${API}/projects/upload`, { method: 'POST', body: form });
progressFill.style.width = '100%';
if (res.ok) {
setTimeout(() => { progressBar.style.display = 'none'; progressFill.style.width = '0%'; loadProjects(); loadStats(); }, 500);
} else {
uploadError.textContent = 'Ошибка загрузки: ' + res.statusText;
progressBar.style.display = 'none';
}
} catch (e) {
uploadError.textContent = 'Ошибка сети';
progressBar.style.display = 'none';
}
}
// Toast notification
function showToast(msg, type) {
const toast = document.createElement('div');
toast.style.cssText = `position:fixed; bottom:20px; right:20px; background:rgba(0,0,0,0.95); color:white; padding:14px 24px; border-radius:8px; border:1px solid ${type==='success'?'#00c864':'#ff4444'}; z-index:100000; font-size:13px; box-shadow:0 4px 20px rgba(0,0,0,0.5);`;
toast.textContent = msg;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 4000);
}
// Init
loadStats();
loadProjects();
setInterval(() => { loadProjects(); loadStats(); }, 5000); // Обновление каждые 5 сек
</script>
</body>
</html>

495
backend/static/review.html Normal file
View File

@ -0,0 +1,495 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Review Issues — Blueprint QC</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: #0d0d1a;
color: #eee;
min-height: 100vh;
}
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #0f3460;
}
h1 { color: #e94560; }
.project-info { opacity: 0.7; font-size: 14px; }
.filters {
display: flex;
gap: 15px;
margin-bottom: 25px;
flex-wrap: wrap;
align-items: center;
}
.filter-group {
display: flex;
gap: 8px;
align-items: center;
}
.filter-group label { font-size: 13px; opacity: 0.8; }
.filter-group select, .filter-group input {
background: #1a1a2e;
border: 1px solid #0f3460;
color: #eee;
padding: 6px 12px;
border-radius: 4px;
font-size: 13px;
}
.filter-btn {
background: #e94560;
border: none;
color: white;
padding: 6px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
}
.filter-btn.secondary {
background: #1a1a2e;
border: 1px solid #0f3460;
}
.progress-bar {
width: 100%;
height: 6px;
background: #1a1a2e;
border-radius: 3px;
margin-bottom: 25px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #e94560, #ffaa00);
transition: width 0.5s;
}
.progress-text {
font-size: 12px;
opacity: 0.6;
margin-bottom: 8px;
}
.issues-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 15px;
}
.issue-card {
background: #16213e;
border-left: 4px solid var(--card-color, #888);
border-radius: 8px;
padding: 15px;
transition: all 0.2s;
position: relative;
}
.issue-card:hover {
background: #1a2847;
transform: translateY(-2px);
}
.issue-card.reviewed-tp { border-left-color: #00c864; }
.issue-card.reviewed-fp { opacity: 0.6; }
.issue-card.reviewed-fp .issue-text { text-decoration: line-through; }
.issue-card.reviewed-ns { border-left-color: #888; }
.issue-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.issue-id {
font-size: 11px;
background: rgba(255,255,255,0.1);
padding: 2px 8px;
border-radius: 10px;
}
.issue-type {
font-size: 11px;
text-transform: uppercase;
opacity: 0.7;
}
.issue-severity {
font-size: 10px;
padding: 2px 8px;
border-radius: 10px;
font-weight: bold;
}
.severity-error { background: rgba(255,0,0,0.2); color: #ff4444; }
.severity-warning { background: rgba(255,170,0,0.2); color: #ffaa00; }
.severity-info { background: rgba(0,150,255,0.2); color: #44aaff; }
.issue-text {
font-size: 13px;
line-height: 1.5;
margin-bottom: 10px;
}
.issue-meta {
font-size: 11px;
opacity: 0.6;
margin-bottom: 12px;
}
.issue-meta span { margin-right: 15px; }
.feedback-bar {
display: flex;
gap: 8px;
}
.feedback-bar button {
flex: 1;
padding: 8px;
border: 1px solid #333;
background: #0d0d1a;
color: #ccc;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
.feedback-bar button:hover {
background: #1a1a2e;
color: #fff;
}
.feedback-bar button.tp:hover { background: rgba(0,200,100,0.2); border-color: #00c864; color: #00c864; }
.feedback-bar button.fp:hover { background: rgba(255,0,0,0.2); border-color: #ff4444; color: #ff4444; }
.feedback-bar button.ns:hover { background: rgba(150,150,150,0.2); border-color: #888; color: #888; }
.feedback-bar button:disabled { opacity: 0.5; cursor: not-allowed; }
.reviewed-badge {
position: absolute;
top: 10px;
right: 10px;
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: bold;
}
.badge-tp { background: rgba(0,200,100,0.2); color: #00c864; }
.badge-fp { background: rgba(255,0,0,0.2); color: #ff4444; }
.badge-ns { background: rgba(150,150,150,0.2); color: #888; }
.empty-state {
text-align: center;
padding: 80px 20px;
opacity: 0.5;
}
.empty-state h2 { margin-bottom: 15px; }
.viewer-link {
color: #44aaff;
text-decoration: none;
font-size: 12px;
}
.viewer-link:hover { text-decoration: underline; }
#toast {
position: fixed;
bottom: 20px;
right: 20px;
background: rgba(0,0,0,0.95);
color: white;
padding: 14px 24px;
border-radius: 8px;
border: 1px solid #e94560;
z-index: 100000;
display: none;
font-size: 13px;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
}
.stats-row {
display: flex;
gap: 20px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.stat-pill {
background: #1a1a2e;
padding: 8px 16px;
border-radius: 20px;
font-size: 13px;
}
.stat-pill .num { font-weight: bold; color: #e94560; }
</style>
</head>
<body>
<div class="container">
<header>
<div>
<h1>Review Issues</h1>
<div class="project-info" id="projectInfo">Loading...</div>
</div>
<a href="/" style="color:#44aaff; text-decoration:none;">← Dashboard</a>
</header>
<div class="stats-row" id="statsRow"></div>
<div class="progress-text" id="progressText">0% reviewed</div>
<div class="progress-bar">
<div class="progress-fill" id="progressFill" style="width:0%"></div>
</div>
<div class="filters">
<div class="filter-group">
<label>Project:</label>
<select id="projectSelect"><option value="">Select...</option></select>
</div>
<div class="filter-group">
<label>Severity:</label>
<select id="severityFilter">
<option value="all">All</option>
<option value="error">Error</option>
<option value="warning">Warning</option>
<option value="info">Info</option>
</select>
</div>
<div class="filter-group">
<label>Type:</label>
<select id="typeFilter"><option value="all">All</option></select>
</div>
<div class="filter-group">
<label>Status:</label>
<select id="statusFilter">
<option value="all">All</option>
<option value="unreviewed">Unreviewed</option>
<option value="reviewed">Reviewed</option>
</select>
</div>
<button class="filter-btn" onclick="loadIssues()">Apply</button>
<button class="filter-btn secondary" onclick="exportTrainingData()">📥 Export Training Data</button>
</div>
<div class="issues-grid" id="issuesGrid"></div>
<div class="empty-state" id="emptyState" style="display:none;">
<h2>No issues found</h2>
<p>Try adjusting filters or upload a new PDF.</p>
</div>
</div>
<div id="toast"></div>
<script>
const API = '/api';
let currentProjectId = null;
let allIssues = [];
let allProjects = [];
// Init
async function init() {
await loadProjects();
// Check URL param
const urlParams = new URLSearchParams(window.location.search);
const pid = urlParams.get('project');
if (pid) {
document.getElementById('projectSelect').value = pid;
await selectProject(pid);
}
}
async function loadProjects() {
try {
const res = await fetch(`${API}/projects`);
allProjects = await res.json();
const select = document.getElementById('projectSelect');
select.innerHTML = '<option value="">Select project...</option>' +
allProjects.map(p => `<option value="${p.id}">${p.name || p.pdf_filename} (#${p.id})</option>`).join('');
select.addEventListener('change', (e) => selectProject(e.target.value));
} catch(e) { console.error(e); }
}
async function selectProject(pid) {
if (!pid) return;
currentProjectId = pid;
const project = allProjects.find(p => p.id == pid);
if (project) {
document.getElementById('projectInfo').textContent =
`${project.name || project.pdf_filename} | Status: ${project.status}`;
}
await loadIssues();
}
async function loadIssues() {
if (!currentProjectId) {
showToast('Select a project first', 'warning');
return;
}
const severity = document.getElementById('severityFilter').value;
const typeFilter = document.getElementById('typeFilter').value;
const statusFilter = document.getElementById('statusFilter').value;
try {
let url = `${API}/projects/${currentProjectId}/issues`;
const params = [];
if (severity !== 'all') params.push(`severity=${severity}`);
if (typeFilter !== 'all') params.push(`issue_type=${typeFilter}`);
if (statusFilter === 'reviewed') params.push(`has_feedback=true`);
if (statusFilter === 'unreviewed') params.push(`has_feedback=false`);
if (params.length) url += '?' + params.join('&');
const res = await fetch(url);
allIssues = await res.json();
renderIssues();
updateStats();
updateTypeFilter();
} catch(e) { showToast('Error loading issues: ' + e.message, 'error'); }
}
function renderIssues() {
const grid = document.getElementById('issuesGrid');
const empty = document.getElementById('emptyState');
if (!allIssues.length) {
grid.innerHTML = '';
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
grid.innerHTML = allIssues.map(issue => {
const color = issue.severity === 'error' ? '#ff4444' : (issue.severity === 'warning' ? '#ffaa00' : '#44aaff');
const reviewedClass = issue.feedback ?
(issue.feedback.is_true_positive === true ? 'reviewed-tp' :
(issue.feedback.is_true_positive === false ? 'reviewed-fp' : 'reviewed-ns')) : '';
const badge = issue.feedback ?
(issue.feedback.is_true_positive === true ? '<span class="reviewed-badge badge-tp">✅ TP</span>' :
(issue.feedback.is_true_positive === false ? '<span class="reviewed-badge badge-fp">❌ FP</span>' :
'<span class="reviewed-badge badge-ns">🤷 NS</span>')) : '';
return `
<div class="issue-card ${reviewedClass}" style="--card-color:${color}" data-issue-id="${issue.id}">
${badge}
<div class="issue-header">
<span class="issue-id">#${issue.id}</span>
<span class="issue-severity severity-${issue.severity}">${issue.severity}</span>
</div>
<div class="issue-type">${issue.issue_type}</div>
<div class="issue-text">${issue.message}</div>
<div class="issue-meta">
<span>Page ${issue.page_number || '?'}</span>
<span>Conf: ${issue.confidence ? issue.confidence.toFixed(2) : 'N/A'}</span>
${issue.dimension_text ? `<span>Dim: "${issue.dimension_text}"</span>` : ''}
<a href="/viewer/${issue.project_id}/${issue.page_number || 1}" class="viewer-link" target="_blank">Open Viewer →</a>
</div>
<div class="feedback-bar">
<button class="tp" onclick="submitFeedback(${issue.id}, true, this)" ${issue.feedback ? 'disabled' : ''}>✅ Real Issue</button>
<button class="fp" onclick="submitFeedback(${issue.id}, false, this)" ${issue.feedback ? 'disabled' : ''}>❌ False Positive</button>
<button class="ns" onclick="submitFeedback(${issue.id}, null, this)" ${issue.feedback ? 'disabled' : ''}>🤷 Not Sure</button>
</div>
</div>
`;
}).join('');
}
async function submitFeedback(issueId, isTP, btn) {
const card = btn.closest('.issue-card');
const btns = card.querySelectorAll('.feedback-bar button');
btns.forEach(b => b.disabled = true);
btn.textContent = '...';
try {
const res = await fetch(`${API}/feedback`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
issue_id: issueId,
is_true_positive: isTP,
action_taken: isTP === true ? 'fixed' : (isTP === false ? 'ignored' : 'not_sure')
})
});
if (res.ok) {
// Update the issue in memory
const issue = allIssues.find(i => i.id === issueId);
if (issue) {
issue.feedback = { is_true_positive: isTP };
}
renderIssues();
updateStats();
showToast('Saved!', 'success');
} else {
const err = await res.text();
showToast('Error: ' + err, 'error');
btns.forEach(b => b.disabled = false);
btn.textContent = isTP === true ? '✅ Real Issue' : (isTP === false ? '❌ False Positive' : '🤷 Not Sure');
}
} catch(e) {
showToast('Network error: ' + e.message, 'error');
btns.forEach(b => b.disabled = false);
btn.textContent = isTP === true ? '✅ Real Issue' : (isTP === false ? '❌ False Positive' : '🤷 Not Sure');
}
}
function updateStats() {
const total = allIssues.length;
const reviewed = allIssues.filter(i => i.feedback).length;
const tp = allIssues.filter(i => i.feedback && i.feedback.is_true_positive === true).length;
const fp = allIssues.filter(i => i.feedback && i.feedback.is_true_positive === false).length;
const pct = total ? Math.round((reviewed / total) * 100) : 0;
document.getElementById('progressFill').style.width = pct + '%';
document.getElementById('progressText').textContent =
`${pct}% reviewed (${reviewed}/${total}) | ✅ TP: ${tp} | ❌ FP: ${fp}`;
document.getElementById('statsRow').innerHTML = `
<div class="stat-pill">Total: <span class="num">${total}</span></div>
<div class="stat-pill">Reviewed: <span class="num">${reviewed}</span></div>
<div class="stat-pill">True Positive: <span class="num" style="color:#00c864">${tp}</span></div>
<div class="stat-pill">False Positive: <span class="num" style="color:#ff4444">${fp}</span></div>
`;
}
function updateTypeFilter() {
const types = [...new Set(allIssues.map(i => i.issue_type))];
const select = document.getElementById('typeFilter');
const current = select.value;
select.innerHTML = '<option value="all">All</option>' +
types.map(t => `<option value="${t}">${t}</option>`).join('');
if (types.includes(current)) select.value = current;
}
async function exportTrainingData() {
if (!currentProjectId) {
showToast('Select a project first', 'warning');
return;
}
try {
const res = await fetch(`${API}/training/export?project_id=${currentProjectId}&only_labeled=true`);
const data = await res.json();
// Download as file
const blob = new Blob([JSON.stringify(data, null, 2)], {type: 'application/json'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `training_data_project_${currentProjectId}_${new Date().toISOString().slice(0,10)}.json`;
a.click();
URL.revokeObjectURL(url);
showToast(`Exported ${data.total_samples} samples!`, 'success');
} catch(e) {
showToast('Export error: ' + e.message, 'error');
}
}
function showToast(msg, type) {
const toast = document.getElementById('toast');
toast.textContent = msg;
toast.style.borderColor = type === 'success' ? '#00c864' : (type === 'error' ? '#ff4444' : '#ffaa00');
toast.style.display = 'block';
setTimeout(() => toast.style.display = 'none', 3000);
}
init();
</script>
</body>
</html>

160
compare_ocr.py Normal file
View File

@ -0,0 +1,160 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Визуализация сравнения: обычный OCR vs tiling OCR.
Рисует bbox зелёным (только обычный), красным (только tiling), жёлтым (оба).
"""
import sys
import json
import re
from pathlib import Path
from PIL import Image, ImageDraw
def load_ocr(path: Path):
"""Загружает OCR lines из JSON."""
data = json.loads(path.read_text(encoding="utf-8"))
if "lines" in data:
return data["lines"]
if "pages" in data:
lines = []
for page in data["pages"]:
lines.extend(page.get("ocr_lines", []))
return lines
return []
def bbox_center(box):
if isinstance(box[0], list):
xs = [p[0] for p in box]
ys = [p[1] for p in box]
else:
xs = [box[0], box[2]]
ys = [box[1], box[3]]
return sum(xs)/len(xs), sum(ys)/len(ys)
def bbox_rect(box):
if isinstance(box[0], list):
xs = [p[0] for p in box]
ys = [p[1] for p in box]
else:
xs = [box[0], box[2]]
ys = [box[1], box[3]]
return min(xs), min(ys), max(xs), max(ys)
def find_matches(text: str, list_b, iou_thresh=0.3):
"""Находит ближайший совпадающий bbox в list_b по IoU и тексту."""
matches = []
for b in list_b:
if b["text"].strip() != text.strip():
continue
# IoU
ax1, ay1, ax2, ay2 = bbox_rect(a["bbox"] if 'a' in dir() else None)
# ... (упрощённо: сравниваем по центру)
return matches
def visualize_comparison(png_path: Path, normal_ocr_path: Path, tiling_ocr_path: Path, out_path: Path):
"""Рисует сравнение."""
img = Image.open(png_path)
draw = ImageDraw.Draw(img)
normal = load_ocr(normal_ocr_path)
tiling = load_ocr(tiling_ocr_path)
# Индексы для быстрого поиска
normal_by_text = {}
for n in normal:
txt = n["text"].strip()
if re.match(r'^\d+([,.]\d+)?$', txt):
normal_by_text.setdefault(txt, []).append(n)
tiling_by_text = {}
for t in tiling:
txt = t["text"].strip()
if re.match(r'^\d+([,.]\d+)?$', txt):
tiling_by_text.setdefault(txt, []).append(t)
# Классификация
only_normal = [] # зелёный
only_tiling = [] # красный
both = [] # жёлтый
all_texts = set(normal_by_text.keys()) | set(tiling_by_text.keys())
for txt in all_texts:
n_list = normal_by_text.get(txt, [])
t_list = tiling_by_text.get(txt, [])
# Сопоставляем по минимальному расстоянию центров
used_t = set()
for n in n_list:
cx_n, cy_n = bbox_center(n["bbox"])
best = None
best_dist = float('inf')
for i, t in enumerate(t_list):
if i in used_t:
continue
cx_t, cy_t = bbox_center(t["bbox"])
d = ((cx_n - cx_t)**2 + (cy_n - cy_t)**2)**0.5
if d < best_dist:
best_dist = d
best = i
if best is not None and best_dist < 100: # совпадение
both.append((n, t_list[best]))
used_t.add(best)
else:
only_normal.append(n)
for i, t in enumerate(t_list):
if i not in used_t:
only_tiling.append(t)
# Рисуем
for item in only_normal:
x1, y1, x2, y2 = bbox_rect(item["bbox"])
draw.rectangle([x1, y1, x2, y2], outline="green", width=3)
for item in only_tiling:
x1, y1, x2, y2 = bbox_rect(item["bbox"])
draw.rectangle([x1, y1, x2, y2], outline="red", width=3)
cx, cy = bbox_center(item["bbox"])
draw.text((cx, cy-15), item["text"], fill="red")
for n, t in both:
# Используем bbox из tiling (крупнее)
x1, y1, x2, y2 = bbox_rect(t["bbox"])
draw.rectangle([x1, y1, x2, y2], outline="yellow", width=2)
img.save(out_path)
print(f"[OK] Сохранено: {out_path}")
print(f" Только обычный (зелёный): {len(only_normal)}")
print(f" Только tiling (красный): {len(only_tiling)}")
print(f" Оба (жёлтый): {len(both)}")
# Вывод новых чисел
print(f"\nНовые числа от tiling OCR:")
for item in sorted(only_tiling, key=lambda x: x["bbox"][0][1]):
cx, cy = bbox_center(item["bbox"])
print(f" {item['text']:>10} x={cx:>8.0f} y={cy:>8.0f}")
def main():
if len(sys.argv) < 4:
print("Usage: python compare_ocr.py <png> <normal_ocr.json> <tiling_ocr.json>")
sys.exit(1)
png = Path(sys.argv[1])
normal = Path(sys.argv[2])
tiling = Path(sys.argv[3])
out = png.parent / f"{png.stem}_ocr_compare.png"
visualize_comparison(png, normal, tiling, out)
if __name__ == "__main__":
main()

193
dimension_extractor.py Normal file
View File

@ -0,0 +1,193 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Локальный детектор размеров на чертеже.
Подход:
1. Находим линии на PNG (Canny + HoughLinesP) только горизонтальные/вертикальные
2. Загружаем OCR результаты, фильтруем только числа (regex ^\d+([,.]\d+)?$)
3. Для каждого числа проверяем: есть ли линия в радиусе 60px?
4. Если да считаем это размером
5. Визуализируем результат
Результат: dimensions.json + *_dims_detected.png
"""
import sys
import json
import math
import re
from pathlib import Path
from typing import List, Dict, Tuple
import cv2
import numpy as np
from PIL import Image, ImageDraw
def find_numbers_with_context(ocr_path: Path, png_path: Path) -> Tuple[List[Dict], List[Dict]]:
"""
Находит размеры через анализ контекста:
1. Берём все числа из OCR
2. Ищем "соседей" на той же горизонтали/вертикали (размерные цепочки)
3. Исключаем числа из таблиц (по bbox: справа на странице)
4. Проверяем пиксели между числами: есть ли линия?
"""
print(f"[INFO] Обработка {png_path.name}...")
ocr = json.loads(ocr_path.read_text(encoding="utf-8"))
img = cv2.imread(str(png_path), cv2.IMREAD_GRAYSCALE)
h, w = img.shape[:2]
numbers = []
for page in ocr.get("pages", []):
for line_data in page.get("ocr_lines", []):
txt = line_data["text"].strip()
if not re.match(r'^\d+([,.]\d+)?$', txt):
continue
bbox = line_data.get("bbox")
if not bbox:
continue
if isinstance(bbox[0], list):
xs = [p[0] for p in bbox]
ys = [p[1] for p in bbox]
else:
xs = [bbox[0], bbox[2]]
ys = [bbox[1], bbox[3]]
cx = sum(xs) / len(xs)
cy = sum(ys) / len(ys)
# Определяем границы
x1, y1, x2, y2 = min(xs), min(ys), max(xs), max(ys)
numbers.append({
"text": txt,
"bbox": bbox,
"x1": x1, "y1": y1, "x2": x2, "y2": y2,
"cx": cx, "cy": cy,
"page": page["page_number"]
})
print(f"[INFO] Всего чисел: {len(numbers)}")
# Фильтр 1: исключаем числа из правой части таблиц (x > 0.5w и y > 0.1h)
# Это эвристика для данного чертежа
filtered = [n for n in numbers if not (n["x1"] > w * 0.55 and n["y1"] > h * 0.05)]
print(f"[INFO] После фильтра таблиц: {len(filtered)}")
# Фильтр 2: ищем "пары" чисел на одной горизонтали (±15px по Y)
# Если между числами есть линия — это размерная цепочка
dimensions = []
used = set()
for i, a in enumerate(filtered):
if i in used:
continue
# Ищем соседей на той же Y
neighbors = []
for j, b in enumerate(filtered):
if i == j or j in used:
continue
# Сравниваем Y (горизонтальная линия) или X (вертикальная)
dy = abs(a["cy"] - b["cy"])
dx = abs(a["cx"] - b["cx"])
if dy < 20 and dx > 30 and dx < 600:
# Проверяем, есть ли между ними тёмная линия
y_check = int((a["cy"] + b["cy"]) / 2)
x_start = min(int(a["cx"]), int(b["cx"]))
x_end = max(int(a["cx"]), int(b["cx"]))
line_px = img[y_check, x_start:x_end]
dark_ratio = np.sum(line_px < 200) / len(line_px) if len(line_px) > 0 else 0
if dark_ratio > 0.3: # >30% тёмных пикселей
neighbors.append((j, b, dx, "horizontal"))
# Ищем вертикальных соседей
for j, b in enumerate(filtered):
if i == j or j in used:
continue
dx = abs(a["cx"] - b["cx"])
dy = abs(a["cy"] - b["cy"])
if dx < 20 and dy > 30 and dy < 600:
x_check = int((a["cx"] + b["cx"]) / 2)
y_start = min(int(a["cy"]), int(b["cy"]))
y_end = max(int(a["cy"]), int(b["cy"]))
line_px = img[y_start:y_end, x_check]
dark_ratio = np.sum(line_px < 200) / len(line_px) if len(line_px) > 0 else 0
if dark_ratio > 0.3:
neighbors.append((j, b, dy, "vertical"))
if neighbors:
# Берём ближайшего соседа
neighbors.sort(key=lambda x: x[2])
j, b, dist, orient = neighbors[0]
dimensions.append({
"text": a["text"],
"bbox": a["bbox"],
"neighbor_text": b["text"],
"distance": int(dist),
"orientation": orient,
"page": a["page"]
})
used.add(i)
used.add(j)
# Одиночные числа — это скорее всего массы/количества из таблиц, игнорируем
print(f"[INFO] Размеров найдено: {len(dimensions)}")
return numbers, dimensions
def visualize(png_path: Path, all_numbers: List[Dict], dimensions: List[Dict], out_path: Path):
"""Рисует визуализацию: размеры — красные, остальные числа — синие."""
img = Image.open(png_path)
draw = ImageDraw.Draw(img)
# Все числа (синие)
dim_texts = {d["text"] for d in dimensions}
for num in all_numbers:
bbox = num["bbox"]
if isinstance(bbox[0], list):
pts = [(p[0], p[1]) for p in bbox]
else:
pts = [(bbox[0], bbox[1]), (bbox[2], bbox[1]), (bbox[2], bbox[3]), (bbox[0], bbox[3])]
color = "red" if num["text"] in dim_texts else "blue"
width = 3 if num["text"] in dim_texts else 1
draw.polygon(pts, outline=color, width=width)
if num["text"] in dim_texts:
x = min(p[0] for p in pts)
y = min(p[1] for p in pts)
draw.text((x, y-15), num["text"], fill="red")
img.save(out_path)
print(f"[OK] Визуализация сохранена: {out_path}")
def main():
if len(sys.argv) < 3:
print("Usage: python dimension_extractor.py <png> <ocr_json>")
sys.exit(1)
png_path = Path(sys.argv[1])
ocr_path = Path(sys.argv[2])
out_json = png_path.parent / "dimensions.json"
out_png = png_path.parent / f"{png_path.stem}_dims_detected.png"
all_numbers, dimensions = find_numbers_with_context(ocr_path, png_path)
with open(out_json, "w", encoding="utf-8") as f:
json.dump({
"dimensions": dimensions,
"stats": {
"total_numbers": len(all_numbers),
"dimensions_found": len(dimensions)
}
}, f, ensure_ascii=False, indent=2)
print(f"[OK] Результаты сохранены: {out_json}")
visualize(png_path, all_numbers, dimensions, out_png)
print("\nНайденные размеры:")
for d in dimensions:
neighbor = f"{d['neighbor_text']}" if d['neighbor_text'] else ""
print(f" {d['text']}{neighbor}")
if __name__ == "__main__":
main()

349
dimension_qc_checker.py Normal file
View File

@ -0,0 +1,349 @@
#!/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()

114
generate_dzi.py Normal file
View File

@ -0,0 +1,114 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Генератор Deep Zoom Image (DZI) тайлов из PNG для быстрого просмотра больших чертежей.
Использует PIL не требует внешних зависимостей.
Использование:
python generate_dzi.py <png_file> [--tile-size 256] [--format png]
Результат:
<png_stem>.dzi XML-дескриптор
<png_stem>_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'''<?xml version="1.0" encoding="UTF-8"?>
<Image xmlns="http://schemas.microsoft.com/deepzoom/2008"
Format="{fmt}"
Overlap="0"
TileSize="{tile_size}">
<Size Width="{orig_w}"
Height="{orig_h}"/>
</Image>'''
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 <png_file> [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()

857
generate_web_viewer.py Normal file
View File

@ -0,0 +1,857 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Генерация HTML-viewer с OpenSeadragon + DZI для просмотра замечаний QC прямо на чертеже.
Использование:
python generate_web_viewer.py <output_folder> [--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 = "<div style='color:#ffaa00; padding:10px;'>⚠ DZI не найден — используется прямое PNG (медленно для больших чертежей)</div>"
# Генерация HTML
html = f'''<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>QC Viewer Страница {target_page}</title>
<script src="https://cdn.jsdelivr.net/npm/openseadragon@4.1/build/openseadragon/openseadragon.min.js"></script>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
font-family: 'Segoe UI', system-ui, sans-serif;
background: #0d0d1a;
color: #eee;
overflow: hidden;
height: 100vh;
}}
.layout {{
display: flex;
height: 100vh;
}}
.viewer-area {{
flex: 1;
position: relative;
background: #080810;
}}
#openseadragon {{
width: 100%;
height: 100%;
}}
.overlay-rect {{
border: 2px solid var(--oc-color);
background: var(--oc-color);
opacity: 0.15;
cursor: pointer;
transition: opacity 0.2s, border-width 0.2s;
position: relative;
}}
.overlay-rect:hover {{
opacity: 0.5;
border-width: 3px;
z-index: 100;
}}
.overlay-number {{
position: absolute;
top: -18px;
left: 0;
color: var(--oc-color);
font-size: 13px;
font-weight: bold;
text-shadow: 1px 1px 2px rgba(0,0,0,0.8);
pointer-events: none;
}}
.sidebar {{
width: 400px;
background: #16213e;
border-left: 2px solid #0f3460;
display: flex;
flex-direction: column;
overflow: hidden;
}}
.sidebar-header {{
padding: 20px;
border-bottom: 2px solid #0f3460;
}}
.sidebar-header h2 {{
margin: 0 0 15px 0;
color: #e94560;
font-size: 18px;
}}
.stats {{
display: flex;
gap: 10px;
}}
.stat {{
flex: 1;
background: #1a1a2e;
padding: 10px;
border-radius: 6px;
text-align: center;
}}
.stat-value {{
font-size: 22px;
font-weight: bold;
}}
.stat.error .stat-value {{ color: #ff4444; }}
.stat.warning .stat-value {{ color: #ffaa00; }}
.stat.info .stat-value {{ color: #44aaff; }}
.stat-label {{
font-size: 11px;
opacity: 0.7;
margin-top: 4px;
}}
.legend {{
display: flex;
gap: 15px;
margin-top: 12px;
font-size: 12px;
}}
.legend-item {{
display: flex;
align-items: center;
gap: 6px;
}}
.legend-dot {{
width: 12px;
height: 12px;
border-radius: 3px;
}}
.issue-list {{
flex: 1;
overflow-y: auto;
padding: 15px;
}}
.issue-card {{
background: #1a1a2e;
border-left: 4px solid var(--card-color);
padding: 12px;
margin-bottom: 10px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}}
.issue-card:hover {{
background: #252540;
transform: translateX(4px);
}}
.issue-card.active {{
background: #2a2a50;
box-shadow: 0 0 0 2px var(--card-color);
}}
.issue-num {{
display: inline-block;
background: var(--card-color);
color: #000;
font-size: 11px;
font-weight: bold;
padding: 2px 8px;
border-radius: 10px;
margin-right: 8px;
}}
.issue-type {{
font-size: 11px;
text-transform: uppercase;
opacity: 0.6;
margin-bottom: 6px;
}}
.issue-message {{
font-size: 13px;
line-height: 1.4;
}}
.issue-severity {{
font-size: 10px;
opacity: 0.5;
margin-top: 6px;
text-transform: uppercase;
}}
.feedback-bar {{
display: flex;
gap: 6px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(255,255,255,0.1);
}}
.feedback-bar button {{
flex: 1;
padding: 4px 8px;
border: none;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
transition: all 0.2s;
background: #1a1a2e;
color: #ccc;
border: 1px solid #333;
}}
.feedback-bar button:hover {{
background: #252540;
color: #fff;
}}
.feedback-bar button.tp:hover {{ background: rgba(0,200,100,0.2); border-color: #00c864; color: #00c864; }}
.feedback-bar button.fp:hover {{ background: rgba(255,0,0,0.2); border-color: #ff4444; color: #ff4444; }}
.feedback-bar button.ns:hover {{ background: rgba(150,150,150,0.2); border-color: #888; color: #888; }}
.issue-card.feedback-tp {{ border-left-color: #00c864 !important; box-shadow: 0 0 0 2px rgba(0,200,100,0.3); }}
.issue-card.feedback-fp {{ opacity: 0.5; }}
.issue-card.feedback-fp .issue-message {{ text-decoration: line-through; }}
.feedback-status {{
font-size: 11px;
margin-top: 6px;
font-weight: bold;
}}
.feedback-status.tp {{ color: #00c864; }}
.feedback-status.fp {{ color: #ff4444; }}
.feedback-status.ns {{ color: #888; }}
#toast {{
position: fixed;
bottom: 20px;
right: 20px;
background: rgba(0,0,0,0.9);
color: white;
padding: 12px 20px;
border-radius: 8px;
border: 1px solid #e94560;
z-index: 100000;
display: none;
font-size: 13px;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
}}
#tooltip {{
position: fixed;
background: rgba(0,0,0,0.92);
color: white;
padding: 12px 16px;
border-radius: 8px;
font-size: 13px;
max-width: 350px;
pointer-events: none;
z-index: 10000;
display: none;
border: 1px solid #e94560;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
}}
.controls {{
position: absolute;
top: 15px;
left: 15px;
z-index: 1000;
background: rgba(22,33,62,0.95);
padding: 10px;
border-radius: 8px;
border: 1px solid #0f3460;
}}
.controls button {{
background: #1a1a2e;
border: 1px solid #0f3460;
color: #eee;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
margin-right: 5px;
}}
.controls button:hover {{
background: #252540;
}}
.controls button.active {{
background: #e94560;
border-color: #e94560;
}}
.page-nav {{
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
background: rgba(22,33,62,0.95);
padding: 10px 20px;
border-radius: 30px;
border: 1px solid #0f3460;
display: flex;
align-items: center;
gap: 15px;
box-shadow: 0 4px 20px rgba(0,0,0,0.5);
}}
.page-nav button {{
background: #1a1a2e;
border: 1px solid #0f3460;
color: #eee;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}}
.page-nav button:hover:not(:disabled) {{
background: #252540;
}}
.page-nav button:disabled {{
opacity: 0.3;
cursor: not-allowed;
}}
.page-nav select {{
background: #1a1a2e;
border: 1px solid #0f3460;
color: #eee;
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}}
.page-nav .page-info {{
font-size: 13px;
opacity: 0.8;
min-width: 100px;
text-align: center;
}}
.page-nav .back-link {{
color: #44aaff;
text-decoration: none;
font-size: 12px;
margin-left: 10px;
}}
{dzi_note}
</style>
</head>
<body>
<div class="layout">
<div class="viewer-area">
<div class="controls">
<button id="btn-all" class="active" onclick="filterIssues('all')">Все</button>
<button id="btn-error" onclick="filterIssues('error')">Ошибки</button>
<button id="btn-warning" onclick="filterIssues('warning')">Предупр.</button>
<button id="btn-info" onclick="filterIssues('info')">Инфо</button>
<button onclick="resetView()">Сброс</button>
</div>
<div id="openseadragon"></div>
<!-- Page Navigation -->
<div class="page-nav" id="pageNav">
<button id="btnPrev" onclick="goPage(-1)"> Предыдущая</button>
<span class="page-info">Страница <span id="pageNum">{target_page}</span> / <span id="totalPages">{total_pages or '?'}</span></span>
<select id="pageSelect" onchange="jumpPage(this.value)">
{''.join([f'<option value="{i}"{" selected" if i == target_page else ""}>Стр. {i}</option>' for i in range(1, (total_pages or target_page or 1) + 1)])}
</select>
<button id="btnNext" onclick="goPage(1)">Следующая </button>
<a href="{api_base or '.'}" class="back-link"> Dashboard</a>
</div>
</div>
<div class="sidebar">
<div class="sidebar-header">
<h2>Замечания Стр. {target_page}</h2>
<div class="stats">
<div class="stat error">
<div class="stat-value">{len([i for i in issues if i['severity']=='error'])}</div>
<div class="stat-label">Ошибки</div>
</div>
<div class="stat warning">
<div class="stat-value">{len([i for i in issues if i['severity']=='warning'])}</div>
<div class="stat-label">Предупр.</div>
</div>
<div class="stat info">
<div class="stat-value">{len([i for i in issues if i['severity']=='info'])}</div>
<div class="stat-label">Инфо</div>
</div>
</div>
<div class="legend">
<div class="legend-item">
<div class="legend-dot" style="background:#ff4444;"></div>
<span>Ошибка</span>
</div>
<div class="legend-item">
<div class="legend-dot" style="background:#ffaa00;"></div>
<span>Предупреждение</span>
</div>
<div class="legend-item">
<div class="legend-dot" style="background:#44aaff;"></div>
<span>Информация</span>
</div>
</div>
</div>
<div class="issue-list" id="issueList">
'''
issue_counter = 0
for issue in issues:
issue_counter += 1
css_var = f"--card-color: {issue['color']}"
db_id_attr = f'data-db-id="{issue_db_ids[issue_counter-1]}"' if issue_db_ids and issue_counter <= len(issue_db_ids) else ''
has_api = 'true' if issue_db_ids else 'false'
html += f'''
<div class="issue-card" style="{css_var}"
data-severity="{issue['severity']}" data-id="{issue_counter}" {db_id_attr} data-has-api="{has_api}"
onclick="focusIssue({issue_counter})">
<div class="issue-type">
<span class="issue-num">#{issue_counter}</span>
{issue["type"]}
</div>
<div class="issue-message">{issue["message"][:250]}</div>
<div class="issue-severity">{issue["severity"]}</div>
<div class="feedback-bar" onclick="event.stopPropagation();">
<button class="tp" onclick="submitFeedback(this, true)" title="Реальная проблема"> Да</button>
<button class="fp" onclick="submitFeedback(this, false)" title="Ложное срабатывание"> Нет</button>
<button class="ns" onclick="submitFeedback(this, null)" title="Не уверен">🤷 Не знаю</button>
</div>
<div class="feedback-status" id="fbstatus-{issue_counter if not issue_db_ids else issue_db_ids[issue_counter-1]}"></div>
</div>
'''
html += f'''
</div>
</div>
</div>
<div id="tooltip"></div>
<div id="toast"></div>
<script>
const OVERLAYS = {overlays_json};
let viewer;
let activeFilter = 'all';
// Initialize OpenSeadragon
viewer = OpenSeadragon({{
id: "openseadragon",
prefixUrl: "https://cdn.jsdelivr.net/npm/openseadragon@4.1/build/openseadragon/images/",
tileSources: {tile_source},
showNavigationControl: true,
navigationControlAnchor: OpenSeadragon.ControlAnchor.TOP_RIGHT,
showZoomControl: true,
showHomeControl: true,
showFullPageControl: true,
zoomInButton: "zoom-in",
zoomOutButton: "zoom-out",
homeButton: "home",
fullPageButton: "full-page",
maxZoomPixelRatio: 10,
minZoomLevel: 0.1,
visibilityRatio: 0.5,
constrainDuringPan: true,
}});
// Add overlays after viewer opens
viewer.addHandler('open', function() {{
addOverlays();
}});
function addOverlays() {{
OVERLAYS.forEach((ov, idx) => {{
const el = document.createElement('div');
el.className = 'overlay-rect';
el.id = 'overlay-' + (idx + 1);
el.style.setProperty('--oc-color', ov.color);
el.setAttribute('data-severity', ov.severity);
const num = document.createElement('div');
num.className = 'overlay-number';
num.textContent = '' + ov.num;
el.appendChild(num);
// Tooltip events
el.addEventListener('mouseenter', (e) => showTooltip(e, ov));
el.addEventListener('mousemove', moveTooltip);
el.addEventListener('mouseleave', hideTooltip);
el.addEventListener('click', () => focusIssue(ov.num));
viewer.addOverlay({{
element: el,
location: new OpenSeadragon.Rect(ov.x, ov.y, ov.w, ov.h)
}});
}});
}}
const tooltip = document.getElementById('tooltip');
function showTooltip(e, ov) {{
tooltip.innerHTML = '<strong>#' + ov.num + '' + ov.type + '</strong><br><span style="opacity:0.7">' + ov.severity.toUpperCase() + '</span><br><br>' + ov.message;
tooltip.style.display = 'block';
tooltip.style.borderColor = ov.color;
}}
function moveTooltip(e) {{
tooltip.style.left = (e.clientX + 15) + 'px';
tooltip.style.top = (e.clientY + 15) + 'px';
}}
function hideTooltip() {{
tooltip.style.display = 'none';
}}
function filterIssues(severity) {{
activeFilter = severity;
// Update buttons
document.querySelectorAll('.controls button').forEach(btn => btn.classList.remove('active'));
document.getElementById('btn-' + severity).classList.add('active');
// Filter overlays
document.querySelectorAll('.overlay-rect').forEach(el => {{
const sev = el.getAttribute('data-severity');
el.style.display = (severity === 'all' || sev === severity) ? 'block' : 'none';
}});
// Filter cards
document.querySelectorAll('.issue-card').forEach(card => {{
const sev = card.getAttribute('data-severity');
card.style.display = (severity === 'all' || sev === severity) ? 'block' : 'none';
}});
}}
function focusIssue(num) {{
console.log('focusIssue called for num:', num);
selectIssue(num);
// Find overlay and zoom to it
const overlay = OVERLAYS[num - 1];
console.log('Found overlay:', overlay);
if (!overlay) {{
console.warn('No overlay found for num', num);
return;
}}
// Wait for viewer to be ready
if (!viewer || !viewer.viewport) {{
console.warn('Viewer not ready yet, retrying...');
setTimeout(() => focusIssue(num), 500);
return;
}}
console.log('Viewer ready, fitting bounds to:', overlay);
const rect = new OpenSeadragon.Rect(
Math.max(0, overlay.x - 0.05),
Math.max(0, overlay.y - 0.05),
Math.min(1 - overlay.x + 0.05, overlay.w + 0.1),
Math.min(1 - overlay.y + 0.05, overlay.h + 0.1)
);
console.log('Rect:', rect);
viewer.viewport.fitBounds(rect);
console.log('fitBounds called');
}}
function selectIssue(num) {{
// Highlight card
document.querySelectorAll('.issue-card').forEach(c => c.classList.remove('active'));
const card = document.querySelector(`.issue-card[data-id="${{num}}"]`);
if (card) {{
card.classList.add('active');
card.scrollIntoView({{behavior: 'smooth', block: 'center'}});
}}
// Highlight overlay
document.querySelectorAll('.overlay-rect').forEach(el => {{
el.style.opacity = el.id === 'overlay-' + num ? '0.6' : '0.15';
el.style.borderWidth = el.id === 'overlay-' + num ? '4px' : '2px';
}});
}}
function resetView() {{
viewer.viewport.goHome();
document.querySelectorAll('.issue-card').forEach(c => c.classList.remove('active'));
document.querySelectorAll('.overlay-rect').forEach(el => {{
el.style.opacity = '0.15';
el.style.borderWidth = '2px';
}});
}}
// ===== FEEDBACK SYSTEM =====
async function submitFeedback(btn, isTP) {{
const card = btn.closest('.issue-card');
const hasApi = card.getAttribute('data-has-api') === 'true';
const dbId = card.getAttribute('data-db-id');
if (!hasApi || !dbId) {{
showToast('API недоступен. Сохраните viewer через backend.', 'warning');
// Still update UI for demo
updateFeedbackUI(card, isTP, false);
return;
}}
btn.disabled = true;
const originalText = btn.textContent;
btn.textContent = '...';
try {{
const res = await fetch('/api/feedback', {{
method: 'POST',
headers: {{'Content-Type': 'application/json'}},
body: JSON.stringify({{
issue_id: parseInt(dbId),
is_true_positive: isTP,
action_taken: isTP === true ? 'fixed' : (isTP === false ? 'ignored' : 'not_sure')
}})
}});
if (res.ok) {{
updateFeedbackUI(card, isTP, true);
showToast('Feedback сохранён!', 'success');
}} else {{
const err = await res.text();
showToast('Ошибка: ' + err, 'error');
btn.disabled = false;
btn.textContent = originalText;
}}
}} catch(e) {{
showToast('Сетевой сбой: ' + e.message, 'error');
btn.disabled = false;
btn.textContent = originalText;
}}
}}
function updateFeedbackUI(card, isTP, saved) {{
const statusDiv = card.querySelector('.feedback-status');
const btns = card.querySelector('.feedback-bar');
// Remove all feedback classes
card.classList.remove('feedback-tp', 'feedback-fp');
if (isTP === true) {{
card.classList.add('feedback-tp');
btns.innerHTML = '<span style="color:#00c864; font-size:12px;">✅ Подтверждено как реальная проблема</span>';
if (statusDiv) statusDiv.innerHTML = '<span class="tp">Сохранено' + (saved ? '' : ' (локально)') + '</span>';
}} else if (isTP === false) {{
card.classList.add('feedback-fp');
btns.innerHTML = '<span style="color:#ff4444; font-size:12px;">❌ Отклонено (ложное срабатывание)</span>';
if (statusDiv) statusDiv.innerHTML = '<span class="fp">Сохранено' + (saved ? '' : ' (локально)') + '</span>';
}} else {{
btns.innerHTML = '<span style="color:#888; font-size:12px;">🤷 Не уверен</span>';
if (statusDiv) statusDiv.innerHTML = '<span class="ns">Сохранено' + (saved ? '' : ' (локально)') + '</span>';
}}
}}
function showToast(msg, type) {{
const toast = document.getElementById('toast');
toast.textContent = msg;
toast.style.borderColor = type === 'success' ? '#00c864' : (type === 'error' ? '#ff4444' : '#ffaa00');
toast.style.display = 'block';
setTimeout(() => {{ toast.style.display = 'none'; }}, 3000);
}}
// ===== PAGE NAVIGATION =====
const PROJECT_ID = null;
const CURRENT_PAGE = null;
const TOTAL_PAGES = null;
const API_BASE = '';
function updatePageNav() {{
const btnPrev = document.getElementById('btnPrev');
const btnNext = document.getElementById('btnNext');
const pageSelect = document.getElementById('pageSelect');
if (btnPrev) btnPrev.disabled = CURRENT_PAGE <= 1;
if (btnNext) btnNext.disabled = TOTAL_PAGES && CURRENT_PAGE >= TOTAL_PAGES;
if (pageSelect) pageSelect.value = CURRENT_PAGE;
// If no backend project ID, disable nav and show message
if (!PROJECT_ID) {{
if (btnPrev) btnPrev.disabled = true;
if (btnNext) btnNext.disabled = true;
const nav = document.getElementById('pageNav');
if (nav) nav.title = 'Page navigation requires backend server';
}}
}}
function goPage(delta) {{
if (!PROJECT_ID || !TOTAL_PAGES) return;
const newPage = CURRENT_PAGE + delta;
if (newPage < 1 || newPage > TOTAL_PAGES) return;
jumpPage(newPage);
}}
function jumpPage(pageNum) {{
if (!PROJECT_ID) {{
showToast('Navigation requires backend server', 'warning');
return;
}}
const base = API_BASE || '';
window.location.href = base + '/viewer/' + PROJECT_ID + '/' + pageNum;
}}
// Initialize
updatePageNav();
</script>
</body>
</html>'''
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()

235
gost_dimension_validator.py Normal file
View File

@ -0,0 +1,235 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Валидатор ГОСТ-ов и размеров на чертежах.
Проверяет OCR-результаты на:
1. Найденные ГОСТ/СНиП/СП/ТУ сверка с базой устаревших
2. Размеры валидация по типовым модулям и суммам
3. Низкий confidence OCR флаги для ручной проверки
Использование:
python gost_dimension_validator.py <output_folder>
"""
import sys
import json
import re
from pathlib import Path
from typing import Dict, List, Tuple
# ------------------------------------------------------------------
# База устаревших ГОСТов (пример — расширяется)
# ------------------------------------------------------------------
GOST_DATABASE = {
# Устаревшие ГОСТы → замена
"ГОСТ 21.101-97": {"status": "active", "name": "Система проектной документации"},
"ГОСТ 21.501-93": {"status": "obsolete", "replacement": "ГОСТ Р 21.1017-2022", "name": "Правила выполнения архитектурных чертежей"},
"ГОСТ 2.301-68": {"status": "active", "name": "Форматы"},
"ГОСТ 2.302-68": {"status": "obsolete", "replacement": "ГОСТ 2.302-2019", "name": "Масштабы"},
"ГОСТ 2.303-68": {"status": "obsolete", "replacement": "ГОСТ 2.303-2020", "name": "Линии"},
"ГОСТ 2.304-81": {"status": "obsolete", "replacement": "ГОСТ 2.304-2021", "name": "Шрифты чертежные"},
"ГОСТ 2.305-2008": {"status": "active", "name": "Изображения виды"},
"ГОСТ 2.307-2011": {"status": "active", "name": "Нанесение размеров"},
"СНиП II-22-81": {"status": "obsolete", "replacement": "СП 70.13330.2012", "name": "Каменные и армокаменные конструкции"},
"СНиП 2.01.07-85": {"status": "obsolete", "replacement": "СП 20.13330.2016", "name": "Нагрузки и воздействия"},
"СНиП 31-01-2003": {"status": "obsolete", "replacement": "СП 54.13330.2016", "name": "Жилые многоквартирные дома"},
}
# Типовые строительные модули (мм)
CONSTRUCTION_MODULES = [100, 200, 300, 400, 500, 600, 1000, 1200, 1500, 1800, 2400, 3000, 3600, 4200, 5400, 6000, 6600]
# ------------------------------------------------------------------
# Парсеры
# ------------------------------------------------------------------
def extract_gosts(text: str) -> List[Tuple[str, int]]:
"""Извлекает ГОСТ/СНиП/СП/ТУ из текста с позициями."""
patterns = [
r'ГОСТ\s*Р?\s*\d{1,5}(?:[-.]\d+)*(?:-\d{2,4})?', # ГОСТ 12345-67, ГОСТ Р 21.1017-2022
r'СНиП\s*(?:[IVX]+[-.])?\s*\d{1,3}[-.]\d{1,3}[-.]?\d{0,4}', # СНиП II-22-81, СНиП 31-01-2003
r'СП\s*\d{1,3}\.\d{1,6}\.\d{4}', # СП 54.13330.2016
r'ТУ\s*\d{1,4}(?:[-/]\d+)*[-.]\d{4}', # ТУ 400-...
]
found = []
for pat in patterns:
for m in re.finditer(pat, text, re.I):
found.append((m.group(0), m.start()))
return found
def extract_dimensions(text: str) -> List[Tuple[str, float]]:
"""Извлекает размеры в мм/м/см."""
found = []
# Основные размеры в мм (3600, 5400, 125.30)
for m in re.finditer(r'\b(\d{1,5}(?:[.,]\d{1,2})?)\s*м?[мм]?\b', text):
val = m.group(1).replace(',', '.')
try:
num = float(val)
if 10 <= num <= 50000: # реалистичные строительные размеры
found.append((m.group(0), num))
except ValueError:
pass
return found
def is_typical_module(dim: float, tolerance: float = 5.0) -> bool:
"""Проверяет, кратен ли размер типовому модулю."""
for mod in CONSTRUCTION_MODULES:
if abs(dim - mod) < tolerance or abs(dim % mod) < tolerance:
return True
return False
def validate_gost(gost: str) -> dict:
"""Проверяет статус ГОСТа в базе."""
gost_norm = gost.strip().upper()
# Нормализация
gost_norm = re.sub(r'\s+', ' ', gost_norm)
# Точное совпадение
if gost_norm in GOST_DATABASE:
info = GOST_DATABASE[gost_norm].copy()
info["gost"] = gost
return info
# Нечёткий поиск (без года)
base = re.sub(r'-\d{2,4}$', '', gost_norm)
for key, info in GOST_DATABASE.items():
key_base = re.sub(r'-\d{2,4}$', '', key)
if base == key_base:
result = info.copy()
result["gost"] = gost
result["note"] = f"Найден по базовому номеру ({key})"
return result
return {"gost": gost, "status": "unknown", "note": "Не найден в базе"}
# ------------------------------------------------------------------
# Основная логика
# ------------------------------------------------------------------
def validate_folder(folder: Path):
"""Проверяет OCR-данные из full_ocr_results.json."""
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"]
print(f"[INFO] Проверка {len(pages)} страниц...\n")
all_gosts = []
all_dims = []
low_confidence_items = []
for page in pages:
page_num = page["page_number"]
# --- 1. Проверка ГОСТ-ов ---
full_text = page.get("pdf_text_layer", "")
for line in page.get("ocr_lines", []):
full_text += " " + line["text"]
gosts = extract_gosts(full_text)
for gost, pos in gosts:
info = validate_gost(gost)
all_gosts.append({
"page": page_num,
"gost": gost,
**info
})
# --- 2. Проверка размеров ---
dims = extract_dimensions(full_text)
for dim_text, dim_val in dims:
is_typical = is_typical_module(dim_val)
all_dims.append({
"page": page_num,
"text": dim_text,
"value": dim_val,
"typical": is_typical,
})
# --- 3. Низкий confidence OCR ---
for line in page.get("ocr_lines", []):
conf = line.get("confidence", 0)
if conf < 0.6:
low_confidence_items.append({
"page": page_num,
"text": line["text"],
"confidence": conf,
"bbox": line.get("bbox", []),
})
# --- Вывод результатов ---
print("=" * 60)
print("ГОСТ/СНиП/СП/ТУ:")
print("=" * 60)
obsolete = [g for g in all_gosts if g["status"] == "obsolete"]
active = [g for g in all_gosts if g["status"] == "active"]
unknown = [g for g in all_gosts if g["status"] == "unknown"]
if obsolete:
print(f"\n⚠️ УСТАРЕВШИЕ ({len(obsolete)}):")
for g in obsolete:
print(f" Стр.{g['page']}: {g['gost']}")
print(f" → Замена: {g.get('replacement', 'не указана')}")
if active:
print(f"\n✅ АКТУАЛЬНЫЕ ({len(active)}):")
for g in active[:10]:
print(f" Стр.{g['page']}: {g['gost']} ({g.get('name', '')})")
if len(active) > 10:
print(f" ... и ещё {len(active) - 10}")
if unknown:
print(f"\n❓ НЕИЗВЕСТНЫЕ ({len(unknown)}):")
for g in unknown[:5]:
print(f" Стр.{g['page']}: {g['gost']}")
print("\n" + "=" * 60)
print("РАЗМЕРЫ:")
print("=" * 60)
typical = [d for d in all_dims if d["typical"]]
atypical = [d for d in all_dims if not d["typical"]]
print(f"\n✅ Типовые модули ({len(typical)}):")
for d in typical[:10]:
print(f" Стр.{d['page']}: {d['text']}{d['value']} мм")
if atypical:
print(f"\n⚠️ НЕТИПОВЫЕ/ПРОВЕРИТЬ ({len(atypical)}):")
for d in atypical[:10]:
print(f" Стр.{d['page']}: {d['text']}{d['value']} мм (не кратен модулю)")
print("\n" + "=" * 60)
print("НИЗКИЙ CONFIDENCE OCR (< 0.6):")
print("=" * 60)
if low_confidence_items:
print(f"\n⚠️ Найдено {len(low_confidence_items)} элементов для проверки:")
for item in low_confidence_items[:15]:
print(f" Стр.{item['page']}: '{item['text']}' (conf={item['confidence']:.2f})")
if len(low_confidence_items) > 15:
print(f" ... и ещё {len(low_confidence_items) - 15}")
else:
print("\nВсе элементы с высоким confidence")
# --- Сохранение JSON ---
report = {
"gosts": {"obsolete": obsolete, "active": active, "unknown": unknown},
"dimensions": {"typical": typical, "atypical": atypical},
"low_confidence": low_confidence_items,
}
out_path = folder / "validation_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}")
def main():
folder = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("output_123")
validate_folder(folder)
if __name__ == "__main__":
main()

298
layout_detector.py Normal file
View File

@ -0,0 +1,298 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Layout Detector разделение страницы чертежа на зоны.
Зоны:
- "drawing" схемы, виды, разрезы (линии + текст, разрежено)
- "table" таблицы (плотные линии в сетке)
- "title_block" штамп (нижний правый угол или низ страницы)
- "notes" примечания, текстовые блоки
- "legend" легенда/условные обозначения
Алгоритм:
1. Находит все линии на странице
2. Находит прямоугольники = таблицы
3. Анализирует плотность OCR текста
4. Классифицирует регионы
"""
import sys
import json
from pathlib import Path
from typing import List, Dict, Tuple
import cv2
import numpy as np
from PIL import Image
def find_all_lines(img_gray: np.ndarray, min_length: int = 40):
"""Находит все прямые линии (горизонтальные и вертикальные)."""
_, binary = cv2.threshold(img_gray, 180, 255, cv2.THRESH_BINARY_INV)
h, w = binary.shape
lines = []
# Горизонтальные
for y in range(h):
row = binary[y, :]
in_line = False
start = 0
for x in range(w):
if row[x] > 128:
if not in_line:
in_line = True
start = x
else:
if in_line:
length = x - start
if length >= min_length:
lines.append(("h", start, y, x-1, y))
in_line = False
if in_line:
length = w - start
if length >= min_length:
lines.append(("h", start, y, w-1, y))
# Вертикальные
for x in range(w):
col = binary[:, x]
in_line = False
start = 0
for y in range(h):
if col[y] > 128:
if not in_line:
in_line = True
start = y
else:
if in_line:
length = y - start
if length >= min_length:
lines.append(("v", x, start, x, y-1))
in_line = False
if in_line:
length = h - start
if length >= min_length:
lines.append(("v", x, start, x, h-1))
return lines
def find_rectangles(lines: List[Tuple], min_size: int = 100) -> List[Dict]:
"""Находит прямоугольники, образованные пересечением линий."""
horiz = [(l[1], l[2], l[3], l[4]) for l in lines if l[0] == "h"]
vert = [(l[1], l[2], l[3], l[4]) for l in lines if l[0] == "v"]
# Группируем горизонтальные по Y
from collections import defaultdict
h_by_y = defaultdict(list)
for x1, y1, x2, y2 in horiz:
h_by_y[y1].append((x1, x2))
# Группируем вертикальные по X
v_by_x = defaultdict(list)
for x1, y1, x2, y2 in vert:
v_by_x[x1].append((y1, y2))
rects = []
# Ищем пары горизонтальных линий с общими вертикальными
y_vals = sorted(h_by_y.keys())
for i in range(len(y_vals)):
for j in range(i+1, len(y_vals)):
y_top = y_vals[i]
y_bottom = y_vals[j]
# Ищем общий X-интервал
for x1_a, x2_a in h_by_y[y_top]:
for x1_b, x2_b in h_by_y[y_bottom]:
x_left = max(x1_a, x1_b)
x_right = min(x2_a, x2_b)
if x_right - x_left < min_size:
continue
# Проверяем, есть ли вертикальные линии на x_left и x_right
has_left = any(y_top <= y_bottom and not (y2 < y_top or y1 > y_bottom)
for y1, y2 in v_by_x.get(x_left, []))
has_right = any(y_top <= y_bottom and not (y2 < y_top or y1 > y_bottom)
for y1, y2 in v_by_x.get(x_right, []))
if has_left and has_right:
rects.append({
"x": x_left, "y": y_top,
"w": x_right - x_left, "h": y_bottom - y_top
})
# Фильтруем вложенные прямоугольники (оставляем только внешние)
filtered = []
for r in rects:
is_inner = False
for other in rects:
if r is other:
continue
if (r["x"] > other["x"] and r["y"] > other["y"] and
r["x"] + r["w"] < other["x"] + other["w"] and
r["y"] + r["h"] < other["y"] + other["h"]):
is_inner = True
break
if not is_inner:
filtered.append(r)
return filtered
def classify_regions(rects: List[Dict], ocr_lines: List[Dict], img_w: int, img_h: int) -> List[Dict]:
"""Классифицирует регионы страницы."""
regions = []
# 1. Таблицы = большие прямоугольники с высокой плотностью линий
for r in rects:
area = r["w"] * r["h"]
# Считаем OCR строки внутри
texts_in = [t for t in ocr_lines
if r["x"] <= t["cx"] <= r["x"] + r["w"]
and r["y"] <= t["cy"] <= r["y"] + r["h"]]
density = len(texts_in) / (area / 1000000) # текстов на мегапиксель
if density > 20: # высокая плотность = таблица
regions.append({
"type": "table",
"bbox": [r["x"], r["y"], r["x"]+r["w"], r["y"]+r["h"]],
"density": density,
"text_count": len(texts_in)
})
# 2. Определяем чертежи = области с линиями и текстом, но без плотной сетки
# Для простоты: левая половина, не покрытая таблицами
# Найдём ограничивающий bbox для всех "чертёжных" текстов
drawing_texts = [t for t in ocr_lines if t["cy"] < img_h * 0.75 and t["cx"] < img_w * 0.6]
if drawing_texts:
xs = [t["cx"] for t in drawing_texts]
ys = [t["cy"] for t in drawing_texts]
# Расширяем на 200px
dx = [t["cx"] - t["x1"] for t in drawing_texts if "x1" in t]
max_w = max(dx) if dx else 100
regions.append({
"type": "drawing",
"bbox": [max(0, min(xs)-max_w), max(0, min(ys)-100),
min(img_w, max(xs)+max_w), min(img_h, max(ys)+100)],
"text_count": len(drawing_texts)
})
# 3. Штамп = низ страницы, мелкий текст
title_texts = [t for t in ocr_lines if t["cy"] > img_h * 0.85]
if title_texts:
xs = [t["cx"] for t in title_texts]
ys = [t["cy"] for t in title_texts]
regions.append({
"type": "title_block",
"bbox": [min(xs)-50, min(ys)-50, max(xs)+50, max(ys)+50],
"text_count": len(title_texts)
})
# 4. Примечания = текстовые блоки
note_keywords = ["примечание", "общие указания", "границы", "размеры"]
note_texts = [t for t in ocr_lines
if any(kw in t["text"].lower() for kw in note_keywords)]
if note_texts:
xs = [t["cx"] for t in note_texts]
ys = [t["cy"] for t in note_texts]
regions.append({
"type": "notes",
"bbox": [min(xs)-100, min(ys)-100, max(xs)+100, max(ys)+100],
"text_count": len(note_texts)
})
return regions
def detect_layout(png_path: Path, ocr_path: Path) -> Dict:
"""Основная функция layout detection."""
img = cv2.imread(str(png_path), cv2.IMREAD_GRAYSCALE)
h, w = img.shape[:2]
# Загрузить OCR
ocr = json.loads(ocr_path.read_text(encoding="utf-8"))
# Собрать все OCR lines с координатами
all_texts = []
for page in ocr.get("pages", []):
for line in page.get("ocr_lines", []):
bbox = line.get("bbox", [])
if not bbox:
continue
if isinstance(bbox[0], list):
xs = [p[0] for p in bbox]
ys = [p[1] for p in bbox]
else:
xs = [bbox[0], bbox[2]]
ys = [bbox[1], bbox[3]]
all_texts.append({
"text": line["text"],
"cx": sum(xs)/len(xs),
"cy": sum(ys)/len(ys),
"x1": min(xs), "y1": min(ys),
"x2": max(xs), "y2": max(ys),
"bbox": bbox
})
# Найти линии
lines = find_all_lines(img)
print(f"[INFO] Найдено {len(lines)} линий")
# Найти прямоугольники
rects = find_rectangles(lines)
print(f"[INFO] Найдено {len(rects)} прямоугольников")
# Классифицировать
regions = classify_regions(rects, all_texts, w, h)
print(f"[INFO] Классифицировано {len(regions)} регионов")
for r in regions:
print(f" {r['type']}: bbox={r['bbox']}, texts={r.get('text_count', 0)}")
return {
"image_size": [w, h],
"regions": regions,
"rectangles": rects,
"line_count": len(lines)
}
def visualize_layout(png_path: Path, layout: Dict, out_path: Path):
"""Рисует зоны на изображении."""
img = Image.open(png_path)
draw = ImageDraw.Draw(img)
colors = {
"table": "blue",
"drawing": "green",
"title_block": "purple",
"notes": "orange"
}
for region in layout["regions"]:
x1, y1, x2, y2 = region["bbox"]
color = colors.get(region["type"], "red")
draw.rectangle([x1, y1, x2, y2], outline=color, width=4)
draw.text((x1+5, y1+5), region["type"], fill=color)
img.save(out_path)
print(f"[OK] Layout visualization: {out_path}")
def main():
if len(sys.argv) < 3:
print("Usage: python layout_detector.py <png> <ocr_json>")
sys.exit(1)
png = Path(sys.argv[1])
ocr = Path(sys.argv[2])
out_json = png.parent / "layout.json"
out_png = png.parent / f"{png.stem}_layout.png"
layout = detect_layout(png, ocr)
with open(out_json, "w", encoding="utf-8") as f:
json.dump(layout, f, ensure_ascii=False, indent=2)
print(f"[OK] Layout JSON: {out_json}")
visualize_layout(png, layout, out_png)
if __name__ == "__main__":
from PIL import ImageDraw
main()

183
multi_element_extractor.py Normal file
View File

@ -0,0 +1,183 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Multi-Element Extractor извлечение разных типов элементов из чертежа.
Использует layout zones и OCR для извлечения:
- dimensions: размеры (числа рядом с линиями в зоне drawing)
- positions: позиции арматуры (П-1, X-1, etc.)
- gosts: ссылки на ГОСТ
- steel_grades: марки стали (A500C, B30, etc.)
- elevations: отметки уровней (-1.060, etc.)
- beam_labels: Балка Б-1, Б-2, Б-3
- table_data: структурированные таблицы (позиция длина, масса, etc.)
"""
import sys
import json
import re
from pathlib import Path
from typing import List, Dict
def extract_from_zone(ocr_lines: List[Dict], zone_type: str, zone_bbox: List[int]) -> Dict:
"""Извлекает элементы из конкретной зоны."""
results = {
"dimensions": [],
"positions": [],
"gosts": [],
"steel_grades": [],
"elevations": [],
"beam_labels": [],
"table_rows": []
}
x1, y1, x2, y2 = zone_bbox
zone_texts = [t for t in ocr_lines
if x1 <= t["cx"] <= x2 and y1 <= t["cy"] <= y2]
for t in zone_texts:
txt = t["text"].strip()
# ГОСТ
if re.search(r'ГОС\s*T?\s*\d+', txt):
results["gosts"].append({"text": txt, "bbox": t["bbox"]})
# Марки стали
if re.search(r'A500C|B30|C\d+', txt, re.IGNORECASE):
results["steel_grades"].append({"text": txt, "bbox": t["bbox"]})
# Балки
if re.match(r'Балка\s+Б-\d+', txt):
results["beam_labels"].append({"text": txt, "bbox": t["bbox"]})
# Позиции (П-1, X-1, etc.)
if re.match(r'^[ПX]-\d+$', txt):
results["positions"].append({"text": txt, "bbox": t["bbox"]})
# Отметки уровней
if re.match(r'^-?\d+[,.]\d+$', txt) and float(txt.replace(',', '.').replace('', '-')) < 10:
results["elevations"].append({"text": txt, "bbox": t["bbox"]})
# Размеры: только целые числа 2-4 цифры (исключаем мелкие фрагменты)
if zone_type == "drawing" and re.match(r'^\d{2,4}$', txt) and txt not in ('00', '000', '006'):
results["dimensions"].append({"text": txt, "bbox": t["bbox"]})
# Для таблиц: структурируем
if zone_type == "table":
results["table_rows"] = structure_table(zone_texts)
return results
def structure_table(zone_texts: List[Dict]) -> List[Dict]:
"""Простая структуризация таблицы: группировка по строкам (по Y)."""
if not zone_texts:
return []
# Сортируем по Y
sorted_texts = sorted(zone_texts, key=lambda t: t["cy"])
# Группируем по близости Y (±20px)
rows = []
current_row = []
last_y = None
for t in sorted_texts:
if last_y is None or abs(t["cy"] - last_y) < 20:
current_row.append(t)
else:
if current_row:
# Сортируем по X
current_row.sort(key=lambda x: x["cx"])
rows.append({"cells": [c["text"] for c in current_row]})
current_row = [t]
last_y = t["cy"]
if current_row:
current_row.sort(key=lambda x: x["cx"])
rows.append({"cells": [c["text"] for c in current_row]})
return rows
def extract_all_elements(png_path: Path, ocr_path: Path, layout_path: Path) -> Dict:
"""Извлекает все элементы по зонам."""
ocr = json.loads(ocr_path.read_text(encoding="utf-8"))
layout = json.loads(layout_path.read_text(encoding="utf-8"))
# Собрать все OCR lines с координатами
all_texts = []
for page in ocr.get("pages", []):
for line in page.get("ocr_lines", []):
bbox = line.get("bbox", [])
if not bbox:
continue
if isinstance(bbox[0], list):
xs = [p[0] for p in bbox]
ys = [p[1] for p in bbox]
else:
xs = [bbox[0], bbox[2]]
ys = [bbox[1], bbox[3]]
all_texts.append({
"text": line["text"],
"cx": sum(xs)/len(xs),
"cy": sum(ys)/len(ys),
"bbox": bbox
})
# Извлечь по зонам
all_results = {
"dimensions": [],
"positions": [],
"gosts": [],
"steel_grades": [],
"elevations": [],
"beam_labels": [],
"tables": []
}
for region in layout.get("regions", []):
zone_results = extract_from_zone(all_texts, region["type"], region["bbox"])
for key in all_results:
if key in zone_results:
all_results[key].extend(zone_results[key])
# Убрать дубликаты
for key in all_results:
seen = set()
unique = []
for item in all_results[key]:
if item["text"] not in seen:
seen.add(item["text"])
unique.append(item)
all_results[key] = unique
return all_results
def main():
if len(sys.argv) < 4:
print("Usage: python multi_element_extractor.py <png> <ocr_json> <layout_json>")
sys.exit(1)
png = Path(sys.argv[1])
ocr = Path(sys.argv[2])
layout = Path(sys.argv[3])
results = extract_all_elements(png, ocr, layout)
out = png.parent / "elements.json"
with open(out, "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print(f"[OK] Elements saved: {out}")
for key, items in results.items():
print(f" {key}: {len(items)} items")
for item in items[:5]:
print(f" {item['text']}")
if len(items) > 5:
print(f" ... and {len(items)-5} more")
if __name__ == "__main__":
main()

233
ocr_qwen.py Normal file
View File

@ -0,0 +1,233 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
OCR через Alibaba Cloud qwen-vl-ocr API.
Использование:
from ocr_qwen import run_ocr
results = run_ocr(image_path)
Требует DASHSCOPE_API_KEY в .env
"""
import os
import json
import base64
import io
from pathlib import Path
from typing import List, Dict, Tuple
from PIL import Image
from openai import OpenAI
# Загрузить ключ
_API_KEY = None
_BASE_URL = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
_MODEL = "qwen-vl-ocr"
def _load_key():
global _API_KEY
if _API_KEY:
return _API_KEY
# Попробовать .env
env_candidates = [
Path(__file__).parent / ".env",
Path(__file__).parent.parent / ".env",
Path(__file__).parent.parent.parent / ".env",
]
for env_path in env_candidates:
if env_path.exists():
for line in env_path.read_text().splitlines():
if line.startswith("DASHSCOPE_API_KEY="):
_API_KEY = line.split("=", 1)[1].strip()
os.environ["DASHSCOPE_API_KEY"] = _API_KEY
return _API_KEY
_API_KEY = os.environ.get("DASHSCOPE_API_KEY")
return _API_KEY
def resize_image(image_path: Path, max_size: int = 2048) -> Tuple[str, float, Tuple[int, int]]:
"""
Уменьшает изображение до max_size по длинной стороне.
Возвращает: (base64_string, scale_factor, (orig_w, orig_h))
"""
img = Image.open(image_path)
orig_w, orig_h = img.size
# Если уже меньше — не менять
if max(orig_w, orig_h) <= max_size:
with open(image_path, "rb") as f:
b64 = base64.b64encode(f.read()).decode("utf-8")
return b64, 1.0, (orig_w, orig_h)
# Вычислить новый размер
scale = max_size / max(orig_w, orig_h)
new_w = int(orig_w * scale)
new_h = int(orig_h * scale)
img_resized = img.resize((new_w, new_h), Image.LANCZOS)
# Сохранить в буфер
buf = io.BytesIO()
img_resized.save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
return b64, scale, (orig_w, orig_h)
def encode_image(image_path: Path) -> str:
with open(image_path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
def parse_qwen_response(raw_text: str) -> List[Dict]:
"""Парсит JSON из ответа qwen-vl-ocr."""
import re
text = raw_text.strip()
# Удалить markdown code blocks ```json ... ```
if text.startswith("```"):
lines = text.splitlines()
start = 0
end = len(lines)
for i, line in enumerate(lines):
if line.strip().startswith("```") and start == 0:
start = i + 1
elif line.strip() == "```" and start > 0:
end = i
break
text = "\n".join(lines[start:end]).strip()
# Робастный парсинг: извлекаем каждый объект отдельно через regex
results = []
# Шаблон: {"text": "...", "rotate_rect": [num, num, num, num, num]}
pattern = r'\{\s*"text":\s*"([^"]*)"\s*,\s*"rotate_rect":\s*\[\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\]\s*\}'
for match in re.finditer(pattern, text):
txt = match.group(1)
x, y, w, h, angle = int(match.group(2)), int(match.group(3)), int(match.group(4)), int(match.group(5)), int(match.group(6))
results.append({
"text": txt,
"rotate_rect": [x, y, w, h, angle]
})
if not results:
# Fallback: попробовать стандартный JSON парсинг
try:
json_match = re.search(r'\[[\s\S]*\]', text)
if json_match:
data = json.loads(json_match.group(0))
if isinstance(data, list):
return data
except Exception:
pass
print(f"[WARN] Regex parser не нашёл объекты, JSON тоже не распарсился")
print(f"[WARN] Text preview: {text[:200]}")
return results
def run_ocr(image_path: Path, verbose: bool = False) -> List[Dict]:
"""
Запускает qwen-vl-ocr на изображении.
Returns:
Список словарей: {
"text": str,
"bbox": [x1, y1, x2, y2, angle], # rotate_rect format
"confidence": float # estimated
}
"""
api_key = _load_key()
if not api_key:
raise RuntimeError("DASHSCOPE_API_KEY not found in .env or environment")
client = OpenAI(api_key=api_key, base_url=_BASE_URL)
# Уменьшить изображение для экономии токенов
b64, scale, (orig_w, orig_h) = resize_image(image_path, max_size=2048)
data_url = f"data:image/png;base64,{b64}"
if verbose:
orig_size = image_path.stat().st_size / 1024
print(f"[qwen-ocr] Отправка {image_path.name} (orig {orig_w}x{orig_h}, scale={scale:.2f}, {orig_size:.0f} KB)...", flush=True)
response = client.chat.completions.create(
model=_MODEL,
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": (
"Распознай все текстовые элементы на этом чертеже. "
"Для каждого текста верни ОТДЕЛЬНЫЙ JSON-объект с полями: text, rotate_rect [x,y,w,h,angle]. "
"ВАЖНО: каждый текст — отдельный объект, без дублирующихся ключей в одном объекте. "
"Пример правильного формата:\n"
'[{"text": "Бетон", "rotate_rect": [100, 50, 30, 10, 0]}, {"text": "В30", "rotate_rect": [100, 65, 20, 10, 0]}]'
"\nОтветь строго в формате JSON-массива без markdown."
),
},
{"type": "image_url", "image_url": {"url": data_url}},
],
}
],
temperature=0.1,
max_tokens=8192,
)
raw = response.choices[0].message.content.strip()
# Сохранить raw для отладки
debug_path = image_path.parent / f"{image_path.stem}_qwen_raw.txt"
debug_path.write_text(raw, encoding="utf-8")
items = parse_qwen_response(raw)
# Конвертировать rotate_rect в наш формат, масштабируя обратно к оригиналу
results = []
for item in items:
rect = item.get("rotate_rect", [0, 0, 0, 0, 0])
if len(rect) >= 4:
x, y, w, h = rect[0], rect[1], rect[2], rect[3]
# Масштабировать обратно к оригинальному размеру
if scale != 1.0:
x = round(x / scale)
y = round(y / scale)
w = round(w / scale)
h = round(h / scale)
# bbox: [[x1,y1],[x2,y2],[x3,y3],[x4,y4]]
bbox = [[x, y], [x + w, y], [x + w, y + h], [x, y + h]]
else:
bbox = None
results.append({
"text": item.get("text", ""),
"bbox": bbox,
"confidence": 0.95, # qwen-vl-ocr не возвращает confidence, ставим высокий
"source": "qwen-vl-ocr"
})
if verbose:
print(f"[qwen-ocr] Найдено {len(results)} элементов")
return results
if __name__ == "__main__":
import sys
if len(sys.argv) < 2:
print("Usage: python ocr_qwen.py <image.png>")
sys.exit(1)
image_path = Path(sys.argv[1])
results = run_ocr(image_path, verbose=True)
print(f"\nНайдено {len(results)} текстовых элементов:")
for r in results[:20]:
print(f" '{r['text']}' bbox={r['bbox']}")
if len(results) > 20:
print(f" ... и ещё {len(results) - 20}")

51
preprocess_for_ocr.py Normal file
View File

@ -0,0 +1,51 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Предобработка PNG для улучшения OCR размерных чисел.
Алгоритм:
1. CLAHE локальное повышение контраста
2. Unsharp mask повышение резкости
3. Инвертирование (опционально для некоторых OCR)
4. Масштабирование x2 (если исходное маленькое)
"""
import sys
from pathlib import Path
import cv2
import numpy as np
def preprocess_for_ocr(img_path: Path, out_path: Path, scale: float = 2.0):
img = cv2.imread(str(img_path), cv2.IMREAD_GRAYSCALE)
if img is None:
raise RuntimeError(f"Cannot load {img_path}")
# Масштабирование
if scale != 1.0:
h, w = img.shape
img = cv2.resize(img, (int(w*scale), int(h*scale)), interpolation=cv2.INTER_CUBIC)
# CLAHE (локальный контраст)
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))
img = clahe.apply(img)
# Unsharp mask
gaussian = cv2.GaussianBlur(img, (0,0), 3)
img = cv2.addWeighted(img, 1.5, gaussian, -0.5, 0)
# Нормализация
img = cv2.normalize(img, None, 0, 255, cv2.NORM_MINMAX)
cv2.imwrite(str(out_path), img)
print(f"[OK] Предобработка сохранена: {out_path}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python preprocess_for_ocr.py <png>")
sys.exit(1)
png = Path(sys.argv[1])
out = png.parent / f"{png.stem}_preproc.png"
preprocess_for_ocr(png, out)

View File

@ -2,14 +2,21 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Универсальное распознавание PDF в указанную папку. Универсальное распознавание PDF в указанную папку.
Поддерживает:
- RapidOCR (локально, быстро)
- RapidOCR + tiling (для больших чертежей)
- qwen-vl-ocr (API, точнее)
Использование: Использование:
python process_any_pdf.py <pdf_file> <output_folder_name> python process_any_pdf.py <pdf_file> <output_folder> [--use-qwen] [--use-tiling]
""" """
import sys import sys
import json import json
import re
import fitz import fitz
from pathlib import Path from pathlib import Path
from PIL import Image
from rapidocr_onnxruntime import RapidOCR from rapidocr_onnxruntime import RapidOCR
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -17,19 +24,95 @@ from rapidocr_onnxruntime import RapidOCR
# ------------------------------------------------------------------ # ------------------------------------------------------------------
DPI = 300 DPI = 300
BATCH_SIZE = 5 BATCH_SIZE = 5
TILE_SIZE = 2000
TILE_OVERLAP = 200
engine = RapidOCR() engine = RapidOCR()
# qwen-vl-ocr lazy import
try:
from ocr_qwen import run_ocr as qwen_ocr
QWEN_AVAILABLE = True
except ImportError:
QWEN_AVAILABLE = False
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def process_pdf(pdf_path: Path, out_dir: Path): # Tiling OCR helpers
# ------------------------------------------------------------------
def _make_tiles(img: Image.Image, tile_size: int = 2000, overlap: int = 200):
w, h = img.size
tiles = []
step = tile_size - overlap
for y in range(0, h, step):
for x in range(0, w, step):
x2 = min(x + tile_size, w)
y2 = min(y + tile_size, h)
tiles.append((x, y, img.crop((x, y, x2, y2))))
return tiles
def _bbox_iou(a, b):
def _rect(box):
if isinstance(box[0], list):
xs = [p[0] for p in box]
ys = [p[1] for p in box]
return min(xs), min(ys), max(xs), max(ys)
return box[0], box[1], box[2], box[3]
ax1, ay1, ax2, ay2 = _rect(a)
bx1, by1, bx2, by2 = _rect(b)
ix1, iy1 = max(ax1, bx1), max(ay1, by1)
ix2, iy2 = min(ax2, bx2), min(ay2, by2)
if ix2 <= ix1 or iy2 <= iy1:
return 0.0
inter = (ix2 - ix1) * (iy2 - iy1)
union = (ax2 - ax1) * (ay2 - ay1) + (bx2 - bx1) * (by2 - by1) - inter
return inter / union if union > 0 else 0.0
def run_tiling_ocr(img_path: Path, conf_threshold: float = 0.5):
"""Запускает RapidOCR по кропам и объединяет результаты."""
img = Image.open(img_path)
tiles = _make_tiles(img, TILE_SIZE, TILE_OVERLAP)
all_results = []
for off_x, off_y, crop in tiles:
tmp = f"/tmp/tile_ocr.png"
crop.save(tmp)
res = engine(tmp)
if res and res[0]:
for item in res[0]:
box, txt, score = item
if score < conf_threshold:
continue
shifted = [[pt[0] + off_x, pt[1] + off_y] for pt in box]
all_results.append({"text": txt, "confidence": float(score), "bbox": shifted})
# Дедупликация по IoU
unique = []
for r in sorted(all_results, key=lambda x: -x["confidence"]):
is_dup = any(_bbox_iou(r["bbox"], u["bbox"]) > 0.5 for u in unique)
if not is_dup:
unique.append(r)
return unique
# ------------------------------------------------------------------
def process_pdf(pdf_path: Path, out_dir: Path, use_qwen: bool = False, use_tiling: bool = False):
out_dir.mkdir(parents=True, exist_ok=True) out_dir.mkdir(parents=True, exist_ok=True)
doc = fitz.open(pdf_path) doc = fitz.open(pdf_path)
total = len(doc) total = len(doc)
print(f"=== PDF: {pdf_path.name} | Страниц: {total} -> {out_dir} ===\n") print(f"=== PDF: {pdf_path.name} | Страниц: {total} -> {out_dir} ===")
if use_qwen:
print(f"[INFO] OCR engine: qwen-vl-ocr (API)")
elif use_tiling:
print(f"[INFO] OCR engine: RapidOCR + tiling ({TILE_SIZE}px tiles)")
else:
print(f"[INFO] OCR engine: RapidOCR (local)")
print()
all_pages = [] all_pages = []
for i in range(total): for i in range(total):
print(f"[{i+1}/{total}] Рендер + OCR ...", end=" ") print(f"[{i+1}/{total}] Рендер + OCR ...", end=" ", flush=True)
page = doc.load_page(i) page = doc.load_page(i)
raw_text = page.get_text("text").strip() raw_text = page.get_text("text").strip()
@ -38,6 +121,40 @@ def process_pdf(pdf_path: Path, out_dir: Path):
img_path = out_dir / f"page_{i+1:03d}.png" img_path = out_dir / f"page_{i+1:03d}.png"
pix.save(img_path) pix.save(img_path)
# Выбор OCR engine
if use_qwen and QWEN_AVAILABLE:
try:
ocr_lines = qwen_ocr(img_path, verbose=False)
print(f"qwen-ocr строк: {len(ocr_lines)}")
except Exception as e:
print(f"qwen-ocr ERR: {e}, fallback to RapidOCR")
ocr_lines = _run_rapidocr(img_path)
print(f"RapidOCR строк: {len(ocr_lines)}")
elif use_tiling:
ocr_lines = run_tiling_ocr(img_path)
print(f"Tiling OCR строк: {len(ocr_lines)}")
else:
ocr_lines = _run_rapidocr(img_path)
print(f"RapidOCR строк: {len(ocr_lines)}")
all_pages.append({
"page_number": i + 1,
"image": str(img_path.name),
"pdf_text_layer": raw_text,
"ocr_lines": ocr_lines,
"ocr_line_count": len(ocr_lines)
})
if (i + 1) % BATCH_SIZE == 0 or i == total - 1:
with open(out_dir / "full_ocr_results.json", "w", encoding="utf-8") as f:
json.dump({"pages": all_pages}, f, ensure_ascii=False, indent=2)
print(f" -> сохранено ({i+1} страниц)")
doc.close()
print(f"\n=== Готово. Результат в {out_dir} ===")
def _run_rapidocr(img_path: Path):
res = engine(img_path) res = engine(img_path)
ocr_lines = [] ocr_lines = []
if res and res[0] is not None: if res and res[0] is not None:
@ -48,26 +165,17 @@ def process_pdf(pdf_path: Path, out_dir: Path):
"confidence": float(score), "confidence": float(score),
"bbox": box "bbox": box
}) })
return ocr_lines
all_pages.append({
"page_number": i + 1,
"image": str(img_path.name),
"pdf_text_layer": raw_text,
"ocr_lines": ocr_lines,
"ocr_line_count": len(ocr_lines)
})
print(f"OCR строк: {len(ocr_lines)}")
if (i + 1) % BATCH_SIZE == 0 or i == total - 1:
with open(out_dir / "full_ocr_results.json", "w", encoding="utf-8") as f:
json.dump({"pages": all_pages}, f, ensure_ascii=False, indent=2)
print(f" -> промежуточное сохранение ({i+1} страниц)")
doc.close()
print(f"\n=== Готово. Результат в {out_dir} ===")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def main(): def main():
use_qwen = "--use-qwen" in sys.argv
use_tiling = "--use-tiling" in sys.argv
if use_qwen:
sys.argv.remove("--use-qwen")
if use_tiling:
sys.argv.remove("--use-tiling")
if len(sys.argv) < 2: if len(sys.argv) < 2:
pdf_file = "123.pdf" pdf_file = "123.pdf"
out_name = "output_123" out_name = "output_123"
@ -82,7 +190,7 @@ def main():
print(f"[ERR] Файл не найден: {pdf_path}") print(f"[ERR] Файл не найден: {pdf_path}")
sys.exit(1) sys.exit(1)
process_pdf(pdf_path, out_dir) process_pdf(pdf_path, out_dir, use_qwen=use_qwen, use_tiling=use_tiling)
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -83,7 +83,8 @@ def get_lmstudio_backend(model: str = "qwen2.5:14b"):
temperature=kwargs.get("temperature", 0.3), temperature=kwargs.get("temperature", 0.3),
max_tokens=kwargs.get("max_tokens", 1024), 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") 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), temperature=kwargs.get("temperature", 0.3),
max_tokens=kwargs.get("max_tokens", 1024), 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") embed_model = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")

139
test_qwen_ocr.py Normal file
View File

@ -0,0 +1,139 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Тест Alibaba Cloud DashScope qwen-vl-ocr на чертеже.
Использование:
python test_qwen_ocr.py <png_file>
"""
import os
import sys
import base64
import json
from pathlib import Path
from openai import OpenAI
# Загрузить ключ из .env (рядом со скриптом)
env_path = Path(__file__).parent / ".env"
API_KEY = None
if env_path.exists():
for line in env_path.read_text().splitlines():
if line.startswith("DASHSCOPE_API_KEY="):
API_KEY = line.split("=", 1)[1].strip()
os.environ["DASHSCOPE_API_KEY"] = API_KEY
break
if not API_KEY:
API_KEY = os.environ.get("DASHSCOPE_API_KEY")
BASE_URL = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
MODEL = "qwen-vl-ocr"
def encode_image(image_path: Path) -> str:
with open(image_path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
def test_ocr(image_path: Path):
client = OpenAI(api_key=API_KEY, base_url=BASE_URL)
b64 = encode_image(image_path)
data_url = f"data:image/png;base64,{b64}"
print(f"Отправляем {image_path.name} в qwen-vl-ocr...")
print(f"Размер файла: {image_path.stat().st_size / 1024 / 1024:.1f} MB")
response = client.chat.completions.create(
model=MODEL,
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": (
"Распознай все текстовые элементы на этом чертеже. "
"Для каждого текста укажи:\n"
"- сам текст\n"
"- координаты bbox (x1,y1,x2,y2)\n"
"- confidence (если доступен)\n"
"Ответь в формате JSON-массива."
),
},
{"type": "image_url", "image_url": {"url": data_url}},
],
}
],
temperature=0.1,
max_tokens=2048,
)
raw = response.choices[0].message.content
print("\n=== ОТВЕТ МОДЕЛИ ===")
print(raw[:2000])
print("=" * 50)
# Сохранить результат
out_path = image_path.parent / f"qwen_ocr_result_{image_path.stem}.json"
with open(out_path, "w", encoding="utf-8") as f:
f.write(raw)
print(f"\n[OK] Сохранено: {out_path}")
def describe_image(image_path: Path):
"""Просто описание того, что модель видит на чертеже."""
client = OpenAI(api_key=API_KEY, base_url=BASE_URL)
b64 = encode_image(image_path)
data_url = f"data:image/png;base64,{b64}"
print(f"\nОтправляем {image_path.name} на описание...")
response = client.chat.completions.create(
model=MODEL,
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": (
"Опиши подробно, что ты видишь на этом изображении. "
"Чертеж здания или что-то другое? Какие элементы видны? "
"Размеры, текст, линии, оси — всё, что различимо."
),
},
{"type": "image_url", "image_url": {"url": data_url}},
],
}
],
temperature=0.3,
max_tokens=1024,
)
desc = response.choices[0].message.content
print("\n=== ОПИСАНИЕ ===")
print(desc)
print("=" * 50)
return desc
def main():
if len(sys.argv) < 2:
print("Usage: python test_qwen_ocr.py <png_file> [--describe]")
sys.exit(1)
image_path = Path(sys.argv[1])
if not image_path.exists():
print(f"[ERR] Файл не найден: {image_path}")
sys.exit(1)
if "--describe" in sys.argv:
describe_image(image_path)
else:
test_ocr(image_path)
if __name__ == "__main__":
main()

157
tiling_ocr.py Normal file
View File

@ -0,0 +1,157 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Tiling OCR для больших чертежей.
Разрезает PNG на перекрывающиеся кропы, прогоняет OCR на каждом,
объединяет результаты с дедупликацией.
Эффект: каждый кроп масштабирован "крупнее" для OCR мелкий текст
находится на бОльшем % площади кропа.
"""
import sys
import json
import re
from pathlib import Path
from typing import List, Dict, Tuple
from PIL import Image
from rapidocr_onnxruntime import RapidOCR
def make_tiles(img: Image.Image, tile_size: int = 2000, overlap: int = 200) -> List[Tuple[int, int, Image.Image]]:
"""
Генерирует кропы с перекрытием.
Возвращает: [(offset_x, offset_y, cropped_image), ...]
"""
w, h = img.size
tiles = []
step = tile_size - overlap
for y in range(0, h, step):
for x in range(0, w, step):
x2 = min(x + tile_size, w)
y2 = min(y + tile_size, h)
crop = img.crop((x, y, x2, y2))
tiles.append((x, y, crop))
return tiles
def iou_bbox(a: List, b: List) -> float:
"""IoU двух bbox в формате [[x1,y1],[x2,y2],[x3,y3],[x4,y4]]."""
def _get_rect(box):
if isinstance(box[0], list):
xs = [p[0] for p in box]
ys = [p[1] for p in box]
return min(xs), min(ys), max(xs), max(ys)
else:
return box[0], box[1], box[2], box[3]
ax1, ay1, ax2, ay2 = _get_rect(a)
bx1, by1, bx2, by2 = _get_rect(b)
ix1 = max(ax1, bx1)
iy1 = max(ay1, by1)
ix2 = min(ax2, bx2)
iy2 = min(ay2, by2)
if ix2 <= ix1 or iy2 <= iy1:
return 0.0
inter = (ix2 - ix1) * (iy2 - iy1)
area_a = (ax2 - ax1) * (ay2 - ay1)
area_b = (bx2 - bx1) * (by2 - by1)
union = area_a + area_b - inter
return inter / union if union > 0 else 0.0
def run_tiling_ocr(png_path: Path, tile_size: int = 2000, overlap: int = 200, conf_threshold: float = 0.5):
"""Основная функция."""
print(f"[INFO] Загрузка {png_path.name}...")
img = Image.open(png_path)
print(f"[INFO] Размер: {img.size}")
tiles = make_tiles(img, tile_size, overlap)
print(f"[INFO] Кропов: {len(tiles)}")
engine = RapidOCR()
all_results = []
for i, (off_x, off_y, crop) in enumerate(tiles, 1):
# Временно сохранить кроп
tmp_path = f"/tmp/tile_{i:03d}.png"
crop.save(tmp_path)
print(f" [{i}/{len(tiles)}] tile @ ({off_x}, {off_y}) size {crop.size} ...", end=" ", flush=True)
res = engine(tmp_path)
tile_lines = 0
if res and res[0]:
for item in res[0]:
box, txt, score = item
if score < conf_threshold:
continue
# Сдвинуть bbox на offset кропа
shifted_box = []
for pt in box:
shifted_box.append([pt[0] + off_x, pt[1] + off_y])
all_results.append({
"text": txt,
"confidence": float(score),
"bbox": shifted_box
})
tile_lines += 1
print(f"{tile_lines} lines")
# Дедупликация: если два bbox пересекаются (IoU > 0.5) — оставляем тот, что с higher confidence
print(f"[INFO] Дедупликация {len(all_results)} строк...")
unique = []
for r in sorted(all_results, key=lambda x: -x["confidence"]):
is_dup = False
for u in unique:
if iou_bbox(r["bbox"], u["bbox"]) > 0.5:
is_dup = True
break
if not is_dup:
unique.append(r)
print(f"[OK] Уникальных строк: {len(unique)}")
return unique
def main():
if len(sys.argv) < 2:
print("Usage: python tiling_ocr.py <png> [tile_size] [overlap]")
sys.exit(1)
png_path = Path(sys.argv[1])
tile_size = int(sys.argv[2]) if len(sys.argv) > 2 else 2000
overlap = int(sys.argv[3]) if len(sys.argv) > 3 else 200
results = run_tiling_ocr(png_path, tile_size, overlap)
# Сохранить результаты
out_json = png_path.parent / f"{png_path.stem}_tiling_ocr.json"
with open(out_json, "w", encoding="utf-8") as f:
json.dump({
"source": str(png_path),
"tile_size": tile_size,
"overlap": overlap,
"total_lines": len(results),
"lines": results
}, f, ensure_ascii=False, indent=2)
print(f"[OK] Сохранено: {out_json}")
# Вывести числа
nums = [r for r in results if re.match(r'^\d+([,.]\d+)?$', r["text"].strip())]
print(f"\nНайдено {len(nums)} чисел:")
for n in sorted(nums, key=lambda x: x["bbox"][0][1]):
bbox = n["bbox"]
cx = sum(p[0] for p in bbox) / len(bbox)
cy = sum(p[1] for p in bbox) / len(bbox)
print(f" {n['text']:>10} x={cx:>8.0f} y={cy:>8.0f} conf={n['confidence']:.2f}")
if __name__ == "__main__":
main()

54
visualize_dimensions.py Normal file
View File

@ -0,0 +1,54 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Визуализация найденных размерных чисел на PNG.
Рисует bbox вокруг чисел, извлечённых из OCR.
"""
import sys
import json
import re
from pathlib import Path
from PIL import Image, ImageDraw
def visualize_dimensions(ocr_json_path: Path, png_path: Path, out_path: Path):
"""Рисует bbox вокруг чисел на PNG."""
ocr = json.loads(ocr_json_path.read_text(encoding="utf-8"))
img = Image.open(png_path)
draw = ImageDraw.Draw(img)
found = 0
for page in ocr.get("pages", []):
for line in page.get("ocr_lines", []):
txt = line["text"].strip()
if re.match(r'^\d+([,.]\d+)?$', txt):
bbox = line.get("bbox")
if bbox:
# bbox: [[x1,y1],[x2,y2],[x3,y3],[x4,y4]]
if isinstance(bbox[0], list):
pts = [(p[0], p[1]) for p in bbox]
else:
pts = [(bbox[0], bbox[1]), (bbox[2], bbox[1]),
(bbox[2], bbox[3]), (bbox[0], bbox[3])]
draw.polygon(pts, outline="red", width=3)
# Подпись
x = min(p[0] for p in pts)
y = min(p[1] for p in pts)
draw.text((x, y-20), txt, fill="red")
found += 1
img.save(out_path)
print(f"[OK] Найдено {found} размерных чисел. Сохранено: {out_path}")
if __name__ == "__main__":
if len(sys.argv) < 3:
print("Usage: python visualize_dimensions.py <ocr_json> <png>")
sys.exit(1)
ocr_json = Path(sys.argv[1])
png = Path(sys.argv[2])
out = png.parent / f"{png.stem}_dims.png"
visualize_dimensions(ocr_json, png, out)

View File

@ -1,53 +1,139 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Генерация текстовых описаний PNG-страниц через VLM в LM Studio. VLM Describer объективное извлечение структуры чертежа.
Требования: Отправляет PNG в qwen-vl-plus (DashScope API) с промптом на фактическое
- Запущен LM Studio с загруженной моделью (например, qwen3-vl-4b) описание содержимого. НЕ ищет ошибки, НЕ оценивает качество.
- Сервер: http://127.0.0.1:1234/v1
Результат: <output_folder>/vlm_extraction.json структурированное описание
каждой страницы для использования в RAG и cross-verification.
Использование: Использование:
python vlm_describer.py <output_folder> [--prompt "..."] [--model MODEL] python vlm_describer.py <output_folder> [--model MODEL]
Результат: <output_folder>/vlm_descriptions.json Требует DASHSCOPE_API_KEY в .env или окружении.
""" """
import os import os
import sys import sys
import json import json
import base64 import base64
import argparse import io
import re
from pathlib import Path from pathlib import Path
from typing import List, Dict, Tuple
from PIL import Image
from openai import OpenAI from openai import OpenAI
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Конфигурация LM Studio # Конфигурация
# ------------------------------------------------------------------ # ------------------------------------------------------------------
LMSTUDIO_URL = os.environ.get("LMSTUDIO_URL", "http://127.0.0.1:1234/v1") API_KEY = None
LMSTUDIO_KEY = os.environ.get("LMSTUDIO_API_KEY", "lm-studio") BASE_URL = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
DEFAULT_MODEL = "qwen-vl-plus"
DEFAULT_PROMPT = (
"Опиши этот чертеж подробно. Укажи:\n" def _load_api_key():
"- Какой это этаж (если видно)\n" global API_KEY
"- Какие оси обозначены\n" if API_KEY:
"- Какие размеры указаны\n" return API_KEY
"- Какие помещения/квартиры видны\n" env_candidates = [
"- Общую компоновку и заметные детали.\n" Path(__file__).parent / ".env",
"Отвечай на русском языке." Path(__file__).parent.parent / ".env",
]
for env_path in env_candidates:
if env_path.exists():
for line in env_path.read_text().splitlines():
if line.startswith("DASHSCOPE_API_KEY="):
API_KEY = line.split("=", 1)[1].strip()
os.environ["DASHSCOPE_API_KEY"] = API_KEY
return API_KEY
API_KEY = os.environ.get("DASHSCOPE_API_KEY")
return API_KEY
EXTRACTION_PROMPT = (
"Ты — система распознавания чертежей. Опиши объективно, что изображено на этой странице. "
"НЕ ищи ошибки, НЕ оценивай качество. Просто перечисли факты.\n\n"
"Ответь СТРОГО в формате JSON (без markdown):\n"
"{\n"
' "page_type": "plan / section / elevation / specification / detail / general_view / table / unknown",\n'
' "title": "заголовок или null",\n'
' "beams": ["Балка Б-1"],\n'
' "positions": ["П-1"],\n'
' "gosts": ["ГОСТ ..."],\n'
' "description": "2-3 предложения о содержимом"\n'
"}\n\n"
"ПРАВИЛА:\n"
"- Только реальные элементы с чертежа, не придумывай\n"
"- Пустой массив [] если нет элементов данного типа\n"
"- НЕ включай массы из таблиц в размеры\n"
"- Описание — только факты, без оценок"
) )
client = OpenAI(base_url=LMSTUDIO_URL, api_key=LMSTUDIO_KEY)
def resize_image(image_path: Path, max_size: int = 2048) -> Tuple[str, float, Tuple[int, int]]:
img = Image.open(image_path)
orig_w, orig_h = img.size
def encode_image(image_path: Path) -> str: if max(orig_w, orig_h) <= max_size:
with open(image_path, "rb") as f: with open(image_path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8") b64 = base64.b64encode(f.read()).decode("utf-8")
return b64, 1.0, (orig_w, orig_h)
scale = max_size / max(orig_w, orig_h)
new_w = int(orig_w * scale)
new_h = int(orig_h * scale)
img_resized = img.resize((new_w, new_h), Image.LANCZOS)
buf = io.BytesIO()
img_resized.save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
return b64, scale, (orig_w, orig_h)
def describe_image(image_path: Path, model: str, prompt: str) -> str: def parse_json_response(text: str) -> Dict:
"""Отправляет PNG в VLM и получает текстовое описание.""" """Парсит JSON из ответа VLM."""
b64 = encode_image(image_path) text = text.strip()
if text.startswith("```"):
text = re.sub(r"^```[a-zA-Z]*\n?", "", text)
text = re.sub(r"\n?```$", "", text)
text = text.strip()
json_match = re.search(r'\{[\s\S]*\}', text)
if json_match:
text = json_match.group(0)
try:
return json.loads(text)
except json.JSONDecodeError as e:
print(f"[WARN] Не удалось распарсить JSON: {e}")
print(f"[WARN] Raw preview: {text[:500]}")
return {
"page_type": "unknown",
"title": None,
"elements": [],
"beams": [],
"positions": [],
"dimensions": [],
"gosts": [],
"tables": [],
"description": text[:500] if text else "",
"parse_error": str(e)
}
def describe_page(image_path: Path, model: str) -> Dict:
"""Отправляет PNG в qwen-vl API, получает структурированное описание."""
api_key = _load_api_key()
if not api_key:
raise RuntimeError("DASHSCOPE_API_KEY not found in .env or environment")
client = OpenAI(api_key=api_key, base_url=BASE_URL)
b64, scale, (orig_w, orig_h) = resize_image(image_path, max_size=2048)
data_url = f"data:image/png;base64,{b64}" data_url = f"data:image/png;base64,{b64}"
response = client.chat.completions.create( response = client.chat.completions.create(
@ -56,58 +142,86 @@ def describe_image(image_path: Path, model: str, prompt: str) -> str:
{ {
"role": "user", "role": "user",
"content": [ "content": [
{"type": "text", "text": prompt}, {"type": "text", "text": EXTRACTION_PROMPT},
{"type": "image_url", "image_url": {"url": data_url}}, {"type": "image_url", "image_url": {"url": data_url}},
], ],
} }
], ],
temperature=0.3, temperature=0.1, # низкая температура — меньше галлюцинаций
max_tokens=512, # 4B модель быстро устаёт, не гоним длину max_tokens=8192,
) )
return response.choices[0].message.content.strip() raw = response.choices[0].message.content.strip()
# Сохранить raw для отладки
debug_path = image_path.parent / f"{image_path.stem}_vlm_raw.txt"
debug_path.write_text(raw, encoding="utf-8")
result = parse_json_response(raw)
result["_meta"] = {
"image": image_path.name,
"original_size": [orig_w, orig_h],
"scale": scale,
}
return result
def process_folder(folder: Path, model: str, prompt: str): def run_vlm_describer(folder: Path, model: str = DEFAULT_MODEL):
"""Обрабатывает все PNG в папке и сохраняет описания.""" """Запускает VLM Describer для всех PNG в папке."""
png_files = sorted(folder.glob("page_*.png")) png_files = sorted(folder.glob("page_*.png"))
if not png_files: if not png_files:
print(f"[ERR] В папке {folder} не найдены page_*.png") print(f"[ERR] В папке {folder} не найдены page_*.png")
sys.exit(1) sys.exit(1)
out_path = folder / "vlm_descriptions.json" out_path = folder / "vlm_extraction.json"
descriptions = {} extractions = {}
print(f"[INFO] Найдено {len(png_files)} изображений") print(f"[INFO] VLM Describer: {len(png_files)} страниц")
print(f"[INFO] LM Studio: {LMSTUDIO_URL}") print(f"[INFO] API: DashScope ({BASE_URL})")
print(f"[INFO] Модель: {model}\n") print(f"[INFO] Модель: {model}\n")
for i, png in enumerate(png_files, 1): for i, png in enumerate(png_files, 1):
print(f"[{i}/{len(png_files)}] {png.name} ...", end=" ", flush=True) print(f"[{i}/{len(png_files)}] {png.name} ...", end=" ", flush=True)
try: try:
desc = describe_image(png, model, prompt) data = describe_page(png, model)
descriptions[png.name] = desc extractions[png.name] = data
print(f"OK ({len(desc)} chars)") elem_count = len(data.get("beams", [])) + len(data.get("positions", [])) + len(data.get("gosts", []))
print(f"OK ({elem_count} элементов)")
except Exception as e: except Exception as e:
print(f"ERR: {e}") print(f"ERR: {e}")
descriptions[png.name] = f"[ERROR] {e}" extractions[png.name] = {
"page_type": "unknown",
"error": str(e),
"elements": [],
"beams": [],
"positions": [],
"dimensions": [],
"gosts": [],
"tables": [],
"description": ""
}
with open(out_path, "w", encoding="utf-8") as f: with open(out_path, "w", encoding="utf-8") as f:
json.dump(descriptions, f, ensure_ascii=False, indent=2) json.dump(extractions, f, ensure_ascii=False, indent=2)
print(f"\n[OK] Сохранено: {out_path}") total_elems = sum(
len(v.get("beams", [])) + len(v.get("positions", [])) + len(v.get("gosts", []))
for v in extractions.values()
)
print(f"\n[OK] VLM extraction сохранён: {out_path}")
print(f" Страниц: {len(png_files)}, Всего элементов: {total_elems}")
def main(): def main():
parser = argparse.ArgumentParser(description="VLM-описания PNG через LM Studio") import argparse
parser = argparse.ArgumentParser(description="VLM Describer для чертежей")
parser.add_argument("folder", help="Папка с page_*.png") parser.add_argument("folder", help="Папка с page_*.png")
parser.add_argument("--model", default="qwen/qwen3-vl-4b", parser.add_argument("--model", default=DEFAULT_MODEL, help="Имя модели (default: qwen-vl-plus)")
help="Имя модели в LM Studio (default: qwen/qwen3-vl-4b)")
parser.add_argument("--prompt", default=DEFAULT_PROMPT,
help="Промпт для VLM")
args = parser.parse_args() args = parser.parse_args()
folder = Path(args.folder) folder = Path(args.folder)
process_folder(folder, args.model, args.prompt) run_vlm_describer(folder, args.model)
if __name__ == "__main__": if __name__ == "__main__":

234
vlm_qc_checker.py Normal file
View File

@ -0,0 +1,234 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
VLM-based Quality Control checker for blueprints через Alibaba Cloud API.
Отправляет каждую страницу PNG в qwen-vl-plus (DashScope API)
с промптом, просящим найти проблемы качества чертежа.
Результат: <output_folder>/vlm_qc_report.json тот же формат,
что и dimension_qc_report.json, для совместимости с viewer.
Использование:
python vlm_qc_checker.py <output_folder> [--model MODEL]
Требует DASHSCOPE_API_KEY в .env или окружении.
"""
import os
import sys
import json
import base64
import io
import re
from pathlib import Path
from typing import List, Dict, Tuple
from PIL import Image
from openai import OpenAI
# ------------------------------------------------------------------
# Конфигурация
# ------------------------------------------------------------------
API_KEY = None
BASE_URL = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
DEFAULT_MODEL = "qwen-vl-plus" # vision model для анализа чертежей
def _load_api_key():
global API_KEY
if API_KEY:
return API_KEY
env_candidates = [
Path(__file__).parent / ".env",
Path(__file__).parent.parent / ".env",
]
for env_path in env_candidates:
if env_path.exists():
for line in env_path.read_text().splitlines():
if line.startswith("DASHSCOPE_API_KEY="):
API_KEY = line.split("=", 1)[1].strip()
os.environ["DASHSCOPE_API_KEY"] = API_KEY
return API_KEY
API_KEY = os.environ.get("DASHSCOPE_API_KEY")
return API_KEY
QC_PROMPT = (
"Ты — опытный инженер-конструктор. Проанализируй этот чертёж и найди ошибки "
"и проблемы в простановке размеров, расположении элементов и оформлении.\n\n"
"Ищи такие проблемы:\n"
"1. Пересечение размерных линий друг с другом\n"
"2. Размеры, наложенные на текст или линии\n"
"3. Неправильное расположение размеров (слишком близко к контуру, внутри объекта)\n"
"4. Пропущенные размеры (есть линии, но нет чисел)\n"
"5. Неправильные стрелки размеров\n"
"6. Размеры вне зоны видимости (слишком далеко)\n"
"7. Некорректные цепочки размеров (разрывы)\n"
"8. Плохая читаемость размеров (маленький шрифт, плохой контраст)\n\n"
"Ответь СТРОГО в формате JSON-массива (без markdown, без ```):\n"
'[\n'
' {\n'
' "type": "DIMENSION_OVERLAP",\n'
' "severity": "warning",\n'
' "message": "Описание проблемы на русском",\n'
' "bbox": [[x1,y1],[x2,y2],[x3,y3],[x4,y4]]\n'
' }\n'
']\n\n'
"Если проблем нет — верни пустой массив [].\n"
"severity: 'error' (критично), 'warning' (стоит исправить), 'info' (замечание).\n"
"bbox — координаты проблемной зоны в пикселях (если можешь определить),"
" иначе верни null."
)
def resize_image(image_path: Path, max_size: int = 2048) -> Tuple[str, float, Tuple[int, int]]:
"""
Уменьшает изображение до max_size по длинной стороне для экономии токенов.
Возвращает (base64_string, scale_factor, (orig_w, orig_h)).
"""
img = Image.open(image_path)
orig_w, orig_h = img.size
if max(orig_w, orig_h) <= max_size:
with open(image_path, "rb") as f:
b64 = base64.b64encode(f.read()).decode("utf-8")
return b64, 1.0, (orig_w, orig_h)
scale = max_size / max(orig_w, orig_h)
new_w = int(orig_w * scale)
new_h = int(orig_h * scale)
img_resized = img.resize((new_w, new_h), Image.LANCZOS)
buf = io.BytesIO()
img_resized.save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
return b64, scale, (orig_w, orig_h)
def parse_vlm_response(text: str) -> list:
"""Парсит JSON из ответа VLM."""
text = text.strip()
if text.startswith("```"):
text = re.sub(r"^```[a-zA-Z]*\n", "", text)
text = re.sub(r"\n```$", "", text)
text = text.strip()
json_match = re.search(r'\[[\s\S]*\]', text)
if json_match:
text = json_match.group(0)
try:
data = json.loads(text)
if isinstance(data, list):
return data
elif isinstance(data, dict) and "issues" in data:
return data["issues"]
else:
return []
except json.JSONDecodeError as e:
print(f"[WARN] Не удалось распарсить JSON: {e}")
print(f"[WARN] Raw text: {text[:500]}")
return []
def analyze_page(image_path: Path, model: str) -> list:
"""Отправляет PNG в qwen-vl API, получает список issues."""
api_key = _load_api_key()
if not api_key:
raise RuntimeError("DASHSCOPE_API_KEY not found in .env or environment")
client = OpenAI(api_key=api_key, base_url=BASE_URL)
b64, scale, (orig_w, orig_h) = resize_image(image_path, max_size=2048)
data_url = f"data:image/png;base64,{b64}"
response = client.chat.completions.create(
model=model,
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": QC_PROMPT},
{"type": "image_url", "image_url": {"url": data_url}},
],
}
],
temperature=0.2,
max_tokens=4096,
)
raw = response.choices[0].message.content.strip()
# Сохранить raw для отладки
debug_path = image_path.parent / f"{image_path.stem}_vlm_raw.txt"
debug_path.write_text(raw, encoding="utf-8")
issues = parse_vlm_response(raw)
# Масштабировать bbox обратно к оригиналу
if scale != 1.0:
for issue in issues:
bbox = issue.get("bbox")
if bbox and isinstance(bbox, list):
for point in bbox:
if isinstance(point, list) and len(point) == 2:
point[0] = round(point[0] / scale)
point[1] = round(point[1] / scale)
return issues
def run_vlm_qc(folder: Path, model: str = DEFAULT_MODEL):
"""Запускает VLM-QC для всех PNG в папке."""
png_files = sorted(folder.glob("page_*.png"))
if not png_files:
print(f"[ERR] В папке {folder} не найдены page_*.png")
sys.exit(1)
out_path = folder / "vlm_qc_report.json"
report = {"errors": [], "warnings": [], "infos": [], "source": "vlm"}
print(f"[INFO] VLM QC: {len(png_files)} страниц")
print(f"[INFO] API: DashScope ({BASE_URL})")
print(f"[INFO] Модель: {model}\n")
for i, png in enumerate(png_files, 1):
print(f"[{i}/{len(png_files)}] {png.name} ...", end=" ", flush=True)
try:
issues = analyze_page(png, model)
page_num = int(png.stem.split("_")[1])
for issue in issues:
issue["page"] = page_num
issue["source"] = "vlm"
sev = issue.get("severity", "warning")
if sev not in ("error", "warning", "info"):
sev = "warning"
report[f"{sev}s"].append(issue)
print(f"OK ({len(issues)} issues)")
except Exception as e:
print(f"ERR: {e}")
with open(out_path, "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
total = sum(len(report[k]) for k in ["errors", "warnings", "infos"])
print(f"\n[OK] VLM QC сохранён: {out_path}")
print(f" Всего замечаний: {total}")
print(f" Ошибки: {len(report['errors'])}, Предупреждения: {len(report['warnings'])}, Инфо: {len(report['infos'])})")
def main():
import argparse
parser = argparse.ArgumentParser(description="VLM QC для чертежей через qwen-vl API")
parser.add_argument("folder", help="Папка с page_*.png")
parser.add_argument("--model", default=DEFAULT_MODEL, help="Имя модели (default: qwen-vl-plus)")
args = parser.parse_args()
folder = Path(args.folder)
run_vlm_qc(folder, args.model)
if __name__ == "__main__":
main()