Skip to content

Commit ed0901f

Browse files
authored
fix(api): make REST segmentation API robust for SAM3 (#535)
* fix(api): make REST segmentation API robust for SAM3 Validated the REST API (samgeo/api.py) end-to-end against real example imagery and fixed several bugs found in the process: - Model cache keyed only on (model_version, model_id), so an automatic mask-generation instance was reused for a prompt-predict request and crashed with "'SamGeo2' object has no attribute 'predictor'". The cache key now includes the automatic flag; get_model returns the key so the image-hash cache stays consistent. - SAM3 mutates its encoded-image state during generate_*, so the image-hash optimization (skipping set_image on repeat requests) made the 2nd+ SAM3 request fail with "expected scalar type BFloat16 but found Float". _set_image_cached now always re-encodes for SAM3 (still caches SAM/SAM2). - raster_to_vector crashed on an empty mask (no geometry column). It now emits a valid empty FeatureCollection. - /segment/automatic gained the empty-masks guard the other endpoints have (404 instead of a 500 "No masks found"). - /segment/automatic now defaults to SAM3. Verified on docs/examples/tree_image.tif: text/box/point/detections all return GeoJSON across consecutive requests. Added regression tests. * fix(api): include model-shaping kwargs in the model cache key Address CodeRabbit review on #535: the cache key only considered (model_version, model_id, automatic), so two requests differing only by backend/confidence_threshold (SAM3) or points_per_side/pred_iou_thresh/ stability_score_thresh (SAM/SAM2) would reuse the first, differently configured instance and silently run with the wrong settings. The key now folds in all constructor kwargs via a small order-independent _freeze_kwargs() helper. Added regression tests covering distinct configs (SAM3 confidence_threshold, SAM2 points_per_side) and the freezing helper.
1 parent cdaab8b commit ed0901f

4 files changed

Lines changed: 227 additions & 29 deletions

File tree

samgeo/api.py

Lines changed: 66 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,30 @@ def _normalize_max_size(max_size: Optional[int]) -> Optional[int]:
103103
}
104104

105105

