From 726000b22f68eb0a7f0f8b5956177101d29da01b Mon Sep 17 00:00:00 2001 From: Luke Schaefer Date: Wed, 26 Aug 2026 23:09:35 +0000 Subject: [PATCH 1/3] feat(model): add run-free ("model v2") predictions [DE-8678] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a run-free prediction concept where predictions are tied directly to a Model as (model, dataset_item) -> prediction, with no ModelRun or Dataset. Purely additive: all existing model-run prediction paths are unchanged. - Model.upload_predictions(...) — upsert predictions onto model/{id}/predictions (sync + async), reusing the PredictionUploader batching machinery. - Model.predictions_loc / predictions_refloc / predictions_iloc — model-scoped reads. - Model.copy_predictions_from_run(model_run_id) — backfill from a v1 run (AsyncJob). - create_benchmark_evaluation_v2 gains an optional model_id anchor (accepts a prj_* id or a Model) as an alternative to model_run_id; exactly one required. - EvaluationV2 gains an optional model_id field; model_run_id now optional. - serialize_and_write_to_presigned_url gains route_prefix for the model route. - Docs + CHANGELOG; minor version bump to 0.22.0. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 12 +++ nucleus/__init__.py | 26 ++++- nucleus/evaluation_v2.py | 17 +++- nucleus/model.py | 202 ++++++++++++++++++++++++++++++++++++++- nucleus/model_run.py | 17 +++- nucleus/utils.py | 28 +++++- pyproject.toml | 2 +- 7 files changed, 290 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 021de916..d6b64c36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to the [Nucleus Python Client](https://github.com/scaleapi/n The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.22.0](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.22.0) - 2026-08-26 + +### Added +- **Run-free ("model v2") predictions.** Predictions can now be uploaded and read directly against a `Model`, with no `ModelRun` or `Dataset` involved — the concept is `(model, dataset_item) -> prediction`. New methods on `Model`: + - `Model.upload_predictions(predictions, update=False, asynchronous=False, batch_size=5000, ...)` — upserts predictions onto the model (`box` / `polygon` / `cuboid` only). Reuses the existing `PredictionUploader` batching machinery, targeting `model/{id}/predictions` (async posts to `model/{id}/predictions?async=1` and returns an `AsyncJob`). + - `Model.predictions_loc(dataset_item_id)`, `Model.predictions_refloc(reference_id)`, `Model.predictions_iloc(i)` — model-scoped reads returning the same shape as their `Dataset` equivalents. + - `Model.copy_predictions_from_run(model_run_id, asynchronous=True)` — backfills the run-free store from an existing model run, returning an `AsyncJob`. +- **Model-anchored benchmark evaluations.** `NucleusClient.create_benchmark_evaluation_v2()` accepts a `model_id` (a `prj_*` id or a `Model`) as an alternative to `model_run_id`; the model-anchored flow evaluates the model's run-free predictions and ignores model runs. Provide exactly one of the two. `EvaluationV2` now exposes an optional `model_id` field alongside `model_run_id`. + +### Changed +- The existing run-based prediction paths (`Dataset.upload_predictions`, `ModelRun.add_predictions`, `create_benchmark_evaluation_v2(model_run_id=...)`) are unchanged and continue to work; the model-centric methods are purely additive. + ## [0.21.2](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.21.2) - 2026-08-17 ### Added diff --git a/nucleus/__init__.py b/nucleus/__init__.py index e5a94c5a..b9bab5b9 100644 --- a/nucleus/__init__.py +++ b/nucleus/__init__.py @@ -149,6 +149,7 @@ MESSAGE_KEY, METADATA_KEY, METRIC_TYPE_KEY, + MODEL_ID_KEY, MODEL_IDS_KEY, MODEL_RUN_ID_KEY, MODEL_RUN_IDS_KEY, @@ -1562,8 +1563,9 @@ def finalize_benchmark(self, benchmark_id: str) -> Benchmark: def create_benchmark_evaluation_v2( self, benchmark_id: str, - model_run_id: str, + model_run_id: Optional[str] = None, *, + model_id: Optional[Union[str, Model]] = None, name: Optional[str] = None, rollup_groups: Optional[List[RollupGroup]] = None, allowed_label_matches: Optional[List[AllowedLabelMatch]] = None, @@ -1587,10 +1589,19 @@ def create_benchmark_evaluation_v2( being rejected. To give a run predictions across several datasets, use :meth:`Dataset.upload_predictions_for_model_run`. + The evaluation can be anchored on either a legacy model run + (``model_run_id``) or, for the run-free "model v2" flow, a model + (``model_id``). Provide exactly one; the model-anchored flow evaluates + the model's run-free predictions and ignores model runs entirely. + Parameters: benchmark_id: Benchmark id (``bm_*``). model_run_id: Model run id (``run_*``). It need not cover the benchmark's datasets — coverage may be partial, or empty. + Mutually exclusive with ``model_id``. + model_id: Model id (``prj_*``) or :class:`Model` to anchor the + evaluation on the model's run-free predictions instead of a + model run. Mutually exclusive with ``model_run_id``. name: Optional display name. rollup_groups: Optional rollup classes (the primary label configuration); each :class:`RollupGroup` maps raw labels @@ -1610,6 +1621,13 @@ def create_benchmark_evaluation_v2( Returns: :class:`EvaluationV2`: The created evaluation. """ + resolved_model_id = ( + model_id.id if isinstance(model_id, Model) else model_id + ) + if (resolved_model_id is None) == (model_run_id is None): + raise ValueError( + "Provide exactly one of model_run_id or model_id." + ) if preset is not None: if ( rollup_groups is None @@ -1635,7 +1653,11 @@ def create_benchmark_evaluation_v2( "Set at most one of rollup_groups, allowed_label_matches, " "or allowed_label_matches_id" ) - payload: Dict[str, Any] = {MODEL_RUN_ID_KEY: model_run_id} + payload: Dict[str, Any] = ( + {MODEL_ID_KEY: resolved_model_id} + if resolved_model_id is not None + else {MODEL_RUN_ID_KEY: model_run_id} + ) if name is not None: payload[NAME_KEY] = name if rollup_groups is not None: diff --git a/nucleus/evaluation_v2.py b/nucleus/evaluation_v2.py index 0d4847af..a70e8bd3 100644 --- a/nucleus/evaluation_v2.py +++ b/nucleus/evaluation_v2.py @@ -31,6 +31,7 @@ LABELS_KEY, LIMIT_KEY, MATCH_TYPE_KEY, + MODEL_ID_KEY, MODEL_PREDICTION_LABEL_CAMEL_KEY, MODEL_PREDICTION_LABEL_KEY, MODEL_RUN_ID_KEY, @@ -170,12 +171,13 @@ def _parse_rollup_groups(raw_groups: Any) -> Optional[List[RollupGroup]]: @dataclass class EvaluationV2: - """An Evaluation V2 run for a model run.""" + """An Evaluation V2 run for a model run or a run-free model.""" id: str - model_run_id: str + model_run_id: Optional[str] dataset_id: str status: str + model_id: Optional[str] = None name: Optional[str] = None temporal_workflow_id: Optional[str] = None error_message: Optional[str] = None @@ -202,9 +204,18 @@ def from_json( return cls( id=str(payload[ID_KEY]), - model_run_id=str(payload[MODEL_RUN_ID_KEY]), + model_run_id=( + str(payload[MODEL_RUN_ID_KEY]) + if payload.get(MODEL_RUN_ID_KEY) is not None + else None + ), dataset_id=str(payload[DATASET_ID_KEY]), status=str(payload[STATUS_KEY]), + model_id=( + str(payload[MODEL_ID_KEY]) + if payload.get(MODEL_ID_KEY) is not None + else None + ), name=payload.get(NAME_KEY), temporal_workflow_id=payload.get(TEMPORAL_WORKFLOW_ID_KEY), error_message=payload.get(ERROR_MESSAGE_KEY), diff --git a/nucleus/model.py b/nucleus/model.py index 18340571..75317521 100644 --- a/nucleus/model.py +++ b/nucleus/model.py @@ -1,14 +1,24 @@ -from typing import Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Union import requests +from nucleus.annotation import check_all_mask_paths_remote +from nucleus.annotation_uploader import PredictionUploader +from nucleus.utils import ( + format_prediction_response, + serialize_and_write_to_presigned_url, +) + from .async_job import AsyncJob from .constants import ( METADATA_KEY, + MODEL_RUN_ID_KEY, MODEL_TAGS_KEY, MODEL_TRAINED_SLICE_IDS_KEY, NAME_KEY, REFERENCE_ID_KEY, + REQUEST_ID_KEY, + UPDATE_KEY, ) from .dataset import Dataset from .model_run import ModelRun @@ -17,6 +27,7 @@ BoxPrediction, CuboidPrediction, PolygonPrediction, + Prediction, SegmentationPrediction, ) @@ -229,6 +240,195 @@ def create_run( run.add_predictions(predictions) return run + def upload_predictions( + self, + predictions: List[Prediction], + update: bool = False, + asynchronous: bool = False, + batch_size: int = 5000, + remote_files_per_upload_request: int = 20, + local_files_per_upload_request: int = 10, + ) -> Union[Dict[str, Any], AsyncJob]: + """Uploads predictions directly to this model, with no model run. + + This is the run-free ("model v2") prediction path: predictions are tied + to the model itself as ``(model, dataset_item) -> prediction`` and are + upserted server-side. Each prediction identifies its target item by + ``dataset_item_id`` (the ``di_*`` id returned on exported items) or by + ``reference_id``, so a single model can hold predictions for items that + live in different datasets — no :class:`Dataset` or :class:`ModelRun` is + needed. Reads go through :meth:`predictions_loc`, + :meth:`predictions_refloc`, and :meth:`predictions_iloc`. + + Only ``box``, ``polygon``, and ``cuboid`` predictions are accepted on + this path. + + The legacy run-based path (:meth:`Dataset.upload_predictions` / + :meth:`ModelRun.add_predictions`) continues to work unchanged. + + Args: + predictions: List of prediction objects to upload. + update: If True, existing predictions for the same + (reference_id, annotation_id) are overwritten. If False, they + are skipped. Default is False. + asynchronous: Whether or not to process the upload asynchronously + (and return an :class:`AsyncJob` object). Default is False. + batch_size: Number of predictions processed in each concurrent + batch. Default is 5000. If you get timeouts when uploading + geometric predictions, you can try lowering this batch size. + This is only relevant for asynchronous=False. + remote_files_per_upload_request: Number of remote files to upload in + each request. Only relevant for asynchronous=False. + local_files_per_upload_request: Number of local files to upload in + each request. The maximum is 10. Only relevant for + asynchronous=False. + + Returns: + Payload describing the synchronous upload, or an :class:`AsyncJob` + when ``asynchronous=True``:: + + { + "model_id": str, + "predictions_processed": int, + "predictions_ignored": int, + } + """ + uploader = PredictionUploader( + client=self._client, + route=f"model/{self.id}/predictions", + ) + uploader.check_for_duplicate_ids(predictions) + + if asynchronous: + check_all_mask_paths_remote(predictions) + request_id = serialize_and_write_to_presigned_url( + predictions, + dataset_id=None, + client=self._client, + route_prefix=f"model/{self.id}", + ) + response = self._client.make_request( + payload={REQUEST_ID_KEY: request_id, UPDATE_KEY: update}, + route=f"model/{self.id}/predictions?async=1", + ) + return AsyncJob.from_json(response, self._client) + + return uploader.upload( + annotations=predictions, + batch_size=batch_size, + update=update, + remote_files_per_upload_request=remote_files_per_upload_request, + local_files_per_upload_request=local_files_per_upload_request, + ) + + def predictions_loc(self, dataset_item_id: str): + """Fetches all of this model's predictions for a dataset item by its id. + + Model-scoped counterpart of :meth:`Dataset.prediction_loc` for the + run-free prediction path. + + Parameters: + dataset_item_id: Internally controlled id for the dataset item + (``di_*``). + + Returns: + Dictionary mapping prediction type to a list of prediction objects + for this model:: + + { + "box": List[BoxPrediction], + "polygon": List[PolygonPrediction], + "cuboid": List[CuboidPrediction], + } + """ + return format_prediction_response( + self._client.make_request( + payload=None, + route=f"model/{self.id}/predictions/loc/{dataset_item_id}", + requests_command=requests.get, + ) + ) + + def predictions_refloc(self, reference_id: str): + """Fetches all of this model's predictions for a dataset item by its reference id. + + Model-scoped counterpart of :meth:`Dataset.predictions_refloc` for the + run-free prediction path. + + Parameters: + reference_id: User-defined reference id of the dataset item. + + Returns: + Dictionary mapping prediction type to a list of prediction objects + for this model:: + + { + "box": List[BoxPrediction], + "polygon": List[PolygonPrediction], + "cuboid": List[CuboidPrediction], + } + """ + return format_prediction_response( + self._client.make_request( + payload=None, + route=f"model/{self.id}/predictions/refloc/{reference_id}", + requests_command=requests.get, + ) + ) + + def predictions_iloc(self, i: int): + """Fetches all of this model's predictions for a dataset item by its index. + + Model-scoped counterpart of :meth:`Dataset.predictions_iloc` for the + run-free prediction path. + + Parameters: + i: Absolute index of the dataset item. + + Returns: + Dictionary mapping prediction type to a list of prediction objects + for this model:: + + { + "box": List[BoxPrediction], + "polygon": List[PolygonPrediction], + "cuboid": List[CuboidPrediction], + } + """ + return format_prediction_response( + self._client.make_request( + payload=None, + route=f"model/{self.id}/predictions/iloc/{i}", + requests_command=requests.get, + ) + ) + + def copy_predictions_from_run( + self, model_run_id: str, asynchronous: bool = True + ) -> AsyncJob: + """Copies predictions from a legacy v1 model run onto this model. + + Backfills the run-free ("model v2") prediction store for this model from + an existing :class:`ModelRun`, so predictions previously uploaded via the + run-based path become readable through :meth:`predictions_loc` and + friends. The source run is left untouched. + + Args: + model_run_id: Source model run id (``run_*``) to copy predictions + from. + asynchronous: Retained for forward compatibility; the copy always + runs server-side as an async job. Default is True. + + Returns: + An :class:`AsyncJob` tracking the copy. + """ + response = self._client.make_request( + {MODEL_RUN_ID_KEY: model_run_id}, + route=f"model/{self.id}/predictions/copyFromRun", + requests_command=requests.post, + ) + return AsyncJob.from_json(response, self._client) + def evaluate(self, scenario_test_names: List[str]) -> AsyncJob: """Evaluates this on the specified Unit Tests. :: diff --git a/nucleus/model_run.py b/nucleus/model_run.py index aa626ca8..3f20d7ef 100644 --- a/nucleus/model_run.py +++ b/nucleus/model_run.py @@ -1,18 +1,29 @@ """ Model Runs are deprecated and will be removed in a future version of the python client. -It is now possible to upload model predictions without a need for creating a model run +It is now possible to upload model predictions without a need for creating a model run. -For example:: +The recommended run-free ("model v2") path uploads and reads predictions +directly on a :class:`~nucleus.model.Model` — the concept is +``(model, dataset_item) -> prediction``, with no model run or dataset:: import nucleus client = nucleus.NucleusClient(YOUR_SCALE_API_KEY) prediction_1 = nucleus.BoxPrediction(label="label", x=0, y=0, width=10, height=10, reference_id="1", confidence=0.9, class_pdf={'label': 0.9, 'other_label': 0.1}) prediction_2 = nucleus.BoxPrediction(label="label", x=0, y=0, width=10, height=10, reference_id="2", confidence=0.2, class_pdf={'label': 0.2, 'other_label': 0.8}) model = client.create_model(name="My Model", reference_id="My-CNN", metadata={"timestamp": "121012401"}) + + # Run-free upload / read, tied to the model itself: + model.upload_predictions([prediction_1, prediction_2]) + model.predictions_refloc("1") + +Benchmark evaluations can likewise anchor on the model directly, ignoring model +runs, via ``client.create_benchmark_evaluation_v2(benchmark_id, model_id=model.id)``. + +The older per-dataset path also remains available:: + response = dataset.upload_predictions(model, [prediction_1, prediction_2]) """ - from typing import List, Optional, Union import requests diff --git a/nucleus/utils.py b/nucleus/utils.py index d4db15dc..f8861934 100644 --- a/nucleus/utils.py +++ b/nucleus/utils.py @@ -6,7 +6,17 @@ import urllib.request import uuid from collections import defaultdict -from typing import IO, TYPE_CHECKING, Dict, List, Sequence, Tuple, Type, Union +from typing import ( + IO, + TYPE_CHECKING, + Dict, + List, + Optional, + Sequence, + Tuple, + Type, + Union, +) import requests from PIL import Image @@ -396,12 +406,22 @@ def serialize_and_write_to_presigned_url( upload_units: Sequence[ Union[DatasetItem, Annotation, LidarScene, VideoScene] ], - dataset_id: str, + dataset_id: Optional[str], client, + route_prefix: Optional[str] = None, ): - """This helper function can be used to serialize a list of API objects to NDJSON.""" + """This helper function can be used to serialize a list of API objects to NDJSON. + + By default the presigned URL is requested from the dataset-scoped route + ``dataset/{dataset_id}/signedUrl/{request_id}``. Pass ``route_prefix`` (e.g. + ``model/{model_id}``) to target a different signed-URL route — used by the + model-scoped prediction upload, which has no owning dataset. + """ request_id = uuid.uuid4().hex - route = f"dataset/{dataset_id}/signedUrl/{request_id}" + prefix = ( + route_prefix if route_prefix is not None else f"dataset/{dataset_id}" + ) + route = f"{prefix}/signedUrl/{request_id}" if os.environ.get("S3_ENDPOINT") is not None: route += "?s3Endpoint=" + urllib.request.pathname2url( os.environ["S3_ENDPOINT"] diff --git a/pyproject.toml b/pyproject.toml index 4901f914..5b47c0b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ ignore = ["E501", "E741", "E731", "F401"] # Easy ignore for getting it running [tool.poetry] name = "scale-nucleus" -version = "0.21.2" +version = "0.22.0" description = "The official Python client library for Nucleus, the Data Platform for AI" license = "MIT" authors = ["Scale AI Nucleus Team "] From 3aaf66366890ee307c91de288a19e1b105f0c1de Mon Sep 17 00:00:00 2001 From: Luke Schaefer Date: Wed, 26 Aug 2026 23:38:27 +0000 Subject: [PATCH 2/3] fix(model): make v2 prediction upload + copy sync-only to match backend [DE-8678] The live scaleapi backend for DE-8678 is synchronous-only for these routes: - Model.upload_predictions: the model route has no async/signed-URL endpoint (?async=1 returns HTTP 400). Remove the assumed signed-URL async flow and raise NotImplementedError when asynchronous=True; keep the sync path as-is. Revert the now-unused route_prefix param added to serialize_and_write_to_presigned_url in nucleus/utils.py. - Model.copy_predictions_from_run: the backend runs synchronously and returns {model_id, model_run_ids, predictions_copied, predictions_skipped_unsupported}. Return that dict directly (drop the AsyncJob wrapping and the unused asynchronous param); note it's synchronous in the docstring. CHANGELOG updated to match; still additive, still v0.22.0. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 4 +-- nucleus/model.py | 64 ++++++++++++++++++++---------------------------- nucleus/utils.py | 28 +++------------------ 3 files changed, 32 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6b64c36..8e9ac4e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Run-free ("model v2") predictions.** Predictions can now be uploaded and read directly against a `Model`, with no `ModelRun` or `Dataset` involved — the concept is `(model, dataset_item) -> prediction`. New methods on `Model`: - - `Model.upload_predictions(predictions, update=False, asynchronous=False, batch_size=5000, ...)` — upserts predictions onto the model (`box` / `polygon` / `cuboid` only). Reuses the existing `PredictionUploader` batching machinery, targeting `model/{id}/predictions` (async posts to `model/{id}/predictions?async=1` and returns an `AsyncJob`). + - `Model.upload_predictions(predictions, update=False, batch_size=5000, ...)` — upserts predictions onto the model (`box` / `polygon` / `cuboid` only), targeting `model/{id}/predictions`, and reusing the existing `PredictionUploader` batching machinery. Synchronous only for now: `asynchronous=True` raises `NotImplementedError`. - `Model.predictions_loc(dataset_item_id)`, `Model.predictions_refloc(reference_id)`, `Model.predictions_iloc(i)` — model-scoped reads returning the same shape as their `Dataset` equivalents. - - `Model.copy_predictions_from_run(model_run_id, asynchronous=True)` — backfills the run-free store from an existing model run, returning an `AsyncJob`. + - `Model.copy_predictions_from_run(model_run_id)` — synchronously backfills the run-free store from an existing model run, returning a dict `{model_id, model_run_ids, predictions_copied, predictions_skipped_unsupported}`. - **Model-anchored benchmark evaluations.** `NucleusClient.create_benchmark_evaluation_v2()` accepts a `model_id` (a `prj_*` id or a `Model`) as an alternative to `model_run_id`; the model-anchored flow evaluates the model's run-free predictions and ignores model runs. Provide exactly one of the two. `EvaluationV2` now exposes an optional `model_id` field alongside `model_run_id`. ### Changed diff --git a/nucleus/model.py b/nucleus/model.py index 75317521..d6bab803 100644 --- a/nucleus/model.py +++ b/nucleus/model.py @@ -2,12 +2,8 @@ import requests -from nucleus.annotation import check_all_mask_paths_remote from nucleus.annotation_uploader import PredictionUploader -from nucleus.utils import ( - format_prediction_response, - serialize_and_write_to_presigned_url, -) +from nucleus.utils import format_prediction_response from .async_job import AsyncJob from .constants import ( @@ -17,8 +13,6 @@ MODEL_TRAINED_SLICE_IDS_KEY, NAME_KEY, REFERENCE_ID_KEY, - REQUEST_ID_KEY, - UPDATE_KEY, ) from .dataset import Dataset from .model_run import ModelRun @@ -248,7 +242,7 @@ def upload_predictions( batch_size: int = 5000, remote_files_per_upload_request: int = 20, local_files_per_upload_request: int = 10, - ) -> Union[Dict[str, Any], AsyncJob]: + ) -> Dict[str, Any]: """Uploads predictions directly to this model, with no model run. This is the run-free ("model v2") prediction path: predictions are tied @@ -271,21 +265,19 @@ def upload_predictions( update: If True, existing predictions for the same (reference_id, annotation_id) are overwritten. If False, they are skipped. Default is False. - asynchronous: Whether or not to process the upload asynchronously - (and return an :class:`AsyncJob` object). Default is False. + asynchronous: Not yet supported for this path — passing True raises + :class:`NotImplementedError`. The upload always runs + synchronously. batch_size: Number of predictions processed in each concurrent batch. Default is 5000. If you get timeouts when uploading geometric predictions, you can try lowering this batch size. - This is only relevant for asynchronous=False. remote_files_per_upload_request: Number of remote files to upload in - each request. Only relevant for asynchronous=False. + each request. local_files_per_upload_request: Number of local files to upload in - each request. The maximum is 10. Only relevant for - asynchronous=False. + each request. The maximum is 10. Returns: - Payload describing the synchronous upload, or an :class:`AsyncJob` - when ``asynchronous=True``:: + Payload describing the synchronous upload:: { "model_id": str, @@ -293,26 +285,18 @@ def upload_predictions( "predictions_ignored": int, } """ + if asynchronous: + raise NotImplementedError( + "async is not yet supported for model prediction v2 uploads; " + "use asynchronous=False" + ) + uploader = PredictionUploader( client=self._client, route=f"model/{self.id}/predictions", ) uploader.check_for_duplicate_ids(predictions) - if asynchronous: - check_all_mask_paths_remote(predictions) - request_id = serialize_and_write_to_presigned_url( - predictions, - dataset_id=None, - client=self._client, - route_prefix=f"model/{self.id}", - ) - response = self._client.make_request( - payload={REQUEST_ID_KEY: request_id, UPDATE_KEY: update}, - route=f"model/{self.id}/predictions?async=1", - ) - return AsyncJob.from_json(response, self._client) - return uploader.upload( annotations=predictions, batch_size=batch_size, @@ -403,9 +387,7 @@ def predictions_iloc(self, i: int): ) ) - def copy_predictions_from_run( - self, model_run_id: str, asynchronous: bool = True - ) -> AsyncJob: + def copy_predictions_from_run(self, model_run_id: str) -> Dict[str, Any]: """Copies predictions from a legacy v1 model run onto this model. Backfills the run-free ("model v2") prediction store for this model from @@ -413,21 +395,27 @@ def copy_predictions_from_run( run-based path become readable through :meth:`predictions_loc` and friends. The source run is left untouched. + Runs synchronously server-side and returns once the copy completes. + Args: model_run_id: Source model run id (``run_*``) to copy predictions from. - asynchronous: Retained for forward compatibility; the copy always - runs server-side as an async job. Default is True. Returns: - An :class:`AsyncJob` tracking the copy. + Payload describing the copy:: + + { + "model_id": str, + "model_run_ids": List[str], + "predictions_copied": int, + "predictions_skipped_unsupported": int, + } """ - response = self._client.make_request( + return self._client.make_request( {MODEL_RUN_ID_KEY: model_run_id}, route=f"model/{self.id}/predictions/copyFromRun", requests_command=requests.post, ) - return AsyncJob.from_json(response, self._client) def evaluate(self, scenario_test_names: List[str]) -> AsyncJob: """Evaluates this on the specified Unit Tests. :: diff --git a/nucleus/utils.py b/nucleus/utils.py index f8861934..d4db15dc 100644 --- a/nucleus/utils.py +++ b/nucleus/utils.py @@ -6,17 +6,7 @@ import urllib.request import uuid from collections import defaultdict -from typing import ( - IO, - TYPE_CHECKING, - Dict, - List, - Optional, - Sequence, - Tuple, - Type, - Union, -) +from typing import IO, TYPE_CHECKING, Dict, List, Sequence, Tuple, Type, Union import requests from PIL import Image @@ -406,22 +396,12 @@ def serialize_and_write_to_presigned_url( upload_units: Sequence[ Union[DatasetItem, Annotation, LidarScene, VideoScene] ], - dataset_id: Optional[str], + dataset_id: str, client, - route_prefix: Optional[str] = None, ): - """This helper function can be used to serialize a list of API objects to NDJSON. - - By default the presigned URL is requested from the dataset-scoped route - ``dataset/{dataset_id}/signedUrl/{request_id}``. Pass ``route_prefix`` (e.g. - ``model/{model_id}``) to target a different signed-URL route — used by the - model-scoped prediction upload, which has no owning dataset. - """ + """This helper function can be used to serialize a list of API objects to NDJSON.""" request_id = uuid.uuid4().hex - prefix = ( - route_prefix if route_prefix is not None else f"dataset/{dataset_id}" - ) - route = f"{prefix}/signedUrl/{request_id}" + route = f"dataset/{dataset_id}/signedUrl/{request_id}" if os.environ.get("S3_ENDPOINT") is not None: route += "?s3Endpoint=" + urllib.request.pathname2url( os.environ["S3_ENDPOINT"] From e5a8cb886af95c830eb27f767d675f1dccfc220d Mon Sep 17 00:00:00 2001 From: Luke Schaefer Date: Fri, 28 Aug 2026 12:31:24 -0500 Subject: [PATCH 3/3] fix(model): parse run-free prediction reads in format_prediction_response [DE-8678] Model.predictions_loc / predictions_refloc / predictions_iloc were shipped non-functional: the run-free read endpoints return a flat {"predictions": [...]} list (each element carrying its own "type"), but format_prediction_response only understood the legacy type-keyed {"annotations": {"box": [...]}} shape. It fell through to the "an error occurred" branch and returned the raw payload unparsed, so these reads yielded the raw dict instead of the documented {"box": [...], "polygon": [...], "cuboid": [...]}. Add a flat-list branch that groups predictions by their per-element "type" into that same shape. Legacy type-keyed reads and the error/empty case are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 3 +++ nucleus/utils.py | 24 ++++++++++++++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e9ac4e8..1cf17652 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - The existing run-based prediction paths (`Dataset.upload_predictions`, `ModelRun.add_predictions`, `create_benchmark_evaluation_v2(model_run_id=...)`) are unchanged and continue to work; the model-centric methods are purely additive. +### Fixed +- `Model.predictions_loc` / `predictions_refloc` / `predictions_iloc` now actually parse their responses. The run-free read endpoints return a flat `{"predictions": [...]}` list (each element carrying its own `"type"`), but `format_prediction_response` only understood the legacy type-keyed `{"annotations": {"box": [...]}}` shape, so these methods returned the raw payload unparsed instead of the documented `{"box": [...], "polygon": [...], "cuboid": [...]}` dict. + ## [0.21.2](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.21.2) - 2026-08-17 ### Added diff --git a/nucleus/utils.py b/nucleus/utils.py index d4db15dc..6fd870d7 100644 --- a/nucleus/utils.py +++ b/nucleus/utils.py @@ -47,6 +47,7 @@ SCALE_TASK_INFO_KEY, SCENE_KEY, SEGMENTATION_TYPE, + TYPE_KEY, ) from .dataset_item import DatasetItem from .prediction import ( @@ -131,10 +132,6 @@ def format_prediction_response( keyed by the type name. """ annotation_payload = response.get(ANNOTATIONS_KEY, None) - if not annotation_payload: - # An error occurred - return response - annotation_response = {} type_key_to_class: Dict[ str, Union[ @@ -156,6 +153,24 @@ def format_prediction_response( KEYPOINTS_TYPE: KeypointsPrediction, SEGMENTATION_TYPE: SegmentationPrediction, } + if not annotation_payload: + # Run-free ("model v2") reads (Model.predictions_loc / _refloc / + # _iloc) return a flat list under "predictions", each element carrying + # its own "type", rather than the type-keyed "annotations" dict. Group + # it into the same {type: [obj, ...]} shape those methods promise. + prediction_payload = response.get(PREDICTIONS_KEY, None) + if not prediction_payload: + # An error occurred, or there are no predictions. + return response + annotation_response: Dict[str, list] = {} + for prediction in prediction_payload: + type_key = prediction[TYPE_KEY] + type_class = type_key_to_class[type_key] + annotation_response.setdefault(type_key, []).append( + type_class.from_json(prediction) + ) + return annotation_response + annotation_response = {} for type_key in annotation_payload: type_class = type_key_to_class[type_key] annotation_response[type_key] = [ @@ -230,6 +245,7 @@ def format_scale_task_info_response(response: dict) -> Union[Dict, List[Dict]]: ret.append(row) return ret + # pylint: disable=too-many-branches,too-many-statements def convert_export_payload(api_payload, has_predictions: bool = False): """Helper function to convert raw JSON to API objects