Download code/tt_sam3/tests/test_api_host.py from changh95/sam3-p150: direct link, hf CLI and curl.
- Browser
- Download file 14.8 kB
-
https://huggingface.co/changh95/sam3-p150/resolve/main/code/tt_sam3/tests/test_api_host.py
- Command line
-
hf download hf://changh95/sam3-p150/code/tt_sam3/tests/test_api_host.py
-
curl -L -o test_api_host.py https://huggingface.co/changh95/sam3-p150/resolve/main/code/tt_sam3/tests/test_api_host.py
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) | |
| 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 | |
| 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} | |