wip: local portal changes (backup before disk cleanup)
This commit is contained in:
10
dwg-counting/.gitignore
vendored
Normal file
10
dwg-counting/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
.env
|
||||
.env.local
|
||||
__pycache__/
|
||||
*.pyc
|
||||
samples/
|
||||
test2.pdf
|
||||
test3.pdf
|
||||
symbol.png
|
||||
*.dwg
|
||||
*.dxf
|
||||
@@ -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
124
dwg-counting/blocks.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
337
dwg-counting/counting_gpu.py
Normal file
337
dwg-counting/counting_gpu.py
Normal 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}
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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);
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 %</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>
|
||||
|
||||
217
dwg-counting/vector_match.py
Normal file
217
dwg-counting/vector_match.py
Normal 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)}
|
||||
Reference in New Issue
Block a user