File size: 21,578 Bytes
a4d7d23 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 | """Data-driven graph construction.
This module is the heart of the Day-4 de-hardcoding. The legacy monolith built
its relationship graph by *merging in* `_known_connections()` — 8 edges hand-
coded for the "Plant An App - AWS" diagram — so the graph looked complete even
when arrow detection found nothing. That baked the answer key into the code.
Here, EVERY relationship is derived from a detected arrow:
1. `label_boxes` — assign each box a label from the text contained in it.
2. `build_relationships` — map each arrow's endpoints to the nearest boxes,
gated so segments lying inside/on a box border (the Day-3 snap-to-adjacency
artifact) are rejected.
3. `draw_graph` — render with a general `kamada_kawai` / `spring` layout.
No hardcoded node coordinates.
"""
from __future__ import annotations
from typing import List, Optional
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import networkx as nx
import numpy as np
def label_boxes(boxes: List[dict], texts: List[dict]) -> List[dict]:
"""Assign each box a text label from the OCR fragments whose centre falls
inside it (smallest enclosing box wins, mirroring the legacy
associate_elements). Fragments are joined in reading order (top-to-bottom,
left-to-right). A box that contains no text keeps the label it came with
(an icon node is labelled from its caption) or an empty one."""
contained: dict = {i: [] for i in range(len(boxes))}
for t in texts:
tcx = t["x"] + t["w"] // 2
tcy = t["y"] + t["h"] // 2
best_box = None
best_area = float("inf")
for bi, b in enumerate(boxes):
if b["x"] <= tcx <= b["x"] + b["w"] and b["y"] <= tcy <= b["y"] + b["h"]:
area = b["w"] * b["h"]
if area < best_area:
best_area = area
best_box = bi
if best_box is not None:
contained[best_box].append(t)
for bi, b in enumerate(boxes):
frags = sorted(contained[bi], key=lambda t: (t["y"], t["x"]))
label = " ".join(f["text"].strip() for f in frags if f.get("text", "").strip())
b["label"] = label.strip() or (b.get("label") or "").strip()
return _label_from_captions(boxes, texts)
def _caption_gap(t: dict, b: dict) -> Optional[float]:
"""Distance from box `b` to text `t` if `t` sits just below or above it
(a caption or a title), else None."""
cx = t["x"] + t["w"] / 2
if not b["x"] - 0.25 * b["w"] <= cx <= b["x"] + 1.25 * b["w"]:
return None
gaps = [g for g in (t["y"] - (b["y"] + b["h"]), b["y"] - (t["y"] + t["h"])) # below, above
if -4 <= g <= max(14.0, 0.45 * b["h"])]
return min(abs(g) for g in gaps) if gaps else None
def _label_from_captions(boxes: List[dict], texts: List[dict]) -> List[dict]:
"""An unlabelled component box takes the nearest free text just below or
above it: icons (a database cylinder) carry their caption underneath, and
titled frames (e.g. "Back-End") carry it on top. Text inside any component
box is never free, nor is a caption line an icon node was found and
labelled by (its `caption_boxes`); containers (regions) don't claim text
here. Captions go to the closest box first, so a farther box cannot take a
nearer box's caption."""
def centre_inside(t: dict, b: dict) -> bool:
cx, cy = t["x"] + t["w"] / 2, t["y"] + t["h"] / 2
return b["x"] <= cx <= b["x"] + b["w"] and b["y"] <= cy <= b["y"] + b["h"]
components = [b for b in boxes if not b.get("is_region")]
used = {tuple(c) for b in components for c in (b.get("caption_boxes") or [])}
free = [t for t in texts if t.get("text", "").strip()
and not any(centre_inside(t, b) for b in components)
and (t["x"], t["y"], t["w"], t["h"]) not in used]
pairs = sorted((gap, bi, ti) for bi, b in enumerate(components) if not b.get("label")
for ti, t in enumerate(free) for gap in [_caption_gap(t, b)] if gap is not None)
used_boxes, used_texts = set(), set()
for _, bi, ti in pairs:
if bi not in used_boxes and ti not in used_texts:
components[bi]["label"] = free[ti]["text"].strip()
used_boxes.add(bi)
used_texts.add(ti)
return boxes
def _point_in_box(x: int, y: int, b: dict) -> bool:
return b["x"] <= x <= b["x"] + b["w"] and b["y"] <= y <= b["y"] + b["h"]
def _nearest_box(x: int, y: int, boxes: List[dict], max_dist: float = 100.0,
tie_px: float = 8.0) -> Optional[int]:
"""Index of the box nearest to (x, y) within max_dist, by min(centre, edge)
distance. Among boxes within `tie_px` of the nearest, the smallest wins: an
arrow ending at an inner box is also inside (distance 0) the frame around
it, only a few px from the inner box. Returns None if nothing is close."""
cands = []
for i, b in enumerate(boxes):
bcx = b["x"] + b["w"] / 2
bcy = b["y"] + b["h"] / 2
d_centre = float(np.hypot(x - bcx, y - bcy))
dx = max(b["x"] - x, 0, x - (b["x"] + b["w"]))
dy = max(b["y"] - y, 0, y - (b["y"] + b["h"]))
d = min(d_centre, float(np.hypot(dx, dy)))
if d <= max_dist:
cands.append((d, b["w"] * b["h"], i))
if not cands:
return None
nearest = min(c[0] for c in cands)
return min((c for c in cands if c[0] <= nearest + tie_px), key=lambda c: c[1])[2]
def _arrow_points(a: dict) -> List[List[float]]:
return a.get("points") or [[a["x1"], a["y1"]], [a["x2"], a["y2"]]]
def _along_box_edge(a: dict, boxes: List[dict], tol: float = 4.0, frac: float = 0.7) -> bool:
"""True if the arrow mostly runs along some box's or container's edge.
Box borders are never connectors, but an outline the detector could not
see as closed (e.g. a container edge cut by a white edge label) still
reaches here as a line."""
total = covered = 0.0
pts = _arrow_points(a)
for (x1, y1), (x2, y2) in zip(pts, pts[1:]):
total += float(np.hypot(x2 - x1, y2 - y1))
horizontal, vertical = abs(y2 - y1) <= tol, abs(x2 - x1) <= tol
best = 0.0
for b in boxes:
if horizontal and min(abs((y1 + y2) / 2 - b["y"]), abs((y1 + y2) / 2 - b["y"] - b["h"])) <= tol:
best = max(best, min(max(x1, x2), b["x"] + b["w"]) - max(min(x1, x2), b["x"]))
elif vertical and min(abs((x1 + x2) / 2 - b["x"]), abs((x1 + x2) / 2 - b["x"] - b["w"])) <= tol:
best = max(best, min(max(y1, y2), b["y"] + b["h"]) - max(min(y1, y2), b["y"]))
covered += max(0.0, best)
return total > 0 and covered / total >= frac
def _branch_point(px: float, py: float, path: List[List[float]], tol: float, end_margin: float):
"""Where (px, py) lies on the interior of polyline `path`: (leg index,
foot point), or None."""
total = sum(float(np.hypot(q[0] - p[0], q[1] - p[1])) for p, q in zip(path, path[1:]))
run = 0.0
for i, ((x1, y1), (x2, y2)) in enumerate(zip(path, path[1:])):
seg = float(np.hypot(x2 - x1, y2 - y1))
t = 0.0 if seg == 0 else max(0.0, min(1.0, ((px - x1) * (x2 - x1) + (py - y1) * (y2 - y1)) / seg ** 2))
fx, fy = x1 + t * (x2 - x1), y1 + t * (y2 - y1)
if float(np.hypot(px - fx, py - fy)) <= tol and end_margin <= run + t * seg <= total - end_margin:
return i, [fx, fy]
run += seg
return None
def _inherit_trunks(arrows: List[dict], tol: float = 5.0, end_margin: float = 15.0) -> List[dict]:
"""A branch whose tail lies on another connector's interior starts where
that connector starts: draw.io routes connectors that leave the same port
along one shared first leg, so only one of them is seen from the port.
The trunk's path up to the branch point is spliced in, so the tail end
still points along a real leg, and a branch off a branch resolves to the
first trunk's tail. Both need an arrowhead: a leftover border line is
never a trunk, and a headless line has no known tail -- the end touching
the trunk may be where another connector merges in. A double-headed
trunk has no tail either."""
paths = [[list(p) for p in _arrow_points(a)] for a in arrows]
resolved: dict = {}
def resolve(k: int, visiting: frozenset) -> List[List[float]]:
if k in resolved:
return resolved[k]
pts = paths[k]
if arrows[k].get("has_head"):
for j, o in enumerate(arrows):
if j == k or j in visiting or not o.get("has_head") or o.get("bidirectional"):
continue
hit = _branch_point(pts[0][0], pts[0][1], paths[j], tol, end_margin)
if hit is not None:
trunk = resolve(j, visiting | {k})
# The trunk may itself have been extended back; find the
# branch point on its resolved path.
leg = _branch_point(pts[0][0], pts[0][1], trunk, tol, 0.0) or hit
pts = [list(p) for p in trunk[:leg[0] + 1]] + [leg[1]] + pts[1:]
break
resolved[k] = pts
return pts
out = []
for k, a in enumerate(arrows):
pts = resolve(k, frozenset())
b = dict(a, points=pts)
b["x1"], b["y1"], b["x2"], b["y2"] = pts[0][0], pts[0][1], pts[-1][0], pts[-1][1]
out.append(b)
return out
def _midpoint_inside_any_box(arrow: dict, boxes: List[dict]) -> bool:
"""The Day-3 'outside any box bbox' gate. A genuine connector runs through
whitespace BETWEEN boxes, so its midpoint is not inside a box. A box-border
/ internal-edge segment (the snap-to-adjacency artifact that inflated CNN-
verified recall on Day 3) has its midpoint on or inside a box bbox."""
mx = (arrow["x1"] + arrow["x2"]) // 2
my = (arrow["y1"] + arrow["y2"]) // 2
return any(_point_in_box(mx, my, b) for b in boxes)
def _ray_aabb_t(ox: float, oy: float, dx: float, dy: float, b: dict) -> Optional[float]:
"""Slab ray/AABB intersection. Returns the entry distance t>=0 along the
unit ray (ox,oy)+t*(dx,dy) at which the ray enters box `b`, or None if it
never does. Handles axis-aligned rays (dx or dy == 0)."""
minx, miny = b["x"], b["y"]
maxx, maxy = b["x"] + b["w"], b["y"] + b["h"]
tmin, tmax = 0.0, float("inf")
for o, d, lo, hi in ((ox, dx, minx, maxx), (oy, dy, miny, maxy)):
if abs(d) < 1e-9:
if o < lo or o > hi:
return None
else:
t1, t2 = (lo - o) / d, (hi - o) / d
if t1 > t2:
t1, t2 = t2, t1
tmin = max(tmin, t1)
tmax = min(tmax, t2)
if tmin > tmax:
return None
return tmin if tmax >= 0 else None
def _box_along_ray(x: float, y: float, dx: float, dy: float,
boxes: List[dict], max_proj: float) -> Optional[int]:
"""Index of the first box the outward ray from (x,y) along (dx,dy) enters,
within `max_proj` px. This is the 'intersection-with-box' recovery: a short
detected segment that stops in whitespace still *points at* its true target
box, so we project the line and snap to the box it would hit."""
norm = float(np.hypot(dx, dy))
if norm < 1e-9:
return None
ux, uy = dx / norm, dy / norm
best_i, best_t = None, max_proj
for i, b in enumerate(boxes):
t = _ray_aabb_t(x, y, ux, uy, b)
if t is not None and 0.0 <= t < best_t:
best_t, best_i = t, i
return best_i
def _assign_endpoint(x: float, y: float, ox: float, oy: float,
boxes: List[dict], max_dist: float,
ray_intersection: bool, max_proj: float) -> Optional[int]:
"""Map an arrow endpoint (x,y) to a box. (ox,oy) is the *other* endpoint,
used to compute the outward direction for the ray fallback. Try the nearest
box within `max_dist` first (precise when the endpoint sits on/near a box);
if nothing is close enough and `ray_intersection` is on, project the line
outward and snap to the box it enters."""
i = _nearest_box(x, y, boxes, max_dist)
if i is not None:
return i
if ray_intersection:
return _box_along_ray(x, y, x - ox, y - oy, boxes, max_proj)
return None
def build_relationships(
arrows: List[dict],
boxes: List[dict],
outside_box_gate: bool = True,
max_dist: float = 120.0,
ray_intersection: bool = False,
max_proj: float = 400.0,
edge_filter: bool = True,
trunks: bool = True,
) -> List[dict]:
"""Build relationships purely from detected arrows. No hardcoded edges.
For each arrow, snap its first point (tail) and last point (head) to a
labelled box and emit a de-duplicated source -> target edge, flagged
`bidirectional` for a double-headed connector. With `outside_box_gate`,
arrows whose midpoint lies inside/on a box are dropped (border artifacts).
With `edge_filter`, arrows that run along a box or container edge are
dropped. With `trunks`, a branch that leaves another connector's shared
first leg is traced back to that connector's tail.
Day-5 arrow-mapping fix (`ray_intersection`): error analysis found the
dominant failure mode is arrow-mapping, and within it ~40% of missed
connections are *mis-mapped* short segments whose endpoints stop in
whitespace beyond `max_dist`. With `ray_intersection` on, an endpoint that
has no box within `max_dist` is projected outward along the segment's
direction and snapped to the first box the ray enters (within `max_proj`).
"""
# Endpoints map to component boxes (skip region/container boxes and
# platform background boxes — they swallow endpoints and create spurious
# parent->child edges).
candidates = [
b for b in boxes
if not b.get("is_region") and b.get("entity_type") != "platform"
]
if len(candidates) < 2:
candidates = [b for b in boxes if not b.get("is_region")]
if len(candidates) < 2:
candidates = boxes
gate_boxes = candidates
def labelled(b: dict) -> bool:
return bool((b.get("label") or "").strip())
def within(inner: dict, outer: dict, slack: float = 2.0) -> bool:
return (inner["x"] >= outer["x"] - slack and inner["y"] >= outer["y"] - slack
and inner["x"] + inner["w"] <= outer["x"] + outer["w"] + slack
and inner["y"] + inner["h"] <= outer["y"] + outer["h"] + slack)
# An unlabelled box can never give an edge, but an end that reaches one
# (an icon with no caption, a node whose text OCR missed) stops there --
# it must not carry on to a labelled box nearby. It must not outbid the
# labelled box around it either: a glyph inside a node is not a blocker.
named = [b for b in candidates if labelled(b)]
blockers = []
if named:
blockers = [b for b in candidates if not labelled(b) and not any(within(b, n) for n in named)]
candidates = named
# Likewise only labelled boxes and containers have edges worth filtering
# against: a spurious unlabelled contour can have a connector as a side.
edge_boxes = [b for b in boxes if labelled(b) or b.get("is_region")]
relationships: List[dict] = []
seen: dict = {}
if trunks:
arrows = _inherit_trunks(arrows)
for a in arrows:
if outside_box_gate and _midpoint_inside_any_box(a, gate_boxes):
continue
if edge_filter and _along_box_edge(a, edge_boxes):
continue
# Each end projects along its own leg, which for an elbow is not the
# line to the other end.
pts = _arrow_points(a)
(sx, sy), (sx2, sy2) = pts[0], pts[1]
(tx, ty), (tx2, ty2) = pts[-1], pts[-2]
# A contour around the whole connector (e.g. split along it) blocks
# nothing.
pool = candidates + [u for u in blockers
if not (_point_in_box(sx, sy, u) and _point_in_box(tx, ty, u))]
si = _assign_endpoint(sx, sy, sx2, sy2, pool, max_dist, ray_intersection, max_proj)
ti = _assign_endpoint(tx, ty, tx2, ty2, pool, max_dist, ray_intersection, max_proj)
if si is None or ti is None or si == ti or si >= len(candidates) or ti >= len(candidates):
continue
src = candidates[si].get("label", "").strip()
tgt = candidates[ti].get("label", "").strip()
if not src or not tgt or src == tgt:
continue
bidirectional = bool(a.get("bidirectional"))
if (src, tgt) in seen:
if bidirectional: # a second detection of the same connector saw both heads
relationships[seen[(src, tgt)]]["bidirectional"] = True
continue
reverse = seen.get((tgt, src))
if reverse is not None and (bidirectional or relationships[reverse]["bidirectional"]):
relationships[reverse]["bidirectional"] = True # already covered both ways
continue
seen[(src, tgt)] = len(relationships)
relationships.append({
"source": src,
"target": tgt,
"line_style": a.get("line_style", "solid"),
"direction": a.get("direction", "unknown"),
"relationship": "connects_to",
"bidirectional": bidirectional,
"detected": True,
})
# A one-way row seen before the double-headed detection of the same pair
# is covered by it.
both_ways = {(r["source"], r["target"]) for r in relationships if r["bidirectional"]}
return [r for r in relationships if r["bidirectional"] or (r["target"], r["source"]) not in both_ways]
def build_graph(relationships: List[dict]) -> nx.DiGraph:
G = nx.DiGraph()
for r in relationships:
src, tgt = (r.get("source") or "").strip(), (r.get("target") or "").strip()
if src and tgt and src != tgt:
G.add_edge(src, tgt,
line_style=r.get("line_style", "solid"),
relationship=r.get("relationship", ""))
return G
def _short_label(s: str, max_chars: int = 18) -> str:
s = (s or "").strip()
if len(s) <= max_chars:
return s
words, line1, line2 = s.split(), "", ""
for w in words:
if len(line1) + len(w) + 1 <= max_chars or not line1:
line1 = (line1 + " " + w).strip()
else:
line2 = (line2 + " " + w).strip()
return line1 + ("\n" + line2 if line2 else "")
def draw_graph(relationships: List[dict], output_path: str, layout: str = "kamada_kawai") -> bool:
"""Render the relationship DiGraph with a general, data-driven layout.
No hardcoded `pos = {...}`: node positions come from the graph structure, so
this works on any diagram. Returns False if there is nothing to draw."""
G = nx.DiGraph()
for r in relationships:
src = _short_label(r.get("source", ""))
tgt = _short_label(r.get("target", ""))
if src and tgt and src != tgt:
G.add_edge(src, tgt,
line_style=r.get("line_style", "solid"),
relationship=r.get("relationship", ""),
bidirectional=bool(r.get("bidirectional")))
if len(G.nodes) == 0:
return False
if layout == "spring":
pos = nx.spring_layout(G, seed=42, k=1.2)
else:
try:
pos = nx.kamada_kawai_layout(G)
except Exception:
pos = nx.spring_layout(G, seed=42, k=1.2)
fig, ax = plt.subplots(figsize=(16, 9))
node_size, node_shape = 3600, "s"
nx.draw_networkx_nodes(G, pos, node_size=node_size, node_color="#D5E8F0",
edgecolors="#333", linewidths=2, node_shape=node_shape, ax=ax)
nx.draw_networkx_labels(G, pos, font_size=8, font_weight="bold", ax=ax)
# Edges are told the node size and shape so they stop at the squares;
# otherwise the heads are drawn underneath them.
for both in (False, True):
heads = "<|-|>" if both else "-|>" # double-headed connectors get both heads
edges = [(u, v, d) for u, v, d in G.edges(data=True) if bool(d.get("bidirectional")) == both]
solid = [(u, v) for u, v, d in edges if d.get("line_style") == "solid"]
dashed = [(u, v) for u, v, d in edges if d.get("line_style") != "solid"]
nx.draw_networkx_edges(G, pos, edgelist=solid, edge_color="#333", width=2.0,
arrows=True, arrowstyle=heads, arrowsize=18, ax=ax,
node_size=node_size, node_shape=node_shape,
connectionstyle="arc3,rad=0.08")
nx.draw_networkx_edges(G, pos, edgelist=dashed, edge_color="#666", width=1.5,
style="dashed", arrows=True, arrowstyle=heads, arrowsize=18, ax=ax,
node_size=node_size, node_shape=node_shape,
connectionstyle="arc3,rad=0.08")
edge_labels = {(u, v): d.get("relationship", "")
for u, v, d in G.edges(data=True) if d.get("relationship")}
nx.draw_networkx_edge_labels(G, pos, edge_labels=edge_labels,
font_size=7, font_color="#444", ax=ax)
ax.set_title("Relationship Graph (data-driven)", fontsize=15, fontweight="bold", pad=18)
ax.axis("off")
fig.tight_layout()
fig.savefig(output_path, dpi=150, bbox_inches="tight", facecolor="white")
plt.close(fig)
return True
|