wip: local portal changes (backup before disk cleanup)

This commit is contained in:
Ondřej Glaser
2026-07-07 13:16:26 +02:00
parent 48cef99257
commit 79b17be817
774 changed files with 8592 additions and 245 deletions

10
dwg-counting/.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
.env
.env.local
__pycache__/
*.pyc
samples/
test2.pdf
test3.pdf
symbol.png
*.dwg
*.dxf

View File

@@ -19,15 +19,16 @@ RUN tar xf libredwg-0.12.5.tar.xz \
&& cd .. \
&& rm -rf libredwg-0.12.5 libredwg-0.12.5.tar.xz
# Stage 2: runtime
FROM python:3.12-slim
# Stage 2: runtime — PyTorch CUDA base for GPU template matching
FROM pytorch/pytorch:2.11.0-cuda12.8-cudnn9-runtime
COPY --from=libredwg-builder /opt/libredwg/bin/dwgread /usr/local/bin/dwgread
# poppler-utils for pdf2image; cairo + pango for cairosvg
# poppler-utils for pdf2image; cairo + pango for cairosvg; libgl1 for cv2
RUN apt-get update && apt-get install -y --no-install-recommends \
poppler-utils \
libgl1 \
libglib2.0-0 \
libcairo2 \
libpango-1.0-0 \
libpangocairo-1.0-0 \
@@ -35,8 +36,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /app
# Install Python deps. The PyTorch base image already provides torch +
# numpy with CUDA 12.8 kernels — do NOT reinstall torch via requirements.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir --break-system-packages -r requirements.txt
COPY . .

124
dwg-counting/blocks.py Normal file
View File

@@ -0,0 +1,124 @@
"""Exact symbol counting from CAD vector data.
CAD drawings place repeated symbols as *block references* (DXF INSERT
entities). The file therefore contains the exact count and exact location of
every symbol — no raster matching, no threshold, no false positives. This
module reads that structure.
count_blocks() → list of named blocks with exact counts + every instance's
model coordinates. render_block_thumbnail() draws one block's geometry so the
user can visually identify which block is the symbol they want.
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
import cairosvg
import ezdxf
from ezdxf.addons.drawing import Frontend, RenderContext, layout
from ezdxf.addons.drawing.config import (
BackgroundPolicy, ColorPolicy, Configuration,
)
from ezdxf.addons.drawing.svg import SVGBackend
logger = logging.getLogger(__name__)
# CAD-bookkeeping / paste-artifact block names users never want to count.
_NOISE_PREFIX = re.compile(r"^(\*|A\$C|Clipboard|DWG\d|_|\$|ACAD_)", re.I)
def _is_noise(name: str) -> bool:
if not name or _NOISE_PREFIX.match(name):
return True
if name.isdigit(): # purely numeric auto-names
return True
if len(name.strip()) <= 1:
return True
return False
def count_blocks(dxf_path: Path) -> dict:
"""Parse modelspace block references.
Returns:
{
"blocks": [
{"name", "count", "noise": bool,
"instances": [{"x","y","rot"}, ...]},
... sorted: meaningful first, then by count desc
],
"doc": <ezdxf doc> # reused for thumbnail rendering
}
"""
doc = ezdxf.readfile(str(dxf_path))
msp = doc.modelspace()
blocks: dict[str, dict] = {}
for ins in msp.query("INSERT"):
name = ins.dxf.name
rec = blocks.setdefault(name, {"name": name, "instances": []})
# MINSERT (array insert) places row_count × column_count copies.
rows = int(getattr(ins.dxf, "row_count", 1) or 1)
cols = int(getattr(ins.dxf, "column_count", 1) or 1)
rsp = float(getattr(ins.dxf, "row_spacing", 0) or 0)
csp = float(getattr(ins.dxf, "column_spacing", 0) or 0)
ip = ins.dxf.insert
rot = float(getattr(ins.dxf, "rotation", 0) or 0)
for r in range(max(rows, 1)):
for c in range(max(cols, 1)):
rec["instances"].append({
"x": float(ip.x) + c * csp,
"y": float(ip.y) + r * rsp,
"rot": rot,
})
out = []
for name, rec in blocks.items():
rec["count"] = len(rec["instances"])
rec["noise"] = _is_noise(name)
out.append(rec)
# Meaningful named blocks first, each sorted by count desc.
out.sort(key=lambda b: (b["noise"], -b["count"]))
total = sum(b["count"] for b in out)
logger.info("Block scan: %d instances, %d distinct blocks (%d meaningful)",
total, len(out), sum(1 for b in out if not b["noise"]))
return {"blocks": out, "doc": doc}
def render_block_thumbnail(src_doc, name: str, out_path: Path,
px: int = 200) -> Path:
"""Render one block definition's geometry to a square PNG thumbnail."""
src_block = src_doc.blocks.get(name)
if src_block is None:
raise ValueError(f"Block not found: {name}")
nd = ezdxf.new()
nb = nd.blocks.new(name=name) if name not in nd.blocks \
else nd.blocks.get(name)
for e in src_block:
try:
nb.add_foreign_entity(e)
except Exception:
# Some entity types can't be copied across docs — skip; the
# remaining geometry is still enough to identify the symbol.
pass
msp = nd.modelspace()
msp.add_blockref(name, (0, 0))
cfg = Configuration(
background_policy=BackgroundPolicy.WHITE,
color_policy=ColorPolicy.BLACK,
)
backend = SVGBackend()
page = layout.Page(120, 120, layout.Units.mm, layout.Margins.all(5))
Frontend(RenderContext(nd), backend, config=cfg).draw_layout(
msp, finalize=True)
svg = backend.get_string(page)
png = cairosvg.svg2png(bytestring=svg.encode("utf-8"),
output_width=px, output_height=px)
Path(out_path).write_bytes(png)
return out_path

View File

@@ -28,6 +28,66 @@ logger = logging.getLogger(__name__)
# Template matching: higher = stricter. 0.65 is permissive, 0.85 strict.
DEFAULT_THRESHOLD = 0.7
import threading
import time
# GPU dispatch — if torch + CUDA are available we delegate to the GPU
# implementation, which is mathematically equivalent but ~20-50× faster.
_GPU_AVAILABLE = False
try:
import torch
if torch.cuda.is_available():
from counting_gpu import count_template as _count_template_gpu
_GPU_AVAILABLE = True
logger.info("CUDA detected: %s — GPU template matching enabled",
torch.cuda.get_device_name(0))
else:
logger.info("CUDA not available — using CPU template matching")
except Exception as exc:
logger.info("torch unavailable (%s) — using CPU template matching", exc)
# Serialize ALL GPU work in this process. Two concurrent count requests each
# allocate large FFT tensors for an ~8000px drawing; running them at once
# exhausts VRAM and the second one dies with a CUDA error. The lock makes
# GPU counts run one-at-a-time (each is only a few seconds anyway).
_GPU_LOCK = threading.Lock()
def _try_gpu_count(template_path, drawing_path, threshold, exclude_box,
mirror) -> dict | None:
"""Run the GPU matcher under the global lock, with one retry.
Returns the result dict on success, or None if the GPU stays unavailable
(the caller then falls back to the CPU implementation — slower but it
always works). Transient `cudaErrorDevicesUnavailable` happens when another
process (e.g. Ollama loading an embedding model for the Firemní asistent)
is mid-allocation; a short wait + retry usually clears it.
"""
with _GPU_LOCK:
for attempt in (1, 2):
try:
return _count_template_gpu(
template_path=template_path,
drawing_path=drawing_path,
threshold=threshold,
exclude_box=exclude_box,
mirror=mirror,
)
except Exception as exc: # noqa: BLE001 — any GPU failure → CPU
try:
torch.cuda.empty_cache()
except Exception:
pass
if attempt == 1:
logger.warning(
"GPU count failed (%s) — retrying in 2s", exc)
time.sleep(2.0)
continue
logger.error(
"GPU still unavailable after retry (%s) — CPU fallback",
exc)
return None
def _prep(img_path: Path) -> np.ndarray:
"""Binarize to 'ink vs not-ink'.
@@ -39,6 +99,8 @@ def _prep(img_path: Path) -> np.ndarray:
arr = cv2.imread(str(img_path), cv2.IMREAD_GRAYSCALE)
if arr is None:
raise RuntimeError(f"Could not load {img_path}")
# 245 — keep in sync with INK_THRESHOLD in counting_gpu.py (faint
# templates lose all ink below ~245; see note there).
_, ink = cv2.threshold(arr, 245, 255, cv2.THRESH_BINARY_INV)
return ink
@@ -181,6 +243,16 @@ def count_template(
each rotation — so we catch mirrored instances (e.g. a socket facing
left vs facing right).
"""
if _GPU_AVAILABLE:
# GPU path defaults to a denser sweep (8 rotations × 7 scales) since
# the per-variant cost is negligible. On persistent GPU unavailability
# this returns None and we fall through to the CPU path below.
gpu_result = _try_gpu_count(
template_path, drawing_path, threshold, exclude_box, mirror)
if gpu_result is not None:
return gpu_result
logger.warning("Falling back to CPU template matching for this job")
template = _prep(template_path)
template = _crop_to_content(template)
drawing = _prep(drawing_path)

View File

