Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
418fbe9
Let workflows request the anomaly map through run_classification
probicheaux Oct 15, 2026
9886d30
Add the Anomaly Detection Model workflow block
probicheaux Oct 15, 2026
87e5ec7
Add the Anomaly Heatmap Visualization workflow block
probicheaux Oct 15, 2026
18bc374
Register the anomaly detection blocks and note them in the changelog
probicheaux Oct 15, 2026
1fae874
Merge remote-tracking branch 'origin/main' into peter/anomaly-workflo…
probicheaux Oct 15, 2026
46c79d0
Require inference-sdk 1.7.2 for the workflows package
probicheaux Oct 16, 2026
3e57b3b
chore(workflows): refresh uv.lock for inference-sdk>=1.7.2
probicheaux Oct 16, 2026
7eace7f
Merge remote-tracking branch 'origin/main' into peter/anomaly-workflo…
probicheaux Oct 19, 2026
78bd5e8
Let the request confidence decide the anomaly verdict
probicheaux Oct 19, 2026
dc30a76
Add a confidence input to the Anomaly Detection Model block
probicheaux Oct 19, 2026
aa13bc1
Apply the anomaly confidence contract in the new inference server
probicheaux Oct 19, 2026
89db3e5
Anchor the anomaly heatmap at the block's decision
probicheaux Oct 19, 2026
4c8ad8e
Test anomaly detection end to end against the reference models
probicheaux Oct 19, 2026
72264f8
Skip the anomaly workflow test on the legacy model runtime
probicheaux Oct 20, 2026
39f66c1
Bound PatchCore's distance matrix by elements, not rows
probicheaux Oct 20, 2026
3e67da6
Bump inference version and workflows dependency
PawelPeczek-Roboflow Oct 20, 2026
774e1ad
Bump versions and align dependencies
PawelPeczek-Roboflow Oct 20, 2026
ed19a78
Fix tensor-native variant in server
PawelPeczek-Roboflow Oct 20, 2026
be2cdd9
Adjust docs
PawelPeczek-Roboflow Oct 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion inference/core/entities/responses/inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,13 @@ class AnomalyDetectionResponse(ClassificationInferenceResponse):
anomaly_threshold: float = Field(
description="Decision threshold fitted on validation images"
)
is_anomalous: bool
anomaly_scale: float = Field(
description="Calibration scale: the anomalous confidence is "
"sigmoid((anomaly_score - anomaly_threshold) / anomaly_scale)"
)
is_anomalous: bool = Field(
description="Whether the anomalous confidence reached the request confidence"
)
anomaly_map: Optional[AnomalyMapPayload] = Field(
default=None,
description="Raw local anomaly evidence at the network input resolution",
Expand Down
8 changes: 7 additions & 1 deletion inference/core/interfaces/http/http_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -5072,11 +5072,17 @@ def legacy_infer_from_request(
args = {"keypoint_confidence": keypoint_confidence}
elif task_type == "semantic-segmentation":
inference_request_type = SemanticSegmentationInferenceRequest
# Leave an omitted confidence unset so anomaly detection models can
# tell it apart from an explicit one and use the model-eval value.
if (
task_type != "classification"
or "confidence" in request.query_params
):
args["confidence"] = confidence
inference_request = inference_request_type(
api_key=api_key,
model_id=model_id,
image=request_image,
confidence=confidence,
iou_threshold=overlap,
max_detections=max_detections,
visualization_labels=labels,
Expand Down
5 changes: 5 additions & 0 deletions inference/core/interfaces/workflows_models_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,11 @@ def run_classification(
disable_active_learning: Optional[bool] = None,
active_learning_target_dataset: Optional[str] = None,
inference_kwargs: Optional[Dict[str, Any]] = None,
include_anomaly_map: bool = False,
) -> List[dict]:
# Set only when asked, so the request a classification block builds is
# field-for-field what it was before the anomaly block existed.
anomaly_arguments = {"include_anomaly_map": True} if include_anomaly_map else {}
request = ClassificationInferenceRequest(
api_key=api_key,
model_id=model_id,
Expand All @@ -168,6 +172,7 @@ def run_classification(
disable_active_learning=disable_active_learning,
source=_WORKFLOW_SOURCE,
active_learning_target_dataset=active_learning_target_dataset,
**anomaly_arguments,
)
return self._dump(
self._infer(model_id=model_id, request=request, **(inference_kwargs or {}))
Expand Down
19 changes: 16 additions & 3 deletions inference/core/models/inference_models_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -1834,14 +1834,26 @@ def _reshape_classification_confidences(
class InferenceModelsAnomalyDetectionAdapter(InferenceModelsClassificationAdapter):
"""Serves PatchCore and FoundAD through the classification route."""

def infer_from_request(
self,
request: ClassificationInferenceRequest,
) -> Union[List[InferenceResponse], InferenceResponse]:
# The classification request defaults confidence to 0.4; a caller who sends
# none gets the model-eval recommendation instead.
if "confidence" not in request.model_fields_set:
request = request.model_copy(update={"confidence": "best"})
return super().infer_from_request(request)

def postprocess(
self,
predictions: Any,
returned_metadata: List[Tuple[int, int]],
**kwargs,
) -> List[AnomalyDetectionResponse]:
post_processed_predictions = self._model.post_process(
predictions, include_anomaly_map=kwargs.get("include_anomaly_map", False)
predictions,
include_anomaly_map=kwargs.get("include_anomaly_map", False),
confidence=kwargs["confidence"],
)
return prepare_anomaly_detection_response(
post_processed_predictions,
Expand All @@ -1855,8 +1867,8 @@ def prepare_anomaly_detection_response(
image_sizes: List[Tuple[int, int]],
class_names: List[str],
) -> List[AnomalyDetectionResponse]:
# The decision comes from the threshold saved with the model, so both classes
# are always reported and the request confidence does not filter them.
# The request confidence decides the verdict instead of filtering classes, so
# both classes are always reported, the decided one first.
batch_confidences = _reshape_classification_confidences(
confidence=post_processed_predictions.confidence.cpu(),
expected_num_images=len(image_sizes),
Expand Down Expand Up @@ -1886,6 +1898,7 @@ def prepare_anomaly_detection_response(
confidence=class_predictions[0]["confidence"],
anomaly_score=image_metadata["anomaly_score"],
anomaly_threshold=image_metadata["anomaly_threshold"],
anomaly_scale=image_metadata["anomaly_scale"],
is_anomalous=image_metadata["is_anomalous"],
anomaly_map=(
AnomalyMapPayload(**encode_anomaly_map(anomaly_map))
Expand Down
2 changes: 1 addition & 1 deletion inference/core/version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__version__ = "1.8.1"
__version__ = "1.8.2"


if __name__ == "__main__":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,16 @@ def _p(*dicts: dict) -> dict:
_P_SAM2_SEGMENT = _p(
_P_SAM_SEGMENT_COMMON,
{
"load_from_mask_input_cache": {"type": "bool", "required": False, "default": False},
"save_to_mask_input_cache": {"type": "bool", "required": False, "default": False},
"load_from_mask_input_cache": {
"type": "bool",
"required": False,
"default": False,
},
"save_to_mask_input_cache": {
"type": "bool",
"required": False,
"default": False,
},
},
)

Expand Down Expand Up @@ -413,6 +421,7 @@ def _unpack_config(cfg: tuple) -> tuple:
_p(
_P_IMAGES,
{"include_anomaly_map": {"type": "bool", "required": False}},
_P_CONFIDENCE,
_K_PREPROC,
),
"validate_images_required",
Expand Down Expand Up @@ -639,7 +648,13 @@ def _unpack_config(cfg: tuple) -> tuple:
True,
_p(
_P_VLM_PROMPT,
{"enable_thinking": {"type": "bool", "required": False, "default": False}},
{
"enable_thinking": {
"type": "bool",
"required": False,
"default": False,
}
},
),
"validate_images_and_prompt",
"serialize_text",
Expand All @@ -653,7 +668,13 @@ def _unpack_config(cfg: tuple) -> tuple:
True,
_p(
_P_VLM_PROMPT,
{"images_to_single_prompt": {"type": "bool", "required": False, "default": True}},
{
"images_to_single_prompt": {
"type": "bool",
"required": False,
"default": True,
}
},
),
"validate_images_and_prompt",
"serialize_text",
Expand All @@ -677,7 +698,11 @@ def _unpack_config(cfg: tuple) -> tuple:
"caption",
"caption_image",
True,
_p(_P_IMAGES, {"granularity": {"type": "str", "required": False}}, _P_MAX_NEW_TOKENS),
_p(
_P_IMAGES,
{"granularity": {"type": "str", "required": False}},
_P_MAX_NEW_TOKENS,
),
"validate_images_required",
"serialize_text",
"roboflow-text-v1",
Expand Down Expand Up @@ -829,7 +854,11 @@ def _unpack_config(cfg: tuple) -> tuple:
"caption",
"caption",
True,
_p(_P_IMAGES, {"length": {"type": "str", "required": False}}, _P_MAX_NEW_TOKENS),
_p(
_P_IMAGES,
{"length": {"type": "str", "required": False}},
_P_MAX_NEW_TOKENS,
),
"validate_images_required",
"serialize_text",
"roboflow-text-v1",
Expand Down Expand Up @@ -921,8 +950,16 @@ def _unpack_config(cfg: tuple) -> tuple:
"image": {"type": "image", "required": True},
"bboxes": {"type": "list", "required": True},
"state_dict": {"type": "dict", "required": False},
"clear_old_points": {"type": "bool", "required": False, "default": True},
"normalize_coords": {"type": "bool", "required": False, "default": True},
"clear_old_points": {
"type": "bool",
"required": False,
"default": True,
},
"normalize_coords": {
"type": "bool",
"required": False,
"default": True,
},
"frame_idx": {"type": "int", "required": False, "default": 0},
},
"validate_passthrough",
Expand Down Expand Up @@ -1074,9 +1111,16 @@ def lazy_register_by_names(mro_names: list[str]) -> None:
continue
placeholder = type(name, (), {})
for cfg in config:
action_name, method, default, params, val_name, ser_name, resp_type, aliases = (
_unpack_config(cfg)
)
(
action_name,
method,
default,
params,
val_name,
ser_name,
resp_type,
aliases,
) = _unpack_config(cfg)
registry.register(
placeholder,
action_name,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,13 @@ def serialize_detections_compact(output: Any, model: Any) -> dict:
# Classification (single-label)
# ---------------------------------------------------------------------------

_ANOMALY_FIELDS = ("anomaly_score", "anomaly_threshold", "is_anomalous", "anomaly_map")
_ANOMALY_FIELDS = (
"anomaly_score",
"anomaly_threshold",
"anomaly_scale",
"is_anomalous",
"anomaly_map",
)


def _anomaly_fields(output: Any, index: int = 0) -> dict:
Expand Down
25 changes: 13 additions & 12 deletions inference_model_manager/tests/unit_tests/test_model_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -565,11 +565,19 @@ def forward(self, pre_processed_images, **kwargs):
original_sizes_hw=[(8, 8), (8, 8)],
)

def post_process(self, model_results, include_anomaly_map=False, **kwargs):
def post_process(
self,
model_results,
include_anomaly_map=False,
confidence="best",
**kwargs,
):
return post_process_anomaly_scores(
model_results,
calibration=AnomalyCalibration(threshold=2.0, scale=1.0),
include_anomaly_map=include_anomaly_map,
confidence=confidence,
recommended_parameters=None,
)

mm = ModelManager()
Expand Down Expand Up @@ -1100,9 +1108,7 @@ def test_unload_releases_cuda_cache(self, monkeypatch):
import inference_model_manager.model_manager as mm_mod

calls = []
monkeypatch.setattr(
mm_mod, "_try_release_cuda_memory", lambda: calls.append(1)
)
monkeypatch.setattr(mm_mod, "_try_release_cuda_memory", lambda: calls.append(1))
mm = ModelManager()
_patch_create_backend(mm, {})
mm.load("model-a", api_key="")
Expand Down Expand Up @@ -1225,8 +1231,7 @@ def load_model(name):
errors.append(exc)

threads = [
threading.Thread(target=load_model, args=(f"new{i}",))
for i in range(n_new)
threading.Thread(target=load_model, args=(f"new{i}",)) for i in range(n_new)
]
for t in threads:
t.start()
Expand Down Expand Up @@ -1465,11 +1470,10 @@ class TestStreamPipeline:

@pytest.fixture
def segmentation_manager(self, monkeypatch):
from inference_model_manager.registry_defaults import lazy_register
from inference_models.models.base.instance_segmentation import (
InstanceSegmentationModel,
)

from inference_model_manager.registry_defaults import lazy_register
from tests.unit_tests.test_stream_pipeline import FakeSegmentationModel

lazy_register(InstanceSegmentationModel)
Expand Down Expand Up @@ -1697,9 +1701,7 @@ def test_unload_shuts_the_pipeline_down(self, segmentation_manager):
assert mm.flush_model_stream_pipeline("seg/1") is None

def test_unload_resolves_an_in_flight_frame(self, segmentation_manager):
from inference_models.models.base.async_handoff import (
get_async_response_future,
)
from inference_models.models.base.async_handoff import get_async_response_future

mm, _, models = segmentation_manager
mm.load("seg/1", api_key="")
Expand Down Expand Up @@ -1824,7 +1826,6 @@ def test_pipeline_is_drained_after_the_last_admitted_frame(
from inference_models.models.base.async_handoff import (
get_async_response_context_id,
)

from tests.unit_tests.test_stream_pipeline import (
FakeSegmentationModel,
queues_of,
Expand Down
Loading
Loading