sam3-p150 / code /tt_sam3 /tests /test_api_host.py
changh95's picture
p150 ETH-dispatch compliance (2026-10-05): default ETH dispatch, 1 CQ, 12x10 in Python API and server; numbers re-measured
3eb7396 verified
Raw History Blame Contribute Delete
14.8 kB
# SPDX-License-Identifier: Apache-2.0
"""Host tests (no device, no weights) for the Python API ``tt_sam3.Sam3``: import surface, image input conversion,
argument checks, result format, the image cache, lifetime, and equality with the HTTP server's results for the same
pipeline outputs (fake pipeline).
TT_VISIBLE_DEVICES=none python -m pytest -q code/tt_sam3/tests/test_api_host.py
"""
import base64
import io
import os
import subprocess
import sys
import types
import numpy as np
import pytest
import torch
from PIL import Image
import tt_sam3
from tt_sam3 import api
MODEL_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
DEMO = os.path.join(MODEL_DIR, "media", "source_1.png")
# ------------------------------------------------------------------------------------------------
# import surface
# ------------------------------------------------------------------------------------------------
def test_import_is_light_and_exports():
code = ("import sys, tt_sam3; assert 'torch' not in sys.modules and 'ttnn' not in sys.modules; "
"from tt_sam3 import Sam3, Sam3Result, load_image, device_open_kwargs, PINNED_REVISION; "
"assert 'ttnn' not in sys.modules, 'ttnn imported at API import'; print(tt_sam3.__version__)")
env = {**os.environ, "TT_VISIBLE_DEVICES": "none"}
out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, env=env, timeout=300)
assert out.returncode == 0, out.stderr[-2000:]
assert out.stdout.strip() == tt_sam3.__version__
for name in ("from_pretrained", "__call__", "predict_many", "close", "__enter__", "__exit__"):
assert callable(getattr(api.Sam3, name))
assert api.Sam3.from_pretrained.__doc__ and api.Sam3.__call__.__doc__ and api.Sam3Result.__doc__
def test_no_machine_specific_paths_in_runtime_code():
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
bad = []
for d, _, files in os.walk(root):
if os.sep + "tests" in d or "__pycache__" in d:
continue
for f in files:
if f.endswith(".py"):
text = open(os.path.join(d, f), encoding="utf-8").read()
if "/home/" in text or "deepgadget" in text:
bad.append(os.path.join(d, f))
assert not bad, bad
def test_package_data_declared():
text = open(os.path.join(MODEL_DIR, "code", "pyproject.toml")).read()
assert '"assets/*.gz"' in text and '"kernels/*.cpp"' in text
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
assert os.path.isfile(os.path.join(root, "assets", "bpe_simple_vocab_16e6.txt.gz"))
# ------------------------------------------------------------------------------------------------
# image input
# ------------------------------------------------------------------------------------------------
def _img(seed, h=37, w=53, c=3):
return np.random.default_rng(seed).integers(0, 256, size=(h, w, c), dtype=np.uint8)
def _png(arr):
b = io.BytesIO()
Image.fromarray(arr).save(b, format="PNG")
return b.getvalue()
def test_load_image_kinds(tmp_path):
a = _img(0)
p = tmp_path / "x.png"
p.write_bytes(_png(a))
for src in (str(p), p, p.read_bytes(), Image.fromarray(a), Image.fromarray(np.dstack([a, a[..., :1]])), a,
torch.from_numpy(a).permute(2, 0, 1), torch.from_numpy(a).permute(2, 0, 1)[None],
np.moveaxis(a, -1, 0), a.astype(np.float32) / 255.0):
rgb = api.load_image(src)
assert rgb.dtype == np.uint8 and rgb.shape == (37, 53, 3)
assert np.array_equal(rgb, a), type(src)
g = a[..., 0]
assert np.array_equal(api.load_image(g), np.dstack([g, g, g]))
assert np.array_equal(api.load_image(Image.fromarray(g)), np.dstack([g, g, g]))
assert np.array_equal(api.load_image(a.astype(np.float64)), a) # 0..255 floats
with pytest.raises(TypeError):
api.load_image(123)
with pytest.raises(ValueError):
api.load_image(np.zeros((2, 2, 2, 2)))
def test_load_image_float_matches_upstream_todtype():
from torchvision.transforms import v2
x = torch.rand(3, 19, 23)
ref = v2.ToDtype(torch.uint8, scale=True)(x).permute(1, 2, 0).numpy()
assert np.array_equal(api.load_image(x), ref)
@pytest.mark.skipif(not os.path.isfile(DEMO), reason="demo image missing")
def test_demo_decode_equals_server_decode():
from tt_sam3.server import app as A
raw = open(DEMO, "rb").read()
assert np.array_equal(api.load_image(DEMO), A._decode_bytes(raw).rgb)
assert np.array_equal(api.load_image(Image.open(DEMO)), A._decode_bytes(raw).rgb)
def test_prompt_checks():
assert api._check_prompts("car") == (["car"], True)
assert api._check_prompts([" car ", "tree"]) == (["car", "tree"], False)
for bad in ([], [""], [" "], [1]):
with pytest.raises(ValueError):
api._check_prompts(bad)
def test_boxes_match_server_rounding():
from tt_sam3.server import app as A
b = torch.tensor([[0.5, 0.5, 0.2, 0.3], [0.01, 0.99, 0.5, 0.5], [0.3, 0.2, 0.1, 0.05]])
ours = api.boxes_cxcywh_to_xyxy_pixels(b, 1920, 1080)
assert ours.dtype == np.float32 and ours.shape == (3, 4)
assert [[round(float(v), 2) for v in r] for r in ours] == A._boxes_to_xyxy_pixels(b, 1920, 1080)
assert api.boxes_cxcywh_to_xyxy_pixels(torch.zeros(0, 4), 10, 10).shape == (0, 4)
# ------------------------------------------------------------------------------------------------
# fake pipeline: the API against the server on the same pipeline outputs
# ------------------------------------------------------------------------------------------------
class _FakeRunner:
def __init__(self):
self.stats = {}
self.img_key = None
class _FakePipeline:
"""Deterministic outputs from the pixels it receives; emulates the device runner's image-cache contract (a wrong
hit would return the resident image's answer)."""
def __init__(self):
self._full = _FakeRunner()
self.calls = []
self.resident = None
self.fail = False
self.released = 0
def prefetch_text(self, prompts):
pass
def release(self):
self.released += 1
def forward_rgb_selected_multi(self, rgb, prompts, threshold, max_instances, image_key=None):
if self.fail:
raise RuntimeError("device error")
hit = image_key is not None and image_key == self._full.img_key
if not hit:
self.resident = np.ascontiguousarray(rgb).astype(np.float64)
self._full.img_key = image_key
self.calls.append((image_key, hit))
out = []
for k, _p in enumerate(prompts):
m = float(self.resident.mean()) / 255.0
logits = torch.randn(3, api.MASK_RES, api.MASK_RES, generator=torch.Generator().manual_seed(k + int(m * 1e6)))
logits[0, 10:40 + int(50 * m), 20:60] += 3.0
scores = torch.tensor([0.5 + 0.4 * m, 0.45, 0.3])
boxes = torch.tensor([[0.5, 0.5, 0.2 + 0.1 * m, 0.3], [0.1, 0.1, 0.3, 0.3], [0.9, 0.9, 0.5, 0.5]])
keep = scores > threshold
out.append((scores[keep][:max_instances], boxes[keep][:max_instances], logits[keep][:max_instances], 0.9))
return out
def _model(cache=True, fast_mask=True):
pytest.importorskip("xxhash")
pipe = _FakePipeline()
m = api.Sam3(pipe, device=None, owns_device=False, info={"rgb_input": True, "fast_mask": fast_mask}, cache_image=cache)
return m, pipe
@pytest.mark.parametrize("fast_mask", [True, False])
def test_results_equal_server(monkeypatch, fast_mask):
pytest.importorskip("fastapi")
from fastapi.testclient import TestClient
from tt_sam3.server import app as A
model, pipe = _model(fast_mask=fast_mask)
arr = _img(3, 97, 131)
png = _png(arr)
res = model(png, ["car", "tree"], threshold=0.4)
assert isinstance(res, list) and [r.prompt for r in res] == ["car", "tree"]
monkeypatch.setattr(A, "STATE", {"pipeline": pipe, "config": types.SimpleNamespace(max_prompts=4, max_side=4096),
"rgb_input": True})
j = TestClient(A.app).post("/predict", json={"image": base64.b64encode(png).decode(), "prompts": ["car", "tree"],
"threshold": 0.4}).json()
for r, jr in zip(res, j["results"]):
assert r.prompt == jr["prompt"] and round(r.presence, 4) == jr["presence"] and len(r) == jr["num_instances"]
assert r.masks.shape == (len(r), 97, 131) and r.masks.dtype == bool
assert r.boxes.shape == (len(r), 4) and r.scores.shape == (len(r),)
for i, inst in enumerate(jr["instances"]):
assert round(float(r.scores[i]), 4) == inst["score"]
assert [round(float(v), 2) for v in r.boxes[i]] == inst["box_xyxy"]
png_mask = np.asarray(Image.open(io.BytesIO(base64.b64decode(inst["mask_png"])))) > 0
assert np.array_equal(r.masks[i], png_mask) and int(r.masks[i].sum()) == inst["mask_area"]
def test_single_prompt_and_result_access():
model, _ = _model()
r = model(_img(1), "car")
assert isinstance(r, api.Sam3Result)
assert r["masks"] is r.masks and set(r.keys()) >= {"masks", "boxes", "scores"}
assert (r.width, r.height) == (53, 37) and r.mask_logits is None
d = r.to_dict()
assert d["num_instances"] == len(r) and len(d["mask_areas"]) == len(r)
assert "Sam3Result(prompt='car'" in repr(r)
r2 = model(_img(1), "car", return_masks=False, return_mask_logits=True, max_instances=1, threshold=0.0)
assert r2.masks is None and r2.mask_logits.shape == (1, 288, 288) and len(r2) == 1
with pytest.raises(ValueError):
model(_img(1), "car", threshold=1.5)
with pytest.raises(ValueError):
model(_img(1), "car", max_instances=0)
with pytest.raises(ValueError):
model(_img(1), [])
def test_image_cache_keys(tmp_path):
model, pipe = _model()
a, b = _img(1), _img(2)
r1 = model(a, "car")
r2 = model(a.copy(), "tree") # same pixels, other array object: hit
assert pipe.calls[0][1] is False and pipe.calls[1][1] is True
a2 = a.copy()
a2[0, 0, 0] ^= 1 # one pixel changed: miss
model(a2, "car")
assert pipe.calls[2][1] is False
model(b, "car")
assert pipe.calls[3][1] is False
p = tmp_path / "a.png"
p.write_bytes(_png(a))
model(str(p), "car")
model(str(p), "car")
assert pipe.calls[-1][1] is True and pipe.calls[-1][0][0] == "enc"
assert np.array_equal(r1.masks, model(a, "car").masks) and len(r2) >= 0
def test_image_cache_off():
model, pipe = _model(cache=False)
a = _img(1)
model(a, "car")
model(a, "car")
assert [k for k, _ in pipe.calls] == [None, None]
def test_failure_invalidates_cache():
model, pipe = _model()
a = _img(1)
model(a, "car")
assert pipe._full.img_key is not None
pipe.fail = True
with pytest.raises(RuntimeError):
model(a, "car")
assert pipe._full.img_key is None
pipe.fail = False
model(a, "car")
assert pipe.calls[-1][1] is False
def test_predict_many_equals_loop(tmp_path):
model, _ = _model(cache=False)
imgs = [_img(i, 40 + i, 50) for i in range(4)]
paths = []
for i, a in enumerate(imgs):
p = tmp_path / f"{i}.png"
p.write_bytes(_png(a))
paths.append(str(p))
many = model.predict_many(paths, ["car", "tree"])
loop = [model(a, ["car", "tree"]) for a in imgs]
assert len(many) == 4
for rs, ls in zip(many, loop):
for r, l in zip(rs, ls):
assert np.array_equal(r.masks, l.masks) and np.array_equal(r.scores, l.scores) and np.array_equal(r.boxes, l.boxes)
single = model.predict_many(imgs[:2], "car")
assert all(isinstance(r, api.Sam3Result) for r in single)
assert model.predict_many([], "car") == []
def test_close_and_context_manager():
model, pipe = _model()
with model as m:
assert m is model and not m.closed
m(_img(1), "car")
assert model.closed and pipe.released == 1 and model.pipeline is None
model.close() # idempotent
assert pipe.released == 1
with pytest.raises(RuntimeError):
model(_img(1), "car")
def test_dispatch_resolution(monkeypatch, tmp_path):
with pytest.raises(ValueError):
api._resolve_dispatch("tensix")
assert api._resolve_dispatch("worker") == "worker" and api._resolve_dispatch("eth") == "eth"
d = tmp_path / "tt_metal" / "impl" / "dispatch"
d.mkdir(parents=True)
(d / "topology.cpp").write_text("static const std::vector<X> single_chip_arch_1cq_no_dispatch_s = {};")
monkeypatch.setenv("TT_METAL_HOME", str(tmp_path))
assert api.eth_dispatch_available() and api._resolve_dispatch("auto") == "eth"
(d / "topology.cpp").write_text("stock tree")
with pytest.warns(RuntimeWarning, match="ETH-dispatch patch"):
assert api._resolve_dispatch("auto") == "worker"
assert "dispatch_core_config" not in api.device_open_kwargs(0, "worker")
kw = api.device_open_kwargs(3, "worker")
assert kw["device_id"] == 3 and kw.get("trace_region_size", 0) > 0 and kw.get("l1_small_size", 0) >= 32768
def test_checkpoint_resolution(tmp_path, monkeypatch):
monkeypatch.delenv("SAM3_WEIGHTS_DIR", raising=False)
(tmp_path / "sam3.pt").write_bytes(b"x")
assert api._resolve_checkpoint("facebook/sam3", None, str(tmp_path)) == str(tmp_path / "sam3.pt")
assert api._resolve_checkpoint("facebook/sam3", None, str(tmp_path / "sam3.pt")) == str(tmp_path / "sam3.pt")
monkeypatch.setenv("SAM3_WEIGHTS_DIR", str(tmp_path))
assert api._resolve_checkpoint("facebook/sam3", "abc", None) == str(tmp_path / "sam3.pt")
with pytest.raises(FileNotFoundError):
api._resolve_checkpoint("facebook/sam3", None, str(tmp_path / "missing.pt"))
def test_server_and_api_share_the_eth_default(monkeypatch, tmp_path):
"""The server (SAM3_DISPATCH, default auto) and the Python API resolve the dispatch through one module:
with the patched tree, auto = ETH dispatch + 1 CQ."""
from tt_sam3.server import app as appmod
from tt_sam3.tt import device_open
d = tmp_path / "tt_metal" / "impl" / "dispatch"
d.mkdir(parents=True)
(d / "topology.cpp").write_text("single_chip_arch_1cq_no_dispatch_s")
monkeypatch.setenv("TT_METAL_HOME", str(tmp_path))
monkeypatch.delenv("SAM3_DISPATCH", raising=False)
assert appmod.read_config().dispatch == "auto"
assert device_open.resolve_dispatch(appmod.read_config().dispatch) == "eth"
monkeypatch.setenv("SAM3_DISPATCH", "worker")
assert device_open.resolve_dispatch(appmod.read_config().dispatch) == "worker"
assert device_open.add_dispatch_kwargs({"device_id": 0}, "worker") == {"device_id": 0}