File size: 12,074 Bytes
d18e0a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
# SPDX-License-Identifier: Apache-2.0
"""Host tests (no device) for the serving layer: the image-result cache (SAM3_IMG_CACHE) key / hit / invalidation
logic in the device runner and in server/app.py, and the HTTP routes with a fake pipeline (FastAPI TestClient).

    TT_VISIBLE_DEVICES=none python -m pytest -q code/tt_sam3/tests/test_server_host.py
"""
import base64
import io
import os
import sys
import types

import numpy as np
import pytest
import torch
from PIL import Image

sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))  # <repo>/code

from tt_sam3.server import app as A  # noqa: E402
from tt_sam3.tt import ttnn_sam3_device_graph as dg  # noqa: E402

xxhash = pytest.importorskip("xxhash")


# ------------------------------------------------------------------------------------------------
# device runner: _run_image key logic (fake ttnn, fake upload)
# ------------------------------------------------------------------------------------------------
def _fake_runner(monkeypatch):
    log = []
    fake_ttnn = types.SimpleNamespace(
        execute_trace=lambda dev, tid, cq_id=0, blocking=False: log.append(("replay", tid)),
        release_trace=lambda dev, tid: log.append(("release", tid)),
    )
    monkeypatch.setattr(dg, "ttnn", fake_ttnn)
    monkeypatch.setattr(dg, "_BANDED_INPUT", True)
    r = object.__new__(dg.TtSam3FullDevice)
    r.device = None
    r.stats = {}
    r.img_key = None
    r.trace_img, r.trace_id = "IMG", "PROMPT"
    r.hx_bands = ["band"]
    r.g = types.SimpleNamespace(_dec0=None)
    r._upload_bands = lambda px: log.append(("upload", px))
    return r, log


def test_run_image_hit_skips_upload_and_trace(monkeypatch):
    r, log = _fake_runner(monkeypatch)
    r._run_image("px1", ("enc", 1, b"a"))
    assert log == [("upload", "px1"), ("replay", "IMG")]
    r._run_image("px1", ("enc", 1, b"a"))  # same key: nothing on the device
    assert len(log) == 2 and r.stats["image_cache_hits"] == 1
    r._run_image("px2", ("enc", 1, b"b"))  # different image: full path, the key moves
    assert log[2:] == [("upload", "px2"), ("replay", "IMG")]
    r._run_image("px1", ("enc", 1, b"a"))  # one entry only: the first image is no longer resident
    assert log[4:] == [("upload", "px1"), ("replay", "IMG")]
    assert r.stats["image_cache_hits"] == 1 and r.stats["image_replays"] == 3


def test_run_image_no_key_always_runs_and_clears(monkeypatch):
    r, log = _fake_runner(monkeypatch)
    r._run_image("px1", ("enc", 1, b"a"))
    r._run_image("px1")  # forward_image / __call__ path: no key -> runs, and the resident image is now unkeyed
    assert r.img_key is None
    r._run_image("px1", ("enc", 1, b"a"))
    assert [e for e in log if e[0] == "replay"] == [("replay", "IMG")] * 3
    assert "image_cache_hits" not in r.stats


def test_run_image_failed_upload_leaves_no_key(monkeypatch):
    r, log = _fake_runner(monkeypatch)
    r._run_image("px1", ("enc", 1, b"a"))

    def boom(px):
        raise RuntimeError("upload failed")

    r._upload_bands = boom
    with pytest.raises(RuntimeError):
        r._run_image("px2", ("enc", 1, b"b"))
    assert r.img_key is None  # the resident outputs may be half rewritten: no hit for either image
    r._upload_bands = lambda px: log.append(("upload", px))
    r._run_image("px1", ("enc", 1, b"a"))
    assert log[-1] == ("replay", "IMG") and "image_cache_hits" not in r.stats


def test_release_clears_key(monkeypatch):
    r, log = _fake_runner(monkeypatch)
    r._run_image("px1", ("enc", 1, b"a"))
    r.release()
    assert r.img_key is None and r.trace_img is None


# ------------------------------------------------------------------------------------------------
# app: routes with a fake pipeline (deterministic outputs from the pixels it receives)
# ------------------------------------------------------------------------------------------------
class _FakeRunner:
    def __init__(self):
        self.stats = {}
        self.img_key = None


