Silly98 commited on
Commit
6cc5a7c
·
verified ·
1 Parent(s): 11a4152

Upload 2 files

Browse files
Files changed (2) hide show
  1. batch.py +138 -0
  2. server.py +178 -0
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())