Skip to content

Commit add648b

Browse files
committed
Adds peregrine benchmarking.
1 parent 03123c6 commit add648b

8 files changed

Lines changed: 296 additions & 24 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "additive-manufacturing"
3-
version = "0.0.25"
3+
version = "0.0.28"
44
authors = [
55
{ name = "Peter Pak", email = "ppak10@gmail.com" },
66
]

src/am/benchmark/llm/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from .melt_pool_geometry_prediction import _benchmark_melt_pool_geometry_prediction
1111
from .fdm_3d_printing_defect import _benchmark_fdm_3d_printing_defect
1212
from .machines import _benchmark_machines
13+
from .peregrine_anomaly_detection import _benchmark_peregrine_anomaly_detection
1314
from .score_card import _compile_score_card
1415

1516

@@ -63,6 +64,10 @@ def _run_benchmark_tasks(
6364
proctor_model=proctor_model,
6465
run_index=run_index,
6566
)
67+
elif task == "peregrine_anomaly_detection":
68+
reports[task] = _benchmark_peregrine_anomaly_detection(
69+
vision_runner, model, num_proc, task_out, run_index=run_index
70+
)
6671
else:
6772
print(f"Unknown task '{task}', skipping.")
6873

src/am/benchmark/llm/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
DATASET_NAME = "ppak10/Additive-Manufacturing-Benchmark"
2+
PEREGRINE_DATASET_NAME = "ppak10/Peregrine-Dataset-v2023-11"
23
PROCTOR_MODEL = "openai/gpt-oss-20b"
34
MAX_NEW_TOKENS_SHORT_ANSWER = 4096
45
MAX_NEW_TOKENS_MELT_POOL = 1024
56
MAX_NEW_TOKENS_MULTIPLE_CHOICE = 256
67
MAX_NEW_TOKENS_PROCTOR = 512
78
MAX_NEW_TOKENS_DEFECT_CLASSIFICATION = 128
89
MAX_NEW_TOKENS_MACHINES = 256
10+
MAX_NEW_TOKENS_ANOMALY_DETECTION = 256
911
TASKS = [
1012
"general_knowledge_multiple_choice",
1113
"general_knowledge_short_answer",
1214
"melt_pool_geometry_prediction",
1315
"fdm_3d_printing_defect",
1416
"machines",
17+
"peregrine_anomaly_detection",
1518
]

src/am/benchmark/llm/melt_pool_geometry_prediction.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,14 @@ def _to_float(v):
8989
def _rmse(pairs: list[tuple[float, float]]) -> float | None:
9090
if not pairs:
9191
return None
92-
return round(math.sqrt(sum((a - p) ** 2 for a, p in pairs) / len(pairs)), 4)
92+
valid = [
93+
(a, p)
94+
for a, p in pairs
95+
if not (math.isnan(a) or math.isnan(p) or math.isinf(a) or math.isinf(p))
96+
]
97+
if not valid:
98+
return None
99+
return round(math.sqrt(sum((a - p) ** 2 for a, p in valid) / len(valid)), 4)
93100

94101

95102
def _benchmark_melt_pool_geometry_prediction(
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
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)

src/am/benchmark/llm/score_card.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import json
2+
import math
23
import statistics
34
from datetime import datetime, timezone
45
from pathlib import Path
56

67

78
def _stats(values: list, num_runs: int):
89
"""Return mean for a single run, or {"mean": ..., "std": ...} for multiple runs."""
9-
valid = [v for v in values if v is not None]
10+
valid = [
11+
v
12+
for v in values
13+
if v is not None and not (isinstance(v, float) and math.isnan(v))
14+
]
1015
if not valid:
1116
return None
1217
mean = round(statistics.mean(valid), 4)
@@ -108,6 +113,19 @@ def _gather(task, key):
108113
"sample_size": all_reports[0]["machines"].get("sample_size"),
109114
}
110115

116+
if any("peregrine_anomaly_detection" in r for r in all_reports):
117+
tasks_summary["peregrine_anomaly_detection"] = {
118+
"mean_f1": _stats(
119+
_gather("peregrine_anomaly_detection", "mean_f1"), num_runs
120+
),
121+
"sample_size": all_reports[0]["peregrine_anomaly_detection"].get(
122+
"sample_size"
123+
),
124+
"total_layers": all_reports[0]["peregrine_anomaly_detection"].get(
125+
"total_layers"
126+
),
127+
}
128+
111129
dataset = next((r[next(iter(r))].get("dataset") for r in all_reports if r), None)
112130
score_card = {
113131
"model": model,

0 commit comments

Comments
 (0)