Silly98 commited on
Commit
cbb3781
·
verified ·
1 Parent(s): d157fe7

Upload server.py

Browse files
Files changed (1) hide show
  1. server.py +225 -0
server.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 atexit
22
+ import os
23
+ import shutil
24
+ import sys
25
+ import tempfile
26
+ import threading
27
+ import time
28
+ from pathlib import Path
29
+ from typing import List, Optional
30
+
31
+ from fastapi import FastAPI, HTTPException
32
+ from pydantic import BaseModel
33
+ import uvicorn
34
+
35
+ from heg_brep import (
36
+ DEFAULT_PASS1_MODEL, DEFAULT_ELBOW_MODEL, DEFAULT_TEE_MODEL,
37
+ )
38
+ from heg_brep.inference import LoadedModel, TwoPassClassifier
39
+ from heg_brep.extraction import extract_step_to_npz
40
+
41
+ # Eagerly import the BRepExtractor pipeline at startup. Without this, the first
42
+ # /classify call pays ~20-30s of OCC + occwl + igl cold-import cost — terrible
43
+ # UX for an interactive viewer.
44
+ import pipeline.extract_brep_extractor_data_from_step # noqa: F401
45
+
46
+
47
+ class State:
48
+ classifier: Optional[TwoPassClassifier] = None
49
+ started_at: float = 0.0
50
+ device: str = "cpu"
51
+ pass2_min_conf: float = 0.85
52
+ pass2_tau: float = 0.0
53
+ temp_artifact_baseline: set[str] = set()
54
+ temp_cleanup_done: bool = False
55
+ temp_cleanup_lock = threading.Lock()
56
+
57
+
58
+ app = FastAPI(title="HEG BRep component identification")
59
+
60
+
61
+ class ClassifyRequest(BaseModel):
62
+ step_path: str
63
+ # Optional override: persist the NPZ to disk for inspection.
64
+ npz_keep_dir: Optional[str] = None
65
+
66
+
67
+ class ClassifyBatchRequest(BaseModel):
68
+ step_paths: List[str]
69
+ npz_keep_dir: Optional[str] = None
70
+
71
+
72
+ @app.get("/health")
73
+ def health():
74
+ return {
75
+ "status": "ok",
76
+ "models_loaded": State.classifier is not None,
77
+ "device": State.device,
78
+ "uptime_sec": round(time.time() - State.started_at, 2),
79
+ }
80
+
81
+
82
+ def _classify_one(step_path: str, npz_keep_dir: Optional[str]) -> dict:
83
+ if State.classifier is None:
84
+ raise HTTPException(status_code=503, detail="models not loaded yet")
85
+ sp = Path(step_path).expanduser()
86
+ if not sp.exists():
87
+ return {"step_path": str(sp), "status": "error", "error": "step_path not found"}
88
+ temp_npz_dir = npz_keep_dir is None
89
+ out_dir = Path(npz_keep_dir).expanduser().resolve() if npz_keep_dir \
90
+ else Path(tempfile.mkdtemp(prefix="heg_brep_npz_"))
91
+ try:
92
+ try:
93
+ npz = extract_step_to_npz(sp, out_dir)
94
+ except Exception as exc:
95
+ return {"step_path": str(sp), "status": "extraction_failed", "error": str(exc)[:500]}
96
+ try:
97
+ result = State.classifier.classify_npz(npz)
98
+ except Exception as exc:
99
+ return {"step_path": str(sp), "status": "inference_failed", "error": str(exc)[:500],
100
+ "npz_path": str(npz)}
101
+ result.update({"step_path": str(sp), "status": "ok", "npz_path": str(npz)})
102
+ return result
103
+ finally:
104
+ if temp_npz_dir:
105
+ shutil.rmtree(out_dir, ignore_errors=True)
106
+
107
+
108
+ def _snapshot_service_temp_artifacts() -> set[str]:
109
+ temp_root = Path(tempfile.gettempdir())
110
+ out: set[str] = set()
111
+ for p in temp_root.glob("heg_brep_npz_*"):
112
+ out.add(str(p.resolve()))
113
+ for p in temp_root.glob("torch_geometric.nn.conv.sage_conv_SAGEConv_propagate_*.py"):
114
+ out.add(str(p.resolve()))
115
+ pycache = temp_root / "__pycache__"
116
+ if pycache.exists():
117
+ for p in pycache.glob("torch_geometric.nn.conv.sage_conv_SAGEConv_propagate_*.pyc"):
118
+ out.add(str(p.resolve()))
119
+ return out
120
+
121
+
122
+ def _remove_path(p: Path) -> None:
123
+ if p.is_dir():
124
+ shutil.rmtree(p, ignore_errors=True)
125
+ else:
126
+ p.unlink(missing_ok=True)
127
+
128
+
129
+ def _cleanup_service_temp_artifacts() -> None:
130
+ with State.temp_cleanup_lock:
131
+ if State.temp_cleanup_done:
132
+ return
133
+ current = _snapshot_service_temp_artifacts()
134
+ to_remove = sorted(current - State.temp_artifact_baseline)
135
+ for raw in to_remove:
136
+ _remove_path(Path(raw))
137
+ State.temp_cleanup_done = True
138
+
139
+
140
+ @app.post("/classify")
141
+ def classify(req: ClassifyRequest):
142
+ return _classify_one(req.step_path, req.npz_keep_dir)
143
+
144
+
145
+ @app.post("/classify_batch")
146
+ def classify_batch(req: ClassifyBatchRequest):
147
+ return {"results": [_classify_one(p, req.npz_keep_dir) for p in req.step_paths]}
148
+
149
+
150
+ @app.post("/shutdown")
151
+ def shutdown():
152
+ def _exit_soon():
153
+ time.sleep(0.2)
154
+ try:
155
+ _cleanup_service_temp_artifacts()
156
+ finally:
157
+ os._exit(0)
158
+ threading.Thread(target=_exit_soon, daemon=True).start()
159
+ return {"status": "shutting_down"}
160
+
161
+
162
+ def _load_models(args) -> None:
163
+ pass1 = LoadedModel(Path(args.pass1_model), device=args.device)
164
+ elbow = LoadedModel(Path(args.elbow_model), device=args.device) if Path(args.elbow_model).exists() else None
165
+ tee = LoadedModel(Path(args.tee_model), device=args.device) if Path(args.tee_model).exists() else None
166
+ State.classifier = TwoPassClassifier(
167
+ pass1=pass1, elbow=elbow, tee=tee,
168
+ pass2_min_conf=args.pass2_min_conf, pass2_tau=args.pass2_tau,
169
+ )
170
+ State.device = args.device
171
+ State.pass2_min_conf = args.pass2_min_conf
172
+ State.pass2_tau = args.pass2_tau
173
+
174
+
175
+ def parse_args() -> argparse.Namespace:
176
+ ap = argparse.ArgumentParser(description="HEG BRep classification service")
177
+ ap.add_argument("--host", default="127.0.0.1")
178
+ ap.add_argument("--port", type=int, default=0,
179
+ help="Port to bind. 0 = let the OS pick. The chosen port is "
180
+ "printed to stdout as a single line `READY port=<N>`.")
181
+ ap.add_argument("--pass1_model", default=str(DEFAULT_PASS1_MODEL))
182
+ ap.add_argument("--elbow_model", default=str(DEFAULT_ELBOW_MODEL))
183
+ ap.add_argument("--tee_model", default=str(DEFAULT_TEE_MODEL))
184
+ ap.add_argument("--device", default="cpu", choices=["cpu", "cuda"])
185
+ ap.add_argument("--pass2_min_conf", type=float, default=0.85)
186
+ ap.add_argument("--pass2_tau", type=float, default=0.0)
187
+ return ap.parse_args()
188
+
189
+
190
+ def main() -> int:
191
+ args = parse_args()
192
+ State.temp_artifact_baseline = _snapshot_service_temp_artifacts()
193
+ atexit.register(_cleanup_service_temp_artifacts)
194
+ State.started_at = time.time()
195
+ print(f"[heg_brep] loading models from:", file=sys.stderr)
196
+ print(f" pass1: {args.pass1_model}", file=sys.stderr)
197
+ print(f" elbow: {args.elbow_model}", file=sys.stderr)
198
+ print(f" tee : {args.tee_model}", file=sys.stderr)
199
+ print(f" device: {args.device}", file=sys.stderr)
200
+ t0 = time.time()
201
+ _load_models(args)
202
+ print(f"[heg_brep] models loaded in {time.time() - t0:.1f}s", file=sys.stderr)
203
+
204
+ # Bind socket up front so we can tell the parent which port we got.
205
+ import socket
206
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
207
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
208
+ sock.bind((args.host, int(args.port)))
209
+ bound_port = sock.getsockname()[1]
210
+ print(f"READY port={bound_port}", flush=True)
211
+
212
+ config = uvicorn.Config(app=app, host=args.host, port=bound_port, log_level="warning")
213
+ server = uvicorn.Server(config)
214
+ # uvicorn doesn't accept an already-bound socket in the simple Config API; close
215
+ # ours and let uvicorn rebind. Race window is fine because we're loopback only.
216
+ sock.close()
217
+ try:
218
+ server.run()
219
+ finally:
220
+ _cleanup_service_temp_artifacts()
221
+ return 0
222
+
223
+
224
+ if __name__ == "__main__":
225
+ sys.exit(main())