Initial portal commit: landing + 9 AI-powered apps
Apps: - dwg-rooms: extract room numbers from DWG/DXF - dwg-counting: count symbols in PDF drawings (OpenCV template matching) - contract-check: review PDF contracts against a checklist (Claude vision + Tesseract OCR fallback) - email-drafter: bullet notes → polished Czech/English business emails - invoice-extractor: PDF/image invoice → structured data → Excel - translator: Czech-first translator across 19 languages with tone control - vv-check: find inconsistent unit prices across VV sheets in one workbook - vv-compare: diff original vs new VV files (changes / added / removed) - feature-request: portal users submit ideas + sample files Infrastructure: - LiteLLM gateway with per-app virtual keys + budgets - Langfuse observability - Geist font, shared theme, cross-subdomain back link + theme sync via cookie/URL - Caddy reverse proxy on *.klas.chat
This commit is contained in:
3
dwg-counting/.env.example
Normal file
3
dwg-counting/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
LITELLM_BASE_URL=http://host.docker.internal:4000/v1
|
||||
LITELLM_API_KEY=sk-...
|
||||
LLM_MODEL=anthropic/claude-sonnet-4-20250514
|
||||
47
dwg-counting/Dockerfile
Normal file
47
dwg-counting/Dockerfile
Normal file
@@ -0,0 +1,47 @@
|
||||
# Stage 1: compile LibreDWG (provides dwgread for DWG → DXF conversion)
|
||||
FROM debian:bookworm-slim AS libredwg-builder
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
xz-utils \
|
||||
build-essential \
|
||||
autoconf \
|
||||
automake \
|
||||
libtool \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY libredwg-0.12.5.tar.xz .
|
||||
RUN tar xf libredwg-0.12.5.tar.xz \
|
||||
&& cd libredwg-0.12.5 \
|
||||
&& ./configure --prefix=/opt/libredwg --disable-shared --disable-bindings \
|
||||
&& make -j"$(nproc)" \
|
||||
&& make install \
|
||||
&& cd .. \
|
||||
&& rm -rf libredwg-0.12.5 libredwg-0.12.5.tar.xz
|
||||
|
||||
# Stage 2: runtime
|
||||
FROM python:3.12-slim
|
||||
|
||||
COPY --from=libredwg-builder /opt/libredwg/bin/dwgread /usr/local/bin/dwgread
|
||||
|
||||
# poppler-utils for pdf2image; cairo + pango for cairosvg
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
poppler-utils \
|
||||
libgl1 \
|
||||
libcairo2 \
|
||||
libpango-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /tmp/dwg-counting
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
286
dwg-counting/counting.py
Normal file
286
dwg-counting/counting.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""Shape-based symbol counting via contour matching.
|
||||
|
||||
Why not cv2.matchTemplate: CAD symbols are usually thin-line drawings on a
|
||||
white background (~10-20% ink). Normalized cross-correlation gives spuriously
|
||||
high scores for any sparse-ink region (e.g. wall edges), producing false
|
||||
positives everywhere.
|
||||
|
||||
Approach used here:
|
||||
1. Binarize template + drawing to "ink" maps.
|
||||
2. Find external contours in both.
|
||||
3. Use the template's main contour as a shape reference.
|
||||
4. For every contour in the drawing, compare via cv2.matchShapes (Hu moments
|
||||
— invariant to scale, rotation, translation).
|
||||
5. Filter by area ratio (similar size to template, allowing ±factor).
|
||||
6. Keep contours below a shape-distance threshold.
|
||||
|
||||
This is the classic approach to CAD symbol takeoff.
|
||||
"""
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Template matching: higher = stricter. 0.65 is permissive, 0.85 strict.
|
||||
DEFAULT_THRESHOLD = 0.7
|
||||
|
||||
|
||||
def _prep(img_path: Path) -> np.ndarray:
|
||||
"""Binarize to 'ink vs not-ink'.
|
||||
|
||||
No dilation — for contour-based matching we need lines to stay narrow
|
||||
so distinct symbols don't merge into one mega-contour through CAD's
|
||||
dense linework.
|
||||
"""
|
||||
arr = cv2.imread(str(img_path), cv2.IMREAD_GRAYSCALE)
|
||||
if arr is None:
|
||||
raise RuntimeError(f"Could not load {img_path}")
|
||||
_, ink = cv2.threshold(arr, 245, 255, cv2.THRESH_BINARY_INV)
|
||||
return ink
|
||||
|
||||
|
||||
def _crop_to_content(template: np.ndarray, bg_threshold: int = 240) -> np.ndarray:
|
||||
"""Crop a template to its non-background bounding box.
|
||||
|
||||
After _prep the template is BINARY (ink=255, bg=0). So 'content' is
|
||||
everything with value > 0. The bg_threshold param is kept for the
|
||||
grayscale case for backward compat.
|
||||
"""
|
||||
if template.max() <= 1 or template.min() == 0 and template.max() == 255:
|
||||
# Binary map: any non-zero pixel is ink
|
||||
mask = template > 0
|
||||
else:
|
||||
mask = template < bg_threshold
|
||||
if not mask.any():
|
||||
return template
|
||||
ys, xs = np.where(mask)
|
||||
y0, y1 = ys.min(), ys.max() + 1
|
||||
x0, x1 = xs.min(), xs.max() + 1
|
||||
pad = 2
|
||||
y0 = max(0, y0 - pad)
|
||||
x0 = max(0, x0 - pad)
|
||||
y1 = min(template.shape[0], y1 + pad)
|
||||
x1 = min(template.shape[1], x1 + pad)
|
||||
return template[y0:y1, x0:x1]
|
||||
|
||||
|
||||
def _nms(boxes: list[tuple], scores: list[float], overlap_thresh: float = 0.3) -> list[int]:
|
||||
"""Non-max suppression. Returns indices of kept boxes."""
|
||||
if not boxes:
|
||||
return []
|
||||
boxes_arr = np.array(boxes, dtype=np.float32)
|
||||
x1 = boxes_arr[:, 0]
|
||||
y1 = boxes_arr[:, 1]
|
||||
x2 = boxes_arr[:, 2]
|
||||
y2 = boxes_arr[:, 3]
|
||||
areas = (x2 - x1) * (y2 - y1)
|
||||
order = np.argsort(scores)[::-1]
|
||||
keep = []
|
||||
while order.size > 0:
|
||||
i = order[0]
|
||||
keep.append(int(i))
|
||||
xx1 = np.maximum(x1[i], x1[order[1:]])
|
||||
yy1 = np.maximum(y1[i], y1[order[1:]])
|
||||
xx2 = np.minimum(x2[i], x2[order[1:]])
|
||||
yy2 = np.minimum(y2[i], y2[order[1:]])
|
||||
w = np.maximum(0.0, xx2 - xx1)
|
||||
h = np.maximum(0.0, yy2 - yy1)
|
||||
inter = w * h
|
||||
union = areas[i] + areas[order[1:]] - inter
|
||||
iou = inter / np.maximum(union, 1e-6)
|
||||
order = order[1:][iou <= overlap_thresh]
|
||||
return keep
|
||||
|
||||
|
||||
MAX_DRAWING_PX = 9000 # effectively no downscale for typical A1/A0
|
||||
|
||||
|
||||
def debug_template(template_path: Path, drawing_path: Path) -> dict:
|
||||
"""Return diagnostics for tuning a symbol template."""
|
||||
template = _prep(template_path)
|
||||
template_cropped = _crop_to_content(template)
|
||||
drawing = _prep(drawing_path)
|
||||
info = {
|
||||
"template_size": list(template.shape),
|
||||
"template_cropped_size": list(template_cropped.shape),
|
||||
"template_ink_pixels": int((template_cropped > 0).sum()
|
||||
if template_cropped.max() == 255 and template_cropped.min() == 0
|
||||
else (template_cropped < 240).sum()),
|
||||
"template_total_pixels": int(template_cropped.size),
|
||||
"drawing_size": list(drawing.shape),
|
||||
}
|
||||
if min(template_cropped.shape) < 8:
|
||||
info["error"] = "template too small after content crop"
|
||||
return info
|
||||
# Scale sweep — finds the size at which template matches best
|
||||
scale_scan = []
|
||||
best_overall = -1.0
|
||||
for scale in (0.15, 0.25, 0.35, 0.5, 0.7, 0.85, 1.0, 1.2, 1.5, 2.0):
|
||||
nw = max(8, int(template_cropped.shape[1] * scale))
|
||||
nh = max(8, int(template_cropped.shape[0] * scale))
|
||||
if nh > drawing.shape[0] or nw > drawing.shape[1]:
|
||||
continue
|
||||
tmpl = cv2.resize(template_cropped, (nw, nh), interpolation=cv2.INTER_AREA)
|
||||
res = cv2.matchTemplate(drawing, tmpl, cv2.TM_CCOEFF_NORMED)
|
||||
m = float(res.max())
|
||||
scale_scan.append({
|
||||
"scale": scale,
|
||||
"template_px": [nh, nw],
|
||||
"max_score": round(m, 3),
|
||||
"count_at_0.7": int((res >= 0.7).sum()),
|
||||
"count_at_0.6": int((res >= 0.6).sum()),
|
||||
})
|
||||
if m > best_overall:
|
||||
best_overall = m
|
||||
info["max_score"] = best_overall
|
||||
info["scale_scan"] = scale_scan
|
||||
# Threshold counts at scale=1.0 for reference
|
||||
result = cv2.matchTemplate(drawing, template_cropped, cv2.TM_CCOEFF_NORMED)
|
||||
info["matches_at_threshold"] = {
|
||||
"0.60": int((result >= 0.60).sum()),
|
||||
"0.70": int((result >= 0.70).sum()),
|
||||
"0.75": int((result >= 0.75).sum()),
|
||||
"0.80": int((result >= 0.80).sum()),
|
||||
"0.85": int((result >= 0.85).sum()),
|
||||
"0.90": int((result >= 0.90).sum()),
|
||||
}
|
||||
return info
|
||||
|
||||
|
||||
def _merge_template_contour(ink: np.ndarray) -> np.ndarray | None:
|
||||
"""Combine all strokes of a template into one contour via closing.
|
||||
|
||||
Returns the biggest connected component. The template is small enough that
|
||||
closing safely merges the line + arc of symbols like '-C' into one shape
|
||||
without affecting matching against the drawing.
|
||||
"""
|
||||
closed = cv2.morphologyEx(ink, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))
|
||||
contours, _ = cv2.findContours(closed, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
|
||||
if not contours:
|
||||
return None
|
||||
return max(contours, key=cv2.contourArea)
|
||||
|
||||
|
||||
def count_template(
|
||||
template_path: Path,
|
||||
drawing_path: Path,
|
||||
threshold: float = DEFAULT_THRESHOLD,
|
||||
rotations: Iterable[int] = (0, 90, 180, 270),
|
||||
scales: Iterable[float] = (0.6, 0.8, 1.0, 1.25, 1.5),
|
||||
exclude_box: tuple | None = None,
|
||||
area_tolerance: float = 200.0,
|
||||
mirror: bool = True,
|
||||
) -> dict:
|
||||
"""Multi-scale, multi-rotation template matching (TM_SQDIFF_NORMED).
|
||||
|
||||
With mirror=True the template is also tested flipped horizontally — at
|
||||
each rotation — so we catch mirrored instances (e.g. a socket facing
|
||||
left vs facing right).
|
||||
"""
|
||||
template = _prep(template_path)
|
||||
template = _crop_to_content(template)
|
||||
drawing = _prep(drawing_path)
|
||||
|
||||
coord_scale = 1.0
|
||||
if max(drawing.shape) > MAX_DRAWING_PX:
|
||||
coord_scale = max(drawing.shape) / MAX_DRAWING_PX
|
||||
new_w = int(drawing.shape[1] / coord_scale)
|
||||
new_h = int(drawing.shape[0] / coord_scale)
|
||||
drawing = cv2.resize(drawing, (new_w, new_h), interpolation=cv2.INTER_AREA)
|
||||
|
||||
if exclude_box is not None:
|
||||
ex_x, ex_y, ex_w, ex_h = exclude_box
|
||||
ex_x = int(ex_x / coord_scale)
|
||||
ex_y = int(ex_y / coord_scale)
|
||||
ex_w = int(ex_w / coord_scale)
|
||||
ex_h = int(ex_h / coord_scale)
|
||||
h, w = drawing.shape
|
||||
ex_x = max(0, min(w, ex_x))
|
||||
ex_y = max(0, min(h, ex_y))
|
||||
ex_x2 = max(0, min(w, ex_x + ex_w))
|
||||
ex_y2 = max(0, min(h, ex_y + ex_h))
|
||||
drawing[ex_y:ex_y2, ex_x:ex_x2] = 0
|
||||
logger.info("Masked legend region (%d,%d,%d,%d)", ex_x, ex_y, ex_w, ex_h)
|
||||
|
||||
if min(template.shape) < 8 or int((template > 0).sum()) < 5:
|
||||
logger.warning("Template too small / empty after preprocessing")
|
||||
return {"count": 0, "matches": [], "threshold_used": threshold}
|
||||
|
||||
all_boxes: list[tuple] = []
|
||||
all_scores: list[float] = []
|
||||
|
||||
# Build the variants we'll search with: rotations × (original, mirrored)
|
||||
variants = [(template, False)]
|
||||
if mirror:
|
||||
variants.append((cv2.flip(template, 1), True))
|
||||
|
||||
def _rotate(img, angle_deg):
|
||||
if angle_deg == 0:
|
||||
return img
|
||||
if angle_deg in (90, 180, 270):
|
||||
return np.rot90(img, k=angle_deg // 90)
|
||||
# Arbitrary angle — rotate with bbox expansion + white fill
|
||||
h, w = img.shape[:2]
|
||||
center = (w / 2, h / 2)
|
||||
M = cv2.getRotationMatrix2D(center, angle_deg, 1.0)
|
||||
cos = abs(M[0, 0]); sin = abs(M[0, 1])
|
||||
new_w = int(h * sin + w * cos)
|
||||
new_h = int(h * cos + w * sin)
|
||||
M[0, 2] += new_w / 2 - center[0]
|
||||
M[1, 2] += new_h / 2 - center[1]
|
||||
return cv2.warpAffine(img, M, (new_w, new_h),
|
||||
flags=cv2.INTER_NEAREST, borderValue=0)
|
||||
|
||||
# Build the full job list (one entry per rot×scale×mirror)
|
||||
jobs_list = []
|
||||
for variant, _ in variants:
|
||||
for rot in rotations:
|
||||
base = _rotate(variant, rot)
|
||||
for scale in scales:
|
||||
new_w = max(8, int(base.shape[1] * scale))
|
||||
new_h = max(8, int(base.shape[0] * scale))
|
||||
if new_h > drawing.shape[0] or new_w > drawing.shape[1]:
|
||||
continue
|
||||
tmpl = cv2.resize(base, (new_w, new_h),
|
||||
interpolation=cv2.INTER_AREA)
|
||||
jobs_list.append((tmpl, new_w, new_h))
|
||||
|
||||
def _run(args):
|
||||
tmpl, new_w, new_h = args
|
||||
sq = cv2.matchTemplate(drawing, tmpl, cv2.TM_SQDIFF_NORMED)
|
||||
cc = cv2.matchTemplate(drawing, tmpl, cv2.TM_CCOEFF_NORMED)
|
||||
sim = np.maximum(1.0 - sq, cc)
|
||||
ys, xs = np.where(sim >= threshold)
|
||||
out = []
|
||||
for y, x in zip(ys, xs):
|
||||
out.append((float(x), float(y), float(x + new_w),
|
||||
float(y + new_h), float(sim[y, x])))
|
||||
return out
|
||||
|
||||
# Threading: cv2.matchTemplate releases the GIL, so threads give real speedup
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as ex:
|
||||
for result in ex.map(_run, jobs_list):
|
||||
for x0, y0, x1, y1, score in result:
|
||||
all_boxes.append((x0, y0, x1, y1))
|
||||
all_scores.append(score)
|
||||
|
||||
if not all_boxes:
|
||||
return {"count": 0, "matches": [], "threshold_used": threshold}
|
||||
|
||||
keep = _nms(all_boxes, all_scores, overlap_thresh=0.3)
|
||||
matches = []
|
||||
for i in keep:
|
||||
x0, y0, x1, y1 = all_boxes[i]
|
||||
matches.append({
|
||||
"x": int(x0 * coord_scale),
|
||||
"y": int(y0 * coord_scale),
|
||||
"w": int((x1 - x0) * coord_scale),
|
||||
"h": int((y1 - y0) * coord_scale),
|
||||
"score": round(all_scores[i], 3),
|
||||
})
|
||||
return {"count": len(matches), "matches": matches, "threshold_used": threshold}
|
||||
24
dwg-counting/docker-compose.yml
Normal file
24
dwg-counting/docker-compose.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
dwg-counting:
|
||||
build: .
|
||||
container_name: dwg-counting
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:3026:8000"
|
||||
environment:
|
||||
LITELLM_BASE_URL: ${LITELLM_BASE_URL:-http://host.docker.internal:4000/v1}
|
||||
LITELLM_API_KEY: ${LITELLM_API_KEY:-}
|
||||
LLM_MODEL: ${LLM_MODEL:-anthropic/claude-sonnet-4-20250514}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
- localai
|
||||
volumes:
|
||||
- dwg-counting-data:/tmp/dwg-counting
|
||||
|
||||
volumes:
|
||||
dwg-counting-data:
|
||||
|
||||
networks:
|
||||
localai:
|
||||
external: true
|
||||
41
dwg-counting/excel_export.py
Normal file
41
dwg-counting/excel_export.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Export symbol count results to XLSX."""
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
|
||||
|
||||
def export_to_excel(results: list[dict], filename: str, out_path: str) -> None:
|
||||
"""results = [{id, description, count, confidence, notes}, ...]"""
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Symboly"
|
||||
|
||||
headers = ["Symbol", "Popis", "Počet", "Spolehlivost", "Poznámka"]
|
||||
ws.append(headers)
|
||||
for cell in ws[1]:
|
||||
cell.font = Font(bold=True, color="FFFFFF")
|
||||
cell.fill = PatternFill("solid", fgColor="2563EB")
|
||||
cell.alignment = Alignment(horizontal="center")
|
||||
|
||||
for r in results:
|
||||
ws.append([
|
||||
r.get("id", ""),
|
||||
r.get("description", ""),
|
||||
r.get("count", 0),
|
||||
r.get("confidence", ""),
|
||||
r.get("notes", ""),
|
||||
])
|
||||
|
||||
# Auto-width
|
||||
widths = [16, 60, 10, 14, 40]
|
||||
for i, w in enumerate(widths, 1):
|
||||
ws.column_dimensions[chr(64 + i)].width = w
|
||||
|
||||
# Total row
|
||||
total = sum(int(r.get("count", 0) or 0) for r in results)
|
||||
ws.append([])
|
||||
last = ws.max_row + 1
|
||||
ws.append(["", f"Celkem ({filename})", total, "", ""])
|
||||
ws[f"B{last+1}"].font = Font(bold=True)
|
||||
ws[f"C{last+1}"].font = Font(bold=True)
|
||||
|
||||
wb.save(out_path)
|
||||
BIN
dwg-counting/libredwg-0.12.5.tar.xz
Normal file
BIN
dwg-counting/libredwg-0.12.5.tar.xz
Normal file
Binary file not shown.
440
dwg-counting/main.py
Normal file
440
dwg-counting/main.py
Normal file
@@ -0,0 +1,440 @@
|
||||
"""FastAPI app: upload DWG/DXF/PDF → vision-detect legend → count selected symbols → Excel."""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, File, HTTPException, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
from counting import count_template, debug_template
|
||||
from excel_export import export_to_excel
|
||||
from pdf_export import render_annotated_pdf
|
||||
from renderer import crop_region, render
|
||||
from vision import detect_legend
|
||||
|
||||
DEFAULT_FLOOR_INDEX = 0 # MVP: process the first detected floor
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(title="DWG Symbol Counter")
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
WORK_DIR = Path(os.getenv("WORK_DIR", "/tmp/dwg-counting"))
|
||||
WORK_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
jobs: dict[str, dict] = {}
|
||||
# Persistent action log so the operator can replay what the user did.
|
||||
ACTION_LOG = WORK_DIR / "action.log"
|
||||
|
||||
|
||||
def _log_action(event: str, **fields):
|
||||
import datetime, json as _json
|
||||
line = _json.dumps({
|
||||
"ts": datetime.datetime.now().isoformat(timespec="seconds"),
|
||||
"event": event,
|
||||
**fields,
|
||||
}, ensure_ascii=False)
|
||||
try:
|
||||
with open(ACTION_LOG, "a") as f:
|
||||
f.write(line + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return FileResponse("static/index.html")
|
||||
|
||||
|
||||
@app.post("/api/upload")
|
||||
async def upload(file: UploadFile = File(...), auto_detect: bool = False):
|
||||
suffix = Path(file.filename or "").suffix.lower()
|
||||
if suffix != ".pdf":
|
||||
raise HTTPException(400, "Podporovaný formát: .pdf")
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
job_dir = WORK_DIR / job_id
|
||||
job_dir.mkdir()
|
||||
|
||||
input_path = job_dir / f"input{suffix}"
|
||||
input_path.write_bytes(await file.read())
|
||||
logger.info("Job %s: %s (%d bytes)", job_id, file.filename, input_path.stat().st_size)
|
||||
_log_action("upload", job_id=job_id, filename=file.filename,
|
||||
size=input_path.stat().st_size, auto_detect=auto_detect)
|
||||
|
||||
try:
|
||||
rendered = await asyncio.to_thread(render, input_path, job_dir)
|
||||
floors = rendered["floors"]
|
||||
if not floors:
|
||||
raise RuntimeError("Z výkresu se nepodařilo nic vyrenderovat")
|
||||
floor = floors[DEFAULT_FLOOR_INDEX]
|
||||
png_path = job_dir / floor["png"]
|
||||
legend_norm = floor.get("legend_norm_bbox")
|
||||
legend_pixel_box = None
|
||||
symbols = []
|
||||
detect_path = png_path
|
||||
|
||||
if legend_norm:
|
||||
from PIL import Image as _Img
|
||||
full_img = _Img.open(png_path)
|
||||
W, H = full_img.size
|
||||
nx0, ny0, nx1, ny1 = legend_norm
|
||||
# Expand box generously around the LEGENDA text
|
||||
cx = (nx0 + nx1) / 2
|
||||
cy = (ny0 + ny1) / 2
|
||||
# Legend rows extend DOWNWARD from the LEGENDA header text.
|
||||
# Crop a narrow column starting just above the header.
|
||||
half_w = 0.10
|
||||
top_pad = 0.02
|
||||
below = 0.30
|
||||
px0 = max(0, int((cx - half_w) * W))
|
||||
px1 = min(W, int((cx + half_w) * W))
|
||||
py0 = max(0, int((cy - top_pad) * H))
|
||||
py1 = min(H, int((cy + below) * H))
|
||||
legend_crop = job_dir / "legend_area.png"
|
||||
crop_img = full_img.crop((px0, py0, px1, py1))
|
||||
crop_img.save(legend_crop, "PNG")
|
||||
detect_path = legend_crop
|
||||
legend_pixel_box = (px0, py0, px1 - px0, py1 - py0)
|
||||
logger.info("Legend area %dx%d ready", px1 - px0, py1 - py0)
|
||||
|
||||
if auto_detect:
|
||||
symbols = await detect_legend(detect_path)
|
||||
|
||||
# Symbol bboxes returned by vision are normalized to the image vision saw
|
||||
# (detect_path), which may be the cropped legend region or the full page.
|
||||
for s in symbols:
|
||||
bbox = s.get("bbox") or {}
|
||||
if all(k in bbox for k in ("x", "y", "w", "h")):
|
||||
crop_path = job_dir / f"sym_{s['id']}.png"
|
||||
try:
|
||||
crop_region(detect_path, bbox, crop_path, pad=1.5, min_px=120)
|
||||
s["crop_file"] = crop_path.name
|
||||
except Exception as exc:
|
||||
logger.warning("Crop failed for %s: %s", s.get("id"), exc)
|
||||
|
||||
jobs[job_id] = {
|
||||
"filename": file.filename,
|
||||
"png_path": str(png_path),
|
||||
"job_dir": str(job_dir),
|
||||
"floors": floors,
|
||||
"symbols": symbols,
|
||||
"results": [],
|
||||
"legend_pixel_box": legend_pixel_box,
|
||||
"next_user_sym_id": 1,
|
||||
}
|
||||
return {"job_id": job_id, "symbols": symbols, "floor_count": len(floors),
|
||||
"auto_detect": auto_detect}
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Job %s failed: %s", job_id, exc)
|
||||
raise HTTPException(500, str(exc))
|
||||
|
||||
|
||||
@app.get("/api/preview/{job_id}")
|
||||
async def preview(job_id: str):
|
||||
if job_id not in jobs:
|
||||
raise HTTPException(404, "Not found")
|
||||
return FileResponse(jobs[job_id]["png_path"], media_type="image/png")
|
||||
|
||||
|
||||
@app.get("/api/symbol/{job_id}/{sym_id}")
|
||||
async def symbol_crop(job_id: str, sym_id: str):
|
||||
if job_id not in jobs:
|
||||
raise HTTPException(404, "Not found")
|
||||
job_dir = Path(jobs[job_id]["job_dir"])
|
||||
crop_path = job_dir / f"sym_{sym_id}.png"
|
||||
if not crop_path.exists():
|
||||
raise HTTPException(404, "Crop not available")
|
||||
return FileResponse(crop_path, media_type="image/png")
|
||||
|
||||
|
||||
@app.get("/api/legend/{job_id}")
|
||||
async def legend_image(job_id: str):
|
||||
"""Return the cropped legend area image (what vision saw)."""
|
||||
if job_id not in jobs:
|
||||
raise HTTPException(404, "Not found")
|
||||
legend_path = Path(jobs[job_id]["job_dir"]) / "legend_area.png"
|
||||
if not legend_path.exists():
|
||||
# Fall back to full page if no legend crop
|
||||
legend_path = Path(jobs[job_id]["png_path"])
|
||||
return FileResponse(legend_path, media_type="image/png")
|
||||
|
||||
|
||||
class RecropRequest(BaseModel):
|
||||
bbox: dict # {x, y, w, h} normalized 0-1 relative to the legend image
|
||||
|
||||
|
||||
class CreateSymbolRequest(BaseModel):
|
||||
bbox: dict # normalized 0-1 relative to the FULL drawing image
|
||||
description: str
|
||||
source: str = "drawing" # "drawing" or "legend"
|
||||
|
||||
|
||||
@app.post("/api/symbols/{job_id}")
|
||||
async def create_user_symbol(job_id: str, req: CreateSymbolRequest):
|
||||
"""User drew a rectangle on the drawing → create a symbol from that crop."""
|
||||
if job_id not in jobs:
|
||||
raise HTTPException(404, "Not found")
|
||||
job = jobs[job_id]
|
||||
job_dir = Path(job["job_dir"])
|
||||
# Pick source image
|
||||
if req.source == "legend":
|
||||
src = job_dir / "legend_area.png"
|
||||
if not src.exists():
|
||||
src = Path(job["png_path"])
|
||||
else:
|
||||
src = Path(job["png_path"])
|
||||
|
||||
sym_id = f"user_{job['next_user_sym_id']}"
|
||||
job["next_user_sym_id"] += 1
|
||||
crop_path = job_dir / f"sym_{sym_id}.png"
|
||||
# User's rectangle is exact — no padding, no min size enforcement.
|
||||
crop_region(src, req.bbox, crop_path, pad=0.0, min_px=0)
|
||||
sym = {
|
||||
"id": sym_id,
|
||||
"description": req.description or sym_id,
|
||||
"bbox": req.bbox,
|
||||
"crop_file": crop_path.name,
|
||||
"user_defined": True,
|
||||
}
|
||||
job["symbols"].append(sym)
|
||||
_log_action("create_symbol", job_id=job_id, sym_id=sym_id,
|
||||
description=req.description, bbox=req.bbox, source=req.source)
|
||||
return sym
|
||||
|
||||
|
||||
@app.post("/api/symbols/{job_id}/upload")
|
||||
async def upload_symbol_image(
|
||||
job_id: str,
|
||||
file: UploadFile = File(...),
|
||||
description: str = "",
|
||||
):
|
||||
"""Accept a pre-cropped symbol image as a template, bypassing the
|
||||
rectangle-drawing UI. Useful when the user has a clean PNG from elsewhere.
|
||||
"""
|
||||
if job_id not in jobs:
|
||||
raise HTTPException(404, "Not found")
|
||||
job = jobs[job_id]
|
||||
job_dir = Path(job["job_dir"])
|
||||
sym_id = f"user_{job['next_user_sym_id']}"
|
||||
job["next_user_sym_id"] += 1
|
||||
crop_path = job_dir / f"sym_{sym_id}.png"
|
||||
raw = await file.read()
|
||||
crop_path.write_bytes(raw)
|
||||
# Normalize: ensure RGB on white background (drop alpha so processing
|
||||
# doesn't see transparency as "not white")
|
||||
from PIL import Image as _Img
|
||||
img = _Img.open(crop_path)
|
||||
if img.mode in ("RGBA", "LA"):
|
||||
bg = _Img.new("RGB", img.size, (255, 255, 255))
|
||||
bg.paste(img, mask=img.split()[-1])
|
||||
bg.save(crop_path, "PNG")
|
||||
sym = {
|
||||
"id": sym_id,
|
||||
"description": (description or file.filename or sym_id).strip(),
|
||||
"crop_file": crop_path.name,
|
||||
"user_defined": True,
|
||||
"uploaded": True,
|
||||
}
|
||||
job["symbols"].append(sym)
|
||||
_log_action("upload_symbol", job_id=job_id, sym_id=sym_id,
|
||||
description=sym["description"], filename=file.filename,
|
||||
size=len(raw))
|
||||
return sym
|
||||
|
||||
|
||||
@app.delete("/api/symbols/{job_id}/{sym_id}")
|
||||
async def delete_symbol(job_id: str, sym_id: str):
|
||||
if job_id not in jobs:
|
||||
raise HTTPException(404, "Not found")
|
||||
job = jobs[job_id]
|
||||
job["symbols"] = [s for s in job["symbols"] if s["id"] != sym_id]
|
||||
crop = Path(job["job_dir"]) / f"sym_{sym_id}.png"
|
||||
if crop.exists():
|
||||
crop.unlink()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/auto-detect/{job_id}")
|
||||
async def trigger_auto_detect(job_id: str):
|
||||
"""Run the vision legend detection on demand (optional shortcut)."""
|
||||
if job_id not in jobs:
|
||||
raise HTTPException(404, "Not found")
|
||||
job = jobs[job_id]
|
||||
job_dir = Path(job["job_dir"])
|
||||
legend_path = job_dir / "legend_area.png"
|
||||
detect_path = legend_path if legend_path.exists() else Path(job["png_path"])
|
||||
found = await detect_legend(detect_path)
|
||||
for s in found:
|
||||
bbox = s.get("bbox") or {}
|
||||
if all(k in bbox for k in ("x", "y", "w", "h")):
|
||||
crop_path = job_dir / f"sym_{s['id']}.png"
|
||||
try:
|
||||
crop_region(detect_path, bbox, crop_path, pad=1.5, min_px=120)
|
||||
s["crop_file"] = crop_path.name
|
||||
except Exception as exc:
|
||||
logger.warning("Crop failed for %s: %s", s.get("id"), exc)
|
||||
# Replace vision-detected ones (keep user-defined)
|
||||
job["symbols"] = [s for s in job["symbols"] if s.get("user_defined")] + found
|
||||
return {"symbols": job["symbols"]}
|
||||
|
||||
|
||||
@app.get("/api/drawing/{job_id}")
|
||||
async def drawing_image(job_id: str):
|
||||
"""Return the rendered full drawing image (for the user to crop on)."""
|
||||
if job_id not in jobs:
|
||||
raise HTTPException(404, "Not found")
|
||||
return FileResponse(jobs[job_id]["png_path"], media_type="image/png")
|
||||
|
||||
|
||||
@app.get("/api/debug/{job_id}/{sym_id}")
|
||||
async def debug_symbol(job_id: str, sym_id: str):
|
||||
"""Diagnostics about a symbol's template: size, ink, match scores."""
|
||||
if job_id not in jobs:
|
||||
raise HTTPException(404, "Not found")
|
||||
job = jobs[job_id]
|
||||
crop = Path(job["job_dir"]) / f"sym_{sym_id}.png"
|
||||
if not crop.exists():
|
||||
raise HTTPException(404, "Crop not available")
|
||||
drawing = Path(job["png_path"])
|
||||
info = await asyncio.to_thread(debug_template, crop, drawing)
|
||||
return info
|
||||
|
||||
|
||||
@app.get("/api/debug-template/{job_id}/{sym_id}")
|
||||
async def debug_template_image(job_id: str, sym_id: str):
|
||||
"""Return the *processed* template (what the matcher actually sees)."""
|
||||
import cv2
|
||||
from counting import _prep, _crop_to_content
|
||||
if job_id not in jobs:
|
||||
raise HTTPException(404, "Not found")
|
||||
job = jobs[job_id]
|
||||
crop = Path(job["job_dir"]) / f"sym_{sym_id}.png"
|
||||
if not crop.exists():
|
||||
raise HTTPException(404, "Crop not available")
|
||||
tmpl = _prep(crop)
|
||||
tmpl = _crop_to_content(tmpl)
|
||||
out = Path(job["job_dir"]) / f"sym_{sym_id}_processed.png"
|
||||
cv2.imwrite(str(out), tmpl)
|
||||
return FileResponse(str(out), media_type="image/png")
|
||||
|
||||
|
||||
@app.post("/api/symbol/{job_id}/{sym_id}/recrop")
|
||||
async def recrop_symbol(job_id: str, sym_id: str, req: RecropRequest):
|
||||
"""Replace a symbol's crop. bbox is normalized 0-1 relative to the FULL
|
||||
drawing image (which is what the frontend shows in the editor)."""
|
||||
if job_id not in jobs:
|
||||
raise HTTPException(404, "Not found")
|
||||
job = jobs[job_id]
|
||||
src = Path(job["png_path"])
|
||||
crop_path = Path(job["job_dir"]) / f"sym_{sym_id}.png"
|
||||
crop_region(src, req.bbox, crop_path, pad=0.0, min_px=0)
|
||||
return {"ok": True, "crop_file": crop_path.name}
|
||||
|
||||
|
||||
class CountRequest(BaseModel):
|
||||
symbol_ids: list[str]
|
||||
threshold: float | None = None # Override default (0.7); lower = more matches
|
||||
|
||||
|
||||
@app.post("/api/count/{job_id}")
|
||||
async def count(job_id: str, req: CountRequest):
|
||||
if job_id not in jobs:
|
||||
raise HTTPException(404, "Not found")
|
||||
job = jobs[job_id]
|
||||
png = Path(job["png_path"])
|
||||
job_dir = Path(job["job_dir"])
|
||||
selected = [s for s in job["symbols"] if s["id"] in req.symbol_ids]
|
||||
|
||||
# Determine the legend mask box (avoid matching the legend itself).
|
||||
legend_box = job.get("legend_pixel_box") # set during upload if available
|
||||
|
||||
thr = req.threshold if req.threshold is not None else None
|
||||
|
||||
def _count_one(sym):
|
||||
crop = job_dir / f"sym_{sym['id']}.png"
|
||||
if not crop.exists():
|
||||
return {"id": sym["id"], "description": sym["description"],
|
||||
"count": 0, "matches": [], "notes": "no crop"}
|
||||
try:
|
||||
kwargs = {"exclude_box": legend_box}
|
||||
if thr is not None:
|
||||
kwargs["threshold"] = thr
|
||||
res = count_template(crop, png, **kwargs)
|
||||
except Exception as exc:
|
||||
logger.exception("Counting failed for %s", sym["id"])
|
||||
return {"id": sym["id"], "description": sym["description"],
|
||||
"count": 0, "matches": [], "notes": f"error: {exc}"}
|
||||
return {
|
||||
"id": sym["id"],
|
||||
"description": sym["description"],
|
||||
"count": res["count"],
|
||||
"matches": res["matches"],
|
||||
"notes": "" if res["count"] else "žádné shody nenalezeny",
|
||||
}
|
||||
|
||||
# Serialize OpenCV calls — parallel matchTemplate on a 4000px drawing
|
||||
# blows past 4GB peak memory and OOM-kills the container.
|
||||
results = []
|
||||
for s in selected:
|
||||
r = await asyncio.to_thread(_count_one, s)
|
||||
results.append(r)
|
||||
_log_action("count_one", job_id=job_id, sym_id=s["id"],
|
||||
description=s.get("description"), count=r.get("count"))
|
||||
job["results"] = list(results)
|
||||
# Trim matches from API response (keep them server-side for PDF export)
|
||||
response_results = [{k: v for k, v in r.items() if k != "matches"} | {"count": r["count"]}
|
||||
for r in job["results"]]
|
||||
return {"results": response_results}
|
||||
|
||||
|
||||
@app.get("/api/export/{job_id}")
|
||||
async def export(job_id: str):
|
||||
if job_id not in jobs:
|
||||
raise HTTPException(404, "Not found")
|
||||
job = jobs[job_id]
|
||||
if not job["results"]:
|
||||
raise HTTPException(400, "Nejprve spočítejte symboly")
|
||||
out_path = Path(job["job_dir"]) / "counts.xlsx"
|
||||
export_to_excel(job["results"], job["filename"] or "drawing", str(out_path))
|
||||
stem = Path(job["filename"]).stem if job["filename"] else "drawing"
|
||||
return FileResponse(
|
||||
str(out_path),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
filename=f"symboly_{stem}.xlsx",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/export-pdf/{job_id}")
|
||||
async def export_pdf(job_id: str):
|
||||
if job_id not in jobs:
|
||||
raise HTTPException(404, "Not found")
|
||||
job = jobs[job_id]
|
||||
if not job["results"]:
|
||||
raise HTTPException(400, "Nejprve spočítejte symboly")
|
||||
png_path = Path(job["png_path"])
|
||||
stem = Path(job["filename"]).stem if job["filename"] else "drawing"
|
||||
out_path = Path(job["job_dir"]) / "annotated.pdf"
|
||||
await asyncio.to_thread(
|
||||
render_annotated_pdf, png_path, job["results"], out_path, stem,
|
||||
)
|
||||
return FileResponse(
|
||||
str(out_path),
|
||||
media_type="application/pdf",
|
||||
filename=f"vyznaceno_{stem}.pdf",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
93
dwg-counting/pdf_export.py
Normal file
93
dwg-counting/pdf_export.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Render annotated PDF with color-coded symbol match highlights."""
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Distinct visually-separable colors (RGB) for up to 12 symbol types
|
||||
PALETTE = [
|
||||
(228, 26, 28), # red
|
||||
(55, 126, 184), # blue
|
||||
(77, 175, 74), # green
|
||||
(152, 78, 163), # purple
|
||||
(255, 127, 0), # orange
|
||||
(0, 184, 184), # teal
|
||||
(247, 129, 191), # pink
|
||||
(153, 153, 0), # olive
|
||||
(166, 86, 40), # brown
|
||||
(102, 102, 102), # grey
|
||||
(0, 0, 0), # black
|
||||
(190, 81, 51), # rust
|
||||
]
|
||||
|
||||
|
||||
def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||
for path in (
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
):
|
||||
try:
|
||||
return ImageFont.truetype(path, size)
|
||||
except OSError:
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def render_annotated_pdf(
|
||||
drawing_path: Path,
|
||||
results: list[dict],
|
||||
out_path: Path,
|
||||
filename: str = "drawing",
|
||||
) -> Path:
|
||||
"""Build a multi-page PDF: page 1 = legend, page 2 = annotated drawing.
|
||||
|
||||
results: [{id, description, count, matches: [{x,y,w,h,score,rot}, ...]}, ...]
|
||||
"""
|
||||
base = Image.open(drawing_path).convert("RGB")
|
||||
annotated = base.copy()
|
||||
draw = ImageDraw.Draw(annotated)
|
||||
|
||||
# Scale stroke width by image size so boxes are visible at any zoom
|
||||
stroke = max(2, min(annotated.size) // 800)
|
||||
label_font = _load_font(max(14, min(annotated.size) // 200))
|
||||
|
||||
for idx, r in enumerate(results):
|
||||
color = PALETTE[idx % len(PALETTE)]
|
||||
for m in r.get("matches", []):
|
||||
x, y, w, h = m["x"], m["y"], m["w"], m["h"]
|
||||
draw.rectangle([x, y, x + w, y + h], outline=color, width=stroke)
|
||||
|
||||
# Legend page
|
||||
legend_w = annotated.size[0]
|
||||
row_h = max(36, legend_w // 30)
|
||||
legend_h = row_h * (len(results) + 4)
|
||||
legend = Image.new("RGB", (legend_w, legend_h), "white")
|
||||
ldraw = ImageDraw.Draw(legend)
|
||||
title_font = _load_font(max(28, legend_w // 50))
|
||||
ldraw.text((40, 30), f"Počítání symbolů — {filename}", font=title_font, fill="black")
|
||||
ldraw.text(
|
||||
(40, 30 + row_h),
|
||||
f"Nalezeno {sum(r['count'] for r in results)} symbolů v {len(results)} kategoriích.",
|
||||
font=label_font, fill="black",
|
||||
)
|
||||
|
||||
y = 30 + 3 * row_h
|
||||
swatch = row_h - 10
|
||||
for idx, r in enumerate(results):
|
||||
color = PALETTE[idx % len(PALETTE)]
|
||||
ldraw.rectangle([40, y, 40 + swatch, y + swatch], fill=color, outline="black", width=2)
|
||||
text = f"{r.get('description', r['id'])} — {r['count']}×"
|
||||
ldraw.text((40 + swatch + 16, y), text, font=label_font, fill="black")
|
||||
y += row_h
|
||||
|
||||
# Save as multi-page PDF
|
||||
legend.save(
|
||||
out_path,
|
||||
"PDF",
|
||||
resolution=150.0,
|
||||
save_all=True,
|
||||
append_images=[annotated],
|
||||
)
|
||||
return out_path
|
||||
351
dwg-counting/renderer.py
Normal file
351
dwg-counting/renderer.py
Normal file
@@ -0,0 +1,351 @@
|
||||
"""Render DWG/DXF/PDF → PNG image(s) for vision model consumption.
|
||||
|
||||
Strategy: multi-floor architectural drawings are split per detected legend.
|
||||
Each floor renders to its own PNG at a resolution where individual symbols
|
||||
remain distinguishable for the vision model.
|
||||
"""
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import io
|
||||
|
||||
import ezdxf
|
||||
from ezdxf.addons.drawing.config import (
|
||||
BackgroundPolicy, ColorPolicy, Configuration,
|
||||
)
|
||||
from ezdxf.addons.drawing import Frontend, RenderContext, layout
|
||||
from ezdxf.addons.drawing.svg import SVGBackend
|
||||
import cairosvg
|
||||
from PIL import Image
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RENDER_PX = 8000 # target pixel size of the longer edge — Claude vision max
|
||||
|
||||
|
||||
def dwg_to_dxf(dwg_path: Path, out_dir: Path) -> Path:
|
||||
dxf_path = out_dir / f"{dwg_path.stem}.dxf"
|
||||
r = subprocess.run(
|
||||
["dwgread", "-O", "DXF", "-o", str(dxf_path), str(dwg_path)],
|
||||
capture_output=True, text=True, timeout=180,
|
||||
)
|
||||
if not dxf_path.exists():
|
||||
raise RuntimeError(f"DWG→DXF failed (exit {r.returncode}): {r.stderr or r.stdout}")
|
||||
return dxf_path
|
||||
|
||||
|
||||
SKIP_LAYER_PATTERNS = (
|
||||
"dimens", "kota", "kóta", "koty", # dimensions
|
||||
"sit_konst", "konstrukce", "ckkoty", # structural / dimensioning
|
||||
"ckprofi", "profily", "csprofily", # steel/concrete profile rezy
|
||||
"sanita", "vzt", "ov_kan", "tzb", # plumbing / HVAC (when not the target)
|
||||
"viewport", "defpoints", "0_", "_b_", # CAD bookkeeping
|
||||
"raster", "wipeout",
|
||||
)
|
||||
|
||||
|
||||
def _clean_doc(doc, drop_layers=False):
|
||||
"""Strip elements that overwhelm vision rendering."""
|
||||
msp = doc.modelspace()
|
||||
valid_blocks = {b.name for b in doc.blocks}
|
||||
for e in list(msp.query("INSERT")):
|
||||
if e.dxf.name not in valid_blocks:
|
||||
msp.delete_entity(e)
|
||||
for typ in ("HATCH", "SOLID", "MPOLYGON"):
|
||||
for e in list(msp.query(typ)):
|
||||
msp.delete_entity(e)
|
||||
if drop_layers:
|
||||
for e in list(msp):
|
||||
layer = str(getattr(e.dxf, "layer", "")).lower()
|
||||
if any(p in layer for p in SKIP_LAYER_PATTERNS):
|
||||
try:
|
||||
msp.delete_entity(e)
|
||||
except Exception:
|
||||
pass
|
||||
for e in msp:
|
||||
try:
|
||||
e.dxf.lineweight = 5
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def find_floors(dxf_path: Path) -> list[dict]:
|
||||
"""Find legend 'LEGENDA' markers — each represents one floor.
|
||||
|
||||
Returns list of {legend_xy, floor_bbox} dicts ordered top to bottom.
|
||||
"""
|
||||
doc = ezdxf.readfile(str(dxf_path))
|
||||
msp = doc.modelspace()
|
||||
|
||||
positions = []
|
||||
for e in msp:
|
||||
text = ""
|
||||
if e.dxftype() == "MTEXT":
|
||||
text = e.text
|
||||
elif e.dxftype() == "TEXT":
|
||||
text = e.dxf.text
|
||||
if text and text.strip().upper() == "LEGENDA":
|
||||
try:
|
||||
positions.append((e.dxf.insert.x, e.dxf.insert.y))
|
||||
except Exception:
|
||||
pass
|
||||
positions.sort(key=lambda p: -p[1])
|
||||
|
||||
if not positions:
|
||||
return [{"legend_xy": None, "floor_bbox": None}]
|
||||
|
||||
# Use the same cleanup as render_region so extents reflect what'll be drawn
|
||||
_clean_doc(doc)
|
||||
from ezdxf.bbox import extents
|
||||
try:
|
||||
ext = extents(msp, fast=True)
|
||||
mxmin, mymin = ext.extmin.x, ext.extmin.y
|
||||
mxmax, mymax = ext.extmax.x, ext.extmax.y
|
||||
except Exception:
|
||||
mxmin, mymin = -1e9, -1e9
|
||||
mxmax, mymax = 1e9, 1e9
|
||||
logger.info("find_floors: model extents x=(%.0f,%.0f) y=(%.0f,%.0f)",
|
||||
mxmin, mxmax, mymin, mymax)
|
||||
|
||||
floors = []
|
||||
for i, (lx, ly) in enumerate(positions):
|
||||
if i + 1 < len(positions):
|
||||
y_height = ly - positions[i + 1][1]
|
||||
elif i > 0:
|
||||
y_height = positions[i - 1][1] - ly
|
||||
else:
|
||||
y_height = 60000
|
||||
# Legend appears at the TOP of its floor view; plan extends downward
|
||||
y_top = min(mymax, ly + 0.10 * y_height)
|
||||
y_bot = max(mymin, ly - 0.95 * y_height)
|
||||
# X span = whole model width (floor plans typically span the page width)
|
||||
x_left = mxmin
|
||||
x_right = mxmax
|
||||
floors.append({
|
||||
"legend_xy": (lx, ly),
|
||||
"floor_bbox": (x_left, y_bot, x_right, y_top),
|
||||
})
|
||||
return floors
|
||||
|
||||
|
||||
def render_region(dxf_path: Path, out_path: Path, bbox: tuple | None) -> Path:
|
||||
"""Render a DXF (optionally clipped to bbox) to PNG via SVG.
|
||||
|
||||
Pipeline: ezdxf → SVG (vector, faithful linework) → cairosvg → PNG.
|
||||
Cropping is done in pixel space after rasterization, using the page
|
||||
rectangle ezdxf assigned to the SVG.
|
||||
"""
|
||||
doc = ezdxf.readfile(str(dxf_path))
|
||||
auditor = doc.audit()
|
||||
if auditor.has_errors:
|
||||
logger.info("DXF audit: %d errors", len(auditor.errors))
|
||||
_clean_doc(doc)
|
||||
msp = doc.modelspace()
|
||||
|
||||
from ezdxf.bbox import extents
|
||||
try:
|
||||
ext = extents(msp, fast=True)
|
||||
model_xmin = ext.extmin.x
|
||||
model_ymin = ext.extmin.y
|
||||
model_w = ext.size.x or 1
|
||||
model_h = ext.size.y or 1
|
||||
except Exception:
|
||||
model_xmin = model_ymin = 0
|
||||
model_w = model_h = 1
|
||||
|
||||
config = Configuration(
|
||||
background_policy=BackgroundPolicy.WHITE,
|
||||
color_policy=ColorPolicy.BLACK,
|
||||
lineweight_scaling=0.5,
|
||||
min_lineweight=0.05,
|
||||
)
|
||||
|
||||
# ezdxf SVG: target page sized so longest dimension is RENDER_PX pixels.
|
||||
# SVG uses mm; pick a scale such that the page fits cleanly.
|
||||
aspect = model_w / max(model_h, 1)
|
||||
if aspect >= 1:
|
||||
page_w_mm = 1000
|
||||
page_h_mm = 1000 / aspect
|
||||
else:
|
||||
page_h_mm = 1000
|
||||
page_w_mm = 1000 * aspect
|
||||
|
||||
page = layout.Page(width=page_w_mm, height=page_h_mm,
|
||||
units=layout.Units.mm, margins=layout.Margins.all(0))
|
||||
backend = SVGBackend()
|
||||
Frontend(RenderContext(doc), backend, config=config).draw_layout(msp, finalize=True)
|
||||
svg_str = backend.get_string(page)
|
||||
|
||||
# cairosvg renders SVG → PNG. output_width sets the PNG pixel width.
|
||||
longest_px = RENDER_PX
|
||||
out_width = longest_px if aspect >= 1 else int(longest_px * aspect)
|
||||
png_bytes = cairosvg.svg2png(bytestring=svg_str.encode("utf-8"),
|
||||
output_width=out_width)
|
||||
img = Image.open(io.BytesIO(png_bytes))
|
||||
# Ensure white background (cairosvg may produce alpha)
|
||||
if img.mode == "RGBA":
|
||||
white = Image.new("RGB", img.size, (255, 255, 255))
|
||||
white.paste(img, mask=img.split()[3])
|
||||
img = white
|
||||
|
||||
if bbox is not None:
|
||||
W, H = img.size
|
||||
xmin, ymin, xmax, ymax = bbox
|
||||
logger.info("Crop input: model_w=%.0f model_h=%.0f W=%d H=%d bbox=%s",
|
||||
model_w, model_h, W, H, bbox)
|
||||
px0 = max(0, int((xmin - model_xmin) / model_w * W))
|
||||
px1 = min(W, int((xmax - model_xmin) / model_w * W))
|
||||
py0 = max(0, int((model_ymin + model_h - ymax) / model_h * H))
|
||||
py1 = min(H, int((model_ymin + model_h - ymin) / model_h * H))
|
||||
logger.info("Crop pixels: (%d,%d) → (%d,%d)", px0, py0, px1, py1)
|
||||
if px1 > px0 and py1 > py0:
|
||||
img = img.crop((px0, py0, px1, py1))
|
||||
|
||||
if max(img.size) > RENDER_PX:
|
||||
r = RENDER_PX / max(img.size)
|
||||
img = img.resize((int(img.size[0] * r), int(img.size[1] * r)), Image.LANCZOS)
|
||||
|
||||
img.save(out_path, "PNG", optimize=True)
|
||||
logger.info("Rendered → %s (%dx%d)", out_path.name, img.size[0], img.size[1])
|
||||
return out_path
|
||||
|
||||
|
||||
def render(input_path: Path, out_dir: Path) -> dict:
|
||||
"""Convert input → list of floor images.
|
||||
|
||||
Returns: {"floors": [{"index":0, "png":"floor_0.png", "legend_xy":[x,y]}, ...]}.
|
||||
"""
|
||||
suffix = input_path.suffix.lower()
|
||||
|
||||
if suffix == ".pdf":
|
||||
return _render_pdf(input_path, out_dir)
|
||||
|
||||
if suffix == ".dwg":
|
||||
dxf_path = dwg_to_dxf(input_path, out_dir)
|
||||
elif suffix == ".dxf":
|
||||
dxf_path = input_path
|
||||
else:
|
||||
raise ValueError(f"Unsupported format: {suffix}")
|
||||
|
||||
floors = find_floors(dxf_path)
|
||||
logger.info("Detected %d floor(s) via LEGENDA markers", len(floors))
|
||||
# MVP: render only the first floor. Multi-floor selection is a follow-up.
|
||||
f = floors[0]
|
||||
png = out_dir / "floor_0.png"
|
||||
render_region(dxf_path, png, f["floor_bbox"])
|
||||
return {"floors": [{"index": 0, "png": png.name,
|
||||
"legend_xy": list(f["legend_xy"]) if f["legend_xy"] else None}]}
|
||||
|
||||
|
||||
def _render_pdf(pdf_path: Path, out_dir: Path) -> dict:
|
||||
"""Render PDF → PNG, auto-rotate so LEGENDA reads horizontally.
|
||||
|
||||
Uses pdfplumber to find 'LEGENDA' text and its rotation, then renders
|
||||
via pdf2image and applies image rotation so the legend is upright.
|
||||
Returns the same shape as the DXF render path.
|
||||
"""
|
||||
from pdf2image import convert_from_path
|
||||
import pdfplumber
|
||||
# Allow large rasterizations (architectural PDFs can be 200M+ px at high DPI)
|
||||
Image.MAX_IMAGE_PIXELS = None
|
||||
|
||||
# Pick DPI so longest page edge lands near RENDER_PX pixels
|
||||
with pdfplumber.open(str(pdf_path)) as pdf:
|
||||
first = pdf.pages[0]
|
||||
pw_in = max(first.width, first.height) / 72 # PDF points → inches
|
||||
target_dpi = max(150, min(600, int(RENDER_PX / max(pw_in, 1))))
|
||||
logger.info("PDF page longest edge %.1f in → using dpi=%d", pw_in, target_dpi)
|
||||
|
||||
pages = convert_from_path(str(pdf_path), dpi=target_dpi)
|
||||
out_paths = []
|
||||
legend_info: list[dict] = []
|
||||
|
||||
with pdfplumber.open(str(pdf_path)) as pdf:
|
||||
for i, plumb_page in enumerate(pdf.pages):
|
||||
page_img = pages[i] if i < len(pages) else None
|
||||
if page_img is None:
|
||||
continue
|
||||
pw, ph = plumb_page.width, plumb_page.height
|
||||
iw, ih = page_img.size
|
||||
# Find any text matching legend headings
|
||||
words = plumb_page.extract_words(extra_attrs=["upright"]) or []
|
||||
legend_word = None
|
||||
for w in words:
|
||||
text = w["text"].strip().upper()
|
||||
if text in ("LEGENDA", "VYSVĚTLIVKY", "LEGENDA:", "POPIS"):
|
||||
legend_word = w
|
||||
break
|
||||
rotation = 0
|
||||
if legend_word is not None and not legend_word.get("upright", True):
|
||||
# Sideways text → rotate the image so text is upright.
|
||||
# PIL.rotate uses CCW for positive angles.
|
||||
rotation = 90
|
||||
page_img = page_img.rotate(90, expand=True)
|
||||
iw, ih = page_img.size
|
||||
logger.info("PDF page %d: rotated 90° CCW (LEGENDA was sideways)", i)
|
||||
if legend_word is not None:
|
||||
# Convert PDF coords to NORMALIZED image coords (after rotation)
|
||||
x0, y0 = legend_word["x0"], legend_word["top"]
|
||||
x1, y1 = legend_word["x1"], legend_word["bottom"]
|
||||
if rotation == 90:
|
||||
# CCW 90° rotation: original (x, y) → new (y, W-x).
|
||||
# Rotated image has width=ph, height=pw.
|
||||
nx0 = y0 / ph
|
||||
nx1 = y1 / ph
|
||||
ny0 = 1 - (x1 / pw)
|
||||
ny1 = 1 - (x0 / pw)
|
||||
else:
|
||||
nx0, nx1 = x0 / pw, x1 / pw
|
||||
ny0, ny1 = y0 / ph, y1 / ph
|
||||
legend_info.append({"page": i, "norm_bbox": (nx0, ny0, nx1, ny1)})
|
||||
logger.info("PDF page %d: LEGENDA at norm bbox %s",
|
||||
i, (nx0, ny0, nx1, ny1))
|
||||
|
||||
if max(page_img.size) > RENDER_PX:
|
||||
r = RENDER_PX / max(page_img.size)
|
||||
page_img = page_img.resize(
|
||||
(int(page_img.size[0] * r), int(page_img.size[1] * r)),
|
||||
Image.LANCZOS,
|
||||
)
|
||||
p = out_dir / f"floor_{i}.png"
|
||||
page_img.save(p, "PNG", optimize=True)
|
||||
out_paths.append(p)
|
||||
|
||||
return {
|
||||
"floors": [
|
||||
{"index": i, "png": p.name, "legend_xy": None,
|
||||
"legend_norm_bbox": next((li["norm_bbox"] for li in legend_info
|
||||
if li["page"] == i), None)}
|
||||
for i, p in enumerate(out_paths)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def crop_region(image_path: Path, bbox: dict, out_path: Path,
|
||||
pad: float = 1.5, min_px: int = 120) -> Path:
|
||||
"""Crop a region with generous padding so symbols are visible.
|
||||
|
||||
pad: multiplier of the bbox half-extent added on each side.
|
||||
min_px: ensure the output is at least this many pixels wide/tall by
|
||||
expanding the crop region if the requested area is smaller.
|
||||
"""
|
||||
img = Image.open(image_path)
|
||||
W, H = img.size
|
||||
bx, by, bw, bh = bbox["x"], bbox["y"], bbox["w"], bbox["h"]
|
||||
cx, cy = bx + bw / 2, by + bh / 2
|
||||
# Padded extents in normalized coords
|
||||
half_w = bw / 2 + bw * pad
|
||||
half_h = bh / 2 + bh * pad
|
||||
# Enforce minimum pixel dimensions
|
||||
if (2 * half_w) * W < min_px:
|
||||
half_w = (min_px / 2) / W
|
||||
if (2 * half_h) * H < min_px:
|
||||
half_h = (min_px / 2) / H
|
||||
x0 = max(0, int((cx - half_w) * W))
|
||||
x1 = min(W, int((cx + half_w) * W))
|
||||
y0 = max(0, int((cy - half_h) * H))
|
||||
y1 = min(H, int((cy + half_h) * H))
|
||||
crop = img.crop((x0, y0, x1, y1))
|
||||
crop.save(out_path, "PNG")
|
||||
return out_path
|
||||
16
dwg-counting/requirements.txt
Normal file
16
dwg-counting/requirements.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.30
|
||||
ezdxf>=1.3
|
||||
matplotlib>=3.8
|
||||
cairosvg>=2.7
|
||||
Pillow>=10.0
|
||||
pdf2image>=1.17
|
||||
pdfplumber>=0.11
|
||||
opencv-python-headless>=4.10
|
||||
numpy>=1.26
|
||||
reportlab>=4.0
|
||||
openpyxl>=3.1
|
||||
python-multipart>=0.0.9
|
||||
openai>=1.50
|
||||
python-dotenv>=1.0
|
||||
aiofiles>=23.0
|
||||
442
dwg-counting/static/app.js
Normal file
442
dwg-counting/static/app.js
Normal file
@@ -0,0 +1,442 @@
|
||||
// dwg-counting frontend
|
||||
(() => {
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const sections = {
|
||||
upload: $("s-upload"),
|
||||
processing: $("s-processing"),
|
||||
symbols: $("s-symbols"),
|
||||
results: $("s-results"),
|
||||
};
|
||||
const show = (name) => {
|
||||
for (const [k, el] of Object.entries(sections)) el.classList.toggle("hidden", k !== name);
|
||||
};
|
||||
|
||||
let state = { jobId: null, symbols: [], selected: new Set(), results: [] };
|
||||
|
||||
// Upload handlers
|
||||
const fileInput = $("file-input");
|
||||
const dropZone = $("drop-zone");
|
||||
$("browse-btn").addEventListener("click", () => fileInput.click());
|
||||
fileInput.addEventListener("change", (e) => e.target.files[0] && upload(e.target.files[0]));
|
||||
["dragenter", "dragover"].forEach((ev) =>
|
||||
dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); }));
|
||||
["dragleave", "drop"].forEach((ev) =>
|
||||
dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.remove("drag-over"); }));
|
||||
dropZone.addEventListener("drop", (e) => e.dataTransfer.files[0] && upload(e.dataTransfer.files[0]));
|
||||
|
||||
async function upload(file) {
|
||||
show("processing");
|
||||
$("processing-title").textContent = "Připravuji výkres…";
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
try {
|
||||
// Skip auto-detection by default — user defines symbols manually
|
||||
const r = await fetch("/api/upload?auto_detect=false", {
|
||||
method: "POST", body: fd,
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
|
||||
const data = await r.json();
|
||||
state.jobId = data.job_id;
|
||||
state.symbols = data.symbols || [];
|
||||
state.selected = new Set();
|
||||
renderSymbols();
|
||||
show("symbols");
|
||||
} catch (err) {
|
||||
alert("Chyba: " + err.message);
|
||||
show("upload");
|
||||
}
|
||||
}
|
||||
|
||||
$("auto-detect-btn").addEventListener("click", async () => {
|
||||
if (!state.jobId) return;
|
||||
show("processing");
|
||||
$("processing-title").textContent = "Hledám legendu pomocí AI…";
|
||||
try {
|
||||
const r = await fetch(`/api/auto-detect/${state.jobId}`, { method: "POST" });
|
||||
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
|
||||
const data = await r.json();
|
||||
state.symbols = data.symbols || [];
|
||||
renderSymbols();
|
||||
show("symbols");
|
||||
} catch (err) {
|
||||
alert("Chyba: " + err.message);
|
||||
show("symbols");
|
||||
}
|
||||
});
|
||||
|
||||
$("add-symbol-btn").addEventListener("click", () => {
|
||||
openAddSymbolModal();
|
||||
});
|
||||
|
||||
$("upload-symbol-btn").addEventListener("click", () => {
|
||||
$("symbol-file-input").click();
|
||||
});
|
||||
$("symbol-file-input").addEventListener("change", async (e) => {
|
||||
const f = e.target.files[0];
|
||||
if (!f) return;
|
||||
const name = prompt("Název symbolu:", f.name.replace(/\.[a-z]+$/i, ""));
|
||||
if (!name) return;
|
||||
const fd = new FormData();
|
||||
fd.append("file", f);
|
||||
fd.append("description", name);
|
||||
try {
|
||||
const r = await fetch(`/api/symbols/${state.jobId}/upload`, { method: "POST", body: fd });
|
||||
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
|
||||
const sym = await r.json();
|
||||
state.symbols.push(sym);
|
||||
renderSymbols();
|
||||
} catch (err) { alert("Chyba: " + err.message); }
|
||||
e.target.value = "";
|
||||
});
|
||||
|
||||
function renderSymbols() {
|
||||
const meta = $("symbols-meta");
|
||||
if (!state.symbols.length) {
|
||||
meta.textContent = 'Klikněte „+ Přidat symbol" a vyznačte ve výkresu, co chcete počítat. Nebo zkuste „Auto-detekce z legendy" pro automatický návrh.';
|
||||
$("symbols-grid").innerHTML = "";
|
||||
return;
|
||||
}
|
||||
meta.textContent = `${state.symbols.length} symbolů. Zaškrtněte k spočítání, ✎ upravit, 🗑 smazat.`;
|
||||
const grid = $("symbols-grid");
|
||||
grid.innerHTML = "";
|
||||
for (const s of state.symbols) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "symbol-card";
|
||||
card.innerHTML = `
|
||||
<input type="checkbox" data-id="${s.id}">
|
||||
<img class="symbol-thumb" src="/api/symbol/${state.jobId}/${s.id}?v=${s._v||0}" alt=""
|
||||
onerror="this.style.visibility='hidden'">
|
||||
<div class="symbol-info">
|
||||
<div class="symbol-id">${s.id}</div>
|
||||
<div class="symbol-desc">${escapeHtml(s.description || "")}</div>
|
||||
</div>
|
||||
<button class="symbol-edit" type="button" title="Upravit výřez">✎</button>
|
||||
<button class="symbol-debug" type="button" title="Debug: zobrazit šablonu a skóre">🔍</button>
|
||||
<button class="symbol-delete" type="button" title="Smazat symbol">🗑</button>`;
|
||||
const cb = card.querySelector("input");
|
||||
cb.addEventListener("change", () => {
|
||||
if (cb.checked) { state.selected.add(s.id); card.classList.add("selected"); }
|
||||
else { state.selected.delete(s.id); card.classList.remove("selected"); }
|
||||
$("count-btn").disabled = state.selected.size === 0;
|
||||
});
|
||||
// Click on row toggles checkbox (except on edit btn / thumb)
|
||||
card.addEventListener("click", (e) => {
|
||||
if (e.target.closest(".symbol-edit") || e.target === cb) return;
|
||||
cb.checked = !cb.checked;
|
||||
cb.dispatchEvent(new Event("change"));
|
||||
});
|
||||
card.querySelector(".symbol-edit").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
openCropModal(s);
|
||||
});
|
||||
card.querySelector(".symbol-debug").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
openDebugModal(s);
|
||||
});
|
||||
card.querySelector(".symbol-delete").addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm(`Smazat symbol "${s.description || s.id}"?`)) return;
|
||||
try {
|
||||
const r = await fetch(`/api/symbols/${state.jobId}/${s.id}`, { method: "DELETE" });
|
||||
if (!r.ok) throw new Error("delete failed");
|
||||
state.symbols = state.symbols.filter(x => x.id !== s.id);
|
||||
state.selected.delete(s.id);
|
||||
renderSymbols();
|
||||
$("count-btn").disabled = state.selected.size === 0;
|
||||
} catch (err) { alert("Chyba: " + err.message); }
|
||||
});
|
||||
grid.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
// Add-new-symbol modal: same crop modal, but uses full drawing image and
|
||||
// asks for a name.
|
||||
function openAddSymbolModal() {
|
||||
const modal = $("crop-modal");
|
||||
const img = $("crop-img");
|
||||
const sel = $("crop-selection");
|
||||
const wrap = $("crop-canvas-wrap");
|
||||
$("crop-modal-title").textContent = "Nový symbol — vyznačte oblast ve výkresu";
|
||||
$("crop-modal-hint").textContent =
|
||||
"DOPORUČENO: najděte symbol PŘÍMO ve výkresu (ne v legendě) — barvy a tloušťka čar tam přesně odpovídají tomu, co budeme hledat. Vyznačte těsný rámeček kolem jedné instance symbolu a pojmenujte ho.";
|
||||
$("crop-name-row").classList.remove("hidden");
|
||||
$("crop-name").value = "";
|
||||
img.src = `/api/drawing/${state.jobId}`;
|
||||
sel.style.display = "none";
|
||||
$("crop-save").disabled = true;
|
||||
cropState = { mode: "create", sym: null, bbox: null, dragging: false };
|
||||
modal.classList.remove("hidden");
|
||||
attachCropHandlers(wrap, img, sel);
|
||||
}
|
||||
|
||||
// ── Crop modal logic ──────────────────────
|
||||
let cropState = null;
|
||||
|
||||
function openCropModal(sym) {
|
||||
const modal = $("crop-modal");
|
||||
const img = $("crop-img");
|
||||
const sel = $("crop-selection");
|
||||
const wrap = $("crop-canvas-wrap");
|
||||
$("crop-modal-title").textContent = `Upravit symbol: ${sym.description || sym.id}`;
|
||||
$("crop-modal-hint").textContent =
|
||||
"Označte oblast obsahující pouze grafický symbol (bez popisu).";
|
||||
$("crop-name-row").classList.add("hidden");
|
||||
// For editing: use the full drawing so user can pick any instance
|
||||
img.src = `/api/drawing/${state.jobId}`;
|
||||
sel.style.display = "none";
|
||||
$("crop-save").disabled = true;
|
||||
cropState = { mode: "edit", sym, bbox: null, dragging: false };
|
||||
modal.classList.remove("hidden");
|
||||
attachCropHandlers(wrap, img, sel);
|
||||
return;
|
||||
}
|
||||
|
||||
function attachCropHandlers(wrap, img, sel) {
|
||||
|
||||
// Mouse coords relative to the IMAGE's top-left (in image-pixel space,
|
||||
// which is also wrap's content space since image is at native scale and
|
||||
// sits at content origin (0,0) in the relative-positioned wrap).
|
||||
function localXY(e) {
|
||||
const rect = img.getBoundingClientRect();
|
||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||
}
|
||||
|
||||
function onMouseDown(e) {
|
||||
const p = localXY(e);
|
||||
cropState.startX = p.x;
|
||||
cropState.startY = p.y;
|
||||
cropState.dragging = true;
|
||||
sel.style.display = "block";
|
||||
sel.style.left = p.x + "px";
|
||||
sel.style.top = p.y + "px";
|
||||
sel.style.width = "0px";
|
||||
sel.style.height = "0px";
|
||||
}
|
||||
function onMouseMove(e) {
|
||||
if (!cropState || !cropState.dragging) return;
|
||||
const p = localXY(e);
|
||||
const left = Math.min(p.x, cropState.startX);
|
||||
const top = Math.min(p.y, cropState.startY);
|
||||
const w = Math.abs(p.x - cropState.startX);
|
||||
const h = Math.abs(p.y - cropState.startY);
|
||||
sel.style.left = left + "px";
|
||||
sel.style.top = top + "px";
|
||||
sel.style.width = w + "px";
|
||||
sel.style.height = h + "px";
|
||||
}
|
||||
function onMouseUp() {
|
||||
if (!cropState) return;
|
||||
cropState.dragging = false;
|
||||
const rect = img.getBoundingClientRect();
|
||||
const dispW = rect.width, dispH = rect.height;
|
||||
const left = parseFloat(sel.style.left), top = parseFloat(sel.style.top);
|
||||
const w = parseFloat(sel.style.width), h = parseFloat(sel.style.height);
|
||||
if (w < 5 || h < 5) {
|
||||
sel.style.display = "none";
|
||||
$("crop-save").disabled = true;
|
||||
return;
|
||||
}
|
||||
cropState.bbox = {
|
||||
x: left / dispW,
|
||||
y: top / dispH,
|
||||
w: w / dispW,
|
||||
h: h / dispH,
|
||||
};
|
||||
$("crop-save").disabled = false;
|
||||
}
|
||||
|
||||
wrap.onmousedown = onMouseDown;
|
||||
wrap.onmousemove = onMouseMove;
|
||||
wrap.onmouseup = onMouseUp;
|
||||
wrap.onmouseleave = onMouseUp;
|
||||
}
|
||||
|
||||
function closeCropModal() {
|
||||
$("crop-modal").classList.add("hidden");
|
||||
cropState = null;
|
||||
}
|
||||
|
||||
async function openDebugModal(sym) {
|
||||
const modal = $("debug-modal");
|
||||
const body = $("debug-body");
|
||||
$("debug-title").textContent = `Debug: ${sym.description || sym.id}`;
|
||||
body.innerHTML = '<p style="padding:20px">Načítám diagnostiku…</p>';
|
||||
modal.classList.remove("hidden");
|
||||
try {
|
||||
const r = await fetch(`/api/debug/${state.jobId}/${sym.id}`);
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
const info = await r.json();
|
||||
const tmplURL = `/api/symbol/${state.jobId}/${sym.id}?v=${sym._v||0}`;
|
||||
const procURL = `/api/debug-template/${state.jobId}/${sym.id}?v=${sym._v||0}&t=${Date.now()}`;
|
||||
const drawURL = `/api/drawing/${state.jobId}`;
|
||||
const matches = info.matches_at_threshold || {};
|
||||
const maxMatchCount = Math.max(1, ...Object.values(matches));
|
||||
const threshRows = Object.entries(matches).map(([t, n]) => `
|
||||
<div>práh ${t}</div>
|
||||
<div class="debug-bar"><div class="debug-bar-fill" style="width:${Math.min(100,(n/maxMatchCount)*100)}%"></div></div>
|
||||
<div>${n} shod</div>`).join("");
|
||||
const inkPct = info.template_total_pixels
|
||||
? ((info.template_ink_pixels / info.template_total_pixels) * 100).toFixed(1)
|
||||
: "?";
|
||||
body.innerHTML = `
|
||||
<div class="debug-section">
|
||||
<h4>Obrazy</h4>
|
||||
<div class="debug-thumbs">
|
||||
<div class="debug-thumb">
|
||||
<a href="${tmplURL}" target="_blank"><img src="${tmplURL}" alt=""></a>
|
||||
<span>Šablona (uložená)</span>
|
||||
</div>
|
||||
<div class="debug-thumb">
|
||||
<a href="${procURL}" target="_blank"><img src="${procURL}" alt=""></a>
|
||||
<span>Po předzpracování<br>(co matcher vidí)</span>
|
||||
</div>
|
||||
<div class="debug-thumb">
|
||||
<a href="${drawURL}" target="_blank">otevřít celý výkres ↗</a>
|
||||
<span>${info.drawing_size.join(" × ")} px</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="debug-section">
|
||||
<h4>Měření</h4>
|
||||
<div class="debug-kv">
|
||||
<strong>Šablona originál</strong><span>${info.template_size.join(" × ")} px</span>
|
||||
<strong>Šablona po ořezu</strong><span>${info.template_cropped_size.join(" × ")} px</span>
|
||||
<strong>Inkoust v šabloně</strong><span>${info.template_ink_pixels} / ${info.template_total_pixels} px (${inkPct}%)</span>
|
||||
<strong>Výkres</strong><span>${info.drawing_size.join(" × ")} px</span>
|
||||
<strong>Nejlepší skóre</strong><span>${info.max_score?.toFixed(3) ?? "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="debug-section">
|
||||
<h4>Shody při různých prazích (bez rotací, scale 1.0)</h4>
|
||||
<div class="debug-thresholds">${threshRows}</div>
|
||||
<p style="margin-top:8px; color: var(--text-tertiary); font-size:12px">
|
||||
Aktuálně používaný práh pro počítání: <strong>0.75</strong>.
|
||||
Skutečné počty (s rotacemi 0/90/180/270° a třemi scale faktory) jsou typicky nižší kvůli deduplikaci.
|
||||
</p>
|
||||
</div>
|
||||
<div class="debug-section" id="debug-interpret"></div>
|
||||
`;
|
||||
// Heuristic interpretation
|
||||
const interpret = [];
|
||||
if (info.template_ink_pixels < 30)
|
||||
interpret.push('⚠ Šablona obsahuje příliš málo „inkoustu" — pravděpodobně jste vybrali převážně bílou plochu. Vyznačte rámeček těsně kolem čar symbolu.');
|
||||
if (Math.min(...info.template_cropped_size) < 12)
|
||||
interpret.push("⚠ Šablona je velmi malá (<12 px). Malé šablony hlásí spoustu falešných shod nebo žádné. Zkuste přidat trochu okolí.");
|
||||
if ((info.max_score ?? 0) < 0.5)
|
||||
interpret.push("⚠ Nejlepší skóre v celém výkresu je velmi nízké — symbol vypadá v plánu jinak než v šabloně. Zkuste vyznačit symbol přímo z plánu (ne z legendy), nebo zkuste jinou variantu/orientaci.");
|
||||
else if ((info.max_score ?? 0) < 0.7)
|
||||
interpret.push("ℹ Nejlepší skóre ≈ " + info.max_score.toFixed(2) + ". Práh 0.75 je nad maximem — zkuste snížit práh nebo upravit šablonu těsněji.");
|
||||
else
|
||||
interpret.push("✓ Symbol se v plánu vyskytuje. Pokud nejsou shody, může jít o problém s rotacemi nebo měřítkem.");
|
||||
$("debug-interpret").innerHTML = "<h4>Interpretace</h4>" +
|
||||
interpret.map(t => `<p>${t}</p>`).join("");
|
||||
} catch (err) {
|
||||
body.innerHTML = `<p style="color:#dc2626; padding:12px">Chyba: ${err.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
$("debug-close").addEventListener("click", () => $("debug-modal").classList.add("hidden"));
|
||||
$("debug-ok").addEventListener("click", () => $("debug-modal").classList.add("hidden"));
|
||||
|
||||
$("crop-close").addEventListener("click", closeCropModal);
|
||||
$("crop-cancel").addEventListener("click", closeCropModal);
|
||||
$("crop-save").addEventListener("click", async () => {
|
||||
if (!cropState || !cropState.bbox) return;
|
||||
try {
|
||||
if (cropState.mode === "create") {
|
||||
const name = ($("crop-name").value || "").trim();
|
||||
if (!name) { alert("Zadejte název symbolu."); return; }
|
||||
const r = await fetch(`/api/symbols/${state.jobId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
bbox: cropState.bbox,
|
||||
description: name,
|
||||
source: "drawing",
|
||||
}),
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
|
||||
const newSym = await r.json();
|
||||
state.symbols.push(newSym);
|
||||
} else {
|
||||
const r = await fetch(`/api/symbol/${state.jobId}/${cropState.sym.id}/recrop`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ bbox: cropState.bbox }),
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
|
||||
cropState.sym._v = (cropState.sym._v || 0) + 1;
|
||||
}
|
||||
renderSymbols();
|
||||
closeCropModal();
|
||||
} catch (err) {
|
||||
alert("Chyba: " + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Threshold slider live readout
|
||||
const thrSlider = $("threshold-slider");
|
||||
const thrValue = $("threshold-value");
|
||||
thrSlider.addEventListener("input", () => {
|
||||
thrValue.textContent = parseFloat(thrSlider.value).toFixed(2);
|
||||
});
|
||||
|
||||
$("count-btn").addEventListener("click", async () => {
|
||||
show("processing");
|
||||
$("processing-title").textContent = `Počítám ${state.selected.size} symbolů…`;
|
||||
try {
|
||||
const r = await fetch(`/api/count/${state.jobId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
symbol_ids: [...state.selected],
|
||||
threshold: parseFloat(thrSlider.value),
|
||||
}),
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
|
||||
const data = await r.json();
|
||||
state.results = data.results || [];
|
||||
renderResults();
|
||||
show("results");
|
||||
} catch (err) {
|
||||
alert("Chyba: " + err.message);
|
||||
show("symbols");
|
||||
}
|
||||
});
|
||||
|
||||
function renderResults() {
|
||||
const total = state.results.reduce((a, r) => a + (r.count || 0), 0);
|
||||
$("results-meta").textContent = `Celkem ${total} symbolů ve ${state.results.length} kategoriích.`;
|
||||
const tb = $("results-tbody");
|
||||
tb.innerHTML = "";
|
||||
for (const r of state.results) {
|
||||
const tr = document.createElement("tr");
|
||||
const conf = r.confidence || "";
|
||||
tr.innerHTML = `
|
||||
<td><img class="symbol-thumb" style="width:40px;height:40px" src="/api/symbol/${state.jobId}/${r.id}" alt=""></td>
|
||||
<td>${escapeHtml(r.description || "")}</td>
|
||||
<td><strong>${r.count}</strong></td>
|
||||
<td class="confidence-${conf}">${conf || "—"}</td>
|
||||
<td>${escapeHtml(r.notes || "")}</td>`;
|
||||
tb.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
$("export-btn").addEventListener("click", () => {
|
||||
window.location.href = `/api/export/${state.jobId}`;
|
||||
});
|
||||
$("pdf-btn").addEventListener("click", () => {
|
||||
window.location.href = `/api/export-pdf/${state.jobId}`;
|
||||
});
|
||||
|
||||
$("back-btn").addEventListener("click", () => show("symbols"));
|
||||
$("reset-btn").addEventListener("click", () => {
|
||||
state = { jobId: null, symbols: [], selected: new Set(), results: [] };
|
||||
fileInput.value = "";
|
||||
show("upload");
|
||||
});
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, (c) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||
}
|
||||
})();
|
||||
301
dwg-counting/static/extra.css
Normal file
301
dwg-counting/static/extra.css
Normal file
@@ -0,0 +1,301 @@
|
||||
/* Extra styles specific to dwg-counting */
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px 6px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
text-decoration: none;
|
||||
border: 0.5px solid var(--border-default);
|
||||
background: var(--bg-primary);
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.back-link:hover {
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary));
|
||||
}
|
||||
.back-link svg { opacity: 0.8; }
|
||||
@media (max-width: 640px) {
|
||||
.back-link span { display: none; }
|
||||
.back-link { padding: 6px; }
|
||||
.back-link svg { width: 16px; height: 16px; }
|
||||
}
|
||||
|
||||
.processing-sub {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.symbols-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.symbol-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.symbol-card:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.symbol-card.selected {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 25%, transparent);
|
||||
}
|
||||
|
||||
.symbol-card input[type=checkbox] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
.symbol-thumb {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 6px;
|
||||
object-fit: contain;
|
||||
padding: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.symbol-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.symbol-id {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.symbol-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
margin-top: 2px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.threshold-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 12px 0;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.threshold-row label { font-weight: 600; }
|
||||
.threshold-row input[type=range] {
|
||||
flex: 0 0 240px;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
#threshold-value {
|
||||
display: inline-block;
|
||||
min-width: 40px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
}
|
||||
.threshold-hint { color: var(--text-tertiary); font-size: 12px; flex: 1; }
|
||||
|
||||
.confidence-high { color: #16a34a; font-weight: 600; }
|
||||
.confidence-medium { color: #d97706; font-weight: 600; }
|
||||
.confidence-low { color: #dc2626; font-weight: 600; }
|
||||
|
||||
/* Edit button on symbol cards */
|
||||
.symbol-edit {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 6px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.symbol-edit:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Crop modal */
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-content {
|
||||
background: var(--card);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.btn-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.modal-hint {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.crop-canvas-wrap {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
max-height: 65vh;
|
||||
border: 1px solid var(--border-default);
|
||||
background: #fff;
|
||||
cursor: crosshair;
|
||||
}
|
||||
#crop-img {
|
||||
display: block;
|
||||
max-width: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
#crop-selection {
|
||||
position: absolute;
|
||||
border: 2px dashed var(--primary);
|
||||
background: rgba(21, 90, 239, 0.08);
|
||||
pointer-events: none;
|
||||
display: none;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.crop-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.crop-name-row label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.crop-name-row input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.symbol-delete {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 14px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.symbol-delete:hover {
|
||||
color: #dc2626;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
.symbol-debug {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 14px;
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.symbol-debug:hover {
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.debug-body {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.debug-section {
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
.debug-section:last-child { border-bottom: none; }
|
||||
.debug-section h4 {
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.debug-kv { display: grid; grid-template-columns: 220px 1fr; gap: 4px 12px; }
|
||||
.debug-kv strong { color: var(--text-secondary); font-weight: 500; }
|
||||
.debug-thumbs { display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap; }
|
||||
.debug-thumb {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
gap: 4px; min-width: 120px;
|
||||
}
|
||||
.debug-thumb a {
|
||||
display: block;
|
||||
border: 1px solid var(--border-default);
|
||||
padding: 4px;
|
||||
background: #fff;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.debug-thumb img { display: block; max-width: 160px; max-height: 160px; }
|
||||
.debug-thumb span { font-size: 11px; color: var(--text-tertiary); }
|
||||
.debug-thresholds { display: grid; grid-template-columns: 60px 1fr 60px; gap: 4px 10px; align-items: center; }
|
||||
.debug-bar {
|
||||
height: 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.debug-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
}
|
||||
205
dwg-counting/static/index.html
Normal file
205
dwg-counting/static/index.html
Normal file
@@ -0,0 +1,205 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Počítání symbolů z PDF výkresu | Colsys AI</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap">
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
<link rel="stylesheet" href="/static/extra.css">
|
||||
<script>
|
||||
// Theme handling — runs before first paint to prevent flash.
|
||||
// Priority: URL ?theme= → localStorage → portal_theme cookie → system.
|
||||
(function () {
|
||||
var t = null;
|
||||
try {
|
||||
var url = new URL(window.location.href);
|
||||
var p = url.searchParams.get("theme");
|
||||
if (p === "dark" || p === "light") t = p;
|
||||
} catch (e) {}
|
||||
if (!t) {
|
||||
try { t = localStorage.getItem("app_theme"); } catch (e) {}
|
||||
}
|
||||
if (!t) {
|
||||
var m = document.cookie.match(/(?:^|;\s*)portal_theme=([^;]+)/);
|
||||
if (m) t = decodeURIComponent(m[1]);
|
||||
}
|
||||
if (t === "dark" || t === "light") {
|
||||
document.documentElement.classList.add(t);
|
||||
try { localStorage.setItem("app_theme", t); } catch (e) {}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="header">
|
||||
<div class="header-inner">
|
||||
<a href="https://ai.klas.chat" class="brand">
|
||||
<span class="brand-icon">C</span>
|
||||
<span class="brand-name">Colsys <span class="brand-ai">AI</span></span>
|
||||
</a>
|
||||
<span class="header-crumb">Počítání symbolů z PDF výkresu</span>
|
||||
<a href="https://ai.klas.chat" class="back-link" title="Zpět na portál">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m15 18-6-6 6-6"/>
|
||||
</svg>
|
||||
<span>Zpět na portál</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
|
||||
<!-- ── Upload ────────────────────────────── -->
|
||||
<section id="s-upload">
|
||||
<div class="section-intro">
|
||||
<h1 class="section-title">Počítání symbolů z PDF výkresu</h1>
|
||||
<p class="section-desc">
|
||||
Nahrajte výkres ve formátu <strong>PDF</strong>, vyznačte symboly, které
|
||||
chcete spočítat, a aplikace najde jejich výskyty v celém výkresu.
|
||||
Výsledek lze stáhnout jako Excel nebo jako PDF s vyznačením.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="drop-zone" class="drop-zone">
|
||||
<svg class="drop-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M12 16.5V4.5m0 0-3.75 3.75M12 4.5l3.75 3.75M4.5 19.5h15"/>
|
||||
</svg>
|
||||
<p class="drop-text">Přetáhněte PDF výkres sem</p>
|
||||
<p class="drop-or">nebo</p>
|
||||
<button class="btn btn-secondary" id="browse-btn" type="button">Vybrat soubor</button>
|
||||
<p class="drop-formats">Podporovaný formát: .pdf</p>
|
||||
<input type="file" id="file-input" accept=".pdf" style="display:none">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Processing ────────────────────────── -->
|
||||
<section id="s-processing" class="hidden">
|
||||
<div class="processing-card">
|
||||
<div class="spinner"></div>
|
||||
<h2 class="processing-title" id="processing-title">Hledám legendu ve výkresu…</h2>
|
||||
<p class="processing-sub" id="processing-sub">Toto může trvat 20–60 sekund.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Symbol selection ──────────────────── -->
|
||||
<section id="s-symbols" class="hidden">
|
||||
<div class="results-header">
|
||||
<div>
|
||||
<h2 class="results-title">Vyberte symboly k spočítání</h2>
|
||||
<p class="results-meta" id="symbols-meta"></p>
|
||||
</div>
|
||||
<div class="results-actions">
|
||||
<button class="btn btn-secondary" id="reset-btn" type="button">Nahrát jiný</button>
|
||||
<button class="btn btn-secondary" id="auto-detect-btn" type="button" title="Najít legendu pomocí AI">
|
||||
Auto-detekce z legendy
|
||||
</button>
|
||||
<button class="btn btn-primary" id="add-symbol-btn" type="button">
|
||||
+ Vyříznout symbol z výkresu
|
||||
</button>
|
||||
<button class="btn btn-secondary" id="upload-symbol-btn" type="button">
|
||||
+ Nahrát PNG
|
||||
</button>
|
||||
<input type="file" id="symbol-file-input" accept="image/*" style="display:none">
|
||||
<button class="btn btn-primary" id="count-btn" type="button" disabled>
|
||||
Spočítat vybrané
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="threshold-row">
|
||||
<label for="threshold-slider">Citlivost (práh shody):</label>
|
||||
<input type="range" id="threshold-slider" min="0.40" max="0.95" step="0.01" value="0.70">
|
||||
<span id="threshold-value">0.70</span>
|
||||
<span class="threshold-hint">nižší = víc nálezů (i falešných), vyšší = jen přesné shody</span>
|
||||
</div>
|
||||
|
||||
<div class="symbols-grid" id="symbols-grid"></div>
|
||||
</section>
|
||||
|
||||
<!-- ── Debug modal ───────────────────────── -->
|
||||
<div id="debug-modal" class="modal hidden">
|
||||
<div class="modal-content" style="max-width:760px">
|
||||
<div class="modal-header">
|
||||
<h3 id="debug-title">Debug symbolu</h3>
|
||||
<button class="btn-close" id="debug-close" type="button">×</button>
|
||||
</div>
|
||||
<div id="debug-body" class="debug-body"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" id="debug-ok" type="button">Zavřít</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Crop editor modal ─────────────────── -->
|
||||
<div id="crop-modal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 id="crop-modal-title">Vyznačte symbol</h3>
|
||||
<button class="btn-close" id="crop-close" type="button">×</button>
|
||||
</div>
|
||||
<p class="modal-hint" id="crop-modal-hint">Kliknutím a tažením vyberte oblast obsahující grafický symbol.</p>
|
||||
<div class="crop-name-row hidden" id="crop-name-row">
|
||||
<label for="crop-name">Název symbolu:</label>
|
||||
<input type="text" id="crop-name" placeholder="např. Zásuvka 230V">
|
||||
</div>
|
||||
<div class="crop-canvas-wrap" id="crop-canvas-wrap">
|
||||
<img id="crop-img" alt="" draggable="false">
|
||||
<div id="crop-selection"></div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" id="crop-cancel" type="button">Zrušit</button>
|
||||
<button class="btn btn-primary" id="crop-save" type="button" disabled>Uložit výřez</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Results ───────────────────────────── -->
|
||||
<section id="s-results" class="hidden">
|
||||
<div class="results-header">
|
||||
<div>
|
||||
<h2 class="results-title">Výsledky počítání</h2>
|
||||
<p class="results-meta" id="results-meta"></p>
|
||||
</div>
|
||||
<div class="results-actions">
|
||||
<button class="btn btn-secondary" id="back-btn" type="button">Zpět na výběr</button>
|
||||
<button class="btn btn-secondary" id="pdf-btn" type="button">
|
||||
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v12m0 0-3.75-3.75M12 16.5l3.75-3.75M4.5 19.5h15"/>
|
||||
</svg>
|
||||
PDF s vyznačením
|
||||
</button>
|
||||
<button class="btn btn-primary" id="export-btn" type="button">
|
||||
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v12m0 0-3.75-3.75M12 16.5l3.75-3.75M4.5 19.5h15"/>
|
||||
</svg>
|
||||
Exportovat Excel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:80px">Symbol</th>
|
||||
<th>Popis</th>
|
||||
<th style="width:80px">Počet</th>
|
||||
<th style="width:120px">Spolehlivost</th>
|
||||
<th>Poznámka</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="results-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
461
dwg-counting/static/styles.css
Normal file
461
dwg-counting/static/styles.css
Normal file
@@ -0,0 +1,461 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f9fafb;
|
||||
--bg-tertiary: #f2f4f7;
|
||||
--text-primary: #101828;
|
||||
--text-secondary: #354052;
|
||||
--text-tertiary: #676f83;
|
||||
--text-quaternary: #98a2b2;
|
||||
--border-default: rgb(16 24 40 / 0.08);
|
||||
--border-strong: #d0d5dc;
|
||||
--border-subtle: rgb(16 24 40 / 0.04);
|
||||
--card: #ffffff;
|
||||
--primary: #155aef;
|
||||
--primary-hover: #004aeb;
|
||||
--accent-indigo: #444ce7;
|
||||
--shadow-card: 0 1px 2px rgb(16 24 40 / 0.05);
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
}
|
||||
|
||||
/* Dark theme — applies when (a) user OS prefers dark and no .light override,
|
||||
or (b) :root has explicit .dark class (set by portal_theme cookie). */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not(.light) {
|
||||
--bg-primary: #14181f;
|
||||
--bg-secondary: #1a1f29;
|
||||
--bg-tertiary: #232936;
|
||||
--text-primary: #f5f7fa;
|
||||
--text-secondary: #c8ccd5;
|
||||
--text-tertiary: #98a2b2;
|
||||
--text-quaternary: #676f83;
|
||||
--border-default: rgb(255 255 255 / 0.08);
|
||||
--border-strong: #354052;
|
||||
--border-subtle: rgb(255 255 255 / 0.04);
|
||||
--card: #1a1f29;
|
||||
}
|
||||
}
|
||||
:root.dark {
|
||||
--bg-primary: #14181f;
|
||||
--bg-secondary: #1a1f29;
|
||||
--bg-tertiary: #232936;
|
||||
--text-primary: #f5f7fa;
|
||||
--text-secondary: #c8ccd5;
|
||||
--text-tertiary: #98a2b2;
|
||||
--text-quaternary: #676f83;
|
||||
--border-default: rgb(255 255 255 / 0.08);
|
||||
--border-strong: #354052;
|
||||
--border-subtle: rgb(255 255 255 / 0.04);
|
||||
--card: #1a1f29;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Geist", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────── */
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
border-bottom: 0.5px solid var(--border-default);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.header-inner {
|
||||
max-width: 1280px; /* match portal max-w-7xl so brand doesn't shift */
|
||||
margin: 0 auto;
|
||||
height: 56px; /* portal uses h-14 */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px; /* portal px-4 */
|
||||
gap: 16px;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.header-inner { padding: 0 32px; } /* portal sm:px-8 */
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.brand-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--accent-indigo) 100%);
|
||||
box-shadow: 0 1px 2px rgb(16 24 40 / 0.06), inset 0 1px 0 rgb(255 255 255 / 0.18);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
letter-spacing: -0.04em;
|
||||
color: white;
|
||||
}
|
||||
.brand-name {
|
||||
font-size: 14px; /* tailwind text-sm */
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em; /* tailwind tracking-tight */
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.brand-ai { color: var(--primary); }
|
||||
.header-crumb {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Main ────────────────────────────────────────── */
|
||||
.main {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 24px 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.header-inner { padding: 0 16px; }
|
||||
.main { padding: 24px 16px 60px; }
|
||||
}
|
||||
|
||||
/* ── Section intro ───────────────────────────────── */
|
||||
.section-intro { margin-bottom: 24px; }
|
||||
.section-title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.section-desc {
|
||||
color: var(--text-tertiary);
|
||||
max-width: 580px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Examples panel ──────────────────────────────── */
|
||||
.examples-panel {
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--card);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.examples-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.examples-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.examples-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.examples-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.examples-list:empty::before {
|
||||
content: "Zatím žádné vzory — přidejte alespoň jeden níže";
|
||||
font-size: 12px;
|
||||
color: var(--text-quaternary);
|
||||
font-style: italic;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.example-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary));
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent);
|
||||
color: var(--text-primary);
|
||||
padding: 4px 4px 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
.example-chip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-tertiary);
|
||||
padding: 0;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
.example-chip-remove:hover {
|
||||
background: color-mix(in srgb, var(--primary) 18%, transparent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.example-chip-remove svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
.examples-input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.example-input {
|
||||
flex: 1;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-width: 0;
|
||||
}
|
||||
.example-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
|
||||
}
|
||||
.example-add-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Drop zone ───────────────────────────────────── */
|
||||
.drop-zone {
|
||||
border: 1.5px dashed var(--border-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--card);
|
||||
padding: 56px 32px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.drop-zone:hover, .drop-zone.drag-over {
|
||||
border-color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 4%, var(--card));
|
||||
}
|
||||
.drop-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
color: var(--text-quaternary);
|
||||
margin: 0 auto 18px;
|
||||
display: block;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.drop-zone:hover .drop-icon, .drop-zone.drag-over .drop-icon {
|
||||
color: var(--primary);
|
||||
}
|
||||
.drop-text {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.drop-or {
|
||||
font-size: 13px;
|
||||
color: var(--text-quaternary);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.drop-formats {
|
||||
font-size: 12px;
|
||||
color: var(--text-quaternary);
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: background 0.15s, box-shadow 0.15s;
|
||||
line-height: 1;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 2px rgb(21 90 239 / 0.25);
|
||||
}
|
||||
.btn-primary:hover { background: var(--primary-hover); }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: default; }
|
||||
.btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-strong);
|
||||
}
|
||||
.btn-secondary:hover { background: var(--border-strong); }
|
||||
.btn-icon { width: 15px; height: 15px; flex-shrink: 0; }
|
||||
|
||||
/* ── Processing card ─────────────────────────────── */
|
||||
.processing-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 56px 32px;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 3px solid var(--border-strong);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.75s linear infinite;
|
||||
margin: 0 auto 22px;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.processing-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.steps-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
text-align: left;
|
||||
}
|
||||
.step-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
.step-item.active {
|
||||
color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary));
|
||||
}
|
||||
.step-item.done { color: #17b26a; }
|
||||
.step-item.error { color: #d92d20; background: #fef3f2; }
|
||||
.step-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Results ─────────────────────────────────────── */
|
||||
.results-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.results-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.015em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.results-meta {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.results-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
/* ── Table ───────────────────────────────────────── */
|
||||
.table-wrap {
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-card);
|
||||
background: var(--card);
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
min-width: 500px;
|
||||
}
|
||||
thead {
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
th {
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
td {
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
color: var(--text-primary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:nth-child(even) td { background: var(--bg-secondary); }
|
||||
td[contenteditable]:focus {
|
||||
outline: none;
|
||||
background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary));
|
||||
box-shadow: inset 0 0 0 1.5px var(--primary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-rule { background: #d1e0ff; color: #004aeb; }
|
||||
.badge-llm { background: #d1fae5; color: #065f46; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.badge-rule { background: #1e3a8a; color: #93c5fd; }
|
||||
.badge-llm { background: #064e3b; color: #6ee7b7; }
|
||||
}
|
||||
|
||||
.table-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-quaternary);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.hidden { display: none !important; }
|
||||
121
dwg-counting/vision.py
Normal file
121
dwg-counting/vision.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Claude Sonnet 4 vision pipeline: legend detection + symbol counting."""
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_client: AsyncOpenAI | None = None
|
||||
|
||||
|
||||
def _get_client() -> AsyncOpenAI:
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = AsyncOpenAI(
|
||||
base_url=os.getenv("LITELLM_BASE_URL", "http://litellm-proxy:4000/v1"),
|
||||
api_key=os.getenv("LITELLM_API_KEY", "sk-dummy"),
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
def _b64(path: Path) -> str:
|
||||
return base64.b64encode(path.read_bytes()).decode("ascii")
|
||||
|
||||
|
||||
MODEL = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-20250514")
|
||||
|
||||
|
||||
LEGEND_PROMPT = """You are analyzing a Czech architectural / engineering drawing (HVAC, electrical, plumbing, fire safety, etc).
|
||||
|
||||
NOTE: The drawing may be rotated 90° or 180°. Mentally rotate the page until text reads horizontally before searching. The legend is typically near a page edge or corner, often in a colored (yellow / red / green) text block that lists symbols and their meanings.
|
||||
|
||||
Your task: find any legend / symbol key in this drawing. Possible Czech headings include "LEGENDA", "VYSVĚTLIVKY", "POPIS", "POPIS SYMBOLŮ", "POUŽITÉ ZNAČENÍ". A legend is a column-table where each row pairs a small graphical symbol with a Czech description.
|
||||
|
||||
For each symbol entry found, return:
|
||||
- `id`: short stable identifier (1-3 words, lowercase with underscores; e.g. "smoke_detector", "socket_230v", "valve_3way")
|
||||
- `description`: full Czech description text exactly as written
|
||||
- `bbox`: bounding box of THE SYMBOL ONLY (NOT the description text), normalized 0-1 coords relative to the full image. Format `{"x": 0.05, "y": 0.10, "w": 0.02, "h": 0.02}` where x,y is the top-left corner.
|
||||
|
||||
Return ONLY valid JSON, no markdown, no commentary:
|
||||
{"symbols": [{"id":"...","description":"...","bbox":{"x":0,"y":0,"w":0,"h":0}}]}
|
||||
|
||||
If you genuinely cannot find any legend, return {"symbols": [], "reason": "<one-line Czech explanation of where you looked>"}.
|
||||
Skip rows that are room schedules, material totals, or project info — only include rows showing actual graphical symbols with descriptions."""
|
||||
|
||||
|
||||
COUNT_PROMPT_TEMPLATE = """You are counting graphical symbols in a Czech architectural/engineering drawing.
|
||||
|
||||
I will show you:
|
||||
1. A reference symbol crop from the drawing's legend
|
||||
2. The full drawing image
|
||||
|
||||
Your task: count the number of times the reference symbol appears in the full drawing. Look only at the drawing area (not the legend itself). The symbol may be rotated 0°, 90°, 180°, 270° — count rotated instances. Ignore size variations within reason (the symbol scale should be similar).
|
||||
|
||||
Reference symbol description (from legend): "{description}"
|
||||
|
||||
Return ONLY valid JSON, no markdown:
|
||||
{{"count": <integer>, "confidence": <"low"|"medium"|"high">, "notes": "<optional brief note about uncertainty>"}}"""
|
||||
|
||||
|
||||
async def detect_legend(image_path: Path) -> list[dict]:
|
||||
"""Pass 1: Find legend, return list of symbols with bbox."""
|
||||
img_b64 = _b64(image_path)
|
||||
resp = await _get_client().chat.completions.create(
|
||||
model=MODEL,
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": LEGEND_PROMPT},
|
||||
{"type": "image_url",
|
||||
"image_url": {"url": f"data:image/png;base64,{img_b64}"}},
|
||||
],
|
||||
}],
|
||||
max_tokens=4000,
|
||||
temperature=0.0,
|
||||
)
|
||||
raw = (resp.choices[0].message.content or "").strip()
|
||||
logger.info("Legend raw response (first 800 chars): %s", raw[:800])
|
||||
raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Legend JSON parse failed: %s\nraw=%s", e, raw[:500])
|
||||
return []
|
||||
symbols = data.get("symbols", []) if isinstance(data, dict) else []
|
||||
logger.info("Legend detection found %d symbols", len(symbols))
|
||||
return symbols
|
||||
|
||||
|
||||
async def count_symbol(symbol_crop: Path, full_image: Path, description: str) -> dict:
|
||||
"""Pass 2: Count instances of one symbol in the drawing."""
|
||||
crop_b64 = _b64(symbol_crop)
|
||||
full_b64 = _b64(full_image)
|
||||
prompt = COUNT_PROMPT_TEMPLATE.format(description=description)
|
||||
resp = await _get_client().chat.completions.create(
|
||||
model=MODEL,
|
||||
messages=[{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{"type": "text", "text": "Reference symbol:"},
|
||||
{"type": "image_url",
|
||||
"image_url": {"url": f"data:image/png;base64,{crop_b64}"}},
|
||||
{"type": "text", "text": "Full drawing:"},
|
||||
{"type": "image_url",
|
||||
"image_url": {"url": f"data:image/png;base64,{full_b64}"}},
|
||||
],
|
||||
}],
|
||||
max_tokens=300,
|
||||
temperature=0.0,
|
||||
)
|
||||
raw = (resp.choices[0].message.content or "").strip()
|
||||
raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.error("Count JSON parse failed: %s", raw[:200])
|
||||
return {"count": 0, "confidence": "low", "notes": "parse failed"}
|
||||
Reference in New Issue
Block a user