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