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())