Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ 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.21.0](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.21.0) - 2026-08-19

### Added
- **Dataset-less model runs.** `Model.create_run_without_dataset(name, metadata=None, reference_id=None)` creates a model run that starts with an empty dataset set and is not bound to any dataset up front. The run is not readable until predictions are added, and its dataset set grows as predictions arrive — letting a single run span multiple datasets without naming them ahead of time.
- **`ModelRun.add_predictions(predictions, ...)`** uploads predictions to a dataset-less run via `POST /nucleus/modelRun/:modelRunId/uploadPredictions`. The server resolves each prediction's target item, groups by dataset, and widens the run's dataset set. Supports `update` / `batch_size` / file-batching arguments; `asynchronous=True` raises `NotImplementedError` (use `Dataset.upload_predictions_for_model_run` for async per-dataset uploads).
- **Per-prediction upload targets.** Every prediction type (`box`, `line`, `polygon`, `keypoints`, `cuboid`, `category`, `scene_category`, `segmentation`) now accepts a `dataset_id` constructor kwarg alongside the existing `dataset_item_id`, and emits them in `to_payload` (as `item_id` and `dataset_id`) when set. A prediction targets its item by `dataset_item_id` (preferred) or `dataset_id` + `reference_id`, which is what the dataset-less upload route uses to resolve items. Both are optional and unset by default, so per-dataset uploads are unchanged.

> **Server dependency:** requires the `POST /nucleus/model/:modelId/modelRun/create` and `POST /nucleus/modelRun/:modelRunId/uploadPredictions` routes in scaleapi. Unit tests pass regardless; live calls 404 until that deploys.

## [0.20.2](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.20.2) - 2026-08-18