106+
def _freeze_kwargs(kwargs: dict):
107+
"""Return a hashable, order-independent representation of model kwargs.
108+
109+
Used to build the model cache key. Nested dicts (e.g. ``sam_kwargs``) become
110+
sorted tuples and lists become tuples so two calls with the same
111+
configuration map to the same key regardless of argument order.
112+
113+
Args:
114+
kwargs: The keyword arguments passed to a model constructor.
115+
116+
Returns:
117+
A hashable value derived from ``kwargs``.
118+
"""
119+
120+
def freeze(value):
121+
if isinstance(value, dict):
122+
return tuple(sorted((k, freeze(v)) for k, v in value.items()))
123+
if isinstance(value, (list, tuple)):
124+
return tuple(freeze(v) for v in value)
125+
return value
126+
127+
return freeze(kwargs)
128+
129+
106130
def get_model(model_version: str, model_id: Optional[str] = None, **kwargs):
107131
"""Get or create a cached model instance.
108132
@@ -112,7 +136,12 @@ def get_model(model_version: str, model_id: Optional[str] = None, **kwargs):
112136
**kwargs: Additional keyword arguments for model initialization.
113137
114138
Returns:
115-
tuple: (model_instance, threading.Lock)
139+
tuple: (model_instance, threading.Lock, cache_key). The cache key
140+
includes the model-shaping kwargs (e.g. ``automatic``, ``backend``,
141+
``confidence_threshold``, ``points_per_side``) so that instances
142+
built with different configurations -- including automatic
143+
mask-generation vs. prompt prediction -- are cached separately and a
144+
request never silently reuses a differently-configured instance.
116145
117146
Raises:
118147
HTTPException: If model_version or model_id is invalid, or
@@ -140,11 +169,20 @@ def get_model(model_version: str, model_id: Optional[str] = None, **kwargs):
140169
),
141170
)
142171

143-
key = (model_version, model_id)
172+
# Include the model-shaping kwargs in the cache key. The same model class is
173+
# built differently for automatic mask generation (a mask generator, via the
174+
# default automatic=True) vs. prompt prediction (an image predictor, via
175+
# automatic=False), and SAM3 takes per-request knobs like ``backend`` and
176+
# ``confidence_threshold``. Keying on these keeps configurations from
177+
# colliding -- which previously reused an automatic instance for a predict
178+
# request ("no attribute 'predictor'") and would also silently reuse a
179+
# stale threshold/points_per_side from an earlier request.
180+
key = (model_version, model_id, _freeze_kwargs(kwargs))
144181
with _model_cache_lock:
145182
if key in _model_cache:
146183
logger.info("Model cache hit for %s", key)
147-
return _model_cache[key]
184+
model, lock = _model_cache[key]
185+
return model, lock, key
148186

149187
logger.info("Loading model %s", key)
150188
extra = _EXTRAS_MAP.get(model_version, model_version)
@@ -172,7 +210,8 @@ def get_model(model_version: str, model_id: Optional[str] = None, **kwargs):
172210
),
173211
)
174212
_model_cache[key] = (model, threading.Lock())
175-
return _model_cache[key]
213+
model, lock = _model_cache[key]
214+
return model, lock, key
176215

177216

178217
def _set_image_cached(
@@ -183,16 +222,23 @@ def _set_image_cached(
183222
Compares the provided hash to the last image encoded on this model.
184223
Skips the expensive image-encoder forward pass when the hash matches.
185224
225+
SAM3 is exempt from the skip: its ``generate_*`` calls mutate the encoded
226+
image state in place, so reusing it on a second request without re-encoding
227+
fails with an ``expected scalar type BFloat16 but found Float`` dtype
228+
mismatch. For SAM3 we always re-encode (correctness over the small speedup);
229+
SAM/SAM2 still benefit from the cache.
230+
186231
Args:
187232
model: The SAM model instance.
188-
model_key: Cache key for the model, e.g. ("sam3", "facebook/sam3").
233+
model_key: Cache key for the model, e.g. ("sam3", "facebook/sam3", True).
189234
image_path: Path to the saved image file.
190235
image_hash: SHA-256 hex digest of the uploaded file bytes.
191236
192237
Returns:
193238
True if set_image was called (new image), False if skipped (cache hit).
194239
"""
195-
if _image_hash_cache.get(model_key) == image_hash:
240+
is_sam3 = bool(model_key) and model_key[0] == "sam3"
241+
if not is_sam3 and _image_hash_cache.get(model_key) == image_hash:
196242
logger.info("Image cache hit for model %s, skipping set_image()", model_key)
197243
# Update source path so save_masks() can find GeoTIFF metadata
198244
# even though we skip the expensive encoding step.
@@ -545,7 +591,7 @@ def clear_models():
545591
@app.post("/segment/automatic")
546592
async def segment_automatic(
547593
file: UploadFile = File(...),
548-
model_version: str = Form("sam2"),
594+
model_version: str = Form("sam3"),
549595
model_id: Optional[str] = Form(None),
550596
output_format: str = Form("geojson"),
551597
foreground: bool = Form(True),
@@ -583,15 +629,20 @@ async def segment_automatic(
583629

584630
t_start = time.time()
585631
if model_version == "sam3":
586-
model, lock = get_model(model_version, model_id)
587-
model_key = (model_version, model_id or _DEFAULT_MODEL_IDS[model_version])
632+
model, lock, model_key = get_model(model_version, model_id)
588633
with lock:
589634
_set_image_cached(model, model_key, input_path, image_hash)
590635
model.generate_masks(
591636
prompt="everything",
592637
min_size=min_size,
593638
max_size=max_size,
594639
)
640+
if model.masks is None or len(model.masks) == 0:
641+
_cleanup_tmpdir(tmpdir)
642+
raise HTTPException(
643+
status_code=404,
644+
detail="No objects found for automatic segmentation.",
645+
)
595646
model.save_masks(output=output_path, unique=unique)
596647
else:
597648
sam_kwargs = {
@@ -600,11 +651,11 @@ async def segment_automatic(
600651
"stability_score_thresh": stability_score_thresh,
601652
}
602653
if model_version == "sam":
603-
model, lock = get_model(
654+
model, lock, _ = get_model(
604655
model_version, model_id, sam_kwargs=sam_kwargs
605656
)
606657
else:
607-
model, lock = get_model(model_version, model_id, **sam_kwargs)
658+
model, lock, _ = get_model(model_version, model_id, **sam_kwargs)
608659

609660
with lock:
610661
model.generate(
@@ -707,11 +758,7 @@ async def segment_predict(
707758
t_start = time.time()
708759

709760
if model_version == "sam3":
710-
model, lock = get_model(model_version, model_id)
711-
model_key = (
712-
model_version,
713-
model_id or _DEFAULT_MODEL_IDS[model_version],
714-
)
761+
model, lock, model_key = get_model(model_version, model_id)
715762
with lock:
716763
_set_image_cached(model, model_key, input_path, image_hash)
717764
if parsed_boxes is not None:
@@ -745,10 +792,8 @@ async def segment_predict(
745792
max_size=max_size,
746793
)
747794
else:
748-
model, lock = get_model(model_version, model_id, automatic=False)
749-
model_key = (
750-
model_version,
751-
model_id or _DEFAULT_MODEL_IDS[model_version],
795+
model, lock, model_key = get_model(
796+
model_version, model_id, automatic=False
752797
)
753798
with lock:
754799
_set_image_cached(model, model_key, input_path, image_hash)
@@ -818,14 +863,13 @@ async def segment_text(
818863
input_path, image_hash = await _save_upload(file, tmpdir)
819864
output_path = os.path.join(tmpdir, "mask.tif")
820865

821-
model, lock = get_model(
866+
model, lock, model_key = get_model(
822867
"sam3",
823868
model_id,
824869
backend=backend,
825870
confidence_threshold=confidence_threshold,
826871
)
827872
t_start = time.time()
828-
model_key = ("sam3", model_id or _DEFAULT_MODEL_IDS["sam3"])
829873
with lock:
830874
_set_image_cached(model, model_key, input_path, image_hash)
831875
model.generate_masks(

samgeo/common.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1444,7 +1444,14 @@ def raster_to_vector(source, output, simplify_tolerance=None, dst_crs=None, **kw
14441444
for i in fc:
14451445
i["geometry"] = i["geometry"].simplify(tolerance=simplify_tolerance)
14461446

1447-
gdf = gpd.GeoDataFrame.from_features(fc)
1447+
if fc:
1448+
gdf = gpd.GeoDataFrame.from_features(fc)
1449+
else:
1450+
# No foreground pixels in the mask. Build an empty GeoDataFrame that
1451+
# still carries a geometry column so set_crs/to_crs/to_file work and we
1452+
# emit a valid (empty) layer instead of raising "no active geometry
1453+
# column" from geopandas.
1454+
gdf = gpd.GeoDataFrame({"value": []}, geometry=[])
14481455
if src.crs is not None:
14491456
gdf.set_crs(crs=src.crs, inplace=True)
14501457

tests/test_api.py

Lines changed: 119 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -107,20 +107,41 @@ def test_predict_missing_prompts(sample_image_path):
107107
assert "point_coords or boxes" in response.json()["detail"]
108108

109109

110-
def test_predict_sam3_rejected(sample_image_path):
111-
"""Test that predict endpoint rejects SAM3."""
110+
@patch("samgeo.api.get_model")
111+
def test_predict_sam3_points_accepted(mock_get, sample_image_path):
112+
"""SAM3 point prompts are accepted by /segment/predict (added in #508).
113+
114+
SAM3 support was added to the predict endpoint, so a point prompt should
115+
be dispatched to ``predict_inst`` and produce a mask rather than being
116+
rejected. The model is mocked so the test stays fast and offline.
117+
"""
118+
from threading import Lock
119+
120+
mock_model = MagicMock()
121+
mock_model.masks = [np.ones((64, 64), dtype=np.uint8)]
122+
123+
def fake_save_masks(output, **kwargs):
124+
from PIL import Image
125+
126+
Image.fromarray(np.zeros((64, 64), dtype=np.uint8)).save(output)
127+
128+
mock_model.save_masks = fake_save_masks
129+
mock_get.return_value = (mock_model, Lock(), ("sam3", "facebook/sam3", True))
130+
112131
with open(sample_image_path, "rb") as f:
113132
response = client.post(
114133
"/segment/predict",
115134
files={"file": ("test.tif", f, "image/tiff")},
116135
data={
117136
"model_version": "sam3",
118-
"point_coords": "[[100, 200]]",
137+
"point_coords": "[[10, 20]]",
119138
"point_labels": "[1]",
139+
"output_format": "png",
120140
},
121141
)
122-
assert response.status_code == 400
123-
assert "SAM3" in response.json()["detail"]
142+
assert response.status_code == 200
143+
assert response.headers["content-type"] == "image/png"
144+
mock_model.predict_inst.assert_called_once()
124145

125146

126147
def test_invalid_output_format(sample_image_path):
@@ -149,7 +170,7 @@ def fake_generate(source, output, **kwargs):
149170
img.save(output)
150171

151172
mock_model.generate = fake_generate
152-
mock_get.return_value = (mock_model, Lock())
173+
mock_get.return_value = (mock_model, Lock(), ("sam2", "sam2-hiera-large", True))
153174

154175
with open(sample_image_path, "rb") as f:
155176
response = client.post(
@@ -159,3 +180,95 @@ def fake_generate(source, output, **kwargs):
159180
)
160181
assert response.status_code == 200
161182
assert response.headers["content-type"] == "image/png"
183+
184+
185+
def test_get_model_caches_automatic_and_predict_separately():
186+
"""Regression: automatic and predict instances must not share a cache slot.
187+
188+
A model built for automatic mask generation has no image ``predictor``;
189+
reusing it for a predict request crashed with
190+
``'SamGeo2' object has no attribute 'predictor'``. The cache key now
191+
includes the ``automatic`` flag so the two are distinct instances.
192+
"""
193+
import samgeo.api as api
194+
195+
with patch("samgeo.samgeo2.SamGeo2") as mock_cls:
196+
mock_cls.side_effect = lambda **kw: MagicMock()
197+
auto_model, _, auto_key = api.get_model("sam2", "sam2-hiera-tiny")
198+
pred_model, _, pred_key = api.get_model(
199+
"sam2", "sam2-hiera-tiny", automatic=False
200+
)
201+
202+
assert auto_key != pred_key
203+
assert auto_model is not pred_model
204+
205+
# A repeat request for the same key is served from cache (no new instance).
206+
with patch("samgeo.samgeo2.SamGeo2") as mock_again:
207+
cached_model, _, _ = api.get_model("sam2", "sam2-hiera-tiny")
208+
mock_again.assert_not_called()
209+
assert cached_model is auto_model
210+
211+
212+
def test_get_model_caches_distinct_configs_separately():
213+
"""Requests differing only by model-shaping kwargs get separate instances.
214+
215+
Regression: the cache key once ignored constructor kwargs, so a second
216+
SAM3 text request with a different ``confidence_threshold`` (or a SAM2
217+
request with a different ``points_per_side``) silently reused the first,
218+
differently-configured instance. The key now folds in the kwargs.
219+
"""
220+
import samgeo.api as api
221+
222+
# SAM3: two text requests differing only by confidence_threshold.
223+
with patch("samgeo.samgeo3.SamGeo3") as mock_sam3:
224+
mock_sam3.side_effect = lambda **kw: MagicMock()
225+
m_a, _, key_a = api.get_model("sam3", backend="meta", confidence_threshold=0.4)
226+
m_b, _, key_b = api.get_model("sam3", backend="meta", confidence_threshold=0.6)
227+
m_a2, _, _ = api.get_model("sam3", backend="meta", confidence_threshold=0.4)
228+
assert key_a != key_b
229+
assert m_a is not m_b
230+
assert m_a2 is m_a # identical config is a cache hit
231+
232+
# SAM2: two automatic requests differing only by points_per_side.
233+
with patch("samgeo.samgeo2.SamGeo2") as mock_sam2:
234+
mock_sam2.side_effect = lambda **kw: MagicMock()
235+
s_a, _, k_a = api.get_model("sam2", "sam2-hiera-tiny", points_per_side=16)
236+
s_b, _, k_b = api.get_model("sam2", "sam2-hiera-tiny", points_per_side=32)
237+
assert k_a != k_b
238+
assert s_a is not s_b
239+
240+
241+
def test_freeze_kwargs_is_order_independent():
242+
"""_freeze_kwargs yields the same hashable key regardless of arg order."""
243+
from samgeo.api import _freeze_kwargs
244+
245+
a = _freeze_kwargs({"backend": "meta", "sam_kwargs": {"x": 1, "y": 2}})
246+
b = _freeze_kwargs({"sam_kwargs": {"y": 2, "x": 1}, "backend": "meta"})
247+
assert a == b
248+
assert hash(a) == hash(b)
249+
assert _freeze_kwargs({"a": 1}) != _freeze_kwargs({"a": 2})
250+
251+
252+
def test_set_image_cached_skips_for_sam2_but_not_sam3():
253+
"""The image-encoder skip is safe for SAM/SAM2 but must be off for SAM3.
254+
255+
Regression: SAM3 mutates its encoded-image state during ``generate_*``, so
256+
reusing it on a second request without re-encoding fails with
257+
``expected scalar type BFloat16 but found Float``. ``_set_image_cached``
258+
therefore always re-encodes for SAM3, while still caching for SAM/SAM2.
259+
"""
260+
from samgeo.api import _set_image_cached, _image_hash_cache
261+
262+
# SAM2: second call with the same hash is skipped (set_image not re-run).
263+
sam2_model = MagicMock()
264+
sam2_key = ("sam2", "sam2-hiera-tiny", False)
265+
assert _set_image_cached(sam2_model, sam2_key, "/tmp/x.tif", "hash-a") is True
266+
assert _set_image_cached(sam2_model, sam2_key, "/tmp/x.tif", "hash-a") is False
267+
assert sam2_model.set_image.call_count == 1
268+
269+
# SAM3: every call re-encodes even when the hash matches.
270+
sam3_model = MagicMock()
271+
sam3_key = ("sam3", "facebook/sam3", True)
272+
assert _set_image_cached(sam3_model, sam3_key, "/tmp/y.tif", "hash-b") is True
273+
assert _set_image_cached(sam3_model, sam3_key, "/tmp/y.tif", "hash-b") is True
274+
assert sam3_model.set_image.call_count == 2

0 commit comments

Comments
 (0)