828 lines
32 KiB
Python
828 lines
32 KiB
Python
"""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 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"]
|
||
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.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:
|
||
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")
|