Upload 2 files
Browse files
batch.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Batch CLI: STEP folder -> Excel. Single process, models loaded once."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import argparse
|
| 4 |
+
import shutil
|
| 5 |
+
import sys
|
| 6 |
+
import tempfile
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Dict, List
|
| 10 |
+
|
| 11 |
+
from openpyxl import Workbook
|
| 12 |
+
|
| 13 |
+
from heg_brep import (
|
| 14 |
+
DEFAULT_PASS1_MODEL, DEFAULT_ELBOW_MODEL, DEFAULT_TEE_MODEL, REPO_ROOT,
|
| 15 |
+
)
|
| 16 |
+
from heg_brep.inference import LoadedModel, TwoPassClassifier
|
| 17 |
+
from heg_brep.extraction import extract_folder_to_npz
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
COLUMNS = [
|
| 21 |
+
"file_name", "file_path", "stem", "status",
|
| 22 |
+
"pass1_argmax", "pass1_conf", "route",
|
| 23 |
+
"pass2_argmax", "pass2_predicted", "pass2_conf",
|
| 24 |
+
"final_label", "final_conf", "npz_path", "note",
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def parse_args() -> argparse.Namespace:
|
| 29 |
+
ap = argparse.ArgumentParser(description=__doc__)
|
| 30 |
+
ap.add_argument("step_folder")
|
| 31 |
+
ap.add_argument("output_excel", nargs="?", default=None,
|
| 32 |
+
help="Defaults to ./brep_two_pass_<timestamp>.xlsx")
|
| 33 |
+
ap.add_argument("--pass1_model", default=str(DEFAULT_PASS1_MODEL))
|
| 34 |
+
ap.add_argument("--elbow_model", default=str(DEFAULT_ELBOW_MODEL))
|
| 35 |
+
ap.add_argument("--tee_model", default=str(DEFAULT_TEE_MODEL))
|
| 36 |
+
ap.add_argument("--device", default="cpu", choices=["cpu", "cuda"])
|
| 37 |
+
ap.add_argument("--pass2_min_conf", type=float, default=0.85)
|
| 38 |
+
ap.add_argument("--pass2_tau", type=float, default=0.0)
|
| 39 |
+
ap.add_argument("--num_workers", type=int, default=1)
|
| 40 |
+
ap.add_argument("--max_file_mb", type=float, default=None)
|
| 41 |
+
ap.add_argument("--npz_dir", default=None, help="Persist NPZs here (default: temp).")
|
| 42 |
+
return ap.parse_args()
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def write_excel(rows: List[Dict[str, object]], output_excel: Path) -> None:
|
| 46 |
+
wb = Workbook()
|
| 47 |
+
ws = wb.active
|
| 48 |
+
ws.title = "brep_two_pass"
|
| 49 |
+
ws.append(COLUMNS)
|
| 50 |
+
for r in rows:
|
| 51 |
+
ws.append([r.get(c, "") for c in COLUMNS])
|
| 52 |
+
for col in ws.columns:
|
| 53 |
+
letter = col[0].column_letter
|
| 54 |
+
max_len = max((len(str(c.value)) if c.value is not None else 0 for c in col), default=0)
|
| 55 |
+
ws.column_dimensions[letter].width = min(max(12, max_len + 2), 70)
|
| 56 |
+
output_excel.parent.mkdir(parents=True, exist_ok=True)
|
| 57 |
+
wb.save(output_excel)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def main() -> int:
|
| 61 |
+
args = parse_args()
|
| 62 |
+
step_folder = Path(args.step_folder).expanduser().resolve()
|
| 63 |
+
if not step_folder.is_dir():
|
| 64 |
+
raise FileNotFoundError(f"step_folder not found: {step_folder}")
|
| 65 |
+
|
| 66 |
+
output_excel = (Path(args.output_excel).expanduser().resolve()
|
| 67 |
+
if args.output_excel
|
| 68 |
+
else REPO_ROOT / f"brep_two_pass_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx")
|
| 69 |
+
temp_npz_dir = args.npz_dir is None
|
| 70 |
+
npz_dir = (Path(args.npz_dir).expanduser().resolve() if args.npz_dir
|
| 71 |
+
else Path(tempfile.mkdtemp(prefix="heg_brep_npz_")))
|
| 72 |
+
try:
|
| 73 |
+
print(f"Extracting STEP files in {step_folder} -> {npz_dir}")
|
| 74 |
+
t0 = datetime.now()
|
| 75 |
+
summary = extract_folder_to_npz(
|
| 76 |
+
step_folder, output_dir=npz_dir,
|
| 77 |
+
num_workers=args.num_workers, max_file_mb=args.max_file_mb,
|
| 78 |
+
)
|
| 79 |
+
print(f" extraction took {(datetime.now() - t0).total_seconds():.1f}s "
|
| 80 |
+
f"({len(summary['ok_stems'])} OK, {len(summary['skipped'])} skipped)")
|
| 81 |
+
|
| 82 |
+
print(f"Loading models on {args.device}...")
|
| 83 |
+
t0 = datetime.now()
|
| 84 |
+
pass1 = LoadedModel(Path(args.pass1_model), device=args.device)
|
| 85 |
+
elbow = LoadedModel(Path(args.elbow_model), device=args.device) if Path(args.elbow_model).exists() else None
|
| 86 |
+
tee = LoadedModel(Path(args.tee_model), device=args.device) if Path(args.tee_model).exists() else None
|
| 87 |
+
clf = TwoPassClassifier(pass1=pass1, elbow=elbow, tee=tee,
|
| 88 |
+
pass2_min_conf=args.pass2_min_conf, pass2_tau=args.pass2_tau)
|
| 89 |
+
print(f" models loaded in {(datetime.now() - t0).total_seconds():.1f}s")
|
| 90 |
+
|
| 91 |
+
print(f"Classifying {len(summary['step_paths'])} files...")
|
| 92 |
+
t0 = datetime.now()
|
| 93 |
+
rows: List[Dict[str, object]] = []
|
| 94 |
+
ok = pass1_failed = pass2_failed = extraction_failed = 0
|
| 95 |
+
for sp in summary["step_paths"]:
|
| 96 |
+
stem = sp.stem
|
| 97 |
+
base = {c: "" for c in COLUMNS}
|
| 98 |
+
base.update({"file_name": sp.name, "file_path": str(sp), "stem": stem})
|
| 99 |
+
npz = npz_dir / f"{stem}.npz"
|
| 100 |
+
if not npz.exists():
|
| 101 |
+
base["status"] = "EXTRACTION_FAILED"
|
| 102 |
+
base["note"] = summary["skipped"].get(stem, "NPZ missing after extraction")
|
| 103 |
+
extraction_failed += 1
|
| 104 |
+
rows.append(base); continue
|
| 105 |
+
base["npz_path"] = str(npz)
|
| 106 |
+
try:
|
| 107 |
+
result = clf.classify_npz(npz)
|
| 108 |
+
except Exception as exc:
|
| 109 |
+
base["status"] = "INFERENCE_FAILED"
|
| 110 |
+
base["note"] = str(exc)[:500]
|
| 111 |
+
pass1_failed += 1
|
| 112 |
+
rows.append(base); continue
|
| 113 |
+
base.update({k: result.get(k, "") for k in result})
|
| 114 |
+
# Round confidences for the spreadsheet
|
| 115 |
+
for k in ("pass1_conf", "pass2_conf", "final_conf"):
|
| 116 |
+
v = base.get(k)
|
| 117 |
+
base[k] = round(float(v), 6) if isinstance(v, (int, float)) else v
|
| 118 |
+
base["status"] = "OK"
|
| 119 |
+
ok += 1
|
| 120 |
+
rows.append(base)
|
| 121 |
+
|
| 122 |
+
elapsed = (datetime.now() - t0).total_seconds()
|
| 123 |
+
print(f" inference took {elapsed:.1f}s ({elapsed / max(1, len(rows)):.2f}s/file)")
|
| 124 |
+
|
| 125 |
+
write_excel(rows, output_excel)
|
| 126 |
+
print(f"Saved Excel: {output_excel}")
|
| 127 |
+
print(f"Summary -> total: {len(rows)} | OK: {ok} | "
|
| 128 |
+
f"extraction_failed: {extraction_failed} | inference_failed: {pass1_failed}")
|
| 129 |
+
if not temp_npz_dir:
|
| 130 |
+
print(f"NPZ dir: {npz_dir}")
|
| 131 |
+
return 0
|
| 132 |
+
finally:
|
| 133 |
+
if temp_npz_dir:
|
| 134 |
+
shutil.rmtree(npz_dir, ignore_errors=True)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
if __name__ == "__main__":
|
| 138 |
+
sys.exit(main())
|
server.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Local FastAPI service: STEP-file-in, classification-out. One process, models loaded once.
|
| 2 |
+
|
| 3 |
+
Designed to be launched by a C# host app as a child process. The service binds to
|
| 4 |
+
127.0.0.1 only; never exposes a network-facing port.
|
| 5 |
+
|
| 6 |
+
Startup protocol:
|
| 7 |
+
- Bind a free port (default: OS-assigned via --port 0).
|
| 8 |
+
- Once models are loaded and the server is accepting requests, write
|
| 9 |
+
`READY port=<PORT>` to stdout (single line). The host reads that line to learn
|
| 10 |
+
where to connect.
|
| 11 |
+
- On `POST /shutdown` (or SIGTERM), drain in-flight requests and exit.
|
| 12 |
+
|
| 13 |
+
Endpoints:
|
| 14 |
+
GET /health -> service / model state
|
| 15 |
+
POST /classify {step_path} -> single STEP classification
|
| 16 |
+
POST /classify_batch {paths} -> multiple in one call (sequential, single-process)
|
| 17 |
+
POST /shutdown -> graceful exit
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
import argparse
|
| 21 |
+
import os
|
| 22 |
+
import shutil
|
| 23 |
+
import sys
|
| 24 |
+
import tempfile
|
| 25 |
+
import threading
|
| 26 |
+
import time
|
| 27 |
+
from pathlib import Path
|
| 28 |
+
from typing import List, Optional
|
| 29 |
+
|
| 30 |
+
from fastapi import FastAPI, HTTPException
|
| 31 |
+
from pydantic import BaseModel
|
| 32 |
+
import uvicorn
|
| 33 |
+
|
| 34 |
+
from heg_brep import (
|
| 35 |
+
DEFAULT_PASS1_MODEL, DEFAULT_ELBOW_MODEL, DEFAULT_TEE_MODEL,
|
| 36 |
+
)
|
| 37 |
+
from heg_brep.inference import LoadedModel, TwoPassClassifier
|
| 38 |
+
from heg_brep.extraction import extract_step_to_npz
|
| 39 |
+
|
| 40 |
+
# Eagerly import the BRepExtractor pipeline at startup. Without this, the first
|
| 41 |
+
# /classify call pays ~20-30s of OCC + occwl + igl cold-import cost — terrible
|
| 42 |
+
# UX for an interactive viewer.
|
| 43 |
+
import pipeline.extract_brep_extractor_data_from_step # noqa: F401
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class State:
|
| 47 |
+
classifier: Optional[TwoPassClassifier] = None
|
| 48 |
+
started_at: float = 0.0
|
| 49 |
+
device: str = "cpu"
|
| 50 |
+
pass2_min_conf: float = 0.85
|
| 51 |
+
pass2_tau: float = 0.0
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
app = FastAPI(title="HEG BRep component identification")
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class ClassifyRequest(BaseModel):
|
| 58 |
+
step_path: str
|
| 59 |
+
# Optional override: persist the NPZ to disk for inspection.
|
| 60 |
+
npz_keep_dir: Optional[str] = None
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class ClassifyBatchRequest(BaseModel):
|
| 64 |
+
step_paths: List[str]
|
| 65 |
+
npz_keep_dir: Optional[str] = None
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@app.get("/health")
|
| 69 |
+
def health():
|
| 70 |
+
return {
|
| 71 |
+
"status": "ok",
|
| 72 |
+
"models_loaded": State.classifier is not None,
|
| 73 |
+
"device": State.device,
|
| 74 |
+
"uptime_sec": round(time.time() - State.started_at, 2),
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _classify_one(step_path: str, npz_keep_dir: Optional[str]) -> dict:
|
| 79 |
+
if State.classifier is None:
|
| 80 |
+
raise HTTPException(status_code=503, detail="models not loaded yet")
|
| 81 |
+
sp = Path(step_path).expanduser()
|
| 82 |
+
if not sp.exists():
|
| 83 |
+
return {"step_path": str(sp), "status": "error", "error": "step_path not found"}
|
| 84 |
+
temp_npz_dir = npz_keep_dir is None
|
| 85 |
+
out_dir = Path(npz_keep_dir).expanduser().resolve() if npz_keep_dir \
|
| 86 |
+
else Path(tempfile.mkdtemp(prefix="heg_brep_npz_"))
|
| 87 |
+
try:
|
| 88 |
+
try:
|
| 89 |
+
npz = extract_step_to_npz(sp, out_dir)
|
| 90 |
+
except Exception as exc:
|
| 91 |
+
return {"step_path": str(sp), "status": "extraction_failed", "error": str(exc)[:500]}
|
| 92 |
+
try:
|
| 93 |
+
result = State.classifier.classify_npz(npz)
|
| 94 |
+
except Exception as exc:
|
| 95 |
+
return {"step_path": str(sp), "status": "inference_failed", "error": str(exc)[:500],
|
| 96 |
+
"npz_path": str(npz)}
|
| 97 |
+
result.update({"step_path": str(sp), "status": "ok", "npz_path": str(npz)})
|
| 98 |
+
return result
|
| 99 |
+
finally:
|
| 100 |
+
if temp_npz_dir:
|
| 101 |
+
shutil.rmtree(out_dir, ignore_errors=True)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
@app.post("/classify")
|
| 105 |
+
def classify(req: ClassifyRequest):
|
| 106 |
+
return _classify_one(req.step_path, req.npz_keep_dir)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@app.post("/classify_batch")
|
| 110 |
+
def classify_batch(req: ClassifyBatchRequest):
|
| 111 |
+
return {"results": [_classify_one(p, req.npz_keep_dir) for p in req.step_paths]}
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
@app.post("/shutdown")
|
| 115 |
+
def shutdown():
|
| 116 |
+
threading.Thread(target=lambda: (time.sleep(0.2), os._exit(0)), daemon=True).start()
|
| 117 |
+
return {"status": "shutting_down"}
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _load_models(args) -> None:
|
| 121 |
+
pass1 = LoadedModel(Path(args.pass1_model), device=args.device)
|
| 122 |
+
elbow = LoadedModel(Path(args.elbow_model), device=args.device) if Path(args.elbow_model).exists() else None
|
| 123 |
+
tee = LoadedModel(Path(args.tee_model), device=args.device) if Path(args.tee_model).exists() else None
|
| 124 |
+
State.classifier = TwoPassClassifier(
|
| 125 |
+
pass1=pass1, elbow=elbow, tee=tee,
|
| 126 |
+
pass2_min_conf=args.pass2_min_conf, pass2_tau=args.pass2_tau,
|
| 127 |
+
)
|
| 128 |
+
State.device = args.device
|
| 129 |
+
State.pass2_min_conf = args.pass2_min_conf
|
| 130 |
+
State.pass2_tau = args.pass2_tau
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def parse_args() -> argparse.Namespace:
|
| 134 |
+
ap = argparse.ArgumentParser(description="HEG BRep classification service")
|
| 135 |
+
ap.add_argument("--host", default="127.0.0.1")
|
| 136 |
+
ap.add_argument("--port", type=int, default=0,
|
| 137 |
+
help="Port to bind. 0 = let the OS pick. The chosen port is "
|
| 138 |
+
"printed to stdout as a single line `READY port=<N>`.")
|
| 139 |
+
ap.add_argument("--pass1_model", default=str(DEFAULT_PASS1_MODEL))
|
| 140 |
+
ap.add_argument("--elbow_model", default=str(DEFAULT_ELBOW_MODEL))
|
| 141 |
+
ap.add_argument("--tee_model", default=str(DEFAULT_TEE_MODEL))
|
| 142 |
+
ap.add_argument("--device", default="cpu", choices=["cpu", "cuda"])
|
| 143 |
+
ap.add_argument("--pass2_min_conf", type=float, default=0.85)
|
| 144 |
+
ap.add_argument("--pass2_tau", type=float, default=0.0)
|
| 145 |
+
return ap.parse_args()
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def main() -> int:
|
| 149 |
+
args = parse_args()
|
| 150 |
+
State.started_at = time.time()
|
| 151 |
+
print(f"[heg_brep] loading models from:", file=sys.stderr)
|
| 152 |
+
print(f" pass1: {args.pass1_model}", file=sys.stderr)
|
| 153 |
+
print(f" elbow: {args.elbow_model}", file=sys.stderr)
|
| 154 |
+
print(f" tee : {args.tee_model}", file=sys.stderr)
|
| 155 |
+
print(f" device: {args.device}", file=sys.stderr)
|
| 156 |
+
t0 = time.time()
|
| 157 |
+
_load_models(args)
|
| 158 |
+
print(f"[heg_brep] models loaded in {time.time() - t0:.1f}s", file=sys.stderr)
|
| 159 |
+
|
| 160 |
+
# Bind socket up front so we can tell the parent which port we got.
|
| 161 |
+
import socket
|
| 162 |
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
| 163 |
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
| 164 |
+
sock.bind((args.host, int(args.port)))
|
| 165 |
+
bound_port = sock.getsockname()[1]
|
| 166 |
+
print(f"READY port={bound_port}", flush=True)
|
| 167 |
+
|
| 168 |
+
config = uvicorn.Config(app=app, host=args.host, port=bound_port, log_level="warning")
|
| 169 |
+
server = uvicorn.Server(config)
|
| 170 |
+
# uvicorn doesn't accept an already-bound socket in the simple Config API; close
|
| 171 |
+
# ours and let uvicorn rebind. Race window is fine because we're loopback only.
|
| 172 |
+
sock.close()
|
| 173 |
+
server.run()
|
| 174 |
+
return 0
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
if __name__ == "__main__":
|
| 178 |
+
sys.exit(main())
|