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:
Ondřej Glaser
2026-05-13 15:25:04 +02:00
commit 48cef99257
139 changed files with 20171 additions and 0 deletions

View 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
View 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
View 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}

View 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

View 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)

Binary file not shown.

440
dwg-counting/main.py Normal file
View 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")

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

View 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
View 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 dedupli­kaci.
</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) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
}
})();

View 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);
}

View 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 2060 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>

View 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
View 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"}