@@ -0,0 +1,337 @@
"""GPU-accelerated template matching using FFT correlation + integral images.
Math is identical to OpenCV's TM_SQDIFF_NORMED + TM_CCOEFF_NORMED, but the
heavy operations are restructured to exploit GPU strengths:
* Cross-correlation SI = sum(I[y+u, x+v] * T[u, v]) is computed in the
frequency domain. We FFT the drawing ONCE, then per-variant pad+FFT the
template and elementwise-multiply. On Blackwell (sm_120), cuDNN's direct
conv kernels are unoptimized and take ~1 s for an 8000² drawing with a
100² kernel — FFT correlation does the same work in ~12 ms.
* Local sums S1 = sum(I in window) and S2 = sum(I² in window) are computed
from integral images via four-corner subtraction — O(1) per output pixel,
so per-variant cost is essentially free.
Putting these together: per-variant cost drops from ~3 s (direct conv) to
~15 ms, and the cost is dominated by the one-time drawing FFT (~230 ms on
an 8000×6000 page).
API and return shape match the CPU implementation in counting.py.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Iterable
import cv2
import numpy as np
import torch
import torch.fft as tfft
logger = logging.getLogger(__name__)
DEFAULT_THRESHOLD = 0.7
MAX_DRAWING_PX = 9000
EPS = 1e-6
# Binarization: pixels darker than this become "ink". Kept at 245 — measured
# the actual renders: drawing strokes are crisp (well below 245) so tightening
# barely changes the drawing ink (2.10% vs 2.24% at 210 vs 245), but faint
# templates (e.g. a low-contrast symbol crop, 241–255) lose ALL ink below ~245
# and silently match nothing. Not worth the fragility — the false-positive fix
# is the consensus scoring + verify gate below, not binarization.
INK_THRESHOLD = 245
# Propose-then-verify tuning.
# - Consensus uses min(sim_sq, cc): far stricter than the old max(), so we
# propose slightly below the user threshold to protect recall …
PROPOSE_RELAX = 0.08
# - … then a geometric ink-overlap gate removes the candidates that only
# matched by luck (right ink density, wrong shape).
VERIFY_INK_IOU = 0.25
def _prep(img_path: Path) -> np.ndarray:
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, INK_THRESHOLD, 255, cv2.THRESH_BINARY_INV)
return ink
def _crop_to_content(template: np.ndarray) -> np.ndarray:
mask = template > 0
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 _rotate(img: np.ndarray, angle_deg: int) -> np.ndarray:
if angle_deg == 0:
return img
if angle_deg in (90, 180, 270):
return np.rot90(img, k=angle_deg // 90)
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)
def _nms(boxes: list[tuple], scores: list[float],
overlap_thresh: float = 0.3) -> list[int]:
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, EPS)
order = order[1:][iou <= overlap_thresh]
return keep
def _integral(I: torch.Tensor) -> torch.Tensor:
"""Build integral image. Output shape: (H+1, W+1), with zero first row/col.
II[i, j] = sum(I[:i, :j]). Then for any rectangle [y, y+h) × [x, x+w):
local_sum = II[y+h, x+w] - II[y, x+w] - II[y+h, x] + II[y, x]
"""
H, W = I.shape
II = torch.zeros((H + 1, W + 1), device=I.device, dtype=I.dtype)
II[1:, 1:] = I.cumsum(0).cumsum(1)
return II
def _local_sum(II: torch.Tensor, h: int, w: int) -> torch.Tensor:
"""Sum of every h×w window using integral image. Output: (H-h+1, W-w+1)."""
return (II[h:, w:] - II[:-h, w:] - II[h:, :-w] + II[:-h, :-w])
def _fft_correlate(
Ifft: torch.Tensor, T: torch.Tensor, H: int, W: int,
) -> torch.Tensor:
"""Cross-correlation of drawing (whose rfft2 is Ifft) with template T.
Returns the 'valid' region [0, H-h+1) × [0, W-w+1) — matching the
output region of cv2.matchTemplate / F.conv2d.
Math: cross-correlation in time domain = FFT(I) * conj(FFT(T_padded))
in frequency domain. T_padded has T in the top-left of a zero canvas
of size (H, W).
"""
h, w = T.shape
Tpad = torch.zeros((H, W), device=T.device, dtype=T.dtype)
Tpad[:h, :w] = T
Tfft = tfft.rfft2(Tpad)
full = tfft.irfft2(Ifft * Tfft.conj(), s=(H, W))
return full[: H - h + 1, : W - w + 1]
def count_template(
template_path: Path,
drawing_path: Path,
threshold: float = DEFAULT_THRESHOLD,
rotations: Iterable[int] = (0, 45, 90, 135, 180, 225, 270, 315),
scales: Iterable[float] = (0.6, 0.75, 0.9, 1.0, 1.15, 1.35, 1.6),
exclude_box: tuple | None = None,
area_tolerance: float = 200.0, # ignored — kept for API compat
mirror: bool = True,
) -> dict:
"""GPU template matcher. Same return shape as the CPU implementation."""
device = torch.device("cuda")
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
if min(template.shape) < 8 or int((template > 0).sum()) < 5:
return {"count": 0, "matches": [], "threshold_used": threshold}
H, W = drawing.shape
# One-time GPU setup. Use float32 normalized to [0,1] to keep magnitudes
# tame in the FFT (large summations on uint8 → 0..255 can overflow into
# noisier float regions of the spectrum).
drawing_t = torch.from_numpy(drawing.astype(np.float32) / 255.0).to(device)
drawing_sq_t = drawing_t * drawing_t
# FFT of drawing (used by every variant for SI)
Ifft = tfft.rfft2(drawing_t)
# Integral images for S1 and S2 (used by every variant)
II = _integral(drawing_t)
II2 = _integral(drawing_sq_t)
# Build all variants
base_variants = [template]
if mirror:
base_variants.append(cv2.flip(template, 1))
variants_np: list[np.ndarray] = []
for variant in base_variants:
for rot in rotations:
rotated = _rotate(variant, rot)
for scale in scales:
nw = max(8, int(rotated.shape[1] * scale))
nh = max(8, int(rotated.shape[0] * scale))
if nh > H or nw > W:
continue
tmpl = cv2.resize(rotated, (nw, nh),
interpolation=cv2.INTER_AREA)
variants_np.append(tmpl)
if not variants_np:
return {"count": 0, "matches": [], "threshold_used": threshold}
all_boxes: list[tuple] = []
all_scores: list[float] = []
all_vidx: list[int] = [] # which variant produced each candidate (verify)
propose_thr = max(0.0, threshold - PROPOSE_RELAX)
for vi, v in enumerate(variants_np):
v_norm = (v.astype(np.float32) / 255.0)
h_t, w_t = v_norm.shape
T = torch.from_numpy(v_norm).to(device)
# SI = sum_{u,v} I[y+u, x+v] * T[u, v]
SI = _fft_correlate(Ifft, T, H, W)
# S1 = sum_{u,v} I[y+u, x+v]
# S2 = sum_{u,v} I[y+u, x+v]^2
S1 = _local_sum(II, h_t, w_t)
S2 = _local_sum(II2, h_t, w_t)
n = float(h_t * w_t)
sumT = float(T.sum())
sumT2 = float((T * T).sum())
meanT = sumT / n
var_T_sum = sumT2 - n * meanT * meanT
# TM_SQDIFF_NORMED → similarity = 1 - normalized squared difference
sq = (sumT2 - 2.0 * SI + S2) / torch.sqrt(
torch.clamp(sumT2 * S2, min=EPS))
sim_sq = 1.0 - sq
if var_T_sum <= EPS:
sim = sim_sq
else:
# TM_CCOEFF_NORMED
cc_num = SI - meanT * S1
var_I_sum = torch.clamp(S2 - S1 * S1 / n, min=0.0)
cc = cc_num / torch.sqrt(
torch.clamp(var_T_sum * var_I_sum, min=EPS))
# Score = CCOEFF_NORMED only. The old code used max(sim_sq, cc),
# which let sim_sq's spurious highs over big white/low-ink regions
# through → thousands of false positives. cc is the shape-
# discriminative metric and keeps recall; the ink-IoU verify gate
# below removes cc's remaining thin-line spurious hits. (min() was
# tried and over-pruned: sim_sq is structurally low for sparse
# linework even on true matches, so it killed real symbols too.)
sim = cc
hits = sim >= propose_thr
if hits.any():
ys, xs = torch.where(hits)
scores_t = sim[ys, xs]
ys = ys.cpu().numpy(); xs = xs.cpu().numpy()
scores_np = scores_t.cpu().numpy()
for y, x, s in zip(ys, xs, scores_np):
all_boxes.append(
(float(x), float(y), float(x + w_t), float(y + h_t)))
all_scores.append(float(s))
all_vidx.append(vi)
del SI, S1, S2, sq, sim_sq, T
del drawing_t, drawing_sq_t, Ifft, II, II2
torch.cuda.empty_cache()
if not all_boxes:
return {"count": 0, "matches": [], "threshold_used": threshold}
keep = _nms(all_boxes, all_scores, overlap_thresh=0.3)
# ── Verify pass ──────────────────────────────────────────────
# For each surviving box: (a) enforce the real user threshold (propose was
# relaxed for recall), and (b) require the candidate window's ink to
# geometrically overlap the matched template's ink. (b) is what removes the
# leftover "right ink density, wrong shape" false positives.
matches = []
for i in keep:
score = all_scores[i]
if score < threshold:
continue
x0, y0, x1, y1 = all_boxes[i]
tmpl = variants_np[all_vidx[i]]
th, tw = tmpl.shape
yi, xi = int(round(y0)), int(round(x0))
if yi < 0 or xi < 0 or yi + th > H or xi + tw > W:
continue
win = drawing[yi:yi + th, xi:xi + tw]
if win.shape != tmpl.shape:
continue
t_ink = tmpl > 0
w_ink = win > 0
union = int(np.logical_or(t_ink, w_ink).sum())
if union == 0:
continue
iou = int(np.logical_and(t_ink, w_ink).sum()) / union
if iou < VERIFY_INK_IOU:
continue
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(score, 3),
})
return {"count": len(matches), "matches": matches,
"threshold_used": threshold}

View File

@@ -9,12 +9,21 @@ services:
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}
NVIDIA_VISIBLE_DEVICES: all
NVIDIA_DRIVER_CAPABILITIES: compute,utility
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- localai
volumes:
- dwg-counting-data:/tmp/dwg-counting
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
volumes:
dwg-counting-data:

View File

@@ -49,14 +49,17 @@ def _log_action(event: str, **fields):
@app.get("/")
async def root():
return FileResponse("static/index.html")
# no-store so the shell HTML always revalidates — the versioned asset
# URLs inside it then guarantee fresh JS/CSS (fixes stale block UI).
return FileResponse("static/index.html",
headers={"Cache-Control": "no-store"})
@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")
if suffix not in (".pdf", ".dwg", ".dxf"):
raise HTTPException(400, "Podporované formáty: .pdf, .dwg, .dxf")
job_id = str(uuid.uuid4())
job_dir = WORK_DIR / job_id
@@ -68,6 +71,56 @@ async def upload(file: UploadFile = File(...), auto_detect: bool = False):
_log_action("upload", job_id=job_id, filename=file.filename,
size=input_path.stat().st_size, auto_detect=auto_detect)
# ── CAD vector path: exact block-reference counting ──────────
# DWG/DXF carry the symbols as block INSERTs — the file has the exact
# count and location of every symbol. No raster matching, no threshold,
# no false positives. This is the universal, self-verifying path.
if suffix in (".dwg", ".dxf"):
try:
from blocks import count_blocks
from renderer import dwg_to_dxf
# Counting only needs the DXF parse (seconds). The full-drawing
# raster render is the slow part (minutes on dense site plans)
# and is NOT needed to count — defer it to annotated-PDF export.
if suffix == ".dwg":
dxf_path = await asyncio.to_thread(
dwg_to_dxf, input_path, job_dir)
else:
dxf_path = input_path
scan = await asyncio.to_thread(count_blocks, Path(dxf_path))
blocks_list = [
{"idx": i, "name": b["name"], "count": b["count"],
"noise": b["noise"]}
for i, b in enumerate(scan["blocks"])
]
jobs[job_id] = {
"filename": file.filename,
"mode": "blocks",
"dxf_path": str(dxf_path),
"png_path": None, # rendered lazily on PDF export
"job_dir": str(job_dir),
"transform": None, # ditto
"block_doc": scan["doc"],
"blocks": scan["blocks"],
"results": [],
"render_task": None,
}
# Kick off the full-drawing render in the background. The user
# spends 10–60 s selecting blocks; by the time they click PDF
# the raster is usually ready, so the export feels instant.
jobs[job_id]["render_task"] = asyncio.create_task(
asyncio.to_thread(_ensure_render, jobs[job_id], 4500))
return {
"job_id": job_id,
"mode": "blocks",
"blocks": blocks_list,
"total_instances": sum(b["count"] for b in scan["blocks"]),
}
except Exception as exc:
logger.exception("Job %s (block scan) failed: %s", job_id, exc)
raise HTTPException(500, str(exc))
# ── Raster path (PDF): template matching (unchanged) ─────────
try:
rendered = await asyncio.to_thread(render, input_path, job_dir)
floors = rendered["floors"]
@@ -129,8 +182,8 @@ async def upload(file: UploadFile = File(...), auto_detect: bool = False):
"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}
return {"job_id": job_id, "mode": "raster", "symbols": symbols,
"floor_count": len(floors), "auto_detect": auto_detect}
except Exception as exc:
logger.exception("Job %s failed: %s", job_id, exc)
@@ -268,6 +321,9 @@ async def trigger_auto_detect(job_id: str):
if job_id not in jobs:
raise HTTPException(404, "Not found")
job = jobs[job_id]
if job.get("mode") == "blocks":
raise HTTPException(409, "Tento výkres se počítá přes výběr bloků — "
"obnovte stránku (Ctrl+Shift+R).")
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"])
@@ -291,7 +347,11 @@ 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")
png = jobs[job_id].get("png_path")
if not png:
raise HTTPException(409, "Výkres ještě nebyl vykreslen (CAD režim "
"bloků) — obnovte stránku (Ctrl+Shift+R).")
return FileResponse(png, media_type="image/png")
@app.get("/api/debug/{job_id}/{sym_id}")
@@ -395,6 +455,282 @@ async def count(job_id: str, req: CountRequest):
return {"results": response_results}
# ── Block-mode endpoints (DWG/DXF exact counting) ───────────────
@app.get("/api/block-thumb/{job_id}/{idx}")
async def block_thumb(job_id: str, idx: int):
"""Render (and cache) a thumbnail of block #idx so the user can
visually identify which block is their symbol."""
if job_id not in jobs:
raise HTTPException(404, "Not found")
job = jobs[job_id]
if job.get("mode") != "blocks":
raise HTTPException(400, "Not a block job")
blocks = job["blocks"]
if idx < 0 or idx >= len(blocks):
raise HTTPException(404, "Block index out of range")
job_dir = Path(job["job_dir"])
thumb = job_dir / f"blk_{idx}.png"
if not thumb.exists():
from blocks import render_block_thumbnail
try:
await asyncio.to_thread(
render_block_thumbnail, job["block_doc"],
blocks[idx]["name"], thumb)
except Exception as exc:
logger.warning("Thumb failed for block %s: %s",
blocks[idx]["name"], exc)
raise HTTPException(500, "Thumbnail render failed")
return FileResponse(str(thumb), media_type="image/png")
class CountBlocksRequest(BaseModel):
idxs: list[int]
@app.post("/api/count-blocks/{job_id}")
async def count_blocks_endpoint(job_id: str, req: CountBlocksRequest):
"""Exact count for the selected blocks — instant (just reads parsed CAD
data). No render needed: counts + Excel work without it. The pixel
mapping for the annotated PDF is computed lazily in export-pdf, because
rendering a dense site plan can take minutes and most users only want
the number / Excel."""
if job_id not in jobs:
raise HTTPException(404, "Not found")
job = jobs[job_id]
if job.get("mode") != "blocks":
raise HTTPException(400, "Not a block job")
blocks = job["blocks"]
results = []
for i in req.idxs:
if i < 0 or i >= len(blocks):
continue
b = blocks[i]
results.append({
"id": f"blk_{i}",
"idx": i,
"description": b["name"],
"count": b["count"], # EXACT — from the CAD data
"matches": [], # filled lazily on PDF export
"notes": "",
})
_log_action("count_block", job_id=job_id, block=b["name"],
count=b["count"])
job.pop("annot_png", None) # block results annotate the overview, not a zoom
job["results"] = results
return {"results": [{k: v for k, v in r.items() if k != "matches"}
for r in results]}
# ── Region-select vector matching (non-block DWG symbols) ───────
@app.get("/api/render/{job_id}")
async def render_drawing(job_id: str):
"""Lazily render the DWG to an image so the user can box a symbol on it.
Awaits the background pre-render kicked off at upload time."""
if job_id not in jobs:
raise HTTPException(404, "Not found")
job = jobs[job_id]
if job.get("mode") != "blocks":
raise HTTPException(400, "Not a CAD job")
task = job.get("render_task")
if task is not None and not task.done():
await task
if not job.get("png_path"):
await asyncio.to_thread(_ensure_render, job, 4500)
return FileResponse(job["png_path"], media_type="image/png")
class ZoomRegionRequest(BaseModel):
bbox: dict # {x,y,w,h} normalized 0..1 against the overview image
@app.post("/api/zoom-region/{job_id}")
async def zoom_region(job_id: str, req: ZoomRegionRequest):
"""Render a high-DPI raster of just one model-space region of the
drawing. Returned image covers ONLY that region — 4500 px across a
small area gives the user enough detail to precisely box even a tiny
symbol. Stores the zoom transform on the job so /api/match-region
can convert the symbol's pixel coords back to model space."""
if job_id not in jobs:
raise HTTPException(404, "Not found")
job = jobs[job_id]
if job.get("mode") != "blocks":
raise HTTPException(400, "Not a CAD job")
# Make sure the overview is ready so we have a transform.
task = job.get("render_task")
if task is not None and not task.done():
await task
if not job.get("transform"):
await asyncio.to_thread(_ensure_render, job, 4500)
t = job["transform"]
b = req.bbox
# Convert normalized overview-pixel bbox → model coords.
mx0 = t["model_xmin"] + b["x"] * t["model_w"]
mx1 = t["model_xmin"] + (b["x"] + b["w"]) * t["model_w"]
my1 = t["model_ymin"] + t["model_h"] - b["y"] * t["model_h"]
my0 = t["model_ymin"] + t["model_h"] - (b["y"] + b["h"]) * t["model_h"]
# Pad by 5 % so the user has wiggle room when drawing the symbol box.
pad_x = (mx1 - mx0) * 0.05
pad_y = (my1 - my0) * 0.05
model_bbox = (mx0 - pad_x, my0 - pad_y, mx1 + pad_x, my1 + pad_y)
from renderer import render_region
zoom_png = Path(job["job_dir"]) / f"zoom_{int(__import__('time').time())}.png"
res = await asyncio.to_thread(
render_region, Path(job["dxf_path"]), zoom_png, model_bbox, 4500)
job["zoom_png_path"] = str(zoom_png)
job["zoom_transform"] = res["transform"]
return {"transform": res["transform"], "ts": zoom_png.stem}
@app.get("/api/zoom-render/{job_id}")
async def zoom_render(job_id: str):
if job_id not in jobs:
raise HTTPException(404, "Not found")
job = jobs[job_id]
p = job.get("zoom_png_path")
if not p or not Path(p).exists():
raise HTTPException(404, "No zoom yet")
return FileResponse(p, media_type="image/png")
class MatchRegionRequest(BaseModel):
bbox: dict # {x,y,w,h} normalized 0..1 (same as the crop tool)
@app.post("/api/match-region/{job_id}")
async def match_region_endpoint(job_id: str, req: MatchRegionRequest):
"""User boxed a symbol on the rendered drawing. Convert px→model, find
every exact repeat of that entity-set, store results for export."""
if job_id not in jobs:
raise HTTPException(404, "Not found")
job = jobs[job_id]
if job.get("mode") != "blocks":
raise HTTPException(400, "Not a CAD job")
task = job.get("render_task")
if task is not None and not task.done():
await task
# Prefer the zoom transform if the user staged a zoom — the symbol
# bbox came from clicks on the zoom render, not the overview.
if job.get("zoom_transform"):
t = job["zoom_transform"]
else:
t = await asyncio.to_thread(_ensure_render, job, 4500)
b = req.bbox
px0 = b["x"] * t["img_w"]
py0 = b["y"] * t["img_h"]
px1 = (b["x"] + b["w"]) * t["img_w"]
py1 = (b["y"] + b["h"]) * t["img_h"]
# pixel → model (inverse of the render transform; y is flipped)
def to_model(px, py):
x = t["model_xmin"] + px / t["img_w"] * t["model_w"]
y = t["model_ymin"] + t["model_h"] - py / t["img_h"] * t["model_h"]
return x, y
mx0, my1 = to_model(px0, py0) # top-left px → (xmin, ymax)
mx1, my0 = to_model(px1, py1) # bottom-right px → (xmax, ymin)
model_bbox = (min(mx0, mx1), min(my0, my1),
max(mx0, mx1), max(my0, my1))
from vector_match import match_region
res = await asyncio.to_thread(
match_region, Path(job["dxf_path"]), model_bbox)
if res.get("error"):
raise HTTPException(400, res["error"])
# Annotation markers are drawn on the OVERVIEW image during PDF
# export, so convert match positions through the overview transform —
# never the zoom transform, even if we used it to read the symbol bbox.
ov = await asyncio.to_thread(_ensure_render, job, 4500)
half = max(8, ov["img_w"] // 250)
matches = []
for inst in res["instances"]:
ipx = (inst["x"] - ov["model_xmin"]) / ov["model_w"] * ov["img_w"]
ipy = (ov["model_ymin"] + ov["model_h"] - inst["y"]) / \
ov["model_h"] * ov["img_h"]
matches.append({"x": int(ipx - half), "y": int(ipy - half),
"w": int(2 * half), "h": int(2 * half),
"score": 1.0})
# Drop the zoom transform — next "Vyznačit ve výkresu" should start
# fresh from the overview.
job.pop("zoom_transform", None)
job.pop("annot_png", None) # vector matches annotate the overview
job["results"] = [{
"id": "region",
"description": "Vyznačený symbol",
"count": res["count"],
"matches": matches,
"notes": "",
}]
_log_action("match_region", job_id=job_id, count=res["count"],
template_entities=res["template_entities"])
return {"count": res["count"],
"template_entities": res["template_entities"]}
class MatchRasterRequest(BaseModel):
bbox: dict # {x,y,w,h} normalized 0..1 of the ZOOM image
threshold: float | None = None # 0.40–0.95; lower = more matches
@app.post("/api/match-raster/{job_id}")
async def match_raster_endpoint(job_id: str, req: MatchRasterRequest):
"""Raster template matching for a user-boxed symbol (works for symbols
that aren't blocks and aren't exact vector copies — e.g. emergency exit
pictograms). The symbol is cropped from the OVERVIEW image at the boxed
location, then matched across the whole overview at the given threshold.
Matches are stored in overview-pixel coords so the annotated PDF works."""
from PIL import Image as _Img
if job_id not in jobs:
raise HTTPException(404, "Not found")
job = jobs[job_id]
if job.get("mode") != "blocks":
raise HTTPException(400, "Not a CAD job")
zt = job.get("zoom_transform")
zoom_png = job.get("zoom_png_path")
if not zt or not zoom_png or not Path(zoom_png).exists():
raise HTTPException(400, "Nejprve vyznačte oblast (krok 1).")
# Match WITHIN the high-DPI zoom image: symbols are large there, so
# template matching is reliable (on the whole-drawing overview they are
# only a few pixels and match poorly). Counts instances inside the
# selected area. The annotated PDF shows that zoomed area.
img = _Img.open(zoom_png).convert("L")
iw, ih = img.size
b = req.bbox # normalized 0..1 of the zoom image
pad = 3
left = max(0, int(b["x"] * iw) - pad)
top = max(0, int(b["y"] * ih) - pad)
right = min(iw, int((b["x"] + b["w"]) * iw) + pad)
bottom = min(ih, int((b["y"] + b["h"]) * ih) + pad)
if right - left < 6 or bottom - top < 6:
raise HTTPException(400, "Vyznačená oblast je příliš malá.")
job_dir = Path(job["job_dir"])
tmpl_path = job_dir / "region_template.png"
img.crop((left, top, right, bottom)).save(tmpl_path)
thr = req.threshold
kwargs = {} if thr is None else {"threshold": float(thr)}
res = await asyncio.to_thread(
count_template, tmpl_path, Path(zoom_png), **kwargs)
# Matches live on the zoom image → annotate THAT for the PDF, but DON'T
# touch the canonical overview png_path/transform, so "Vyznačit ve
# výkresu" still re-opens the full drawing for a fresh selection.
job["annot_png"] = zoom_png
job["results"] = [{
"id": "region",
"description": "Vyznačený symbol",
"count": res["count"],
"matches": res["matches"],
"notes": "" if res["count"] else "žádné shody nenalezeny",
}]
_log_action("match_raster", job_id=job_id, count=res["count"],
threshold=res.get("threshold_used"))
return {"count": res["count"], "threshold_used": res.get("threshold_used")}
@app.get("/api/export/{job_id}")
async def export(job_id: str):
if job_id not in jobs:
@@ -412,6 +748,46 @@ async def export(job_id: str):
)
def _ensure_render(job: dict, render_px: int | None = None) -> dict:
"""Render the full drawing once and cache png_path + transform on the
job. Reused by region-select display, vector-match overlay, annotated
PDF. `render_px` lets callers pick a faster, lower-res raster (the
annotated PDF only needs marker resolution, not vector precision)."""
from renderer import render_region
if job.get("png_path") and job.get("transform"):
return job["transform"]
job_dir = Path(job["job_dir"])
png_path = job_dir / "floor_0.png"
rr = render_region(Path(job["dxf_path"]), png_path, None,
render_px=render_px)
job["png_path"] = str(png_path)
job["transform"] = rr["transform"]
return job["transform"]
def _render_block_job(job: dict) -> None:
"""Map every selected block instance to its true pixel box (renders the
drawing first if not yet done). Only run on annotated-PDF export."""
# Annotated PDF only needs enough resolution to show marker boxes;
# halving the longest edge cuts cairosvg time ~4×.
_ensure_render(job, render_px=4500)
t = job["transform"]
half = max(8, t["img_w"] // 250)
by_idx = {b_i: b for b_i, b in enumerate(job["blocks"])}
for r in job["results"]:
b = by_idx.get(r.get("idx"))
if not b:
continue
ms = []
for inst in b["instances"]:
px = (inst["x"] - t["model_xmin"]) / t["model_w"] * t["img_w"]
py = (t["model_ymin"] + t["model_h"] - inst["y"]) / \
t["model_h"] * t["img_h"]
ms.append({"x": int(px - half), "y": int(py - half),
"w": int(2 * half), "h": int(2 * half), "score": 1.0})
r["matches"] = ms
@app.get("/api/export-pdf/{job_id}")
async def export_pdf(job_id: str):
if job_id not in jobs:
@@ -419,7 +795,18 @@ async def export_pdf(job_id: str):
job = jobs[job_id]
if not job["results"]:
raise HTTPException(400, "Nejprve spočítejte symboly")
png_path = Path(job["png_path"])
if job.get("annot_png"):
# Region raster result — matches are in the zoom image's coords.
png_path = Path(job["annot_png"])
else:
if job.get("mode") == "blocks":
# If the upload kicked off a background pre-render, wait for it
# instead of starting a second concurrent render.
task = job.get("render_task")
if task is not None and not task.done():
await task
await asyncio.to_thread(_render_block_job, job)
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(

View File

@@ -16,12 +16,17 @@ from ezdxf.addons.drawing.config import (
)
from ezdxf.addons.drawing import Frontend, RenderContext, layout
from ezdxf.addons.drawing.svg import SVGBackend
from ezdxf.addons.drawing.matplotlib import MatplotlibBackend
import cairosvg
import matplotlib
matplotlib.use("Agg") # headless, no GUI thread
import matplotlib.pyplot as plt
from PIL import Image
logger = logging.getLogger(__name__)
RENDER_PX = 8000 # target pixel size of the longer edge — Claude vision max
RENDER_PX = 9000 # longer-edge target; matches counting MAX_DRAWING_PX so the
# matcher never has to downscale the (lossless) vector render
def dwg_to_dxf(dwg_path: Path, out_dir: Path) -> Path:
@@ -48,11 +53,46 @@ SKIP_LAYER_PATTERNS = (
def _clean_doc(doc, drop_layers=False):
"""Strip elements that overwhelm vision rendering."""
msp = doc.modelspace()
# Force every layer visible. Electrical/symbol layers (power sockets,
# lights, etc.) are frequently turned OFF or FROZEN in the source DWG,
# so ezdxf skips them as "invisible" and they never reach the render —
# even though the geometry exists and the matcher can find it. Turning
# all layers on lets the user actually see and box those symbols.
for layer in doc.layers:
try:
layer.on()
layer.thaw()
except Exception:
pass
# Purge INSERTs that reference a missing block — in modelspace AND inside
# block definitions. dwgread-converted DXFs sometimes drop anonymous
# blocks ("*U", "*D") while a valid block still contains a nested INSERT
# of them; the renderer then crashes ("Required block definition for *U
# does not exist") when it explodes that reference.
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 layout in (msp, *list(doc.blocks)):
for ins in list(layout.query("INSERT")):
if ins.dxf.name not in valid_blocks:
try:
layout.delete_entity(ins)
except Exception:
pass
# Clear per-entity invisibility flags (same reason as layers above).
for e in msp:
try:
if e.dxf.hasattr("invisible") and e.dxf.invisible:
e.dxf.invisible = 0
except Exception:
pass
# Remove annotation/fill noise by ENTITY TYPE (robust across drawings —
# unlike layer-name matching, which is per-drawing fragile). Dimensions,
# text and leaders are never the symbol being counted but dominate ink
# density on real architectural DWGs (~38% → a few %). Geometry that
# forms symbols (LINE/LWPOLYLINE/CIRCLE/ARC/INSERT) is kept.
for typ in ("HATCH", "SOLID", "MPOLYGON", "DIMENSION", "MTEXT", "TEXT",
"LEADER", "MULTILEADER", "ARC_DIMENSION"):
for e in list(msp.query(typ)):
msp.delete_entity(e)
if drop_layers:
@@ -65,6 +105,8 @@ def _clean_doc(doc, drop_layers=False):
pass
for e in msp:
try:
# 5 (0.05 mm) — known to render visibly at our raster scale.
# Hairline (0) went sub-pixel and produced a blank image.
e.dxf.lineweight = 5
except Exception:
pass
@@ -129,17 +171,25 @@ def find_floors(dxf_path: Path) -> list[dict]:
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.
def render_region(dxf_path: Path, out_path: Path, bbox: tuple | None,
render_px: int | None = None,
backend: str = "cairosvg") -> Path:
"""Render a DXF (optionally clipped to bbox) to PNG.
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.
Two backends:
- "cairosvg" (default): ezdxf → SVG → cairosvg → PNG. Faithful
vector linework. ~30–90 s on dense site plans at 4500 px.
- "matplotlib": ezdxf → Agg → PNG. Comparable speed, sometimes
faster on extremely entity-dense drawings.
Cropping is done in pixel space after rasterization.
"""
doc = ezdxf.readfile(str(dxf_path))
auditor = doc.audit()
if auditor.has_errors:
logger.info("DXF audit: %d errors", len(auditor.errors))
# Entity-type cleanup only (drop_layers=False). Layer-name matching is
# per-drawing fragile — it blanked this sample entirely. The unconditional
# DIMENSION/TEXT/HATCH removal in _clean_doc is the robust density fix.
_clean_doc(doc)
msp = doc.modelspace()
@@ -161,54 +211,123 @@ def render_region(dxf_path: Path, out_path: Path, bbox: tuple | None) -> Path:
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 the caller asked for a sub-region (zoom into one area), copy
# the in-region entities into a fresh doc. ezdxf auto-fits the layout
# extents to the doc's geometry, so the zoom render's page covers
# ONLY the user's region at high effective DPI. (Without this, the
# SVG keeps the FULL drawing's coord range and our filtered geometry
# ends up at sub-pixel scale.)
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))
model_xmin = float(xmin)
model_ymin = float(ymin)
model_w = float(xmax - xmin) or 1.0
model_h = float(ymax - ymin) or 1.0
if max(img.size) > RENDER_PX:
r = RENDER_PX / max(img.size)
# Keep ANY entity whose bounding box overlaps the selected region —
# no entity-type whitelist, so symbols made of splines/solids/etc.
# and blocks whose insertion point sits outside the box are kept.
# A small margin avoids clipping symbols straddling the edge.
from ezdxf import bbox as _ezbbox
mx = model_w * 0.03
my = model_h * 0.03
def _in_bbox(e) -> bool:
try:
bb = _ezbbox.extents([e], fast=True)
if not bb.has_data:
return True # can't measure → keep rather than drop
return not (bb.extmax.x < xmin - mx or bb.extmin.x > xmax + mx or
bb.extmax.y < ymin - my or bb.extmin.y > ymax + my)
except Exception:
return True # on error keep the entity, don't lose symbols
keep = [e for e in msp if _in_bbox(e)]
sub_doc = ezdxf.new(dxfversion=doc.dxfversion)
# ezdxf's Importer copies entities WITH their dependencies (block
# definitions incl. anonymous "*U"/"*D" blocks, layers, linetypes).
# Hand-copying via blocks.new() can't recreate anonymous blocks and
# crashed rendering with "Required block definition for *U".
from ezdxf.addons import Importer
importer = Importer(doc, sub_doc)
for e in keep:
try:
importer.import_entity(e, sub_doc.modelspace())
except Exception:
pass
try:
importer.finalize()
except Exception:
logger.exception("zoom importer.finalize failed (continuing)")
doc = sub_doc
msp = sub_doc.modelspace()
aspect = model_w / max(model_h, 1)
longest_px = render_px or RENDER_PX
out_width = longest_px if aspect >= 1 else int(longest_px * aspect)
out_height = int(longest_px / aspect) if aspect >= 1 else longest_px
# Zoom (bbox) → matplotlib with a FIXED window pinned to the requested
# box. Large entities (sheet border, walls, long wires) that merely
# cross the box are clipped to it instead of stretching the view to the
# whole drawing. The transform then exactly equals the requested box.
if bbox is not None or backend == "matplotlib":
dpi = 100
fig = plt.figure(figsize=(max(out_width, 1) / dpi,
max(out_height, 1) / dpi), dpi=dpi)
ax = fig.add_axes([0, 0, 1, 1])
ax.set_axis_off()
ax.set_facecolor("white")
mpl_backend = MatplotlibBackend(ax)
Frontend(RenderContext(doc), mpl_backend, config=config).draw_layout(
msp, finalize=(bbox is None))
if bbox is not None:
ax.set_xlim(model_xmin, model_xmin + model_w)
ax.set_ylim(model_ymin, model_ymin + model_h)
ax.set_aspect("equal", adjustable="box")
fig.savefig(out_path, dpi=dpi, facecolor="white", pad_inches=0)
plt.close(fig)
img = Image.open(out_path).convert("RGB")
else:
if aspect >= 1:
page_w_mm, page_h_mm = 1000, 1000 / aspect
else:
page_w_mm, page_h_mm = 1000 * aspect, 1000
page = layout.Page(width=page_w_mm, height=page_h_mm,
units=layout.Units.mm,
margins=layout.Margins.all(0))
svg_backend = SVGBackend()
Frontend(RenderContext(doc), svg_backend, config=config).draw_layout(
msp, finalize=True)
svg_str = svg_backend.get_string(page)
png_bytes = cairosvg.svg2png(bytestring=svg_str.encode("utf-8"),
output_width=out_width)
img = Image.open(io.BytesIO(png_bytes))
if img.mode == "RGBA":
white = Image.new("RGB", img.size, (255, 255, 255))
white.paste(img, mask=img.split()[3])
img = white
cap_px = render_px or RENDER_PX
if max(img.size) > cap_px:
r = cap_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
# Transform so callers can map model (x,y) → pixel on this image:
# px = (x - model_xmin) / model_w * img_w
# py = (model_ymin + model_h - y) / model_h * img_h (y is flipped)
# Only valid when bbox is None (no crop) — which is the block path.
return {
"path": out_path,
"transform": {
"model_xmin": float(model_xmin), "model_ymin": float(model_ymin),
"model_w": float(model_w), "model_h": float(model_h),
"img_w": int(img.size[0]), "img_h": int(img.size[1]),
"cropped": bbox is not None,
},
}
def render(input_path: Path, out_dir: Path) -> dict:
@@ -228,14 +347,15 @@ def render(input_path: Path, out_dir: Path) -> dict:
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]
# Render the WHOLE drawing (bbox=None → full modelspace extents). These
# DWGs are one big sheet with multiple floor rectangles inside; the old
# find_floors crop kept only the first rectangle and silently dropped the
# rest of the drawing. Counting must cover everything, so no crop.
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}]}
rr = render_region(dxf_path, png, None)
return {"floors": [{"index": 0, "png": png.name, "legend_xy": None,
"transform": rr["transform"]}],
"dxf_path": str(dxf_path)}
def _render_pdf(pdf_path: Path, out_dir: Path) -> dict:

