Compare commits
5 Commits
c756a5766b
...
95093736da
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95093736da | ||
|
|
eaddf9f14b | ||
|
|
f37c477a0a | ||
|
|
feeb02242b | ||
|
|
b5f7c6327e |
10
.gitignore
vendored
10
.gitignore
vendored
@ -12,6 +12,16 @@ __pycache__/
|
||||
# Secrets
|
||||
.env
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# PDFs and uploads
|
||||
*.pdf
|
||||
backend/uploads/
|
||||
backend/outputs/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
122
backend/README.md
Normal file
122
backend/README.md
Normal 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
1
backend/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# backend/__init__.py
|
||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# app/__init__.py
|
||||
192
backend/app/crud.py
Normal file
192
backend/app/crud.py
Normal 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
23
backend/app/database.py
Normal 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
401
backend/app/main.py
Normal 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
95
backend/app/models.py
Normal 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
217
backend/app/processing.py
Normal 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
137
backend/app/schemas.py
Normal 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
223
backend/backend.log
Normal 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
137
backend/import_existing.py
Normal 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
6
backend/requirements.txt
Normal 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
329
backend/static/index.html
Normal 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
495
backend/static/review.html
Normal 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
160
compare_ocr.py
Normal 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
193
dimension_extractor.py
Normal 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
349
dimension_qc_checker.py
Normal 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
114
generate_dzi.py
Normal 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
857
generate_web_viewer.py
Normal 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
235
gost_dimension_validator.py
Normal 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
298
layout_detector.py
Normal 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
183
multi_element_extractor.py
Normal 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
233
ocr_qwen.py
Normal 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
51
preprocess_for_ocr.py
Normal 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)
|
||||
@ -2,14 +2,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Универсальное распознавание 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 json
|
||||
import re
|
||||
import fitz
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
from rapidocr_onnxruntime import RapidOCR
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@ -17,19 +24,95 @@ from rapidocr_onnxruntime import RapidOCR
|
||||
# ------------------------------------------------------------------
|
||||
DPI = 300
|
||||
BATCH_SIZE = 5
|
||||
TILE_SIZE = 2000
|
||||
TILE_OVERLAP = 200
|
||||
|
||||
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)
|
||||
doc = fitz.open(pdf_path)
|
||||
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 = []
|
||||
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)
|
||||
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"
|
||||
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)
|
||||
ocr_lines = []
|
||||
if res and res[0] is not None:
|
||||
@ -48,26 +165,17 @@ def process_pdf(pdf_path: Path, out_dir: Path):
|
||||
"confidence": float(score),
|
||||
"bbox": box
|
||||
})
|
||||
|
||||
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} ===")
|
||||
return ocr_lines
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
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:
|
||||
pdf_file = "123.pdf"
|
||||
out_name = "output_123"
|
||||
@ -82,7 +190,7 @@ def main():
|
||||
print(f"[ERR] Файл не найден: {pdf_path}")
|
||||
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__":
|
||||
main()
|
||||
|
||||
@ -83,7 +83,8 @@ def get_lmstudio_backend(model: str = "qwen2.5:14b"):
|
||||
temperature=kwargs.get("temperature", 0.3),
|
||||
max_tokens=kwargs.get("max_tokens", 1024),
|
||||
)
|
||||
return response.choices[0].message.content
|
||||
content = response.choices[0].message.content
|
||||
return content if content is not None else ""
|
||||
|
||||
embed_model = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
|
||||
|
||||
@ -117,7 +118,8 @@ def get_opencode_backend(model: str = "nemotron-3-super-free"):
|
||||
temperature=kwargs.get("temperature", 0.3),
|
||||
max_tokens=kwargs.get("max_tokens", 1024),
|
||||
)
|
||||
return response.choices[0].message.content
|
||||
content = response.choices[0].message.content
|
||||
return content if content is not None else ""
|
||||
|
||||
embed_model = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
|
||||
|
||||
|
||||
139
test_qwen_ocr.py
Normal file
139
test_qwen_ocr.py
Normal 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
157
tiling_ocr.py
Normal 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
54
visualize_dimensions.py
Normal 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)
|
||||
206
vlm_describer.py
206
vlm_describer.py
@ -1,53 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Генерация текстовых описаний PNG-страниц через VLM в LM Studio.
|
||||
VLM Describer — объективное извлечение структуры чертежа.
|
||||
|
||||
Требования:
|
||||
- Запущен LM Studio с загруженной моделью (например, qwen3-vl-4b)
|
||||
- Сервер: http://127.0.0.1:1234/v1
|
||||
Отправляет PNG в qwen-vl-plus (DashScope API) с промптом на фактическое
|
||||
описание содержимого. НЕ ищет ошибки, НЕ оценивает качество.
|
||||
|
||||
Результат: <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 sys
|
||||
import json
|
||||
import base64
|
||||
import argparse
|
||||
import io
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Tuple
|
||||
from PIL import Image
|
||||
from openai import OpenAI
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Конфигурация LM Studio
|
||||
# Конфигурация
|
||||
# ------------------------------------------------------------------
|
||||
LMSTUDIO_URL = os.environ.get("LMSTUDIO_URL", "http://127.0.0.1:1234/v1")
|
||||
LMSTUDIO_KEY = os.environ.get("LMSTUDIO_API_KEY", "lm-studio")
|
||||
API_KEY = None
|
||||
BASE_URL = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"
|
||||
DEFAULT_MODEL = "qwen-vl-plus"
|
||||
|
||||
DEFAULT_PROMPT = (
|
||||
"Опиши этот чертеж подробно. Укажи:\n"
|
||||
"- Какой это этаж (если видно)\n"
|
||||
"- Какие оси обозначены\n"
|
||||
"- Какие размеры указаны\n"
|
||||
"- Какие помещения/квартиры видны\n"
|
||||
"- Общую компоновку и заметные детали.\n"
|
||||
"Отвечай на русском языке."
|
||||
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
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:
|
||||
"""Отправляет PNG в VLM и получает текстовое описание."""
|
||||
b64 = encode_image(image_path)
|
||||
def parse_json_response(text: str) -> Dict:
|
||||
"""Парсит 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:
|
||||
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}"
|
||||
|
||||
response = client.chat.completions.create(
|
||||
@ -56,58 +142,86 @@ def describe_image(image_path: Path, model: str, prompt: str) -> str:
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "text", "text": EXTRACTION_PROMPT},
|
||||
{"type": "image_url", "image_url": {"url": data_url}},
|
||||
],
|
||||
}
|
||||
],
|
||||
temperature=0.3,
|
||||
max_tokens=512, # 4B модель быстро устаёт, не гоним длину
|
||||
temperature=0.1, # низкая температура — меньше галлюцинаций
|
||||
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):
|
||||
"""Обрабатывает все PNG в папке и сохраняет описания."""
|
||||
def run_vlm_describer(folder: Path, model: str = DEFAULT_MODEL):
|
||||
"""Запускает VLM Describer для всех 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_descriptions.json"
|
||||
descriptions = {}
|
||||
out_path = folder / "vlm_extraction.json"
|
||||
extractions = {}
|
||||
|
||||
print(f"[INFO] Найдено {len(png_files)} изображений")
|
||||
print(f"[INFO] LM Studio: {LMSTUDIO_URL}")
|
||||
print(f"[INFO] VLM Describer: {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:
|
||||
desc = describe_image(png, model, prompt)
|
||||
descriptions[png.name] = desc
|
||||
print(f"OK ({len(desc)} chars)")
|
||||
data = describe_page(png, model)
|
||||
extractions[png.name] = data
|
||||
elem_count = len(data.get("beams", [])) + len(data.get("positions", [])) + len(data.get("gosts", []))
|
||||
print(f"OK ({elem_count} элементов)")
|
||||
except Exception as 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:
|
||||
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():
|
||||
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("--model", default="qwen/qwen3-vl-4b",
|
||||
help="Имя модели в LM Studio (default: qwen/qwen3-vl-4b)")
|
||||
parser.add_argument("--prompt", default=DEFAULT_PROMPT,
|
||||
help="Промпт для VLM")
|
||||
parser.add_argument("--model", default=DEFAULT_MODEL, help="Имя модели (default: qwen-vl-plus)")
|
||||
args = parser.parse_args()
|
||||
|
||||
folder = Path(args.folder)
|
||||
process_folder(folder, args.model, args.prompt)
|
||||
run_vlm_describer(folder, args.model)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
234
vlm_qc_checker.py
Normal file
234
vlm_qc_checker.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user