### Added
Expand Down
14 changes: 11 additions & 3 deletions nucleus/annotation_uploader.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,13 +273,21 @@ def __init__(
if route is not None:
self._route = route
return
if dataset_id is None:
raise ValueError("dataset_id is required to upload predictions.")
if model_run_id is not None and model_id is not None:
raise ValueError("Pass either model_id or model_run_id, not both.")
if model_run_id is not None:
self._route = f"dataset/{dataset_id}/modelRun/{model_run_id}/uploadPredictions"
if dataset_id is not None:
self._route = f"dataset/{dataset_id}/modelRun/{model_run_id}/uploadPredictions"
else:
# Dataset-less model run: predictions carry their own item_id
# (or dataset_id + reference_id); the server groups by dataset
# and widens the run.
self._route = f"modelRun/{model_run_id}/uploadPredictions"
elif model_id is not None:
if dataset_id is None:
raise ValueError(
"dataset_id is required to upload predictions."
)
self._route = (
f"dataset/{dataset_id}/model/{model_id}/uploadPredictions"
)
Expand Down
1 change: 1 addition & 0 deletions nucleus/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
DATASET_IS_SCENE_KEY = "is_scene"
DATASET_ITEM_ID_KEY = "dataset_item_id"
DATASET_ITEM_IDS_KEY = "dataset_item_ids"
ITEM_ID_KEY = "item_id"
DATASET_ITEMS_KEY = "dataset_items"
DATASET_LENGTH_KEY = "length"
DATASET_MODEL_RUNS_KEY = "model_run_ids"
Expand Down
39 changes: 39 additions & 0 deletions nucleus/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,45 @@ def create_run(

return model_run

def create_run_without_dataset(
self,
name: str,
metadata: Optional[Dict] = None,
reference_id: Optional[str] = None,
) -> "ModelRun":
"""Creates a model run that is not bound to any dataset up front.

The run starts with an empty dataset set and is not readable until
predictions are added to it via :meth:`ModelRun.add_predictions`. Each
prediction names its own target item (``dataset_item_id``, or
``dataset_id`` + ``reference_id``); the server groups them by dataset
and widens the run's dataset set as predictions arrive. This is what
lets a single run span multiple datasets.

Args:
name: Human-readable name for the model run.
metadata: Optional arbitrary metadata blob for the run.
reference_id: Optional user-defined reference id for the run.

Returns:
The created :class:`ModelRun` (with ``dataset_id`` unset).
"""
payload: dict = {
NAME_KEY: name,
REFERENCE_ID_KEY: reference_id,
METADATA_KEY: metadata or {},
}
response = self._client.make_request(
payload,
route=f"model/{self.id}/modelRun/create",
requests_command=requests.post,
)
return ModelRun(
model_run_id=response["model_run_id"],
dataset_id=None,
client=self._client,
)

def evaluate(self, scenario_test_names: List[str]) -> AsyncJob:
"""Evaluates this on the specified Unit Tests. ::

Expand Down
84 changes: 82 additions & 2 deletions nucleus/model_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,12 @@ class ModelRun:
This class is deprecated and will be removed from the python client.
"""

def __init__(self, model_run_id: str, dataset_id: str, client):
def __init__(
self,
model_run_id: str,
dataset_id: Optional[str] = None,
client=None,
):
self.model_run_id = model_run_id
self._client = client
self.dataset_id = dataset_id
Expand Down Expand Up @@ -173,7 +178,7 @@ def predict(
"predictions_ignored": int,
}
"""

uploader = PredictionUploader(
client=self._client,
dataset_id=self.dataset_id,
Expand All @@ -184,6 +189,11 @@ def predict(
if asynchronous:
check_all_mask_paths_remote(annotations)

if self.dataset_id is None:
raise ValueError(
"Asynchronous predict() requires a dataset-bound model run. "
"Dataset-less runs must use add_predictions(...)."
)
request_id = serialize_and_write_to_presigned_url(
annotations, self.dataset_id, self._client
)
Expand All @@ -201,6 +211,76 @@ def predict(
local_files_per_upload_request=local_files_per_upload_request,
)

def add_predictions(
self,
predictions: List[
Union[
BoxPrediction,
PolygonPrediction,
CuboidPrediction,
SegmentationPrediction,
]
],
update: bool = False,
asynchronous: bool = False,
batch_size: int = 5000,
remote_files_per_upload_request: int = 20,
local_files_per_upload_request: int = 10,
) -> dict:
"""Uploads predictions to a dataset-less model run.

Unlike :meth:`predict`, this run is not bound to a single dataset. Each
prediction names its own target item, and the server groups the
predictions by dataset and widens the run's dataset set accordingly.
Every prediction must carry either ``dataset_item_id`` (preferred) or
``dataset_id`` + ``reference_id`` so the server can resolve its item.

Args:
predictions: Predictions to upload. Each must carry
``dataset_item_id`` or ``dataset_id`` + ``reference_id``.
update: If True, existing predictions for the same
(reference_id, annotation_id) will be overwritten. If False,
existing predictions will be skipped.
asynchronous: Not supported for dataset-less uploads yet — passing
True raises :class:`NotImplementedError`. Use
``dataset.upload_predictions_for_model_run(...)`` for async
per-dataset uploads.
batch_size: Number of predictions processed in each concurrent batch.
remote_files_per_upload_request: Number of remote segmentation files
to upload in each request.
local_files_per_upload_request: Number of local segmentation files
to upload in each request. The maximum is 10.

Returns::

{
"model_run_id": str,
"predictions_processed": int,
"predictions_ignored": int,
}
"""
if asynchronous:
raise NotImplementedError(
"Asynchronous upload is not supported for dataset-less model "
"runs yet. Use "
"dataset.upload_predictions_for_model_run(model_run_id, "
"predictions, asynchronous=True) for the per-dataset route."
)

uploader = PredictionUploader(
client=self._client,
model_run_id=self.model_run_id,
)
uploader.check_for_duplicate_ids(predictions)

return uploader.upload(
annotations=predictions,
update=update,
batch_size=batch_size,
remote_files_per_upload_request=remote_files_per_upload_request,
local_files_per_upload_request=local_files_per_upload_request,
)

def iloc(self, i: int):
"""Returns Model Run Info For Dataset Item by its number.

Expand Down
Loading