View File

@@ -5,6 +5,7 @@
upload: $("s-upload"),
processing: $("s-processing"),
symbols: $("s-symbols"),
blocks: $("s-blocks"),
results: $("s-results"),
};
const show = (name) => {
@@ -31,12 +32,20 @@
fd.append("file", file);
try {
// Skip auto-detection by default — user defines symbols manually
const r = await fetch("/api/upload?auto_detect=false", {
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;
if (data.mode === "blocks") {
state.blocks = data.blocks || [];
state.blockSel = new Set();
state.regionActive = false;
renderBlocks(data);
show("blocks");
return;
}
state.symbols = data.symbols || [];
state.selected = new Set();
renderSymbols();
@@ -52,7 +61,7 @@
show("processing");
$("processing-title").textContent = "Hledám legendu pomocí AI…";
try {
const r = await fetch(`/api/auto-detect/${state.jobId}`, { method: "POST" });
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 || [];
@@ -80,7 +89,7 @@
fd.append("file", f);
fd.append("description", name);
try {
const r = await fetch(`/api/symbols/${state.jobId}/upload`, { method: "POST", body: fd });
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);
@@ -104,7 +113,7 @@
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=""
<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>
@@ -137,7 +146,7 @@
e.stopPropagation();
if (!confirm(`Smazat symbol "${s.description || s.id}"?`)) return;
try {
const r = await fetch(`/api/symbols/${state.jobId}/${s.id}`, { method: "DELETE" });
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);
@@ -161,7 +170,7 @@
"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}`;
img.src = `api/drawing/${state.jobId}`;
sel.style.display = "none";
$("crop-save").disabled = true;
cropState = { mode: "create", sym: null, bbox: null, dragging: false };
@@ -182,7 +191,7 @@
"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}`;
img.src = `api/drawing/${state.jobId}`;
sel.style.display = "none";
$("crop-save").disabled = true;
cropState = { mode: "edit", sym, bbox: null, dragging: false };
@@ -191,64 +200,176 @@
return;
}
// Crop modal: zoom + pan + bbox selection.
// • Wheel = zoom in/out at cursor (1.2× per notch).
// • Left-drag = draw selection rectangle.
// • Shift+drag or middle-button drag = pan.
// • Selection box is tracked in image-natural pixels, so it stays
// attached to the same image region across zoom changes.
// • Initial scale is "fit": the whole drawing visible in the modal.
function attachCropHandlers(wrap, img, sel) {
let scale = 1;
let curBox = null; // {x,y,w,h} in image-natural pixels (or null)
let dragging = false;
let startX = 0, startY = 0; // drag start in image-natural pixels
let panning = false;
let panFromX = 0, panFromY = 0, panScrollX = 0, panScrollY = 0;
// 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 setBtnState = () => {
const ok = !!curBox && curBox.w >= 3 && curBox.h >= 3;
$("crop-save").disabled = !ok;
};
function clientToImg(clientX, clientY) {
const rect = img.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
return { x: (clientX - rect.left) / scale,
y: (clientY - rect.top) / scale };
}
function drawSel() {
if (!curBox || curBox.w <= 0 || curBox.h <= 0) {
sel.style.display = "none";
return;
}
sel.style.display = "block";
sel.style.left = (curBox.x * scale) + "px";
sel.style.top = (curBox.y * scale) + "px";
sel.style.width = (curBox.w * scale) + "px";
sel.style.height = (curBox.h * scale) + "px";
}
function applyScale() {
img.style.width = (img.naturalWidth * scale) + "px";
img.style.height = "auto";
drawSel();
}
function setScale(newScale, anchorClientX, anchorClientY) {
newScale = Math.max(0.05, Math.min(20, newScale));
const imgRect = img.getBoundingClientRect();
const wrapRect = wrap.getBoundingClientRect();
// Image-natural coords under the cursor BEFORE the zoom.
const imgCx = (anchorClientX - imgRect.left) / scale;
const imgCy = (anchorClientY - imgRect.top) / scale;
// Cursor position relative to the wrap viewport.
const cx = anchorClientX - wrapRect.left;
const cy = anchorClientY - wrapRect.top;
scale = newScale;
applyScale();
// Keep the same image point under the cursor after the resize.
wrap.scrollLeft = imgCx * scale - cx;
wrap.scrollTop = imgCy * scale - cy;
}
function fitToModal() {
const wrapRect = wrap.getBoundingClientRect();
const sx = wrapRect.width / img.naturalWidth;
const sy = wrapRect.height / img.naturalHeight;
scale = Math.min(sx, sy, 1);
applyScale();
wrap.scrollLeft = 0;
wrap.scrollTop = 0;
}
// Reset every time a new image loads.
img.addEventListener("load", () => {
curBox = null;
sel.style.display = "none";
// Wait one frame so wrap has its measured size.
requestAnimationFrame(fitToModal);
setBtnState();
});
// If the image is already loaded (cached), fit now too.
if (img.complete && img.naturalWidth) {
curBox = null;
requestAnimationFrame(fitToModal);
}
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;
if (e.button === 1 || (e.button === 0 && e.shiftKey)) {
panning = true;
panFromX = e.clientX;
panFromY = e.clientY;
panScrollX = wrap.scrollLeft;
panScrollY = wrap.scrollTop;
wrap.style.cursor = "grabbing";
e.preventDefault();
return;
}
cropState.bbox = {
x: left / dispW,
y: top / dispH,
w: w / dispW,
h: h / dispH,
if (e.button !== 0) return;
const p = clientToImg(e.clientX, e.clientY);
dragging = true;
startX = p.x; startY = p.y;
curBox = { x: p.x, y: p.y, w: 0, h: 0 };
drawSel();
e.preventDefault();
}
function onMouseMove(e) {
if (panning) {
wrap.scrollLeft = panScrollX - (e.clientX - panFromX);
wrap.scrollTop = panScrollY - (e.clientY - panFromY);
return;
}
if (!dragging) return;
const p = clientToImg(e.clientX, e.clientY);
curBox = {
x: Math.min(p.x, startX),
y: Math.min(p.y, startY),
w: Math.abs(p.x - startX),
h: Math.abs(p.y - startY),
};
$("crop-save").disabled = false;
drawSel();
}
function onMouseUp() {
if (panning) {
panning = false;
wrap.style.cursor = "";
}
if (!dragging) return;
dragging = false;
if (!curBox || curBox.w < 3 || curBox.h < 3) {
curBox = null;
sel.style.display = "none";
} else {
const W = img.naturalWidth, H = img.naturalHeight;
cropState.bbox = {
x: curBox.x / W,
y: curBox.y / H,
w: curBox.w / W,
h: curBox.h / H,
};
}
setBtnState();
}
function onWheel(e) {
e.preventDefault();
const factor = e.deltaY < 0 ? 1.2 : 1 / 1.2;
setScale(scale * factor, e.clientX, e.clientY);
}
wrap.onmousedown = onMouseDown;
wrap.onmousemove = onMouseMove;
wrap.onmouseup = onMouseUp;
wrap.onmouseleave = onMouseUp;
// Replace any prior wheel listener (close/reopen of the modal).
if (wrap._onWheel) wrap.removeEventListener("wheel", wrap._onWheel);
wrap._onWheel = onWheel;
wrap.addEventListener("wheel", onWheel, { passive: false });
// Expose for toolbar buttons.
wrap._zoom = {
in: () => setScale(scale * 1.5,
wrap.getBoundingClientRect().left + wrap.clientWidth / 2,
wrap.getBoundingClientRect().top + wrap.clientHeight / 2),
out: () => setScale(scale / 1.5,
wrap.getBoundingClientRect().left + wrap.clientWidth / 2,
wrap.getBoundingClientRect().top + wrap.clientHeight / 2),
fit: fitToModal,
one: () => { scale = 1; applyScale(); },
};
}
function closeCropModal() {
@@ -263,12 +384,12 @@
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}`);
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 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]) => `
@@ -342,11 +463,13 @@
$("crop-cancel").addEventListener("click", closeCropModal);
$("crop-save").addEventListener("click", async () => {
if (!cropState || !cropState.bbox) return;
if (cropState.mode === "region" || cropState.mode === "region-area")
return; // handled separately by the capture-phase dispatcher
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}`, {
const r = await fetch(`api/symbols/${state.jobId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -359,7 +482,7 @@
const newSym = await r.json();
state.symbols.push(newSym);
} else {
const r = await fetch(`/api/symbol/${state.jobId}/${cropState.sym.id}/recrop`, {
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 }),
@@ -385,7 +508,7 @@
show("processing");
$("processing-title").textContent = `Počítám ${state.selected.size} symbolů…`;
try {
const r = await fetch(`/api/count/${state.jobId}`, {
const r = await fetch(`api/count/${state.jobId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -396,6 +519,7 @@
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
const data = await r.json();
state.results = data.results || [];
state.resultsBack = "symbols";
renderResults();
show("results");
} catch (err) {
@@ -412,8 +536,11 @@
for (const r of state.results) {
const tr = document.createElement("tr");
const conf = r.confidence || "";
const thumb = r.id === "region"
? ""
: `<img class="symbol-thumb" style="width:40px;height:40px" src="api/symbol/${state.jobId}/${r.id}" alt="" onerror="this.style.display='none'">`;
tr.innerHTML = `
<td><img class="symbol-thumb" style="width:40px;height:40px" src="/api/symbol/${state.jobId}/${r.id}" alt=""></td>
<td>${thumb}</td>
<td>${escapeHtml(r.description || "")}</td>
<td><strong>${r.count}</strong></td>
<td class="confidence-${conf}">${conf || "—"}</td>
@@ -422,14 +549,89 @@
}
}
$("export-btn").addEventListener("click", () => {
window.location.href = `/api/export/${state.jobId}`;
// Stream a slow file download with an in-flight overlay so the user
// knows the server is working (DWG → PNG → annotated PDF takes minutes
// on dense site plans). Falls back to a clear error message instead of
// a silent browser spinner.
function showBusyOverlay(title, sub) {
let o = document.getElementById("busy-overlay");
if (!o) {
o = document.createElement("div");
o.id = "busy-overlay";
o.style.cssText =
"position:fixed;inset:0;background:rgba(0,0,0,0.55);" +
"display:flex;align-items:center;justify-content:center;" +
"z-index:9999;backdrop-filter:blur(2px);";
o.innerHTML =
'<div style="background:var(--bg,#fff);color:var(--fg,#111);' +
'padding:28px 36px;border-radius:12px;text-align:center;' +
'box-shadow:0 8px 32px rgba(0,0,0,0.3);max-width:480px">' +
'<div class="spinner" style="margin:0 auto 16px"></div>' +
'<div id="busy-title" style="font-size:18px;font-weight:600;margin-bottom:6px"></div>' +
'<div id="busy-sub" style="font-size:14px;opacity:0.75"></div>' +
'<div id="busy-elapsed" style="font-size:12px;opacity:0.55;margin-top:10px"></div>' +
"</div>";
document.body.appendChild(o);
}
o.querySelector("#busy-title").textContent = title;
o.querySelector("#busy-sub").textContent = sub || "";
const elapsedEl = o.querySelector("#busy-elapsed");
elapsedEl.textContent = "Uplynulo: 0 s";
const t0 = Date.now();
const timer = setInterval(() => {
const s = Math.floor((Date.now() - t0) / 1000);
const m = Math.floor(s / 60);
elapsedEl.textContent = m ? `Uplynulo: ${m} min ${s % 60} s`
: `Uplynulo: ${s} s`;
}, 1000);
o.style.display = "flex";
return () => { clearInterval(timer); o.style.display = "none"; };
}
async function downloadWithProgress(url, label, btn, sub) {
const wasDisabled = btn ? btn.disabled : false;
const oldLabel = btn ? btn.textContent : "";
if (btn) { btn.disabled = true; btn.textContent = label + "…"; }
const hideOverlay = showBusyOverlay(label,
sub || "Server pracuje, prosím vyčkejte.");
try {
const r = await fetch(url);
if (!r.ok) {
let msg = r.statusText;
try { msg = (await r.json()).detail || msg; } catch (_) {}
throw new Error(msg);
}
const cd = r.headers.get("content-disposition") || "";
const m = cd.match(/filename="?([^"]+)"?/);
const filename = m ? m[1] : "soubor";
const blob = await r.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 60000);
} catch (err) {
alert("Chyba při stahování: " + err.message);
} finally {
hideOverlay();
if (btn) { btn.disabled = wasDisabled; btn.textContent = oldLabel; }
}
}
$("export-btn").addEventListener("click", (e) => {
downloadWithProgress(`api/export/${state.jobId}`,
"Vytvářím Excel", e.currentTarget,
"Sestavuji tabulku s nalezenými symboly.");
});
$("pdf-btn").addEventListener("click", () => {
window.location.href = `/api/export-pdf/${state.jobId}`;
$("pdf-btn").addEventListener("click", (e) => {
downloadWithProgress(`api/export-pdf/${state.jobId}`,
"Vytvářím PDF s vyznačením", e.currentTarget,
"U velkých výkresů to může trvat několik minut — server rastruje výkres ve vysokém rozlišení a kreslí značky.");
});
$("back-btn").addEventListener("click", () => show("symbols"));
$("back-btn").addEventListener("click", () => show(state.resultsBack || "symbols"));
$("reset-btn").addEventListener("click", () => {
state = { jobId: null, symbols: [], selected: new Set(), results: [] };
fileInput.value = "";
@@ -439,4 +641,241 @@
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
}
// ── Block picker (DWG/DXF exact counting) ───────────────────
function renderBlocks(data) {
const grid = $("blocks-grid");
const showNoise = $("blocks-shownoise").checked;
const list = state.blocks.filter((b) => showNoise || !b.noise);
$("blocks-meta").textContent =
`${data.total_instances} symbolů celkem · ${state.blocks.length} bloků` +
(showNoise ? "" : ` · ${state.blocks.length - list.length} pomocných skryto`);
grid.innerHTML = "";
for (const b of list) {
const card = document.createElement("div");
card.className = "block-card" + (state.blockSel.has(b.idx) ? " is-sel" : "");
card.innerHTML = `
<div class="block-thumb">
<img loading="lazy" src="api/block-thumb/${state.jobId}/${b.idx}" alt="">
</div>
<div class="block-info">
<div class="block-name" title="${escapeHtml(b.name)}">${escapeHtml(b.name)}</div>
<div class="block-count">${b.count}×</div>
</div>`;
card.addEventListener("click", () => {
if (state.blockSel.has(b.idx)) state.blockSel.delete(b.idx);
else state.blockSel.add(b.idx);
card.classList.toggle("is-sel");
updateBlockTotal();
});
grid.appendChild(card);
}
updateBlockTotal();
}
function updateBlockTotal() {
const sel = state.blocks.filter((b) => state.blockSel.has(b.idx));
const total = sel.reduce((a, b) => a + b.count, 0);
$("blocks-total").textContent = sel.length
? `Vybráno ${sel.length} bloků · celkem ${total} symbolů`
: "Nic nevybráno";
const dis = sel.length === 0;
$("blocks-export-btn").disabled = dis;
$("blocks-pdf-btn").disabled = dis;
}
async function commitBlockSelection() {
const idxs = [...state.blockSel];
if (!idxs.length) return false;
const r = await fetch(`api/count-blocks/${state.jobId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ idxs }),
});
if (!r.ok) { alert("Chyba: " + ((await r.json()).detail || r.statusText)); return false; }
return true;
}
$("blocks-shownoise").addEventListener("change", () => renderBlocks(
{ total_instances: state.blocks.reduce((a, b) => a + b.count, 0) }));
$("blocks-reset-btn").addEventListener("click", () => {
state = { jobId: null, symbols: [], selected: new Set(), results: [] };
fileInput.value = "";
show("upload");
});
$("blocks-export-btn").addEventListener("click", async (e) => {
// Region results are already stored server-side — export directly.
if (state.regionActive || await commitBlockSelection())
downloadWithProgress(`api/export/${state.jobId}`,
"Vytvářím Excel", e.currentTarget,
"Sestavuji tabulku s nalezenými symboly.");
});
$("blocks-pdf-btn").addEventListener("click", async (e) => {
if (state.regionActive || await commitBlockSelection())
downloadWithProgress(`api/export-pdf/${state.jobId}`,
"Vytvářím PDF s vyznačením", e.currentTarget,
"U velkých výkresů to může trvat několik minut — server rastruje výkres ve vysokém rozlišení a kreslí značky.");
});
// Crop modal zoom toolbar — delegates to the wrap's exposed handlers.
function cropZoom(action) {
const wrap = $("crop-canvas-wrap");
if (wrap && wrap._zoom && typeof wrap._zoom[action] === "function")
wrap._zoom[action]();
}
$("crop-zoom-in").addEventListener("click", () => cropZoom("in"));
$("crop-zoom-out").addEventListener("click", () => cropZoom("out"));
$("crop-zoom-fit").addEventListener("click", () => cropZoom("fit"));
$("crop-zoom-one").addEventListener("click", () => cropZoom("one"));
// ── Region-select vector matching (non-block symbols) ───────
// Two stages: (1) box a coarse AREA on the overview, (2) the server
// re-renders just that area at high DPI and the user precisely boxes
// the SYMBOL on it. Stage 2's pixel coords go through the zoom
// transform on the server, so symbol coords map correctly back to
// model space even though they were measured on a sub-region image.
function enterRegionAreaStage() {
const modal = $("crop-modal");
const img = $("crop-img");
const sel = $("crop-selection");
const wrap = $("crop-canvas-wrap");
$("crop-modal-title").textContent = "Krok 1 / 2 — vyznačte oblast";
$("crop-modal-hint").textContent =
"Vyznačte přibližnou oblast, ve které je hledaný symbol. " +
"V dalším kroku oblast přiblížíme a vyberete přesně samotný symbol.";
$("crop-name-row").classList.add("hidden");
$("crop-back-btn").classList.add("hidden");
$("crop-threshold-row").classList.add("hidden");
sel.style.display = "none";
$("crop-save").disabled = true;
$("crop-save").textContent = "Pokračovat";
cropState = { mode: "region-area", bbox: null, dragging: false };
img.onload = () => {
$("crop-modal-hint").textContent =
"Vyznačte přibližnou oblast, ve které je hledaný symbol. " +
"Bude přiblížena ve vysokém rozlišení.";
$("crop-modal-hint").style.color = "";
};
img.onerror = async () => {
let detail = "Server vrátil chybu.";
try {
const r = await fetch(`api/render/${state.jobId}`);
if (r.status === 404) {
detail = "Výkres už není v paměti serveru (server byl mezitím " +
"restartován). Zavřete toto okno a nahrajte výkres znovu.";
} else if (!r.ok) {
try { detail = (await r.json()).detail || r.statusText; }
catch (_) { detail = r.statusText; }
}
} catch (_) {}
$("crop-modal-hint").textContent = "Chyba: " + detail;
$("crop-modal-hint").style.color = "var(--danger, #c0392b)";
};
img.src = `api/render/${state.jobId}?t=${Date.now()}`;
modal.classList.remove("hidden");
attachCropHandlers(wrap, img, sel);
}
$("region-match-btn").addEventListener("click", enterRegionAreaStage);
async function runZoomRegion() {
if (!cropState || !cropState.bbox) return;
const btn = $("crop-save");
btn.disabled = true;
btn.textContent = "Přibližuji…";
const hide = showBusyOverlay("Přibližuji oblast",
"Renderuji oblast ve vysokém rozlišení.");
try {
const r = await fetch(`api/zoom-region/${state.jobId}`, {
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);
// Transition to stage 2.
const img = $("crop-img");
const sel = $("crop-selection");
const wrap = $("crop-canvas-wrap");
$("crop-modal-title").textContent = "Krok 2 / 2 — vyznačte symbol";
$("crop-modal-hint").textContent =
"Nakreslete těsný rámeček kolem jedné instance symbolu. " +
"Najdeme všechny vizuálně podobné výskyty (i otočené a zrcadlené). " +
"Citlivost nastavte níže.";
sel.style.display = "none";
btn.textContent = "Najít symbol";
btn.disabled = true;
$("crop-back-btn").classList.remove("hidden");
$("crop-threshold-row").classList.remove("hidden");
cropState = { mode: "region", bbox: null, dragging: false };
img.src = `api/zoom-render/${state.jobId}?t=${Date.now()}`;
attachCropHandlers(wrap, img, sel);
} catch (err) {
alert("Chyba: " + err.message);
btn.disabled = false;
btn.textContent = "Pokračovat";
} finally {
hide();
}
}
async function runRegionMatch() {
if (!cropState || !cropState.bbox) return;
const btn = $("crop-save");
btn.disabled = true;
btn.textContent = "Hledám…";
const hide = showBusyOverlay("Hledám symbol",
"Porovnávám vyznačený symbol s celým výkresem.");
try {
const r = await fetch(`api/match-raster/${state.jobId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
bbox: cropState.bbox,
threshold: parseFloat($("crop-threshold").value),
}),
});
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
const d = await r.json();
state.regionActive = true;
closeCropModal();
$("crop-threshold-row").classList.add("hidden");
$("crop-save").textContent = "Uložit výřez";
// Show the dedicated results view (count + Excel + annotated PDF).
state.results = [{
id: "region",
description: "Vyznačený symbol (práh " +
(+$("crop-threshold").value).toFixed(2) + ")",
count: d.count,
notes: d.count ? "" : "žádné shody nenalezeny",
}];
state.resultsBack = "blocks";
renderResults();
show("results");
} catch (err) {
alert("Chyba: " + err.message);
btn.disabled = false;
btn.textContent = "Najít symbol";
} finally {
hide();
}
}
// Crop modal "Save" button dispatches by current stage. Capture phase
// so it runs before the symbol-create flow (which no-ops on mode!=create).
$("crop-save").addEventListener("click", () => {
if (!cropState) return;
if (cropState.mode === "region-area") runZoomRegion();
else if (cropState.mode === "region") runRegionMatch();
}, true);
$("crop-back-btn").addEventListener("click", () => {
if (cropState && (cropState.mode === "region" || cropState.mode === "region-area"))
enterRegionAreaStage();
});
// Region threshold slider live value.
const cropThr = $("crop-threshold");
if (cropThr) cropThr.addEventListener("input", () => {
$("crop-threshold-value").textContent = (+cropThr.value).toFixed(2);
});
})();

View File

@@ -187,6 +187,23 @@
background: #fff;
cursor: crosshair;
}
.crop-toolbar {
display: flex;
gap: 6px;
align-items: center;
margin: 8px 0;
flex-wrap: wrap;
}
.crop-toolbar .btn-sm {
padding: 4px 10px;
font-size: 13px;
min-width: 36px;
}
.crop-toolbar .crop-hint {
font-size: 12px;
color: var(--text-tertiary);
margin-left: auto;
}
#crop-img {
display: block;
max-width: none;
@@ -299,3 +316,54 @@
height: 100%;
background: var(--primary);
}
/* ── Block picker (DWG/DXF exact counting) ───────────── */
.blocks-hint {
font-size: 13px; color: var(--text-tertiary);
background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary));
border: 1px solid var(--border-default);
border-radius: 8px; padding: 10px 14px; margin: 4px 0 14px;
}
.blocks-shownoise {
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; color: var(--text-secondary); cursor: pointer;
}
.blocks-total {
font-size: 13px; font-weight: 600; color: var(--text-primary);
margin-bottom: 12px;
}
.blocks-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
}
.block-card {
border: 1px solid var(--border-default);
border-radius: 10px; background: var(--card);
cursor: pointer; overflow: hidden;
transition: border-color .15s, box-shadow .15s, background .15s;
}
.block-card:hover { border-color: var(--border-strong); }
.block-card.is-sel {
border-color: var(--primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 30%, transparent);
background: color-mix(in srgb, var(--primary) 5%, var(--card));
}
.block-thumb {
aspect-ratio: 1 / 1; background: #fff;
display: flex; align-items: center; justify-content: center;
border-bottom: 1px solid var(--border-default);
}
.block-thumb img { max-width: 88%; max-height: 88%; object-fit: contain; }
.block-info {
display: flex; align-items: center; justify-content: space-between;
gap: 8px; padding: 8px 10px;
}
.block-name {
font-size: 12.5px; color: var(--text-secondary);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.block-count {
font-size: 12.5px; font-weight: 700; color: var(--primary);
flex-shrink: 0;
}

View File

@@ -4,45 +4,77 @@
<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>
<base href="/apps/dwg-counting/">
<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">
<link rel="stylesheet" href="static/styles.css?v=blocks11">
<link rel="stylesheet" href="static/extra.css?v=blocks11">
<script>
// Theme handling — runs before first paint to prevent flash.
// Priority: URL ?theme= → localStorage → portal_theme cookie → system.
// Theme bootstrap — read source-of-truth in this order:
// ?theme= URL → portal_theme cookie → app_theme localStorage → OS
// The cookie is set by the portal landing on every resolvedTheme change,
// so it always reflects the portal's current state.
(function () {
var t = null;
try {
var url = new URL(window.location.href);
var p = url.searchParams.get("theme");
var p = new URL(window.location.href).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) { try { t = localStorage.getItem("app_theme"); } catch (e) {} }
if (!t && typeof matchMedia !== "undefined") {
try {
t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
} catch (e) {}
}
if (t === "dark" || t === "light") {
document.documentElement.classList.add(t);
try { localStorage.setItem("app_theme", t); } catch (e) {}
}
})();
// bfcache rehydration — re-sync the class when the page is restored
// (e.g. user toggled theme on the portal, then hit Back).
// Same as pageshow, but fires when the user switches tabs.
// Covers: portal tab toggles theme → user comes back to app tab.
document.addEventListener("visibilitychange", function () {
if (document.visibilityState !== "visible") return;
var m = document.cookie.match(/(?:^|;\s*)portal_theme=([^;]+)/);
if (!m) return;
var t = decodeURIComponent(m[1]);
if (t !== "dark" && t !== "light") return;
var cls = document.documentElement.classList;
if (cls.contains(t)) return;
cls.remove(t === "dark" ? "light" : "dark");
cls.add(t);
try { localStorage.setItem("app_theme", t); } catch (e) {}
});
window.addEventListener("pageshow", function () {
var m = document.cookie.match(/(?:^|;\s*)portal_theme=([^;]+)/);
if (!m) return;
var t = decodeURIComponent(m[1]);
if (t !== "dark" && t !== "light") return;
var cls = document.documentElement.classList;
if (cls.contains(t)) return;
cls.remove(t === "dark" ? "light" : "dark");
cls.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">
<a href="/" 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">
<a href="/" 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>
@@ -69,11 +101,11 @@
<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-text">Přetáhněte výkres sem (PDF, DWG nebo DXF)</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">
<p class="drop-formats">Podporované formáty: .pdf, .dwg, .dxf</p>
<input type="file" id="file-input" accept=".pdf,.dwg,.dxf" style="display:none">
</div>
</section>
@@ -121,6 +153,34 @@
<div class="symbols-grid" id="symbols-grid"></div>
</section>
<!-- ── Block picker (DWG/DXF — exact counting) ─────── -->
<section id="s-blocks" class="hidden">
<div class="results-header">
<div>
<h2 class="results-title">Vyberte symboly k spočítání</h2>
<p class="results-meta" id="blocks-meta"></p>
</div>
<div class="results-actions">
<button class="btn btn-secondary" id="blocks-reset-btn" type="button">Nahrát jiný</button>
<label class="blocks-shownoise">
<input type="checkbox" id="blocks-shownoise"> Zobrazit i pomocné bloky
</label>
<button class="btn btn-secondary" id="region-match-btn" type="button">Symbol není blok? Vyznačit ve výkresu</button>
<button class="btn btn-secondary" id="blocks-pdf-btn" type="button" disabled>PDF s vyznačením</button>
<button class="btn btn-primary" id="blocks-export-btn" type="button" disabled>Exportovat Excel</button>
</div>
</div>
<p class="blocks-hint">
Výkres je CAD soubor — počty jsou <strong>přesné</strong>, čtené přímo
z dat (žádné falešné nálezy). Klikněte na bloky, které představují
hledaný symbol. Pokud symbol není samostatný blok, použijte
<strong>„Vyznačit ve výkresu"</strong> a označte ho přímo — najdeme
všechny jeho přesné kopie (i otočené/zrcadlené).
</p>
<div class="blocks-total" id="blocks-total"></div>
<div class="blocks-grid" id="blocks-grid"></div>
</section>
<!-- ── Debug modal ───────────────────────── -->
<div id="debug-modal" class="modal hidden">
<div class="modal-content" style="max-width:760px">
@@ -147,11 +207,25 @@
<label for="crop-name">Název symbolu:</label>
<input type="text" id="crop-name" placeholder="např. Zásuvka 230V">
</div>
<div class="crop-toolbar">
<button class="btn btn-secondary btn-sm" id="crop-zoom-in" type="button" title="Přiblížit (kolečko myši)">+</button>
<button class="btn btn-secondary btn-sm" id="crop-zoom-out" type="button" title="Oddálit">−</button>
<button class="btn btn-secondary btn-sm" id="crop-zoom-fit" type="button" title="Celý výkres">Vejít</button>
<button class="btn btn-secondary btn-sm" id="crop-zoom-one" type="button" title="100 % (skutečná velikost)">100&nbsp;%</button>
<span class="crop-hint">Kolečko = přiblížit · Shift+tažení = posunout · Tažení = vybrat</span>
</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="threshold-row hidden" id="crop-threshold-row">
<label for="crop-threshold">Citlivost (práh shody):</label>
<input type="range" id="crop-threshold" min="0.40" max="0.95" step="0.01" value="0.70">
<span id="crop-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="modal-actions">
<button class="btn btn-secondary hidden" id="crop-back-btn" type="button">Zpět na celý výkres</button>
<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>
@@ -200,6 +274,6 @@
</main>
<script src="/static/app.js"></script>
<script src="static/app.js?v=blocks11"></script>
</body>
</html>

View File

@@ -0,0 +1,217 @@
"""Exact symbol counting by repeated-geometry matching.
The symbol is whatever the user boxes on the drawing — a set of DXF entities
(lines / arcs / circles / polylines). CAD copy-paste preserves geometry
exactly, so other occurrences are the SAME entity set under a rigid transform
(translation + rotation, optionally mirrored). We find them exactly:
1. Describe each template entity by a transform-invariant shape descriptor
plus a representative model-space point.
2. Pick a rare 'anchor' entity and the farthest 'orient' entity. Every
(anchor-candidate, orient-candidate) pair in the drawing whose shapes and
separation match the template fixes a candidate rigid transform.
3. Map all template points through the transform and verify — via a spatial
hash of the drawing's entities — that EVERY template entity is present.
4. Dedupe verified placements; report exact instance centres.
A placement is reported only if every entity of the symbol is found at the
transformed position, so there are no false positives and the count is exact.
"""
from __future__ import annotations
import logging
import math
from collections import defaultdict
from pathlib import Path
import ezdxf
logger = logging.getLogger(__name__)
TOL = 0.05 # absolute model-unit tolerance (exact copies match ~1e-6)
_KINDS = ("LINE", "CIRCLE", "ARC", "LWPOLYLINE", "POLYLINE")
def _pts(e) -> list[tuple[float, float]]:
t = e.dxftype()
if t == "LINE":
s, en = e.dxf.start, e.dxf.end
return [(s.x, s.y), (en.x, en.y)]
if t == "CIRCLE":
c = e.dxf.center
return [(c.x, c.y)]
if t == "ARC":
c, r = e.dxf.center, e.dxf.radius
a0, a1 = math.radians(e.dxf.start_angle), math.radians(e.dxf.end_angle)
return [(c.x, c.y),
(c.x + r * math.cos(a0), c.y + r * math.sin(a0)),
(c.x + r * math.cos(a1), c.y + r * math.sin(a1))]
if t in ("LWPOLYLINE", "POLYLINE"):
try:
return [(float(p[0]), float(p[1])) for p in e.get_points()]
except Exception:
return []
return []
def _descriptor(e) -> tuple | None:
"""Rotation/translation/mirror-invariant shape signature."""
t = e.dxftype()
if t == "LINE":
s, en = e.dxf.start, e.dxf.end
return ("L", round(math.hypot(en.x - s.x, en.y - s.y), 3))
if t == "CIRCLE":
return ("C", round(e.dxf.radius, 3))
if t == "ARC":
span = (e.dxf.end_angle - e.dxf.start_angle) % 360.0
return ("A", round(e.dxf.radius, 3), round(span, 2))
if t in ("LWPOLYLINE", "POLYLINE"):
p = _pts(e)
if len(p) < 2:
return None
segs = tuple(round(math.hypot(p[i + 1][0] - p[i][0],
p[i + 1][1] - p[i][1]), 3)
for i in range(len(p) - 1))
closed = bool(getattr(e, "closed", False) or
(getattr(e.dxf, "flags", 0) & 1))
return ("P", min(segs, tuple(reversed(segs))), closed, len(p))
return None
def _primary(e) -> tuple[float, float]:
"""One representative point per entity (centroid of its key points)."""
p = _pts(e)
if not p:
b = e.dxf.start if e.dxftype() == "LINE" else e.dxf.center
return (b.x, b.y)
if e.dxftype() == "CIRCLE":
return p[0]
return (sum(x for x, _ in p) / len(p), sum(y for _, y in p) / len(p))
def _iter_entities(msp):
"""Yield modelspace entities, expanding INSERTs into their world-space
geometry so symbols built from blocks are visible to the matcher."""
for e in msp:
t = e.dxftype()
if t == "INSERT":
try:
for ve in e.virtual_entities():
if ve.dxftype() in _KINDS:
yield ve
except Exception as exc:
logger.debug("virtual_entities failed on INSERT %s: %s",
getattr(e.dxf, "name", "?"), exc)
continue
if t in _KINDS:
yield e
def _collect(msp, bbox=None):
out = []
for e in _iter_entities(msp):
d = _descriptor(e)
if d is None:
continue
px, py = _primary(e)
if bbox is not None:
x0, y0, x1, y1 = bbox
if not (x0 <= px <= x1 and y0 <= py <= y1):
continue
out.append((d, (px, py)))
return out
def _snap(x, y):
return (round(x / TOL), round(y / TOL))
def _map(anchor_q, a_p, cos, sin, mirror, x, y):
"""Map model point (x,y): express relative to template anchor a_p, mirror
in x if needed, rotate, translate to the drawing anchor anchor_q."""
rx, ry = x - a_p[0], y - a_p[1]
if mirror:
rx = -rx
return (anchor_q[0] + cos * rx - sin * ry,
anchor_q[1] + sin * rx + cos * ry)
def match_region(dxf_path: Path, bbox: tuple) -> dict:
"""bbox = (x0,y0,x1,y1) in MODEL coords. Returns
{"count", "instances":[{"x","y"}], "template_entities"}."""
doc = ezdxf.readfile(str(dxf_path))
msp = doc.modelspace()
template = _collect(msp, bbox)
if not template:
return {"count": 0, "instances": [], "template_entities": 0,
"error": "Ve vybrané oblasti nejsou žádné rozpoznatelné křivky."}
drawing = _collect(msp, None)
by_desc: dict[tuple, list] = defaultdict(list)
grid: dict = defaultdict(list)
for d, p in drawing:
by_desc[d].append(p)
grid[(d, _snap(*p))].append(p)
def present(d, qx, qy) -> bool:
sx, sy = _snap(qx, qy)
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
for gp in grid.get((d, (sx + dx, sy + dy)), ()):
if abs(gp[0] - qx) <= TOL and abs(gp[1] - qy) <= TOL:
return True
return False
# Anchor = template entity with the fewest drawing candidates.
anchor_i = min(range(len(template)),
key=lambda i: len(by_desc.get(template[i][0], [])) or 1 << 30)
a_d, a_p = template[anchor_i]
def dist(p, q):
return math.hypot(p[0] - q[0], p[1] - q[1])
orient_i = max((i for i in range(len(template)) if i != anchor_i),
key=lambda i: dist(template[i][1], a_p), default=None)
instances: list[tuple[float, float]] = []
seen = set()
cx = sum(p[0] for _, p in template) / len(template)
cy = sum(p[1] for _, p in template) / len(template)
dedupe_cell = max(TOL * 20, dist((cx, cy), a_p) * 0.25 or TOL * 20)
def record(anchor_q, c, s, m):
for d, (px, py) in template:
qx, qy = _map(anchor_q, a_p, c, s, m, px, py)
if not present(d, qx, qy):
return
ix, iy = _map(anchor_q, a_p, c, s, m, cx, cy)
k = (round(ix / dedupe_cell), round(iy / dedupe_cell))
if k not in seen:
seen.add(k)
instances.append((ix, iy))
if orient_i is None:
# Single-entity symbol → translation only.
for q in by_desc.get(a_d, []):
record(q, 1.0, 0.0, False)
else:
o_d, o_p = template[orient_i]
sep = dist(a_p, o_p)
for aq in by_desc.get(a_d, []):
for oq in by_desc.get(o_d, []):
if abs(dist(aq, oq) - sep) > TOL:
continue
for mirror in (False, True):
vx, vy = o_p[0] - a_p[0], o_p[1] - a_p[1]
if mirror:
vx = -vx
th = (math.atan2(oq[1] - aq[1], oq[0] - aq[0]) -
math.atan2(vy, vx))
record(aq, math.cos(th), math.sin(th), mirror)
logger.info("vector_match: %d template entities → %d instances",
len(template), len(instances))
return {"count": len(instances),
"instances": [{"x": x, "y": y} for x, y in instances],
"template_entities": len(template)}