diff --git a/datamint/api/endpoints/projects_api.py b/datamint/api/endpoints/projects_api.py index 48571be4..1b036a6d 100644 --- a/datamint/api/endpoints/projects_api.py +++ b/datamint/api/endpoints/projects_api.py @@ -291,6 +291,32 @@ def set_work_status(self, add_path=f'resources/{resource_id}/status', json=jsondata) + def set_pinned_metrics(self, + metrics: list[str], + project: str | Project | None = None) -> None: + """Set the pinned metrics for a project (replaces the full list). + + Args: + metrics: The full list of metric names to pin (e.g. ``val/accuracy`` + for classification, ``val/iou``/``val/dice`` for segmentation, + ``val/map`` for detection - see :meth:`Project.set_pinned_metrics` + for how the built-in trainers name their logged metrics). + project: The project ID or Project instance. Falls back to the + session's default project (see `datamint.select_project()`) when omitted. + """ + proj_id = self._entid(self._resolve_project_or_default(project)) + self.patch(proj_id, {'pinned_metrics': metrics}) + + def get_pinned_metrics(self, project: str | Project | None = None) -> list[str]: + """Get the pinned metrics for a project (always fetches fresh from the server). + + Args: + project: The project ID or Project instance. Falls back to the + session's default project (see `datamint.select_project()`) when omitted. + """ + proj_id = self._entid(self._resolve_project_or_default(project)) + return self.get_by_id(proj_id).pinned_metrics + # ------------------------------------------------------------------ # Project members # ------------------------------------------------------------------ diff --git a/datamint/entities/project.py b/datamint/entities/project.py index 3e32145d..d3cc8284 100644 --- a/datamint/entities/project.py +++ b/datamint/entities/project.py @@ -46,6 +46,7 @@ class Project(BaseEntity): closed_resources_count: Number of resources marked as closed/completed resources_to_annotate_count: Number of resources still needing annotation annotators: List of annotators assigned to this project + pinned_metrics: Metric names pinned for display for this project """ id: str name: str @@ -66,6 +67,7 @@ class Project(BaseEntity): is_active_learning: bool = Field(default=MISSING_FIELD) two_up_display: bool = Field(default=MISSING_FIELD) require_review: bool = Field(default=MISSING_FIELD) + pinned_metrics: list[str] = Field(default=MISSING_FIELD) _api: 'ProjectsApi' = PrivateAttr() @@ -141,6 +143,29 @@ def set_work_status(self, resource: 'Resource', status: Literal['opened', 'annot return self._api.set_work_status(resource, status, self) + def set_pinned_metrics(self, metrics: list[str]) -> None: + """Set the pinned metrics for this project (replaces the full list). + + Args: + metrics: The full list of metric names to pin. Names should match + what your training runs actually log. The built-in trainers use + a ``{stage}/{metric}`` naming convention, e.g.: + + * Classification: + ``val/accuracy``, ``val/f1``. + * Segmentation, 2D or 3D: + ``val/iou``, ``val/dice``. + * Detection: ``val/map``. + + Example: + >>> project = api.projects.get_by_name("My Project") + >>> # classification project + >>> project.set_pinned_metrics(["val/accuracy", "val/f1"]) + >>> # segmentation project + >>> project.set_pinned_metrics(["val/iou", "val/dice"]) + """ + return self._api.set_pinned_metrics(metrics, self) + @property def url(self) -> str: """Get the URL to access this project in the DataMint web application.""" diff --git a/docs/source/client_api_content.rst b/docs/source/client_api_content.rst index a49712ef..47b18cb1 100644 --- a/docs/source/client_api_content.rst +++ b/docs/source/client_api_content.rst @@ -302,6 +302,9 @@ The :py:class:`~datamint.entities.project.Project` entity provides shortcuts for resource = project.fetch_resources()[0] project.set_work_status(resource, "annotated") + # Pin the metrics that matter most for this project (replaces the full list) + project.set_pinned_metrics(["val/accuracy", "val/f1"]) + specs = project.get_annotations_specs() print([spec.identifier for spec in specs]) diff --git a/tests/test_projects_api.py b/tests/test_projects_api.py index 7e49d8cc..e77aee14 100644 --- a/tests/test_projects_api.py +++ b/tests/test_projects_api.py @@ -63,6 +63,49 @@ def handler(request: httpx.Request) -> httpx.Response: assert requests[3].url.params["resource_id"] == api_ids.resource_id +def test_projects_api_pinned_metrics( + api_config: ApiConfig, + api_ids, + make_client, + decoded_path, + json_body, +) -> None: + requests: list[httpx.Request] = [] + project_payload = { + "id": api_ids.project_id, + "name": "Test Project", + "created_at": "2026-04-13T10:00:00Z", + "created_by": "tester@datamint.io", + "dataset_id": "dataset-1", + "archived": False, + "resource_count": 1, + "description": None, + "pinned_metrics": ["val/accuracy", "val/f1"], + } + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + path = decoded_path(request) + if request.method == "PATCH" and path == f"/projects/{api_ids.project_id}": + return httpx.Response(200, json={}) + if request.method == "GET" and path == f"/projects/{api_ids.project_id}": + return httpx.Response(200, json=project_payload) + raise AssertionError(f"Unexpected request: {request.method} {request.url}") + + with make_client(handler) as client: + projects_api = ProjectsApi(api_config, client=client) + + projects_api.set_pinned_metrics(["val/accuracy", "val/f1"], project=api_ids.project_id) + pinned = projects_api.get_pinned_metrics(project=api_ids.project_id) + + project = projects_api.get_by_id(api_ids.project_id) + project.set_pinned_metrics(["val/iou", "val/dice"]) + + assert json_body(requests[0]) == {"pinned_metrics": ["val/accuracy", "val/f1"]} + assert pinned == ["val/accuracy", "val/f1"] + assert json_body(requests[3]) == {"pinned_metrics": ["val/iou", "val/dice"]} + + def test_projects_api_download_annotations_streams_export_to_disk( api_config: ApiConfig, api_ids,