Spaces:
Runtime error
Runtime error
| import traceback | |
| import os | |
| os.environ["YOLO_VERBOSE"] = "False" | |
| import re | |
| import io as _io | |
| import json | |
| import base64 | |
| import tempfile | |
| from collections import defaultdict | |
| import cv2 | |
| import numpy as np | |
| import gradio as gr | |
| import networkx as nx | |
| from ultralytics import YOLO | |
| from PIL import Image | |
| import easyocr | |
| import pandas as pd | |
| from openai import OpenAI | |
| from skimage.morphology import skeletonize | |
| from scipy.spatial import KDTree | |
| model = YOLO("best.pt") | |
| # Lazy-initialize EasyOCR so startup isn't blocked | |
| _ocr_reader = None | |
| def get_ocr_reader(): | |
| global _ocr_reader | |
| if _ocr_reader is None: | |
| _ocr_reader = easyocr.Reader(['en'], gpu=False) | |
| return _ocr_reader | |
| DEFAULT_VALUES = { | |
| 'resistor': '1kΞ©', 'capacitor': '100Β΅F', 'inductor': '10mH', 'led': 'LED', | |
| 'diode': '0.7V', 'transistor': 'NPN', 'voltage source': '5V', 'battery': '9V', | |
| 'switch': 'SPST', 'switch open': 'OPEN', 'switch closed': 'CLOSED', | |
| 'ground': 'GND', 'gnd': 'GND', 'ammeter': '0A', 'voltmeter': '0V', 'galvanometer': '0A', | |
| } | |
| PREFIX_MAP = { | |
| 'resistor': 'R', 'capacitor': 'C', 'inductor': 'L', 'led': 'LED', 'diode': 'D', | |
| 'transistor': 'Q', 'voltage source': 'V', 'battery': 'V', 'switch': 'SW', | |
| 'switch open': 'SW', 'switch closed': 'SW', 'ground': 'GND', 'gnd': 'GND', | |
| 'op-amp': 'U', 'ammeter': 'A', 'voltmeter': 'VM', 'galvanometer': 'G', | |
| } | |
| SUPPRESS_CLASSES = {'arrow'} | |
| DEBUG_LOG = [] | |
| def debug_log(label, content): | |
| DEBUG_LOG.append(f"βββββ {label} βββββ\n{content}\n") | |
| print(f"[{label}] {content}") | |
| def clear_debug_log(): | |
| DEBUG_LOG.clear() | |
| def get_debug_text(): | |
| return "\n".join(DEBUG_LOG) if DEBUG_LOG else "No output yet." | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # LLM / VLM | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_client(): | |
| key = os.environ.get("OPENROUTER_API_KEY") | |
| if not key: | |
| raise ValueError("OPENROUTER_API_KEY not set") | |
| return OpenAI(api_key=key, base_url="https://openrouter.ai/api/v1") | |
| def image_to_b64(image): | |
| if image.mode != 'RGB': | |
| image = image.convert('RGB') | |
| buf = _io.BytesIO() | |
| image.save(buf, format="JPEG", quality=85) | |
| return base64.standard_b64encode(buf.getvalue()).decode("utf-8") | |
| import time | |
| VLM_FALLBACK_MODELS = [ | |
| "google/gemma-3-27b-it:free", | |
| "nvidia/nemotron-nano-12b-v2-vl:free", | |
| "openrouter/auto", | |
| ] | |
| _HARD_SKIP = ("404", "not found", "unavailable", "no endpoints", "paid", "model_not_found", "invalid model") | |
| _DEAD_MODELS = set() | |
| def call_mistral(messages, label="MISTRAL"): | |
| last_err = None | |
| for attempt, slug in enumerate(VLM_FALLBACK_MODELS): | |
| if slug in _DEAD_MODELS: | |
| continue | |
| try: | |
| resp = get_client().chat.completions.create( | |
| model=slug, messages=messages, max_tokens=2000, temperature=0.1, timeout=30 | |
| ) | |
| if not resp.choices: | |
| last_err = "empty choices"; continue | |
| text = (resp.choices[0].message.content or "").strip() | |
| if not text: | |
| last_err = "empty text"; continue | |
| debug_log(label, f"[β {slug}] {text[:400]}") | |
| return text | |
| except Exception as e: | |
| err = f"{type(e).__name__}: {e}" | |
| last_err = err | |
| el = err.lower() | |
| if any(s in el for s in _HARD_SKIP): | |
| debug_log(f"{label} DEAD", f"{slug} β {err[:120]}") | |
| _DEAD_MODELS.add(slug) | |
| continue | |
| if "429" in err or "rate" in el: | |
| time.sleep(min(2 ** attempt, 16)) | |
| debug_log(f"{label} EXHAUSTED", f"All VLMs failed. Last: {last_err}") | |
| return "" | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # STAGE 1 β YOLO DETECTION | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def is_fp(cls, x1, y1, x2, y2): | |
| w, h = int(x2 - x1), int(y2 - y1) | |
| if h == 0 or w == 0: | |
| return True | |
| WIDE_OK = {'resistor', 'inductor', 'switch', 'switch open', 'switch closed', | |
| 'battery', 'voltage source', 'led', 'diode'} | |
| if cls.lower() not in WIDE_OK and w > 10 * h: | |
| return True | |
| if h > 10 * w and cls.lower() not in {'voltage source', 'battery', 'led'}: | |
| return True | |
| return False | |
| def box_iou(a, b): | |
| ax2, ay2 = a['x'] + a['w'], a['y'] + a['h'] | |
| bx2, by2 = b['x'] + b['w'], b['y'] + b['h'] | |
| ix1, iy1 = max(a['x'], b['x']), max(a['y'], b['y']) | |
| ix2, iy2 = min(ax2, bx2), min(ay2, by2) | |
| iw, ih = max(0, ix2 - ix1), max(0, iy2 - iy1) | |
| inter = iw * ih | |
| union = a['w'] * a['h'] + b['w'] * b['h'] - inter | |
| return inter / union if union > 0 else 0.0 | |
| def cluster_overlap(a, b): | |
| if a['component'].lower() != b['component'].lower(): | |
| return False | |
| if box_iou(a, b) > 0.25: | |
| return True | |
| acx, acy = a['x'] + a['w'] / 2, a['y'] + a['h'] / 2 | |
| if b['x'] <= acx <= b['x'] + b['w'] and b['y'] <= acy <= b['y'] + b['h']: | |
| return True | |
| bcx, bcy = b['x'] + b['w'] / 2, b['y'] + b['h'] / 2 | |
| if a['x'] <= bcx <= a['x'] + a['w'] and a['y'] <= bcy <= a['y'] + a['h']: | |
| return True | |
| return False | |
| def deduplicate_detections(details): | |
| n = len(details) | |
| parent = list(range(n)) | |
| def find(i): | |
| while parent[i] != i: | |
| parent[i] = parent[parent[i]] | |
| i = parent[i] | |
| return i | |
| def union(i, j): | |
| ri, rj = find(i), find(j) | |
| if ri != rj: | |
| parent[ri] = rj | |
| for i in range(n): | |
| for j in range(i + 1, n): | |
| if cluster_overlap(details[i], details[j]): | |
| union(i, j) | |
| clusters = {} | |
| for i in range(n): | |
| clusters.setdefault(find(i), []).append(details[i]) | |
| return [max(v, key=lambda d: d['confidence']) for v in clusters.values()] | |
| def freeze_labels(details): | |
| non_gnd = [d for d in details if 'gnd' not in d['component'].lower() and 'ground' not in d['component'].lower()] | |
| gnd = [d for d in details if 'gnd' in d['component'].lower() or 'ground' in d['component'].lower()] | |
| # FIX: sort by (prefix, x, y) -- x first -- so left-to-right reading order | |
| # matches schematic convention and avoids label/value swaps between | |
| # vertically-adjacent components in different columns (e.g. R2 vs R3). | |
| ds = sorted(non_gnd, key=lambda d: (d['component'].lower(), d['x'], d['y'])) | |
| ds += sorted(gnd, key=lambda d: (d['y'], d['x'])) | |
| counters = {} | |
| for d in ds: | |
| prefix = PREFIX_MAP.get(d['component'].lower(), d['component'][0].upper()) | |
| counters[prefix] = counters.get(prefix, 0) + 1 | |
| d['label'] = f"{prefix}{counters[prefix]}" | |
| if not d.get('schematic_label'): | |
| d['schematic_label'] = d['label'] | |
| return ds | |
| def detect_components(image, confidence): | |
| img_np = np.array(image) | |
| all_details = [] | |
| passes = [ | |
| (confidence, 0.55, "p1"), | |
| (confidence, 0.30, "p2"), | |
| (max(0.01, confidence * 0.40), 0.45, "p3"), | |
| ] | |
| for conf_t, iou_t, tag in passes: | |
| results = model.predict(source=image, conf=conf_t, iou=iou_t, imgsz=1280, | |
| augment=True, agnostic_nms=True, verbose=False) | |
| nb = len(all_details) | |
| for box in results[0].boxes: | |
| cls = results[0].names[int(box.cls[0])] | |
| cv_ = float(box.conf[0]) | |
| x1, y1, x2, y2 = map(int, box.xyxy[0]) | |
| if cls.lower() in SUPPRESS_CLASSES: | |
| continue | |
| if is_fp(cls, x1, y1, x2, y2): | |
| continue | |
| all_details.append({ | |
| "component": cls, "value": None, "confidence": round(cv_ * 100, 1), | |
| "x": x1, "y": y1, "w": x2 - x1, "h": y2 - y1, | |
| "is_default": False, "source": "", "label": "", "schematic_label": "", | |
| }) | |
| debug_log("YOLO", f"{tag} conf={conf_t:.3f} iou={iou_t} -> +{len(all_details) - nb}") | |
| all_details = deduplicate_detections(all_details) | |
| all_details = freeze_labels(all_details) | |
| debug_log("LABELS", f"{len(all_details)}: {[d['label'] for d in all_details]}") | |
| results_plot = model.predict(source=image, conf=confidence, iou=0.55, imgsz=1280, | |
| augment=True, agnostic_nms=True, verbose=False) | |
| return img_np, results_plot, all_details | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # STAGE 2 β VALUE / LABEL EXTRACTION | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def fix_ocr_units(v, label): | |
| if v is None: | |
| return v | |
| v = re.sub(r'\s+', ' ', str(v).strip()) | |
| if label.startswith('C'): | |
| v = re.sub(r'(\d+\.?\d*)\s*[pP][fF]?\b', r'\1Β΅F', v, flags=re.IGNORECASE) | |
| v = re.sub(r'(\d+\.?\d*)\s*Β΅\s*[fF]', r'\1Β΅F', v) | |
| if label.startswith('L'): | |
| v = re.sub(r'(\d+\.?\d*)\s*m\s*[Hh]\b', r'\1mH', v) | |
| v = re.sub(r'(\d+\.?\d*)\s*[pP][Hh]\b', r'\1Β΅H', v) | |
| v = re.sub(r'(\d+\.?\d*)\s*Β΅\s*[Hh]\b', r'\1Β΅H', v) | |
| if label.startswith('R'): | |
| v = re.sub(r'(\d+\.?\d*)\s*k\b(?!Ξ©)', r'\1kΞ©', v) | |
| v = re.sub(r'(\d+\.?\d)\s*K\s*[Ξ©o]', r'\1kΞ©', v) | |
| return v | |
| def extract_values_near_box(ocr_results, box, label=None): | |
| x1, y1, w, h = box | |
| x2, y2 = x1 + w, y1 + h | |
| cx, cy = (x1 + x2) / 2, (y1 + y2) / 2 | |
| lbl_pat = re.compile(r'^[A-Za-z]{1,4}\d*$') | |
| val_hint = re.compile(r'\d+\.?\d*\s*([kKMmΒ΅unpΞ©FHVA]|ohm)', re.IGNORECASE) | |
| def in_zone(tx, ty): | |
| return ((x1 - 60 < tx < x2 + 60 and y1 - 30 < ty < y2 + 130) or | |
| (x1 - 120 < tx < x2 + 120 and y1 - 60 < ty < y2 + 60)) | |
| cands = [] | |
| for (coords, text, *_rest) in ocr_results: | |
| tx = int(np.mean([c[0] for c in coords])) | |
| ty = int(np.mean([c[1] for c in coords])) | |
| t = text.strip() | |
| if in_zone(tx, ty) and val_hint.search(t) and not lbl_pat.match(t): | |
| cands.append((t, tx, ty, ((tx - cx) ** 2 + (ty - cy) ** 2) ** 0.5)) | |
| cands.sort(key=lambda x: x[3]) | |
| return [(t, tx, ty) for t, tx, ty, _ in cands] | |
| def parse_value(items): | |
| full = re.compile(r'(\d+\.?\d*)\s*([kKMmΒ΅unp])?\s*(Ξ©|ohm|F|H|V|A|W)\b', re.IGNORECASE) | |
| pfx = re.compile(r'(\d+\.?\d*)\s*([kKMmΒ΅unp])\b') | |
| nohz = re.compile(r'\d+(\.\d+)?\s*Hz', re.IGNORECASE) | |
| bare_ohm = re.compile(r'(\d+\.?\d)\s*Ξ©') | |
| pm = {'k': 'k', 'K': 'k', 'M': 'M', 'm': 'm', 'u': 'Β΅', 'Β΅': 'Β΅', 'n': 'n', 'p': 'p'} | |
| texts = [t for t, _, _ in items] | |
| joined = texts + [' '.join(texts[i:i + 2]) for i in range(len(texts) - 1)] | |
| for text in joined: | |
| if nohz.search(text): | |
| continue | |
| m = full.search(text) | |
| if m: | |
| return f"{m.group(1)}{pm.get(m.group(2) or '', m.group(2) or '')}{m.group(3)}" | |
| m2 = pfx.search(text) | |
| if m2: | |
| return f"{m2.group(1)}{pm.get(m2.group(2), m2.group(2))}" | |
| m3 = bare_ohm.search(text) | |
| if m3: | |
| return f"{m3.group(1)}Ξ©" | |
| return None | |
| def preprocess_for_ocr(img_np): | |
| """FIX (OCR improvement): schematic label/value text is often small, | |
| thin-stroke, and low-contrast against a white background, which | |
| EasyOCR's default detector tends to miss or mis-read at native | |
| resolution. This upscales the image and boosts local contrast (CLAHE) | |
| before OCR, which substantially improves recall on small printed | |
| text like '4.7Β΅F' or '10kΞ©' without touching detection/wire/graph | |
| logic elsewhere in the pipeline.""" | |
| gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY) | |
| clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) | |
| enhanced = clahe.apply(gray) | |
| enhanced_bgr = cv2.cvtColor(enhanced, cv2.COLOR_GRAY2RGB) | |
| h, w = enhanced_bgr.shape[:2] | |
| scale_factor = 2.0 if max(h, w) < 1600 else 1.5 | |
| upscaled = cv2.resize(enhanced_bgr, (int(w * scale_factor), int(h * scale_factor)), | |
| interpolation=cv2.INTER_CUBIC) | |
| return upscaled, scale_factor | |
| def draw_labels_for_llm(img_np, details): | |
| out = img_np.copy() | |
| for d in details: | |
| x1, y1, x2, y2 = d['x'], d['y'], d['x'] + d['w'], d['y'] + d['h'] | |
| cv2.rectangle(out, (x1, y1), (x2, y2), (0, 100, 255), 2) | |
| txt = d['label'] | |
| (tw, th), _ = cv2.getTextSize(txt, cv2.FONT_HERSHEY_SIMPLEX, 0.65, 2) | |
| ty = max(th + 6, y1 - 6) | |
| cv2.rectangle(out, (x1, ty - th - 4), (x1 + tw + 8, ty + 2), (0, 100, 255), -1) | |
| cv2.putText(out, txt, (x1 + 3, ty - 2), cv2.FONT_HERSHEY_SIMPLEX, 0.65, (255, 255, 255), 2, cv2.LINE_AA) | |
| return Image.fromarray(out) | |
| def get_all_values_with_llm(labeled_pil, details): | |
| comp_list = "\n".join([f"{d['label']} ({d['component']}) at ({d['x']+d['w']//2},{d['y']+d['h']//2})" for d in details]) | |
| prompt = ( | |
| "You are an expert electronics engineer reading a circuit schematic image.\n" | |
| "Blue boxes mark each detected component with a TEMPORARY ID.\n\n" | |
| f"Detected components:\n{comp_list}\n\n" | |
| "For EACH component:\n" | |
| "1. Find the SCHEMATIC LABEL printed near it (e.g. R1, C2, L3, S1, S2, LED1)\n" | |
| "2. Find the VALUE printed near it\n\n" | |
| "STRICT UNIT RULES:\n" | |
| "- Resistors R: Ξ© ONLY β 270Ξ©, 1kΞ©, 2.2kΞ©, 3.3kΞ©, 4.7kΞ©, 10kΞ©\n" | |
| "- Capacitors C: Farads ONLY β 10Β΅F, 4.7Β΅F, 2.2Β΅F, 1Β΅F\n" | |
| "- Inductors L: Henrys ONLY β 10mH, 22mH, 33mH, 47mH\n" | |
| "- Voltage/Battery V: Volts β 12V, 5V\n" | |
| "- Switches SW: open OR closed\n" | |
| "- LEDs: LED\n\n" | |
| "Output ONLY this exact format, one line per component:\n" | |
| "TEMP_ID | SCHEMATIC_LABEL | VALUE\n\n" | |
| "If label unknown repeat TEMP_ID. No skips. No extra text." | |
| ) | |
| messages = [{ | |
| "role": "user", | |
| "content": [ | |
| {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_to_b64(labeled_pil)}"}}, | |
| {"type": "text", "text": prompt}, | |
| ], | |
| }] | |
| text = call_mistral(messages, "LLM-VALUES") | |
| value_map, label_map = {}, {} | |
| for line in text.split("\n"): | |
| line = line.strip() | |
| if not line or "|" not in line: | |
| continue | |
| parts = [p.strip() for p in line.split("|")] | |
| if len(parts) < 3: | |
| continue | |
| det_lbl = parts[0].strip("*_β’ ").split()[-1] if parts[0].strip() else "" | |
| sch_lbl = parts[1].strip("*_β’ ") | |
| val = re.split(r'\s{2,}|\(', parts[2].strip())[0].strip() | |
| if not det_lbl: | |
| continue | |
| if sch_lbl and sch_lbl.lower() not in ("unknown", "", "n/a", "not visible"): | |
| label_map[det_lbl] = sch_lbl | |
| if val and val.lower() not in ("unknown", "", "n/a", "not visible"): | |
| value_map[det_lbl] = val | |
| debug_log("VALUES", str(value_map) if value_map else "EMPTY") | |
| debug_log("LABELS", str(label_map) if label_map else "EMPTY") | |
| return value_map, label_map | |
| def prune_invalid_units(details): | |
| for d in details: | |
| lbl = d['label'] | |
| val = str(d.get('value', '')).lower() | |
| bad = False | |
| if lbl.startswith('R') and re.search(r'\d+\s*(v\b|Β΅f|nf|pf)', val, re.IGNORECASE): | |
| bad = True | |
| if lbl.startswith('R') and re.search(r'\d+\s*[kKmM]?h\b', val) and 'ohm' not in val: | |
| bad = True | |
| if lbl.startswith('C') and re.search(r'\d+\s*(mh|Β΅h|\bh\b|Ο|ohm)\b', val, re.IGNORECASE): | |
| bad = True | |
| if lbl.startswith('L') and re.search(r'\d+\s*(Β΅f|nf|pf|\bf\b|Ο|ohm)\b', val, re.IGNORECASE): | |
| bad = True | |
| if bad: | |
| d['value'] = DEFAULT_VALUES.get(d['component'].lower(), "unknown") | |
| d['is_default'] = True | |
| d['source'] = "default(pruned)" | |
| return details | |
| def fill_values(img_np, details, ocr_results): | |
| labeled_pil = draw_labels_for_llm(img_np, details) | |
| lv, label_map = get_all_values_with_llm(labeled_pil, details) | |
| for d in details: | |
| det_lbl = d['label'] | |
| if det_lbl in label_map: | |
| d['schematic_label'] = label_map[det_lbl] | |
| unified_val = {} | |
| for yolo_lbl, val in lv.items(): | |
| unified_val[yolo_lbl] = val | |
| sch = label_map.get(yolo_lbl) | |
| if sch: | |
| unified_val[sch] = val | |
| debug_log("UNIFIED_VAL", str(unified_val)) | |
| for d in details: | |
| nearby = extract_values_near_box(ocr_results, (d['x'], d['y'], d['w'], d['h']), d['label']) | |
| ocr_val = parse_value(nearby) | |
| if ocr_val: | |
| d['value'] = fix_ocr_units(ocr_val, d['label']) | |
| d['is_default'] = False | |
| d['source'] = "OCR" | |
| for d in details: | |
| det_lbl = d['label'] | |
| sch = d.get('schematic_label', det_lbl) | |
| v = unified_val.get(sch) or unified_val.get(det_lbl) | |
| if v: | |
| if d['value'] is None or d.get('source') == 'default': | |
| d['value'] = fix_ocr_units(v, det_lbl) | |
| d['is_default'] = False | |
| d['source'] = "LLM" | |
| elif d['value'] is None: | |
| d['value'] = DEFAULT_VALUES.get(d['component'].lower(), "unknown") | |
| d['is_default'] = True | |
| d['source'] = "default" | |
| details = prune_invalid_units(details) | |
| for d in details: | |
| if d.get('source') in ('default', 'default(pruned)'): | |
| sch = d.get('schematic_label', d['label']) | |
| v = unified_val.get(sch) or unified_val.get(d['label']) | |
| if v: | |
| fixed = fix_ocr_units(v, d['label']) | |
| test = prune_invalid_units([{**d, 'value': fixed, 'source': 'LLM'}]) | |
| if test[0].get('source') != 'default(pruned)': | |
| d['value'] = fixed | |
| d['is_default'] = False | |
| d['source'] = "LLM" | |
| return details, labeled_pil | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # STAGE 3 β WIRE EXTRACTION & CONNECTIVITY | |
| # Skeletonize -> junctions -> break at component boxes -> CC -> | |
| # union-find merge -> KDTree reconnect -> graph | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _img_scale(img_np): | |
| h, w = img_np.shape[:2] | |
| diag = (h * h + w * w) ** 0.5 | |
| return diag / 800.0 | |
| def extract_wire_layer(img_np): | |
| """FIX (mega-net root cause): the previous version OR'd together Otsu | |
| threshold, adaptive threshold, AND dilated Canny edges, then ran a | |
| morphological CLOSE on top -- four separate fattening operations | |
| stacked on each other. On a dense bottom rail this was enough to weld | |
| multiple genuinely-separate wire runs into a single thick blob region | |
| before skeletonize/connectedComponents ever got a chance to see them | |
| as distinct nets (the "mega-net" NET_58 problem). Fix: drop the edge | |
| dilation, drop the adaptive-threshold layer (the most prone to | |
| flooding dark regions into wide blobs), and drop the CLOSE morph op | |
| entirely -- only keep a light OPEN pass for despeckling.""" | |
| gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY) | |
| _, wire_otsu = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) | |
| blurred = cv2.GaussianBlur(gray, (3, 3), 0) | |
| edges = cv2.Canny(blurred, 50, 150) | |
| # NOTE: no dilation of edges -- dilating before the OR was the single | |
| # biggest contributor to bridging parallel rails into one blob. | |
| wire = cv2.bitwise_or(wire_otsu, edges) | |
| # NOTE: wire_adapt (adaptive threshold) dropped from the mask -- it | |
| # tended to flood thicker dark regions (bottom rails) into wide blobs | |
| # rather than thin lines. If thin/faint wires start being missed, | |
| # reintroduce it but OR'd in AFTER skeletonization candidate testing, | |
| # not before. | |
| # NOTE: morphological CLOSE removed -- CLOSE actively closes gaps | |
| # between nearby-but-distinct wire runs, which is the opposite of | |
| # what's needed here. Only despeckle with OPEN. | |
| k_open = cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2)) | |
| wire = cv2.morphologyEx(wire, cv2.MORPH_OPEN, k_open) | |
| debug_log("WIRE", f"nonzero={int(np.count_nonzero(wire))}") | |
| return wire | |
| def erase_component_bodies(wire_layer, details): | |
| """Erase interior of each component, leaving terminal stubs. | |
| FIX: ground symbols are now skipped from erasure entirely AND | |
| explicitly kept un-dilated/un-broken downstream so the thin GND lead | |
| is never severed from the bus before connected components runs.""" | |
| out = wire_layer.copy() | |
| for d in details: | |
| x1, y1 = d['x'], d['y'] | |
| x2, y2 = x1 + d['w'], y1 + d['h'] | |
| ctype = d.get('component', '').lower() | |
| if 'ground' in ctype or 'gnd' in ctype: | |
| continue # never erase ground symbol pixels -- keep its lead intact | |
| pad_x = max(4, d['w'] // 10) | |
| pad_y = max(4, d['h'] // 10) | |
| if 'voltage' in ctype or 'battery' in ctype: | |
| pad_x = max(3, d['w'] // 12) | |
| pad_y = max(3, d['h'] // 12) | |
| ix1, iy1 = x1 + pad_x, y1 + pad_y | |
| ix2, iy2 = x2 - pad_x, y2 - pad_y | |
| if ix2 > ix1 and iy2 > iy1: | |
| out[iy1:iy2, ix1:ix2] = 0 | |
| return out | |
| def skeletonize_wire(wire_clean): | |
| """FIX: light erosion before skeletonize to break thin bridges between | |
| adjacent-but-distinct rails that may still have survived mask | |
| construction. skeletonize alone can't separate a true blob -- it only | |
| traces its centerline -- so erosion needs to happen first to actually | |
| sever weak connecting bridges. | |
| FIX 2: confirmed via RAW-NET debug logging that a single erosion pass | |
| was insufficient -- one net was still forming as a single 3968px | |
| blob with a 1343px bounding-box diagonal (vs ~250-700px for every | |
| other net) BEFORE merge_split_nets even runs, meaning the blob forms | |
| at this mask/skeleton stage, not from later net-merging. Bumped to 2 | |
| erosion iterations to more reliably sever the bridge. If this starts | |
| fragmenting legitimately-thin wires elsewhere, dial back to 1.""" | |
| wire_bin = (wire_clean > 0).astype(np.uint8) | |
| k_erode = cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2)) | |
| wire_bin = cv2.erode(wire_bin, k_erode, iterations=2) | |
| skel = skeletonize(wire_bin.astype(bool)).astype(np.uint8) | |
| return skel | |
| def break_skeleton_at_component_boxes(skel, details, margin=1): | |
| """Cut skeleton inside each component's interior box. | |
| FIX: ground symbols are excluded here too, so their lead is never cut.""" | |
| out = skel.copy() | |
| for d in details: | |
| ctype = d.get('component', '').lower() | |
| if 'ground' in ctype or 'gnd' in ctype: | |
| continue | |
| x1 = max(0, d['x'] + margin) | |
| y1 = max(0, d['y'] + margin) | |
| x2 = min(out.shape[1], d['x'] + d['w'] - margin) | |
| y2 = min(out.shape[0], d['y'] + d['h'] - margin) | |
| if x2 > x1 and y2 > y1: | |
| out[y1:y2, x1:x2] = 0 | |
| return out | |
| def compute_nets(skel): | |
| num_cc, cc_map = cv2.connectedComponents(skel, connectivity=8) | |
| return num_cc, cc_map | |
| def build_net_pixel_index(cc_map, valid_nets): | |
| net_pixels = {} | |
| net_endpoints = {} | |
| for nid in valid_nets: | |
| ys, xs = np.where(cc_map == nid) | |
| pts = np.column_stack((xs, ys)) | |
| net_pixels[nid] = pts | |
| if len(pts): | |
| mean = pts.mean(axis=0) | |
| centered = pts - mean | |
| cov = np.cov(centered.T) if len(pts) > 1 else np.eye(2) | |
| try: | |
| eigvals, eigvecs = np.linalg.eigh(cov) | |
| axis = eigvecs[:, np.argmax(eigvals)] | |
| except np.linalg.LinAlgError: | |
| axis = np.array([1.0, 0.0]) | |
| proj = centered @ axis | |
| net_endpoints[nid] = (pts[np.argmin(proj)], pts[np.argmax(proj)]) | |
| return net_pixels, net_endpoints | |
| class UnionFind: | |
| def __init__(self, items): | |
| self.parent = {x: x for x in items} | |
| def find(self, x): | |
| while self.parent[x] != x: | |
| self.parent[x] = self.parent[self.parent[x]] | |
| x = self.parent[x] | |
| return x | |
| def union(self, a, b): | |
| ra, rb = self.find(a), self.find(b) | |
| if ra != rb: | |
| self.parent[ra] = rb | |
| def merge_split_nets(valid_nets, net_endpoints, gap_threshold, net_pixels=None, max_span_factor=3.0): | |
| """Union-find merge of nets split by erasure/OCR gaps. | |
| FIX (chaining bug): the previous version unioned any pair under | |
| gap_threshold with no bound on the resulting group's size. That let a | |
| bottom rail get fused transitively -- net A close to B, B close to C, | |
| so A and C end up in the same group even though A and C are far apart | |
| and were never actually close to each other. This produced the | |
| mega-net swallowing C1-C4/GND/L4/R3/V1/V2. | |
| Fix: before unioning a pair (a, b), compute what the merged group's | |
| pixel bounding-box diagonal WOULD become (group-of-a U group-of-b), | |
| and reject the union if that diagonal exceeds max_span_factor times | |
| the larger of the two individual original net diagonals. Pairs are | |
| also processed closest-distance-first so genuine local merges aren't | |
| blocked by farther candidates being considered first.""" | |
| uf = UnionFind(valid_nets) | |
| orig_diag = {} | |
| group_bbox = {} | |
| if net_pixels: | |
| for nid, pts in net_pixels.items(): | |
| if len(pts): | |
| x0, y0 = pts[:, 0].min(), pts[:, 1].min() | |
| x1, y1 = pts[:, 0].max(), pts[:, 1].max() | |
| orig_diag[nid] = float(((x1 - x0) ** 2 + (y1 - y0) ** 2) ** 0.5) | |
| group_bbox[nid] = [x0, y0, x1, y1] | |
| else: | |
| orig_diag[nid] = 0.0 | |
| group_bbox[nid] = [0, 0, 0, 0] | |
| def bbox_diag(bb): | |
| return ((bb[2] - bb[0]) ** 2 + (bb[3] - bb[1]) ** 2) ** 0.5 | |
| def union_bbox(b1, b2): | |
| return [min(b1[0], b2[0]), min(b1[1], b2[1]), max(b1[2], b2[2]), max(b1[3], b2[3])] | |
| ids = list(valid_nets) | |
| pairs = [] | |
| for i in range(len(ids)): | |
| for j in range(i + 1, len(ids)): | |
| a, b = ids[i], ids[j] | |
| if a not in net_endpoints or b not in net_endpoints: | |
| continue | |
| best = min( | |
| np.linalg.norm(pa.astype(float) - pb.astype(float)) | |
| for pa in net_endpoints[a] for pb in net_endpoints[b] | |
| ) | |
| if best < gap_threshold: | |
| pairs.append((best, a, b)) | |
| pairs.sort(key=lambda t: t[0]) | |
| rejected = 0 | |
| for best, a, b in pairs: | |
| ra, rb = uf.find(a), uf.find(b) | |
| if ra == rb: | |
| continue | |
| if net_pixels: | |
| cap = max_span_factor * max(orig_diag.get(a, 0.0), orig_diag.get(b, 0.0), 1.0) | |
| would_be = union_bbox(group_bbox[ra], group_bbox[rb]) | |
| if bbox_diag(would_be) > cap: | |
| rejected += 1 | |
| continue | |
| uf.union(ra, rb) | |
| group_bbox[uf.find(ra)] = would_be | |
| else: | |
| uf.union(a, b) | |
| if rejected: | |
| debug_log("MERGE-CAP", f"rejected {rejected} merge(s) exceeding span cap") | |
| merged = {} | |
| for nid in valid_nets: | |
| root = uf.find(nid) | |
| merged.setdefault(root, []).append(nid) | |
| return merged, uf | |
| def remap_cc_map(cc_map, merged_groups): | |
| remap = {} | |
| for root, members in merged_groups.items(): | |
| new_id = min(members) | |
| for m in members: | |
| remap[m] = new_id | |
| out = cc_map.copy() | |
| for old_id, new_id in remap.items(): | |
| if old_id != new_id: | |
| out[cc_map == old_id] = new_id | |
| new_valid = set(remap.values()) | |
| return out, new_valid | |
| def build_kdtree(cc_map, valid_nets): | |
| points, labels = [], [] | |
| for nid in valid_nets: | |
| ys, xs = np.where(cc_map == nid) | |
| for x, y in zip(xs, ys): | |
| points.append((x, y)) | |
| labels.append(nid) | |
| if not points: | |
| return None, [] | |
| return KDTree(points), labels | |
| def terminal_nets_via_kdtree(tree, labels, terminal_xy, max_dist): | |
| """FIX: previously used query_ball_point, which returns EVERY net | |
| pixel within max_dist -- so a single physical terminal point near | |
| multiple unrelated wire fragments would pick up ALL of them as | |
| "connected" nets. This was confirmed in debug logs: a 2-terminal | |
| resistor (R1) was reporting nets=[37, 58, 81] -- three nets from one | |
| component, when a resistor terminal can only be touching one real | |
| wire. That spurious extra net was bridging components that aren't | |
| actually wired together (e.g. forcing L1/R1/R2 onto a shared net). | |
| Fix: query only the SINGLE nearest net pixel within max_dist, so each | |
| terminal point contributes at most one net -- matching how a real | |
| pin only touches the one wire it's soldered to.""" | |
| if tree is None: | |
| return set() | |
| dist, idx = tree.query(terminal_xy, k=1) | |
| if dist <= max_dist: | |
| return {labels[idx]} | |
| return set() | |
| def component_terminal_points(d): | |
| x1, y1 = d['x'], d['y'] | |
| x2, y2 = x1 + d['w'], y1 + d['h'] | |
| cx, cy = (x1 + x2) // 2, (y1 + y2) // 2 | |
| horizontal = d['w'] >= d['h'] | |
| if horizontal: | |
| return [(x1, cy), (x2, cy), (cx, y1), (cx, y2)] | |
| return [(cx, y1), (cx, y2), (x1, cy), (x2, cy)] | |
| def build_graph_wire_cc(details, img_np): | |
| """Full Stage 3 pipeline. | |
| FIX: merge gap and KDTree query radius are scale-relative and | |
| tightened; GND lead is preserved end-to-end; merge_split_nets now | |
| receives net_pixels so it can cap merge span (see merge_split_nets | |
| docstring); GND kdtree radius tightened from 18*scale to 10*scale | |
| since the looser radius was eagerly grabbing onto unrelated rail | |
| segments, compounding the mega-net problem.""" | |
| scale = _img_scale(img_np) | |
| merge_gap = max(3, int(4 * scale)) | |
| kdtree_radius = max(4, int(5 * scale)) | |
| gnd_kdtree_radius = max(8, int(10 * scale)) # was 18*scale -- too eager to grab onto unrelated rail segments | |
| wire_layer = extract_wire_layer(img_np) | |
| wire_clean = erase_component_bodies(wire_layer, details) | |
| skel = skeletonize_wire(wire_clean) | |
| skel = break_skeleton_at_component_boxes(skel, details) | |
| num_cc, cc_map = compute_nets(skel) | |
| # DEBUG: raw (pre-merge) net sizes/bboxes -- helps confirm whether a | |
| # mega-net originates here (mask/skeleton stage) or in merge_split_nets. | |
| for nid in range(1, num_cc): | |
| ys, xs = np.where(cc_map == nid) | |
| if len(xs) > 200: | |
| raw_diag = ((xs.max() - xs.min()) ** 2 + (ys.max() - ys.min()) ** 2) ** 0.5 | |
| debug_log("RAW-NET", f"id={nid} px={len(xs)} bbox_diag={raw_diag:.0f}") | |
| MIN_NET_PX = max(8, int(15 * (scale ** 0.5))) | |
| net_sizes = {nid: int(np.sum(cc_map == nid)) for nid in range(1, num_cc)} | |
| valid_nets = {nid for nid, sz in net_sizes.items() if sz >= MIN_NET_PX} | |
| debug_log("VALID NETS", f"{len(valid_nets)} >= {MIN_NET_PX}px (scale={scale:.2f})") | |
| net_pixels, net_endpoints = build_net_pixel_index(cc_map, valid_nets) | |
| merged_groups, _ = merge_split_nets(valid_nets, net_endpoints, gap_threshold=merge_gap, net_pixels=net_pixels) | |
| cc_map, valid_nets = remap_cc_map(cc_map, merged_groups) | |
| net_pixels, net_endpoints = build_net_pixel_index(cc_map, valid_nets) | |
| debug_log("MERGE", f"merged into {len(valid_nets)} nets (gap={merge_gap}px)") | |
| tree, labels = build_kdtree(cc_map, valid_nets) | |
| comp_nets = {} | |
| for d in details: | |
| ctype = d.get('component', '').lower() | |
| is_gnd = 'ground' in ctype or 'gnd' in ctype | |
| radius = gnd_kdtree_radius if is_gnd else kdtree_radius | |
| nets_found = set() | |
| for tx, ty in component_terminal_points(d): | |
| nets_found |= terminal_nets_via_kdtree(tree, labels, (tx, ty), max_dist=radius) | |
| # GND: also try its own centroid in case the lead is short/centered | |
| if is_gnd and not nets_found: | |
| cx, cy = d['x'] + d['w'] // 2, d['y'] + d['h'] // 2 | |
| nets_found |= terminal_nets_via_kdtree(tree, labels, (cx, cy), max_dist=gnd_kdtree_radius) | |
| comp_nets[d['label']] = nets_found | |
| debug_log("TERMINAL", f"{d['label']}({d['component']}) -> nets={sorted(nets_found)} r={radius}") | |
| G = nx.Graph() | |
| for d in details: | |
| G.add_node(d['label'], node_type='component', **d) | |
| for nid in valid_nets: | |
| G.add_node(f"NET_{nid}", node_type='net', net_id=nid) | |
| for comp, nets in comp_nets.items(): | |
| for nid in nets: | |
| G.add_edge(comp, f"NET_{nid}", edge_type='pin') | |
| orphans = [ | |
| n for n in list(G.nodes()) | |
| if G.nodes[n].get('node_type') == 'net' | |
| and not any(G.nodes[nb].get('node_type') == 'component' for nb in G.neighbors(n)) | |
| ] | |
| G.remove_nodes_from(orphans) | |
| debug_log("GRAPH", f"{G.number_of_nodes()} nodes {G.number_of_edges()} edges") | |
| return G, skel, cc_map, num_cc, valid_nets, net_pixels, net_endpoints, scale | |
| # ββ GND DETECTION βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def detect_gnd_symbols(img_np, details): | |
| if any('gnd' in d['component'].lower() or 'ground' in d['component'].lower() for d in details): | |
| return details | |
| gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY) | |
| _, bw = cv2.threshold(gray, 128, 255, cv2.THRESH_BINARY_INV) | |
| h_img, w_img = img_np.shape[:2] | |
| contours, _ = cv2.findContours(bw, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE) | |
| cands = [] | |
| for cnt in contours: | |
| x, y, w, h = cv2.boundingRect(cnt) | |
| area = cv2.contourArea(cnt) | |
| if not (30 < area < 12000): | |
| continue | |
| if not (y > h_img * 0.2): | |
| continue | |
| aspect = w / max(h, 1) | |
| if not (0.8 < aspect < 10): | |
| continue | |
| if h > 80: | |
| continue | |
| roi = bw[y:y + h, x:x + w] | |
| if np.sum(np.sum(roi > 0, axis=1) > w * 0.3) < 2: | |
| continue | |
| ccx, ccy = x + w // 2, y + h // 2 | |
| if any(d['x'] <= ccx <= d['x'] + d['w'] and d['y'] <= ccy <= d['y'] + d['h'] for d in details): | |
| continue | |
| cands.append({ | |
| "component": "ground", "value": "GND", "confidence": 50.0, | |
| "x": x, "y": y, "w": w, "h": h, "is_default": False, "source": "shape", | |
| "label": "", "schematic_label": "GND", "_area": area, | |
| }) | |
| if cands: | |
| cands.sort(key=lambda d: -(d['y'] + d['h'] // 2)) | |
| best = cands[:3] | |
| for i, g in enumerate(best, 1): | |
| g.pop('_area', None) | |
| g['label'] = f"GND{i}" | |
| g['schematic_label'] = "GND" | |
| details = details + best | |
| debug_log("GND", f"Injected {len(best)}: {[g['label'] for g in best]}") | |
| return details | |
| def get_gnd_nodes(G): | |
| return [n for n, d in G.nodes(data=True) | |
| if d.get('node_type', 'component') == 'component' | |
| and ('gnd' in d.get('component', '').lower() or 'ground' in d.get('component', '').lower())] | |
| def prune_orphan_nets(G, label="ORPHAN-SWEEP"): | |
| """FIX: net nodes can lose their last component neighbor after | |
| GND-merge, proximity-repair, or VLM edges get added/rewired -- e.g. a | |
| net that used to anchor one GND gets that GND's edge redirected | |
| elsewhere, leaving the net node behind with zero component | |
| neighbors. build_graph_wire_cc only sweeps orphans ONCE, before any | |
| of those later stages run, so such leftovers (e.g. a stray "NET_121" | |
| with no real connections) silently persist and show up as a fake | |
| extra "isolated section" in the final report/netlist. Call this | |
| after any stage that can change net<->component edges.""" | |
| orphans = [ | |
| n for n in list(G.nodes()) | |
| if G.nodes[n].get('node_type') == 'net' | |
| and not any(G.nodes[nb].get('node_type') == 'component' for nb in G.neighbors(n)) | |
| ] | |
| if orphans: | |
| G.remove_nodes_from(orphans) | |
| debug_log(label, f"removed {len(orphans)} orphan net(s): {orphans}") | |
| return G | |
| def wire_gnd_to_nearest_net(G, details, cc_map, valid_nets, scale): | |
| """FIX (isolated-ground-cluster bug): the old guard only fired when a | |
| GND node had degree==0. But GND symbols clustered near each other can | |
| each find a real *local* net via the initial terminal-kdtree pass | |
| (e.g. GND1->NET_123, GND2->NET_122, GND3->NET_121/122) -- so they | |
| have degree>0 and this function silently did nothing, even though | |
| that local net is itself a tiny isolated fragment, disconnected from | |
| the main circuit net (e.g. NET_75). repair_isolated_nodes then just | |
| kept re-confirming the same already-attached tiny net every pass | |
| (visible as repeated identical REPAIR-DONE lines), since "nearest | |
| net" isn't the same question as "nearest net that's part of the main | |
| circuit". | |
| Fix: identify the MAIN net cluster (the net belonging to the largest | |
| component-connected-component in G, e.g. NET_75) and explicitly | |
| check, for every GND, whether it is already wired (via any path) to | |
| that main cluster. If not, snap directly to the nearest pixel | |
| belonging to nets that ARE part of the main cluster, regardless of | |
| the GND's current degree.""" | |
| h, w = cc_map.shape | |
| snap_dist = int(0.25 * (h * h + w * w) ** 0.5) | |
| gnd_details = [ | |
| d for d in details | |
| if ('gnd' in d.get('component', '').lower() or 'ground' in d.get('component', '').lower()) | |
| and d['label'] in G | |
| ] | |
| if not gnd_details: | |
| return G | |
| # Find the main component cluster (largest set of component nodes in | |
| # one connected piece of G) and the net node IDs that belong to it. | |
| net_node_ids = {n for n, dd in G.nodes(data=True) if dd.get('node_type') == 'net'} | |
| comp_node_ids = {n for n in G.nodes() if n not in net_node_ids} | |
| best_cc, best_comp_count = None, -1 | |
| for cc_set in nx.connected_components(G): | |
| comp_count = len([n for n in cc_set if n in comp_node_ids]) | |
| if comp_count > best_comp_count: | |
| best_comp_count = comp_count | |
| best_cc = cc_set | |
| main_net_ids = set() | |
| if best_cc: | |
| for n in best_cc: | |
| if n in net_node_ids: | |
| nid = G.nodes[n].get('net_id') | |
| if isinstance(nid, int): | |
| main_net_ids.add(nid) | |
| for d in gnd_details: | |
| lbl = d['label'] | |
| already_main = best_cc is not None and lbl in best_cc | |
| if already_main: | |
| continue # already wired into the main circuit, nothing to do | |
| cx = d['x'] + d['w'] // 2 | |
| ty = d['y'] | |
| best_nid, best_dist = None, float('inf') | |
| search_space = main_net_ids if main_net_ids else valid_nets | |
| for nid in search_space: | |
| nys, nxs = np.where(cc_map == nid) | |
| if not len(nxs): | |
| continue | |
| dm = float(np.min((nxs - cx) ** 2 + (nys - ty) ** 2)) | |
| if dm < best_dist: | |
| best_dist = dm | |
| best_nid = nid | |
| if best_nid is not None and best_dist < snap_dist ** 2: | |
| nn = f"NET_{best_nid}" | |
| if not G.has_node(nn): | |
| G.add_node(nn, node_type='net', net_id=best_nid) | |
| G.add_edge(lbl, nn, edge_type='gnd-snap') | |
| debug_log("GND-SNAP", f"{lbl}->NET_{best_nid} ({best_dist**0.5:.0f}px) [main-net]") | |
| else: | |
| debug_log("GND-SNAP", f"{lbl} no main-circuit net within {snap_dist}px (best={best_dist**0.5:.0f}px)") | |
| # Union all ground nodes that ended up wired (directly or via the | |
| # original terminal pass) onto a shared GND rail, as before. | |
| snapped_nets = set() | |
| for d in gnd_details: | |
| if d['label'] in G: | |
| for nb in G.neighbors(d['label']): | |
| if G.nodes[nb].get('node_type') == 'net': | |
| snapped_nets.add(nb) | |
| if len(snapped_nets) > 1: | |
| # FIX: canonical net used to be picked via sorted(snapped_nets)[0], | |
| # which sorts net NAMES as plain strings -- "NET_121" sorts before | |
| # "NET_75" lexicographically ('1' < '7' as a character), even | |
| # though NET_75 is the real net with all the actual component | |
| # connections and NET_121 is a tiny orphan fragment. That could | |
| # make the merge pick the wrong net as canonical and effectively | |
| # rewire the real circuit's net identity onto a near-empty one. | |
| # Fix: pick whichever net currently has the most component | |
| # neighbors as canonical (ties broken by name for determinism). | |
| def _comp_neighbor_count(net): | |
| return sum(1 for nb in G.neighbors(net) if G.nodes[nb].get('node_type') != 'net') | |
| canonical = max(snapped_nets, key=lambda n: (_comp_neighbor_count(n), n)) | |
| for other in snapped_nets: | |
| if other == canonical: | |
| continue | |
| for nb in list(G.neighbors(other)): | |
| if nb != canonical: | |
| G.add_edge(nb, canonical, edge_type='gnd-merge') | |
| G.remove_node(other) | |
| debug_log("GND-MERGE", f"merged {snapped_nets} -> {canonical}") | |
| return G | |
| # ββ ISOLATION REPAIR ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def repair_isolated_nodes(G, details, img_np, cc_map, valid_nets): | |
| h, w = img_np.shape[:2] | |
| max_dist = int(0.10 * (h * h + w * w) ** 0.5) | |
| debug_log("REPAIR", f"max snap dist={max_dist}px") | |
| net_nodes = {n for n, d in G.nodes(data=True) if d.get('node_type') == 'net'} | |
| comp_nodes = {n for n in G.nodes() if n not in net_nodes} | |
| net_px_cache = {} | |
| for nid in valid_nets: | |
| ys, xs = np.where(cc_map == nid) | |
| if len(xs): | |
| net_px_cache[nid] = (xs, ys) | |
| def _main_comps(): | |
| best = set() | |
| for cc_set in nx.connected_components(G): | |
| cm = {n for n in cc_set if n in comp_nodes} | |
| if len(cm) > len(best): | |
| best = cm | |
| return best | |
| repaired = [] | |
| skipped = [] | |
| details_map = {d2['label']: d2 for d2 in details} | |
| for _ in range(4): | |
| main = _main_comps() | |
| changed = False | |
| for d in details: | |
| lbl = d['label'] | |
| if lbl not in G or lbl in main: | |
| continue | |
| cx = d['x'] + d['w'] // 2 | |
| cy = d['y'] + d['h'] // 2 | |
| best_nid, best_dist = None, float('inf') | |
| for nid, (xs, ys) in net_px_cache.items(): | |
| dists = (xs - cx) ** 2 + (ys - cy) ** 2 | |
| d_min = float(np.min(dists)) | |
| if d_min < best_dist: | |
| best_dist = d_min | |
| best_nid = nid | |
| if best_nid is not None and best_dist <= max_dist ** 2: | |
| nn = f"NET_{best_nid}" | |
| if not G.has_node(nn): | |
| G.add_node(nn, node_type='net', net_id=best_nid) | |
| G.add_edge(lbl, nn, edge_type='proximity-repair') | |
| repaired.append(f"{lbl}->NET_{best_nid} ({best_dist**0.5:.0f}px)") | |
| main.add(lbl) | |
| changed = True | |
| else: | |
| best_clbl, best_cdist = None, float('inf') | |
| for other in main: | |
| od = details_map.get(other) | |
| if od is None: | |
| continue | |
| dist = ((cx - od['x'] - od['w'] // 2) ** 2 + (cy - od['y'] - od['h'] // 2) ** 2) ** 0.5 | |
| if dist < best_cdist: | |
| best_cdist = dist | |
| best_clbl = other | |
| if best_clbl and best_cdist <= max_dist: | |
| nid_str = f"NET_REPAIR_{lbl}" | |
| G.add_node(nid_str, node_type='net', net_id=nid_str) | |
| G.add_edge(lbl, nid_str, edge_type='proximity-repair') | |
| G.add_edge(best_clbl, nid_str, edge_type='proximity-repair') | |
| repaired.append(f"{lbl}->{best_clbl} [centroid-fallback] ({best_cdist:.0f}px)") | |
| main.add(lbl) | |
| changed = True | |
| else: | |
| skipped.append(f"{lbl} min_wire={best_dist**0.5:.0f}px>{max_dist}px") | |
| if not changed: | |
| break | |
| if repaired: | |
| debug_log("REPAIR-DONE", "\n".join(repaired)) | |
| if skipped: | |
| debug_log("REPAIR-SKIP", "\n".join(skipped)) | |
| return G | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # STAGE 4 β VLM TOPOLOGY VERIFICATION | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def verify_connections_with_llm(image_pil, details, G): | |
| debug_log("VLM", "Starting topology verification...") | |
| img_np = np.array(image_pil) if hasattr(image_pil, 'convert') else image_pil | |
| _, buf = cv2.imencode(".png", img_np) | |
| b64 = "data:image/png;base64," + base64.b64encode(buf.tobytes()).decode() | |
| comp_list = "\n".join([f" {d.get('schematic_label', d['label'])} ({d['component']})" for d in details]) | |
| messages = [{ | |
| "role": "user", | |
| "content": [ | |
| {"type": "text", "text": ( | |
| "Analyze this circuit schematic carefully.\n\n" | |
| f"Known components:\n{comp_list}\n\n" | |
| "List every direct wire connection between two components.\n" | |
| 'Return ONLY valid JSON: {"connections": [["A","B"],...]}\n\n' | |
| "Rules: use exact labels above; only directly wired pairs; no extra text." | |
| )}, | |
| {"type": "image_url", "image_url": {"url": b64}}, | |
| ], | |
| }] | |
| text = call_mistral(messages, "VLM-TOPO") | |
| if not text: | |
| return G | |
| text = re.sub(r'```json|```', '', text).strip() | |
| try: | |
| data = json.loads(text) | |
| except Exception as e: | |
| debug_log("VLM-JSON-ERR", str(e)) | |
| return G | |
| label_to_node = {} | |
| for node, attrs in G.nodes(data=True): | |
| if attrs.get("node_type") != "net": | |
| label_to_node[node.strip().upper()] = node | |
| sch = attrs.get("schematic_label", node) | |
| if sch: | |
| label_to_node[sch.strip().upper()] = node | |
| comp_to_real_nets = {} | |
| for node, attrs in G.nodes(data=True): | |
| if attrs.get("node_type") == "net": | |
| continue | |
| comp_to_real_nets[node] = set() | |
| for nb in G.neighbors(node): | |
| na = G.nodes[nb] | |
| if na.get("node_type") == "net" and isinstance(na.get("net_id"), int): | |
| comp_to_real_nets[node].add(na["net_id"]) | |
| added = hall = dup = 0 | |
| for conn in data.get("connections", []): | |
| if len(conn) != 2: | |
| continue | |
| ak = str(conn[0]).strip().upper() | |
| bk = str(conn[1]).strip().upper() | |
| if ak not in label_to_node or bk not in label_to_node: | |
| continue | |
| an = label_to_node[ak] | |
| bn = label_to_node[bk] | |
| if an == bn: | |
| continue | |
| if set(G.neighbors(an)) & set(G.neighbors(bn)): | |
| dup += 1 | |
| continue | |
| shared = comp_to_real_nets.get(an, set()) & comp_to_real_nets.get(bn, set()) | |
| both_have_nets = (len(comp_to_real_nets.get(an, set())) > 0 and | |
| len(comp_to_real_nets.get(bn, set())) > 0) | |
| if both_have_nets and not shared: | |
| debug_log("VLM-HALL", f"{conn[0]}--{conn[1]} rejected (wired comps, no shared net)") | |
| hall += 1 | |
| continue | |
| nn = f"NET_VLM_{conn[0]}_{conn[1]}" | |
| if not G.has_node(nn): | |
| G.add_node(nn, node_type="net") | |
| G.add_edge(an, nn, edge_type="vlm") | |
| G.add_edge(bn, nn, edge_type="vlm") | |
| added += 1 | |
| debug_log("VLM", f"added={added} hall={hall} dup={dup}") | |
| return G | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # STAGE 5 β TOPOLOGY ANALYSIS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def build_component_graph(G): | |
| """Simple Graph (not MultiGraph); dedupe edges per net pair.""" | |
| net_nodes = {n for n, d in G.nodes(data=True) if d.get('node_type') == 'net'} | |
| comp_nodes = {n for n in G.nodes() if n not in net_nodes} | |
| CG = nx.Graph() | |
| for n in comp_nodes: | |
| CG.add_node(n, **G.nodes[n]) | |
| wire_pairs = set() | |
| for net in net_nodes: | |
| inferred = "REPAIR" in str(net) or "VLM" in str(net) | |
| nbs = [nb for nb in G.neighbors(net) if nb in comp_nodes] | |
| for i in range(len(nbs)): | |
| for j in range(i + 1, len(nbs)): | |
| a, b = nbs[i], nbs[j] | |
| if not inferred: | |
| wire_pairs.add(tuple(sorted([a, b]))) | |
| if not CG.has_edge(a, b): | |
| CG.add_edge(a, b, net=net, inferred=False) | |
| else: | |
| pair = tuple(sorted([a, b])) | |
| if pair not in wire_pairs and not CG.has_edge(a, b): | |
| CG.add_edge(a, b, net=net, inferred=True) | |
| return CG | |
| def circuit_sections(G): | |
| CG = build_component_graph(G) | |
| return list(nx.connected_components(CG)) | |
| def detect_series_parallel(CG, details_map): | |
| res = {'series_chains': [], 'parallel_groups': [], 'junctions': [], 'isolated': []} | |
| res['junctions'] = [n for n in CG.nodes() if CG.degree(n) > 2] | |
| res['isolated'] = [n for n in CG.nodes() if CG.degree(n) == 0] | |
| endpoint_types = set(res['junctions']) | {n for n in CG.nodes() if CG.degree(n) == 1} | |
| visited_edges = set() | |
| for start in endpoint_types: | |
| for nb in CG.neighbors(start): | |
| ek = tuple(sorted([start, nb])) | |
| if ek in visited_edges: | |
| continue | |
| visited_edges.add(ek) | |
| chain = [start, nb] | |
| cur = nb | |
| prev = start | |
| while cur not in endpoint_types: | |
| nbs2 = [n for n in CG.neighbors(cur) if n != prev] | |
| if not nbs2: | |
| break | |
| nxt = nbs2[0] | |
| visited_edges.add(tuple(sorted([cur, nxt]))) | |
| chain.append(nxt) | |
| prev, cur = cur, nxt | |
| if len(chain) >= 3: | |
| res['series_chains'].append(chain) | |
| return res | |
| def get_source_nodes(G): | |
| return [n for n, d in G.nodes(data=True) | |
| if d.get('node_type', 'component') == 'component' | |
| and any(k in d.get('component', '').lower() for k in ['voltage', 'battery', 'source'])] | |
| HUB_MIN_COMPONENTS = 5 | |
| def get_hub_nets(G, min_components=HUB_MIN_COMPONENTS): | |
| """A 'hub' net is one with many component neighbors (a shared rail, | |
| e.g. a common ground/power bus). FIX (path explosion): all_simple_paths | |
| has no concept of 'this is the same physical branch traversed in a | |
| different visiting order' -- a single high-degree net acts as a | |
| combinatorial fan-out point, producing one distinct 'path' per | |
| ordering of which hub members get visited and in what sequence, even | |
| though electrically they're all just '...reaches the shared rail'. | |
| Surfacing hub nets explicitly lets path-finding stop AT the rail | |
| instead of enumerating every walk through it, and lets the report | |
| describe the rail once as a fact rather than burying it in dozens of | |
| near-duplicate paths.""" | |
| hubs = [] | |
| for n, d in G.nodes(data=True): | |
| if d.get('node_type') != 'net': | |
| continue | |
| comps = [nb for nb in G.neighbors(n) if G.nodes[nb].get('node_type') != 'net'] | |
| if len(comps) >= min_components: | |
| hubs.append((n, d.get('net_id', n), sorted(comps))) | |
| return hubs | |
| def fmt_comp(label, details_map): | |
| d = details_map.get(label, {}) | |
| sch = d.get('schematic_label', label) | |
| val = d.get('value', '') | |
| return f"{sch}({val})" if val else sch | |
| def find_all_circuit_paths(G, details_map, max_paths=200): | |
| """FIX: previously ran all_simple_paths on build_component_graph(G), | |
| which collapses every net into a clique of direct component-component | |
| edges, letting the search hop between ANY two components on a shared | |
| net in any order. Fixed by searching the bipartite component/net | |
| graph G directly. | |
| FIX 2: relabeling GND1/2/3 to a common key post-hoc didn't catch | |
| duplicates that inserted an extra GND->net->GND detour mid-path (a | |
| longer tuple than the direct version even after relabeling). Real | |
| fix: contract all physical GND nodes into ONE node before the search | |
| runs, so hopping between grounds as a detour is structurally | |
| impossible. | |
| FIX 3 (path explosion on shared rails): a high-degree hub net (e.g. | |
| a common rail touching 9 components) made all_simple_paths enumerate | |
| one 'path' per ordering of which hub members get visited, producing | |
| dozens of paths that are really the same '...reaches the rail' fact | |
| restated with different component orderings. Now, once a raw path | |
| crosses into a hub net, the component path is truncated right after | |
| the first hub member reached -- the path says 'X -> ... -> reaches | |
| the rail via Y', not 'X -> ... -> Y -> Z -> W -> ... (every other | |
| rail member in some order)'. The hub nets themselves are reported | |
| separately (see get_hub_nets / SHARED RAILS section).""" | |
| sources = get_source_nodes(G) | |
| gnd_nodes = list(get_gnd_nodes(G)) | |
| hub_net_ids = {nid for _, nid, _ in get_hub_nets(G) if isinstance(nid, int)} | |
| SG = G | |
| if len(gnd_nodes) > 1: | |
| SG = G.copy() | |
| canonical_gnd = gnd_nodes[0] | |
| for other in gnd_nodes[1:]: | |
| if other not in SG or canonical_gnd not in SG: | |
| continue | |
| for nb in list(SG.neighbors(other)): | |
| if nb != canonical_gnd: | |
| SG.add_edge(canonical_gnd, nb) | |
| SG.remove_node(other) | |
| gnd_set = {gnd_nodes[0]} if gnd_nodes else set() | |
| all_paths = [] | |
| def to_truncated_comp_path(raw_path): | |
| """Walk raw_path (alternating component/net nodes); once a hub | |
| net is crossed, include the first component reached via it, then | |
| stop -- collapsing the rest of the combinatorial fan-out into | |
| that single 'reaches the rail' step.""" | |
| out = [] | |
| hit_hub = False | |
| for node in raw_path: | |
| ndata = SG.nodes[node] | |
| if ndata.get('node_type') == 'net': | |
| if ndata.get('net_id') in hub_net_ids: | |
| hit_hub = True | |
| continue | |
| out.append(node) | |
| if hit_hub: | |
| break | |
| return out | |
| for src in sources: | |
| if src not in SG: | |
| continue | |
| src_fmt = fmt_comp(src, details_map) | |
| seen_keys = set() | |
| comp_paths = [] | |
| targets = ([g for g in gnd_set if g in SG and nx.has_path(SG, src, g)] | |
| if gnd_set else | |
| [n for n in SG.nodes() if SG.nodes[n].get('node_type') != 'net' | |
| and SG.degree(n) == 1 and n != src]) | |
| if not targets: | |
| targets = [n for n in SG.nodes() if n != src and SG.nodes[n].get('node_type') != 'net'] | |
| for tgt in targets: | |
| try: | |
| for raw_path in nx.all_simple_paths(SG, src, tgt, cutoff=30): | |
| cpath = to_truncated_comp_path(raw_path) | |
| if len(cpath) < 2: | |
| continue | |
| key = tuple(cpath) | |
| if key in seen_keys: | |
| continue | |
| seen_keys.add(key) | |
| comp_paths.append(cpath) | |
| if len(comp_paths) >= max_paths: | |
| break | |
| except (nx.NetworkXNoPath, nx.NodeNotFound): | |
| pass | |
| if len(comp_paths) >= max_paths: | |
| break | |
| if comp_paths: | |
| comp_paths.sort(key=lambda p: len(p)) | |
| def is_strict_subpath(short, long_): | |
| if len(short) >= len(long_): | |
| return False | |
| ls = len(short) | |
| for i in range(len(long_) - ls + 1): | |
| if long_[i:i + ls] == short: | |
| return True | |
| return False | |
| filt = [] | |
| for path in comp_paths: | |
| if not any(is_strict_subpath(path, longer) for longer in comp_paths if longer != path): | |
| filt.append(path) | |
| if filt: | |
| all_paths.append((src_fmt, filt[:15])) | |
| return all_paths | |
| def find_loops(CG, details_map, max_loops=20): | |
| loops = [] | |
| try: | |
| for cycle in nx.cycle_basis(CG)[:max_loops]: | |
| loops.append([fmt_comp(n, details_map) for n in cycle]) | |
| except Exception: | |
| pass | |
| return loops | |
| def export_spice_netlist(G, details, title='Extracted Circuit'): | |
| lines = [f'* {title}', '* Auto-generated by Circuit Detector', ''] | |
| net_nodes = {n for n, d in G.nodes(data=True) if d.get('node_type') == 'net'} | |
| comp_nodes = {n for n in G.nodes() if n not in net_nodes} | |
| all_nets = sorted(net_nodes, key=str) | |
| net_map = {} | |
| counter = [1] | |
| for net in all_nets: | |
| if ('GND' in str(net).upper() or | |
| any('gnd' in str(G.nodes[nb].get('component', '')).lower() | |
| for nb in G.neighbors(net) | |
| if G.nodes[nb].get('node_type') == 'component')): | |
| net_map[net] = '0' | |
| else: | |
| net_map[net] = str(counter[0]) | |
| counter[0] += 1 | |
| dm = {d['label']: d for d in details} | |
| spice_prefix = { | |
| 'resistor': 'R', 'capacitor': 'C', 'inductor': 'L', | |
| 'voltage source': 'V', 'battery': 'V', 'led': 'D', 'diode': 'D', | |
| 'switch open': 'S', 'switch closed': 'S', 'switch': 'S', | |
| 'transistor': 'Q', 'op-amp': 'U', | |
| } | |
| for comp in sorted(comp_nodes): | |
| d = dm.get(comp, {}) | |
| ctype = d.get('component', '').lower() | |
| val = d.get('value', '?') | |
| lbl = d.get('schematic_label', comp) | |
| prefix = next((v for k, v in spice_prefix.items() if k in ctype), 'X') | |
| nets_of_comp = [net_map.get(nb, '?') for nb in G.neighbors(comp) if nb in net_map] | |
| if len(nets_of_comp) < 2: | |
| nets_of_comp += ['?'] * (2 - len(nets_of_comp)) | |
| n1, n2 = nets_of_comp[0], nets_of_comp[1] | |
| if 'switch' in ctype: | |
| lines.append(f'{prefix}{lbl} {n1} {n2} SW_{lbl} ; {"OFF" if "open" in ctype else "ON"}') | |
| elif ctype in ('led', 'diode'): | |
| lines.append(f'{prefix}{lbl} {n1} {n2} D1N4148') | |
| else: | |
| spice_val = re.sub(r'Β΅', 'u', val) | |
| lines.append(f'{prefix}{lbl} {n1} {n2} {spice_val}') | |
| lines += ['', '.tran 1m 10m', '.end'] | |
| return '\n'.join(lines) | |
| def describe_connectivity(G, details=None): | |
| dm = {d['label']: d for d in details} if details else {} | |
| for d in (details or []): | |
| sch = d.get('schematic_label', d['label']) | |
| if sch != d['label']: | |
| dm[sch] = d | |
| def fmt(l): | |
| return fmt_comp(l, dm) | |
| if G.number_of_edges() == 0: | |
| return "No connections detected." | |
| gnd_nodes = get_gnd_nodes(G) | |
| net_nodes = {n for n, d in G.nodes(data=True) if d.get('node_type') == 'net'} | |
| comp_nodes = {n for n in G.nodes() if n not in net_nodes} | |
| CG = build_component_graph(G) | |
| lines = ["=== CIRCUIT ANALYSIS REPORT ===", ""] | |
| lines += ["--- DETECTED COMPONENTS ---"] | |
| for d in sorted(details or [], key=lambda x: x['label']): | |
| sch = d.get('schematic_label', d['label']) | |
| val = d.get('value', '') | |
| src = d.get('source', '?') | |
| lines.append(f" {sch:8s} {d['component']:20s} {val:10s} [{src}]") | |
| lines.append("") | |
| lines += ["--- WIRE CONNECTIONS ---"] | |
| net_to_comps = defaultdict(set) | |
| for net in net_nodes: | |
| for nb in G.neighbors(net): | |
| if nb in comp_nodes: | |
| net_to_comps[net].add(nb) | |
| seen_pairs = set() | |
| net_edges_by_net = defaultdict(list) | |
| for net, comps in net_to_comps.items(): | |
| comp_list_sorted = sorted(comps) | |
| for i in range(len(comp_list_sorted)): | |
| for j in range(i + 1, len(comp_list_sorted)): | |
| a, b = comp_list_sorted[i], comp_list_sorted[j] | |
| pair = tuple(sorted([a, b])) | |
| if pair not in seen_pairs: | |
| seen_pairs.add(pair) | |
| net_edges_by_net[net].append((a, b)) | |
| for net in sorted(net_edges_by_net.keys(), key=str): | |
| tag = " [inferred]" if ("REPAIR" in str(net) or "VLM" in str(net)) else "" | |
| for a, b in sorted(net_edges_by_net[net]): | |
| lines.append(f" {fmt(a):18s} ---- {fmt(b):18s} [{net}]{tag}") | |
| lines.append("") | |
| lines += ["--- TOPOLOGY ANALYSIS ---"] | |
| topo = detect_series_parallel(CG, dm) | |
| if topo['junctions']: | |
| lines.append(" Junction nodes (degree > 2):") | |
| for n in sorted(topo['junctions']): | |
| neighbors_fmt = ', '.join(fmt(nb) for nb in sorted(CG.neighbors(n))) | |
| lines.append(f" {fmt(n):14s} -> {neighbors_fmt}") | |
| if topo['isolated']: | |
| lines.append(f"\n WARNING Isolated: {', '.join(fmt(n) for n in topo['isolated'])}") | |
| lines.append("") | |
| lines += ["--- SHARED RAILS ---"] | |
| hub_nets = get_hub_nets(G) | |
| if not hub_nets: | |
| lines.append(" None (no net touches 5+ components)") | |
| else: | |
| for net, nid, comps in hub_nets: | |
| tag = " [GND rail]" if any('gnd' in str(c).lower() or | |
| 'gnd' in str(dm.get(c, {}).get('component', '')).lower() | |
| for c in comps) else "" | |
| lines.append(f" {net}{tag}: {', '.join(fmt(c) for c in comps)}") | |
| lines.append(" (paths below stop at the rail rather than enumerating every order") | |
| lines.append(" through it -- see rails above for full membership)") | |
| lines.append("") | |
| has_gnd_path = any(nx.has_path(G, s, g) for s in get_source_nodes(G) if s in G for g in gnd_nodes if g in G) | |
| lines += ["--- CIRCUIT PATHS ---"] | |
| if not gnd_nodes: | |
| lines.append(" WARNING No GND detected.") | |
| elif not has_gnd_path: | |
| lines.append(" WARNING GND not reachable from any source.") | |
| pg = find_all_circuit_paths(G, dm) | |
| if not pg: | |
| lines.append(" No paths found.") | |
| else: | |
| total = 0 | |
| for src_fmt, paths in pg: | |
| lines.append(f"\n From {src_fmt}") | |
| for i, path in enumerate(paths, 1): | |
| lines.append(f" Path {i}: {' -> '.join(fmt(n) for n in path)}") | |
| total += 1 | |
| lines.append(f" -- {len(paths)} path(s)") | |
| lines.append(f"\n Total: {total} path(s)") | |
| lines.append("") | |
| lines += ["--- SPICE NETLIST ---"] | |
| try: | |
| netlist = export_spice_netlist(G, details or []) | |
| lines.append(netlist) | |
| except Exception as e: | |
| lines.append(f" (netlist error: {e})") | |
| lines.append("") | |
| lines += ["--- CONNECTIVITY ---"] | |
| real_secs = circuit_sections(G) | |
| if len(real_secs) == 1: | |
| lines.append(" OK Fully connected circuit") | |
| else: | |
| lines.append(f" WARNING {len(real_secs)} separate sections:") | |
| for i, sec in enumerate(real_secs, 1): | |
| cn = [fmt(n) for n in sorted(sec)] | |
| lines.append(f" Section {i}: {', '.join(cn)}") | |
| lines.append("") | |
| n_conns = len(seen_pairs) | |
| n_nets = len([n for n in G.nodes() if G.nodes[n].get('node_type') == 'net']) | |
| lines.append(f" Components: {len(details or [])} Connections: {n_conns} Nets: {n_nets}") | |
| return "\n".join(lines) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # VISUALIZATION | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| COMP_COLORS = { | |
| 'resistor': (100, 180, 255), 'capacitor': (100, 255, 180), 'inductor': (255, 200, 100), | |
| 'led': (255, 100, 255), 'diode': (200, 100, 255), 'transistor': (255, 80, 80), | |
| 'voltage source': (255, 255, 80), 'battery': (255, 255, 80), 'switch': (80, 255, 255), | |
| 'ground': (160, 160, 160), 'gnd': (160, 160, 160), | |
| } | |
| def get_comp_color(ct): | |
| ct = ct.lower() | |
| for k, v in COMP_COLORS.items(): | |
| if k in ct: | |
| return v | |
| return (200, 200, 200) | |
| def get_center(d): | |
| return (int(d['x'] + d['w'] / 2), int(d['y'] + d['h'] / 2)) | |
| def draw_graph_overlay(base_img_np, details, G): | |
| out = base_img_np.copy() | |
| centers = {d['label']: get_center(d) for d in details} | |
| dm = {d['label']: d for d in details} | |
| for node, attrs in G.nodes(data=True): | |
| if attrs.get('node_type') == 'component' and node not in centers: | |
| cx = attrs.get('x', 0) + attrs.get('w', 0) // 2 | |
| cy = attrs.get('y', 0) + attrs.get('h', 0) // 2 | |
| centers[node] = (cx, cy) | |
| dm[node] = attrs | |
| net_nodes = {n for n, d in G.nodes(data=True) if d.get('node_type') == 'net'} | |
| comp_nodes = {n for n in G.nodes() if n not in net_nodes} | |
| # FIX (visual clutter): a hub net (shared rail, e.g. a common GND/power | |
| # bus touching many components) used to be drawn as a full pairwise | |
| # clique -- N components means N*(N-1)/2 crossing lines, which for a | |
| # 9-component rail is 36 overlapping cyan lines burying every other | |
| # connection in the diagram. Hub nets are now drawn as a dim star: one | |
| # short line per component out to a small rail marker, instead of a | |
| # line between every pair. Non-hub (normal, small) nets are unaffected | |
| # and still drawn as direct point-to-point wires. | |
| hub_net_ids = {nid for _, nid, _ in get_hub_nets(G)} | |
| drawn = set() | |
| for net in net_nodes: | |
| is_repair = "REPAIR" in str(net) | |
| is_vlm = "VLM" in str(net) | |
| is_hub = G.nodes[net].get('net_id') in hub_net_ids | |
| color = (255, 80, 80) if is_repair else (255, 165, 50) if is_vlm else (80, 220, 255) | |
| thickness = 1 if (is_repair or is_vlm) else 2 | |
| nbs = [nb for nb in G.neighbors(net) if nb in comp_nodes and nb in centers] | |
| if is_hub and not is_repair and not is_vlm: | |
| if len(nbs) < 2: | |
| continue | |
| pts = np.array([centers[nb] for nb in nbs]) | |
| cx, cy = int(pts[:, 0].mean()), int(pts[:, 1].mean()) | |
| rail_color = (130, 130, 150) | |
| for nb in nbs: | |
| cv2.line(out, centers[nb], (cx, cy), rail_color, 1, cv2.LINE_AA) | |
| cv2.circle(out, (cx, cy), 4, rail_color, -1) | |
| cv2.putText(out, f"RAIL {net}", (cx + 6, cy - 6), cv2.FONT_HERSHEY_SIMPLEX, | |
| 0.32, rail_color, 1, cv2.LINE_AA) | |
| continue | |
| for i in range(len(nbs)): | |
| for j in range(i + 1, len(nbs)): | |
| a, b = nbs[i], nbs[j] | |
| pair = tuple(sorted([a, b])) | |
| if pair in drawn: | |
| continue | |
| pa, pb = centers[a], centers[b] | |
| if is_repair: | |
| pts = np.linspace(np.array(pa), np.array(pb), 20).astype(int) | |
| for k in range(0, len(pts) - 1, 2): | |
| cv2.line(out, tuple(pts[k]), tuple(pts[k + 1]), color, 1, cv2.LINE_AA) | |
| else: | |
| cv2.line(out, pa, pb, color, thickness, cv2.LINE_AA) | |
| drawn.add(pair) | |
| for lbl, c in centers.items(): | |
| d = dm.get(lbl, {}) | |
| nc = get_comp_color(d.get('component', '')) | |
| deg = G.degree(lbl) if lbl in G else 0 | |
| cv2.circle(out, c, 8, nc, -1) | |
| cv2.circle(out, c, 8, (255, 255, 255), 1) | |
| if deg == 0: | |
| cv2.circle(out, c, 11, (200, 50, 50), 2) | |
| sch = d.get('schematic_label', lbl) | |
| val = d.get('value', '') | |
| txt = f"{sch}={val}" if val else sch | |
| (tw, th), _ = cv2.getTextSize(txt, cv2.FONT_HERSHEY_SIMPLEX, 0.38, 1) | |
| tx, ty = c[0] + 10, c[1] - 4 | |
| cv2.rectangle(out, (tx - 2, ty - th - 2), (tx + tw + 2, ty + 2), (20, 20, 40), -1) | |
| cv2.putText(out, txt, (tx, ty), cv2.FONT_HERSHEY_SIMPLEX, 0.38, nc, 1, cv2.LINE_AA) | |
| for lc, lt, ly in [((80, 220, 255), "Wire-traced", 20), | |
| ((255, 165, 50), "VLM-verified", 36), | |
| ((255, 80, 80), "Proximity guess", 52), | |
| ((130, 130, 150), "Shared rail (hub net)", 68)]: | |
| cv2.circle(out, (16, ly), 5, lc, -1) | |
| cv2.putText(out, lt, (24, ly + 4), cv2.FONT_HERSHEY_SIMPLEX, 0.32, lc, 1, cv2.LINE_AA) | |
| return out | |
| def draw_net_overlay(img_np, cc_map, num_cc): | |
| out = img_np.copy() | |
| rng = np.random.default_rng(42) | |
| colors = rng.integers(80, 255, size=(max(num_cc + 1, 2), 3)).tolist() | |
| for nid in range(1, num_cc): | |
| mask = (cc_map == nid) | |
| if int(np.sum(mask)) >= 8: | |
| out[mask] = colors[nid] | |
| return out | |
| def draw_component_graph(details, G, img_shape): | |
| h, w = img_shape[:2] | |
| canvas = np.zeros((h, w, 3), dtype=np.uint8) | |
| canvas[:] = (20, 22, 30) | |
| net_nodes = {n for n, d in G.nodes(data=True) if d.get('node_type') == 'net'} | |
| comp_nodes = {n for n in G.nodes() if n not in net_nodes} | |
| centers = {d['label']: get_center(d) for d in details} | |
| dm = {d['label']: d for d in details} | |
| for node, attrs in G.nodes(data=True): | |
| if attrs.get('node_type') == 'component' and node not in centers: | |
| centers[node] = (attrs.get('x', 0) + attrs.get('w', 0) // 2, attrs.get('y', 0) + attrs.get('h', 0) // 2) | |
| dm[node] = attrs | |
| drawn = set() | |
| hub_net_ids = {nid for _, nid, _ in get_hub_nets(G)} | |
| for net in net_nodes: | |
| inferred = "REPAIR" in str(net) or "VLM" in str(net) | |
| is_hub = G.nodes[net].get('net_id') in hub_net_ids | |
| color = (60, 100, 60) if inferred else (60, 180, 60) | |
| nbs = [nb for nb in G.neighbors(net) if nb in comp_nodes and nb in centers] | |
| if is_hub and not inferred: | |
| if len(nbs) < 2: | |
| continue | |
| pts_all = np.array([centers[nb] for nb in nbs]) | |
| cx, cy = int(pts_all[:, 0].mean()), int(pts_all[:, 1].mean()) | |
| rail_color = (70, 70, 90) | |
| for nb in nbs: | |
| cv2.line(canvas, centers[nb], (cx, cy), rail_color, 1, cv2.LINE_AA) | |
| cv2.circle(canvas, (cx, cy), 3, rail_color, -1) | |
| continue | |
| for i in range(len(nbs)): | |
| for j in range(i + 1, len(nbs)): | |
| a, b = nbs[i], nbs[j] | |
| pair = tuple(sorted([a, b])) | |
| if pair in drawn: | |
| continue | |
| drawn.add(pair) | |
| pa, pb = np.array(centers[a]), np.array(centers[b]) | |
| mx = (pa[0] + pb[0]) // 2 | |
| pts = [tuple(pa), (mx, pa[1]), (mx, pb[1]), tuple(pb)] | |
| for k in range(len(pts) - 1): | |
| cv2.line(canvas, pts[k], pts[k + 1], color, 1 if inferred else 2, cv2.LINE_AA) | |
| for lbl, c in centers.items(): | |
| d = dm.get(lbl, {}) | |
| nc = get_comp_color(d.get('component', '')) | |
| cv2.circle(canvas, c, 10, nc, -1) | |
| cv2.circle(canvas, c, 10, (255, 255, 255), 1) | |
| sch = d.get('schematic_label', lbl) | |
| val = d.get('value', '') | |
| for ti, lt in enumerate([sch, val] if val else [sch]): | |
| (tw, th), _ = cv2.getTextSize(lt, cv2.FONT_HERSHEY_SIMPLEX, 0.40, 1) | |
| tx = c[0] - tw // 2 | |
| ty = c[1] - 14 + ti * 14 | |
| cv2.putText(canvas, lt, (tx, ty), cv2.FONT_HERSHEY_SIMPLEX, 0.40, nc, 1, cv2.LINE_AA) | |
| return canvas | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # PIPELINE | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def run_detection(image, confidence): | |
| clear_debug_log() | |
| debug_log("PIPELINE", "Step 1: YOLO three-pass detection") | |
| img_np, results, details = detect_components(image, confidence) | |
| debug_log("PIPELINE", "Step 2: OCR + LLM value/label extraction") | |
| ocr_img, ocr_scale = preprocess_for_ocr(img_np) | |
| raw_ocr_results = get_ocr_reader().readtext( | |
| ocr_img, | |
| text_threshold=0.5, # FIX: lowered from EasyOCR default 0.7 -- small | |
| # schematic text often has low confidence | |
| low_text=0.3, # FIX: lowered from default 0.4 -- catches | |
| # faint/thin-stroke characters | |
| contrast_ths=0.05, # FIX: lowered from default 0.1 -- text on | |
| # light schematic backgrounds is low-contrast | |
| adjust_contrast=0.7, | |
| mag_ratio=1.0, # already upscaled in preprocess_for_ocr | |
| ) | |
| # rescale coordinates back to original image space since OCR ran on | |
| # the upscaled image | |
| ocr_results = [ | |
| ([[c[0] / ocr_scale, c[1] / ocr_scale] for c in coords], text, *rest) | |
| for (coords, text, *rest) in raw_ocr_results | |
| ] | |
| debug_log("OCR", f"{len(ocr_results)} text regions (scale={ocr_scale}x)") | |
| details, labeled_pil = fill_values(img_np, details, ocr_results) | |
| debug_log("PIPELINE", "Step 2b: GND symbol detection") | |
| details = detect_gnd_symbols(img_np, details) | |
| gnd_counter = 1 | |
| for d in details: | |
| if not d['label']: | |
| d['label'] = f"GND{gnd_counter}" | |
| d['schematic_label'] = "GND" | |
| gnd_counter += 1 | |
| debug_log("PIPELINE", "Step 3: Wire-CC graph") | |
| G, skel, cc_map, num_cc, valid_nets, net_pixels, net_endpoints, scale = build_graph_wire_cc(details, img_np) | |
| debug_log("PIPELINE", f"Wire-CC: {G.number_of_nodes()}N {G.number_of_edges()}E gnd={get_gnd_nodes(G)}") | |
| debug_log("PIPELINE", "Step 3b: GND snap") | |
| G = wire_gnd_to_nearest_net(G, details, cc_map, valid_nets, scale) | |
| G = prune_orphan_nets(G, label="ORPHAN-SWEEP(gnd)") | |
| debug_log("PIPELINE", "Step 3c: Isolation repair") | |
| G = repair_isolated_nodes(G, details, img_np, cc_map, valid_nets) | |
| G = prune_orphan_nets(G, label="ORPHAN-SWEEP(repair)") | |
| debug_log("PIPELINE", "Step 4: VLM topology verification") | |
| G = verify_connections_with_llm(labeled_pil, details, G) | |
| G = prune_orphan_nets(G, label="ORPHAN-SWEEP(vlm)") | |
| debug_log("PIPELINE", f"Final: {G.number_of_nodes()}N {G.number_of_edges()}E " | |
| f"sections={len(circuit_sections(G))}") | |
| base_plot = results[0].plot() | |
| annotated = draw_graph_overlay(base_plot, details, G) | |
| net_vis = draw_net_overlay(img_np.copy(), cc_map, num_cc) | |
| comp_g = draw_component_graph(details, G, img_np.shape) | |
| return Image.fromarray(annotated), Image.fromarray(net_vis), Image.fromarray(comp_g), details, G | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # HANDLERS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def handle_image1(image, confidence): | |
| if image is None: | |
| return None, None, None, {}, "Upload an image to begin.", "", "", get_debug_text() | |
| try: | |
| ann, nv, cg, details, G = run_detection(image, confidence) | |
| total = len(details) | |
| det_cnt = sum(1 for d in details if not d.get("is_default")) | |
| def_cnt = total - det_cnt | |
| summary = f"### {total} component{'s' if total != 1 else ''} detected\n\n" | |
| summary += f"OCR/LLM: {det_cnt} | Default: {def_cnt}\n\n" | |
| summary += "| Label | Schematic | Component | Value | Src |\n|---|---|---|---|---|\n" | |
| for d in sorted(details, key=lambda x: x['label']): | |
| src = d.get("source") or "default" | |
| sch = d.get("schematic_label", d['label']) | |
| summary += f"| {d['label']} | {sch} | {d['component']} | {d['value'] or '-'} | {src} |\n" | |
| CG = build_component_graph(G) | |
| dm = {d['label']: d for d in details} | |
| loops = find_loops(CG, dm) | |
| summary += f"\n**Edges:** {G.number_of_edges()} " | |
| if loops: | |
| summary += f"**Loops:** {len(loops)}" | |
| summary += "\n\n_Base saved._" | |
| report = describe_connectivity(G, details=details) | |
| return ann, nv, cg, details, summary, report, "", get_debug_text() | |
| except Exception as e: | |
| traceback.print_exc() | |
| debug_log("FATAL", f"{type(e).__name__}: {e}\n{traceback.format_exc()}") | |
| return None, None, None, {}, "Error during detection", str(e), "", get_debug_text() | |
| def parse_base_value_to_float(value_str): | |
| if value_str is None: | |
| return None, None | |
| pm = {'k': 1e3, 'K': 1e3, 'M': 1e6, 'm': 1e-3, 'Β΅': 1e-6, 'u': 1e-6, 'n': 1e-9, 'p': 1e-12} | |
| m = re.compile(r'([\d.]+)\s*([kKMmΒ΅unp])?\s*(Ξ©|ohm|F|H|V|A|W)?', re.IGNORECASE).search(str(value_str).strip()) | |
| if not m: | |
| return None, None | |
| return float(m.group(1)) * pm.get(m.group(2) or '', 1.0), m.group(3) or '' | |
| def values_match(base_str, csv_val): | |
| try: | |
| bf, _ = parse_base_value_to_float(str(base_str)) | |
| if bf is None: | |
| return str(base_str).strip().upper() == str(csv_val).strip().upper() | |
| return abs(bf - float(str(csv_val).strip())) < 1e-9 | |
| except Exception: | |
| return str(base_str).strip().upper() == str(csv_val).strip().upper() | |
| def handle_csv(csv_file, base_details): | |
| if csv_file is None: | |
| return "Upload CSV.", "", None | |
| if not base_details: | |
| return "No base circuit saved.", "", None | |
| try: | |
| csv_path = csv_file if isinstance(csv_file, str) else getattr(csv_file, 'name', None) | |
| if csv_path is None: | |
| return "Could not read CSV file path.", "", None | |
| df = pd.read_csv(csv_path) | |
| df.columns = [c.strip() for c in df.columns] | |
| sch_to_lbl = {} | |
| base_map = {d['label']: d for d in base_details} | |
| for d in base_details: | |
| sch = d.get('schematic_label', d['label']) | |
| sch_to_lbl[sch] = d['label'] | |
| sch_to_lbl[d['label']] = d['label'] | |
| matched = [c for c in df.columns if c in sch_to_lbl] | |
| col_to_lbl = {c: sch_to_lbl[c] for c in matched} | |
| if not matched: | |
| return (f"No matching labels.\nCSV:{', '.join(df.columns)}\nBase:{', '.join(base_map)}", "", None) | |
| total = len(df) | |
| wrong_rows = {c: [] for c in matched} | |
| ok_rows = {} | |
| for col in matched: | |
| lbl = col_to_lbl[col] | |
| bv = base_map[lbl]['value'] | |
| ok = 0 | |
| for i, rv in enumerate(df[col]): | |
| if values_match(bv, rv): | |
| ok += 1 | |
| else: | |
| wrong_rows[col].append((i + 1, rv, bv)) | |
| ok_rows[col] = ok | |
| issues = sum(len(v) for v in wrong_rows.values()) | |
| status = "ISSUES FOUND" if issues else "ALL OK" | |
| summary = f"### {status}\n\nRows: {total} | Checked: {len(matched)}\n\n" | |
| unm_base = [l for l in base_map if l not in col_to_lbl.values()] | |
| unm_csv = [c for c in df.columns if c not in matched] | |
| if unm_base: | |
| summary += f"> Not in CSV: {', '.join(unm_base)}\n\n" | |
| if unm_csv: | |
| summary += f"> CSV extra: {', '.join(unm_csv)}\n\n" | |
| summary += "| Component | Base | OK | Wrong | Status |\n|---|---|---|---|---|\n" | |
| for col in matched: | |
| lbl = col_to_lbl[col] | |
| bv = base_map[lbl]['value'] | |
| ok = ok_rows[col] | |
| wr = len(wrong_rows[col]) | |
| comp = base_map[lbl]['component'] | |
| summary += f"| {col} ({comp}) | {bv} | {ok} | {wr} | {'OK' if wr == 0 else f'WRONG({wr})'} |\n" | |
| for col in matched: | |
| if not wrong_rows[col]: | |
| continue | |
| lbl = col_to_lbl[col] | |
| bv = base_map[lbl]['value'] | |
| comp = base_map[lbl]['component'] | |
| summary += f"\n### {col} ({comp})\n| Row | Got | Expected |\n|---|---|---|\n" | |
| for rn, cv_, b in wrong_rows[col]: | |
| summary += f"| {rn} | {cv_} | {b} |\n" | |
| report = f"CSV COMPARISON\n{'='*38}\nStatus:{status}\nRows:{total} Checked:{len(matched)} Issues:{issues}\n\n" | |
| for col in matched: | |
| lbl = col_to_lbl[col] | |
| bv = base_map[lbl]['value'] | |
| ok = ok_rows[col] | |
| wr = len(wrong_rows[col]) | |
| report += f"{col}({base_map[lbl]['component']}) expected={bv} ok={ok} wrong={wr}\n" | |
| for rn, cv_, b in wrong_rows[col]: | |
| report += f" row{rn}: got={cv_} expected={b}\n" | |
| df_out = df.copy() | |
| for col in matched: | |
| lbl = col_to_lbl[col] | |
| bv = base_map[lbl]['value'] | |
| df_out[col + "_status"] = df_out[col].apply(lambda v: "OK" if values_match(bv, v) else "WRONG") | |
| fd, out_csv_path = tempfile.mkstemp(suffix=".csv") | |
| os.close(fd) | |
| df_out.to_csv(out_csv_path, index=False) | |
| return summary, report, out_csv_path | |
| except Exception as e: | |
| traceback.print_exc() | |
| return f"Error: {e}", "", None | |
| def reset_all(): | |
| clear_debug_log() | |
| return (None, None, None, None, None, "Upload image to set base circuit.", "", | |
| "Upload CSV to compare.", "", [], None, get_debug_text()) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # UI | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| css = """ | |
| html,body{margin:0;padding:0} | |
| .gradio-container{max-width:100%!important;width:100%!important;margin:0!important;padding:0!important;background:#0f1117!important;font-family:'DM Sans',sans-serif!important} | |
| .app-header{background:linear-gradient(135deg,#0f1117,#1a1f2e);border-bottom:1px solid #2a2f3e;padding:12px 28px;display:flex;align-items:center;gap:10px;flex-wrap:wrap} | |
| .app-header h1{font-size:1.2rem!important;font-weight:700!important;color:#e2e8f0!important;margin:0!important} | |
| .badge{font-size:.70rem;padding:2px 9px;border-radius:20px} | |
| .b1{color:#4ade80;background:#0d2818;border:1px solid #166534} | |
| .b2{color:#818cf8;background:#1e1b4b;border:1px solid #3730a3} | |
| .b3{color:#fb923c;background:#1c0f05;border:1px solid #7c2d12} | |
| .b4{color:#f43f5e;background:#1c0509;border:1px solid #881337} | |
| .panel-label{font-size:.7rem!important;font-weight:600!important;text-transform:uppercase!important;letter-spacing:1px!important;color:#4b5563!important;margin-bottom:4px!important} | |
| .image-box{border-radius:10px!important;overflow:hidden!important;background:#1a1f2e!important;border:1px solid #1e2330!important} | |
| .action-btn{background:linear-gradient(135deg,#3b82f6,#6366f1)!important;border:none!important;color:#fff!important;font-weight:600!important;border-radius:8px!important;padding:10px!important;cursor:pointer!important;width:100%!important} | |
| .csv-btn{background:linear-gradient(135deg,#059669,#047857)!important;border:none!important;color:#fff!important;font-weight:600!important;border-radius:8px!important;padding:10px!important;cursor:pointer!important;width:100%!important} | |
| .clear-btn{background:#1e2330!important;border:1px solid #2a2f3e!important;color:#9ca3af!important;font-weight:500!important;border-radius:8px!important;padding:10px!important;cursor:pointer!important;width:100%!important} | |
| .summary-box{overflow-y:auto!important;background:#1a1f2e!important;border-radius:10px!important;padding:12px!important;border:1px solid #1e2330!important;font-size:.82rem!important;color:#cbd5e1!important} | |
| .report-box textarea{font-family:'JetBrains Mono',monospace!important;font-size:.70rem!important;background:#1a1f2e!important;color:#94a3b8!important;border:1px solid #1e2330!important;border-radius:10px!important} | |
| .debug-box textarea{font-family:'JetBrains Mono',monospace!important;font-size:.68rem!important;background:#120d05!important;color:#facc15!important;border:1px solid #422006!important;border-radius:10px!important} | |
| ::-webkit-scrollbar{width:4px} | |
| ::-webkit-scrollbar-thumb{background:#2a2f3e;border-radius:4px} | |
| footer{display:none!important} | |
| """ | |
| with gr.Blocks( | |
| theme=gr.themes.Base(primary_hue="blue", neutral_hue="slate", | |
| font=[gr.themes.GoogleFont("DM Sans"), "sans-serif"], | |
| font_mono=[gr.themes.GoogleFont("JetBrains Mono"), "monospace"]), | |
| css=css, title="Circuit Detector v24" | |
| ) as app: | |
| base_state = gr.State([]) | |
| gr.HTML(""" | |
| <div class="app-header"> | |
| <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2"> | |
| <rect x="2" y="2" width="20" height="20" rx="3"/> | |
| <path d="M7 12h2m6 0h2M12 7v2m0 6v2"/><circle cx="12" cy="12" r="2"/> | |
| </svg> | |
| <h1>Circuit Component Detector</h1> | |
| <span class="badge b1">YOLOv8 3-pass</span> | |
| <span class="badge b2">OCR + LLM</span> | |
| <span class="badge b3">Skeleton + Union-Find + KDTree</span> | |
| <span class="badge b4">v24 - terminal nets now nearest-only (kdtree query, not ball_point) -- fixes spurious multi-net terminals</span> | |
| </div>""") | |
| with gr.Row(): | |
| with gr.Column(scale=1, min_width=260): | |
| gr.HTML('<div class="panel-label" style="margin-top:12px">Confidence Threshold</div>') | |
| confidence = gr.Slider(0.01, 1.0, value=0.03, step=0.01, label="", info="Lower=more detections") | |
| gr.HTML('<div class="panel-label" style="margin-top:12px">Step 1 - Base Circuit</div>') | |
| image1 = gr.Image(type="pil", label="Upload Circuit Image", elem_classes="image-box", height=200) | |
| analyze_btn = gr.Button("Analyze and Save as Base", elem_classes="action-btn") | |
| gr.HTML('<hr style="border:none;border-top:1px solid #1e2330;margin:8px 0"><div class="panel-label">Step 2 - CSV Log</div>') | |
| csv_input = gr.File(label="Upload CSV Log File", file_types=[".csv"]) | |
| compare_btn = gr.Button("Compare Log vs Base", elem_classes="csv-btn") | |
| gr.HTML('<hr style="border:none;border-top:1px solid #1e2330;margin:8px 0">') | |
| clear_btn = gr.Button("Reset Everything", elem_classes="clear-btn") | |
| with gr.Column(scale=2, min_width=300): | |
| gr.HTML('<div class="panel-label" style="margin-top:12px">Connection Graph Overlay</div>') | |
| out_image_graph = gr.Image(label="", elem_classes="image-box", show_label=False, height=210) | |
| gr.HTML('<div class="panel-label" style="margin-top:8px">Wire Net Map</div>') | |
| out_image_nets = gr.Image(label="", elem_classes="image-box", show_label=False, height=210) | |
| gr.HTML('<div class="panel-label" style="margin-top:8px">Component Graph</div>') | |
| out_comp_graph = gr.Image(label="", elem_classes="image-box", show_label=False, height=210) | |
| with gr.Column(scale=2, min_width=300): | |
| gr.HTML('<div class="panel-label" style="margin-top:12px">Base Summary</div>') | |
| out_summary1 = gr.Markdown(value="_Upload circuit image to begin._", elem_classes="summary-box", height=200) | |
| gr.HTML('<div class="panel-label" style="margin-top:10px">CSV Comparison</div>') | |
| out_summary2 = gr.Markdown(value="_Upload CSV to compare._", elem_classes="summary-box", height=240) | |
| gr.HTML('<div class="panel-label" style="margin-top:10px">Download Result CSV</div>') | |
| out_csv_download = gr.File(label="", show_label=False) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| gr.HTML('<div class="panel-label" style="margin-top:6px">Circuit Analysis Report</div>') | |
| out_report1 = gr.Textbox(label="", lines=24, show_label=False, | |
| placeholder="Full report: topology, paths, loops, SPICE netlist...", elem_classes="report-box") | |
| with gr.Column(scale=1): | |
| gr.HTML('<div class="panel-label" style="margin-top:6px">CSV Comparison Report</div>') | |
| out_report2 = gr.Textbox(label="", lines=24, show_label=False, | |
| placeholder="CSV comparison report...", elem_classes="report-box") | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| gr.HTML('<div class="panel-label" style="margin-top:6px">Debug Log</div>') | |
| out_debug = gr.Textbox(label="", lines=14, show_label=False, elem_classes="debug-box") | |
| analyze_btn.click(fn=handle_image1, inputs=[image1, confidence], | |
| outputs=[out_image_graph, out_image_nets, out_comp_graph, base_state, | |
| out_summary1, out_report1, out_summary2, out_debug]) | |
| compare_btn.click(fn=handle_csv, inputs=[csv_input, base_state], | |
| outputs=[out_summary2, out_report2, out_csv_download]) | |
| clear_btn.click(fn=reset_all, inputs=[], | |
| outputs=[image1, csv_input, out_image_graph, out_image_nets, out_comp_graph, | |
| out_summary1, out_report1, out_summary2, out_report2, | |
| base_state, out_csv_download, out_debug]) | |
| app.launch(server_name="0.0.0.0", server_port=7860) |