Files
AI_portal/dwg-counting/main.py
2026-07-07 13:16:26 +02:00

828 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""FastAPI app: upload DWG/DXF/PDF → vision-detect legend → count selected symbols → Excel."""
import asyncio
import logging
import os
import uuid
from pathlib import Path
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from counting import count_template, debug_template
from excel_export import export_to_excel
from pdf_export import render_annotated_pdf
from renderer import crop_region, render
from vision import detect_legend
DEFAULT_FLOOR_INDEX = 0 # MVP: process the first detected floor
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="DWG Symbol Counter")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
WORK_DIR = Path(os.getenv("WORK_DIR", "/tmp/dwg-counting"))
WORK_DIR.mkdir(parents=True, exist_ok=True)
jobs: dict[str, dict] = {}
# Persistent action log so the operator can replay what the user did.
ACTION_LOG = WORK_DIR / "action.log"
def _log_action(event: str, **fields):
import datetime, json as _json
line = _json.dumps({
"ts": datetime.datetime.now().isoformat(timespec="seconds"),
"event": event,
**fields,
}, ensure_ascii=False)
try:
with open(ACTION_LOG, "a") as f:
f.write(line + "\n")
except Exception:
pass
@app.get("/")
async def root():
# 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 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
job_dir.mkdir()
input_path = job_dir / f"input{suffix}"
input_path.write_bytes(await file.read())
logger.info("Job %s: %s (%d bytes)", job_id, file.filename, input_path.stat().st_size)
_log_action("upload", job_id=job_id, filename=file.filename,
size=input_path.stat().st_size, auto_detect=auto_detect)
# ── 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 1060 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"]
if not floors:
raise RuntimeError("Z výkresu se nepodařilo nic vyrenderovat")
floor = floors[DEFAULT_FLOOR_INDEX]
png_path = job_dir / floor["png"]
legend_norm = floor.get("legend_norm_bbox")
legend_pixel_box = None
symbols = []
detect_path = png_path
if legend_norm:
from PIL import Image as _Img
full_img = _Img.open(png_path)
W, H = full_img.size
nx0, ny0, nx1, ny1 = legend_norm
# Expand box generously around the LEGENDA text
cx = (nx0 + nx1) / 2
cy = (ny0 + ny1) / 2
# Legend rows extend DOWNWARD from the LEGENDA header text.
# Crop a narrow column starting just above the header.
half_w = 0.10
top_pad = 0.02
below = 0.30
px0 = max(0, int((cx - half_w) * W))
px1 = min(W, int((cx + half_w) * W))
py0 = max(0, int((cy - top_pad) * H))
py1 = min(H, int((cy + below) * H))
legend_crop = job_dir / "legend_area.png"
crop_img = full_img.crop((px0, py0, px1, py1))
crop_img.save(legend_crop, "PNG")
detect_path = legend_crop
legend_pixel_box = (px0, py0, px1 - px0, py1 - py0)
logger.info("Legend area %dx%d ready", px1 - px0, py1 - py0)
if auto_detect:
symbols = await detect_legend(detect_path)
# Symbol bboxes returned by vision are normalized to the image vision saw
# (detect_path), which may be the cropped legend region or the full page.
for s in symbols:
bbox = s.get("bbox") or {}
if all(k in bbox for k in ("x", "y", "w", "h")):
crop_path = job_dir / f"sym_{s['id']}.png"
try:
crop_region(detect_path, bbox, crop_path, pad=1.5, min_px=120)
s["crop_file"] = crop_path.name
except Exception as exc:
logger.warning("Crop failed for %s: %s", s.get("id"), exc)
jobs[job_id] = {
"filename": file.filename,
"png_path": str(png_path),
"job_dir": str(job_dir),
"floors": floors,
"symbols": symbols,
"results": [],
"legend_pixel_box": legend_pixel_box,
"next_user_sym_id": 1,
}
return {"job_id": job_id, "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)
raise HTTPException(500, str(exc))
@app.get("/api/preview/{job_id}")
async def preview(job_id: str):
if job_id not in jobs:
raise HTTPException(404, "Not found")
return FileResponse(jobs[job_id]["png_path"], media_type="image/png")
@app.get("/api/symbol/{job_id}/{sym_id}")
async def symbol_crop(job_id: str, sym_id: str):
if job_id not in jobs:
raise HTTPException(404, "Not found")
job_dir = Path(jobs[job_id]["job_dir"])
crop_path = job_dir / f"sym_{sym_id}.png"
if not crop_path.exists():
raise HTTPException(404, "Crop not available")
return FileResponse(crop_path, media_type="image/png")
@app.get("/api/legend/{job_id}")
async def legend_image(job_id: str):
"""Return the cropped legend area image (what vision saw)."""
if job_id not in jobs:
raise HTTPException(404, "Not found")
legend_path = Path(jobs[job_id]["job_dir"]) / "legend_area.png"
if not legend_path.exists():
# Fall back to full page if no legend crop
legend_path = Path(jobs[job_id]["png_path"])
return FileResponse(legend_path, media_type="image/png")
class RecropRequest(BaseModel):
bbox: dict # {x, y, w, h} normalized 0-1 relative to the legend image
class CreateSymbolRequest(BaseModel):
bbox: dict # normalized 0-1 relative to the FULL drawing image
description: str
source: str = "drawing" # "drawing" or "legend"
@app.post("/api/symbols/{job_id}")
async def create_user_symbol(job_id: str, req: CreateSymbolRequest):
"""User drew a rectangle on the drawing → create a symbol from that crop."""
if job_id not in jobs:
raise HTTPException(404, "Not found")
job = jobs[job_id]
job_dir = Path(job["job_dir"])
# Pick source image
if req.source == "legend":
src = job_dir / "legend_area.png"
if not src.exists():
src = Path(job["png_path"])
else:
src = Path(job["png_path"])
sym_id = f"user_{job['next_user_sym_id']}"
job["next_user_sym_id"] += 1
crop_path = job_dir / f"sym_{sym_id}.png"
# User's rectangle is exact — no padding, no min size enforcement.
crop_region(src, req.bbox, crop_path, pad=0.0, min_px=0)
sym = {
"id": sym_id,
"description": req.description or sym_id,
"bbox": req.bbox,
"crop_file": crop_path.name,
"user_defined": True,
}
job["symbols"].append(sym)
_log_action("create_symbol", job_id=job_id, sym_id=sym_id,
description=req.description, bbox=req.bbox, source=req.source)
return sym
@app.post("/api/symbols/{job_id}/upload")
async def upload_symbol_image(
job_id: str,
file: UploadFile = File(...),
description: str = "",
):
"""Accept a pre-cropped symbol image as a template, bypassing the
rectangle-drawing UI. Useful when the user has a clean PNG from elsewhere.
"""
if job_id not in jobs:
raise HTTPException(404, "Not found")
job = jobs[job_id]
job_dir = Path(job["job_dir"])
sym_id = f"user_{job['next_user_sym_id']}"
job["next_user_sym_id"] += 1
crop_path = job_dir / f"sym_{sym_id}.png"
raw = await file.read()
crop_path.write_bytes(raw)
# Normalize: ensure RGB on white background (drop alpha so processing
# doesn't see transparency as "not white")
from PIL import Image as _Img
img = _Img.open(crop_path)
if img.mode in ("RGBA", "LA"):
bg = _Img.new("RGB", img.size, (255, 255, 255))
bg.paste(img, mask=img.split()[-1])
bg.save(crop_path, "PNG")
sym = {
"id": sym_id,
"description": (description or file.filename or sym_id).strip(),
"crop_file": crop_path.name,
"user_defined": True,
"uploaded": True,
}
job["symbols"].append(sym)
_log_action("upload_symbol", job_id=job_id, sym_id=sym_id,
description=sym["description"], filename=file.filename,
size=len(raw))
return sym
@app.delete("/api/symbols/{job_id}/{sym_id}")
async def delete_symbol(job_id: str, sym_id: str):
if job_id not in jobs:
raise HTTPException(404, "Not found")
job = jobs[job_id]
job["symbols"] = [s for s in job["symbols"] if s["id"] != sym_id]
crop = Path(job["job_dir"]) / f"sym_{sym_id}.png"
if crop.exists():
crop.unlink()
return {"ok": True}
@app.post("/api/auto-detect/{job_id}")
async def trigger_auto_detect(job_id: str):
"""Run the vision legend detection on demand (optional shortcut)."""
if job_id not in jobs:
raise HTTPException(404, "Not found")
job = jobs[job_id]
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"])
found = await detect_legend(detect_path)
for s in found:
bbox = s.get("bbox") or {}
if all(k in bbox for k in ("x", "y", "w", "h")):
crop_path = job_dir / f"sym_{s['id']}.png"
try:
crop_region(detect_path, bbox, crop_path, pad=1.5, min_px=120)
s["crop_file"] = crop_path.name
except Exception as exc:
logger.warning("Crop failed for %s: %s", s.get("id"), exc)
# Replace vision-detected ones (keep user-defined)
job["symbols"] = [s for s in job["symbols"] if s.get("user_defined")] + found
return {"symbols": job["symbols"]}
@app.get("/api/drawing/{job_id}")
async def drawing_image(job_id: str):
"""Return the rendered full drawing image (for the user to crop on)."""
if job_id not in jobs:
raise HTTPException(404, "Not found")
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}")
async def debug_symbol(job_id: str, sym_id: str):
"""Diagnostics about a symbol's template: size, ink, match scores."""
if job_id not in jobs:
raise HTTPException(404, "Not found")
job = jobs[job_id]
crop = Path(job["job_dir"]) / f"sym_{sym_id}.png"
if not crop.exists():
raise HTTPException(404, "Crop not available")
drawing = Path(job["png_path"])
info = await asyncio.to_thread(debug_template, crop, drawing)
return info
@app.get("/api/debug-template/{job_id}/{sym_id}")
async def debug_template_image(job_id: str, sym_id: str):
"""Return the *processed* template (what the matcher actually sees)."""
import cv2
from counting import _prep, _crop_to_content
if job_id not in jobs:
raise HTTPException(404, "Not found")
job = jobs[job_id]
crop = Path(job["job_dir"]) / f"sym_{sym_id}.png"
if not crop.exists():
raise HTTPException(404, "Crop not available")
tmpl = _prep(crop)
tmpl = _crop_to_content(tmpl)
out = Path(job["job_dir"]) / f"sym_{sym_id}_processed.png"
cv2.imwrite(str(out), tmpl)
return FileResponse(str(out), media_type="image/png")
@app.post("/api/symbol/{job_id}/{sym_id}/recrop")
async def recrop_symbol(job_id: str, sym_id: str, req: RecropRequest):
"""Replace a symbol's crop. bbox is normalized 0-1 relative to the FULL
drawing image (which is what the frontend shows in the editor)."""
if job_id not in jobs:
raise HTTPException(404, "Not found")
job = jobs[job_id]
src = Path(job["png_path"])
crop_path = Path(job["job_dir"]) / f"sym_{sym_id}.png"
crop_region(src, req.bbox, crop_path, pad=0.0, min_px=0)
return {"ok": True, "crop_file": crop_path.name}
class CountRequest(BaseModel):
symbol_ids: list[str]
threshold: float | None = None # Override default (0.7); lower = more matches
@app.post("/api/count/{job_id}")
async def count(job_id: str, req: CountRequest):
if job_id not in jobs:
raise HTTPException(404, "Not found")
job = jobs[job_id]
png = Path(job["png_path"])
job_dir = Path(job["job_dir"])
selected = [s for s in job["symbols"] if s["id"] in req.symbol_ids]
# Determine the legend mask box (avoid matching the legend itself).
legend_box = job.get("legend_pixel_box") # set during upload if available
thr = req.threshold if req.threshold is not None else None
def _count_one(sym):
crop = job_dir / f"sym_{sym['id']}.png"
if not crop.exists():
return {"id": sym["id"], "description": sym["description"],
"count": 0, "matches": [], "notes": "no crop"}
try:
kwargs = {"exclude_box": legend_box}
if thr is not None:
kwargs["threshold"] = thr
res = count_template(crop, png, **kwargs)
except Exception as exc:
logger.exception("Counting failed for %s", sym["id"])
return {"id": sym["id"], "description": sym["description"],
"count": 0, "matches": [], "notes": f"error: {exc}"}
return {
"id": sym["id"],
"description": sym["description"],
"count": res["count"],
"matches": res["matches"],
"notes": "" if res["count"] else "žádné shody nenalezeny",
}
# Serialize OpenCV calls — parallel matchTemplate on a 4000px drawing
# blows past 4GB peak memory and OOM-kills the container.
results = []
for s in selected:
r = await asyncio.to_thread(_count_one, s)
results.append(r)
_log_action("count_one", job_id=job_id, sym_id=s["id"],
description=s.get("description"), count=r.get("count"))
job["results"] = list(results)
# Trim matches from API response (keep them server-side for PDF export)
response_results = [{k: v for k, v in r.items() if k != "matches"} | {"count": r["count"]}
for r in job["results"]]
return {"results": response_results}
# ── 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.400.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:
raise HTTPException(404, "Not found")
job = jobs[job_id]
if not job["results"]:
raise HTTPException(400, "Nejprve spočítejte symboly")
out_path = Path(job["job_dir"]) / "counts.xlsx"
export_to_excel(job["results"], job["filename"] or "drawing", str(out_path))
stem = Path(job["filename"]).stem if job["filename"] else "drawing"
return FileResponse(
str(out_path),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename=f"symboly_{stem}.xlsx",
)
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:
raise HTTPException(404, "Not found")
job = jobs[job_id]
if not job["results"]:
raise HTTPException(400, "Nejprve spočítejte symboly")
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(
render_annotated_pdf, png_path, job["results"], out_path, stem,
)
return FileResponse(
str(out_path),
media_type="application/pdf",
filename=f"vyznaceno_{stem}.pdf",
)
@app.get("/health")
async def health():
return {"status": "ok"}
app.mount("/static", StaticFiles(directory="static"), name="static")