File size: 9,337 Bytes
3461c19 6ffd3f8 3461c19 6ffd3f8 3461c19 6ffd3f8 3461c19 2237e2c 3461c19 6ffd3f8 3461c19 2237e2c 3461c19 6ffd3f8 3461c19 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 | # SPDX-License-Identifier: Apache-2.0
"""Smoke test for the SuperPoint tt-dit-server: one request, sanity checks, PASS/FAIL.
python code/models/server/smoke_test.py --url http://127.0.0.1:20000
Default input: ``sample_data/house_in_field_1080p.jpg`` (the port's natural-image
validation frame, 1600x900) resolved relative to this file (code/sample_data).
Only the standard library + numpy are needed (numpy decodes the descriptor NPZ).
Checks (the README reports ~500 strong keypoints on this frame at top-500;
we ask for 1024 and require a few hundred):
* /health is ok, /info answers, /v1/models lists the weights repo
* /predict returns 200 with num_keypoints in [200, max_keypoints]
* every keypoint lies inside the ORIGINAL image, scores in (threshold, 1]
and sorted-consistent with top-k, descriptors (N, 256) float16 finite and
unit-norm (|1 - ||d||| < 0.05), timing_ms present
* a second call with return_descriptors=false / max_keypoints=100 obeys both
* POST /predict_raw (the file bytes) and POST /predict_plane (the R plane, needs Pillow) return the
same response as /predict apart from timing_ms (skip with --no-binary-routes)
Exit code 0 on PASS, 1 on FAIL.
"""
from __future__ import annotations
import argparse
import base64
import io
import json
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path
DEFAULT_IMAGE = Path(__file__).resolve().parents[2] / "sample_data" / "house_in_field_1080p.jpg"
def _get(url: str, timeout: float = 30.0):
with urllib.request.urlopen(url, timeout=timeout) as r:
return r.status, json.loads(r.read().decode("utf-8"))
def _post(url: str, payload: dict, timeout: float = 300.0):
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, json.loads(r.read().decode("utf-8"))
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", "replace")
return e.code, {"detail": body}
def _post_bytes(url: str, body: bytes, timeout: float = 300.0):
req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/octet-stream"}, method="POST")
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, json.loads(r.read().decode("utf-8"))
except urllib.error.HTTPError as e:
return e.code, {"detail": e.read().decode("utf-8", "replace")}
def _fail(msg: str) -> int:
print(f"FAIL {msg}")
return 1
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--url", default="http://127.0.0.1:20000", help="server base URL (the port `serve` printed)")
ap.add_argument("--image", type=Path, default=DEFAULT_IMAGE)
ap.add_argument("--max-keypoints", type=int, default=1024)
ap.add_argument("--min-keypoints", type=int, default=200, help="lower bound for PASS on the default image")
ap.add_argument("--threshold", type=float, default=0.005)
ap.add_argument("--timeout", type=float, default=300.0)
ap.add_argument("--no-binary-routes", action="store_true", help="skip /predict_raw and /predict_plane")
ap.add_argument("--save-json", type=Path, default=None, help="write the first /predict response here")
args = ap.parse_args()
base = args.url.rstrip("/")
if not args.image.is_file():
return _fail(f"input image not found: {args.image}")
raw = args.image.read_bytes()
if raw[:4] not in (b"\xff\xd8\xff\xe0", b"\xff\xd8\xff\xe1", b"\x89PNG") and raw[:3] != b"\xff\xd8\xff":
return _fail(f"{args.image} is not a PNG/JPEG (git-lfs pointer? run `git lfs pull`)")
b64 = base64.b64encode(raw).decode("ascii")
try:
st, health = _get(f"{base}/health")
except Exception as e:
return _fail(f"GET /health failed: {e}")
if st != 200 or health.get("status") != "ok":
return _fail(f"/health not ok: {health}")
st, info = _get(f"{base}/info")
if st != 200 or info.get("model") != "superpoint-p150":
return _fail(f"/info unexpected: {info}")
st, models = _get(f"{base}/v1/models")
if st != 200 or not models.get("data"):
return _fail(f"/v1/models unexpected: {models}")
weights_repo = models["data"][0]["id"]
payload = {
"image": b64,
"max_keypoints": args.max_keypoints,
"keypoint_threshold": args.threshold,
"nms_radius": 4,
"return_descriptors": True,
}
t0 = time.perf_counter()
st, resp = _post(f"{base}/predict", payload, timeout=args.timeout)
wall_ms = (time.perf_counter() - t0) * 1000.0
if st != 200:
return _fail(f"/predict HTTP {st}: {str(resp)[:300]}")
if args.save_json:
args.save_json.write_text(json.dumps(resp))
n = resp.get("num_keypoints")
kps = resp.get("keypoints") or []
scores = resp.get("scores") or []
if not isinstance(n, int) or n != len(kps) or n != len(scores):
return _fail(f"inconsistent counts: num_keypoints={n} keypoints={len(kps)} scores={len(scores)}")
if n < args.min_keypoints or n > args.max_keypoints:
return _fail(f"num_keypoints={n} outside [{args.min_keypoints}, {args.max_keypoints}]")
osz = resp.get("original_size") or {}
W, H = osz.get("width"), osz.get("height")
if not W or not H:
return _fail(f"original_size missing: {osz}")
for x, y in kps:
if not (0.0 <= x < W and 0.0 <= y < H):
return _fail(f"keypoint ({x}, {y}) outside the {W}x{H} image")
if min(scores) <= args.threshold or max(scores) > 1.0 + 1e-6:
return _fail(f"scores out of range: min={min(scores)} max={max(scores)} threshold={args.threshold}")
if "timing_ms" not in resp or "device_forward" not in resp["timing_ms"]:
return _fail("timing_ms.device_forward missing")
d = resp.get("descriptors")
if not d or d.get("format") != "npz" or d.get("shape") != [n, 256]:
return _fail(f"descriptors missing or wrong shape: {None if not d else d.get('shape')}")
try:
import numpy as np
npz = np.load(io.BytesIO(base64.b64decode(d["data"])))
desc = npz[d.get("key", "descriptors")]
if desc.shape != (n, 256) or desc.dtype != np.float16:
return _fail(f"descriptor array is {desc.shape} {desc.dtype}, expected ({n}, 256) float16")
desc32 = desc.astype(np.float32)
if not np.isfinite(desc32).all():
return _fail("descriptors contain non-finite values")
norms = np.linalg.norm(desc32, axis=1)
if np.abs(1.0 - norms).max() > 0.05:
return _fail(f"descriptors not unit-norm: |1-norm| max {np.abs(1.0 - norms).max():.3f}")
norm_dev = float(np.abs(1.0 - norms).max())
except ImportError:
norm_dev = float("nan") # numpy unavailable: shape/format checks above still ran
# Second call: descriptors off + small top-k must be honoured.
st2, resp2 = _post(f"{base}/predict", {**payload, "return_descriptors": False, "max_keypoints": 100}, timeout=args.timeout)
if st2 != 200:
return _fail(f"second /predict HTTP {st2}: {str(resp2)[:200]}")
if resp2.get("num_keypoints") != 100 or "descriptors" in resp2:
return _fail(f"top-k/return_descriptors not honoured: n={resp2.get('num_keypoints')} desc={'descriptors' in resp2}")
binary = "skipped"
if not args.no_binary_routes:
from urllib.parse import urlencode
q = {k: v for k, v in payload.items() if k != "image"}
q["return_descriptors"] = "true"
ref = {k: v for k, v in resp.items() if k != "timing_ms"}
st3, resp3 = _post_bytes(f"{base}/predict_raw?{urlencode(q)}", raw, timeout=args.timeout)
if st3 != 200 or {k: v for k, v in resp3.items() if k != "timing_ms"} != ref:
return _fail(f"/predict_raw HTTP {st3}, response differs from /predict: {str(resp3)[:200]}")
binary = "raw=ok"
try:
from PIL import Image
except ImportError:
binary += " plane=skipped(no Pillow)"
else:
im = Image.open(io.BytesIO(raw))
im = im if im.mode == "RGB" else im.convert("RGB")
plane = im.getchannel(0).tobytes()
st4, resp4 = _post_bytes(f"{base}/predict_plane?{urlencode({**q, 'height': im.height, 'width': im.width})}",
plane, timeout=args.timeout)
if st4 != 200 or {k: v for k, v in resp4.items() if k != "timing_ms"} != ref:
return _fail(f"/predict_plane HTTP {st4}, response differs from /predict: {str(resp4)[:200]}")
binary += " plane=ok"
tm = resp["timing_ms"]
print(
f"PASS superpoint-p150: {n} keypoints on {args.image.name} ({W}x{H}), "
f"score max={max(scores):.4f} min={min(scores):.4f}, desc (N,256) f16 |1-norm|max={norm_dev:.3f}, "
f"device_forward={tm.get('device_forward')} ms postprocess={tm.get('postprocess')} ms "
f"server_total={tm.get('total')} ms wall={wall_ms:.0f} ms, binary routes {binary}, weights={weights_repo}"
)
return 0
if __name__ == "__main__":
sys.exit(main())
|