| import cv2 |
| import numpy as np |
| from pathlib import Path |
| from ultralytics import YOLO |
| from huggingface_hub import hf_hub_download |
|
|
|
|
| class WeldVision: |
| """ |
| Local four-model WeldVision ensemble. |
| |
| Usage: |
| model = WeldVision.from_pretrained( |
| "bhavibhatt/weldvision-ensemble" |
| ) |
| result = model.predict("weld.jpg") |
| """ |
|
|
| CLASS_NAMES = { |
| 0: "Bad Welding", |
| 1: "Crack", |
| 2: "Excess Reinforcement", |
| 3: "Good Welding", |
| 4: "Porosity", |
| 5: "Spatters", |
| } |
|
|
| PENALTIES = { |
| "Crack": 40, |
| "Porosity": 15, |
| "Spatters": 5, |
| "Excess Reinforcement": 20, |
| "Bad Welding": 50, |
| "Good Welding": 0, |
| } |
|
|
| def __init__(self, model_paths, conf=0.25, ensemble_iou=0.50, imgsz=640): |
| self.conf = conf |
| self.ensemble_iou = ensemble_iou |
| self.imgsz = imgsz |
|
|
| self.base_1 = YOLO(str(model_paths["best.pt"])) |
| self.base_2 = YOLO(str(model_paths["best_v0.pt"])) |
| self.crack = YOLO(str(model_paths["crack_specialist.pt"])) |
| self.spatters = YOLO(str(model_paths["spatters_specialist.pt"])) |
|
|
| @classmethod |
| def from_pretrained( |
| cls, |
| repo_id, |
| revision=None, |
| cache_dir=None, |
| conf=0.25, |
| ensemble_iou=0.50, |
| imgsz=640, |
| ): |
| """ |
| Download the four weights from Hugging Face Hub and load them locally. |
| """ |
| names = [ |
| "best.pt", |
| "best_v0.pt", |
| "crack_specialist.pt", |
| "spatters_specialist.pt", |
| ] |
|
|
| paths = {} |
| for name in names: |
| paths[name] = hf_hub_download( |
| repo_id=repo_id, |
| filename=f"weights/{name}", |
| revision=revision, |
| cache_dir=cache_dir, |
| ) |
|
|
| return cls( |
| paths, |
| conf=conf, |
| ensemble_iou=ensemble_iou, |
| imgsz=imgsz, |
| ) |
|
|
| @staticmethod |
| def _load_image(image): |
| if isinstance(image, (str, Path)): |
| image = cv2.imread(str(image)) |
| if image is None: |
| raise ValueError(f"Could not read image: {image}") |
| return image |
|
|
| if isinstance(image, np.ndarray): |
| if image.ndim != 3 or image.shape[2] != 3: |
| raise ValueError("Image must have shape H x W x 3") |
| return image |
|
|
| raise TypeError("image must be a file path or HxWx3 numpy array") |
|
|
| @staticmethod |
| def _mask_iou(a, b): |
| a = a.astype(bool) |
| b = b.astype(bool) |
| inter = np.logical_and(a, b).sum() |
| union = np.logical_or(a, b).sum() |
| return float(inter / union) if union else 0.0 |
|
|
| def _extract(self, result, source): |
| if result.boxes is None or result.masks is None: |
| return [] |
|
|
| boxes = result.boxes.data.cpu().numpy() |
| masks = result.masks.data.cpu().numpy() |
| out = [] |
|
|
| for box, mask in zip(boxes, masks): |
| x1, y1, x2, y2, conf, cls_id = box |
| cls_id = int(cls_id) |
|
|
| if source == "crack_specialist": |
| class_name = "Crack" |
| elif source == "spatters_specialist": |
| class_name = "Spatters" |
| else: |
| class_name = self.CLASS_NAMES.get(cls_id, str(cls_id)) |
|
|
| out.append({ |
| "box": np.array([x1, y1, x2, y2], dtype=np.float32), |
| "conf": float(conf), |
| "class_name": class_name, |
| "mask": mask.astype(np.float32), |
| "source": source, |
| }) |
|
|
| return out |
|
|
| def _run(self, model, image, source): |
| result = model.predict( |
| image, |
| conf=self.conf, |
| imgsz=self.imgsz, |
| verbose=False, |
| )[0] |
| return self._extract(result, source) |
|
|
| def _merge(self, predictions): |
| predictions = sorted( |
| predictions, |
| key=lambda p: p["conf"], |
| reverse=True, |
| ) |
|
|
| selected = [] |
| for candidate in predictions: |
| duplicate = False |
|
|
| for existing in selected: |
| if candidate["class_name"] != existing["class_name"]: |
| continue |
|
|
| if self._mask_iou( |
| candidate["mask"], |
| existing["mask"], |
| ) >= self.ensemble_iou: |
| duplicate = True |
| break |
|
|
| if not duplicate: |
| selected.append(candidate) |
|
|
| return selected |
|
|
| def _severity(self, name): |
| penalty = self.PENALTIES.get(name, 0) |
| if penalty >= 30: |
| return "HIGH" |
| if penalty >= 15: |
| return "MEDIUM" |
| if penalty > 0: |
| return "LOW" |
| return "NONE" |
|
|
| def predict(self, image): |
| """ |
| Run the four-model ensemble. |
| |
| Returns a JSON-serializable dictionary. |
| """ |
| image_bgr = self._load_image(image) |
| h, w = image_bgr.shape[:2] |
|
|
| p1 = self._run(self.base_1, image_bgr, "best.pt") |
| p2 = self._run(self.base_2, image_bgr, "best_v0.pt") |
| pc = self._run(self.crack, image_bgr, "crack_specialist") |
| ps = self._run(self.spatters, image_bgr, "spatters_specialist") |
|
|
| merged = self._merge(p1 + p2 + pc + ps) |
|
|
| detections = [] |
| score = 100 |
| highest = "NONE" |
| rank = {"NONE": 0, "LOW": 1, "MEDIUM": 2, "HIGH": 3} |
|
|
| for p in merged: |
| name = p["class_name"] |
| if name == "Good Welding": |
| continue |
|
|
| severity = self._severity(name) |
| score -= self.PENALTIES.get(name, 0) |
| if rank[severity] > rank[highest]: |
| highest = severity |
|
|
| x1, y1, x2, y2 = p["box"] |
| detections.append({ |
| "class": name, |
| "confidence": round(float(p["conf"]), 4), |
| "severity": severity, |
| "box": [ |
| round(float(max(0, min(w, x1))), 2), |
| round(float(max(0, min(h, y1))), 2), |
| round(float(max(0, min(w, x2))), 2), |
| round(float(max(0, min(h, y2))), 2), |
| ], |
| "source": p["source"], |
| }) |
|
|
| score = max(0, score) |
|
|
| if score < 70 or highest == "HIGH": |
| decision = "FAIL" |
| elif score < 85 or highest == "MEDIUM": |
| decision = "REVIEW" |
| else: |
| decision = "PASS" |
|
|
| return { |
| "model": "WeldVision-Ensemble", |
| "version": "1.0", |
| "decision": decision, |
| "score": score, |
| "highest_severity": highest, |
| "model_counts": { |
| "best.pt": len(p1), |
| "best_v0.pt": len(p2), |
| "crack_specialist.pt": len(pc), |
| "spatters_specialist.pt": len(ps), |
| "ensemble": len(merged), |
| }, |
| "detections": detections, |
| } |
|
|