Upload server.py
Browse files
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())
|