|
| 1 | +import io |
| 2 | +import json |
| 3 | +import re |
| 4 | +from datetime import datetime, timezone |
| 5 | +from pathlib import Path |
| 6 | + |
| 7 | +import numpy as np |
| 8 | +from datasets import load_dataset |
| 9 | + |
| 10 | +from .constants import MAX_NEW_TOKENS_ANOMALY_DETECTION, PEREGRINE_DATASET_NAME |
| 11 | + |
| 12 | +_ANOMALY_LABELS = [ |
| 13 | + "recoater_hopping", |
| 14 | + "recoater_streaking", |
| 15 | + "incomplete_spreading", |
| 16 | + "swelling", |
| 17 | + "debris", |
| 18 | + "super_elevation", |
| 19 | + "spatter", |
| 20 | + "misprint", |
| 21 | + "over_melting", |
| 22 | + "under_melting", |
| 23 | +] |
| 24 | + |
| 25 | +_DATASET_CONFIG = "anomaly_0250" |
| 26 | +_SAMPLE_SIZE = 100 |
| 27 | +_SAMPLE_SEED = 42 |
| 28 | + |
| 29 | + |
| 30 | +def _build_anomaly_prompt() -> str: |
| 31 | + labels_str = "\n".join(f"- {label}" for label in _ANOMALY_LABELS) |
| 32 | + return ( |
| 33 | + "You are an expert in Laser Powder Bed Fusion (L-PBF) additive manufacturing " |
| 34 | + "quality control and in-situ process monitoring.\n" |
| 35 | + "Examine this in-situ monitoring image captured after laser melting of a build layer.\n\n" |
| 36 | + "Identify all anomaly types present in the image from the following list:\n" |
| 37 | + f"{labels_str}\n\n" |
| 38 | + "Respond with a JSON list of the detected anomaly names. " |
| 39 | + "If no anomalies are detected, respond with an empty list.\n" |
| 40 | + 'Example: ["spatter", "recoater_hopping"]\n' |
| 41 | + "Respond with the JSON list only, nothing else." |
| 42 | + ) |
| 43 | + |
| 44 | + |
| 45 | +def _parse_anomaly_response(response: str) -> list[str]: |
| 46 | + """Extract list of anomaly labels from model response.""" |
| 47 | + match = re.search(r"\[.*?\]", response, re.DOTALL) |
| 48 | + if match: |
| 49 | + try: |
| 50 | + parsed = json.loads(match.group()) |
| 51 | + if isinstance(parsed, list): |
| 52 | + matched = [] |
| 53 | + for item in parsed: |
| 54 | + if isinstance(item, str): |
| 55 | + for label in _ANOMALY_LABELS: |
| 56 | + if label.lower() == item.lower().strip(): |
| 57 | + matched.append(label) |
| 58 | + break |
| 59 | + return matched |
| 60 | + except json.JSONDecodeError: |
| 61 | + pass |
| 62 | + # Fallback: scan response for any mentioned anomaly labels |
| 63 | + return [ |
| 64 | + label |
| 65 | + for label in _ANOMALY_LABELS |
| 66 | + if re.search(rf"\b{re.escape(label)}\b", response, re.IGNORECASE) |
| 67 | + ] |
| 68 | + |
| 69 | + |
| 70 | +def _get_ground_truth(row: dict) -> list[str]: |
| 71 | + """Return list of anomaly types present in the layer (any True pixels in mask).""" |
| 72 | + present = [] |
| 73 | + for label in _ANOMALY_LABELS: |
| 74 | + mask_bytes = row.get(f"segmentation_{label}") |
| 75 | + if mask_bytes is None: |
| 76 | + continue |
| 77 | + try: |
| 78 | + mask = np.load(io.BytesIO(bytes(mask_bytes))) |
| 79 | + if mask.any(): |
| 80 | + present.append(label) |
| 81 | + except Exception: |
| 82 | + pass |
| 83 | + return present |
| 84 | + |
| 85 | + |
| 86 | +def _pil_to_bytes(img) -> bytes: |
| 87 | + buf = io.BytesIO() |
| 88 | + img.save(buf, format="PNG") |
| 89 | + return buf.getvalue() |
| 90 | + |
| 91 | + |
| 92 | +def _sample_f1(predicted: list[str], ground_truth: list[str]) -> float: |
| 93 | + pred_set = set(predicted) |
| 94 | + gt_set = set(ground_truth) |
| 95 | + if not pred_set and not gt_set: |
| 96 | + return 1.0 |
| 97 | + if not pred_set or not gt_set: |
| 98 | + return 0.0 |
| 99 | + tp = len(pred_set & gt_set) |
| 100 | + return round(2 * tp / (len(pred_set) + len(gt_set)), 4) |
| 101 | + |
| 102 | + |
| 103 | +def _benchmark_peregrine_anomaly_detection( |
| 104 | + vision_runner, |
| 105 | + model: str, |
| 106 | + num_proc: int, |
| 107 | + out_path: Path | None, |
| 108 | + run_index: int = 1, |
| 109 | +) -> dict: |
| 110 | + task = "peregrine_anomaly_detection" |
| 111 | + print(f"\n[{task}] Loading dataset (sampling {_SAMPLE_SIZE} of 250)...") |
| 112 | + full_data = load_dataset( |
| 113 | + PEREGRINE_DATASET_NAME, |
| 114 | + _DATASET_CONFIG, |
| 115 | + num_proc=num_proc, |
| 116 | + )["train"] |
| 117 | + data = full_data.shuffle(seed=_SAMPLE_SEED).select(range(_SAMPLE_SIZE)) |
| 118 | + |
| 119 | + prompt_text = _build_anomaly_prompt() |
| 120 | + items = [] |
| 121 | + for row in data: |
| 122 | + preview = row["image_after_melt_preview"] |
| 123 | + if hasattr(preview, "save"): # PIL Image |
| 124 | + image_bytes = _pil_to_bytes(preview) |
| 125 | + else: |
| 126 | + image_bytes = bytes(preview) |
| 127 | + items.append( |
| 128 | + { |
| 129 | + "text": prompt_text, |
| 130 | + "image_bytes": image_bytes, |
| 131 | + "image_ext": "png", |
| 132 | + } |
| 133 | + ) |
| 134 | + responses = vision_runner(items, MAX_NEW_TOKENS_ANOMALY_DETECTION) |
| 135 | + |
| 136 | + results = [] |
| 137 | + f1_scores = [] |
| 138 | + for i, row in enumerate(data): |
| 139 | + ground_truth = _get_ground_truth(row) |
| 140 | + predicted = _parse_anomaly_response(responses[i]) |
| 141 | + f1 = _sample_f1(predicted, ground_truth) |
| 142 | + f1_scores.append(f1) |
| 143 | + results.append( |
| 144 | + { |
| 145 | + "build": row.get("build"), |
| 146 | + "layer": row.get("layer"), |
| 147 | + "ground_truth": ground_truth, |
| 148 | + "response": responses[i], |
| 149 | + "predicted": predicted, |
| 150 | + "f1": f1, |
| 151 | + } |
| 152 | + ) |
| 153 | + |
| 154 | + total = len(results) |
| 155 | + mean_f1 = round(sum(f1_scores) / total, 4) if total else 0.0 |
| 156 | + |
| 157 | + # Per-class precision, recall, F1 |
| 158 | + per_class = {} |
| 159 | + for label in _ANOMALY_LABELS: |
| 160 | + tp = sum( |
| 161 | + 1 for r in results if label in r["ground_truth"] and label in r["predicted"] |
| 162 | + ) |
| 163 | + fp = sum( |
| 164 | + 1 |
| 165 | + for r in results |
| 166 | + if label not in r["ground_truth"] and label in r["predicted"] |
| 167 | + ) |
| 168 | + fn = sum( |
| 169 | + 1 |
| 170 | + for r in results |
| 171 | + if label in r["ground_truth"] and label not in r["predicted"] |
| 172 | + ) |
| 173 | + precision = round(tp / (tp + fp), 4) if (tp + fp) else 0.0 |
| 174 | + recall = round(tp / (tp + fn), 4) if (tp + fn) else 0.0 |
| 175 | + f1_class = ( |
| 176 | + round(2 * precision * recall / (precision + recall), 4) |
| 177 | + if (precision + recall) |
| 178 | + else 0.0 |
| 179 | + ) |
| 180 | + per_class[label] = { |
| 181 | + "precision": precision, |
| 182 | + "recall": recall, |
| 183 | + "f1": f1_class, |
| 184 | + "support": tp + fn, |
| 185 | + } |
| 186 | + |
| 187 | + report = { |
| 188 | + "model": model, |
| 189 | + "dataset": PEREGRINE_DATASET_NAME, |
| 190 | + "config": _DATASET_CONFIG, |
| 191 | + "timestamp": datetime.now(timezone.utc).isoformat(), |
| 192 | + "sample_size": _SAMPLE_SIZE, |
| 193 | + "total_layers": len(full_data), |
| 194 | + "mean_f1": mean_f1, |
| 195 | + "per_class": per_class, |
| 196 | + "results": results, |
| 197 | + } |
| 198 | + |
| 199 | + _print_peregrine_report(report) |
| 200 | + |
| 201 | + if out_path is not None: |
| 202 | + report_file = Path(out_path) / f"run_{run_index:02d}.json" |
| 203 | + with open(report_file, "w") as f: |
| 204 | + json.dump(report, f, indent=2) |
| 205 | + print(f"Report saved to: {report_file}") |
| 206 | + |
| 207 | + return report |
| 208 | + |
| 209 | + |
| 210 | +def _print_peregrine_report(report: dict): |
| 211 | + sep = "-" * 60 |
| 212 | + print(sep) |
| 213 | + print("Peregrine L-PBF Anomaly Detection — Report") |
| 214 | + print(sep) |
| 215 | + print(f"Model: {report['model']}") |
| 216 | + print(f"Sample: {report['sample_size']} / {report['total_layers']}") |
| 217 | + print(f"Mean F1: {report['mean_f1']:.4f}") |
| 218 | + print(sep) |
| 219 | + print("Per-class F1:") |
| 220 | + for label, stats in report["per_class"].items(): |
| 221 | + print( |
| 222 | + f" {label:<25} P={stats['precision']:.2f} R={stats['recall']:.2f}" |
| 223 | + f" F1={stats['f1']:.2f} (support={stats['support']})" |
| 224 | + ) |
| 225 | + print(sep) |
| 226 | + for i, r in enumerate(report["results"], 1): |
| 227 | + gt_str = ",".join(r["ground_truth"]) or "none" |
| 228 | + pred_str = ",".join(r["predicted"]) or "none" |
| 229 | + mark = "✓" if r["f1"] == 1.0 else "~" if r["f1"] > 0 else "✗" |
| 230 | + print(f"[{i:>3}] {mark} f1={r['f1']:.2f} gt=[{gt_str}] pred=[{pred_str}]") |
| 231 | + print(sep) |
| 232 | + print(f"Mean F1: {report['mean_f1']:.4f}") |
| 233 | + print(sep) |
0 commit comments