IamBatman07's picture
Sync with dev 539d30a: draw.io accuracy (GitHub PR #18)
a4d7d23 verified
Raw History Blame Contribute Delete
21.6 kB
"""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