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

472 lines
19 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.

"""Render DWG/DXF/PDF → PNG image(s) for vision model consumption.
Strategy: multi-floor architectural drawings are split per detected legend.
Each floor renders to its own PNG at a resolution where individual symbols
remain distinguishable for the vision model.
"""
import logging
import subprocess
from pathlib import Path
import io
import ezdxf
from ezdxf.addons.drawing.config import (
BackgroundPolicy, ColorPolicy, Configuration,
)
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 = 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:
dxf_path = out_dir / f"{dwg_path.stem}.dxf"
r = subprocess.run(
["dwgread", "-O", "DXF", "-o", str(dxf_path), str(dwg_path)],
capture_output=True, text=True, timeout=180,
)
if not dxf_path.exists():
raise RuntimeError(f"DWG→DXF failed (exit {r.returncode}): {r.stderr or r.stdout}")
return dxf_path
SKIP_LAYER_PATTERNS = (
"dimens", "kota", "kóta", "koty", # dimensions
"sit_konst", "konstrukce", "ckkoty", # structural / dimensioning
"ckprofi", "profily", "csprofily", # steel/concrete profile rezy
"sanita", "vzt", "ov_kan", "tzb", # plumbing / HVAC (when not the target)
"viewport", "defpoints", "0_", "_b_", # CAD bookkeeping
"raster", "wipeout",
)
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 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:
for e in list(msp):
layer = str(getattr(e.dxf, "layer", "")).lower()
if any(p in layer for p in SKIP_LAYER_PATTERNS):
try:
msp.delete_entity(e)
except Exception:
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
def find_floors(dxf_path: Path) -> list[dict]:
"""Find legend 'LEGENDA' markers — each represents one floor.
Returns list of {legend_xy, floor_bbox} dicts ordered top to bottom.
"""
doc = ezdxf.readfile(str(dxf_path))
msp = doc.modelspace()
positions = []
for e in msp:
text = ""
if e.dxftype() == "MTEXT":
text = e.text
elif e.dxftype() == "TEXT":
text = e.dxf.text
if text and text.strip().upper() == "LEGENDA":
try:
positions.append((e.dxf.insert.x, e.dxf.insert.y))
except Exception:
pass
positions.sort(key=lambda p: -p[1])
if not positions:
return [{"legend_xy": None, "floor_bbox": None}]
# Use the same cleanup as render_region so extents reflect what'll be drawn
_clean_doc(doc)
from ezdxf.bbox import extents
try:
ext = extents(msp, fast=True)
mxmin, mymin = ext.extmin.x, ext.extmin.y
mxmax, mymax = ext.extmax.x, ext.extmax.y
except Exception:
mxmin, mymin = -1e9, -1e9
mxmax, mymax = 1e9, 1e9
logger.info("find_floors: model extents x=(%.0f,%.0f) y=(%.0f,%.0f)",
mxmin, mxmax, mymin, mymax)
floors = []
for i, (lx, ly) in enumerate(positions):
if i + 1 < len(positions):
y_height = ly - positions[i + 1][1]
elif i > 0:
y_height = positions[i - 1][1] - ly
else:
y_height = 60000
# Legend appears at the TOP of its floor view; plan extends downward
y_top = min(mymax, ly + 0.10 * y_height)
y_bot = max(mymin, ly - 0.95 * y_height)
# X span = whole model width (floor plans typically span the page width)
x_left = mxmin
x_right = mxmax
floors.append({
"legend_xy": (lx, ly),
"floor_bbox": (x_left, y_bot, x_right, y_top),
})
return floors
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.
Two backends:
- "cairosvg" (default): ezdxf → SVG → cairosvg → PNG. Faithful
vector linework. ~3090 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()
from ezdxf.bbox import extents
try:
ext = extents(msp, fast=True)
model_xmin = ext.extmin.x
model_ymin = ext.extmin.y
model_w = ext.size.x or 1
model_h = ext.size.y or 1
except Exception:
model_xmin = model_ymin = 0
model_w = model_h = 1
config = Configuration(
background_policy=BackgroundPolicy.WHITE,
color_policy=ColorPolicy.BLACK,
lineweight_scaling=0.5,
min_lineweight=0.05,
)
# 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:
xmin, ymin, xmax, ymax = bbox
model_xmin = float(xmin)
model_ymin = float(ymin)
model_w = float(xmax - xmin) or 1.0
model_h = float(ymax - ymin) or 1.0
# 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])
# 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:
"""Convert input → list of floor images.
Returns: {"floors": [{"index":0, "png":"floor_0.png", "legend_xy":[x,y]}, ...]}.
"""
suffix = input_path.suffix.lower()
if suffix == ".pdf":
return _render_pdf(input_path, out_dir)
if suffix == ".dwg":
dxf_path = dwg_to_dxf(input_path, out_dir)
elif suffix == ".dxf":
dxf_path = input_path
else:
raise ValueError(f"Unsupported format: {suffix}")
# 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"
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:
"""Render PDF → PNG, auto-rotate so LEGENDA reads horizontally.
Uses pdfplumber to find 'LEGENDA' text and its rotation, then renders
via pdf2image and applies image rotation so the legend is upright.
Returns the same shape as the DXF render path.
"""
from pdf2image import convert_from_path
import pdfplumber
# Allow large rasterizations (architectural PDFs can be 200M+ px at high DPI)
Image.MAX_IMAGE_PIXELS = None
# Pick DPI so longest page edge lands near RENDER_PX pixels
with pdfplumber.open(str(pdf_path)) as pdf:
first = pdf.pages[0]
pw_in = max(first.width, first.height) / 72 # PDF points → inches
target_dpi = max(150, min(600, int(RENDER_PX / max(pw_in, 1))))
logger.info("PDF page longest edge %.1f in → using dpi=%d", pw_in, target_dpi)
pages = convert_from_path(str(pdf_path), dpi=target_dpi)
out_paths = []
legend_info: list[dict] = []
with pdfplumber.open(str(pdf_path)) as pdf:
for i, plumb_page in enumerate(pdf.pages):
page_img = pages[i] if i < len(pages) else None
if page_img is None:
continue
pw, ph = plumb_page.width, plumb_page.height
iw, ih = page_img.size
# Find any text matching legend headings
words = plumb_page.extract_words(extra_attrs=["upright"]) or []
legend_word = None
for w in words:
text = w["text"].strip().upper()
if text in ("LEGENDA", "VYSVĚTLIVKY", "LEGENDA:", "POPIS"):
legend_word = w
break
rotation = 0
if legend_word is not None and not legend_word.get("upright", True):
# Sideways text → rotate the image so text is upright.
# PIL.rotate uses CCW for positive angles.
rotation = 90
page_img = page_img.rotate(90, expand=True)
iw, ih = page_img.size
logger.info("PDF page %d: rotated 90° CCW (LEGENDA was sideways)", i)
if legend_word is not None:
# Convert PDF coords to NORMALIZED image coords (after rotation)
x0, y0 = legend_word["x0"], legend_word["top"]
x1, y1 = legend_word["x1"], legend_word["bottom"]
if rotation == 90:
# CCW 90° rotation: original (x, y) → new (y, W-x).
# Rotated image has width=ph, height=pw.
nx0 = y0 / ph
nx1 = y1 / ph
ny0 = 1 - (x1 / pw)
ny1 = 1 - (x0 / pw)
else:
nx0, nx1 = x0 / pw, x1 / pw
ny0, ny1 = y0 / ph, y1 / ph
legend_info.append({"page": i, "norm_bbox": (nx0, ny0, nx1, ny1)})
logger.info("PDF page %d: LEGENDA at norm bbox %s",
i, (nx0, ny0, nx1, ny1))
if max(page_img.size) > RENDER_PX:
r = RENDER_PX / max(page_img.size)
page_img = page_img.resize(
(int(page_img.size[0] * r), int(page_img.size[1] * r)),
Image.LANCZOS,
)
p = out_dir / f"floor_{i}.png"
page_img.save(p, "PNG", optimize=True)
out_paths.append(p)
return {
"floors": [
{"index": i, "png": p.name, "legend_xy": None,
"legend_norm_bbox": next((li["norm_bbox"] for li in legend_info
if li["page"] == i), None)}
for i, p in enumerate(out_paths)
]
}
def crop_region(image_path: Path, bbox: dict, out_path: Path,
pad: float = 1.5, min_px: int = 120) -> Path:
"""Crop a region with generous padding so symbols are visible.
pad: multiplier of the bbox half-extent added on each side.
min_px: ensure the output is at least this many pixels wide/tall by
expanding the crop region if the requested area is smaller.
"""
img = Image.open(image_path)
W, H = img.size
bx, by, bw, bh = bbox["x"], bbox["y"], bbox["w"], bbox["h"]
cx, cy = bx + bw / 2, by + bh / 2
# Padded extents in normalized coords
half_w = bw / 2 + bw * pad
half_h = bh / 2 + bh * pad
# Enforce minimum pixel dimensions
if (2 * half_w) * W < min_px:
half_w = (min_px / 2) / W
if (2 * half_h) * H < min_px:
half_h = (min_px / 2) / H
x0 = max(0, int((cx - half_w) * W))
x1 = min(W, int((cx + half_w) * W))
y0 = max(0, int((cy - half_h) * H))
y1 = min(H, int((cy + half_h) * H))
crop = img.crop((x0, y0, x1, y1))
crop.save(out_path, "PNG")
return out_path