Add dimension QC, DZI generator, web viewer, and fix RAG query bug
- dimension_qc_checker.py: rules-based QC for dimension chains, overlaps, crowding - generate_dzi.py: Deep Zoom Image tile pyramid generator for OpenSeadragon - generate_web_viewer.py: OpenSeadragon viewer with SVG overlays and issue feedback buttons - rag_query.py: fix LightRAG remove_think_tags crash on None response from LLM - .gitignore: add *.pdf, *.db, backend/uploads/, backend/outputs/
This commit is contained in:
parent
eaddf9f14b
commit
95093736da
10
.gitignore
vendored
10
.gitignore
vendored
@ -12,6 +12,16 @@ __pycache__/
|
|||||||
# Secrets
|
# Secrets
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# PDFs and uploads
|
||||||
|
*.pdf
|
||||||
|
backend/uploads/
|
||||||
|
backend/outputs/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
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()
|
||||||
@ -83,7 +83,8 @@ def get_lmstudio_backend(model: str = "qwen2.5:14b"):
|
|||||||
temperature=kwargs.get("temperature", 0.3),
|
temperature=kwargs.get("temperature", 0.3),
|
||||||
max_tokens=kwargs.get("max_tokens", 1024),
|
max_tokens=kwargs.get("max_tokens", 1024),
|
||||||
)
|
)
|
||||||
return response.choices[0].message.content
|
content = response.choices[0].message.content
|
||||||
|
return content if content is not None else ""
|
||||||
|
|
||||||
embed_model = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
|
embed_model = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
|
||||||
|
|
||||||
@ -117,7 +118,8 @@ def get_opencode_backend(model: str = "nemotron-3-super-free"):
|
|||||||
temperature=kwargs.get("temperature", 0.3),
|
temperature=kwargs.get("temperature", 0.3),
|
||||||
max_tokens=kwargs.get("max_tokens", 1024),
|
max_tokens=kwargs.get("max_tokens", 1024),
|
||||||
)
|
)
|
||||||
return response.choices[0].message.content
|
content = response.choices[0].message.content
|
||||||
|
return content if content is not None else ""
|
||||||
|
|
||||||
embed_model = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
|
embed_model = SentenceTransformer("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user