class _FakePipeline:
    """forward_rgb_selected_multi emulates the device runner's cache contract: a hit (same key) must return what the
    resident image gives, so a wrong hit (stale pixels) shows up as a wrong answer."""

    def __init__(self):
        self._full = _FakeRunner()
        self.calls = []
        self.resident = None
        self.fail = False

    def prefetch_text(self, prompts):
        pass

    def forward_rgb_selected_multi(self, rgb, prompts, threshold, max_instances, image_key=None):
        if self.fail:
            raise RuntimeError("device error")
        if image_key is None or image_key != self._full.img_key:
            self.resident = np.ascontiguousarray(rgb).astype(np.float64)
            self._full.img_key = image_key
            hit = False
        else:
            self._full.stats["image_cache_hits"] = self._full.stats.get("image_cache_hits", 0) + 1
            hit = True
        self.calls.append((image_key, hit))
        out = []
        for k, p in enumerate(prompts):
            m = float(self.resident.mean()) / 255.0
            logits = torch.full((1, A.MASK_RES, A.MASK_RES), -1.0)
            logits[0, 10:40 + int(50 * m), 20:60] = 1.0 + k
            out.append((torch.tensor([0.5 + 0.4 * m]), torch.tensor([[0.5, 0.5, 0.2 + 0.1 * m, 0.3]]), logits, 0.9))
        return out


@pytest.fixture
def client(monkeypatch):
    from fastapi.testclient import TestClient

    pipe = _FakePipeline()
    cfg = types.SimpleNamespace(max_prompts=4, max_side=4096)
    monkeypatch.setattr(A, "STATE", {"pipeline": pipe, "config": cfg, "rgb_input": True})
    monkeypatch.setattr(A, "_IMG_CACHE", True)
    A._LAST_DECODED[:] = [None, None]
    yield TestClient(A.app), pipe
    A._LAST_DECODED[:] = [None, None]


def _png(arr):
    b = io.BytesIO()
    Image.fromarray(arr).save(b, format="PNG")
    return b.getvalue()


def _img(seed, h=97, w=131):
    return np.random.default_rng(seed).integers(0, 256, size=(h, w, 3), dtype=np.uint8)


def _post_json(c, png, prompts=("car",)):
    r = c.post("/predict", json={"image": base64.b64encode(png).decode(), "prompts": list(prompts)})
    assert r.status_code == 200, r.text
    return r.json()


def test_predict_cache_hit_same_results(client):
    c, pipe = client
    p1, p2 = _png(_img(1)), _png(_img(2))
    a = _post_json(c, p1)
    b = _post_json(c, p1, ("tree",))  # same image, other prompt: hit
    assert pipe.calls[0][1] is False and pipe.calls[1][1] is True and pipe.calls[0][0] == pipe.calls[1][0]
    assert a["results"][0]["instances"] == b["results"][0]["instances"]
    _post_json(c, p2)  # different image: miss
    assert pipe.calls[2][1] is False and pipe.calls[2][0] != pipe.calls[0][0]
    a2 = _post_json(c, p1)  # back to the first image: miss again (one entry), same answer
    assert pipe.calls[3][1] is False and a2["results"] == a["results"]


def test_predict_cache_off(client, monkeypatch):
    c, pipe = client
    monkeypatch.setattr(A, "_IMG_CACHE", False)
    p1 = _png(_img(1))
    a, b = _post_json(c, p1), _post_json(c, p1)
    assert [k for k, _ in pipe.calls] == [None, None] and a["results"] == b["results"]


def test_predict_failure_invalidates(client):
    c, pipe = client
    p1 = _png(_img(1))
    _post_json(c, p1)
    assert pipe._full.img_key is not None and A._LAST_DECODED[0] is not None
    pipe.fail = True
    r = c.post("/predict", json={"image": base64.b64encode(p1).decode(), "prompts": ["car"]})
    assert r.status_code == 500
    assert pipe._full.img_key is None and A._LAST_DECODED == [None, None]
    pipe.fail = False
    _post_json(c, p1)
    assert pipe.calls[-1][1] is False


def test_predict_response_schema_unchanged(client):
    c, _ = client
    j = _post_json(c, _png(_img(3)), ("car", "tree"))
    assert set(j) == {"width", "height", "input_size", "threshold", "mask_format", "results", "timing_ms"}
    assert set(j["timing_ms"]) == {"decode", "preprocess", "forward", "forward_per_prompt", "postprocess", "total"}
    assert [r["prompt"] for r in j["results"]] == ["car", "tree"]
    assert set(j["results"][0]["instances"][0]) == {"score", "box_xyxy", "mask_area", "mask_png"}
    r = c.post("/predict", json={"image": "!!!", "prompts": ["car"]})
    assert r.status_code == 400
    r = c.post("/predict", json={"image": base64.b64encode(_png(_img(3))).decode(), "prompts": [" "]})
    assert r.status_code == 400


# ------------------------------------------------------------------------------------------------
# /predict_raw (binary body)
# ------------------------------------------------------------------------------------------------
def test_predict_raw_png_and_rgb_match_predict(client):
    c, pipe = client
    arr = _img(5)
    png = _png(arr)
    j = _post_json(c, png, ("car", "tree"))
    r = c.post("/predict_raw", params=[("prompts", "car"), ("prompts", "tree")], content=png,
               headers={"Content-Type": "image/png"})
    assert r.status_code == 200, r.text
    jr = r.json()
    assert jr["results"] == j["results"] and set(jr) == set(j)
    assert pipe.calls[-1][1] is True  # same PNG bytes as the JSON request: same key, cache hit
    h, w = arr.shape[:2]
    r = c.post("/predict_raw", params={"prompts": ["car", "tree"]}, content=arr.tobytes(),
               headers={"Content-Type": "application/x-rgb24", "X-Width": str(w), "X-Height": str(h)})
    assert r.status_code == 200, r.text
    assert r.json()["results"] == j["results"] and (r.json()["width"], r.json()["height"]) == (w, h)
    assert pipe.calls[-1][1] is False and pipe.calls[-1][0][0] == f"rgb{w}x{h}"  # raw pixels never alias encoded bytes


def test_predict_raw_rgb_key_includes_shape(client):
    c, pipe = client
    arr = _img(6, 12, 12)
    body = arr.tobytes()
    for w, h in ((12, 12), (24, 6)):  # same bytes, different shape -> different image, no hit
        r = c.post("/predict_raw", params={"prompts": "car"}, content=body,
                   headers={"Content-Type": "application/x-rgb24", "X-Width": str(w), "X-Height": str(h)})
        assert r.status_code == 200, r.text
    assert [hit for _, hit in pipe.calls] == [False, False]


def test_predict_raw_errors(client):
    c, _ = client
    arr = _img(7)
    h, w = arr.shape[:2]
    hdr = {"Content-Type": "application/x-rgb24", "X-Width": str(w), "X-Height": str(h)}
    assert c.post("/predict_raw", params={"prompts": "car"}, content=arr.tobytes()[:-1], headers=hdr).status_code == 400
    assert c.post("/predict_raw", params={"prompts": "car"}, content=arr.tobytes(),
                  headers={"Content-Type": "application/x-rgb24"}).status_code == 400
    assert c.post("/predict_raw", params={"prompts": "car"}, content=b"x" * 30,
                  headers={**hdr, "X-Width": "5000", "X-Height": "2"}).status_code == 400
    assert c.post("/predict_raw", params={"prompts": "car"}, content=b"not an image",
                  headers={"Content-Type": "image/png"}).status_code == 400
    assert c.post("/predict_raw", params={"prompts": "car"}, content=b"", headers={"Content-Type": "image/png"}).status_code == 400
    assert c.post("/predict_raw", content=_png(arr), headers={"Content-Type": "image/png"}).status_code == 422
    assert c.post("/predict_raw", params={"prompts": " "}, content=_png(arr), headers={"Content-Type": "image/png"}).status_code == 400
    assert c.post("/predict_raw", params={"prompts": ["a"] * 5}, content=_png(arr),
                  headers={"Content-Type": "image/png"}).status_code == 400  # > max_prompts (4)
    assert c.post("/predict_raw", params={"prompts": "car", "threshold": 2}, content=_png(arr),
                  headers={"Content-Type": "image/png"}).status_code == 422


def test_predict_raw_not_ready(monkeypatch):
    from fastapi.testclient import TestClient

    monkeypatch.setattr(A, "STATE", {})
    c = TestClient(A.app)
    assert c.post("/predict_raw", params={"prompts": "car"}, content=b"x", headers={"Content-Type": "image/png"}).status_code == 503
    assert c.post("/predict", json={"image": "eA==", "prompts": ["car"]}).status_code == 503