diff --git a/.flake8 b/.flake8 index 6ef4364..fb743b2 100644 --- a/.flake8 +++ b/.flake8 @@ -1,4 +1,4 @@ [flake8] max-line-length = 200 extend-ignore = E203, W503, E402, F405 -exclude = .git,__pycache__,.venv,build,dist,venv,driver.py +exclude = .git,__pycache__,.venv,build,dist,venv,driver.py,.history diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index eaaccc7..d1bed36 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -2,9 +2,13 @@ name: Claude Auto Review on: pull_request: types: [opened, synchronize] + paths-ignore: + - "**/*.md" + - "docs/**" jobs: review: + if: github.event.pull_request.update_count < 3 runs-on: ubuntu-latest permissions: contents: read @@ -31,7 +35,7 @@ jobs: - Security concerns - Test coverage - Code repeatability - - Over engineering + - Over engineering Note: The PR branch is already checked out in the current working directory. @@ -40,4 +44,4 @@ jobs: Only post GitHub comments - don't submit review text as messages. claude_args: | - --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)" \ No newline at end of file + --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)" diff --git a/.gitignore b/.gitignore index 1812a6f..4a8ebad 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ download labellerr/__pycache__/ env.* claude.md +.history/ diff --git a/labellerr/core/annotation_templates/__init__.py b/labellerr/core/annotation_templates/__init__.py index f89441b..2bd2f7a 100644 --- a/labellerr/core/annotation_templates/__init__.py +++ b/labellerr/core/annotation_templates/__init__.py @@ -1,8 +1,15 @@ -from .base import LabellerrAnnotationTemplate -from ..schemas.annotation_templates import CreateTemplateParams, QuestionType, Option +import uuid +from typing import List + from .. import constants from ..client import LabellerrClient -import uuid +from ..schemas.annotation_templates import ( + CreateTemplateParams, + DatasetDataType, + Option, + QuestionType, +) +from .base import LabellerrAnnotationTemplate __all__ = [ "LabellerrAnnotationTemplate", @@ -62,3 +69,59 @@ def create_template( client=client, annotation_template_id=response.get("response", None).get("template_id"), ) + + +def list_templates( + client: LabellerrClient, data_type: DatasetDataType +) -> List[LabellerrAnnotationTemplate]: + """ + List all annotation templates for a given data type + + :param client: The client to use for the request. + :param data_type: The data type to list templates for. + :return: A list of LabellerrAnnotationTemplate instances. + """ + unique_id = str(uuid.uuid4()) + url = ( + f"{constants.BASE_URL}/annotations/list_questions_templates?client_id={client.client_id}&data_type={data_type.value}" + f"&uuid={unique_id}" + ) + + response = client.make_request( + "GET", + url, + extra_headers={"content-type": "application/json"}, + request_id=unique_id, + ) + return [ + LabellerrAnnotationTemplate.from_annotation_template_data(client, **item) + for item in response.get("response", []) + ] + + +def list_templates( + client: LabellerrClient, data_type: DatasetDataType +) -> List[LabellerrAnnotationTemplate]: + """ + List all annotation templates for a given data type + + :param client: The client to use for the request. + :param data_type: The data type to list templates for. + :return: A list of LabellerrAnnotationTemplate instances. + """ + unique_id = str(uuid.uuid4()) + url = ( + f"{constants.BASE_URL}/annotations/list_questions_templates?client_id={client.client_id}&data_type={data_type.value}" + f"&uuid={unique_id}" + ) + + response = client.make_request( + "GET", + url, + extra_headers={"content-type": "application/json"}, + request_id=unique_id, + ) + return [ + LabellerrAnnotationTemplate.from_annotation_template_data(client, **item) + for item in response.get("response", []) + ] diff --git a/labellerr/core/annotation_templates/base.py b/labellerr/core/annotation_templates/base.py index 21a24af..3960581 100644 --- a/labellerr/core/annotation_templates/base.py +++ b/labellerr/core/annotation_templates/base.py @@ -1,7 +1,8 @@ +import uuid + from .. import constants from ..client import LabellerrClient from ..exceptions import InvalidAnnotationTemplateError -import uuid class LabellerrAnnotationTemplate: @@ -24,8 +25,18 @@ def get_annotation_template(client: "LabellerrClient", annotation_template_id: s """Base class for all Labellerr projects with factory behavior""" - def __new__(cls, client: "LabellerrClient", annotation_template_id: str): - # Validate that the annotation template exists before creating the instance + def __new__( + cls, + client: "LabellerrClient", + annotation_template_id: str, + _skip_api_fetch: bool = False, + **kwargs, + ): + # If skip flag is set, create instance without API call + if _skip_api_fetch: + return super().__new__(cls) + + # Otherwise, fetch from API and validate annotation_template_data = cls.get_annotation_template( client, annotation_template_id ) @@ -37,14 +48,82 @@ def __new__(cls, client: "LabellerrClient", annotation_template_id: str): f"Annotation template with ID '{annotation_template_id}' does not exist or could not be retrieved." ) - # Create the instance only if validation passes - instance = super().__new__(cls) - # Store the data on the instance to avoid calling API again in __init__ - instance.__annotation_template_data = annotation_template_data - return instance + # Pass fetched data to __init__ via kwargs + kwargs["_fetched_data"] = annotation_template_data + return super().__new__(cls) - def __init__(self, client: "LabellerrClient", annotation_template_id: str): + def __init__( + self, + client: "LabellerrClient", + annotation_template_id: str, + _skip_api_fetch: bool = False, + **kwargs, + ): self.client = client - self.annotation_template_id = annotation_template_id - # Use the data already fetched in __new__ - self.annotation_template_data = self.__annotation_template_data + self.__annotation_template_id = annotation_template_id + + # Set __annotation_template_data from either source + if "_cached_data" in kwargs: + # Data provided directly (from factory method) + self.__annotation_template_data = kwargs["_cached_data"] + elif "_fetched_data" in kwargs: + # Data fetched in __new__ + self.__annotation_template_data = kwargs["_fetched_data"] + else: + # Fallback - shouldn't happen in normal usage + self.__annotation_template_data = {} + + @classmethod + def from_annotation_template_data(cls, client: "LabellerrClient", **kwargs): + """ + Create a LabellerrAnnotationTemplate instance from annotation template data. + + :param client: LabellerrClient instance + :param kwargs: Annotation template fields (template_id, template_name, questions, etc.) + :return: Instance of LabellerrAnnotationTemplate + """ + # Validate required fields + required_fields = { + "template_id", + "template_name", + "questions", + "created_at", + "created_by", + } + missing_fields = required_fields - set(kwargs.keys()) + if missing_fields: + raise ValueError( + f"Missing required fields in annotation_template_data: {missing_fields}" + ) + + # Create instance without API call - explicit flag makes intent clear + return cls( + client, + annotation_template_id=kwargs.get("template_id"), + _skip_api_fetch=True, + _cached_data=kwargs, + ) + + @property + def template_name(self): + return self.__annotation_template_data.get("template_name") + + @property + def data_type(self): + return self.__annotation_template_data.get("data_type") + + @property + def annotation_template_id(self): + return self.__annotation_template_id + + @property + def created_at(self): + return self.__annotation_template_data.get("created_at") + + @property + def created_by(self): + return self.__annotation_template_data.get("created_by") + + @property + def questions(self): + return self.__annotation_template_data.get("questions") diff --git a/labellerr/core/constants.py b/labellerr/core/constants.py index 5484189..ccd2d06 100644 --- a/labellerr/core/constants.py +++ b/labellerr/core/constants.py @@ -1,4 +1,4 @@ -BASE_URL = "https://api.labellerr.com" +BASE_URL = "https://api-gateway-qcb3iv2gaa-uc.a.run.app" ALLOWED_ORIGINS = "https://pro.labellerr.com" @@ -7,7 +7,7 @@ TOTAL_FILES_SIZE_LIMIT_PER_DATASET = 2.5 * 1024 * 1024 * 1024 # 2.5GB TOTAL_FILES_COUNT_LIMIT_PER_DATASET = 2500 -ANNOTATION_FORMAT = ["json", "coco_json", "csv", "png"] +ANNOTATION_FORMAT = ["json", "coco_json", "csv", "png", "video_json"] LOCAL_EXPORT_FORMAT = ["json", "coco_json", "csv", "png"] LOCAL_EXPORT_STATUS = [ "review", diff --git a/labellerr/core/datasets/base.py b/labellerr/core/datasets/base.py index b41355f..a48ddd0 100644 --- a/labellerr/core/datasets/base.py +++ b/labellerr/core/datasets/base.py @@ -4,14 +4,13 @@ import logging import uuid from abc import ABCMeta -from typing import Dict, Any, List, TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Dict, Generator from .. import constants -from ..exceptions import InvalidDatasetError, LabellerrError from ..client import LabellerrClient - -from ..files import LabellerrFile from ..connectors import LabellerrConnection +from ..exceptions import InvalidDatasetError, LabellerrError +from ..files import LabellerrFile if TYPE_CHECKING: from ..projects import LabellerrProject @@ -175,15 +174,22 @@ def on_success(dataset_data): on_success=on_success, ) - def fetch_files(self, page_size: int = 1000) -> List[LabellerrFile]: + def fetch_files( + self, page_size: int = 1000 + ) -> Generator[LabellerrFile, None, None]: + def fetch_files( + self, page_size: int = 1000 + ) -> Generator[LabellerrFile, None, None]: """ Fetch all files in this dataset as LabellerrFile instances. - :param page_size: Number of files to fetch per API request (default: 10) - :return: List of file IDs + :param page_size: Number of files to fetch per API request (default: 1000) + :return: Generator yielding LabellerrFile instances + :param page_size: Number of files to fetch per API request (default: 1000) + :return: Generator yielding LabellerrFile instances """ - print(f"Fetching files for dataset: {self.dataset_id}") - file_ids = [] + logging.info(f"Fetching files for dataset: {self.dataset_id}") + logging.info(f"Fetching files for dataset: {self.dataset_id}") next_search_after = None # Start with None for first page while True: @@ -205,15 +211,26 @@ def fetch_files(self, page_size: int = 1000) -> List[LabellerrFile]: response = self.client.make_request( "GET", url, extra_headers=None, request_id=unique_id, params=params ) - print(response) # Extract files from the response files = response.get("response", {}).get("files", []) # Collect file IDs - for file_info in files: - file_id = file_info.get("file_id") - if file_id: - file_ids.append(file_id) + for file_data in files: + try: + _file = LabellerrFile.from_file_data(self.client, file_data) + yield _file + except LabellerrError as e: + logging.warning( + f"Warning: Failed to create file instance for {file_data.get('file_id')}: {str(e)}" + ) + for file_data in files: + try: + _file = LabellerrFile.from_file_data(self.client, file_data) + yield _file + except LabellerrError as e: + logging.warning( + f"Warning: Failed to create file instance for {file_data.get('file_id')}: {str(e)}" + ) # Get next_search_after for pagination next_search_after = response.get("response", {}).get("next_search_after") @@ -222,23 +239,6 @@ def fetch_files(self, page_size: int = 1000) -> List[LabellerrFile]: if not next_search_after or not files: break - files = [] - - for file_id in file_ids: - try: - _file = LabellerrFile( - client=self.client, - file_id=file_id, - dataset_id=self.dataset_id, - ) - files.append(_file) - except LabellerrError as e: - logging.warning( - f"Warning: Failed to create file instance for {file_id}: {str(e)}" - ) - - return files - def sync_with_connection( self, project: "LabellerrProject", diff --git a/labellerr/core/exports/base.py b/labellerr/core/exports/base.py index 39ebb63..aa1770e 100644 --- a/labellerr/core/exports/base.py +++ b/labellerr/core/exports/base.py @@ -2,9 +2,9 @@ Export class for handling export operations with status tracking and polling. """ -from typing import TYPE_CHECKING, Dict, Any, Optional -import logging import json +import logging +from typing import TYPE_CHECKING, Any, Dict, Optional if TYPE_CHECKING: from ..projects.base import LabellerrProject @@ -39,9 +39,6 @@ def report_id(self) -> str: def _status(self) -> Dict[str, Any]: """Get current export status (single check).""" response = self._project.check_export_status([self._report_id]) - if isinstance(response, str): - response = json.loads(response) - for status_item in response.get("status", []): if status_item.get("report_id") == self._report_id: return status_item @@ -58,8 +55,6 @@ def status( def get_status(): response = self._project.check_export_status([self._report_id]) - if isinstance(response, str): - response = json.loads(response) return response def is_completed(response_data): diff --git a/labellerr/core/files/base.py b/labellerr/core/files/base.py index b504e1d..584d59d 100644 --- a/labellerr/core/files/base.py +++ b/labellerr/core/files/base.py @@ -97,6 +97,33 @@ def __init__( self.client = client self.__file_data = kwargs.get("file_data", {}) + @classmethod + def from_file_data(cls, client: "LabellerrClient", file_data: dict): + """ + Create a LabellerrFile instance from file_data dictionary. + + :param client: LabellerrClient instance + :param file_data: Dictionary containing file information + :return: Instance of appropriate LabellerrFile subclass based on data_type + """ + # Validate required fields + required_fields = {"file_id", "file_name", "data_type", "file_metadata"} + missing_fields = required_fields - set(file_data.keys()) + if missing_fields: + raise ValueError(f"Missing required fields in file_data: {missing_fields}") + + data_type = file_data.get("data_type", "").lower() + + # Get the appropriate file class from registry + file_class = LabellerrFileMeta._registry.get(data_type) + if file_class is None: + raise LabellerrError(f"Unsupported file type: {data_type}") + + # Create instance with file_data + return file_class( + client=client, file_id=file_data.get("file_id"), file_data=file_data + ) + @property def file_id(self): return self.__file_data.get("file_id", "") @@ -112,3 +139,11 @@ def dataset_id(self): @property def metadata(self): return self.__file_data.get("file_metadata", {}) + + @property + def file_name(self): + return self.__file_data.get("file_name", "") + + @property + def data_type(self): + return self.__file_data.get("data_type") diff --git a/labellerr/core/files/video_file.py b/labellerr/core/files/video_file.py index cb97956..43fab0f 100644 --- a/labellerr/core/files/video_file.py +++ b/labellerr/core/files/video_file.py @@ -61,6 +61,7 @@ def get_frames(self, frame_start: int = 0, frame_end: int | None = None): "frame_end": frame_end, "project_id": self.project_id, "uuid": unique_id, + "client_id": self.client.client_id, } response = self.client.make_request( @@ -115,8 +116,15 @@ def download_frames( :return: Dictionary with download statistics """ try: - # Use file_id as folder name - folder_name = self.file_id + # Use [Dataset_id]+[File_id]+[File_name] as folder name + if self.dataset_id and self.file_name: + # Remove extension from file_name if present + base_name = os.path.splitext(self.file_name)[0] + folder_name = f"{self.dataset_id}+{self.file_id}+{base_name}" + elif self.dataset_id: + folder_name = f"{self.dataset_id}+{self.file_id}" + else: + folder_name = self.file_id # Set output path if output_folder: @@ -207,7 +215,13 @@ def create_video( input_pattern = os.path.join(frames_folder, pattern) if output_file is None: - output_file = f"{self.file_id}.mp4" + # Use [Dataset_id]+[File_id]+[File_name] as default output filename + if self.dataset_id and self.file_name: + output_file = f"{self.dataset_id}+{self.file_id}+{self.file_name}" + elif self.dataset_id: + output_file = f"{self.dataset_id}+{self.file_id}.mp4" + else: + output_file = f"{self.file_id}.mp4" # FFmpeg command command = [ @@ -235,7 +249,7 @@ def create_video( raise LabellerrError(f"Error while joining frames: {str(e)}") def download_create_video_auto_cleanup( - self, output_folder: str = "./Labellerr_datastets" + self, output_folder: str = "./Labellerr_datasets" ): """ Download frames, create video, and automatically clean up temporary frames. @@ -258,26 +272,33 @@ def download_create_video_auto_cleanup( print(f"\n[1/4] Fetching frame data from API (0 to {total_frames})...") frames_data = self.get_frames(frame_start=0, frame_end=total_frames) + # print(frames_data) + if not frames_data: raise LabellerrError("No frame data retrieved from API") print(f"Retrieved {len(frames_data)} frames") - # Step 2: Create dataset folder structure + # Step 2: Create output folder structure print("\n[2/4] Setting up output folders...") - if self.dataset_id is None: - dataset_folder = output_folder + # Videos will be saved directly in output_folder (labellerr_datasets) + os.makedirs(output_folder, exist_ok=True) + + # Define actual frames folder path using [Dataset_id]+[File_id]+[File_name] naming + # Frames will be temporarily stored in a subfolder for organization + if self.dataset_id and self.file_name: + base_name = os.path.splitext(self.file_name)[0] + folder_name = f"{self.dataset_id}+{self.file_id}+{base_name}" + elif self.dataset_id: + folder_name = f"{self.dataset_id}+{self.file_id}" else: - dataset_folder = os.path.join(output_folder, self.dataset_id) - os.makedirs(dataset_folder, exist_ok=True) - - # Define actual frames folder path - actual_frames_folder = os.path.join(dataset_folder, self.file_id) + folder_name = self.file_id + actual_frames_folder = os.path.join(output_folder, folder_name) # Step 3: Download frames print("\n[3/4] Downloading frames...") download_result = self.download_frames( - frames_data=frames_data, output_folder=dataset_folder + frames_data=frames_data, output_folder=output_folder ) if download_result["failed_downloads"] > 0: @@ -285,9 +306,16 @@ def download_create_video_auto_cleanup( f"\nWarning: {download_result['failed_downloads']} frames failed to download" ) - # Step 4: Create video from downloaded frames + # Step 4: Create video from downloaded frames using [Dataset_id]+[File_id]+[File_name] naming + # Save video directly in output_folder (labellerr_datasets) print("\n[4/4] Creating video from frames...") - video_output_path = os.path.join(dataset_folder, f"{self.file_id}.mp4") + if self.dataset_id and self.file_name: + video_filename = f"{self.dataset_id}+{self.file_id}+{self.file_name}" + elif self.dataset_id: + video_filename = f"{self.dataset_id}+{self.file_id}.mp4" + else: + video_filename = f"{self.file_id}.mp4" + video_output_path = os.path.join(output_folder, video_filename) self.create_video( frames_folder=actual_frames_folder, output_file=video_output_path @@ -304,7 +332,7 @@ def download_create_video_auto_cleanup( "file_id": self.file_id, "dataset_id": self.dataset_id, "video_path": video_output_path, - "output_folder": dataset_folder, + "output_folder": output_folder, "frames_downloaded": download_result["successful_downloads"], "frames_failed": download_result["failed_downloads"], "failed_frames_info": download_result["failed_frames"], @@ -313,19 +341,22 @@ def download_create_video_auto_cleanup( print(f"\n{'='*60}") print("Processing complete!") print(f"Video saved to: {video_output_path}") - print("{'='*60}\n") + print(f"{'='*60}\n") return result except Exception as e: # Attempt cleanup on error - # Get the frames folder path + # Get the frames folder path using [Dataset_id]+[File_id]+[File_name] naming if self.dataset_id is None: cleanup_folder = os.path.join(output_folder, self.file_id) else: - cleanup_folder = os.path.join( - output_folder, self.dataset_id, self.file_id - ) + if self.file_name: + base_name = os.path.splitext(self.file_name)[0] + folder_name = f"{self.dataset_id}+{self.file_id}+{base_name}" + else: + folder_name = f"{self.dataset_id}+{self.file_id}" + cleanup_folder = os.path.join(output_folder, folder_name) if os.path.exists(cleanup_folder): shutil.rmtree(cleanup_folder) diff --git a/labellerr/core/projects/__init__.py b/labellerr/core/projects/__init__.py index a5eb0c6..0ca1c87 100644 --- a/labellerr/core/projects/__init__.py +++ b/labellerr/core/projects/__init__.py @@ -1,7 +1,6 @@ import json import uuid -import requests import requests from labellerr import LabellerrClient @@ -17,7 +16,6 @@ from ..annotation_templates import LabellerrAnnotationTemplate from typing import List from concurrent.futures import ThreadPoolExecutor -from concurrent.futures import ThreadPoolExecutor __all__ = [ "LabellerrProject", diff --git a/labellerr/core/projects/base.py b/labellerr/core/projects/base.py index c7967bf..cf32138 100644 --- a/labellerr/core/projects/base.py +++ b/labellerr/core/projects/base.py @@ -11,11 +11,10 @@ import requests from .. import client_utils, constants, schemas +from ..client import LabellerrClient from ..exceptions import InvalidProjectError, LabellerrError -from .utils import poll from ..exports import Export - -from ..client import LabellerrClient +from .utils import poll class LabellerrProjectMeta(ABCMeta): @@ -467,7 +466,7 @@ def create_export(self, export_config: schemas.CreateExportParams): report_id = response.get("response", {}).get("report_id") return Export(report_id=report_id, project=self) - def create_local_export(self, export_config): + def create_local_export(self, export_config: schemas.CreateExportParams): """ Creates a local export with the given configuration. @@ -475,13 +474,15 @@ def create_local_export(self, export_config): :return: Export instance with report_id and status tracking :raises LabellerrError: If the export creation fails """ - # Validate export config using client_utils - client_utils.validate_export_config(export_config) unique_id = client_utils.generate_request_id() - export_config.update({"export_destination": "local", "question_ids": ["all"]}) - payload = json.dumps(export_config) + export_config_dict = export_config.model_dump() + export_config_dict.update( + {"export_destination": schemas.ExportDestination.LOCAL.value} + ) + + payload = json.dumps(export_config_dict) response = self.client.make_request( "POST", @@ -496,6 +497,37 @@ def create_local_export(self, export_config): report_id = response.get("response", {}).get("report_id") return Export(report_id=report_id, project=self) + def list_exports(self): + """ + Lists all exports for the project. + + :return: ExportsListResponse + :raises LabellerrError: If the request fails + """ + request_uuid = client_utils.generate_request_id() + url = f"{constants.BASE_URL}/exports/list?project_id={self.project_id}&uuid={request_uuid}&client_id={self.client.client_id}" + + response = self.client.make_request( + "GET", + url, + request_id=request_uuid, + ) + if not response: + raise LabellerrError( + f"No response received from list exports API (request_id: {request_uuid})" + ) + if "response" not in response: + error_msg = response.get("error", "Unknown error") + raise LabellerrError( + f"List exports API failed: {error_msg} (request_id: {request_uuid})" + ) + response_data = response.get("response", {}) + + return schemas.ExportsListResponse( + completed=response_data.get("completed", []), + in_progress=response_data.get("inProgress", []), + ) + def check_export_status(self, report_ids: list[str]): request_uuid = client_utils.generate_request_id() try: @@ -516,22 +548,21 @@ def check_export_status(self, report_ids: list[str]): ) # Now process each report_id - for status_item in result.get("status", []): + for idx, status_item in enumerate(result.get("status", [])): if ( status_item.get("is_completed") and status_item.get("export_status") == "Created" ): # Download URL if job completed - download_url = ( # noqa E999 todo check use of that - self.__fetch_exports_download_url( - project_id=self.project_id, - uuid=request_uuid, - export_id=status_item["report_id"], - client_id=self.client.client_id, - ) + download_url = self.__fetch_exports_download_url( + project_id=self.project_id, + uuid=request_uuid, + export_id=status_item["report_id"], + client_id=self.client.client_id, ) + result["status"][idx]["download_url"] = download_url - return json.dumps(result, indent=2) + return result except requests.exceptions.RequestException as e: logging.error(f"Failed to check export status: {str(e)}") diff --git a/labellerr/core/projects/video_project.py b/labellerr/core/projects/video_project.py index a70d21c..6d6b912 100644 --- a/labellerr/core/projects/video_project.py +++ b/labellerr/core/projects/video_project.py @@ -1,5 +1,6 @@ +import os import uuid -from typing import List +from typing import Any, Dict, List from .. import constants from ..exceptions import LabellerrError @@ -91,5 +92,56 @@ def delete_keyframes(self, file_id: str, keyframes: List[int]): except Exception as e: raise LabellerrError(f"Failed to delete key frames: {str(e)}") + def upload_keyframe_preannotations(self, video_json_file_path: str = None) -> Any: + """ + Uploads pre-annotations for video project. + + Supports both the parent signature and a video-specific signature for backward compatibility. + + :param annotation_format: (Deprecated) The format of the preannotation data + :param annotation_file: (Deprecated) The file path of the preannotation data + :param conf_bucket: (Deprecated) Confidence bucket [low, medium, high] + :param _async: (Deprecated) Whether to return a future object + :param video_json_file_path: Path to the video JSON file containing pre-annotations + :return: Response from the API + """ + # Support both old and new signatures + file_path = video_json_file_path + + # Parameter validation + if not isinstance(file_path, str): + raise LabellerrError("file_path must be a str") + + try: + # Validate if the file exists + if not os.path.exists(file_path): + raise LabellerrError(f"File not found: {file_path}") + + unique_id = str(uuid.uuid4()) + url = f"{constants.BASE_URL}/actions/upload_answers?project_id={self.project_id}&answer_format=video_json&client_id={self.client.client_id}&uuid={unique_id}" + + # Get file name from path + file_name = os.path.basename(file_path) + + # Open file and prepare multipart form data + with open(file_path, "rb") as f: + files = [("file", (file_name, f, "application/json"))] + payload: Dict[Any, Any] = {} + + response = self.client.make_request( + "POST", + url, + request_id=unique_id, + handle_response=False, + data=payload, + files=files, + ) + + return self.client.handle_upload_response(response, unique_id) + except LabellerrError: + raise + except Exception as e: + raise LabellerrError(f"Failed to upload pre-annotations: {str(e)}") + LabellerrProjectMeta._register(DatasetDataType.video, VideoProject) diff --git a/labellerr/core/schemas/__init__.py b/labellerr/core/schemas/__init__.py index 5579571..d6b06ed 100644 --- a/labellerr/core/schemas/__init__.py +++ b/labellerr/core/schemas/__init__.py @@ -14,18 +14,30 @@ # Import from autolabel.typings for backward compatibility from labellerr.core.autolabel.typings import * # noqa: F403, F401 +# Export annotation templates +# Export annotation templates +from labellerr.core.schemas.annotation_templates import ( + AnnotationQuestion, + Option, + QuestionType, +) + +# Autolabel schemas +# Autolabel schemas +from labellerr.core.schemas.autolabel import Hyperparameters, KeyFrame, TrainingRequest + # Base custom types from labellerr.core.schemas.base import DirPathStr, FilePathStr, NonEmptyStr # Connection schemas from labellerr.core.schemas.connectors import ( AWSConnectionParams, - DatasetDataType, - DeleteConnectionParams, - GCSConnectionParams, AWSConnectionTestParams, ConnectionType, ConnectorType, + DatasetDataType, + DeleteConnectionParams, + GCSConnectionParams, GCSConnectionTestParams, ) @@ -43,6 +55,14 @@ UploadFilesParams, ) +# Export schemas +# Export schemas +from labellerr.core.schemas.exports import ( + CreateExportParams, + ExportDestination, + ExportsListResponse, +) + # File operation schemas from labellerr.core.schemas.files import BulkAssignFilesParams, ListFileParams @@ -65,25 +85,6 @@ UpdateUserRoleParams, ) -# Autolabel schemas -from labellerr.core.schemas.autolabel import ( - Hyperparameters, - KeyFrame, - TrainingRequest, -) - -# Export schemas -from labellerr.core.schemas.exports import CreateExportParams, ExportDestination - - -# Export annotation templates -from labellerr.core.schemas.annotation_templates import ( - AnnotationQuestion, - Option, - QuestionType, -) - - __all__ = [ # Base types "NonEmptyStr", @@ -132,6 +133,8 @@ # Export schemas "CreateExportParams", "ExportDestination", + "ExportsListResponse", + "ExportsListResponse", # Annotation templates schemas "AnnotationQuestion", "Option", diff --git a/labellerr/core/schemas/annotation_templates.py b/labellerr/core/schemas/annotation_templates.py index 4885737..8120ce9 100644 --- a/labellerr/core/schemas/annotation_templates.py +++ b/labellerr/core/schemas/annotation_templates.py @@ -1,8 +1,10 @@ -from pydantic import BaseModel, Field -from typing import List, Optional -from enum import Enum -from ..schemas import DatasetDataType import uuid +from enum import Enum +from typing import List, Optional + +from pydantic import BaseModel, Field + +from .base import DatasetDataType class QuestionType(str, Enum): diff --git a/labellerr/core/schemas/exports.py b/labellerr/core/schemas/exports.py index 05a842d..9e977f1 100644 --- a/labellerr/core/schemas/exports.py +++ b/labellerr/core/schemas/exports.py @@ -1,6 +1,7 @@ -from pydantic import BaseModel, Field -from typing import List, Optional from enum import Enum +from typing import Dict, List, Optional + +from pydantic import BaseModel, Field class ExportDestination(str, Enum): @@ -15,6 +16,26 @@ class CreateExportParams(BaseModel): export_description: str = Field(min_length=1) export_format: str = Field(min_length=1) statuses: List[str] = Field(min_length=1) + question_ids: List[str] = Field(default=["all"]) + question_ids: List[str] = Field(default=["all"]) export_destination: ExportDestination = Field(default=ExportDestination.LOCAL) connection_id: Optional[str] = None export_folder_path: Optional[str] = None + updated_after_timestamp: Optional[int] = Field( + default=None, + description="Unix Timestamp in milliseconds to filter files to be exported based on last updated time", + ) + + +class ExportsListResponse(BaseModel): + completed: List[Dict] = Field(default=[]) + in_progress: List[Dict] = Field(default=[]) + updated_after_timestamp: Optional[int] = Field( + default=None, + description="Unix Timestamp in milliseconds to filter files to be exported based on last updated time", + ) + + +class ExportsListResponse(BaseModel): + completed: List[Dict] = Field(default=[]) + in_progress: List[Dict] = Field(default=[]) diff --git a/labellerr/core/schemas/projects.py b/labellerr/core/schemas/projects.py index 830853b..a70f0b5 100644 --- a/labellerr/core/schemas/projects.py +++ b/labellerr/core/schemas/projects.py @@ -51,7 +51,6 @@ class CreateProjectParams(BaseModel): class CreateTemplateParams(BaseModel): """Parameters for creating an annotation template.""" - client_id: str = Field(min_length=1) data_type: Literal["image", "video", "audio", "document", "text"] template_name: str = Field(min_length=1) questions: List[Question] = Field(min_length=1) diff --git a/labellerr/mcp_server/server.py b/labellerr/mcp_server/server.py index 0e42133..9cc0c72 100644 --- a/labellerr/mcp_server/server.py +++ b/labellerr/mcp_server/server.py @@ -6,42 +6,37 @@ the SDK core module for all API operations. """ -import os -import sys -import json import asyncio +import json import logging +import os +import sys import uuid from datetime import datetime from typing import Any, Dict, List, Optional from mcp.server import Server from mcp.server.stdio import stdio_server -from mcp.types import ( - Tool, - TextContent, - Resource, -) +from mcp.types import Resource, TextContent, Tool # Import SDK core modules from labellerr.core import LabellerrClient -from labellerr.core.exceptions import LabellerrError +from labellerr.core import annotation_templates as template_ops from labellerr.core import datasets as dataset_ops from labellerr.core import projects as project_ops -from labellerr.core import annotation_templates as template_ops +from labellerr.core import schemas +from labellerr.core.annotation_templates import LabellerrAnnotationTemplate from labellerr.core.datasets import LabellerrDataset from labellerr.core.datasets.base import LabellerrDatasetMeta from labellerr.core.datasets.utils import upload_files, upload_folder_files_to_dataset +from labellerr.core.exceptions import LabellerrError from labellerr.core.projects import LabellerrProject from labellerr.core.projects.base import LabellerrProjectMeta -from labellerr.core.annotation_templates import LabellerrAnnotationTemplate -from labellerr.core import schemas +from labellerr.core.schemas.annotation_templates import AnnotationQuestion from labellerr.core.schemas.annotation_templates import ( CreateTemplateParams as TemplateParams, - AnnotationQuestion, - QuestionType, - Option, ) +from labellerr.core.schemas.annotation_templates import Option, QuestionType # Import tool definitions try: @@ -52,8 +47,8 @@ # Configure logging logging.basicConfig( level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=[logging.StreamHandler(sys.stderr)] + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[logging.StreamHandler(sys.stderr)], ) logger = logging.getLogger(__name__) @@ -90,9 +85,7 @@ def _initialize_client(self): try: self.client = LabellerrClient( - api_key=api_key, - api_secret=api_secret, - client_id=self.client_id + api_key=api_key, api_secret=api_secret, client_id=self.client_id ) logger.info("Labellerr SDK client initialized successfully") except Exception as e: @@ -108,7 +101,7 @@ async def list_tools() -> list[Tool]: Tool( name=tool["name"], description=tool["description"], - inputSchema=tool["inputSchema"] + inputSchema=tool["inputSchema"], ) for tool in ALL_TOOLS ] @@ -117,12 +110,17 @@ async def list_tools() -> list[Tool]: async def call_tool(name: str, arguments: dict) -> list[TextContent]: """Handle tool execution""" if not self.client: - return [TextContent( - type="text", - text=json.dumps({ - "error": "SDK client not initialized. Please check environment variables." - }, indent=2) - )] + return [ + TextContent( + type="text", + text=json.dumps( + { + "error": "SDK client not initialized. Please check environment variables." + }, + indent=2, + ), + ) + ] try: # Route to appropriate handler based on tool category @@ -139,46 +137,53 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: else: result = {"error": f"Unknown tool: {name}"} - return [TextContent( - type="text", - text=json.dumps(result, indent=2, default=str) - )] + return [ + TextContent( + type="text", text=json.dumps(result, indent=2, default=str) + ) + ] except LabellerrError as e: logger.error(f"SDK error in tool execution: {e}", exc_info=True) # Log operation for history - self.operation_history.append({ - "timestamp": datetime.now().isoformat(), - "tool": name, - "status": "failed", - "error": str(e) - }) + self.operation_history.append( + { + "timestamp": datetime.now().isoformat(), + "tool": name, + "status": "failed", + "error": str(e), + } + ) - return [TextContent( - type="text", - text=json.dumps({ - "error": f"SDK Error: {str(e)}" - }, indent=2) - )] + return [ + TextContent( + type="text", + text=json.dumps({"error": f"SDK Error: {str(e)}"}, indent=2), + ) + ] except Exception as e: logger.error(f"Tool execution failed: {e}", exc_info=True) # Log operation for history - self.operation_history.append({ - "timestamp": datetime.now().isoformat(), - "tool": name, - "status": "failed", - "error": str(e) - }) + self.operation_history.append( + { + "timestamp": datetime.now().isoformat(), + "tool": name, + "status": "failed", + "error": str(e), + } + ) - return [TextContent( - type="text", - text=json.dumps({ - "error": f"Tool execution failed: {str(e)}" - }, indent=2) - )] + return [ + TextContent( + type="text", + text=json.dumps( + {"error": f"Tool execution failed: {str(e)}"}, indent=2 + ), + ) + ] @self.server.list_resources() async def list_resources() -> list[Resource]: @@ -187,30 +192,38 @@ async def list_resources() -> list[Resource]: # Add active projects as resources for project_id, project in self.active_projects.items(): - resources.append(Resource( - uri=f"labellerr://project/{project_id}", - name=project.get("project_name", project_id), - mimeType="application/json", - description=(f"Project: {project.get('project_name', project_id)} " - f"({project.get('data_type', 'unknown')})") - )) + resources.append( + Resource( + uri=f"labellerr://project/{project_id}", + name=project.get("project_name", project_id), + mimeType="application/json", + description=( + f"Project: {project.get('project_name', project_id)} " + f"({project.get('data_type', 'unknown')})" + ), + ) + ) # Add active datasets as resources for dataset_id, dataset in self.active_datasets.items(): - resources.append(Resource( - uri=f"labellerr://dataset/{dataset_id}", - name=dataset.get("name", dataset_id), - mimeType="application/json", - description=f"Dataset: {dataset.get('name', dataset_id)}" - )) + resources.append( + Resource( + uri=f"labellerr://dataset/{dataset_id}", + name=dataset.get("name", dataset_id), + mimeType="application/json", + description=f"Dataset: {dataset.get('name', dataset_id)}", + ) + ) # Add operation history as a resource - resources.append(Resource( - uri="labellerr://history", - name="Operation History", - mimeType="application/json", - description="History of all operations performed" - )) + resources.append( + Resource( + uri="labellerr://history", + name="Operation History", + mimeType="application/json", + description="History of all operations performed", + ) + ) return resources @@ -252,29 +265,27 @@ async def _handle_project_tool(self, name: str, args: dict) -> dict: "workflow": { "step_1": "Create dataset with files: dataset_upload_folder or dataset_upload_files", "step_2": "Create annotation template: template_create", - "step_3": "Create project: project_create (with dataset_id and annotation_template_id)" - } + "step_3": "Create project: project_create (with dataset_id and annotation_template_id)", + }, } if not template_id: return { "error": "annotation_template_id is required", - "message": "Please create an annotation template first using template_create tool" + "message": "Please create an annotation template first using template_create tool", } # Validate dataset exists and is ready logger.info(f"Validating dataset {dataset_id}...") try: dataset_data = await asyncio.to_thread( - LabellerrDatasetMeta.get_dataset, - self.client, - dataset_id + LabellerrDatasetMeta.get_dataset, self.client, dataset_id ) if not dataset_data: return { "error": f"Dataset {dataset_id} not found", - "dataset_id": dataset_id + "dataset_id": dataset_id, } dataset_status = dataset_data.get("status_code") @@ -283,24 +294,27 @@ async def _handle_project_tool(self, name: str, args: dict) -> dict: "error": f"Dataset {dataset_id} is not ready", "dataset_id": dataset_id, "status_code": dataset_status, - "message": "Dataset is still processing. Please wait and try again." + "message": "Dataset is still processing. Please wait and try again.", } logger.info(f"✓ Dataset {dataset_id} is ready") except Exception as e: return { "error": f"Failed to validate dataset {dataset_id}", - "details": str(e) + "details": str(e), } # Create project using SDK logger.info(f"Creating project '{args['project_name']}'...") - rotations_config = args.get("rotation_config", { - "annotation_rotation_count": 1, - "review_rotation_count": 1, - "client_review_rotation_count": 1 - }) + rotations_config = args.get( + "rotation_config", + { + "annotation_rotation_count": 1, + "review_rotation_count": 1, + "client_review_rotation_count": 1, + }, + ) # Create params using Pydantic schema params = schemas.CreateProjectParams( @@ -308,7 +322,7 @@ async def _handle_project_tool(self, name: str, args: dict) -> dict: data_type=args["data_type"], rotations=schemas.RotationConfig(**rotations_config), use_ai=args.get("autolabel", False), - created_by=args.get("created_by") + created_by=args.get("created_by"), ) # Get dataset and template objects @@ -325,7 +339,7 @@ async def _handle_project_tool(self, name: str, args: dict) -> dict: self.client, params, [dataset_obj], - template_obj + template_obj, ) project_id = project.project_id @@ -337,26 +351,23 @@ async def _handle_project_tool(self, name: str, args: dict) -> dict: "data_type": args["data_type"], "dataset_id": dataset_id, "template_id": template_id, - "created_at": datetime.now().isoformat() + "created_at": datetime.now().isoformat(), } logger.info(f"✓ Project created successfully: {project_id}") result = { - "response": { - "project_id": project_id - }, + "response": {"project_id": project_id}, "workflow_completed": { "step_1": f"✓ Dataset: {dataset_id}", "step_2": f"✓ Template: {template_id}", - "step_3": f"✓ Project: {project_id}" - } + "step_3": f"✓ Project: {project_id}", + }, } elif name == "project_list": # Use SDK to list projects projects = await asyncio.to_thread( - project_ops.list_projects, - self.client + project_ops.list_projects, self.client ) # Convert project objects to dicts @@ -365,7 +376,7 @@ async def _handle_project_tool(self, name: str, args: dict) -> dict: project_data = await asyncio.to_thread( LabellerrProjectMeta.get_project, self.client, - project.project_id + project.project_id, ) if project_data: projects_list.append(project_data) @@ -376,9 +387,7 @@ async def _handle_project_tool(self, name: str, args: dict) -> dict: elif name == "project_get": # Use SDK to get project details project_data = await asyncio.to_thread( - LabellerrProjectMeta.get_project, - self.client, - args["project_id"] + LabellerrProjectMeta.get_project, self.client, args["project_id"] ) if project_data: @@ -392,8 +401,7 @@ async def _handle_project_tool(self, name: str, args: dict) -> dict: LabellerrProject, self.client, args["project_id"] ) update_result = await asyncio.to_thread( - project.update_rotation_count, - args["rotation_config"] + project.update_rotation_count, args["rotation_config"] ) result = {"response": update_result} @@ -401,13 +409,19 @@ async def _handle_project_tool(self, name: str, args: dict) -> dict: result = {"error": f"Unknown project tool: {name}"} # Log successful operation - self.operation_history.append({ - "timestamp": datetime.now().isoformat(), - "tool": name, - "duration": (datetime.now() - start_time).total_seconds(), - "status": "success", - "args": {k: v for k, v in args.items() if k not in ["files_to_upload", "folder_to_upload"]} - }) + self.operation_history.append( + { + "timestamp": datetime.now().isoformat(), + "tool": name, + "duration": (datetime.now() - start_time).total_seconds(), + "status": "success", + "args": { + k: v + for k, v in args.items() + if k not in ["files_to_upload", "folder_to_upload"] + }, + } + ) return result @@ -428,31 +442,30 @@ async def _handle_dataset_tool(self, name: str, args: dict) -> dict: # STEP 1: Upload files if folder_path or files provided if not connection_id: if args.get("folder_path"): - logger.info(f"[1/3] Uploading files from {args['folder_path']}...") + logger.info( + f"[1/3] Uploading files from {args['folder_path']}..." + ) upload_result = await asyncio.to_thread( upload_folder_files_to_dataset, self.client, { "client_id": self.client_id, "folder_path": args["folder_path"], - "data_type": args["data_type"] - } + "data_type": args["data_type"], + }, ) connection_id = upload_result.get("connection_id") logger.info(f"✓ Files uploaded! Connection ID: {connection_id}") elif args.get("files"): logger.info(f"[1/3] Uploading {len(args['files'])} files...") connection_id = await asyncio.to_thread( - upload_files, - self.client, - self.client_id, - args["files"] + upload_files, self.client, self.client_id, args["files"] ) logger.info(f"✓ Files uploaded! Connection ID: {connection_id}") else: return { "error": "Either connection_id, folder_path, or files must be provided", - "hint": "Provide folder_path to upload an entire folder, or files array for specific files" + "hint": "Provide folder_path to upload an entire folder, or files array for specific files", } # STEP 2: Create dataset with connection_id @@ -462,7 +475,7 @@ async def _handle_dataset_tool(self, name: str, args: dict) -> dict: dataset_name=args["dataset_name"], data_type=args["data_type"], dataset_description=args.get("dataset_description", ""), - multimodal_indexing=False + multimodal_indexing=False, ) dataset = await asyncio.to_thread( @@ -470,7 +483,7 @@ async def _handle_dataset_tool(self, name: str, args: dict) -> dict: self.client, dataset_config, connection_id, - "local" + "local", ) dataset_id = dataset.dataset_id @@ -492,16 +505,18 @@ async def _handle_dataset_tool(self, name: str, args: dict) -> dict: "dataset_id": dataset_id, "files_count": files_count, "status": "ready", - "status_code": 300 + "status_code": 300, } } else: - logger.warning(f"Dataset processing completed with status {status_code}") + logger.warning( + f"Dataset processing completed with status {status_code}" + ) result = { "response": { "dataset_id": dataset_id, "status_code": status_code, - "status": "processing_failed" + "status": "processing_failed", } } except Exception as e: @@ -510,30 +525,23 @@ async def _handle_dataset_tool(self, name: str, args: dict) -> dict: "response": { "dataset_id": dataset_id, "warning": f"Dataset created but processing status unknown: {str(e)}", - "status": "unknown" + "status": "unknown", } } else: - result = { - "response": { - "dataset_id": dataset_id - } - } + result = {"response": {"dataset_id": dataset_id}} # Cache the dataset self.active_datasets[dataset_id] = { "dataset_id": dataset_id, "name": args["dataset_name"], "data_type": args["data_type"], - "created_at": datetime.now().isoformat() + "created_at": datetime.now().isoformat(), } elif name == "dataset_upload_files": connection_id = await asyncio.to_thread( - upload_files, - self.client, - self.client_id, - args["files"] + upload_files, self.client, self.client_id, args["files"] ) result = {"connection_id": connection_id, "success": True} @@ -544,13 +552,13 @@ async def _handle_dataset_tool(self, name: str, args: dict) -> dict: { "client_id": self.client_id, "folder_path": args["folder_path"], - "data_type": args["data_type"] - } + "data_type": args["data_type"], + }, ) result = { "connection_id": upload_result.get("connection_id"), "success": True, - "uploaded_files": len(upload_result.get("success", [])) + "uploaded_files": len(upload_result.get("success", [])), } elif name == "dataset_list": @@ -563,7 +571,7 @@ async def _handle_dataset_tool(self, name: str, args: dict) -> dict: self.client, data_type, schemas.DataSetScope(scope), - page_size=100 # Get first 100 datasets + page_size=100, # Get first 100 datasets ) # Convert generator to list @@ -580,9 +588,7 @@ async def _handle_dataset_tool(self, name: str, args: dict) -> dict: elif name == "dataset_get": # Use SDK to get dataset details dataset_data = await asyncio.to_thread( - LabellerrDatasetMeta.get_dataset, - self.client, - args["dataset_id"] + LabellerrDatasetMeta.get_dataset, self.client, args["dataset_id"] ) if dataset_data: @@ -593,12 +599,14 @@ async def _handle_dataset_tool(self, name: str, args: dict) -> dict: else: result = {"error": f"Unknown dataset tool: {name}"} - self.operation_history.append({ - "timestamp": datetime.now().isoformat(), - "tool": name, - "duration": (datetime.now() - start_time).total_seconds(), - "status": "success" - }) + self.operation_history.append( + { + "timestamp": datetime.now().isoformat(), + "tool": name, + "duration": (datetime.now() - start_time).total_seconds(), + "status": "success", + } + ) return result @@ -618,12 +626,17 @@ async def _handle_annotation_tool(self, name: str, args: dict) -> dict: # Convert questions to AnnotationQuestion objects questions = [] for q in args["questions"]: - question_type = q.get("question_type", q.get("option_type", "BoundingBox")) + question_type = q.get( + "question_type", q.get("option_type", "BoundingBox") + ) # Handle options options = None if q.get("options"): - options = [Option(option_name=opt.get("option_name", opt)) for opt in q["options"]] + options = [ + Option(option_name=opt.get("option_name", opt)) + for opt in q["options"] + ] question = AnnotationQuestion( question_number=q.get("question_number", 1), @@ -632,7 +645,7 @@ async def _handle_annotation_tool(self, name: str, args: dict) -> dict: question_type=QuestionType(question_type), required=q.get("required", True), options=options, - color=q.get("color") + color=q.get("color"), ) questions.append(question) @@ -640,24 +653,18 @@ async def _handle_annotation_tool(self, name: str, args: dict) -> dict: params = TemplateParams( template_name=args["template_name"], data_type=args["data_type"], - questions=questions + questions=questions, ) # Create template using SDK template = await asyncio.to_thread( - template_ops.create_template, - self.client, - params + template_ops.create_template, self.client, params ) template_id = template.annotation_template_id logger.info(f"Template created successfully: {template_id}") - result = { - "response": { - "template_id": template_id - } - } + result = {"response": {"template_id": template_id}} elif name == "annotation_export": # Get project and create export @@ -670,19 +677,12 @@ async def _handle_annotation_tool(self, name: str, args: dict) -> dict: export_description=args.get("export_description", ""), export_format=args["export_format"], statuses=args["statuses"], - export_destination=schemas.ExportDestination.LOCAL + export_destination=schemas.ExportDestination.LOCAL, ) - export = await asyncio.to_thread( - project.create_export, - export_config - ) + export = await asyncio.to_thread(project.create_export, export_config) - result = { - "response": { - "report_id": export.report_id - } - } + result = {"response": {"report_id": export.report_id}} elif name == "annotation_check_export_status": # Get project and check export status @@ -691,14 +691,9 @@ async def _handle_annotation_tool(self, name: str, args: dict) -> dict: ) status_result = await asyncio.to_thread( - project.check_export_status, - args["export_ids"] + project.check_export_status, args["export_ids"] ) - # Parse JSON string result if needed - if isinstance(status_result, str): - status_result = json.loads(status_result) - result = status_result elif name == "annotation_download_export": @@ -712,7 +707,7 @@ async def _handle_annotation_tool(self, name: str, args: dict) -> dict: args["project_id"], str(uuid.uuid4()), args["export_id"], - self.client_id + self.client_id, ) result = {"response": download_result} @@ -727,7 +722,7 @@ async def _handle_annotation_tool(self, name: str, args: dict) -> dict: project.upload_preannotations, args["annotation_format"], args["annotation_file"], - _async=False + _async=False, ) result = {"response": upload_result} @@ -742,25 +737,27 @@ async def _handle_annotation_tool(self, name: str, args: dict) -> dict: project.upload_preannotations, args["annotation_format"], args["annotation_file"], - _async=True + _async=True, ) result = { "response": { "status": "Job started", - "message": "Preannotation upload job has been submitted" + "message": "Preannotation upload job has been submitted", } } else: result = {"error": f"Unknown annotation tool: {name}"} - self.operation_history.append({ - "timestamp": datetime.now().isoformat(), - "tool": name, - "duration": (datetime.now() - start_time).total_seconds(), - "status": "success" - }) + self.operation_history.append( + { + "timestamp": datetime.now().isoformat(), + "tool": name, + "duration": (datetime.now() - start_time).total_seconds(), + "status": "success", + } + ) return result @@ -778,15 +775,13 @@ async def _handle_monitoring_tool(self, name: str, args: dict) -> dict: "success": True, "job_id": args["job_id"], "status": "This feature requires specific job tracking API", - "message": "Use check_export_status for export jobs" + "message": "Use check_export_status for export jobs", } elif name == "monitor_project_progress": # Get project details for progress using SDK project_data = await asyncio.to_thread( - LabellerrProjectMeta.get_project, - self.client, - args["project_id"] + LabellerrProjectMeta.get_project, self.client, args["project_id"] ) result = {"response": project_data} @@ -794,7 +789,7 @@ async def _handle_monitoring_tool(self, name: str, args: dict) -> dict: recent_ops = [op for op in self.operation_history[-50:]] result = { "active_operations": recent_ops, - "total_operations": len(self.operation_history) + "total_operations": len(self.operation_history), } elif name == "monitor_system_health": @@ -804,7 +799,9 @@ async def _handle_monitoring_tool(self, name: str, args: dict) -> dict: "active_projects": len(self.active_projects), "active_datasets": len(self.active_datasets), "operations_performed": len(self.operation_history), - "last_operation": self.operation_history[-1] if self.operation_history else None + "last_operation": ( + self.operation_history[-1] if self.operation_history else None + ), } else: @@ -824,9 +821,7 @@ async def _handle_query_tool(self, name: str, args: dict) -> dict: if name == "query_project_statistics": # Get project details using SDK project_data = await asyncio.to_thread( - LabellerrProjectMeta.get_project, - self.client, - args["project_id"] + LabellerrProjectMeta.get_project, self.client, args["project_id"] ) if project_data: @@ -838,16 +833,16 @@ async def _handle_query_tool(self, name: str, args: dict) -> dict: "annotated_files": project_data.get("annotated_files", 0), "reviewed_files": project_data.get("reviewed_files", 0), "accepted_files": project_data.get("accepted_files", 0), - "completion_percentage": project_data.get("completion_percentage", 0) + "completion_percentage": project_data.get( + "completion_percentage", 0 + ), } else: result = {"error": f"Project {args['project_id']} not found"} elif name == "query_dataset_info": dataset_data = await asyncio.to_thread( - LabellerrDatasetMeta.get_dataset, - self.client, - args["dataset_id"] + LabellerrDatasetMeta.get_dataset, self.client, args["dataset_id"] ) result = {"response": dataset_data} @@ -861,14 +856,13 @@ async def _handle_query_tool(self, name: str, args: dict) -> dict: result = { "total": len(history), - "operations": list(reversed(history[-limit:])) + "operations": list(reversed(history[-limit:])), } elif name == "query_search_projects": # Get all projects using SDK and filter projects = await asyncio.to_thread( - project_ops.list_projects, - self.client + project_ops.list_projects, self.client ) query = args["query"].lower() @@ -878,11 +872,13 @@ async def _handle_query_tool(self, name: str, args: dict) -> dict: project_data = await asyncio.to_thread( LabellerrProjectMeta.get_project, self.client, - project.project_id + project.project_id, ) if project_data: - if (query in project_data.get("project_name", "").lower() or - query in project_data.get("data_type", "").lower()): + if ( + query in project_data.get("project_name", "").lower() + or query in project_data.get("data_type", "").lower() + ): matching_projects.append(project_data) result = {"projects": matching_projects} @@ -903,9 +899,7 @@ async def run(self): async with stdio_server() as (read_stream, write_stream): await self.server.run( - read_stream, - write_stream, - self.server.create_initialization_options() + read_stream, write_stream, self.server.create_initialization_options() ) diff --git a/labellerr/mcp_server/tools.py b/labellerr/mcp_server/tools.py index 5bfdb8c..3b4b746 100644 --- a/labellerr/mcp_server/tools.py +++ b/labellerr/mcp_server/tools.py @@ -6,68 +6,74 @@ PROJECT_TOOLS = [ { "name": "project_create", - "description": ("Create a new annotation project (Step 3 of 3). REQUIRES dataset_id and " - "annotation_template_id. Use this AFTER creating a dataset and template. " - "This enforces an explicit three-step workflow."), + "description": ( + "Create a new annotation project (Step 3 of 3). REQUIRES dataset_id and " + "annotation_template_id. Use this AFTER creating a dataset and template. " + "This enforces an explicit three-step workflow." + ), "inputSchema": { "type": "object", "properties": { "project_name": { "type": "string", - "description": "Name of the project" + "description": "Name of the project", }, "data_type": { "type": "string", "enum": ["image", "video", "audio", "document", "text"], - "description": "Type of data to annotate" + "description": "Type of data to annotate", }, "dataset_id": { "type": "string", - "description": ("ID of the dataset (REQUIRED - must be created first using " - "dataset_upload_folder or dataset_create)") + "description": ( + "ID of the dataset (REQUIRED - must be created first using " + "dataset_upload_folder or dataset_create)" + ), }, "annotation_template_id": { "type": "string", - "description": ("ID of the annotation template (REQUIRED - must be created first " - "using template_create)") - }, - "created_by": { - "type": "string", - "description": "Email of the creator" + "description": ( + "ID of the annotation template (REQUIRED - must be created first " + "using template_create)" + ), }, + "created_by": {"type": "string", "description": "Email of the creator"}, "rotation_config": { "type": "object", "properties": { "annotation_rotation_count": { "type": "number", - "description": "Number of annotation rotations" + "description": "Number of annotation rotations", }, "review_rotation_count": { "type": "number", - "description": "Number of review rotations (must be 1)" + "description": "Number of review rotations (must be 1)", }, "client_review_rotation_count": { "type": "number", - "description": "Number of client review rotations" - } - } + "description": "Number of client review rotations", + }, + }, }, "autolabel": { "type": "boolean", "description": "Enable auto-labeling", - "default": False - } + "default": False, + }, }, - "required": ["project_name", "data_type", "dataset_id", "annotation_template_id", "created_by"] - } + "required": [ + "project_name", + "data_type", + "dataset_id", + "annotation_template_id", + "created_by", + ], + }, }, { "name": "project_list", "description": "List all projects for the client", - "inputSchema": { - "type": "object", - "properties": {} - } + "inputSchema": {"type": "object", "properties": {}}, }, { "name": "project_get", @@ -77,11 +83,11 @@ "properties": { "project_id": { "type": "string", - "description": "ID of the project to retrieve" + "description": "ID of the project to retrieve", } }, - "required": ["project_id"] - } + "required": ["project_id"], + }, }, { "name": "project_update_rotation", @@ -89,74 +95,75 @@ "inputSchema": { "type": "object", "properties": { - "project_id": { - "type": "string", - "description": "ID of the project" - }, + "project_id": {"type": "string", "description": "ID of the project"}, "rotation_config": { "type": "object", "properties": { "annotation_rotation_count": {"type": "number"}, "review_rotation_count": {"type": "number"}, - "client_review_rotation_count": {"type": "number"} - } - } + "client_review_rotation_count": {"type": "number"}, + }, + }, }, - "required": ["project_id", "rotation_config"] - } - } + "required": ["project_id", "rotation_config"], + }, + }, ] # Dataset Management Tools DATASET_TOOLS = [ { "name": "dataset_create", - "description": ("Create a new dataset with automatic file upload and status polling. " - "Provide folder_path or files to upload data directly. The tool handles the " - "complete workflow: upload files → create dataset → wait for processing."), + "description": ( + "Create a new dataset with automatic file upload and status polling. " + "Provide folder_path or files to upload data directly. The tool handles the " + "complete workflow: upload files → create dataset → wait for processing." + ), "inputSchema": { "type": "object", "properties": { "dataset_name": { "type": "string", - "description": "Name of the dataset" + "description": "Name of the dataset", }, "dataset_description": { "type": "string", - "description": "Description of the dataset" + "description": "Description of the dataset", }, "data_type": { "type": "string", "enum": ["image", "video", "audio", "document", "text"], - "description": "Type of data in the dataset" + "description": "Type of data in the dataset", }, "folder_path": { "type": "string", - "description": ("Path to folder containing files to upload " - "(optional - for creating dataset with files)") + "description": ( + "Path to folder containing files to upload " + "(optional - for creating dataset with files)" + ), }, "files": { "type": "array", "items": {"type": "string"}, - "description": "Array of file paths to upload (optional - alternative to folder_path)" + "description": "Array of file paths to upload (optional - alternative to folder_path)", }, "connection_id": { "type": "string", - "description": "Connection ID from previous upload (optional - if files already uploaded)" + "description": "Connection ID from previous upload (optional - if files already uploaded)", }, "wait_for_processing": { "type": "boolean", "description": "Wait for dataset processing to complete (default: true)", - "default": True + "default": True, }, "processing_timeout": { "type": "number", "description": "Maximum seconds to wait for processing (default: 300)", - "default": 300 - } + "default": 300, + }, }, - "required": ["dataset_name", "data_type"] - } + "required": ["dataset_name", "data_type"], + }, }, { "name": "dataset_upload_files", @@ -167,16 +174,16 @@ "files": { "type": "array", "items": {"type": "string"}, - "description": "Array of file paths to upload" + "description": "Array of file paths to upload", }, "data_type": { "type": "string", "enum": ["image", "video", "audio", "document", "text"], - "description": "Type of data being uploaded" - } + "description": "Type of data being uploaded", + }, }, - "required": ["files", "data_type"] - } + "required": ["files", "data_type"], + }, }, { "name": "dataset_upload_folder", @@ -186,16 +193,16 @@ "properties": { "folder_path": { "type": "string", - "description": "Path to the folder containing files" + "description": "Path to the folder containing files", }, "data_type": { "type": "string", "enum": ["image", "video", "audio", "document", "text"], - "description": "Type of data being uploaded" - } + "description": "Type of data being uploaded", + }, }, - "required": ["folder_path", "data_type"] - } + "required": ["folder_path", "data_type"], + }, }, { "name": "dataset_list", @@ -207,10 +214,10 @@ "type": "string", "enum": ["image", "video", "audio", "document", "text"], "description": "Filter by data type", - "default": "image" + "default": "image", } - } - } + }, + }, }, { "name": "dataset_get", @@ -218,14 +225,11 @@ "inputSchema": { "type": "object", "properties": { - "dataset_id": { - "type": "string", - "description": "ID of the dataset" - } + "dataset_id": {"type": "string", "description": "ID of the dataset"} }, - "required": ["dataset_id"] - } - } + "required": ["dataset_id"], + }, + }, ] # Annotation Tools @@ -238,12 +242,12 @@ "properties": { "template_name": { "type": "string", - "description": "Name of the template" + "description": "Name of the template", }, "data_type": { "type": "string", "enum": ["image", "video", "audio", "document", "text"], - "description": "Type of data for the template" + "description": "Type of data for the template", }, "questions": { "type": "array", @@ -253,49 +257,61 @@ "properties": { "question_number": { "type": "number", - "description": "Order number of the question" + "description": "Order number of the question", }, "question": { "type": "string", - "description": "The annotation question text" + "description": "The annotation question text", }, "question_id": { "type": "string", - "description": "Unique identifier for the question (auto-generated if not provided)" + "description": "Unique identifier for the question (auto-generated if not provided)", }, "question_type": { "type": "string", - "enum": ["BoundingBox", "polygon", "polyline", "dot", "input", - "radio", "boolean", "select", "dropdown", "stt", "imc"], - "description": "Type of annotation input" + "enum": [ + "BoundingBox", + "polygon", + "polyline", + "dot", + "input", + "radio", + "boolean", + "select", + "dropdown", + "stt", + "imc", + ], + "description": "Type of annotation input", }, "required": { "type": "boolean", - "description": "Whether this question is required" + "description": "Whether this question is required", }, "options": { "type": "array", "description": "Available options (required for radio, boolean, select, dropdown, etc)", "items": { "type": "object", - "properties": { - "option_name": { - "type": "string" - } - } - } + "properties": {"option_name": {"type": "string"}}, + }, }, "color": { "type": "string", - "description": "Color code (required for BoundingBox, polygon, polyline, dot)" - } + "description": "Color code (required for BoundingBox, polygon, polyline, dot)", + }, }, - "required": ["question_number", "question", "question_type", "required"] - } - } + "required": [ + "question_number", + "question", + "question_type", + "required", + ], + }, + }, }, - "required": ["template_name", "data_type", "questions"] - } + "required": ["template_name", "data_type", "questions"], + }, }, { "name": "annotation_upload_preannotations", @@ -303,22 +319,19 @@ "inputSchema": { "type": "object", "properties": { - "project_id": { - "type": "string", - "description": "ID of the project" - }, + "project_id": {"type": "string", "description": "ID of the project"}, "annotation_format": { "type": "string", "enum": ["json", "coco_json", "csv", "png"], - "description": "Format of the annotation file" + "description": "Format of the annotation file", }, "annotation_file": { "type": "string", - "description": "Path to the annotation file" - } + "description": "Path to the annotation file", + }, }, - "required": ["project_id", "annotation_format", "annotation_file"] - } + "required": ["project_id", "annotation_format", "annotation_file"], + }, }, { "name": "annotation_upload_preannotations_async", @@ -326,22 +339,19 @@ "inputSchema": { "type": "object", "properties": { - "project_id": { - "type": "string", - "description": "ID of the project" - }, + "project_id": {"type": "string", "description": "ID of the project"}, "annotation_format": { "type": "string", "enum": ["json", "coco_json", "csv", "png"], - "description": "Format of the annotation file" + "description": "Format of the annotation file", }, "annotation_file": { "type": "string", - "description": "Path to the annotation file" - } + "description": "Path to the annotation file", + }, }, - "required": ["project_id", "annotation_format", "annotation_file"] - } + "required": ["project_id", "annotation_format", "annotation_file"], + }, }, { "name": "annotation_export", @@ -349,34 +359,34 @@ "inputSchema": { "type": "object", "properties": { - "project_id": { - "type": "string", - "description": "ID of the project" - }, - "export_name": { - "type": "string", - "description": "Name for the export" - }, + "project_id": {"type": "string", "description": "ID of the project"}, + "export_name": {"type": "string", "description": "Name for the export"}, "export_description": { "type": "string", - "description": "Description of the export" + "description": "Description of the export", }, "export_format": { "type": "string", "enum": ["json", "coco_json", "csv", "png"], - "description": "Format for the export" + "description": "Format for the export", }, "statuses": { "type": "array", "items": { "type": "string", - "enum": ["review", "r_assigned", "client_review", "cr_assigned", "accepted"] + "enum": [ + "review", + "r_assigned", + "client_review", + "cr_assigned", + "accepted", + ], }, - "description": "Filter annotations by status" - } + "description": "Filter annotations by status", + }, }, - "required": ["project_id", "export_name", "export_format", "statuses"] - } + "required": ["project_id", "export_name", "export_format", "statuses"], + }, }, { "name": "annotation_check_export_status", @@ -384,18 +394,15 @@ "inputSchema": { "type": "object", "properties": { - "project_id": { - "type": "string", - "description": "ID of the project" - }, + "project_id": {"type": "string", "description": "ID of the project"}, "export_ids": { "type": "array", "items": {"type": "string"}, - "description": "Array of export IDs to check" - } + "description": "Array of export IDs to check", + }, }, - "required": ["project_id", "export_ids"] - } + "required": ["project_id", "export_ids"], + }, }, { "name": "annotation_download_export", @@ -403,18 +410,12 @@ "inputSchema": { "type": "object", "properties": { - "project_id": { - "type": "string", - "description": "ID of the project" - }, - "export_id": { - "type": "string", - "description": "ID of the export" - } + "project_id": {"type": "string", "description": "ID of the project"}, + "export_id": {"type": "string", "description": "ID of the export"}, }, - "required": ["project_id", "export_id"] - } - } + "required": ["project_id", "export_id"], + }, + }, ] # Monitoring Tools @@ -425,13 +426,10 @@ "inputSchema": { "type": "object", "properties": { - "job_id": { - "type": "string", - "description": "ID of the job to monitor" - } + "job_id": {"type": "string", "description": "ID of the job to monitor"} }, - "required": ["job_id"] - } + "required": ["job_id"], + }, }, { "name": "monitor_project_progress", @@ -439,30 +437,21 @@ "inputSchema": { "type": "object", "properties": { - "project_id": { - "type": "string", - "description": "ID of the project" - } + "project_id": {"type": "string", "description": "ID of the project"} }, - "required": ["project_id"] - } + "required": ["project_id"], + }, }, { "name": "monitor_active_operations", "description": "List all active operations and their status", - "inputSchema": { - "type": "object", - "properties": {} - } + "inputSchema": {"type": "object", "properties": {}}, }, { "name": "monitor_system_health", "description": "Check the health and status of the MCP server", - "inputSchema": { - "type": "object", - "properties": {} - } - } + "inputSchema": {"type": "object", "properties": {}}, + }, ] # Query Tools @@ -473,13 +462,10 @@ "inputSchema": { "type": "object", "properties": { - "project_id": { - "type": "string", - "description": "ID of the project" - } + "project_id": {"type": "string", "description": "ID of the project"} }, - "required": ["project_id"] - } + "required": ["project_id"], + }, }, { "name": "query_dataset_info", @@ -487,13 +473,10 @@ "inputSchema": { "type": "object", "properties": { - "dataset_id": { - "type": "string", - "description": "ID of the dataset" - } + "dataset_id": {"type": "string", "description": "ID of the dataset"} }, - "required": ["dataset_id"] - } + "required": ["dataset_id"], + }, }, { "name": "query_operation_history", @@ -504,15 +487,15 @@ "limit": { "type": "number", "description": "Maximum number of operations to return", - "default": 10 + "default": 10, }, "status": { "type": "string", "enum": ["success", "failed", "in_progress"], - "description": "Filter by operation status" - } - } - } + "description": "Filter by operation status", + }, + }, + }, }, { "name": "query_search_projects", @@ -520,15 +503,14 @@ "inputSchema": { "type": "object", "properties": { - "query": { - "type": "string", - "description": "Search query string" - } + "query": {"type": "string", "description": "Search query string"} }, - "required": ["query"] - } - } + "required": ["query"], + }, + }, ] # All tools combined -ALL_TOOLS = PROJECT_TOOLS + DATASET_TOOLS + ANNOTATION_TOOLS + MONITORING_TOOLS + QUERY_TOOLS +ALL_TOOLS = ( + PROJECT_TOOLS + DATASET_TOOLS + ANNOTATION_TOOLS + MONITORING_TOOLS + QUERY_TOOLS +) diff --git a/labellerr/notebooks/Labellerr_datasets/15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p.mp4 b/labellerr/notebooks/Labellerr_datasets/15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p.mp4 new file mode 100644 index 0000000..f28f3e6 Binary files /dev/null and b/labellerr/notebooks/Labellerr_datasets/15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p.mp4 differ diff --git a/labellerr/notebooks/Labellerr_datasets/15908795-09eb-4cdb-a39b-8689f8f936e5+a878e61b-8aeb-46e1-ab10-5f1852bcdcbe+seafood_1280p.mp4 b/labellerr/notebooks/Labellerr_datasets/15908795-09eb-4cdb-a39b-8689f8f936e5+a878e61b-8aeb-46e1-ab10-5f1852bcdcbe+seafood_1280p.mp4 new file mode 100644 index 0000000..b709d5c Binary files /dev/null and b/labellerr/notebooks/Labellerr_datasets/15908795-09eb-4cdb-a39b-8689f8f936e5+a878e61b-8aeb-46e1-ab10-5f1852bcdcbe+seafood_1280p.mp4 differ diff --git a/labellerr/notebooks/SDK.ipynb b/labellerr/notebooks/SDK.ipynb index 1efd28f..6c5a9b2 100644 --- a/labellerr/notebooks/SDK.ipynb +++ b/labellerr/notebooks/SDK.ipynb @@ -5,25 +5,32 @@ "id": "d6488b6b", "metadata": {}, "source": [ - "# Getting Started with Labellerr SDK\n", + "# Keyframe Scene detection with Labellerr SDK\n", "\n", - "This notebook demonstrates how to use the Labellerr SDK for video processing and scene detection. The SDK provides powerful tools for managing video datasets, processing videos, and detecting scene changes using various algorithms.\n", - "\n", - "### Import the required Classes from Labellerr SDK\n", - "We'll start by importing the essential classes needed for working with the SDK:" + "This notebook demonstrates how to use the Labellerr SDK for video processing and scene detection.\n" ] }, { "cell_type": "code", - "execution_count": 1, + "execution_count": 49, "id": "edcdab6a", "metadata": {}, "outputs": [], "source": [ "from labellerr.client import LabellerrClient\n", - "from labellerr.core.datasets import LabellerrDataset\n", - "import os\n", - "from tqdm.notebook import tqdm\n" + "from labellerr.core.datasets import create_dataset_from_local, LabellerrDataset\n", + "from labellerr.core.annotation_templates import create_template\n", + "from labellerr.core.projects import create_project, LabellerrProject\n", + "from labellerr.core.schemas.annotation_templates import AnnotationQuestion, QuestionType, CreateTemplateParams, DatasetDataType\n", + "from labellerr.core.schemas import DatasetConfig\n", + "from labellerr.core.schemas.projects import CreateProjectParams, RotationConfig\n", + "from labellerr.core.exceptions import LabellerrError\n", + "import requests\n", + "import json\n", + "\n", + "import uuid\n", + "from pathlib import Path\n", + "import os\n" ] }, { @@ -31,7 +38,8 @@ "id": "84b7917a", "metadata": {}, "source": [ - "## 1. Authentication Setup\n", + "---\n", + "## ***Authentication Setup***\n", "\n", "Before using the Labellerr SDK, you need to set up your authentication credentials. These credentials ensure secure access to the Labellerr platform and its services.\n", "\n", @@ -44,9 +52,7 @@ "\n", "2. **Client ID**\n", " - This is a unique identifier for your application\n", - " - Contact Labellerr support to obtain your client ID\n", - " \n", - "⚠️ Important: Never share these credentials or commit them to version control." + " - Contact Labellerr support to obtain your client ID\n" ] }, { @@ -57,75 +63,358 @@ "outputs": [], "source": [ "from dotenv import dotenv_values\n", - "config = dotenv_values(\".env\")\n", + "config = dotenv_values(r\"D:\\Professional\\Labellerr_SDK\\dev.env\")\n", "\n", - "api_key = config[\"API_KEY\"]\n", - "api_secret = config[\"API_SECRET\"]\n", - "client_id = config[\"CLIENT_ID\"]" + "api_key = config[\"QA_API_KEY\"]\n", + "api_secret = config[\"QA_API_SECRET\"]\n", + "client_id = config[\"QA_CLIENT_ID\"]\n", + "email = config[\"QA_EMAIL\"]\n", + "\n", + "client = LabellerrClient(api_key, api_secret, client_id)\n" ] }, { "cell_type": "markdown", - "id": "3d05bd0f", + "id": "d2646549", + "metadata": {}, + "source": [ + "---\n", + "## ***Kaggle Dataset Download***" + ] + }, + { + "cell_type": "markdown", + "id": "c2d2a744", "metadata": {}, "source": [ - "## 2. Project Configuration\n", + "Before downloading the dataset from Kaggle, you need to:\n", "\n", - "### Dataset and Project IDs\n", - "To work with specific datasets and projects in Labellerr, you need their respective IDs. These IDs are unique identifiers that link your code to the correct resources on the platform.\n", + "1. Install kagglehub package using pip\n", + "2. Authenticate with Kaggle\n", + "3. Download the CCTV footage dataset\n", "\n", - "How to obtain the IDs:\n", - "1. Go to the Labellerr platform\n", - "2. Create or select an existing dataset\n", - "3. Create or select an existing project\n", - "4. Copy the dataset_id and project_id from their respective pages\n", + "The kagglehub package provides a simple interface to download datasets directly from Kaggle. Make sure you have a Kaggle account and API credentials set up before proceeding.\n", "\n", - "Note: The dataset_id is a UUID format string, while the project_id is typically a human-readable string." + "Note: If you haven't set up Kaggle authentication before, you'll need to:\n", + "1. Create a Kaggle account at https://www.kaggle.com\n", + "2. Go to \"Account\" settings\n", + "3. Scroll to API section and click \"Create New API Token\"\n", + "4. This will download a kaggle.json file with your credentials" ] }, { "cell_type": "code", - "execution_count": 3, - "id": "07dcfae9", + "execution_count": null, + "id": "be12bf3f", "metadata": {}, "outputs": [], "source": [ - "# go to our platform to create dataset and project then get their ids\n", - "dataset_id = \"16257fd6-b91b-4d00-a680-9ece9f3f241c\"\n", - "project_id = \"gabrila_artificial_duck_74237\"" + "# !pip install kagglehub ipywidgets" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e05889d7", + "metadata": {}, + "outputs": [], + "source": [ + "import kagglehub\n", + "\n", + "kagglehub.login()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c5b93e97", + "metadata": {}, + "outputs": [], + "source": [ + "# Download 1000 videos(~1 min) dataset\n", + "\n", + "# large video dataset(1000 videos)\n", + "# path_to_dataset = kagglehub.dataset_download(\"yashsuman/cctv-footage\")\n", + "\n", + "# small video dataset(5 videos)\n", + "path_to_dataset = kagglehub.dataset_download(\"mistag/short-videos\")\n", + "\n", + "print(\"Path to dataset files:\", path_to_dataset)" ] }, { "cell_type": "markdown", - "id": "1b2c7aee", + "id": "3d05bd0f", "metadata": {}, "source": [ - "## 3. Initializing the Labellerr SDK\n", + "---\n", + "## ***Video Project Creation***\n", "\n", - "### Create LabellerrClient Instance\n", - "Now we'll create instances of the main SDK classes:\n", + "Create a Labellerr Video Project with kaggle dataset" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "52e00dbc", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# DATASET_PATH = path_to_dataset\n", + "KAGGLE_DATASET_PATH = Path(r\"..\\..\\..\\.cache\\kagglehub\\datasets\\mistag\\short-videos\\versions\\4\")\n", + "\n", + "KAGGLE_DATASET_PATH.exists()" + ] + }, + { + "cell_type": "markdown", + "id": "d861c36f", + "metadata": {}, + "source": [ + "### Create Labellerr Dataset" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "c1e2e2f3", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'es_multimodal_index': False,\n", + " 'metadata': {},\n", + " 'dataset_id': '15908795-09eb-4cdb-a39b-8689f8f936e5',\n", + " 'origin': 'https://pro.labellerr.com',\n", + " 'created_at': 1765198673509,\n", + " 'description': '',\n", + " 'created_by': '1c8800.8177f647b0b9bc6321bcec4d93',\n", + " 'client_id': '79',\n", + " 'tags': [],\n", + " 'data_type': 'video',\n", + " 'name': 'VIDEO_DATASET_(2)',\n", + " 'extraction_quality': ['normal'],\n", + " 'updated_at': 1765198765922,\n", + " 'progress': 'Processing 0/2 files',\n", + " 'files_count': 2,\n", + " 'status_code': 300,\n", + " 'video_processing_job_id': 'e08357f1-edaa-4b42-a9fb-de3bd9230b4a',\n", + " 'es_index_status': 101,\n", + " 'video_processing_job': {'job_id': 'e08357f1-edaa-4b42-a9fb-de3bd9230b4a',\n", + " 'status_code': 200,\n", + " 'updated_at': 1765198785019}}" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# import logging\n", + "\n", + "# logging.basicConfig(level=logging.DEBUG)\n", + "# logger = logging.getLogger(__name__)\n", "\n", - "1. **LabellerrClient**: The main client that handles communication with the Labellerr API\n", - "2. **LabellerrDataset**: A specialized class for working with datasets\n", "\n", - "These instances will be used for all subsequent operations with the platform." + "dataset = create_dataset_from_local(\n", + " client=client,\n", + " dataset_config=DatasetConfig(dataset_name=\"VIDEO_DATASET_(2)\", \n", + " data_type=\"video\"),\n", + " folder_to_upload=KAGGLE_DATASET_PATH,\n", + " )\n", + "\n", + "dataset.status()" ] }, { "cell_type": "code", - "execution_count": 4, - "id": "9eaec7e1", + "execution_count": 15, + "id": "3d82b343", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "2" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ - "client = LabellerrClient(api_key, api_secret, client_id) \n", - "dataset = LabellerrDataset(client, dataset_id, project_id)" + "dataset.dataset_id\n", + "dataset.files_count" ] }, { "cell_type": "code", "execution_count": 5, - "id": "7b6a7052", + "id": "5205f618", + "metadata": {}, + "outputs": [], + "source": [ + "dataset = LabellerrDataset(client=client,\n", + " dataset_id='15908795-09eb-4cdb-a39b-8689f8f936e5')" + ] + }, + { + "cell_type": "markdown", + "id": "1a1a37ec", + "metadata": {}, + "source": [ + "### Create Labellerr Annotation Template" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "7be23d9a", + "metadata": {}, + "outputs": [], + "source": [ + "template = create_template(\n", + " client=client,\n", + " params=CreateTemplateParams(\n", + " template_name=\"SDK VIDEO TEMPLATE\",\n", + " data_type=DatasetDataType.video,\n", + " questions=[\n", + " AnnotationQuestion(\n", + " question_number=1,\n", + " question=\"Class polygon \",\n", + " question_id=str(uuid.uuid4()),\n", + " question_type=QuestionType.polygon,\n", + " required=True,\n", + " color=\"#FF0000\"\n", + " )\n", + " ]\n", + " )\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "7e281c33", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'4dd84aa0-1a06-4cea-a758-40076c7e3d8c'" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "template.annotation_template_id" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "62360ea1", + "metadata": {}, + "outputs": [], + "source": [ + "from labellerr.core.annotation_templates import LabellerrAnnotationTemplate\n", + "template = LabellerrAnnotationTemplate(client=client,\n", + " annotation_template_id='4dd84aa0-1a06-4cea-a758-40076c7e3d8c')" + ] + }, + { + "cell_type": "markdown", + "id": "a493938f", + "metadata": {}, + "source": [ + "### Create Labellerr Project" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "8996dd01", + "metadata": {}, + "outputs": [], + "source": [ + "video_project = create_project(\n", + " client=client,\n", + " params=CreateProjectParams(\n", + " project_name=\"SDK VIDEO PROJECT TEST\",\n", + " data_type=DatasetDataType.video,\n", + " rotations=RotationConfig(\n", + " annotation_rotation_count=1,\n", + " review_rotation_count=1,\n", + " client_review_rotation_count=1\n", + " )\n", + " ),\n", + " datasets=[dataset],\n", + " annotation_template=template\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "724c67cc", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'caryl_geographical_turkey_21445'" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "video_project.project_id" + ] + }, + { + "cell_type": "code", + "execution_count": 54, + "id": "f74cba85", + "metadata": {}, + "outputs": [], + "source": [ + "video_project = LabellerrProject(client=client,\n", + " project_id='caryl_geographical_turkey_21445')" + ] + }, + { + "cell_type": "markdown", + "id": "0d806eaf", + "metadata": {}, + "source": [ + "---\n", + "## ***Download Labellerr Indexed Dataset***" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "6558a9e9", "metadata": {}, "outputs": [ { @@ -134,71 +423,106 @@ "text": [ "\n", "######################################################################\n", - "# Starting batch video processing for dataset: 16257fd6-b91b-4d00-a680-9ece9f3f241c\n", + "# Starting batch video processing for dataset: 15908795-09eb-4cdb-a39b-8689f8f936e5\n", "######################################################################\n", "\n", - "Total file IDs extracted: 1\n", + "Fetching files for dataset: 15908795-09eb-4cdb-a39b-8689f8f936e5\n", + "{'message': '200: Success', 'response': {'files': [{'has_embedding': False, 'file_id': '471163aa-19dc-4bc7-9aee-04780591281a', 'created_at': 1765198766042, 'file_name_original': 'butterflies_960p.mp4', 'dataset_id': '15908795-09eb-4cdb-a39b-8689f8f936e5', 'connection_id': 'fa6598b0-7a6c-4327-9d00-eb8ae6fd445f', 'email_id': '1c8800.8177f647b0b9bc6321bcec4d93', 'file_name': 'butterflies_960p.mp4', 'file_reference': 'gs://labellerr-connector-files-dev/local_upload/fa6598b0-7a6c-4327-9d00-eb8ae6fd445f/butterflies_960p.mp4', 'file_metadata': {'file_size': 25.047, 'image_width': None, 'file_format': 'mp4', 'additional_metadata': None, 'image_height': None}, 'created_by': '1c8800.8177f647b0b9bc6321bcec4d93', 'data_type': 'video', 'video_playlist': 'https://api-gateway-722091373895.us-central1.run.app/data/15908795-09eb-4cdb-a39b-8689f8f936e5/files/471163aa-19dc-4bc7-9aee-04780591281a/segments/master.m3u8'}, {'has_embedding': False, 'file_id': 'a878e61b-8aeb-46e1-ab10-5f1852bcdcbe', 'created_at': 1765198766042, 'file_name_original': 'seafood_1280p.mp4', 'dataset_id': '15908795-09eb-4cdb-a39b-8689f8f936e5', 'connection_id': 'fa6598b0-7a6c-4327-9d00-eb8ae6fd445f', 'email_id': '1c8800.8177f647b0b9bc6321bcec4d93', 'file_name': 'seafood_1280p.mp4', 'file_reference': 'gs://labellerr-connector-files-dev/local_upload/fa6598b0-7a6c-4327-9d00-eb8ae6fd445f/seafood_1280p.mp4', 'file_metadata': {'file_size': 17.156, 'image_width': None, 'file_format': 'mp4', 'additional_metadata': None, 'image_height': None}, 'created_by': '1c8800.8177f647b0b9bc6321bcec4d93', 'data_type': 'video', 'video_playlist': 'https://api-gateway-722091373895.us-central1.run.app/data/15908795-09eb-4cdb-a39b-8689f8f936e5/files/a878e61b-8aeb-46e1-ab10-5f1852bcdcbe/segments/master.m3u8'}], 'total_count': 2, 'next_search_after': None}, 'error': None, 'tracking_id': 'f2364207b402f43da228219f2209c267'}\n", + "\n", + "Processing 2 video files...\n", + "\n", + "\n", + "Starting download of 2 files...\n", "\n", - "Creating LabellerrFile instances for 1 files...\n", - "Successfully created 1 LabellerrFile instances\n", + "============================================================\n", + "Processing file: 471163aa-19dc-4bc7-9aee-04780591281a\n", + "============================================================\n", "\n", - "Processing 1 video files...\n", + "[1/4] Fetching frame data from API (0 to 1572)...\n", + "Retrieved 1572 frames\n", "\n", + "[2/4] Setting up output folders...\n", "\n", - "Starting download of 1 files...\n", + "[3/4] Downloading frames...\n", + "Starting download of 1572 frames...\n", + "Frames downloaded: 1572/1572 (1572 successful, 0 failed)\n", "\n", + "[4/4] Creating video from frames...\n", + "Running command: ffmpeg -y -start_number 0 -framerate 30 -i ./Labellerr_datasets\\15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p\\%d.jpg -c:v libx264 -pix_fmt yuv420p ./Labellerr_datasets\\15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p.mp4\n", + "Video saved as ./Labellerr_datasets\\15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p.mp4\n", + "\n", + "Cleaning up temporary frames...\n", + "Removed temporary frames folder: ./Labellerr_datasets\\15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p\n", + "\n", + "============================================================\n", + "Processing complete!\n", + "Video saved to: ./Labellerr_datasets\\15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p.mp4\n", + "============================================================\n", + "\n", + "Files processed: 1/2 (1 successful, 0 failed)\n", "============================================================\n", - "Processing file: c44f38f6-0186-436f-8c2d-ffb50a539c76\n", + "Processing file: a878e61b-8aeb-46e1-ab10-5f1852bcdcbe\n", "============================================================\n", "\n", - "[1/4] Fetching frame data from API (0 to 1440)...\n", - "Retrieved 1440 frames\n", + "[1/4] Fetching frame data from API (0 to 389)...\n", + "Retrieved 389 frames\n", "\n", "[2/4] Setting up output folders...\n", "\n", "[3/4] Downloading frames...\n", - "Starting download of 1440 frames...\n", - "Frames downloaded: 1440/1440 (1440 successful, 0 failed)\n", + "Starting download of 389 frames...\n", + "Frames downloaded: 389/389 (389 successful, 0 failed)\n", "\n", "[4/4] Creating video from frames...\n", - "Running command: ffmpeg -y -start_number 0 -framerate 30 -i ./Labellerr_datastets\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\%d.jpg -c:v libx264 -pix_fmt yuv420p ./Labellerr_datastets\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\c44f38f6-0186-436f-8c2d-ffb50a539c76.mp4\n", - "Video saved as ./Labellerr_datastets\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\c44f38f6-0186-436f-8c2d-ffb50a539c76.mp4\n", + "Running command: ffmpeg -y -start_number 0 -framerate 30 -i ./Labellerr_datasets\\15908795-09eb-4cdb-a39b-8689f8f936e5+a878e61b-8aeb-46e1-ab10-5f1852bcdcbe+seafood_1280p\\%d.jpg -c:v libx264 -pix_fmt yuv420p ./Labellerr_datasets\\15908795-09eb-4cdb-a39b-8689f8f936e5+a878e61b-8aeb-46e1-ab10-5f1852bcdcbe+seafood_1280p.mp4\n", + "Video saved as ./Labellerr_datasets\\15908795-09eb-4cdb-a39b-8689f8f936e5+a878e61b-8aeb-46e1-ab10-5f1852bcdcbe+seafood_1280p.mp4\n", "\n", "Cleaning up temporary frames...\n", - "Removed temporary frames folder: ./Labellerr_datastets\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\c44f38f6-0186-436f-8c2d-ffb50a539c76\n", + "Removed temporary frames folder: ./Labellerr_datasets\\15908795-09eb-4cdb-a39b-8689f8f936e5+a878e61b-8aeb-46e1-ab10-5f1852bcdcbe+seafood_1280p\n", "\n", "============================================================\n", - "✓ Processing complete!\n", - "Video saved to: ./Labellerr_datastets\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\c44f38f6-0186-436f-8c2d-ffb50a539c76.mp4\n", + "Processing complete!\n", + "Video saved to: ./Labellerr_datasets\\15908795-09eb-4cdb-a39b-8689f8f936e5+a878e61b-8aeb-46e1-ab10-5f1852bcdcbe+seafood_1280p.mp4\n", "============================================================\n", "\n", - "Files processed: 1/1 (1 successful, 0 failed)\n", + "Files processed: 2/2 (2 successful, 0 failed)\n", "######################################################################\n", "# Batch Processing Complete\n", - "# Total files: 1\n", - "# Successful: 1\n", + "# Total files: 2\n", + "# Successful: 2\n", "# Failed: 0\n", "######################################################################\n", "\n" ] + }, + { + "data": { + "text/plain": [ + "[{'status': 'success',\n", + " 'file_id': '471163aa-19dc-4bc7-9aee-04780591281a',\n", + " 'dataset_id': '15908795-09eb-4cdb-a39b-8689f8f936e5',\n", + " 'video_path': './Labellerr_datasets\\\\15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p.mp4',\n", + " 'output_folder': './Labellerr_datasets',\n", + " 'frames_downloaded': 1572,\n", + " 'frames_failed': 0,\n", + " 'failed_frames_info': []},\n", + " {'status': 'success',\n", + " 'file_id': 'a878e61b-8aeb-46e1-ab10-5f1852bcdcbe',\n", + " 'dataset_id': '15908795-09eb-4cdb-a39b-8689f8f936e5',\n", + " 'video_path': './Labellerr_datasets\\\\15908795-09eb-4cdb-a39b-8689f8f936e5+a878e61b-8aeb-46e1-ab10-5f1852bcdcbe+seafood_1280p.mp4',\n", + " 'output_folder': './Labellerr_datasets',\n", + " 'frames_downloaded': 389,\n", + " 'frames_failed': 0,\n", + " 'failed_frames_info': []}]" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" } ], "source": [ - "results = dataset.download()" - ] - }, - { - "cell_type": "markdown", - "id": "900ea5a7", - "metadata": {}, - "source": [ - "### download Videos\n", - "The `download()` method will:\n", - "- Fetch all videos in the dataset\n", - "- Process them according to the configured settings\n", - "- Return the results of the processing\n", - "\n", - "This is typically used as the first step in video analysis to ensure all videos are properly prepared for further processing." + "dataset.download()" ] }, { @@ -206,9 +530,9 @@ "id": "f6db8522", "metadata": {}, "source": [ - "## 4. Scene Change Detection\n", + "---\n", + "## ***Scene Change Detection on Dataset***\n", "\n", - "### Available Scene Detection Methods\n", "Labellerr SDK provides multiple algorithms for scene detection in videos:\n", "\n", "1. **PySceneDetect**: \n", @@ -231,14 +555,37 @@ }, { "cell_type": "code", - "execution_count": 6, - "id": "f5c41073", + "execution_count": null, + "id": "fd0febab", "metadata": {}, "outputs": [], "source": [ - "from labellerr.services.video_sampling.pyscene_detect import PySceneDetect\n", - "from labellerr.services.video_sampling.ssim import SSIMSceneDetect\n", - "from labellerr.services.video_sampling.ffmpeg import FFMPEGSceneDetect" + "# !pip install opencv-python pillow scenedetect scikit-image" + ] + }, + { + "cell_type": "code", + "execution_count": 57, + "id": "f5c41073", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "d:\\Professional\\Labellerr_SDK\\.venv\\Lib\\site-packages\\tqdm\\auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html\n", + " from .autonotebook import tqdm as notebook_tqdm\n" + ] + } + ], + "source": [ + "from labellerr.services.video_sampling import (\n", + " PySceneDetect,\n", + " FFMPEGSceneDetect,\n", + " SSIMSceneDetect,\n", + " process_videos_batch,\n", + " coco_to_video_json\n", + ")" ] }, { @@ -246,50 +593,45 @@ "id": "db88da50", "metadata": {}, "source": [ - "## Scene Detection Implementation\n", - "\n", - "### Setting up the Scene Detector\n", - "Now we'll set up the scene detection process:\n", - "\n", - "1. First, we'll define the dataset directory where our videos are stored\n", - "2. Then we'll create an instance of our chosen detector\n", - "3. Finally, we'll process each video in the dataset\n", - "\n", - "Note: Make sure you have sufficient disk space for storing the extracted scenes, as this process can generate multiple files per video." + "### Scene Detection Implementation\n" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 7, "id": "49a6f89d", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Path exists and is not empty ✅\n" + ] + } + ], "source": [ - "dataset_dir = f\".\\Labellerr_datasets\\{dataset_id}\"" + "DATASET_DIR = Path(f\".\\\\Labellerr_datasets\")\n", + "if DATASET_DIR.exists() and any(DATASET_DIR.iterdir()):\n", + " print(\"Path exists and is not empty ✅\")\n", + "else:\n", + " print(\"Path does not exist or is empty ❌\")\n", + "\n" ] }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 16, "id": "dd96be8c", "metadata": {}, "outputs": [], "source": [ - "detector = FFMPEGSceneDetect()" - ] - }, - { - "cell_type": "markdown", - "id": "995d99ec", - "metadata": {}, - "source": [ - "### Initialize the Scene Detector\n", - "Here we create an instance of the SSIMSceneDetect class. This detector uses the Structural Similarity Index Measure (SSIM) to identify scene changes in videos. SSIM is particularly effective at detecting subtle changes between frames." + "detector = PySceneDetect()" ] }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 17, "id": "a3052f25", "metadata": {}, "outputs": [ @@ -297,33 +639,63 @@ "name": "stdout", "output_type": "stream", "text": [ - "Keyframes extracted to FFMPEG_detects\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\frames\n", - "JSON mapping saved to: FFMPEG_detects\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\c44f38f6-0186-436f-8c2d-ffb50a539c76_mapping.json\n" + "Found 2 video files to process\n", + "======================================================================\n", + "\n", + "[1/2] Processing: 15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p.mp4\n", + "----------------------------------------------------------------------\n", + "Detecting scene changes...\n", + "Detected 4 scene changes\n", + "Total frames in video: 1572\n", + "Extracting first frame (frame 0)...\n", + "Successfully extracted 5 frames to pyscene_detect\n", + "✓ Successfully extracted 5 frames\n", + "\n", + "[2/2] Processing: 15908795-09eb-4cdb-a39b-8689f8f936e5+a878e61b-8aeb-46e1-ab10-5f1852bcdcbe+seafood_1280p.mp4\n", + "----------------------------------------------------------------------\n", + "Detecting scene changes...\n", + "Detected 0 scene changes\n", + "Total frames in video: 389\n", + "Extracting first frame (frame 0)...\n", + "Successfully extracted 1 frames to pyscene_detect\n", + "✓ Successfully extracted 1 frames\n", + "\n", + "======================================================================\n", + "PROCESSING SUMMARY\n", + "======================================================================\n", + "Total videos processed: 2\n", + "Successful: 2\n", + "Failed: 0\n", + "Total frames extracted: 6\n", + "\n", + "✓ Frames stored in: pyscene_detect/\n" ] } ], "source": [ - "for filename in os.listdir(dataset_dir):\n", - " file_path = os.path.join(dataset_dir, filename)\n", - " \n", - " if os.path.isfile(file_path):\n", - " detector.detect_and_extract(file_path)" + "response = process_videos_batch(detector, DATASET_DIR)" ] }, { - "cell_type": "markdown", - "id": "8d64ac26", + "cell_type": "code", + "execution_count": 23, + "id": "c83cf401", "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'.\\\\pyscene_detect'" + ] + }, + "execution_count": 23, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ - "### Process Videos for Scene Detection\n", - "\n", - "The following code block:\n", - "1. Iterates through all files in the dataset directory\n", - "2. Constructs the full file path for each video\n", - "3. Verifies that each path points to a file (not a directory)\n", - "4. Applies scene detection to each video using the `detect_and_extract` method\n", - "\n", - "The detected scenes will be saved in a subdirectory with the same name as the input video file. Each scene will be saved as a separate video file." + "keyframe_img_path =\".\\\\\" + response[0]['output_folder']\n", + "keyframe_img_path" ] }, { @@ -331,312 +703,285 @@ "id": "6c3eac46", "metadata": {}, "source": [ - "## 5. Project Creation\n", - "\n", - "In this section, we'll explore how to create and manage projects in Labellerr. Projects are essential containers that organize your data and annotations. We'll cover:\n", + "---\n", + "## ***Image Project Creation***\n", "\n", - "1. Creating image datasets from video frames\n", - "2. Setting up annotation projects\n", - "3. Managing project configurations" + "Create Image project of extracted keyframe from video" ] }, { "cell_type": "markdown", - "id": "f5ba527d", + "id": "f5dba054", "metadata": {}, "source": [ - "### Image Dataset Creation from Sampled Frames\n" + "### Create Labellerr Dataset of keyframe" ] }, { "cell_type": "code", - "execution_count": 13, - "id": "1b364362", + "execution_count": 26, + "id": "82629d12", + "metadata": {}, + "outputs": [], + "source": [ + "dataset = create_dataset_from_local(\n", + " client=client,\n", + " dataset_config=DatasetConfig(dataset_name=\"SDK VIDEO KEYFRAME DATASET\", \n", + " data_type=\"image\"),\n", + " folder_to_upload=keyframe_img_path,\n", + " )" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "id": "d530faed", "metadata": {}, "outputs": [ { - "name": "stdout", - "output_type": "stream", - "text": [ - "Found 52 image files\n" - ] + "data": { + "text/plain": [ + "'1addcef0-2c60-4442-8701-856efa573afc'" + ] + }, + "execution_count": 27, + "metadata": {}, + "output_type": "execute_result" } ], "source": [ - "import os\n", - "\n", - "images_files = []\n", - "# Clear existing entries in images_files\n", - "images_files.clear()\n", - "\n", - "# Construct the base directory path for detected frames\n", - "base_dir = os.path.join(\"FFMPEG_detects\", dataset_id)\n", - "\n", - "# Walk through all subdirectories\n", - "for root, dirs, files in os.walk(base_dir):\n", - " for file in files:\n", - " if file.endswith('.jpg'): # Only collect jpg files\n", - " file_path = os.path.join(root, file)\n", - " images_files.append(file_path)\n", - "\n", - "print(f\"Found {len(images_files)} image files\")" + "dataset.dataset_id" ] }, { "cell_type": "code", - "execution_count": 14, - "id": "f39153ab", + "execution_count": 3, + "id": "e0793ba6", + "metadata": {}, + "outputs": [], + "source": [ + "dataset = LabellerrDataset(client=client,\n", + " dataset_id='1addcef0-2c60-4442-8701-856efa573afc')" + ] + }, + { + "cell_type": "markdown", + "id": "756764dd", + "metadata": {}, + "source": [ + "### Create Annotation template of Keyframe Image Project" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "5d5e23c5", + "metadata": {}, + "outputs": [], + "source": [ + "template = create_template(\n", + " client=client,\n", + " params=CreateTemplateParams(\n", + " template_name=\"SDK VIDEO KEYFRAME DATASET\",\n", + " data_type=DatasetDataType.image,\n", + " questions=[\n", + " AnnotationQuestion(\n", + " question_number=1,\n", + " question=\"Class polygon \",\n", + " question_id=str(uuid.uuid4()),\n", + " question_type=QuestionType.polygon,\n", + " required=True,\n", + " color=\"#FF0000\"\n", + " )\n", + " ]\n", + " )\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "a05fac70", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "['FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\0.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\1008.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\1016.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\1028.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\1060.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\1082.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\1106.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\1119.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\1137.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\1157.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\1175.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\1189.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\119.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\1201.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\1218.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\1233.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\1246.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\1257.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\1278.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\1312.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\1319.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\141.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\233.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\263.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\37.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\381.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\408.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\437.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\457.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\484.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\508.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\552.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\575.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\590.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\619.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\63.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\647.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\683.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\706.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\721.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\758.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\776.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\805.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\823.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\83.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\836.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\858.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\876.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\893.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\915.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\949.jpg',\n", - " 'FFMPEG_detects\\\\16257fd6-b91b-4d00-a680-9ece9f3f241c\\\\c44f38f6-0186-436f-8c2d-ffb50a539c76\\\\frames\\\\99.jpg']" + "'1502051f-dbe7-4216-9426-df5757afea85'" ] }, - "execution_count": 14, + "execution_count": 5, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "images_files" + "template.annotation_template_id" + ] + }, + { + "cell_type": "markdown", + "id": "1bef8bfe", + "metadata": {}, + "source": [ + "### Create Image Annotation Project" ] }, { "cell_type": "code", - "execution_count": 15, - "id": "40c70986", + "execution_count": 14, + "id": "b235e08d", "metadata": {}, "outputs": [], "source": [ - "# code to create dataset from sampled frames\n", - "\n", - "def upload_images_from_files(images_files, client, client_id):\n", - " \"\"\"Upload specific image files to create a dataset\"\"\"\n", - " \n", - " client.enable_connection_pooling = True\n", - " \n", - " dataset_config = {\n", - " \"client_id\": client_id,\n", - " \"dataset_name\": \"video_sampling_1\",\n", - " \"dataset_description\": \"video sampling dataset from frames\",\n", - " \"data_type\": \"image\", \n", - " }\n", - " \n", - " try:\n", - " response = client.create_dataset(\n", - " dataset_config=dataset_config,\n", - " files_to_upload=images_files \n", + "img_project = create_project(\n", + " client=client,\n", + " params=CreateProjectParams(\n", + " project_name=\"SDK EXTRACTED KEYFRAMES\",\n", + " data_type=DatasetDataType.image,\n", + " rotations=RotationConfig(\n", + " annotation_rotation_count=1,\n", + " review_rotation_count=1,\n", + " client_review_rotation_count=1\n", " )\n", - " print(f\"Dataset created successfully!\")\n", - " print(f\"Dataset ID: {response['dataset_id']}\")\n", - " return response['dataset_id']\n", - " except Exception as e:\n", - " print(f\"Error creating dataset: {e}\")" + " ),\n", + " datasets=[dataset],\n", + " annotation_template=template\n", + ")" ] }, { "cell_type": "code", - "execution_count": 16, - "id": "a1d96b25", + "execution_count": 15, + "id": "fc8b7e25", "metadata": {}, "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Dataset created successfully!\n", - "Dataset ID: 6a680901-fe81-49f0-9120-bb754d63a341\n" - ] - }, { "data": { "text/plain": [ - "'6a680901-fe81-49f0-9120-bb754d63a341'" + "'laurette_constitutional_herring_39772'" ] }, - "execution_count": 16, + "execution_count": 15, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "upload_images_from_files(images_files, client, client_id)" + "img_project.project_id" ] }, { "cell_type": "code", - "execution_count": 19, - "id": "958fc75e", + "execution_count": 6, + "id": "55c824a7", "metadata": {}, "outputs": [], "source": [ - "new_dataset_id = '6a680901-fe81-49f0-9120-bb754d63a341'" + "img_project = LabellerrProject(client=client,\n", + " project_id='laurette_constitutional_herring_39772')" ] }, { "cell_type": "markdown", - "id": "b454c4f4", + "id": "8645aa60", "metadata": {}, "source": [ - "### Image Annotation Project Creation" + "---\n", + "## ***Performing Annotations of Keyframe Image Project***" ] }, { "cell_type": "code", "execution_count": null, - "id": "d32106c5", + "id": "d6eea565", "metadata": {}, "outputs": [], "source": [ - "# modify to add questions to image project\n", - "\n", - "questions = [\n", - " {\n", - " \"question_number\": 1,\n", - " \"question\": \"Test\",\n", - " \"question_id\": \"533bb0c8-fb2b-4394-a8e1-5042a944802f\",\n", - " \"option_type\": \"polygon\",\n", - " \"required\": True,\n", - " \"options\": [\n", - " { \"option_name\": \"#fe1236\" }\n", - " ],\n", - " \"question_metadata\": []\n", - " }\n", - " ]\n" + "# annotations of image project on labellerr platform" ] }, { - "cell_type": "code", - "execution_count": null, - "id": "b71d2aa0", + "cell_type": "markdown", + "id": "85d88826", "metadata": {}, - "outputs": [], "source": [ - "# creeate the annotation guideline template\n", - "\n", - "template_id = client.create_annotation_guideline(\n", - " client_id=client_id,\n", - " questions=questions,\n", - " template_name=\"video_sampling_template_1\",\n", - " data_type=\"image\",\n", - ")\n" + "### Create Export" ] }, { "cell_type": "code", "execution_count": null, - "id": "83565ec3", + "id": "ccf0e882", "metadata": {}, "outputs": [], "source": [ - "# create the image annotation project\n", + "export_config = {\n", + " \"export_name\": \"Test Export\",\n", + " \"export_description\": \"Export for testing\",\n", + " \"export_format\": \"coco_json\",\n", + " \"statuses\": ['review', 'r_assigned', 'client_review', 'cr_assigned', 'accepted']\n", + "}\n", "\n", - "response = client.create_project(\n", - " project_name=\"Video_sampling_project_1\",\n", - " data_type=\"image\",\n", - " client_id= client_id,\n", - " dataset_id=new_dataset_id,\n", - " annotation_template_id=template_id,\n", - " rotation_config={\n", - " \"annotation_rotation_count\": 1,\n", - " \"review_rotation_count\": 1,\n", - " \"client_review_rotation_count\": 1,\n", - " },\n", - " created_by=\"yashsuman15@gmail.com\"\n", - " )\n" + "result = project.create_local_export(export_config)\n", + "\n" ] }, { "cell_type": "code", - "execution_count": 31, - "id": "9f682f4f", + "execution_count": 10, + "id": "220eaf7a", "metadata": {}, "outputs": [ { - "name": "stdout", - "output_type": "stream", - "text": [ - "Project created successfully!\n", - "Project ID: sherri_puny_rattlesnake_84247\n" - ] + "data": { + "text/plain": [ + "'zmYykSJhCAJqAaXaJQ3g'" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" } ], "source": [ - "if response['response']['project_id']:\n", - " print(f\"Project created successfully!\")\n", - " print(f\"Project ID: {response['response']['project_id']}\")\n", - " image_project_id = response['response']['project_id']" + "result.report_id" ] }, { "cell_type": "markdown", - "id": "8645aa60", + "id": "c7ba3c97", "metadata": {}, "source": [ - "## 6. Performing Annotations of Image Project" + "### Check Status of export" ] }, { "cell_type": "code", "execution_count": null, - "id": "d6eea565", + "id": "ef475591", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'status': [{'report_id': 'zmYykSJhCAJqAaXaJQ3g', 'export_status': 'Created', 'is_completed': True, 'download_url': {'url': 'https://storage.googleapis.com/labellerr-export-dev/92075cec-468b-4cc3-90e3-4b1f2691c57c.json?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=local-dev%40labellerrdev.iam.gserviceaccount.com%2F20251209%2Fauto%2Fstorage%2Fgoog4_request&X-Goog-Date=20251209T064534Z&X-Goog-Expires=3600&X-Goog-SignedHeaders=host&response-content-type=json&response-content-disposition=attachment%3B%20filename%3D%22export-%23zmYykSJhCAJqAaXaJQ3g.json%22&X-Goog-Signature=6c7cf8c2357dc98b749ffa32bf234a952d5c715d5742a91e3ff4cee8e893334a56181e238f2066ce0f05bef2b94dd626c25d0b40fe102fde64e090768462413e5d4ca80da2b77850ec5d0a661b076a11da5b5f44868fc020547b0b53ae3f7c7d0ac47257733a322e4e6face965505dbe4fcb797c0f951505f27afbe62d31bbaf981f200b34f5b76b8ac890be8d009e68158ebb5adab04b7eb48461a373478ca384ea54049f93e8501b5784c740544cc518f86ecd05134f805c1cf1f26f43a7df0d3d05a04cbb261fe51b5448c580a1d991ae0ac9309aea07103b67917e0c0f1c6790cf95c16505231d491c93499b495f01a18d22c462510b12c8361fedc72fa3', 'expires_at': 1765266334139, 'is_expired': False}}]}\n" + ] + } + ], "source": [ - "# annotations of image project on labellerr platform" + "try:\n", + " # Get project instance\n", + " project = LabellerrProject(client=client, project_id=project_id)\n", + " \n", + " # Check export status\n", + " response_data = json.loads(project.check_export_status(\n", + " report_ids=[result.report_id]\n", + " ))\n", + " print(response_data)\n", + "except LabellerrError as e:\n", + " print(f\"Failed to check export status: {str(e)}\")" ] }, { @@ -644,37 +989,40 @@ "id": "8f0611f5", "metadata": {}, "source": [ - "### Exporting the Annotation Data" + "### Download the Annotation" ] }, { "cell_type": "code", - "execution_count": null, - "id": "b78ad296", + "execution_count": 51, + "id": "1d5455c3", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✓ Downloaded: export_zmYykSJhCAJqAaXaJQ3g.json\n", + "Export saved at: export_zmYykSJhCAJqAaXaJQ3g.json\n" + ] + } + ], "source": [ - "# code to export the annotations from image project\n", + "download_url = response_data['status'][0]['download_url']['url']\n", "\n", - "export_config = {\n", - " \"export_name\": \"Weekly Export\",\n", - " \"export_description\": \"Export of all accepted annotations\",\n", - " \"export_format\": \"coco_json\",\n", - " \"statuses\": [\n", - " \"review\",\n", - " \"r_assigned\",\n", - " \"client_review\",\n", - " \"cr_assigned\",\n", - " \"accepted\",\n", - " ],\n", - " }\n", - "\n", - "\n", - "response = client.create_local_export(\n", - " project_id=image_project_id,\n", - " client_id=client_id,\n", - " export_config=export_config\n", - ")" + "# Download the file\n", + "response = requests.get(download_url)\n", + "if response.status_code == 200:\n", + " # Store the filename/path in a variable\n", + " export_json_path = f\"export_{response_data['status'][0]['report_id']}.json\"\n", + " \n", + " with open(export_json_path, 'wb') as f:\n", + " f.write(response.content)\n", + " print(f\"✓ Downloaded: {export_json_path}\")\n", + "else:\n", + " print(f\"✗ Download failed: HTTP {response.status_code}\")\n", + "# Now you can use export_json_path variable for further processing\n", + "print(f\"Export saved at: {export_json_path}\")" ] }, { @@ -682,7 +1030,34 @@ "id": "18529760", "metadata": {}, "source": [ - "## 7. Uploading annotations to Video Project" + "---\n", + "## ***Uploading KeyFrames Pre-Annotation to Video Project***" + ] + }, + { + "cell_type": "markdown", + "id": "5c5ed594", + "metadata": {}, + "source": [ + "### Converting Annotation JSON to required format" + ] + }, + { + "cell_type": "code", + "execution_count": 58, + "id": "14b59cef", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Video JSON saved to: Video_Keyframe_annot.json\n" + ] + } + ], + "source": [ + "video_annotations = coco_to_video_json(export_json_path)" ] }, { @@ -690,25 +1065,36 @@ "id": "deae26b8", "metadata": {}, "source": [ - "### Trigger SAM2 tracking on Video annotation project\n", - "\n", - "Using the export, retrive the prompt to run SAM2 tracking on video" + "### Uploading keyframe pre-annotation" ] }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 60, "id": "df6b3ac7", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'message': '200: Success', 'response': {'metadata': {'questions_ignored': [], 'activity_id': 'ee67556d-ba08-4f71-9767-eff9300ee8d9', 'files_not_updated': [], 'videos_processed': [{'status': 'success', 'file_id': '471163aa-19dc-4bc7-9aee-04780591281a', 'file_name': 'butterflies_960p.mp4', 'frames_processed': 5, 'total_annotations': 5}, {'status': 'success', 'file_name': 'seafood_1280p.mp4', 'file_id': 'a878e61b-8aeb-46e1-ab10-5f1852bcdcbe', 'total_annotations': 1, 'frames_processed': 1}]}, 'job_type': 'pre-annotations', 'created_by': '1c8800.8177f647b0b9bc6321bcec4d93', 'activity_id': 'ee67556d-ba08-4f71-9767-eff9300ee8d9', 'status': 'completed', 'project_id': 'caryl_geographical_turkey_21445', 'job_id': 'ee67556d-ba08-4f71-9767-eff9300ee8d9', 'created_at': 1765273090056, 'updated_at': 1765273225613}, 'error': None, 'tracking_id': None}\n" + ] + } + ], "source": [ - "# code to create video annotation project from image annotations export" + "VIDEO_JSON_PATH = \"./Video_Keyframe_annot.json\"\n", + "\n", + "response = video_project.upload_preannotations(\n", + " annotation_format=\"video_json\", annotation_file=VIDEO_JSON_PATH\n", + " )\n", + "print(response)" ] } ], "metadata": { "kernelspec": { - "display_name": "SDk", + "display_name": ".venv", "language": "python", "name": "python3" }, @@ -722,7 +1108,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.18" + "version": "3.12.0" } }, "nbformat": 4, diff --git a/labellerr/notebooks/Video_Keyframe_annot.json b/labellerr/notebooks/Video_Keyframe_annot.json new file mode 100644 index 0000000..1f10278 --- /dev/null +++ b/labellerr/notebooks/Video_Keyframe_annot.json @@ -0,0 +1,1764 @@ +[ + { + "file_name": "butterflies_960p.mp4", + "annotations": [ + { + "question_type": "polygon", + "question_name": "Class polygon ", + "answer": [ + { + "startFrame": 1064, + "frames": { + "1064": { + "frame": 1064, + "answer": [ + { + "x": 655, + "y": 268 + }, + { + "x": 646, + "y": 271 + }, + { + "x": 634, + "y": 285 + }, + { + "x": 636, + "y": 287 + }, + { + "x": 636, + "y": 298 + }, + { + "x": 633, + "y": 302 + }, + { + "x": 634, + "y": 304 + }, + { + "x": 632, + "y": 306 + }, + { + "x": 630, + "y": 315 + }, + { + "x": 630, + "y": 322 + }, + { + "x": 632, + "y": 326 + }, + { + "x": 632, + "y": 328 + }, + { + "x": 630, + "y": 329 + }, + { + "x": 630, + "y": 334 + }, + { + "x": 632, + "y": 336 + }, + { + "x": 634, + "y": 343 + }, + { + "x": 634, + "y": 351 + }, + { + "x": 638, + "y": 361 + }, + { + "x": 638, + "y": 367 + }, + { + "x": 642, + "y": 372 + }, + { + "x": 644, + "y": 382 + }, + { + "x": 647, + "y": 385 + }, + { + "x": 657, + "y": 408 + }, + { + "x": 655, + "y": 414 + }, + { + "x": 664, + "y": 421 + }, + { + "x": 670, + "y": 423 + }, + { + "x": 696, + "y": 422 + }, + { + "x": 710, + "y": 417 + }, + { + "x": 719, + "y": 416 + }, + { + "x": 726, + "y": 413 + }, + { + "x": 737, + "y": 405 + }, + { + "x": 759, + "y": 396 + }, + { + "x": 764, + "y": 391 + }, + { + "x": 766, + "y": 385 + }, + { + "x": 772, + "y": 379 + }, + { + "x": 773, + "y": 373 + }, + { + "x": 777, + "y": 368 + }, + { + "x": 783, + "y": 356 + }, + { + "x": 783, + "y": 353 + }, + { + "x": 786, + "y": 350 + }, + { + "x": 793, + "y": 345 + }, + { + "x": 805, + "y": 341 + }, + { + "x": 809, + "y": 338 + }, + { + "x": 815, + "y": 337 + }, + { + "x": 834, + "y": 327 + }, + { + "x": 838, + "y": 324 + }, + { + "x": 840, + "y": 319 + }, + { + "x": 838, + "y": 316 + }, + { + "x": 833, + "y": 314 + }, + { + "x": 829, + "y": 309 + }, + { + "x": 826, + "y": 308 + }, + { + "x": 823, + "y": 310 + }, + { + "x": 819, + "y": 310 + }, + { + "x": 815, + "y": 314 + }, + { + "x": 790, + "y": 325 + }, + { + "x": 776, + "y": 333 + }, + { + "x": 762, + "y": 339 + }, + { + "x": 757, + "y": 339 + }, + { + "x": 750, + "y": 334 + }, + { + "x": 740, + "y": 332 + }, + { + "x": 732, + "y": 327 + }, + { + "x": 717, + "y": 322 + }, + { + "x": 698, + "y": 303 + }, + { + "x": 694, + "y": 301 + }, + { + "x": 680, + "y": 287 + }, + { + "x": 677, + "y": 286 + }, + { + "x": 672, + "y": 277 + }, + { + "x": 667, + "y": 272 + }, + { + "x": 658, + "y": 268 + } + ], + "isManualAnnotation": true, + "fps": 23 + } + } + }, + { + "startFrame": 0, + "frames": { + "0": { + "frame": 0, + "answer": [ + { + "x": 254, + "y": 134 + }, + { + "x": 236, + "y": 135 + }, + { + "x": 219, + "y": 138 + }, + { + "x": 211, + "y": 142 + }, + { + "x": 207, + "y": 146 + }, + { + "x": 202, + "y": 154 + }, + { + "x": 202, + "y": 166 + }, + { + "x": 203, + "y": 175 + }, + { + "x": 209, + "y": 183 + }, + { + "x": 210, + "y": 191 + }, + { + "x": 212, + "y": 194 + }, + { + "x": 215, + "y": 194 + }, + { + "x": 219, + "y": 199 + }, + { + "x": 237, + "y": 204 + }, + { + "x": 242, + "y": 214 + }, + { + "x": 253, + "y": 219 + }, + { + "x": 256, + "y": 223 + }, + { + "x": 268, + "y": 227 + }, + { + "x": 273, + "y": 233 + }, + { + "x": 277, + "y": 235 + }, + { + "x": 277, + "y": 237 + }, + { + "x": 283, + "y": 239 + }, + { + "x": 288, + "y": 244 + }, + { + "x": 302, + "y": 250 + }, + { + "x": 306, + "y": 257 + }, + { + "x": 308, + "y": 268 + }, + { + "x": 313, + "y": 275 + }, + { + "x": 317, + "y": 286 + }, + { + "x": 322, + "y": 290 + }, + { + "x": 327, + "y": 291 + }, + { + "x": 333, + "y": 302 + }, + { + "x": 343, + "y": 303 + }, + { + "x": 347, + "y": 307 + }, + { + "x": 350, + "y": 307 + }, + { + "x": 352, + "y": 310 + }, + { + "x": 358, + "y": 311 + }, + { + "x": 362, + "y": 319 + }, + { + "x": 406, + "y": 319 + }, + { + "x": 413, + "y": 316 + }, + { + "x": 418, + "y": 316 + }, + { + "x": 433, + "y": 309 + }, + { + "x": 444, + "y": 306 + }, + { + "x": 447, + "y": 307 + }, + { + "x": 450, + "y": 305 + }, + { + "x": 451, + "y": 300 + }, + { + "x": 456, + "y": 295 + }, + { + "x": 461, + "y": 294 + }, + { + "x": 464, + "y": 291 + }, + { + "x": 470, + "y": 279 + }, + { + "x": 470, + "y": 276 + }, + { + "x": 478, + "y": 268 + }, + { + "x": 477, + "y": 258 + }, + { + "x": 482, + "y": 255 + }, + { + "x": 488, + "y": 258 + }, + { + "x": 485, + "y": 259 + }, + { + "x": 488, + "y": 262 + }, + { + "x": 485, + "y": 264 + }, + { + "x": 488, + "y": 266 + }, + { + "x": 486, + "y": 272 + }, + { + "x": 486, + "y": 290 + }, + { + "x": 488, + "y": 296 + }, + { + "x": 487, + "y": 298 + }, + { + "x": 491, + "y": 304 + }, + { + "x": 491, + "y": 309 + }, + { + "x": 495, + "y": 321 + }, + { + "x": 503, + "y": 328 + }, + { + "x": 507, + "y": 329 + }, + { + "x": 513, + "y": 340 + }, + { + "x": 525, + "y": 352 + }, + { + "x": 531, + "y": 354 + }, + { + "x": 533, + "y": 357 + }, + { + "x": 536, + "y": 357 + }, + { + "x": 540, + "y": 361 + }, + { + "x": 545, + "y": 362 + }, + { + "x": 549, + "y": 366 + }, + { + "x": 553, + "y": 366 + }, + { + "x": 560, + "y": 370 + }, + { + "x": 580, + "y": 373 + }, + { + "x": 599, + "y": 373 + }, + { + "x": 620, + "y": 369 + }, + { + "x": 628, + "y": 366 + }, + { + "x": 647, + "y": 350 + }, + { + "x": 672, + "y": 352 + }, + { + "x": 675, + "y": 354 + }, + { + "x": 692, + "y": 352 + }, + { + "x": 742, + "y": 353 + }, + { + "x": 752, + "y": 349 + }, + { + "x": 763, + "y": 348 + }, + { + "x": 778, + "y": 332 + }, + { + "x": 781, + "y": 319 + }, + { + "x": 781, + "y": 309 + }, + { + "x": 770, + "y": 290 + }, + { + "x": 754, + "y": 277 + }, + { + "x": 741, + "y": 268 + }, + { + "x": 708, + "y": 252 + }, + { + "x": 699, + "y": 250 + }, + { + "x": 688, + "y": 245 + }, + { + "x": 677, + "y": 243 + }, + { + "x": 665, + "y": 238 + }, + { + "x": 638, + "y": 233 + }, + { + "x": 614, + "y": 226 + }, + { + "x": 572, + "y": 219 + }, + { + "x": 562, + "y": 220 + }, + { + "x": 546, + "y": 218 + }, + { + "x": 522, + "y": 219 + }, + { + "x": 507, + "y": 222 + }, + { + "x": 505, + "y": 220 + }, + { + "x": 507, + "y": 218 + }, + { + "x": 503, + "y": 214 + }, + { + "x": 502, + "y": 209 + }, + { + "x": 499, + "y": 207 + }, + { + "x": 500, + "y": 205 + }, + { + "x": 498, + "y": 203 + }, + { + "x": 500, + "y": 201 + }, + { + "x": 488, + "y": 201 + }, + { + "x": 482, + "y": 207 + }, + { + "x": 474, + "y": 209 + }, + { + "x": 467, + "y": 203 + }, + { + "x": 460, + "y": 202 + }, + { + "x": 458, + "y": 199 + }, + { + "x": 448, + "y": 192 + }, + { + "x": 436, + "y": 185 + }, + { + "x": 432, + "y": 185 + }, + { + "x": 415, + "y": 174 + }, + { + "x": 392, + "y": 165 + }, + { + "x": 383, + "y": 163 + }, + { + "x": 379, + "y": 160 + }, + { + "x": 356, + "y": 154 + }, + { + "x": 348, + "y": 149 + }, + { + "x": 330, + "y": 147 + }, + { + "x": 318, + "y": 142 + }, + { + "x": 311, + "y": 142 + }, + { + "x": 296, + "y": 137 + }, + { + "x": 266, + "y": 134 + } + ], + "isManualAnnotation": true, + "fps": 23 + } + } + }, + { + "startFrame": 1406, + "frames": { + "1406": { + "frame": 1406, + "answer": [ + { + "x": 132, + "y": 74 + }, + { + "x": 130, + "y": 75 + }, + { + "x": 130, + "y": 77 + }, + { + "x": 119, + "y": 82 + }, + { + "x": 119, + "y": 85 + }, + { + "x": 114, + "y": 88 + }, + { + "x": 109, + "y": 95 + }, + { + "x": 104, + "y": 95 + }, + { + "x": 101, + "y": 93 + }, + { + "x": 105, + "y": 91 + }, + { + "x": 96, + "y": 89 + }, + { + "x": 83, + "y": 89 + }, + { + "x": 79, + "y": 87 + }, + { + "x": 72, + "y": 87 + }, + { + "x": 63, + "y": 90 + }, + { + "x": 58, + "y": 89 + }, + { + "x": 54, + "y": 91 + }, + { + "x": 54, + "y": 93 + }, + { + "x": 47, + "y": 99 + }, + { + "x": 47, + "y": 102 + }, + { + "x": 39, + "y": 108 + }, + { + "x": 40, + "y": 111 + }, + { + "x": 37, + "y": 113 + }, + { + "x": 35, + "y": 118 + }, + { + "x": 32, + "y": 121 + }, + { + "x": 28, + "y": 120 + }, + { + "x": 26, + "y": 123 + }, + { + "x": 30, + "y": 130 + }, + { + "x": 31, + "y": 146 + }, + { + "x": 36, + "y": 151 + }, + { + "x": 36, + "y": 155 + }, + { + "x": 40, + "y": 161 + }, + { + "x": 39, + "y": 163 + }, + { + "x": 43, + "y": 166 + }, + { + "x": 43, + "y": 171 + }, + { + "x": 48, + "y": 174 + }, + { + "x": 47, + "y": 177 + }, + { + "x": 49, + "y": 184 + }, + { + "x": 56, + "y": 193 + }, + { + "x": 60, + "y": 201 + }, + { + "x": 59, + "y": 205 + }, + { + "x": 63, + "y": 208 + }, + { + "x": 65, + "y": 213 + }, + { + "x": 77, + "y": 221 + }, + { + "x": 92, + "y": 225 + }, + { + "x": 99, + "y": 234 + }, + { + "x": 103, + "y": 232 + }, + { + "x": 105, + "y": 236 + }, + { + "x": 108, + "y": 236 + }, + { + "x": 116, + "y": 234 + }, + { + "x": 118, + "y": 232 + }, + { + "x": 122, + "y": 239 + }, + { + "x": 121, + "y": 241 + }, + { + "x": 125, + "y": 240 + }, + { + "x": 127, + "y": 230 + }, + { + "x": 131, + "y": 226 + }, + { + "x": 142, + "y": 219 + }, + { + "x": 155, + "y": 215 + }, + { + "x": 158, + "y": 211 + }, + { + "x": 163, + "y": 201 + }, + { + "x": 163, + "y": 182 + }, + { + "x": 165, + "y": 177 + }, + { + "x": 165, + "y": 170 + }, + { + "x": 167, + "y": 168 + }, + { + "x": 168, + "y": 161 + }, + { + "x": 171, + "y": 158 + }, + { + "x": 167, + "y": 140 + }, + { + "x": 161, + "y": 127 + }, + { + "x": 162, + "y": 115 + }, + { + "x": 157, + "y": 96 + }, + { + "x": 151, + "y": 87 + }, + { + "x": 150, + "y": 83 + }, + { + "x": 137, + "y": 74 + } + ], + "isManualAnnotation": true, + "fps": 23 + } + } + }, + { + "startFrame": 760, + "frames": { + "760": { + "frame": 760, + "answer": [ + { + "x": 721, + "y": 128 + }, + { + "x": 715, + "y": 131 + }, + { + "x": 706, + "y": 139 + }, + { + "x": 690, + "y": 156 + }, + { + "x": 681, + "y": 169 + }, + { + "x": 672, + "y": 191 + }, + { + "x": 673, + "y": 198 + }, + { + "x": 697, + "y": 192 + }, + { + "x": 717, + "y": 194 + }, + { + "x": 726, + "y": 203 + }, + { + "x": 728, + "y": 211 + }, + { + "x": 738, + "y": 226 + }, + { + "x": 738, + "y": 230 + }, + { + "x": 736, + "y": 232 + }, + { + "x": 722, + "y": 233 + }, + { + "x": 713, + "y": 244 + }, + { + "x": 706, + "y": 245 + }, + { + "x": 700, + "y": 265 + }, + { + "x": 700, + "y": 271 + }, + { + "x": 702, + "y": 273 + }, + { + "x": 710, + "y": 273 + }, + { + "x": 714, + "y": 271 + }, + { + "x": 723, + "y": 270 + }, + { + "x": 735, + "y": 270 + }, + { + "x": 752, + "y": 266 + }, + { + "x": 758, + "y": 266 + }, + { + "x": 765, + "y": 263 + }, + { + "x": 776, + "y": 261 + }, + { + "x": 785, + "y": 256 + }, + { + "x": 790, + "y": 255 + }, + { + "x": 811, + "y": 239 + }, + { + "x": 814, + "y": 233 + }, + { + "x": 817, + "y": 219 + }, + { + "x": 817, + "y": 208 + }, + { + "x": 814, + "y": 197 + }, + { + "x": 804, + "y": 182 + }, + { + "x": 789, + "y": 168 + }, + { + "x": 772, + "y": 162 + }, + { + "x": 759, + "y": 148 + }, + { + "x": 753, + "y": 145 + }, + { + "x": 742, + "y": 135 + }, + { + "x": 733, + "y": 129 + }, + { + "x": 729, + "y": 128 + } + ], + "isManualAnnotation": true, + "fps": 23 + } + } + }, + { + "startFrame": 316, + "frames": { + "316": { + "frame": 316, + "answer": [ + { + "x": 420, + "y": 148 + }, + { + "x": 417, + "y": 150 + }, + { + "x": 417, + "y": 154 + }, + { + "x": 412, + "y": 155 + }, + { + "x": 412, + "y": 158 + }, + { + "x": 416, + "y": 162 + }, + { + "x": 412, + "y": 167 + }, + { + "x": 406, + "y": 168 + }, + { + "x": 400, + "y": 165 + }, + { + "x": 393, + "y": 166 + }, + { + "x": 382, + "y": 164 + }, + { + "x": 343, + "y": 164 + }, + { + "x": 335, + "y": 166 + }, + { + "x": 299, + "y": 169 + }, + { + "x": 273, + "y": 177 + }, + { + "x": 268, + "y": 181 + }, + { + "x": 259, + "y": 184 + }, + { + "x": 248, + "y": 194 + }, + { + "x": 244, + "y": 201 + }, + { + "x": 244, + "y": 214 + }, + { + "x": 246, + "y": 220 + }, + { + "x": 263, + "y": 237 + }, + { + "x": 270, + "y": 239 + }, + { + "x": 283, + "y": 246 + }, + { + "x": 317, + "y": 252 + }, + { + "x": 327, + "y": 252 + }, + { + "x": 346, + "y": 266 + }, + { + "x": 372, + "y": 274 + }, + { + "x": 389, + "y": 274 + }, + { + "x": 414, + "y": 265 + }, + { + "x": 418, + "y": 267 + }, + { + "x": 421, + "y": 262 + }, + { + "x": 426, + "y": 260 + }, + { + "x": 425, + "y": 258 + }, + { + "x": 428, + "y": 253 + }, + { + "x": 440, + "y": 255 + }, + { + "x": 448, + "y": 259 + }, + { + "x": 450, + "y": 262 + }, + { + "x": 458, + "y": 265 + }, + { + "x": 480, + "y": 265 + }, + { + "x": 492, + "y": 262 + }, + { + "x": 512, + "y": 251 + }, + { + "x": 527, + "y": 237 + }, + { + "x": 536, + "y": 234 + }, + { + "x": 541, + "y": 234 + }, + { + "x": 575, + "y": 222 + }, + { + "x": 577, + "y": 219 + }, + { + "x": 581, + "y": 218 + }, + { + "x": 586, + "y": 214 + }, + { + "x": 593, + "y": 204 + }, + { + "x": 597, + "y": 201 + }, + { + "x": 597, + "y": 172 + }, + { + "x": 590, + "y": 165 + }, + { + "x": 568, + "y": 156 + }, + { + "x": 552, + "y": 153 + }, + { + "x": 511, + "y": 153 + }, + { + "x": 507, + "y": 155 + }, + { + "x": 492, + "y": 154 + }, + { + "x": 478, + "y": 156 + }, + { + "x": 473, + "y": 155 + }, + { + "x": 470, + "y": 157 + }, + { + "x": 460, + "y": 158 + }, + { + "x": 441, + "y": 166 + }, + { + "x": 433, + "y": 166 + }, + { + "x": 431, + "y": 162 + }, + { + "x": 431, + "y": 156 + }, + { + "x": 423, + "y": 148 + } + ], + "isManualAnnotation": true, + "fps": 23 + } + } + } + ] + } + ] + }, + { + "file_name": "seafood_1280p.mp4", + "annotations": [ + { + "question_type": "polygon", + "question_name": "Class polygon ", + "answer": [ + { + "startFrame": 0, + "frames": { + "0": { + "frame": 0, + "answer": [ + { + "x": 100, + "y": 426 + }, + { + "x": 81, + "y": 428 + }, + { + "x": 70, + "y": 431 + }, + { + "x": 55, + "y": 438 + }, + { + "x": 41, + "y": 442 + }, + { + "x": 37, + "y": 445 + }, + { + "x": 26, + "y": 448 + }, + { + "x": 37, + "y": 448 + }, + { + "x": 47, + "y": 451 + }, + { + "x": 77, + "y": 451 + }, + { + "x": 81, + "y": 454 + }, + { + "x": 92, + "y": 456 + }, + { + "x": 108, + "y": 463 + }, + { + "x": 117, + "y": 470 + }, + { + "x": 122, + "y": 478 + }, + { + "x": 126, + "y": 480 + }, + { + "x": 137, + "y": 492 + }, + { + "x": 145, + "y": 494 + }, + { + "x": 163, + "y": 489 + }, + { + "x": 189, + "y": 485 + }, + { + "x": 198, + "y": 479 + }, + { + "x": 205, + "y": 468 + }, + { + "x": 196, + "y": 466 + }, + { + "x": 189, + "y": 458 + }, + { + "x": 174, + "y": 446 + }, + { + "x": 172, + "y": 439 + }, + { + "x": 169, + "y": 437 + }, + { + "x": 169, + "y": 430 + }, + { + "x": 148, + "y": 427 + }, + { + "x": 132, + "y": 428 + }, + { + "x": 127, + "y": 426 + } + ], + "isManualAnnotation": true, + "fps": 23 + } + } + } + ] + } + ] + } +] \ No newline at end of file diff --git a/labellerr/notebooks/converted_video_annotations.json b/labellerr/notebooks/converted_video_annotations.json new file mode 100644 index 0000000..1f10278 --- /dev/null +++ b/labellerr/notebooks/converted_video_annotations.json @@ -0,0 +1,1764 @@ +[ + { + "file_name": "butterflies_960p.mp4", + "annotations": [ + { + "question_type": "polygon", + "question_name": "Class polygon ", + "answer": [ + { + "startFrame": 1064, + "frames": { + "1064": { + "frame": 1064, + "answer": [ + { + "x": 655, + "y": 268 + }, + { + "x": 646, + "y": 271 + }, + { + "x": 634, + "y": 285 + }, + { + "x": 636, + "y": 287 + }, + { + "x": 636, + "y": 298 + }, + { + "x": 633, + "y": 302 + }, + { + "x": 634, + "y": 304 + }, + { + "x": 632, + "y": 306 + }, + { + "x": 630, + "y": 315 + }, + { + "x": 630, + "y": 322 + }, + { + "x": 632, + "y": 326 + }, + { + "x": 632, + "y": 328 + }, + { + "x": 630, + "y": 329 + }, + { + "x": 630, + "y": 334 + }, + { + "x": 632, + "y": 336 + }, + { + "x": 634, + "y": 343 + }, + { + "x": 634, + "y": 351 + }, + { + "x": 638, + "y": 361 + }, + { + "x": 638, + "y": 367 + }, + { + "x": 642, + "y": 372 + }, + { + "x": 644, + "y": 382 + }, + { + "x": 647, + "y": 385 + }, + { + "x": 657, + "y": 408 + }, + { + "x": 655, + "y": 414 + }, + { + "x": 664, + "y": 421 + }, + { + "x": 670, + "y": 423 + }, + { + "x": 696, + "y": 422 + }, + { + "x": 710, + "y": 417 + }, + { + "x": 719, + "y": 416 + }, + { + "x": 726, + "y": 413 + }, + { + "x": 737, + "y": 405 + }, + { + "x": 759, + "y": 396 + }, + { + "x": 764, + "y": 391 + }, + { + "x": 766, + "y": 385 + }, + { + "x": 772, + "y": 379 + }, + { + "x": 773, + "y": 373 + }, + { + "x": 777, + "y": 368 + }, + { + "x": 783, + "y": 356 + }, + { + "x": 783, + "y": 353 + }, + { + "x": 786, + "y": 350 + }, + { + "x": 793, + "y": 345 + }, + { + "x": 805, + "y": 341 + }, + { + "x": 809, + "y": 338 + }, + { + "x": 815, + "y": 337 + }, + { + "x": 834, + "y": 327 + }, + { + "x": 838, + "y": 324 + }, + { + "x": 840, + "y": 319 + }, + { + "x": 838, + "y": 316 + }, + { + "x": 833, + "y": 314 + }, + { + "x": 829, + "y": 309 + }, + { + "x": 826, + "y": 308 + }, + { + "x": 823, + "y": 310 + }, + { + "x": 819, + "y": 310 + }, + { + "x": 815, + "y": 314 + }, + { + "x": 790, + "y": 325 + }, + { + "x": 776, + "y": 333 + }, + { + "x": 762, + "y": 339 + }, + { + "x": 757, + "y": 339 + }, + { + "x": 750, + "y": 334 + }, + { + "x": 740, + "y": 332 + }, + { + "x": 732, + "y": 327 + }, + { + "x": 717, + "y": 322 + }, + { + "x": 698, + "y": 303 + }, + { + "x": 694, + "y": 301 + }, + { + "x": 680, + "y": 287 + }, + { + "x": 677, + "y": 286 + }, + { + "x": 672, + "y": 277 + }, + { + "x": 667, + "y": 272 + }, + { + "x": 658, + "y": 268 + } + ], + "isManualAnnotation": true, + "fps": 23 + } + } + }, + { + "startFrame": 0, + "frames": { + "0": { + "frame": 0, + "answer": [ + { + "x": 254, + "y": 134 + }, + { + "x": 236, + "y": 135 + }, + { + "x": 219, + "y": 138 + }, + { + "x": 211, + "y": 142 + }, + { + "x": 207, + "y": 146 + }, + { + "x": 202, + "y": 154 + }, + { + "x": 202, + "y": 166 + }, + { + "x": 203, + "y": 175 + }, + { + "x": 209, + "y": 183 + }, + { + "x": 210, + "y": 191 + }, + { + "x": 212, + "y": 194 + }, + { + "x": 215, + "y": 194 + }, + { + "x": 219, + "y": 199 + }, + { + "x": 237, + "y": 204 + }, + { + "x": 242, + "y": 214 + }, + { + "x": 253, + "y": 219 + }, + { + "x": 256, + "y": 223 + }, + { + "x": 268, + "y": 227 + }, + { + "x": 273, + "y": 233 + }, + { + "x": 277, + "y": 235 + }, + { + "x": 277, + "y": 237 + }, + { + "x": 283, + "y": 239 + }, + { + "x": 288, + "y": 244 + }, + { + "x": 302, + "y": 250 + }, + { + "x": 306, + "y": 257 + }, + { + "x": 308, + "y": 268 + }, + { + "x": 313, + "y": 275 + }, + { + "x": 317, + "y": 286 + }, + { + "x": 322, + "y": 290 + }, + { + "x": 327, + "y": 291 + }, + { + "x": 333, + "y": 302 + }, + { + "x": 343, + "y": 303 + }, + { + "x": 347, + "y": 307 + }, + { + "x": 350, + "y": 307 + }, + { + "x": 352, + "y": 310 + }, + { + "x": 358, + "y": 311 + }, + { + "x": 362, + "y": 319 + }, + { + "x": 406, + "y": 319 + }, + { + "x": 413, + "y": 316 + }, + { + "x": 418, + "y": 316 + }, + { + "x": 433, + "y": 309 + }, + { + "x": 444, + "y": 306 + }, + { + "x": 447, + "y": 307 + }, + { + "x": 450, + "y": 305 + }, + { + "x": 451, + "y": 300 + }, + { + "x": 456, + "y": 295 + }, + { + "x": 461, + "y": 294 + }, + { + "x": 464, + "y": 291 + }, + { + "x": 470, + "y": 279 + }, + { + "x": 470, + "y": 276 + }, + { + "x": 478, + "y": 268 + }, + { + "x": 477, + "y": 258 + }, + { + "x": 482, + "y": 255 + }, + { + "x": 488, + "y": 258 + }, + { + "x": 485, + "y": 259 + }, + { + "x": 488, + "y": 262 + }, + { + "x": 485, + "y": 264 + }, + { + "x": 488, + "y": 266 + }, + { + "x": 486, + "y": 272 + }, + { + "x": 486, + "y": 290 + }, + { + "x": 488, + "y": 296 + }, + { + "x": 487, + "y": 298 + }, + { + "x": 491, + "y": 304 + }, + { + "x": 491, + "y": 309 + }, + { + "x": 495, + "y": 321 + }, + { + "x": 503, + "y": 328 + }, + { + "x": 507, + "y": 329 + }, + { + "x": 513, + "y": 340 + }, + { + "x": 525, + "y": 352 + }, + { + "x": 531, + "y": 354 + }, + { + "x": 533, + "y": 357 + }, + { + "x": 536, + "y": 357 + }, + { + "x": 540, + "y": 361 + }, + { + "x": 545, + "y": 362 + }, + { + "x": 549, + "y": 366 + }, + { + "x": 553, + "y": 366 + }, + { + "x": 560, + "y": 370 + }, + { + "x": 580, + "y": 373 + }, + { + "x": 599, + "y": 373 + }, + { + "x": 620, + "y": 369 + }, + { + "x": 628, + "y": 366 + }, + { + "x": 647, + "y": 350 + }, + { + "x": 672, + "y": 352 + }, + { + "x": 675, + "y": 354 + }, + { + "x": 692, + "y": 352 + }, + { + "x": 742, + "y": 353 + }, + { + "x": 752, + "y": 349 + }, + { + "x": 763, + "y": 348 + }, + { + "x": 778, + "y": 332 + }, + { + "x": 781, + "y": 319 + }, + { + "x": 781, + "y": 309 + }, + { + "x": 770, + "y": 290 + }, + { + "x": 754, + "y": 277 + }, + { + "x": 741, + "y": 268 + }, + { + "x": 708, + "y": 252 + }, + { + "x": 699, + "y": 250 + }, + { + "x": 688, + "y": 245 + }, + { + "x": 677, + "y": 243 + }, + { + "x": 665, + "y": 238 + }, + { + "x": 638, + "y": 233 + }, + { + "x": 614, + "y": 226 + }, + { + "x": 572, + "y": 219 + }, + { + "x": 562, + "y": 220 + }, + { + "x": 546, + "y": 218 + }, + { + "x": 522, + "y": 219 + }, + { + "x": 507, + "y": 222 + }, + { + "x": 505, + "y": 220 + }, + { + "x": 507, + "y": 218 + }, + { + "x": 503, + "y": 214 + }, + { + "x": 502, + "y": 209 + }, + { + "x": 499, + "y": 207 + }, + { + "x": 500, + "y": 205 + }, + { + "x": 498, + "y": 203 + }, + { + "x": 500, + "y": 201 + }, + { + "x": 488, + "y": 201 + }, + { + "x": 482, + "y": 207 + }, + { + "x": 474, + "y": 209 + }, + { + "x": 467, + "y": 203 + }, + { + "x": 460, + "y": 202 + }, + { + "x": 458, + "y": 199 + }, + { + "x": 448, + "y": 192 + }, + { + "x": 436, + "y": 185 + }, + { + "x": 432, + "y": 185 + }, + { + "x": 415, + "y": 174 + }, + { + "x": 392, + "y": 165 + }, + { + "x": 383, + "y": 163 + }, + { + "x": 379, + "y": 160 + }, + { + "x": 356, + "y": 154 + }, + { + "x": 348, + "y": 149 + }, + { + "x": 330, + "y": 147 + }, + { + "x": 318, + "y": 142 + }, + { + "x": 311, + "y": 142 + }, + { + "x": 296, + "y": 137 + }, + { + "x": 266, + "y": 134 + } + ], + "isManualAnnotation": true, + "fps": 23 + } + } + }, + { + "startFrame": 1406, + "frames": { + "1406": { + "frame": 1406, + "answer": [ + { + "x": 132, + "y": 74 + }, + { + "x": 130, + "y": 75 + }, + { + "x": 130, + "y": 77 + }, + { + "x": 119, + "y": 82 + }, + { + "x": 119, + "y": 85 + }, + { + "x": 114, + "y": 88 + }, + { + "x": 109, + "y": 95 + }, + { + "x": 104, + "y": 95 + }, + { + "x": 101, + "y": 93 + }, + { + "x": 105, + "y": 91 + }, + { + "x": 96, + "y": 89 + }, + { + "x": 83, + "y": 89 + }, + { + "x": 79, + "y": 87 + }, + { + "x": 72, + "y": 87 + }, + { + "x": 63, + "y": 90 + }, + { + "x": 58, + "y": 89 + }, + { + "x": 54, + "y": 91 + }, + { + "x": 54, + "y": 93 + }, + { + "x": 47, + "y": 99 + }, + { + "x": 47, + "y": 102 + }, + { + "x": 39, + "y": 108 + }, + { + "x": 40, + "y": 111 + }, + { + "x": 37, + "y": 113 + }, + { + "x": 35, + "y": 118 + }, + { + "x": 32, + "y": 121 + }, + { + "x": 28, + "y": 120 + }, + { + "x": 26, + "y": 123 + }, + { + "x": 30, + "y": 130 + }, + { + "x": 31, + "y": 146 + }, + { + "x": 36, + "y": 151 + }, + { + "x": 36, + "y": 155 + }, + { + "x": 40, + "y": 161 + }, + { + "x": 39, + "y": 163 + }, + { + "x": 43, + "y": 166 + }, + { + "x": 43, + "y": 171 + }, + { + "x": 48, + "y": 174 + }, + { + "x": 47, + "y": 177 + }, + { + "x": 49, + "y": 184 + }, + { + "x": 56, + "y": 193 + }, + { + "x": 60, + "y": 201 + }, + { + "x": 59, + "y": 205 + }, + { + "x": 63, + "y": 208 + }, + { + "x": 65, + "y": 213 + }, + { + "x": 77, + "y": 221 + }, + { + "x": 92, + "y": 225 + }, + { + "x": 99, + "y": 234 + }, + { + "x": 103, + "y": 232 + }, + { + "x": 105, + "y": 236 + }, + { + "x": 108, + "y": 236 + }, + { + "x": 116, + "y": 234 + }, + { + "x": 118, + "y": 232 + }, + { + "x": 122, + "y": 239 + }, + { + "x": 121, + "y": 241 + }, + { + "x": 125, + "y": 240 + }, + { + "x": 127, + "y": 230 + }, + { + "x": 131, + "y": 226 + }, + { + "x": 142, + "y": 219 + }, + { + "x": 155, + "y": 215 + }, + { + "x": 158, + "y": 211 + }, + { + "x": 163, + "y": 201 + }, + { + "x": 163, + "y": 182 + }, + { + "x": 165, + "y": 177 + }, + { + "x": 165, + "y": 170 + }, + { + "x": 167, + "y": 168 + }, + { + "x": 168, + "y": 161 + }, + { + "x": 171, + "y": 158 + }, + { + "x": 167, + "y": 140 + }, + { + "x": 161, + "y": 127 + }, + { + "x": 162, + "y": 115 + }, + { + "x": 157, + "y": 96 + }, + { + "x": 151, + "y": 87 + }, + { + "x": 150, + "y": 83 + }, + { + "x": 137, + "y": 74 + } + ], + "isManualAnnotation": true, + "fps": 23 + } + } + }, + { + "startFrame": 760, + "frames": { + "760": { + "frame": 760, + "answer": [ + { + "x": 721, + "y": 128 + }, + { + "x": 715, + "y": 131 + }, + { + "x": 706, + "y": 139 + }, + { + "x": 690, + "y": 156 + }, + { + "x": 681, + "y": 169 + }, + { + "x": 672, + "y": 191 + }, + { + "x": 673, + "y": 198 + }, + { + "x": 697, + "y": 192 + }, + { + "x": 717, + "y": 194 + }, + { + "x": 726, + "y": 203 + }, + { + "x": 728, + "y": 211 + }, + { + "x": 738, + "y": 226 + }, + { + "x": 738, + "y": 230 + }, + { + "x": 736, + "y": 232 + }, + { + "x": 722, + "y": 233 + }, + { + "x": 713, + "y": 244 + }, + { + "x": 706, + "y": 245 + }, + { + "x": 700, + "y": 265 + }, + { + "x": 700, + "y": 271 + }, + { + "x": 702, + "y": 273 + }, + { + "x": 710, + "y": 273 + }, + { + "x": 714, + "y": 271 + }, + { + "x": 723, + "y": 270 + }, + { + "x": 735, + "y": 270 + }, + { + "x": 752, + "y": 266 + }, + { + "x": 758, + "y": 266 + }, + { + "x": 765, + "y": 263 + }, + { + "x": 776, + "y": 261 + }, + { + "x": 785, + "y": 256 + }, + { + "x": 790, + "y": 255 + }, + { + "x": 811, + "y": 239 + }, + { + "x": 814, + "y": 233 + }, + { + "x": 817, + "y": 219 + }, + { + "x": 817, + "y": 208 + }, + { + "x": 814, + "y": 197 + }, + { + "x": 804, + "y": 182 + }, + { + "x": 789, + "y": 168 + }, + { + "x": 772, + "y": 162 + }, + { + "x": 759, + "y": 148 + }, + { + "x": 753, + "y": 145 + }, + { + "x": 742, + "y": 135 + }, + { + "x": 733, + "y": 129 + }, + { + "x": 729, + "y": 128 + } + ], + "isManualAnnotation": true, + "fps": 23 + } + } + }, + { + "startFrame": 316, + "frames": { + "316": { + "frame": 316, + "answer": [ + { + "x": 420, + "y": 148 + }, + { + "x": 417, + "y": 150 + }, + { + "x": 417, + "y": 154 + }, + { + "x": 412, + "y": 155 + }, + { + "x": 412, + "y": 158 + }, + { + "x": 416, + "y": 162 + }, + { + "x": 412, + "y": 167 + }, + { + "x": 406, + "y": 168 + }, + { + "x": 400, + "y": 165 + }, + { + "x": 393, + "y": 166 + }, + { + "x": 382, + "y": 164 + }, + { + "x": 343, + "y": 164 + }, + { + "x": 335, + "y": 166 + }, + { + "x": 299, + "y": 169 + }, + { + "x": 273, + "y": 177 + }, + { + "x": 268, + "y": 181 + }, + { + "x": 259, + "y": 184 + }, + { + "x": 248, + "y": 194 + }, + { + "x": 244, + "y": 201 + }, + { + "x": 244, + "y": 214 + }, + { + "x": 246, + "y": 220 + }, + { + "x": 263, + "y": 237 + }, + { + "x": 270, + "y": 239 + }, + { + "x": 283, + "y": 246 + }, + { + "x": 317, + "y": 252 + }, + { + "x": 327, + "y": 252 + }, + { + "x": 346, + "y": 266 + }, + { + "x": 372, + "y": 274 + }, + { + "x": 389, + "y": 274 + }, + { + "x": 414, + "y": 265 + }, + { + "x": 418, + "y": 267 + }, + { + "x": 421, + "y": 262 + }, + { + "x": 426, + "y": 260 + }, + { + "x": 425, + "y": 258 + }, + { + "x": 428, + "y": 253 + }, + { + "x": 440, + "y": 255 + }, + { + "x": 448, + "y": 259 + }, + { + "x": 450, + "y": 262 + }, + { + "x": 458, + "y": 265 + }, + { + "x": 480, + "y": 265 + }, + { + "x": 492, + "y": 262 + }, + { + "x": 512, + "y": 251 + }, + { + "x": 527, + "y": 237 + }, + { + "x": 536, + "y": 234 + }, + { + "x": 541, + "y": 234 + }, + { + "x": 575, + "y": 222 + }, + { + "x": 577, + "y": 219 + }, + { + "x": 581, + "y": 218 + }, + { + "x": 586, + "y": 214 + }, + { + "x": 593, + "y": 204 + }, + { + "x": 597, + "y": 201 + }, + { + "x": 597, + "y": 172 + }, + { + "x": 590, + "y": 165 + }, + { + "x": 568, + "y": 156 + }, + { + "x": 552, + "y": 153 + }, + { + "x": 511, + "y": 153 + }, + { + "x": 507, + "y": 155 + }, + { + "x": 492, + "y": 154 + }, + { + "x": 478, + "y": 156 + }, + { + "x": 473, + "y": 155 + }, + { + "x": 470, + "y": 157 + }, + { + "x": 460, + "y": 158 + }, + { + "x": 441, + "y": 166 + }, + { + "x": 433, + "y": 166 + }, + { + "x": 431, + "y": 162 + }, + { + "x": 431, + "y": 156 + }, + { + "x": 423, + "y": 148 + } + ], + "isManualAnnotation": true, + "fps": 23 + } + } + } + ] + } + ] + }, + { + "file_name": "seafood_1280p.mp4", + "annotations": [ + { + "question_type": "polygon", + "question_name": "Class polygon ", + "answer": [ + { + "startFrame": 0, + "frames": { + "0": { + "frame": 0, + "answer": [ + { + "x": 100, + "y": 426 + }, + { + "x": 81, + "y": 428 + }, + { + "x": 70, + "y": 431 + }, + { + "x": 55, + "y": 438 + }, + { + "x": 41, + "y": 442 + }, + { + "x": 37, + "y": 445 + }, + { + "x": 26, + "y": 448 + }, + { + "x": 37, + "y": 448 + }, + { + "x": 47, + "y": 451 + }, + { + "x": 77, + "y": 451 + }, + { + "x": 81, + "y": 454 + }, + { + "x": 92, + "y": 456 + }, + { + "x": 108, + "y": 463 + }, + { + "x": 117, + "y": 470 + }, + { + "x": 122, + "y": 478 + }, + { + "x": 126, + "y": 480 + }, + { + "x": 137, + "y": 492 + }, + { + "x": 145, + "y": 494 + }, + { + "x": 163, + "y": 489 + }, + { + "x": 189, + "y": 485 + }, + { + "x": 198, + "y": 479 + }, + { + "x": 205, + "y": 468 + }, + { + "x": 196, + "y": 466 + }, + { + "x": 189, + "y": 458 + }, + { + "x": 174, + "y": 446 + }, + { + "x": 172, + "y": 439 + }, + { + "x": 169, + "y": 437 + }, + { + "x": 169, + "y": 430 + }, + { + "x": 148, + "y": 427 + }, + { + "x": 132, + "y": 428 + }, + { + "x": 127, + "y": 426 + } + ], + "isManualAnnotation": true, + "fps": 23 + } + } + } + ] + } + ] + } +] \ No newline at end of file diff --git a/labellerr/notebooks/example_coco_to_video_conversion.py b/labellerr/notebooks/example_coco_to_video_conversion.py new file mode 100644 index 0000000..b14441c --- /dev/null +++ b/labellerr/notebooks/example_coco_to_video_conversion.py @@ -0,0 +1,73 @@ +""" +Example: Converting COCO JSON (Keyframe Export) to Video JSON Format + +This notebook demonstrates how to convert COCO JSON annotations exported from +keyframe image projects into the video JSON format required for preannotation upload. +""" + +import sys +from pathlib import Path + +# Add SDK to path +sys.path.insert(0, str(Path.cwd().parent.parent)) + +from labellerr.services.video_sampling.coco_to_video import coco_to_video_json + +# Example 1: Basic conversion +print("=" * 70) +print("EXAMPLE 1: Basic COCO to Video JSON Conversion") +print("=" * 70) + +coco_json_path = "export_zmYykSJhCAJqAaXaJQ3g.json" +output_path = "video_preannotations.json" + +video_annotations = coco_to_video_json( + coco_json_path=coco_json_path, + output_path=output_path, + fps=23 # Frames per second of your videos +) + +print(f"\n✓ Converted {len(video_annotations)} video(s)") +print(f"✓ Output saved to: {output_path}") + +# Example 2: Conversion without saving to file +print("\n" + "=" * 70) +print("EXAMPLE 2: Conversion without saving (returns data only)") +print("=" * 70) + +video_data = coco_to_video_json( + coco_json_path=coco_json_path, + output_path=None, # Don't save to file + fps=25 +) + +print(f"\n✓ Converted {len(video_data)} video(s) (data in memory)") + +# Display structure +for video in video_data[:1]: # Show first video only + print(f"\nVideo: {video['file_name']}") + print(f" Annotations: {len(video['annotations'])}") + for ann in video['annotations']: + print(f" - {ann['question_name']} ({ann['question_type']})") + print(f" Answer groups: {len(ann['answer'])}") + +# Example 3: Using the converted JSON for preannotation upload +print("\n" + "=" * 70) +print("EXAMPLE 3: Upload converted annotations to video project") +print("=" * 70) + +# Uncomment to use: +# from labellerr import LabellerrClient +# from labellerr.core.projects import LabellerrProject +# +# client = LabellerrClient(api_key="your_api_key") +# project = LabellerrProject(client=client, project_id="your_video_project_id") +# +# # Upload the converted annotations +# result = project.upload_preannotations( +# annotation_format="video_json", +# annotation_file=output_path +# ) +# print(f"✓ Preannotations uploaded successfully!") + +print("\nDone!") diff --git a/labellerr/notebooks/export_zmYykSJhCAJqAaXaJQ3g.json b/labellerr/notebooks/export_zmYykSJhCAJqAaXaJQ3g.json new file mode 100644 index 0000000..f4cedd2 --- /dev/null +++ b/labellerr/notebooks/export_zmYykSJhCAJqAaXaJQ3g.json @@ -0,0 +1,3358 @@ +{ + "info": { + "description": "Test Export", + "year": 2024, + "date_created": 1765261244896, + "contributor": "dev@labellerr.com", + "url": "https://some-url.dk", + "version": "zmYykSJhCAJqAaXaJQ3g" + }, + "licenses": [ + { + "url": "https://some-url.dk", + "id": 1, + "name": "The license name" + } + ], + "images": [ + { + "id": 0, + "width": 960, + "height": 540, + "file_name": "15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p+frame_1064.jpg", + "date_created": 1765227120489, + "labellerr_file_id": "435f3c6b-f95f-4c69-b2d6-577ce897d385", + "labellerr_file_status": "review", + "labellerr_file_remarks": "" + }, + { + "id": 1, + "width": 960, + "height": 540, + "file_name": "15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p+frame_0.jpg", + "date_created": 1765227120326, + "labellerr_file_id": "9399098c-2ff2-448e-a5e0-3d03f6ee507c", + "labellerr_file_status": "review", + "labellerr_file_remarks": "" + }, + { + "id": 2, + "width": 960, + "height": 540, + "file_name": "15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p+frame_1406.jpg", + "date_created": 1765227120579, + "labellerr_file_id": "b90d9ccb-eaa4-4eee-9741-86872f79467b", + "labellerr_file_status": "review", + "labellerr_file_remarks": "" + }, + { + "id": 3, + "width": 960, + "height": 540, + "file_name": "15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p+frame_760.jpg", + "date_created": 1765227120537, + "labellerr_file_id": "bfc6fb39-a5d5-4f3e-bba3-58326b6ee158", + "labellerr_file_status": "review", + "labellerr_file_remarks": "" + }, + { + "id": 4, + "width": 1280, + "height": 720, + "file_name": "15908795-09eb-4cdb-a39b-8689f8f936e5+a878e61b-8aeb-46e1-ab10-5f1852bcdcbe+seafood_1280p+frame_0.jpg", + "date_created": 1765227120546, + "labellerr_file_id": "d4009a05-438c-4c64-8cc2-f17de8d8fe52", + "labellerr_file_status": "review", + "labellerr_file_remarks": "" + }, + { + "id": 5, + "width": 960, + "height": 540, + "file_name": "15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p+frame_316.jpg", + "date_created": 1765227120329, + "labellerr_file_id": "e9456cd6-9ce1-4e92-98ca-9f286ab39bc8", + "labellerr_file_status": "review", + "labellerr_file_remarks": "" + } + ], + "annotations": [ + { + "image_id": 0, + "category_id": 0, + "segmentation": [ + [ + 286, + 47, + 283, + 49, + 281, + 57, + 275, + 107, + 275, + 135, + 278, + 142, + 277, + 144, + 284, + 153, + 284, + 160, + 298, + 169, + 299, + 167, + 305, + 165, + 303, + 163, + 305, + 161, + 306, + 153, + 305, + 136, + 301, + 115, + 301, + 107, + 299, + 102, + 300, + 100, + 294, + 72, + 294, + 65, + 290, + 49, + 287, + 48 + ] + ], + "iscrowd": 0, + "bbox": [ + 275, + 47, + 31, + 122 + ], + "area": 2498.5, + "id": 0, + "attributes": [], + "labellerr_answer_id": "dc9a854d-0b7f-434d-9b60-68e7ea4aa002" + }, + { + "image_id": 0, + "category_id": 0, + "segmentation": [ + [ + 467, + 292, + 463, + 308, + 462, + 330, + 459, + 340, + 457, + 342, + 458, + 362, + 456, + 375, + 453, + 380, + 447, + 379, + 439, + 388, + 436, + 395, + 441, + 392, + 443, + 387, + 451, + 382, + 461, + 393, + 465, + 395, + 476, + 397, + 485, + 392, + 488, + 393, + 489, + 401, + 492, + 403, + 493, + 407, + 492, + 398, + 494, + 395, + 496, + 395, + 496, + 393, + 490, + 386, + 487, + 379, + 485, + 360, + 482, + 352, + 483, + 349, + 481, + 335, + 479, + 330, + 480, + 317, + 474, + 297, + 470, + 292 + ] + ], + "iscrowd": 0, + "bbox": [ + 436, + 292, + 60, + 115 + ], + "area": 2440.5, + "id": 1, + "attributes": [], + "labellerr_answer_id": "5f5f74de-11d9-42f2-aa3b-9ccfa0043d44" + }, + { + "image_id": 0, + "category_id": 0, + "segmentation": [ + [ + 655, + 268, + 646, + 271, + 634, + 285, + 636, + 287, + 636, + 298, + 633, + 302, + 634, + 304, + 632, + 306, + 630, + 315, + 630, + 322, + 632, + 326, + 632, + 328, + 630, + 329, + 630, + 334, + 632, + 336, + 634, + 343, + 634, + 351, + 638, + 361, + 638, + 367, + 642, + 372, + 644, + 382, + 647, + 385, + 657, + 408, + 655, + 414, + 664, + 421, + 670, + 423, + 696, + 422, + 710, + 417, + 719, + 416, + 726, + 413, + 737, + 405, + 759, + 396, + 764, + 391, + 766, + 385, + 772, + 379, + 773, + 373, + 777, + 368, + 783, + 356, + 783, + 353, + 786, + 350, + 793, + 345, + 805, + 341, + 809, + 338, + 815, + 337, + 834, + 327, + 838, + 324, + 840, + 319, + 838, + 316, + 833, + 314, + 829, + 309, + 826, + 308, + 823, + 310, + 819, + 310, + 815, + 314, + 790, + 325, + 776, + 333, + 762, + 339, + 757, + 339, + 750, + 334, + 740, + 332, + 732, + 327, + 717, + 322, + 698, + 303, + 694, + 301, + 680, + 287, + 677, + 286, + 672, + 277, + 667, + 272, + 658, + 268 + ] + ], + "iscrowd": 0, + "bbox": [ + 630, + 268, + 210, + 155 + ], + "area": 15571.0, + "id": 2, + "attributes": [], + "labellerr_answer_id": "34ccaa11-e4df-4b22-a666-fe9aca8adf2d" + }, + { + "image_id": 1, + "category_id": 0, + "segmentation": [ + [ + 254, + 134, + 236, + 135, + 219, + 138, + 211, + 142, + 207, + 146, + 202, + 154, + 202, + 166, + 203, + 175, + 209, + 183, + 210, + 191, + 212, + 194, + 215, + 194, + 219, + 199, + 237, + 204, + 242, + 214, + 253, + 219, + 256, + 223, + 268, + 227, + 273, + 233, + 277, + 235, + 277, + 237, + 283, + 239, + 288, + 244, + 302, + 250, + 306, + 257, + 308, + 268, + 313, + 275, + 317, + 286, + 322, + 290, + 327, + 291, + 333, + 302, + 343, + 303, + 347, + 307, + 350, + 307, + 352, + 310, + 358, + 311, + 362, + 319, + 406, + 319, + 413, + 316, + 418, + 316, + 433, + 309, + 444, + 306, + 447, + 307, + 450, + 305, + 451, + 300, + 456, + 295, + 461, + 294, + 464, + 291, + 470, + 279, + 470, + 276, + 478, + 268, + 477, + 258, + 482, + 255, + 488, + 258, + 485, + 259, + 488, + 262, + 485, + 264, + 488, + 266, + 486, + 272, + 486, + 290, + 488, + 296, + 487, + 298, + 491, + 304, + 491, + 309, + 495, + 321, + 503, + 328, + 507, + 329, + 513, + 340, + 525, + 352, + 531, + 354, + 533, + 357, + 536, + 357, + 540, + 361, + 545, + 362, + 549, + 366, + 553, + 366, + 560, + 370, + 580, + 373, + 599, + 373, + 620, + 369, + 628, + 366, + 647, + 350, + 672, + 352, + 675, + 354, + 692, + 352, + 742, + 353, + 752, + 349, + 763, + 348, + 778, + 332, + 781, + 319, + 781, + 309, + 770, + 290, + 754, + 277, + 741, + 268, + 708, + 252, + 699, + 250, + 688, + 245, + 677, + 243, + 665, + 238, + 638, + 233, + 614, + 226, + 572, + 219, + 562, + 220, + 546, + 218, + 522, + 219, + 507, + 222, + 505, + 220, + 507, + 218, + 503, + 214, + 502, + 209, + 499, + 207, + 500, + 205, + 498, + 203, + 500, + 201, + 488, + 201, + 482, + 207, + 474, + 209, + 467, + 203, + 460, + 202, + 458, + 199, + 448, + 192, + 436, + 185, + 432, + 185, + 415, + 174, + 392, + 165, + 383, + 163, + 379, + 160, + 356, + 154, + 348, + 149, + 330, + 147, + 318, + 142, + 311, + 142, + 296, + 137, + 266, + 134 + ] + ], + "iscrowd": 0, + "bbox": [ + 202, + 134, + 579, + 239 + ], + "area": 66347.0, + "id": 3, + "attributes": [], + "labellerr_answer_id": "936a656d-3600-4328-9cd5-487576bae907" + }, + { + "image_id": 2, + "category_id": 0, + "segmentation": [ + [ + 458, + 216, + 449, + 221, + 448, + 226, + 445, + 228, + 443, + 232, + 441, + 241, + 435, + 255, + 432, + 291, + 430, + 298, + 430, + 342, + 434, + 349, + 433, + 355, + 436, + 362, + 442, + 399, + 449, + 414, + 452, + 426, + 465, + 444, + 470, + 442, + 474, + 446, + 477, + 446, + 480, + 443, + 492, + 443, + 504, + 437, + 517, + 433, + 531, + 424, + 541, + 415, + 551, + 401, + 572, + 390, + 582, + 379, + 597, + 376, + 601, + 374, + 603, + 370, + 599, + 364, + 599, + 360, + 602, + 355, + 596, + 346, + 593, + 338, + 579, + 329, + 575, + 329, + 570, + 325, + 560, + 323, + 551, + 319, + 532, + 305, + 524, + 302, + 509, + 288, + 496, + 262, + 497, + 260, + 490, + 243, + 478, + 223, + 470, + 216 + ] + ], + "iscrowd": 0, + "bbox": [ + 430, + 216, + 173, + 230 + ], + "area": 22420.5, + "id": 4, + "attributes": [], + "labellerr_answer_id": "38f1b8fc-87f1-4ca1-8749-bfdc31fb4f1f" + }, + { + "image_id": 2, + "category_id": 0, + "segmentation": [ + [ + 554, + 172, + 548, + 174, + 533, + 189, + 514, + 222, + 511, + 237, + 508, + 243, + 505, + 261, + 505, + 273, + 510, + 286, + 527, + 301, + 536, + 305, + 556, + 319, + 566, + 320, + 571, + 323, + 602, + 319, + 606, + 316, + 614, + 314, + 618, + 310, + 622, + 301, + 623, + 271, + 625, + 269, + 626, + 264, + 615, + 242, + 609, + 236, + 604, + 228, + 591, + 216, + 588, + 209, + 580, + 199, + 576, + 190, + 570, + 184, + 568, + 178, + 560, + 172 + ] + ], + "iscrowd": 0, + "bbox": [ + 505, + 172, + 121, + 151 + ], + "area": 12404.0, + "id": 5, + "attributes": [], + "labellerr_answer_id": "34169294-ef53-449b-879f-d37fa7eb3b76" + }, + { + "image_id": 2, + "category_id": 0, + "segmentation": [ + [ + 644, + 108, + 631, + 112, + 629, + 115, + 628, + 123, + 623, + 132, + 620, + 147, + 616, + 153, + 619, + 171, + 618, + 176, + 616, + 178, + 618, + 181, + 618, + 185, + 614, + 186, + 617, + 188, + 618, + 195, + 622, + 199, + 622, + 203, + 620, + 205, + 622, + 207, + 620, + 209, + 628, + 217, + 630, + 236, + 633, + 240, + 632, + 245, + 636, + 250, + 634, + 256, + 637, + 259, + 642, + 260, + 646, + 264, + 675, + 262, + 682, + 259, + 702, + 255, + 710, + 255, + 726, + 249, + 736, + 244, + 739, + 240, + 743, + 239, + 759, + 220, + 759, + 205, + 757, + 203, + 758, + 201, + 755, + 196, + 755, + 192, + 742, + 172, + 738, + 169, + 739, + 167, + 708, + 144, + 699, + 134, + 688, + 129, + 681, + 122, + 666, + 114, + 648, + 108 + ] + ], + "iscrowd": 0, + "bbox": [ + 614, + 108, + 145, + 156 + ], + "area": 15708.0, + "id": 6, + "attributes": [], + "labellerr_answer_id": "42d48149-3fe5-4a76-a7cc-38984d73b2cf" + }, + { + "image_id": 2, + "category_id": 0, + "segmentation": [ + [ + 158, + 216, + 155, + 220, + 147, + 218, + 149, + 220, + 144, + 220, + 143, + 223, + 138, + 227, + 133, + 226, + 129, + 228, + 129, + 239, + 128, + 244, + 125, + 248, + 120, + 247, + 117, + 241, + 118, + 237, + 112, + 236, + 107, + 239, + 99, + 237, + 88, + 241, + 68, + 243, + 49, + 250, + 45, + 250, + 36, + 253, + 21, + 262, + 22, + 265, + 27, + 268, + 27, + 272, + 32, + 280, + 38, + 282, + 48, + 291, + 55, + 294, + 71, + 305, + 83, + 319, + 86, + 319, + 90, + 322, + 90, + 328, + 86, + 330, + 91, + 335, + 100, + 335, + 111, + 340, + 114, + 340, + 124, + 347, + 135, + 349, + 140, + 349, + 144, + 344, + 148, + 344, + 149, + 340, + 152, + 337, + 159, + 334, + 163, + 336, + 165, + 334, + 164, + 331, + 172, + 326, + 173, + 319, + 179, + 319, + 176, + 317, + 177, + 316, + 181, + 317, + 180, + 306, + 178, + 301, + 178, + 292, + 181, + 288, + 180, + 285, + 182, + 283, + 184, + 285, + 189, + 283, + 190, + 279, + 197, + 273, + 202, + 273, + 204, + 275, + 208, + 274, + 208, + 268, + 206, + 268, + 205, + 270, + 203, + 270, + 202, + 268, + 208, + 265, + 205, + 262, + 209, + 260, + 211, + 254, + 215, + 252, + 215, + 250, + 219, + 246, + 217, + 244, + 217, + 240, + 214, + 240, + 212, + 243, + 215, + 246, + 211, + 249, + 208, + 247, + 206, + 245, + 207, + 238, + 205, + 233, + 194, + 222, + 175, + 220, + 167, + 216 + ] + ], + "iscrowd": 0, + "bbox": [ + 21, + 216, + 198, + 133 + ], + "area": 14533.0, + "id": 7, + "attributes": [], + "labellerr_answer_id": "5f9ab67a-c58d-4ddb-8a99-c0d85af59057" + }, + { + "image_id": 2, + "category_id": 0, + "segmentation": [ + [ + 762, + 121, + 754, + 127, + 755, + 137, + 756, + 140, + 759, + 142, + 754, + 144, + 758, + 147, + 759, + 150, + 761, + 150, + 764, + 153, + 763, + 162, + 765, + 169, + 775, + 184, + 775, + 187, + 768, + 195, + 769, + 203, + 772, + 207, + 772, + 210, + 770, + 213, + 770, + 217, + 767, + 220, + 765, + 220, + 764, + 223, + 758, + 225, + 745, + 239, + 739, + 241, + 735, + 245, + 734, + 249, + 745, + 251, + 751, + 256, + 771, + 256, + 775, + 254, + 785, + 254, + 797, + 249, + 804, + 249, + 821, + 250, + 834, + 253, + 876, + 253, + 884, + 256, + 886, + 259, + 901, + 256, + 906, + 257, + 908, + 256, + 911, + 249, + 959, + 252, + 959, + 244, + 946, + 241, + 941, + 237, + 916, + 230, + 911, + 226, + 903, + 224, + 898, + 220, + 890, + 217, + 867, + 193, + 861, + 189, + 855, + 181, + 854, + 176, + 850, + 170, + 849, + 163, + 841, + 151, + 838, + 149, + 837, + 144, + 834, + 142, + 835, + 140, + 830, + 132, + 826, + 131, + 826, + 135, + 819, + 136, + 819, + 138, + 823, + 137, + 825, + 140, + 829, + 142, + 824, + 144, + 826, + 146, + 827, + 151, + 824, + 153, + 819, + 153, + 815, + 157, + 817, + 163, + 814, + 165, + 814, + 167, + 817, + 170, + 812, + 171, + 810, + 169, + 810, + 166, + 799, + 155, + 793, + 152, + 787, + 154, + 784, + 151, + 785, + 149, + 789, + 148, + 789, + 144, + 786, + 142, + 788, + 140, + 786, + 138, + 784, + 131, + 782, + 129, + 778, + 131, + 773, + 131, + 771, + 129, + 774, + 127, + 774, + 125, + 768, + 121 + ] + ], + "iscrowd": 0, + "bbox": [ + 734, + 121, + 225, + 138 + ], + "area": 13309.0, + "id": 8, + "attributes": [], + "labellerr_answer_id": "697f821d-9d7b-4586-b20b-d944bd774bd5" + }, + { + "image_id": 2, + "category_id": 0, + "segmentation": [ + [ + 132, + 74, + 130, + 75, + 130, + 77, + 119, + 82, + 119, + 85, + 114, + 88, + 109, + 95, + 104, + 95, + 101, + 93, + 105, + 91, + 96, + 89, + 83, + 89, + 79, + 87, + 72, + 87, + 63, + 90, + 58, + 89, + 54, + 91, + 54, + 93, + 47, + 99, + 47, + 102, + 39, + 108, + 40, + 111, + 37, + 113, + 35, + 118, + 32, + 121, + 28, + 120, + 26, + 123, + 30, + 130, + 31, + 146, + 36, + 151, + 36, + 155, + 40, + 161, + 39, + 163, + 43, + 166, + 43, + 171, + 48, + 174, + 47, + 177, + 49, + 184, + 56, + 193, + 60, + 201, + 59, + 205, + 63, + 208, + 65, + 213, + 77, + 221, + 92, + 225, + 99, + 234, + 103, + 232, + 105, + 236, + 108, + 236, + 116, + 234, + 118, + 232, + 122, + 239, + 121, + 241, + 125, + 240, + 127, + 230, + 131, + 226, + 142, + 219, + 155, + 215, + 158, + 211, + 163, + 201, + 163, + 182, + 165, + 177, + 165, + 170, + 167, + 168, + 168, + 161, + 171, + 158, + 167, + 140, + 161, + 127, + 162, + 115, + 157, + 96, + 151, + 87, + 150, + 83, + 137, + 74 + ] + ], + "iscrowd": 0, + "bbox": [ + 26, + 74, + 145, + 167 + ], + "area": 16331.5, + "id": 9, + "attributes": [], + "labellerr_answer_id": "1023d75f-1c1e-4e31-8ed9-3bd6542fc0d5" + }, + { + "image_id": 3, + "category_id": 0, + "segmentation": [ + [ + 327, + 215, + 323, + 217, + 309, + 232, + 293, + 256, + 281, + 285, + 274, + 316, + 273, + 341, + 274, + 347, + 276, + 349, + 282, + 352, + 289, + 353, + 298, + 352, + 308, + 346, + 330, + 337, + 366, + 319, + 375, + 313, + 386, + 302, + 390, + 297, + 390, + 294, + 393, + 289, + 395, + 279, + 395, + 266, + 389, + 247, + 379, + 240, + 368, + 238, + 366, + 235, + 356, + 231, + 342, + 221, + 340, + 218, + 333, + 215 + ] + ], + "iscrowd": 0, + "bbox": [ + 273, + 215, + 122, + 138 + ], + "area": 11057.5, + "id": 10, + "attributes": [], + "labellerr_answer_id": "8543f430-ca72-445c-a13c-b949dc1fde0b" + }, + { + "image_id": 3, + "category_id": 0, + "segmentation": [ + [ + 406, + 117, + 398, + 120, + 392, + 126, + 381, + 142, + 377, + 150, + 372, + 172, + 372, + 195, + 378, + 223, + 383, + 236, + 395, + 254, + 398, + 260, + 397, + 262, + 399, + 263, + 422, + 262, + 430, + 258, + 440, + 257, + 462, + 250, + 485, + 245, + 489, + 242, + 505, + 237, + 515, + 228, + 526, + 208, + 528, + 192, + 531, + 188, + 532, + 182, + 536, + 174, + 534, + 168, + 525, + 158, + 511, + 150, + 490, + 144, + 477, + 144, + 463, + 134, + 448, + 130, + 422, + 117, + 407, + 117 + ] + ], + "iscrowd": 0, + "bbox": [ + 372, + 117, + 164, + 146 + ], + "area": 17252.5, + "id": 11, + "attributes": [], + "labellerr_answer_id": "a19c25ca-bad5-4e9f-9ab5-0eeb20c521f1" + }, + { + "image_id": 3, + "category_id": 0, + "segmentation": [ + [ + 693, + 194, + 673, + 199, + 653, + 210, + 638, + 221, + 615, + 243, + 596, + 268, + 591, + 272, + 582, + 286, + 575, + 304, + 571, + 308, + 567, + 308, + 562, + 304, + 555, + 302, + 552, + 298, + 547, + 298, + 547, + 313, + 552, + 323, + 549, + 326, + 542, + 327, + 526, + 333, + 512, + 342, + 491, + 361, + 482, + 378, + 482, + 384, + 478, + 392, + 478, + 408, + 480, + 411, + 482, + 424, + 495, + 435, + 500, + 444, + 504, + 447, + 512, + 446, + 512, + 443, + 515, + 440, + 514, + 437, + 517, + 434, + 519, + 428, + 527, + 423, + 541, + 423, + 556, + 426, + 572, + 426, + 584, + 423, + 606, + 424, + 611, + 426, + 615, + 430, + 616, + 434, + 620, + 439, + 624, + 441, + 628, + 440, + 629, + 431, + 627, + 427, + 631, + 424, + 643, + 424, + 646, + 421, + 652, + 421, + 657, + 424, + 659, + 423, + 657, + 418, + 654, + 417, + 653, + 414, + 648, + 413, + 646, + 411, + 642, + 403, + 630, + 390, + 630, + 385, + 634, + 384, + 658, + 392, + 661, + 400, + 668, + 405, + 676, + 402, + 671, + 396, + 668, + 395, + 673, + 392, + 672, + 388, + 677, + 380, + 676, + 377, + 678, + 371, + 685, + 362, + 689, + 361, + 685, + 355, + 684, + 343, + 686, + 337, + 686, + 325, + 690, + 319, + 697, + 298, + 696, + 292, + 698, + 281, + 698, + 261, + 702, + 256, + 703, + 249, + 706, + 244, + 712, + 243, + 717, + 239, + 722, + 232, + 732, + 231, + 733, + 230, + 731, + 229, + 735, + 226, + 735, + 222, + 730, + 216, + 726, + 204, + 722, + 202, + 717, + 195, + 714, + 194 + ] + ], + "iscrowd": 0, + "bbox": [ + 478, + 194, + 257, + 253 + ], + "area": 31710.0, + "id": 12, + "attributes": [], + "labellerr_answer_id": "d47f1429-4a41-4fa5-9446-f06138b3fb85" + }, + { + "image_id": 3, + "category_id": 0, + "segmentation": [ + [ + 721, + 128, + 715, + 131, + 706, + 139, + 690, + 156, + 681, + 169, + 672, + 191, + 673, + 198, + 697, + 192, + 717, + 194, + 726, + 203, + 728, + 211, + 738, + 226, + 738, + 230, + 736, + 232, + 722, + 233, + 713, + 244, + 706, + 245, + 700, + 265, + 700, + 271, + 702, + 273, + 710, + 273, + 714, + 271, + 723, + 270, + 735, + 270, + 752, + 266, + 758, + 266, + 765, + 263, + 776, + 261, + 785, + 256, + 790, + 255, + 811, + 239, + 814, + 233, + 817, + 219, + 817, + 208, + 814, + 197, + 804, + 182, + 789, + 168, + 772, + 162, + 759, + 148, + 753, + 145, + 742, + 135, + 733, + 129, + 729, + 128 + ] + ], + "iscrowd": 0, + "bbox": [ + 672, + 128, + 145, + 145 + ], + "area": 12041.0, + "id": 13, + "attributes": [], + "labellerr_answer_id": "3d9c638e-eef1-4cc4-b952-16bda434fc82" + }, + { + "image_id": 4, + "category_id": 0, + "segmentation": [ + [ + 632, + 351, + 620, + 353, + 621, + 356, + 619, + 359, + 600, + 360, + 597, + 367, + 592, + 367, + 587, + 370, + 572, + 373, + 569, + 376, + 562, + 376, + 555, + 381, + 534, + 383, + 525, + 387, + 512, + 388, + 507, + 392, + 494, + 391, + 486, + 395, + 482, + 395, + 477, + 401, + 471, + 404, + 451, + 407, + 437, + 415, + 423, + 418, + 418, + 421, + 402, + 426, + 397, + 431, + 385, + 434, + 373, + 440, + 363, + 442, + 357, + 447, + 335, + 451, + 345, + 459, + 345, + 463, + 349, + 466, + 350, + 470, + 357, + 473, + 372, + 492, + 377, + 492, + 399, + 481, + 415, + 476, + 417, + 474, + 427, + 472, + 430, + 469, + 444, + 464, + 454, + 458, + 463, + 456, + 478, + 457, + 481, + 455, + 482, + 451, + 489, + 453, + 491, + 451, + 488, + 448, + 492, + 445, + 507, + 444, + 512, + 442, + 523, + 443, + 532, + 440, + 516, + 440, + 512, + 437, + 526, + 432, + 537, + 431, + 546, + 422, + 555, + 420, + 561, + 416, + 573, + 415, + 582, + 411, + 617, + 404, + 624, + 400, + 636, + 397, + 653, + 388, + 677, + 380, + 677, + 374, + 663, + 368, + 659, + 364, + 659, + 361, + 639, + 359, + 635, + 355, + 637, + 354, + 637, + 352, + 635, + 351 + ] + ], + "iscrowd": 0, + "bbox": [ + 335, + 351, + 342, + 141 + ], + "area": 14810.5, + "id": 14, + "attributes": [], + "labellerr_answer_id": "ec4aebd1-023a-4677-88e1-a70a179b63ca" + }, + { + "image_id": 4, + "category_id": 0, + "segmentation": [ + [ + 1019, + 263, + 1003, + 265, + 1000, + 268, + 986, + 271, + 975, + 270, + 973, + 268, + 969, + 268, + 967, + 265, + 964, + 265, + 970, + 270, + 968, + 273, + 951, + 275, + 945, + 272, + 937, + 277, + 932, + 277, + 924, + 280, + 880, + 280, + 854, + 278, + 833, + 275, + 820, + 270, + 801, + 267, + 797, + 265, + 761, + 265, + 756, + 271, + 761, + 291, + 765, + 303, + 767, + 305, + 800, + 306, + 815, + 308, + 844, + 317, + 860, + 319, + 865, + 319, + 880, + 314, + 898, + 314, + 926, + 309, + 993, + 314, + 1016, + 305, + 1028, + 305, + 1032, + 303, + 1037, + 303, + 1041, + 297, + 1039, + 289, + 1031, + 273, + 1027, + 267, + 1022, + 263 + ] + ], + "iscrowd": 0, + "bbox": [ + 756, + 263, + 285, + 56 + ], + "area": 10582.0, + "id": 15, + "attributes": [], + "labellerr_answer_id": "b11c7230-4144-4974-9a4f-a5efeddd20ac" + }, + { + "image_id": 4, + "category_id": 0, + "segmentation": [ + [ + 427, + 81, + 422, + 89, + 417, + 111, + 414, + 114, + 414, + 119, + 417, + 122, + 449, + 130, + 458, + 133, + 462, + 136, + 478, + 140, + 487, + 149, + 505, + 155, + 507, + 159, + 512, + 161, + 512, + 165, + 525, + 160, + 533, + 159, + 537, + 161, + 543, + 161, + 551, + 166, + 558, + 164, + 582, + 173, + 597, + 175, + 601, + 179, + 607, + 179, + 618, + 184, + 623, + 184, + 630, + 178, + 634, + 164, + 639, + 158, + 641, + 152, + 641, + 147, + 637, + 143, + 600, + 135, + 590, + 130, + 581, + 129, + 574, + 125, + 522, + 108, + 517, + 105, + 503, + 102, + 487, + 96, + 435, + 81 + ] + ], + "iscrowd": 0, + "bbox": [ + 414, + 81, + 227, + 103 + ], + "area": 10111.0, + "id": 16, + "attributes": [], + "labellerr_answer_id": "0ea5b3e0-e8db-4b24-b9fe-8c3dcd0834ca" + }, + { + "image_id": 4, + "category_id": 0, + "segmentation": [ + [ + 836, + 85, + 832, + 87, + 793, + 88, + 828, + 89, + 831, + 91, + 830, + 92, + 806, + 93, + 724, + 91, + 711, + 92, + 685, + 89, + 659, + 90, + 649, + 99, + 652, + 102, + 650, + 105, + 650, + 116, + 652, + 125, + 657, + 134, + 657, + 148, + 660, + 147, + 659, + 143, + 661, + 140, + 667, + 142, + 667, + 140, + 665, + 139, + 669, + 136, + 665, + 135, + 663, + 131, + 670, + 129, + 678, + 129, + 682, + 133, + 687, + 130, + 707, + 132, + 738, + 131, + 746, + 133, + 755, + 132, + 769, + 135, + 796, + 135, + 802, + 133, + 840, + 135, + 857, + 133, + 862, + 137, + 867, + 134, + 867, + 130, + 869, + 127, + 871, + 95, + 866, + 92, + 861, + 85 + ] + ], + "iscrowd": 0, + "bbox": [ + 649, + 85, + 222, + 63 + ], + "area": 9366.5, + "id": 17, + "attributes": [], + "labellerr_answer_id": "315d2f4e-d81c-4193-8ffc-850ef564030b" + }, + { + "image_id": 4, + "category_id": 0, + "segmentation": [ + [ + 1072, + 127, + 1049, + 129, + 985, + 129, + 982, + 131, + 980, + 133, + 978, + 151, + 980, + 159, + 978, + 170, + 979, + 179, + 977, + 185, + 978, + 202, + 976, + 204, + 976, + 209, + 978, + 211, + 1038, + 210, + 1112, + 206, + 1115, + 203, + 1114, + 199, + 1119, + 161, + 1118, + 159, + 1120, + 150, + 1120, + 136, + 1116, + 131, + 1074, + 127 + ] + ], + "iscrowd": 0, + "bbox": [ + 976, + 127, + 144, + 84 + ], + "area": 11138.5, + "id": 18, + "attributes": [], + "labellerr_answer_id": "6c8c3bed-01fc-4638-a367-b11477bdefd4" + }, + { + "image_id": 4, + "category_id": 0, + "segmentation": [ + [ + 1266, + 129, + 1248, + 131, + 1208, + 130, + 1196, + 133, + 1169, + 135, + 1167, + 141, + 1166, + 155, + 1167, + 163, + 1165, + 174, + 1165, + 198, + 1168, + 203, + 1174, + 204, + 1197, + 203, + 1205, + 201, + 1279, + 199, + 1279, + 129 + ] + ], + "iscrowd": 0, + "bbox": [ + 1165, + 129, + 114, + 75 + ], + "area": 7884.0, + "id": 19, + "attributes": [], + "labellerr_answer_id": "da82ff7f-e4f0-4a59-9cc8-bd9a54b80749" + }, + { + "image_id": 4, + "category_id": 0, + "segmentation": [ + [ + 72, + 104, + 68, + 108, + 63, + 108, + 57, + 105, + 55, + 108, + 53, + 108, + 52, + 111, + 52, + 122, + 60, + 130, + 61, + 136, + 59, + 141, + 52, + 148, + 47, + 166, + 46, + 200, + 49, + 206, + 47, + 212, + 50, + 217, + 53, + 240, + 49, + 248, + 49, + 254, + 52, + 262, + 54, + 277, + 59, + 291, + 61, + 312, + 67, + 334, + 66, + 338, + 71, + 352, + 82, + 359, + 131, + 358, + 146, + 354, + 157, + 357, + 160, + 354, + 161, + 339, + 158, + 333, + 159, + 322, + 157, + 318, + 155, + 299, + 152, + 297, + 147, + 297, + 146, + 294, + 140, + 290, + 139, + 286, + 139, + 283, + 141, + 281, + 150, + 280, + 152, + 277, + 150, + 274, + 150, + 259, + 148, + 257, + 146, + 238, + 141, + 223, + 138, + 220, + 136, + 214, + 130, + 183, + 113, + 149, + 106, + 142, + 100, + 139, + 97, + 135, + 97, + 120, + 93, + 116, + 93, + 105, + 91, + 104, + 86, + 107, + 82, + 104, + 75, + 104 + ] + ], + "iscrowd": 0, + "bbox": [ + 46, + 104, + 115, + 255 + ], + "area": 20315.0, + "id": 20, + "attributes": [], + "labellerr_answer_id": "ac9d6208-3ce0-4c20-8baa-3607b3870d92" + }, + { + "image_id": 4, + "category_id": 0, + "segmentation": [ + [ + 417, + 201, + 412, + 205, + 402, + 205, + 395, + 210, + 360, + 216, + 348, + 222, + 326, + 224, + 318, + 227, + 304, + 228, + 294, + 231, + 279, + 232, + 272, + 234, + 229, + 237, + 219, + 240, + 216, + 243, + 215, + 249, + 217, + 253, + 221, + 256, + 242, + 254, + 275, + 255, + 282, + 252, + 294, + 252, + 301, + 254, + 358, + 253, + 412, + 235, + 460, + 236, + 479, + 233, + 481, + 231, + 488, + 230, + 495, + 225, + 499, + 214, + 497, + 205, + 475, + 202, + 463, + 204, + 422, + 204, + 418, + 202 + ] + ], + "iscrowd": 0, + "bbox": [ + 215, + 201, + 284, + 55 + ], + "area": 7690.5, + "id": 21, + "attributes": [], + "labellerr_answer_id": "f60c4476-a689-4d97-91fa-e52334be9bae" + }, + { + "image_id": 4, + "category_id": 0, + "segmentation": [ + [ + 425, + 236, + 409, + 237, + 402, + 239, + 367, + 253, + 352, + 268, + 353, + 277, + 364, + 289, + 381, + 290, + 393, + 293, + 405, + 293, + 421, + 296, + 435, + 296, + 447, + 299, + 454, + 297, + 465, + 289, + 483, + 291, + 491, + 288, + 502, + 280, + 502, + 272, + 489, + 250, + 470, + 244, + 465, + 240, + 455, + 240, + 445, + 237, + 430, + 236 + ] + ], + "iscrowd": 0, + "bbox": [ + 352, + 236, + 150, + 63 + ], + "area": 6940.0, + "id": 22, + "attributes": [], + "labellerr_answer_id": "46e969b3-545d-4760-ab7f-2e3255e1e19e" + }, + { + "image_id": 4, + "category_id": 0, + "segmentation": [ + [ + 550, + 227, + 543, + 230, + 531, + 230, + 510, + 235, + 498, + 243, + 498, + 246, + 494, + 254, + 497, + 262, + 511, + 274, + 534, + 285, + 557, + 288, + 561, + 290, + 577, + 290, + 597, + 282, + 607, + 270, + 609, + 266, + 610, + 256, + 610, + 251, + 607, + 244, + 601, + 237, + 597, + 235, + 576, + 232, + 572, + 229, + 565, + 227 + ] + ], + "iscrowd": 0, + "bbox": [ + 494, + 227, + 116, + 63 + ], + "area": 5551.0, + "id": 23, + "attributes": [], + "labellerr_answer_id": "8f73ad95-af87-4ef1-956b-8d191f12ef89" + }, + { + "image_id": 4, + "category_id": 0, + "segmentation": [ + [ + 100, + 426, + 81, + 428, + 70, + 431, + 55, + 438, + 41, + 442, + 37, + 445, + 26, + 448, + 37, + 448, + 47, + 451, + 77, + 451, + 81, + 454, + 92, + 456, + 108, + 463, + 117, + 470, + 122, + 478, + 126, + 480, + 137, + 492, + 145, + 494, + 163, + 489, + 189, + 485, + 198, + 479, + 205, + 468, + 196, + 466, + 189, + 458, + 174, + 446, + 172, + 439, + 169, + 437, + 169, + 430, + 148, + 427, + 132, + 428, + 127, + 426 + ] + ], + "iscrowd": 0, + "bbox": [ + 26, + 426, + 179, + 68 + ], + "area": 5964.0, + "id": 24, + "attributes": [], + "labellerr_answer_id": "2445daf3-f03d-49b1-a64d-810c8d53960c" + }, + { + "image_id": 5, + "category_id": 0, + "segmentation": [ + [ + 420, + 148, + 417, + 150, + 417, + 154, + 412, + 155, + 412, + 158, + 416, + 162, + 412, + 167, + 406, + 168, + 400, + 165, + 393, + 166, + 382, + 164, + 343, + 164, + 335, + 166, + 299, + 169, + 273, + 177, + 268, + 181, + 259, + 184, + 248, + 194, + 244, + 201, + 244, + 214, + 246, + 220, + 263, + 237, + 270, + 239, + 283, + 246, + 317, + 252, + 327, + 252, + 346, + 266, + 372, + 274, + 389, + 274, + 414, + 265, + 418, + 267, + 421, + 262, + 426, + 260, + 425, + 258, + 428, + 253, + 440, + 255, + 448, + 259, + 450, + 262, + 458, + 265, + 480, + 265, + 492, + 262, + 512, + 251, + 527, + 237, + 536, + 234, + 541, + 234, + 575, + 222, + 577, + 219, + 581, + 218, + 586, + 214, + 593, + 204, + 597, + 201, + 597, + 172, + 590, + 165, + 568, + 156, + 552, + 153, + 511, + 153, + 507, + 155, + 492, + 154, + 478, + 156, + 473, + 155, + 470, + 157, + 460, + 158, + 441, + 166, + 433, + 166, + 431, + 162, + 431, + 156, + 423, + 148 + ] + ], + "iscrowd": 0, + "bbox": [ + 244, + 148, + 353, + 126 + ], + "area": 30715.0, + "id": 25, + "attributes": [], + "labellerr_answer_id": "01687af5-4ef6-44d3-bb73-d1a43494043f" + } + ], + "categories": [ + { + "id": 0, + "name": "Class polygon ", + "supercategory": "Annotation", + "isthing": 1, + "color": [ + 255, + 0, + 0 + ], + "attributes": [], + "labellerr_question_id": "5657dd12-7a2c-43cd-80c5-43f8791ee1f4" + } + ] +} \ No newline at end of file diff --git a/labellerr/notebooks/pyscene_detect/15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p+frame_0.jpg b/labellerr/notebooks/pyscene_detect/15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p+frame_0.jpg new file mode 100644 index 0000000..215cf10 Binary files /dev/null and b/labellerr/notebooks/pyscene_detect/15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p+frame_0.jpg differ diff --git a/labellerr/notebooks/pyscene_detect/15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p+frame_1064.jpg b/labellerr/notebooks/pyscene_detect/15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p+frame_1064.jpg new file mode 100644 index 0000000..225ed0b Binary files /dev/null and b/labellerr/notebooks/pyscene_detect/15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p+frame_1064.jpg differ diff --git a/labellerr/notebooks/pyscene_detect/15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p+frame_1406.jpg b/labellerr/notebooks/pyscene_detect/15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p+frame_1406.jpg new file mode 100644 index 0000000..8202c81 Binary files /dev/null and b/labellerr/notebooks/pyscene_detect/15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p+frame_1406.jpg differ diff --git a/labellerr/notebooks/pyscene_detect/15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p+frame_316.jpg b/labellerr/notebooks/pyscene_detect/15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p+frame_316.jpg new file mode 100644 index 0000000..40b2136 Binary files /dev/null and b/labellerr/notebooks/pyscene_detect/15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p+frame_316.jpg differ diff --git a/labellerr/notebooks/pyscene_detect/15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p+frame_760.jpg b/labellerr/notebooks/pyscene_detect/15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p+frame_760.jpg new file mode 100644 index 0000000..873d257 Binary files /dev/null and b/labellerr/notebooks/pyscene_detect/15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p+frame_760.jpg differ diff --git a/labellerr/notebooks/pyscene_detect/15908795-09eb-4cdb-a39b-8689f8f936e5+a878e61b-8aeb-46e1-ab10-5f1852bcdcbe+seafood_1280p+frame_0.jpg b/labellerr/notebooks/pyscene_detect/15908795-09eb-4cdb-a39b-8689f8f936e5+a878e61b-8aeb-46e1-ab10-5f1852bcdcbe+seafood_1280p+frame_0.jpg new file mode 100644 index 0000000..cf9333f Binary files /dev/null and b/labellerr/notebooks/pyscene_detect/15908795-09eb-4cdb-a39b-8689f8f936e5+a878e61b-8aeb-46e1-ab10-5f1852bcdcbe+seafood_1280p+frame_0.jpg differ diff --git a/labellerr/notebooks/test_preannotation_api.py b/labellerr/notebooks/test_preannotation_api.py new file mode 100644 index 0000000..4f048bd --- /dev/null +++ b/labellerr/notebooks/test_preannotation_api.py @@ -0,0 +1,44 @@ +import os + +from dotenv import load_dotenv + +from labellerr.client import LabellerrClient +from labellerr.core.projects.video_project import LabellerrProject + +# Load environment variables from .env file +load_dotenv(r"D:\Professional\Labellerr_SDK\dev.env") + +API_KEY = os.getenv("QA_API_KEY") +API_SECRET = os.getenv("QA_API_SECRET") +CLIENT_ID = os.getenv("QA_CLIENT_ID") + +# Validate that all required credentials are present +if not API_KEY: + raise ValueError("QA_API_KEY is not set") +if not API_SECRET: + raise ValueError("QA_API_SECRET is not set") +if not CLIENT_ID: + raise ValueError("QA_CLIENT_ID is not set") + +PROJECT_ID = "jeanna_mixed_aphid_93841" +VIDEO_JSON_FILE_PATH = r"D:\Professional\Labellerr_SDK\dummy_annotation.json" + + +def main(): + + client = LabellerrClient( + api_key=API_KEY, api_secret=API_SECRET, client_id=CLIENT_ID + ) + + project = LabellerrProject(client=client, project_id=PROJECT_ID) + + print(project.project_id) + + response = project.upload_preannotations( + annotation_format="video_json", annotation_file=VIDEO_JSON_FILE_PATH + ) + print(response) + + +if __name__ == "__main__": + main() diff --git a/labellerr/services/video_sampling/__init__.py b/labellerr/services/video_sampling/__init__.py index c788244..d9a00ed 100644 --- a/labellerr/services/video_sampling/__init__.py +++ b/labellerr/services/video_sampling/__init__.py @@ -3,12 +3,416 @@ All algorithms for video sampling will go in separate files. """ -from .ffmpeg import FFMPEGSceneDetect -from .pyscene_detect import PySceneDetect -from .ssim import SSIMSceneDetect +import json +import os +import re +from collections import defaultdict +from pathlib import Path +from typing import Any, Dict, List, Optional, Union + +# Try to import detectors (optional dependencies) +try: + from .ffmpeg_detect import FFMPEGSceneDetect + from .pyscene_detect import PySceneDetect + from .ssim_detect import SSIMSceneDetect + + _DETECTORS_AVAILABLE = True +except ImportError: + _DETECTORS_AVAILABLE = False + FFMPEGSceneDetect = None + PySceneDetect = None + SSIMSceneDetect = None __all__ = [ "FFMPEGSceneDetect", "PySceneDetect", "SSIMSceneDetect", + "process_videos_batch", + "coco_to_video_json", ] + + +# Supported video file extensions +VIDEO_EXTENSIONS = {".mp4", ".avi", ".mov", ".mkv", ".flv", ".wmv", ".webm", ".m4v"} + + +def process_videos_batch( + detector: Union[PySceneDetect, FFMPEGSceneDetect, SSIMSceneDetect], + dataset_dir: Union[str, Path], + **detector_kwargs, +) -> List[Dict[str, Any]]: + """ + Process all video files in a directory using the specified detector algorithm. + + This function works with any detector algorithm (PySceneDetect, FFMPEGSceneDetect, + SSIMSceneDetect) and processes all video files in the specified directory. + All extracted frames will be stored according to each detector's output structure. + + Args: + detector: Instance of any detector class (PySceneDetect, FFMPEGSceneDetect, or SSIMSceneDetect) + dataset_dir: Path to directory containing video files to process + **detector_kwargs: Additional keyword arguments to pass to the detector's detect_and_extract method + (e.g., threshold=0.3, resize_dim=(320, 240) for SSIMSceneDetect) + + Returns: + List of dictionaries containing processing results for each video: + - filename: Name of the video file + - status: 'success' or 'failed' + - frames_extracted: Number of frames extracted (if successful) + - output_folder: Path where frames were stored (if successful) + - error: Error message (if failed) + + Example: + >>> from labellerr.services.video_sampling import PySceneDetect, process_videos_batch + >>> detector = PySceneDetect() + >>> results = process_videos_batch(detector, "./Labellerr_datasets") + >>> print(f"Processed {len(results)} videos") + + >>> # Using SSIMSceneDetect with custom parameters + >>> from labellerr.services.video_sampling import SSIMSceneDetect, process_videos_batch + >>> detector = SSIMSceneDetect() + >>> results = process_videos_batch( + ... detector, + ... "./Labellerr_datasets", + ... threshold=0.3, + ... resize_dim=(320, 240) + ... ) + """ + # Convert to Path object for easier handling + dataset_path = Path(dataset_dir) + + # Verify dataset directory exists + if not dataset_path.exists(): + raise FileNotFoundError(f"Dataset directory not found: {dataset_dir}") + + if not dataset_path.is_dir(): + raise NotADirectoryError(f"Path is not a directory: {dataset_dir}") + + # Get all video files from the dataset directory + video_files = [ + f + for f in os.listdir(dataset_path) + if os.path.isfile(os.path.join(dataset_path, f)) + and os.path.splitext(f)[1].lower() in VIDEO_EXTENSIONS + ] + + if not video_files: + print(f"⚠️ No video files found in {dataset_dir}") + return [] + + print(f"Found {len(video_files)} video files to process") + print("=" * 70) + + # Process each video file + results = [] + for idx, filename in enumerate(video_files, 1): + file_path = os.path.join(dataset_path, filename) + print(f"\n[{idx}/{len(video_files)}] Processing: {filename}") + print("-" * 70) + + try: + # Call the detector's detect_and_extract method with optional kwargs + result = detector.detect_and_extract(str(file_path), **detector_kwargs) + + results.append( + { + "filename": filename, + "status": "success", + "frames_extracted": len(result.selected_frames), + "output_folder": result.output_folder, + } + ) + print(f"✓ Successfully extracted {len(result.selected_frames)} frames") + + except Exception as e: + results.append({"filename": filename, "status": "failed", "error": str(e)}) + print(f"✗ Failed: {str(e)}") + + # Print summary + print("\n" + "=" * 70) + print("PROCESSING SUMMARY") + print("=" * 70) + + successful = sum(1 for r in results if r["status"] == "success") + failed = sum(1 for r in results if r["status"] == "failed") + total_frames = sum( + r.get("frames_extracted", 0) for r in results if r["status"] == "success" + ) + + print(f"Total videos processed: {len(video_files)}") + print(f"Successful: {successful}") + print(f"Failed: {failed}") + print(f"Total frames extracted: {total_frames}") + + if successful > 0: + # Get output folder from first successful result + output_folder = next( + (r["output_folder"] for r in results if r["status"] == "success"), "N/A" + ) + print(f"\n✓ Frames stored in: {output_folder}/") + + # Print detailed results for failed videos + if failed > 0: + print("\n" + "=" * 70) + print("FAILED VIDEOS:") + print("=" * 70) + for r in results: + if r["status"] == "failed": + print(f" • {r['filename']}: {r['error']}") + + return results + + +# ============================================================================ +# COCO to Video JSON Converter +# ============================================================================ + + +def _extract_video_name_and_frame(filename: str) -> tuple[str, int]: + """ + Extract video name and frame number from keyframe filename. + + Format: {dataset_id}+{file_id}+{video_name}+frame_{frame_number}.jpg + Example: 15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p+frame_1064.jpg + Returns: ("butterflies_960p.mp4", 1064) + + Args: + filename: The keyframe filename + + Returns: + Tuple of (video_name, frame_number) + + Raises: + ValueError: If filename format is invalid + """ + # Split by '+' to get parts + parts = filename.split("+") + + if len(parts) < 4: + raise ValueError(f"Invalid filename format: {filename}") + + # Last part contains video_name+frame_X.jpg + last_part = parts[-1] + + # Extract frame number using regex + frame_match = re.search(r"frame_(\d+)\.jpg$", last_part) + if not frame_match: + raise ValueError(f"Could not extract frame number from: {filename}") + + frame_number = int(frame_match.group(1)) + + # Extract video name (everything before +frame_X.jpg) + video_name_part = parts[-2] # The part before the last '+' + video_name = f"{video_name_part}.mp4" + + return video_name, frame_number + + +def _convert_segmentation_to_polygon(segmentation: List[float]) -> List[Dict[str, int]]: + """ + Convert COCO segmentation format to video polygon format. + + COCO format: [x1, y1, x2, y2, x3, y3, ...] + Video format: [{"x": x1, "y": y1}, {"x": x2, "y": y2}, ...] + + Args: + segmentation: List of alternating x, y coordinates + + Returns: + List of coordinate dictionaries + """ + polygon = [] + for i in range(0, len(segmentation), 2): + polygon.append({"x": int(segmentation[i]), "y": int(segmentation[i + 1])}) + return polygon + + +def _convert_bbox_to_video_format(bbox: List[float]) -> Dict[str, Any]: + """ + Convert COCO bbox format to video bbox format. + + COCO format: [xmin, ymin, width, height] + Video format: {"xmin": x, "ymin": y, "xmax": x+w, "ymax": y+h, "rotation": 0} + + Args: + bbox: COCO bounding box [x, y, width, height] + + Returns: + Video format bounding box dictionary + """ + xmin, ymin, width, height = bbox + return { + "xmin": int(xmin), + "ymin": int(ymin), + "xmax": int(xmin + width), + "ymax": int(ymin + height), + "rotation": 0, + } + + +def coco_to_video_json( + coco_json_path: str, + output_path: Optional[str] = "Video_Keyframe_annot.json", + fps: int = 23, +) -> List[Dict[str, Any]]: + """ + Convert COCO JSON format (from keyframe exports) to Video JSON format. + + This function transforms annotations exported from keyframe image projects + into the format required for video project preannotation upload. + + Args: + coco_json_path: Path to the COCO JSON file + output_path: Path to save the converted JSON. Default: "Video_Keyframe_annot.json" + Set to None to skip saving + fps: Frames per second for the video (default: 23) + + Returns: + List of video annotation dictionaries + + Example: + >>> from labellerr.services.video_sampling import coco_to_video_json + >>> video_annotations = coco_to_video_json( + ... "export_zmYykSJhCAJqAaXaJQ3g.json", + ... "Video_Keyframe_annot.json" + ... ) + """ + # Load COCO JSON + with open(coco_json_path, "r", encoding="utf-8") as f: + coco_data = json.load(f) + + # Create mappings + images = {img["id"]: img for img in coco_data["images"]} + categories = {cat["id"]: cat for cat in coco_data["categories"]} + + # Group annotations by video file + video_annotations: Dict[str, Dict[str, Any]] = defaultdict( + lambda: { + "file_name": "", + "annotations": defaultdict( + lambda: {"question_type": "", "question_name": "", "answer": []} + ), + } + ) + + # Process each annotation + for annotation in coco_data["annotations"]: + image_id = annotation["image_id"] + category_id = annotation["category_id"] + + # Get image and category info + image = images[image_id] + category = categories[category_id] + + # Extract video name and frame number + try: + video_name, frame_number = _extract_video_name_and_frame(image["file_name"]) + except ValueError as e: + print(f"Warning: Skipping annotation - {e}") + continue + + # Determine question type based on annotation structure + if "segmentation" in annotation and annotation["segmentation"]: + question_type = "polygon" + # Convert segmentation to polygon format + answer_data = _convert_segmentation_to_polygon( + annotation["segmentation"][0] + ) + elif "bbox" in annotation: + question_type = "BoundingBox" + # Convert bbox to video format + answer_data = _convert_bbox_to_video_format(annotation["bbox"]) + else: + print( + f"Warning: Unknown annotation type for annotation {annotation.get('id')}" + ) + continue + + # Get or create video entry + video_key = video_name + if not video_annotations[video_key]["file_name"]: + video_annotations[video_key]["file_name"] = video_name + + # Get or create question entry + question_name = category["name"] + question_key = f"{category_id}_{question_type}" + + if not video_annotations[video_key]["annotations"][question_key][ + "question_type" + ]: + video_annotations[video_key]["annotations"][question_key][ + "question_type" + ] = question_type + video_annotations[video_key]["annotations"][question_key][ + "question_name" + ] = question_name + video_annotations[video_key]["annotations"][question_key]["answer"] = [] + + # # Find or create the answer group for this annotation + # # Each unique annotation should be in its own answer group + # answer_id = annotation.get("labellerr_answer_id", annotation.get("id")) + + # Check if we already have an answer group for this annotation + existing_answer = None + for ans_group in video_annotations[video_key]["annotations"][question_key][ + "answer" + ]: + # Check if this frame already exists in this answer group + if str(frame_number) in ans_group.get("frames", {}): + existing_answer = ans_group + break + + if existing_answer is None: + # Create new answer group + existing_answer = {"startFrame": frame_number, "frames": {}} + video_annotations[video_key]["annotations"][question_key]["answer"].append( + existing_answer + ) + else: + # Update startFrame if this frame is earlier + if frame_number < existing_answer["startFrame"]: + existing_answer["startFrame"] = frame_number + + # Add frame data + frame_data = { + "frame": frame_number, + "answer": answer_data, + "isManualAnnotation": True, + "fps": fps, + } + + existing_answer["frames"][str(frame_number)] = frame_data + + # Convert to list format + result = [] + for video_name, video_data in video_annotations.items(): + # Convert annotations dict to list + annotations_list = [] + for question_data in video_data["annotations"].values(): + # Ensure startFrame is set correctly for each answer group + for answer_group in question_data["answer"]: + frames = answer_group["frames"] + if frames: + # Set startFrame to the minimum frame number + min_frame = min(int(f) for f in frames.keys()) + answer_group["startFrame"] = min_frame + + annotations_list.append( + { + "question_type": question_data["question_type"], + "question_name": question_data["question_name"], + "answer": question_data["answer"], + } + ) + + result.append( + {"file_name": video_data["file_name"], "annotations": annotations_list} + ) + + # Save to file if output path is provided + if output_path: + with open(output_path, "w", encoding="utf-8") as f: + json.dump(result, f, indent=2, ensure_ascii=False) + print(f"Video JSON saved to: {output_path}") + + return result diff --git a/labellerr/services/video_sampling/ffmpeg.py b/labellerr/services/video_sampling/ffmpeg.py deleted file mode 100644 index 43f1c59..0000000 --- a/labellerr/services/video_sampling/ffmpeg.py +++ /dev/null @@ -1,162 +0,0 @@ -import json -import os -import subprocess -from typing import List - -from pydantic import BaseModel, Field - -from labellerr.core.base.singleton import Singleton - - -class SceneFrame(BaseModel): - """Represents an extracted keyframe.""" - - frame_path: str - frame_index: int - - -class DetectionResult(BaseModel): - """Contains all extraction results for a video.""" - - file_id: str - output_folder: str - selected_frames: List[SceneFrame] = Field(default_factory=list) - - -class FFMPEGSceneDetect(Singleton): - """Keyframe extraction from videos using FFMPEG (Singleton).""" - - def detect_and_extract(self, video_path: str) -> DetectionResult: - """ - Extract keyframes from video and save to detects folder structure. - - Args: - video_path: Path to the video file - - Returns: - DetectionResult containing file_id, output_folder, and list of SceneFrame objects - """ - # Derive file_id from video_path (base name without extension) - file_id = os.path.splitext(os.path.basename(video_path))[0] - dataset_id = os.path.basename(os.path.dirname(video_path)) - - # Create detects folder structure - base_detect_folder = "FFMPEG_detects" - - output_folder = os.path.join(base_detect_folder, dataset_id, file_id) - frames_folder = os.path.join(output_folder, "frames") - - # Create nested folders - os.makedirs(frames_folder, exist_ok=True) - - # Update output pattern to use frames subfolder in detects structure - output_pattern = os.path.join(frames_folder, "%d.jpg") - - command = [ - "ffmpeg", - "-i", - video_path, - "-vf", - "select='eq(pict_type,PICT_TYPE_I)',showinfo", - "-vsync", - "vfr", - "-frame_pts", - "1", - output_pattern, - ] - - try: - result = subprocess.run(command, check=True, capture_output=True, text=True) - print(f"Keyframes extracted to {frames_folder}") - - # Parse frame information from FFMPEG output - selected_frames = self._parse_ffmpeg_output(result.stderr, frames_folder) - - # Create result - detection_result = DetectionResult( - file_id=file_id, - output_folder=output_folder, # Main detects/file_id folder - selected_frames=selected_frames, - ) - - # Save JSON mapping - self._save_json_mapping(detection_result, output_folder, file_id) - - return detection_result - - except subprocess.CalledProcessError as e: - print(f"Error extracting keyframes: {e}") - raise - - def _parse_ffmpeg_output( - self, stderr_output: str, frames_folder: str - ) -> List[SceneFrame]: - """ - Parse FFMPEG stderr output to extract frame information. - - Args: - stderr_output: FFMPEG stderr output containing showinfo data - frames_folder: Folder where frames are saved (detects/file_id/frames) - - Returns: - List of SceneFrame objects - """ - frames = [] - frame_counter = 1 - - # Parse showinfo output from stderr - for line in stderr_output.split("\n"): - if "showinfo" in line and "n:" in line: - # The frame file is named sequentially starting from 1 - frame_path = os.path.join(frames_folder, f"{frame_counter}.jpg") - - # Extract frame number from showinfo line if needed - # Example: [Parsed_showinfo_1 @ 0x...] n: 0 pts: 0 ... - try: - if "pts_time:" in line: - # Extract the actual frame number from the source - parts = line.split("n:") - if len(parts) > 1: - frame_no = int(parts[1].split()[0]) - else: - frame_no = frame_counter - 1 - else: - frame_no = frame_counter - 1 - - frames.append( - SceneFrame(frame_path=frame_path, frame_index=frame_no) - ) - frame_counter += 1 - except (ValueError, IndexError): - continue - - return frames - - def _save_json_mapping( - self, result: DetectionResult, output_folder: str, file_id: str - ) -> None: - """ - Save JSON mapping of file_id to extracted keyframes. - - Args: - result: DetectionResult object - output_folder: Folder to save the JSON file (detects/file_id/) - file_id: Unique identifier for the video - """ - # Use Pydantic's model_dump - result_dict = result.model_dump() - result_dict["total_selected_frames"] = len(result.selected_frames) - - json_path = os.path.join(output_folder, f"{file_id}_mapping.json") - with open(json_path, "w", encoding="utf-8") as f: - json.dump(result_dict, f, indent=2, ensure_ascii=False) - - print(f"JSON mapping saved to: {json_path}") - - -if __name__ == "__main__": - video_path = r"D:\professional\LABELLERR\Task\Repos\SDKPython\download_video\59438ec3-12e0-4687-8847-1e6e01b0bf25\1cb2eec4-5125-4272-ad09-c249f40fffb3.mp4" - - # Get singleton instance - detector = FFMPEGSceneDetect() - result = detector.detect_and_extract(video_path) diff --git a/labellerr/services/video_sampling/ffmpeg_detect.py b/labellerr/services/video_sampling/ffmpeg_detect.py new file mode 100644 index 0000000..9685d1d --- /dev/null +++ b/labellerr/services/video_sampling/ffmpeg_detect.py @@ -0,0 +1,396 @@ +import json +import os +import shutil +import subprocess +from pathlib import Path +from typing import List + +from pydantic import BaseModel, Field + +from labellerr.core.base.singleton import Singleton + + +class FFMPEGError(Exception): + """Base exception for FFMPEG-related errors.""" + + pass + + +class FFMPEGNotFoundError(FFMPEGError): + """Raised when FFMPEG is not installed or not found in PATH.""" + + pass + + +class VideoFileError(FFMPEGError): + """Raised when there are issues with the video file.""" + + pass + + +class NoKeyframesError(FFMPEGError): + """Raised when no I-frames are found in the video.""" + + pass + + +class SceneFrame(BaseModel): + """Represents an extracted keyframe.""" + + frame_path: str + frame_index: int + + +class DetectionResult(BaseModel): + """Contains all extraction results for a video.""" + + file_id: str + output_folder: str + selected_frames: List[SceneFrame] = Field(default_factory=list) + + +class FFMPEGSceneDetect(Singleton): + """Keyframe extraction from videos using FFMPEG (Singleton).""" + + # Supported video extensions + SUPPORTED_EXTENSIONS = { + ".mp4", + ".avi", + ".mov", + ".mkv", + ".flv", + ".wmv", + ".webm", + ".m4v", + } + + def __init__(self): + """Initialize and verify FFMPEG is available.""" + super().__init__() + self._verify_ffmpeg() + + def _verify_ffmpeg(self) -> None: + """Verify that FFMPEG is installed and accessible.""" + if not shutil.which("ffmpeg"): + raise FFMPEGNotFoundError( + "FFMPEG is not installed or not found in PATH. " + "Please install FFMPEG from https://ffmpeg.org/download.html" + ) + + def _validate_video_file(self, video_path: str) -> None: + """Validate that the video file exists and is a supported format. + + Args: + video_path: Path to the video file + + Raises: + VideoFileError: If file doesn't exist or format is unsupported + """ + path = Path(video_path) + + if not path.exists(): + raise VideoFileError(f"Video file not found: {video_path}") + + if not path.is_file(): + raise VideoFileError(f"Path is not a file: {video_path}") + + if path.suffix.lower() not in self.SUPPORTED_EXTENSIONS: + raise VideoFileError( + f"Unsupported video format: {path.suffix}. " + f"Supported formats: {', '.join(sorted(self.SUPPORTED_EXTENSIONS))}" + ) + + # Check if file is readable + if not os.access(video_path, os.R_OK): + raise VideoFileError(f"Video file is not readable: {video_path}") + + def detect_and_extract(self, video_path: str) -> DetectionResult: + """ + Extract keyframes from video and save to detects folder structure. + Frames are saved with pattern: video_name+frame_X.jpg (e.g., video_name+frame_5.jpg for frame 5). + + Args: + video_path: Path to the video file + + Returns: + DetectionResult containing file_id, output_folder, and list of SceneFrame objects + + Raises: + VideoFileError: If video file is invalid or inaccessible + NoKeyframesError: If no I-frames are found in the video + FFMPEGError: If FFMPEG processing fails + """ + # Validate input file before processing + self._validate_video_file(video_path) + + # Extract identifiers from the video path + # file_id: Video filename without extension (e.g., "video_123") + # dataset_id: Parent directory name (used for organizing outputs) + file_id = os.path.splitext(os.path.basename(video_path))[0] + dataset_id = os.path.basename(os.path.dirname(video_path)) + + # Create hierarchical output folder structure: + # FFMPEG_detects/ + # └── / + # └── / + # ├── frames/ (extracted frame images) + # └── _mapping.json (metadata) + base_detect_folder = "FFMPEG_detects" + + output_folder = os.path.join(base_detect_folder, dataset_id, file_id) + frames_folder = os.path.join(output_folder, "frames") + + # Create all necessary directories (no error if they already exist) + os.makedirs(frames_folder, exist_ok=True) + + try: + # ================================================================ + # PHASE 1: Identify I-frame positions + # ================================================================ + # First pass: Scan the video to find all I-frame positions + # This is done WITHOUT extracting frames to get the complete list + # of frame numbers before extraction begins + print("Identifying I-frame positions...") + frame_numbers = self._get_iframe_numbers(video_path) + + # Validate that at least one I-frame was found + if not frame_numbers: + raise NoKeyframesError( + f"No I-frames (keyframes) found in video: {video_path}. " + "The video may be corrupted or in an unsupported format." + ) + + # Show preview of detected I-frames (limit to first 10 for readability) + print( + f"Found {len(frame_numbers)} I-frames at positions: {frame_numbers[:10]}{'...' if len(frame_numbers) > 10 else ''}" + ) + + # ================================================================ + # PHASE 2: Extract each I-frame individually + # ================================================================ + # Second pass: Extract each I-frame and save with its actual frame number + # Using actual frame numbers ensures frames are named correctly + # (e.g., frame 250 from video → video_name+frame_250.jpg) + selected_frames = [] + for idx, frame_num in enumerate(frame_numbers, 1): + # Save frame with naming pattern: video_name+frame_X.jpg + frame_filename = f"{file_id}+frame_{frame_num}.jpg" + frame_path = os.path.join(frames_folder, frame_filename) + try: + self._extract_single_frame(video_path, frame_num, frame_path) + selected_frames.append( + SceneFrame(frame_path=frame_path, frame_index=frame_num) + ) + if idx % 10 == 0: # Progress update every 10 frames + print(f"Extracted {idx}/{len(frame_numbers)} frames...") + except Exception as e: + print(f"Warning: Failed to extract frame {frame_num}: {e}") + continue + + if not selected_frames: + raise FFMPEGError( + f"Failed to extract any frames from video: {video_path}. " + "All frame extractions failed." + ) + + print( + f"Successfully extracted {len(selected_frames)}/{len(frame_numbers)} keyframes to {frames_folder}" + ) + + # Create result + detection_result = DetectionResult( + file_id=file_id, + output_folder=output_folder, + selected_frames=selected_frames, + ) + + # Save JSON mapping + self._save_json_mapping(detection_result, output_folder, file_id) + + return detection_result + + except subprocess.CalledProcessError as e: + error_msg = e.stderr if hasattr(e, "stderr") and e.stderr else str(e) + raise FFMPEGError(f"FFMPEG command failed: {error_msg}") from e + except (FFMPEGError, VideoFileError, NoKeyframesError): + # Re-raise our custom exceptions + raise + except Exception as e: + raise FFMPEGError( + f"Unexpected error during keyframe extraction: {e}" + ) from e + + def _get_iframe_numbers(self, video_path: str) -> List[int]: + """ + Identify all I-frame (keyframe) positions in the video. + + Args: + video_path: Path to the video file + + Returns: + List of frame numbers (0-indexed) where I-frames occur + + Raises: + FFMPEGError: If FFMPEG command fails + """ + # Build FFMPEG command to identify I-frames without extracting them + # - select filter: Only pass through I-frames (PICT_TYPE_I) + # - showinfo: Print detailed information about each frame to stderr + # - null output: Don't actually save frames, just analyze + command = [ + "ffmpeg", + "-i", + video_path, + "-vf", + "select='eq(pict_type,PICT_TYPE_I)',showinfo", + "-vsync", + "vfr", # Variable frame rate to preserve original timing + "-f", + "null", # Null muxer - discard output, we only need stderr info + "-", + ] + + result = subprocess.run(command, capture_output=True, text=True) + + # ================================================================ + # STEP 1: Extract frame rate from video metadata + # ================================================================ + # We need the frame rate to convert pts_time (seconds) to frame numbers + # Frame number = pts_time × frame_rate + frame_rate = None + for line in result.stderr.split("\n"): + if "Stream #" in line and "Video:" in line: + # Extract frame rate from stream info + # Example: Stream #0:0: Video: h264, 1920x1080, 30 fps + parts = line.split(",") + for part in parts: + if "fps" in part or "tbr" in part: + try: + fps_str = part.strip().split()[0] + frame_rate = float(fps_str) + break + except (ValueError, IndexError): + continue + if frame_rate: + break + + # Fallback to 30 fps if frame rate detection fails + if not frame_rate: + frame_rate = 30.0 + print( + f"Warning: Could not detect frame rate, defaulting to {frame_rate} fps" + ) + + # ================================================================ + # STEP 2: Parse showinfo output to get actual frame numbers + # ================================================================ + # IMPORTANT: The 'n:' value in showinfo is the FILTERED output index (0, 1, 2...) + # NOT the source frame number. We must use pts_time to calculate the real frame number. + frame_numbers = [] + for line in result.stderr.split("\n"): + if "showinfo" in line and "pts_time:" in line: + try: + # Extract pts_time (presentation timestamp in seconds) + # This tells us the exact time position of this frame in the video + pts_time_str = line.split("pts_time:")[1].split()[0] + pts_time = float(pts_time_str) + + # Calculate frame number from pts_time and frame rate + frame_num = int(round(pts_time * frame_rate)) + frame_numbers.append(frame_num) + except (ValueError, IndexError): + # If pts_time parsing fails, skip this frame + continue + + # Always ensure frame 0 (first frame) is included + if 0 not in frame_numbers: + frame_numbers.insert(0, 0) + + return frame_numbers + + def _extract_single_frame( + self, video_path: str, frame_num: int, output_path: str + ) -> None: + """ + Extract a specific frame from the video. + + Args: + video_path: Path to the video file + frame_num: Frame number to extract (0-indexed) + output_path: Path where the frame should be saved + + Raises: + FFMPEGError: If frame extraction fails + """ + command = [ + "ffmpeg", + "-i", + video_path, + "-vf", + f"select='eq(n,{frame_num})'", + "-vsync", + "vfr", + "-frames:v", + "1", + "-y", # Overwrite output file if it exists + output_path, + ] + + try: + subprocess.run( + command, + check=True, + capture_output=True, + text=True, + timeout=30, # 30 second timeout per frame + ) + + # Verify the output file was created + if not os.path.exists(output_path): + raise FFMPEGError( + f"Frame extraction succeeded but output file not found: {output_path}" + ) + + # Verify the output file has content + if os.path.getsize(output_path) == 0: + raise FFMPEGError(f"Extracted frame is empty: {output_path}") + + except subprocess.TimeoutExpired: + raise FFMPEGError(f"Frame extraction timed out for frame {frame_num}") + except subprocess.CalledProcessError as e: + raise FFMPEGError(f"Failed to extract frame {frame_num}: {e.stderr}") from e + + def _save_json_mapping( + self, result: DetectionResult, output_folder: str, file_id: str + ) -> None: + """ + Save JSON mapping of file_id to extracted keyframes. + + Args: + result: DetectionResult object + output_folder: Folder to save the JSON file (detects/file_id/) + file_id: Unique identifier for the video + + Raises: + FFMPEGError: If JSON file cannot be saved + """ + try: + # Use Pydantic's model_dump + result_dict = result.model_dump() + result_dict["total_selected_frames"] = len(result.selected_frames) + + json_path = os.path.join(output_folder, f"{file_id}_mapping.json") + with open(json_path, "w", encoding="utf-8") as f: + json.dump(result_dict, f, indent=2, ensure_ascii=False) + + print(f"JSON mapping saved to: {json_path}") + except (IOError, OSError) as e: + raise FFMPEGError(f"Failed to save JSON mapping to {json_path}: {e}") from e + + +if __name__ == "__main__": + video_path = r"D:\Professional\Labellerr_SDK\SDKPython\labellerr\notebooks\Labellerr_datasets\354681d3-034a-4d66-b070-365f4bd11d8a\2a8d96ca-9161-4dee-ad3b-a5faf301bc6c.mp4" + + # Get singleton instance + detector = FFMPEGSceneDetect() + result = detector.detect_and_extract(video_path) diff --git a/labellerr/services/video_sampling/pyscene_detect.py b/labellerr/services/video_sampling/pyscene_detect.py index bc93070..f4e6c40 100644 --- a/labellerr/services/video_sampling/pyscene_detect.py +++ b/labellerr/services/video_sampling/pyscene_detect.py @@ -1,5 +1,5 @@ -import json import os +from pathlib import Path from typing import List import cv2 @@ -9,16 +9,95 @@ from labellerr.core.base.singleton import Singleton +# ============================================================================ +# Exception Classes +# ============================================================================ + + +class PySceneDetectError(Exception): + """Base exception for all PySceneDetect-related errors. + + This is the parent exception class for all PySceneDetect-specific errors in this module. + Catching this exception will catch all scene detection-related issues including: + - Video file errors + - Scene detection failures + - Frame extraction failures + - No scenes detected + """ + + pass + + +class VideoFileError(PySceneDetectError): + """Raised when there are issues with the input video file. + + Common causes: + - File does not exist + - Path points to a directory instead of a file + - Unsupported video format + - File is not readable (permission issues) + - Video file is corrupted + """ + + pass + + +class NoScenesError(PySceneDetectError): + """Raised when no scene changes are found in the video. + + This can occur if: + - The video is very short (single scene) + - The video has no significant visual changes + - The video file is corrupted + """ + + pass + + +class FrameExtractionError(PySceneDetectError): + """Raised when frame extraction fails. + + This can occur if: + - OpenCV cannot read the video + - Frame number is out of range + - Video codec is unsupported + """ + + pass + + +# ============================================================================ +# Data Models +# ============================================================================ + class SceneFrame(BaseModel): - """Represents a detected scene with its extracted frame.""" + """Represents a single extracted frame from a detected scene. + + Attributes: + frame_path (str): Absolute or relative path to the extracted frame image file. + Example: "PyScene_detects/video_id/frames/250.jpg" + frame_index (int): The 0-indexed frame number in the source video. + Example: 250 means this is the 250th frame of the video. + """ frame_path: str frame_index: int class DetectionResult(BaseModel): - """Contains all detection results for a video.""" + """Contains all scene detection results for a video file. + + This model encapsulates the complete output of the scene detection process, + including metadata about the video and a list of all extracted frames. + + Attributes: + file_id (str): Unique identifier for the video (filename without extension). + output_folder (str): Path to the folder containing extracted frames and metadata. + total_frames (int): Total number of frames in the source video. + selected_frames (List[SceneFrame]): List of all successfully extracted scene frames. + Each frame includes its path and frame index. + """ file_id: str output_folder: str @@ -26,73 +105,200 @@ class DetectionResult(BaseModel): selected_frames: List[SceneFrame] = Field(default_factory=list) -class PySceneDetect(Singleton): - """Scene detection and frame extraction for videos (Singleton).""" +# ============================================================================ +# Main Scene Detection Class +# ============================================================================ - def detect_and_extract(self, video_path: str) -> DetectionResult: - """ - Detect scenes and extract representative frames. + +class PySceneDetect(Singleton): + """Scene change detection and frame extraction using PySceneDetect. + + This singleton class provides methods to detect scene changes in video files + and extract representative frames from each scene. It uses PySceneDetect's + AdaptiveDetector algorithm for robust scene detection. + + The class implements the Singleton pattern to ensure only one instance exists, + which is useful for managing video processing and avoiding redundant initialization. + + Attributes: + SUPPORTED_EXTENSIONS (set): Set of supported video file extensions. + + Example: + >>> detector = PySceneDetect() + >>> result = detector.detect_and_extract("video.mp4") + >>> print(f"Detected {len(result.selected_frames)} scenes") + """ + + # Supported video file extensions + SUPPORTED_EXTENSIONS = { + ".mp4", + ".avi", + ".mov", + ".mkv", + ".flv", + ".wmv", + ".webm", + ".m4v", + } + + def _validate_video_file(self, video_path: str) -> None: + """Validate that the video file exists and is a supported format. Args: video_path: Path to the video file - Returns: - DetectionResult containing file_id, output_folder, total_frames, and list of SceneFrame objects + Raises: + VideoFileError: If file doesn't exist or format is unsupported """ - # Derive file_id from video_path (base name without extension) - file_id = os.path.splitext(os.path.basename(video_path))[0] - dataset_id = os.path.basename(os.path.dirname(video_path)) - - # Create base detect folder and file_id specific folder - base_detect_folder = "PyScene_detects" - - output_folder = os.path.join(base_detect_folder, dataset_id, file_id) - frames_folder = os.path.join(output_folder, "frames") # New frames subfolder - - # Detect scene transitions - scenes = detect(video_path, AdaptiveDetector()) + path = Path(video_path) - # Create nested output folders - os.makedirs(frames_folder, exist_ok=True) # Create frames subfolder + if not path.exists(): + raise VideoFileError(f"Video file not found: {video_path}") - # Open video for frame extraction - video = cv2.VideoCapture(video_path) + if not path.is_file(): + raise VideoFileError(f"Path is not a file: {video_path}") - # Get total frames in video - total_frames = int(video.get(cv2.CAP_PROP_FRAME_COUNT)) + if path.suffix.lower() not in self.SUPPORTED_EXTENSIONS: + raise VideoFileError( + f"Unsupported video format: {path.suffix}. " + f"Supported formats: {', '.join(sorted(self.SUPPORTED_EXTENSIONS))}" + ) - # Extract and save frames - scene_frames = [] - for scene in scenes: - # Calculate middle frame number - frame_no = (scene[1] - scene[0]).frame_num // 2 + scene[0].frame_num + # Check if file is readable + if not os.access(video_path, os.R_OK): + raise VideoFileError(f"Video file is not readable: {video_path}") - # Extract frame - frame = self._get_frame(video, frame_no) - - # Save frame with frame number as filename inside frames folder - frame_filename = f"{frame_no}.jpg" - frame_path = os.path.join(frames_folder, frame_filename) # Updated path - frame.save(frame_path) - - # Create SceneFrame object - scene_frame = SceneFrame(frame_path=frame_path, frame_index=frame_no) - scene_frames.append(scene_frame) - - video.release() + def detect_and_extract(self, video_path: str) -> DetectionResult: + """ + Detect scenes and extract representative frames. + Always extracts the first frame (frame 0) of the video. - # Create result - result = DetectionResult( - file_id=file_id, - output_folder=output_folder, - total_frames=total_frames, - selected_frames=scene_frames, - ) + Args: + video_path: Path to the video file - # Save JSON mapping - self._save_json_mapping(result, output_folder, file_id) + Returns: + DetectionResult containing file_id, output_folder, total_frames, and list of SceneFrame objects - return result + Raises: + VideoFileError: If video file is invalid or inaccessible + NoScenesError: If no scene changes are detected + FrameExtractionError: If frame extraction fails + PySceneDetectError: If scene detection processing fails + """ + # Validate input file before processing + self._validate_video_file(video_path) + + # Extract video filename without extension (e.g., "video_123") + video_name = os.path.splitext(os.path.basename(video_path))[0] + + # Create output folder structure: + # pyscene_detect/ (frames stored directly here) + output_folder = "pyscene_detect" + os.makedirs(output_folder, exist_ok=True) + + try: + # ================================================================ + # PHASE 1: Detect scene changes + # ================================================================ + print("Detecting scene changes...") + scenes = detect(video_path, AdaptiveDetector()) + + # ================================================================ + # PHASE 2: Extract frames from detected scenes + # ================================================================ + # Open video for frame extraction + video = cv2.VideoCapture(video_path) + + if not video.isOpened(): + raise FrameExtractionError(f"Failed to open video file: {video_path}") + + # Get total frames in video + total_frames = int(video.get(cv2.CAP_PROP_FRAME_COUNT)) + + print(f"Detected {len(scenes)} scene changes") + print(f"Total frames in video: {total_frames}") + + # Extract and save frames from detected scenes + scene_frames = [] + frame_numbers_extracted = set() # Track which frames we've extracted + + for idx, scene in enumerate(scenes, 1): + # Calculate middle frame number of the scene + frame_no = (scene[1] - scene[0]).frame_num // 2 + scene[0].frame_num + + # Extract frame + try: + frame = self._get_frame(video, frame_no) + + # Save frame with naming pattern: video_name+frame_X.jpg + frame_filename = f"{video_name}+frame_{frame_no}.jpg" + frame_path = os.path.join(output_folder, frame_filename) + frame.save(frame_path) + + # Create SceneFrame object + scene_frame = SceneFrame( + frame_path=frame_path, frame_index=frame_no + ) + scene_frames.append(scene_frame) + frame_numbers_extracted.add(frame_no) + + # Progress update: Print every 10 scenes to avoid console spam + if idx % 10 == 0: + print(f"Extracted {idx}/{len(scenes)} scene frames...") + except Exception as e: + # Log warning but continue with other frames (graceful degradation) + print(f"Warning: Failed to extract frame {frame_no}: {e}") + continue + + # ================================================================ + # PHASE 3: Always extract first frame (frame 0) + # ================================================================ + # Ensure frame 0 is always extracted, even if it's not a scene change + if 0 not in frame_numbers_extracted: + try: + print("Extracting first frame (frame 0)...") + frame = self._get_frame(video, 0) + + frame_filename = f"{video_name}+frame_0.jpg" + frame_path = os.path.join(output_folder, frame_filename) + frame.save(frame_path) + + # Insert at the beginning of the list + scene_frame = SceneFrame(frame_path=frame_path, frame_index=0) + scene_frames.insert(0, scene_frame) + except Exception as e: + print(f"Warning: Failed to extract first frame: {e}") + + video.release() + + # Validate that at least one frame was successfully extracted + if not scene_frames: + raise NoScenesError( + f"No scenes detected and failed to extract first frame from video: {video_path}" + ) + + # Final success message with extraction statistics + print( + f"Successfully extracted {len(scene_frames)} frames to {output_folder}" + ) + + # Create result + result = DetectionResult( + file_id=video_name, + output_folder=output_folder, + total_frames=total_frames, + selected_frames=scene_frames, + ) + + return result + + except (VideoFileError, NoScenesError, FrameExtractionError): + # Re-raise our custom exceptions + raise + except Exception as e: + raise PySceneDetectError( + f"Unexpected error during scene detection: {e}" + ) from e def _get_frame(self, video: cv2.VideoCapture, frame_no: int) -> Image.Image: """ @@ -104,35 +310,17 @@ def _get_frame(self, video: cv2.VideoCapture, frame_no: int) -> Image.Image: Returns: PIL Image of the frame - """ - video.set(cv2.CAP_PROP_POS_FRAMES, frame_no) - _, frame = video.read() - return Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) - def _save_json_mapping( - self, result: DetectionResult, output_folder: str, file_id: str - ) -> None: + Raises: + FrameExtractionError: If frame extraction fails """ - Save JSON mapping of file_id to extracted scenes. - - Args: - result: DetectionResult object - output_folder: Folder to save the JSON file - file_id: Unique identifier for the video - """ - # Use Pydantic's model_dump instead of asdict - result_dict = result.model_dump() - result_dict["total_selected_frames"] = len(result.selected_frames) - - json_path = os.path.join(output_folder, f"{file_id}_mapping.json") - with open(json_path, "w", encoding="utf-8") as f: - json.dump(result_dict, f, indent=2, ensure_ascii=False) - - print(f"JSON mapping saved to: {json_path}") - + try: + video.set(cv2.CAP_PROP_POS_FRAMES, frame_no) + ret, frame = video.read() -# if __name__ == "__main__": -# video_path = r"D:\professional\LABELLERR\Task\Repos\SDKPython\labellerr\notebooks\c44f38f6-0186-436f-8c2d-ffb50a539c76.mp4" + if not ret or frame is None: + raise FrameExtractionError(f"Failed to read frame {frame_no}") -# detector = PySceneDetect() -# result = detector.detect_and_extract(video_path) + return Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) + except Exception as e: + raise FrameExtractionError(f"Error extracting frame {frame_no}: {e}") from e diff --git a/labellerr/services/video_sampling/ssim.py b/labellerr/services/video_sampling/ssim.py deleted file mode 100644 index 285a4c9..0000000 --- a/labellerr/services/video_sampling/ssim.py +++ /dev/null @@ -1,234 +0,0 @@ -import json -import os -from typing import List - -import cv2 -import numpy as np -from PIL import Image -from pydantic import BaseModel, Field -from skimage.metrics import structural_similarity as ssim - -from labellerr.core.base.singleton import Singleton - - -class SceneFrame(BaseModel): - """Represents a detected scene with its extracted frame.""" - - frame_path: str - frame_index: int - ssim_score: float - - -class DetectionResult(BaseModel): - """Contains all detection results for a video.""" - - file_id: str - output_folder: str - total_frames: int - selected_frames: List[SceneFrame] = Field(default_factory=list) - - -class SSIMSceneDetect(Singleton): - """SSIM-based scene detection and frame extraction for videos (Singleton).""" - - def detect_and_extract( - self, video_path: str, threshold: float = 0.6, resize_dim: tuple = (320, 240) - ) -> DetectionResult: - """ - Detect scenes using SSIM and extract representative frames. - - Args: - video_path: Path to the video file - threshold: SSIM threshold for scene detection (lower = stricter, default: 0.6) - resize_dim: Dimensions to resize frames for SSIM calculation (default: (320, 240)) - - Returns: - DetectionResult containing file_id, output_folder, total_frames, and list of SceneFrame objects - """ - # Derive file_id from video_path (base name without extension) - file_id = os.path.splitext(os.path.basename(video_path))[0] - dataset_id = os.path.basename(os.path.dirname(video_path)) - - # Create detects folder structure - base_detect_folder = "SSIM_detects" - output_folder = os.path.join(base_detect_folder, dataset_id, file_id) - frames_folder = os.path.join(output_folder, "frames") - - # Create nested output folders - os.makedirs(frames_folder, exist_ok=True) - - # Open video for processing - video = cv2.VideoCapture(video_path) - - if not video.isOpened(): - raise ValueError(f"Cannot open video: {video_path}") - - # Get total frames in video - total_frames = int(video.get(cv2.CAP_PROP_FRAME_COUNT)) - - print(f"Processing video: {video_path}") - print(f"Total frames: {total_frames}") - print(f"SSIM threshold: {threshold}") - - # Read first frame - success, prev_frame = video.read() - if not success: - video.release() - raise ValueError(f"Cannot read first frame from: {video_path}") - - # Extract and save frames - scene_frames = [] - frame_count = 0 - - # Always save first frame - self._save_frame(prev_frame, frame_count, 1.0, scene_frames, frames_folder) - # print(f"Saved keyframe 0 at frame {frame_count} (First frame)") - - # Process remaining frames - while True: - success, curr_frame = video.read() - if not success: - break - - frame_count += 1 - - # Calculate SSIM between current and previous frame - ssim_score = self._calculate_ssim(prev_frame, curr_frame, resize_dim) - - # If SSIM is below threshold, it's a scene change - if ssim_score < threshold: - self._save_frame( - curr_frame, frame_count, ssim_score, scene_frames, frames_folder - ) - print( - f"Saved keyframe {len(scene_frames) - 1} at frame {frame_count} (SSIM: {ssim_score:.3f})" - ) - prev_frame = curr_frame - elif frame_count % 100 == 0: - print( - f"Frame {frame_count}: SSIM = {ssim_score:.3f} (threshold: {threshold})" - ) - - video.release() - - # print(f"\nExtracted {len(scene_frames)} keyframes from {frame_count + 1} frames.") - - # Create result - result = DetectionResult( - file_id=file_id, - output_folder=output_folder, # Main detects/file_id folder - total_frames=total_frames, - selected_frames=scene_frames, - ) - - # Save JSON mapping - self._save_json_mapping(result, output_folder, file_id, threshold, resize_dim) - - return result - - def _calculate_ssim( - self, frame1: np.ndarray, frame2: np.ndarray, resize_dim: tuple - ) -> float: - """ - Calculate SSIM score between two frames. - - Args: - frame1: First frame (BGR format) - frame2: Second frame (BGR format) - resize_dim: Dimensions to resize frames for SSIM calculation - - Returns: - SSIM score (0-1, where 1 is identical) - """ - # Resize frames for faster computation - gray1 = cv2.cvtColor(cv2.resize(frame1, resize_dim), cv2.COLOR_BGR2GRAY) - gray2 = cv2.cvtColor(cv2.resize(frame2, resize_dim), cv2.COLOR_BGR2GRAY) - - # Calculate SSIM - score, _ = ssim(gray1, gray2, full=True) - - return score - - def _save_frame( - self, - frame: np.ndarray, - frame_no: int, - ssim_score: float, - scene_frames: List[SceneFrame], - frames_folder: str, - ) -> None: - """ - Save a frame to disk and add to scene_frames list. - - Args: - frame: Frame to save (BGR format) - frame_no: Frame number - ssim_score: SSIM score that triggered this frame - scene_frames: List to append SceneFrame object to - frames_folder: Folder to save the frame (detects/file_id/frames) - """ - # Convert BGR to RGB for PIL - frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - pil_image = Image.fromarray(frame_rgb) - - # Save frame with frame number as filename in frames folder - frame_filename = f"{frame_no}.jpg" - frame_path = os.path.join( - frames_folder, frame_filename - ) # Now uses frames_folder - pil_image.save(frame_path) - - # Create SceneFrame object - scene_frame = SceneFrame( - frame_path=frame_path, frame_index=frame_no, ssim_score=ssim_score - ) - scene_frames.append(scene_frame) - - def _save_json_mapping( - self, - result: DetectionResult, - output_folder: str, # This is now detects/file_id/ - file_id: str, - threshold: float, - resize_dim: tuple, - ) -> None: - """ - Save JSON mapping of file_id to extracted scenes. - - Args: - result: DetectionResult object - output_folder: Folder to save the JSON file (detects/file_id/) - file_id: Unique identifier for the video - threshold: SSIM threshold used - resize_dim: Resize dimensions used - """ - # Use Pydantic's model_dump - result_dict = result.model_dump() - result_dict["total_selected_frames"] = len(result.selected_frames) - result_dict["threshold"] = threshold - result_dict["resize_dim"] = resize_dim - - json_path = os.path.join(output_folder, f"{file_id}_mapping.json") - with open(json_path, "w", encoding="utf-8") as f: - json.dump(result_dict, f, indent=2, ensure_ascii=False) - - print(f"JSON mapping saved to: {json_path}") - - -if __name__ == "__main__": - # Example usage - video_path = r"D:\professional\LABELLERR\Task\Repos\Python_SDK\services\video_sampling\video2.mp4" - - # Get singleton instance - detector = SSIMSceneDetect() - - # Detect and extract frames - result = detector.detect_and_extract( - video_path=video_path, - threshold=0.6, # Lower value = more sensitive to changes - resize_dim=(320, 240), - ) - - print("\nDetection complete!") - print(f"Total frames extracted: {len(result.selected_frames)}") - print(f"Output folder: {result.output_folder}") diff --git a/labellerr/services/video_sampling/ssim_detect.py b/labellerr/services/video_sampling/ssim_detect.py new file mode 100644 index 0000000..cf6b426 --- /dev/null +++ b/labellerr/services/video_sampling/ssim_detect.py @@ -0,0 +1,446 @@ +import json +import os +from pathlib import Path +from typing import List + +import cv2 +import numpy as np +from PIL import Image +from pydantic import BaseModel, Field +from skimage.metrics import structural_similarity as ssim + +from labellerr.core.base.singleton import Singleton + +# ============================================================================ +# Exception Classes +# ============================================================================ + + +class SSIMDetectError(Exception): + """Base exception for all SSIM detection-related errors. + + This is the parent exception class for all SSIM-specific errors in this module. + Catching this exception will catch all SSIM detection-related issues including: + - Video file errors + - Frame extraction failures + - SSIM calculation errors + """ + + pass + + +class VideoFileError(SSIMDetectError): + """Raised when there are issues with the input video file. + + Common causes: + - File does not exist + - Path points to a directory instead of a file + - Unsupported video format + - File is not readable (permission issues) + - Video file is corrupted + - OpenCV cannot open the video + """ + + pass + + +class FrameExtractionError(SSIMDetectError): + """Raised when frame extraction fails. + + This can occur if: + - OpenCV cannot read the video + - Frame number is out of range + - Video codec is unsupported + - Frame data is corrupted + """ + + pass + + +# ============================================================================ +# Data Models +# ============================================================================ + + +class SceneFrame(BaseModel): + """Represents a single extracted frame from a detected scene. + + Attributes: + frame_path (str): Absolute or relative path to the extracted frame image file. + Example: "SSIM_detects/video_id/frames/video_name+frame_250.jpg" + frame_index (int): The 0-indexed frame number in the source video. + Example: 250 means this is the 250th frame of the video. + ssim_score (float): The SSIM score that triggered this frame extraction. + Range: 0.0 to 1.0 (lower = more different from previous frame) + """ + + frame_path: str + frame_index: int + ssim_score: float + + +class DetectionResult(BaseModel): + """Contains all SSIM detection results for a video file. + + This model encapsulates the complete output of the SSIM detection process, + including metadata about the video and a list of all extracted frames. + + Attributes: + file_id (str): Unique identifier for the video (filename without extension). + output_folder (str): Path to the folder containing extracted frames and metadata. + total_frames (int): Total number of frames in the source video. + selected_frames (List[SceneFrame]): List of all successfully extracted scene frames. + Each frame includes its path, frame index, and SSIM score. + """ + + file_id: str + output_folder: str + total_frames: int + selected_frames: List[SceneFrame] = Field(default_factory=list) + + +# ============================================================================ +# Main SSIM Detection Class +# ============================================================================ + + +class SSIMSceneDetect(Singleton): + """SSIM-based scene change detection and frame extraction. + + This singleton class provides methods to detect scene changes in video files + using SSIM (Structural Similarity Index) metric. SSIM measures perceptual + similarity between frames, making it effective for scene change detection. + + The class implements the Singleton pattern to ensure only one instance exists, + which is useful for managing video processing and avoiding redundant initialization. + + Attributes: + SUPPORTED_EXTENSIONS (set): Set of supported video file extensions. + + Example: + >>> detector = SSIMSceneDetect() + >>> result = detector.detect_and_extract("video.mp4", threshold=0.6) + >>> print(f"Detected {len(result.selected_frames)} scenes") + """ + + # Supported video file extensions + SUPPORTED_EXTENSIONS = { + ".mp4", + ".avi", + ".mov", + ".mkv", + ".flv", + ".wmv", + ".webm", + ".m4v", + } + + def _validate_video_file(self, video_path: str) -> None: + """Validate that the video file exists and is a supported format. + + Args: + video_path: Path to the video file + + Raises: + VideoFileError: If file doesn't exist or format is unsupported + """ + path = Path(video_path) + + if not path.exists(): + raise VideoFileError(f"Video file not found: {video_path}") + + if not path.is_file(): + raise VideoFileError(f"Path is not a file: {video_path}") + + if path.suffix.lower() not in self.SUPPORTED_EXTENSIONS: + raise VideoFileError( + f"Unsupported video format: {path.suffix}. " + f"Supported formats: {', '.join(sorted(self.SUPPORTED_EXTENSIONS))}" + ) + + # Check if file is readable + if not os.access(video_path, os.R_OK): + raise VideoFileError(f"Video file is not readable: {video_path}") + + def detect_and_extract( + self, video_path: str, threshold: float = 0.3, resize_dim: tuple = (320, 240) + ) -> DetectionResult: + """ + Detect scenes using SSIM and extract representative frames. + Always extracts the first frame (frame 0) of the video. + + Args: + video_path: Path to the video file + threshold: SSIM threshold for scene detection (lower = stricter, default: 0.3) + Range: 0.0 to 1.0. Values below threshold indicate scene change. + resize_dim: Dimensions to resize frames for SSIM calculation (default: (320, 240)) + Smaller dimensions = faster computation + + Returns: + DetectionResult containing file_id, output_folder, total_frames, and list of SceneFrame objects + + Raises: + VideoFileError: If video file is invalid or inaccessible + FrameExtractionError: If frame extraction fails + SSIMDetectError: If SSIM detection processing fails + """ + # Validate input file before processing + self._validate_video_file(video_path) + + # Extract identifiers from the video path + # file_id: Video filename without extension (e.g., "video_123") + # dataset_id: Parent directory name (used for organizing outputs) + file_id = os.path.splitext(os.path.basename(video_path))[0] + dataset_id = os.path.basename(os.path.dirname(video_path)) + + # Create hierarchical output folder structure: + # SSIM_detects/ + # └── / + # └── / + # ├── frames/ (extracted frame images) + # └── _mapping.json (metadata) + base_detect_folder = "SSIM_detects" + output_folder = os.path.join(base_detect_folder, dataset_id, file_id) + frames_folder = os.path.join(output_folder, "frames") + + # Create all necessary directories (no error if they already exist) + os.makedirs(frames_folder, exist_ok=True) + + try: + # ================================================================ + # PHASE 1: Open video and validate + # ================================================================ + video = cv2.VideoCapture(video_path) + + if not video.isOpened(): + raise VideoFileError(f"Cannot open video: {video_path}") + + # Get total frames in video + total_frames = int(video.get(cv2.CAP_PROP_FRAME_COUNT)) + + print(f"Processing video: {video_path}") + print(f"Total frames: {total_frames}") + print(f"SSIM threshold: {threshold}") + + # ================================================================ + # PHASE 2: Extract first frame (always included) + # ================================================================ + success, prev_frame = video.read() + if not success: + video.release() + raise FrameExtractionError( + f"Cannot read first frame from: {video_path}" + ) + + scene_frames: List[SceneFrame] = [] + frame_count = 0 + + # Always save first frame with SSIM score of 1.0 (perfect match with itself) + self._save_frame( + prev_frame, frame_count, 1.0, scene_frames, frames_folder, file_id + ) + print("Saved first frame (frame 0)") + + # ================================================================ + # PHASE 3: Process remaining frames with SSIM detection + # ================================================================ + while True: + success, curr_frame = video.read() + if not success: + break + + frame_count += 1 + + try: + # Calculate SSIM between current and previous frame + ssim_score = self._calculate_ssim( + prev_frame, curr_frame, resize_dim + ) + + # If SSIM is below threshold, it's a scene change + if ssim_score < threshold: + self._save_frame( + curr_frame, + frame_count, + ssim_score, + scene_frames, + frames_folder, + file_id, + ) + print( + f"Saved keyframe {len(scene_frames) - 1} at frame {frame_count} (SSIM: {ssim_score:.3f})" + ) + prev_frame = curr_frame + elif frame_count % 100 == 0: + # Progress update every 100 frames + print( + f"Frame {frame_count}/{total_frames}: SSIM = {ssim_score:.3f} (threshold: {threshold})" + ) + except Exception as e: + # Log warning but continue with other frames (graceful degradation) + print(f"Warning: Failed to process frame {frame_count}: {e}") + continue + + video.release() + + # Validate that at least one frame was successfully extracted + if not scene_frames: + raise SSIMDetectError( + f"No frames extracted from video: {video_path}. " + "All frame extractions failed." + ) + + # Final success message with extraction statistics + print( + f"\nSuccessfully extracted {len(scene_frames)} frames from {frame_count + 1} total frames" + ) + + # Create result + result = DetectionResult( + file_id=file_id, + output_folder=output_folder, + total_frames=total_frames, + selected_frames=scene_frames, + ) + + # Save JSON mapping + self._save_json_mapping( + result, output_folder, file_id, threshold, resize_dim + ) + + return result + + except (VideoFileError, FrameExtractionError): + # Re-raise our custom exceptions + raise + except Exception as e: + raise SSIMDetectError(f"Unexpected error during SSIM detection: {e}") from e + + def _calculate_ssim( + self, frame1: np.ndarray, frame2: np.ndarray, resize_dim: tuple + ) -> float: + """ + Calculate SSIM score between two frames. + + Args: + frame1: First frame (BGR format from OpenCV) + frame2: Second frame (BGR format from OpenCV) + resize_dim: Dimensions to resize frames for SSIM calculation + + Returns: + SSIM score (0-1, where 1 is identical, 0 is completely different) + + Raises: + SSIMDetectError: If SSIM calculation fails + """ + try: + # Resize frames for faster computation + # Convert to grayscale for SSIM calculation + gray1 = cv2.cvtColor(cv2.resize(frame1, resize_dim), cv2.COLOR_BGR2GRAY) + gray2 = cv2.cvtColor(cv2.resize(frame2, resize_dim), cv2.COLOR_BGR2GRAY) + + # Calculate SSIM using scikit-image + # full=True returns the full SSIM image, we only need the score + score, _ = ssim(gray1, gray2, full=True) + + return float(score) + except Exception as e: + raise SSIMDetectError(f"Failed to calculate SSIM: {e}") from e + + def _save_frame( + self, + frame: np.ndarray, + frame_no: int, + ssim_score: float, + scene_frames: List[SceneFrame], + frames_folder: str, + file_id: str, + ) -> None: + """ + Save a frame to disk and add to scene_frames list. + + Args: + frame: Frame to save (BGR format from OpenCV) + frame_no: Frame number (0-indexed) + ssim_score: SSIM score that triggered this frame extraction + scene_frames: List to append SceneFrame object to + frames_folder: Folder to save the frame + file_id: Video filename without extension (for naming pattern) + + Raises: + FrameExtractionError: If frame saving fails + """ + try: + # Convert BGR (OpenCV format) to RGB (PIL format) + frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + pil_image = Image.fromarray(frame_rgb) + + # Save frame with naming pattern: video_name+frame_X.jpg + frame_filename = f"{file_id}+frame_{frame_no}.jpg" + frame_path = os.path.join(frames_folder, frame_filename) + pil_image.save(frame_path) + + # Create SceneFrame object with SSIM score + scene_frame = SceneFrame( + frame_path=frame_path, frame_index=frame_no, ssim_score=ssim_score + ) + scene_frames.append(scene_frame) + except Exception as e: + raise FrameExtractionError(f"Failed to save frame {frame_no}: {e}") from e + + def _save_json_mapping( + self, + result: DetectionResult, + output_folder: str, + file_id: str, + threshold: float, + resize_dim: tuple, + ) -> None: + """ + Save JSON mapping of file_id to extracted scenes. + + Args: + result: DetectionResult object + output_folder: Folder to save the JSON file + file_id: Unique identifier for the video + threshold: SSIM threshold used for detection + resize_dim: Resize dimensions used for SSIM calculation + + Raises: + SSIMDetectError: If JSON file cannot be saved + """ + try: + # Use Pydantic's model_dump + result_dict = result.model_dump() + result_dict["total_selected_frames"] = len(result.selected_frames) + result_dict["threshold"] = threshold + result_dict["resize_dim"] = resize_dim + + json_path = os.path.join(output_folder, f"{file_id}_mapping.json") + with open(json_path, "w", encoding="utf-8") as f: + json.dump(result_dict, f, indent=2, ensure_ascii=False) + + print(f"JSON mapping saved to: {json_path}") + except (IOError, OSError) as e: + raise SSIMDetectError( + f"Failed to save JSON mapping to {json_path}: {e}" + ) from e + + +if __name__ == "__main__": + # Example usage + video_path = r"D:\Professional\Labellerr_SDK\SDKPython\labellerr\notebooks\Labellerr_datasets\354681d3-034a-4d66-b070-365f4bd11d8a\2a8d96ca-9161-4dee-ad3b-a5faf301bc6c.mp4" + + # Get singleton instance + detector = SSIMSceneDetect() + + # Detect and extract frames + result = detector.detect_and_extract( + video_path=video_path, + threshold=0.6, # Lower value = more sensitive to changes + resize_dim=(320, 240), + ) + + print("\nDetection complete!") + print(f"Total frames extracted: {len(result.selected_frames)}") + print(f"Output folder: {result.output_folder}") diff --git a/mcp_client.py b/mcp_client.py index b912c4e..fcbe36a 100644 --- a/mcp_client.py +++ b/mcp_client.py @@ -5,16 +5,15 @@ """ import asyncio -import sys import os -from typing import Optional +import sys from contextlib import AsyncExitStack - -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client +from typing import Optional from anthropic import Anthropic from dotenv import load_dotenv +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client load_dotenv() # load environment variables from .env @@ -34,8 +33,8 @@ async def connect_to_server(self, server_script_path: str): Args: server_script_path: Path to the server script (.py or .js) """ - is_python = server_script_path.endswith('.py') - is_js = server_script_path.endswith('.js') + is_python = server_script_path.endswith(".py") + is_js = server_script_path.endswith(".js") if not (is_python or is_js): raise ValueError("Server script must be a .py or .js file") @@ -49,9 +48,7 @@ async def connect_to_server(self, server_script_path: str): } server_params = StdioServerParameters( - command=command, - args=[server_script_path], - env=env + command=command, args=[server_script_path], env=env ) stdio_transport = await self.exit_stack.enter_async_context( @@ -72,26 +69,24 @@ async def connect_to_server(self, server_script_path: str): async def process_query(self, query: str) -> str: """Process a query using Claude and available tools""" - messages = [ + messages = [{"role": "user", "content": query}] + + response = await self.session.list_tools() + available_tools = [ { - "role": "user", - "content": query + "name": tool.name, + "description": tool.description, + "input_schema": tool.inputSchema, } + for tool in response.tools ] - response = await self.session.list_tools() - available_tools = [{ - "name": tool.name, - "description": tool.description, - "input_schema": tool.inputSchema - } for tool in response.tools] - # Initial Claude API call response = self.anthropic.messages.create( model="claude-3-5-sonnet-20241022", max_tokens=1000, messages=messages, - tools=available_tools + tools=available_tools, ) # Process response and handle tool calls @@ -99,10 +94,10 @@ async def process_query(self, query: str) -> str: assistant_message_content = [] for content in response.content: - if content.type == 'text': + if content.type == "text": final_text.append(content.text) assistant_message_content.append(content) - elif content.type == 'tool_use': + elif content.type == "tool_use": tool_name = content.name tool_args = content.input @@ -111,27 +106,28 @@ async def process_query(self, query: str) -> str: final_text.append(f"[Calling tool {tool_name} with args {tool_args}]") assistant_message_content.append(content) - messages.append({ - "role": "assistant", - "content": assistant_message_content - }) - messages.append({ - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": content.id, - "content": result.content - } - ] - }) + messages.append( + {"role": "assistant", "content": assistant_message_content} + ) + messages.append( + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": content.id, + "content": result.content, + } + ], + } + ) # Get next response from Claude response = self.anthropic.messages.create( model="claude-3-5-sonnet-20241022", max_tokens=1000, messages=messages, - tools=available_tools + tools=available_tools, ) final_text.append(response.content[0].text) @@ -147,7 +143,7 @@ async def chat_loop(self): try: query = input("\nQuery: ").strip() - if query.lower() == 'quit': + if query.lower() == "quit": break response = await self.process_query(query) diff --git a/tests/conftest.py b/tests/conftest.py index e02c0a9..07942c9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,10 +9,12 @@ import tempfile import time from typing import List, Optional +from unittest.mock import PropertyMock, patch import pytest from labellerr.client import LabellerrClient +from labellerr.core.projects.image_project import ImageProject class TestConfig: @@ -109,6 +111,44 @@ def client(): return LabellerrClient("test_api_key", "test_api_secret", "test_client_id") +@pytest.fixture +def project(client): + """Create a test project instance for unit testing using proper mocking""" + project_data = { + "project_id": "test_project_id", + "data_type": "image", + "attached_datasets": [], + } + + with patch.object( + ImageProject, "project_id", new_callable=PropertyMock + ) as mock_project_id: + mock_project_id.return_value = "test_project_id" + proj = ImageProject.__new__(ImageProject) + proj.client = client + proj._project_data = project_data + yield proj + + +@pytest.fixture +def project(client): + """Create a test project instance for unit testing using proper mocking""" + project_data = { + "project_id": "test_project_id", + "data_type": "image", + "attached_datasets": [], + } + + with patch.object( + ImageProject, "project_id", new_callable=PropertyMock + ) as mock_project_id: + mock_project_id.return_value = "test_project_id" + proj = ImageProject.__new__(ImageProject) + proj.client = client + proj._project_data = project_data + yield proj + + @pytest.fixture def integration_client(test_credentials): """Create a real client for integration testing""" diff --git a/tests/integration/run_mcp_integration_tests.py b/tests/integration/run_mcp_integration_tests.py index 82a3463..3129a8f 100644 --- a/tests/integration/run_mcp_integration_tests.py +++ b/tests/integration/run_mcp_integration_tests.py @@ -13,11 +13,12 @@ python tests/integration/run_mcp_integration_tests.py """ +import getpass import os import sys -import getpass from pathlib import Path -from dotenv import load_dotenv, set_key, find_dotenv + +from dotenv import find_dotenv, load_dotenv, set_key def get_project_root(): @@ -30,7 +31,7 @@ def get_project_root(): def get_env_file(): """Get or create .env file path""" project_root = get_project_root() - env_file = project_root / '.env' + env_file = project_root / ".env" # Try to find existing .env file found = find_dotenv(str(project_root)) @@ -50,16 +51,19 @@ def check_and_prompt_credentials(): env_file = get_env_file() load_dotenv(env_file) - api_key = os.getenv('API_KEY') - api_secret = os.getenv('API_SECRET') - client_id = os.getenv('CLIENT_ID') - test_data_path = os.getenv('LABELLERR_TEST_DATA_PATH') + api_key = os.getenv("API_KEY") + api_secret = os.getenv("API_SECRET") + client_id = os.getenv("CLIENT_ID") + test_data_path = os.getenv("LABELLERR_TEST_DATA_PATH") required_vars = { - 'API_KEY': ('API Key', api_key), - 'API_SECRET': ('API Secret', api_secret), - 'CLIENT_ID': ('Client ID', client_id), - 'LABELLERR_TEST_DATA_PATH': ('Test Data Path (folder with images)', test_data_path) + "API_KEY": ("API Key", api_key), + "API_SECRET": ("API Secret", api_secret), + "CLIENT_ID": ("Client ID", client_id), + "LABELLERR_TEST_DATA_PATH": ( + "Test Data Path (folder with images)", + test_data_path, + ), } print("=" * 60) @@ -88,7 +92,7 @@ def check_and_prompt_credentials(): for env_var, display_name in missing: # Use getpass for sensitive fields - if 'SECRET' in env_var or 'KEY' in env_var: + if "SECRET" in env_var or "KEY" in env_var: value = getpass.getpass(f"{display_name}: ") else: value = input(f"{display_name}: ") @@ -101,7 +105,7 @@ def check_and_prompt_credentials(): try: # Create .env file if it doesn't exist if not os.path.exists(env_file): - with open(env_file, 'w') as f: + with open(env_file, "w") as f: f.write("# Labellerr API Credentials\n") set_key(env_file, env_var, value) @@ -112,9 +116,9 @@ def check_and_prompt_credentials(): print(f" ⚠ Warning: {env_var} left empty") # Check if all required vars are now available (re-check after prompting) - api_key = os.getenv('API_KEY') - api_secret = os.getenv('API_SECRET') - client_id = os.getenv('CLIENT_ID') + api_key = os.getenv("API_KEY") + api_secret = os.getenv("API_SECRET") + client_id = os.getenv("CLIENT_ID") all_present = all([api_key, api_secret, client_id]) if all_present: @@ -144,9 +148,9 @@ def validate_credentials(): from labellerr.core import projects as project_ops client = LabellerrClient( - api_key=os.getenv('API_KEY'), - api_secret=os.getenv('API_SECRET'), - client_id=os.getenv('CLIENT_ID') + api_key=os.getenv("API_KEY"), + api_secret=os.getenv("API_SECRET"), + client_id=os.getenv("CLIENT_ID"), ) # Try to list projects as validation @@ -170,7 +174,7 @@ def check_test_data(): Returns: bool: True if test data is accessible """ - test_data_path = os.getenv('LABELLERR_TEST_DATA_PATH') + test_data_path = os.getenv("LABELLERR_TEST_DATA_PATH") if not test_data_path: print("\n⚠ Warning: LABELLERR_TEST_DATA_PATH not set") @@ -183,10 +187,10 @@ def check_test_data(): return False # Check for image files - image_extensions = ['.jpg', '.jpeg', '.png', '.tiff'] + image_extensions = [".jpg", ".jpeg", ".png", ".tiff"] files = [] for ext in image_extensions: - files.extend(Path(test_data_path).rglob(f'*{ext}')) + files.extend(Path(test_data_path).rglob(f"*{ext}")) if not files: print(f"\n⚠ Warning: No image files found in: {test_data_path}") @@ -217,13 +221,9 @@ def run_tests(): test_file = test_dir / "test_mcp_server.py" # Run pytest with verbose output - exit_code = pytest.main([ - str(test_file), - "-v", - "-s", - "--tb=short", - "--color=yes" - ]) + exit_code = pytest.main( + [str(test_file), "-v", "-s", "--tb=short", "--color=yes"] + ) return exit_code @@ -271,5 +271,6 @@ def main(): except Exception as e: print(f"\n❌ Unexpected error: {e}") import traceback + traceback.print_exc() sys.exit(1) diff --git a/tests/integration/run_mcp_tools_tests.py b/tests/integration/run_mcp_tools_tests.py index 239b23d..bcd6ce1 100755 --- a/tests/integration/run_mcp_tools_tests.py +++ b/tests/integration/run_mcp_tools_tests.py @@ -30,9 +30,9 @@ sys.path.insert(0, str(project_root)) # Check environment variables -api_key = os.getenv('API_KEY') -api_secret = os.getenv('API_SECRET') -client_id = os.getenv('CLIENT_ID') +api_key = os.getenv("API_KEY") +api_secret = os.getenv("API_SECRET") +client_id = os.getenv("CLIENT_ID") if not all([api_key, api_secret, client_id]): print("❌ Missing required environment variables:") @@ -50,48 +50,48 @@ sys.exit(1) # Optional test data path -test_data_path = os.getenv('LABELLERR_TEST_DATA_PATH') +test_data_path = os.getenv("LABELLERR_TEST_DATA_PATH") if test_data_path: print(f"ℹ️ Test data path: {test_data_path}") else: print("ℹ️ No test data path provided - file upload tests will be skipped") print(" Set LABELLERR_TEST_DATA_PATH to enable file upload tests") -print("\n" + "="*80) +print("\n" + "=" * 80) print("LABELLERR MCP SERVER - INTEGRATION TESTS") -print("="*80) +print("=" * 80) print(f"\nAPI Key: {api_key[:10]}...") print(f"Client ID: {client_id}") -print("="*80 + "\n") +print("=" * 80 + "\n") # Run pytest import pytest # Build pytest args pytest_args = [ - 'tests/integration/test_mcp_tools.py', - '-v', # Verbose - '-s', # Show print statements - '--tb=short', # Short traceback format - '--color=yes', # Colored output + "tests/integration/test_mcp_tools.py", + "-v", # Verbose + "-s", # Show print statements + "--tb=short", # Short traceback format + "--color=yes", # Colored output ] # Add any command line arguments if len(sys.argv) > 1: # User specified specific test(s) test_filter = sys.argv[1] - pytest_args.append(f'-k={test_filter}') + pytest_args.append(f"-k={test_filter}") print(f"Running tests matching: {test_filter}\n") # Run tests exit_code = pytest.main(pytest_args) # Print summary -print("\n" + "="*80) +print("\n" + "=" * 80) if exit_code == 0: print("✅ ALL TESTS PASSED!") else: print("❌ SOME TESTS FAILED") -print("="*80 + "\n") +print("=" * 80 + "\n") sys.exit(exit_code) diff --git a/tests/integration/test_mcp_server.py b/tests/integration/test_mcp_server.py index 13d7ef6..c350ef8 100644 --- a/tests/integration/test_mcp_server.py +++ b/tests/integration/test_mcp_server.py @@ -11,8 +11,9 @@ """ import os -import pytest import uuid + +import pytest from dotenv import load_dotenv # Mark all tests in this module as integration tests @@ -21,29 +22,31 @@ # Skip entire module if SDK core dependencies are not installed try: from labellerr.core import LabellerrClient + from labellerr.core import annotation_templates as template_ops + from labellerr.core import constants from labellerr.core import datasets as dataset_ops from labellerr.core import projects as project_ops - from labellerr.core import annotation_templates as template_ops + from labellerr.core import schemas + from labellerr.core.annotation_templates import LabellerrAnnotationTemplate from labellerr.core.datasets import LabellerrDataset from labellerr.core.datasets.base import LabellerrDatasetMeta from labellerr.core.datasets.utils import upload_folder_files_to_dataset from labellerr.core.projects import LabellerrProject from labellerr.core.projects.base import LabellerrProjectMeta - from labellerr.core.annotation_templates import LabellerrAnnotationTemplate - from labellerr.core import schemas from labellerr.core.schemas.annotation_templates import ( - CreateTemplateParams, AnnotationQuestion, - QuestionType, + CreateTemplateParams, Option, + QuestionType, ) - from labellerr.core import constants + SDK_AVAILABLE = True except ImportError as e: SDK_AVAILABLE = False pytest.skip( f"SDK core dependencies not installed: {e}. Install with: pip install -e '.[dev]'", - allow_module_level=True + allow_module_level=True, + allow_module_level=True, ) # Load environment variables @@ -53,19 +56,32 @@ @pytest.fixture(scope="session") def credentials(): """Load API credentials from environment""" - api_key = os.getenv('API_KEY') - api_secret = os.getenv('API_SECRET') - client_id = os.getenv('CLIENT_ID') - test_data_path = os.getenv('LABELLERR_TEST_DATA_PATH') + api_key = os.getenv("API_KEY") + api_secret = os.getenv("API_SECRET") + client_id = os.getenv("CLIENT_ID") + test_data_path = os.getenv("LABELLERR_TEST_DATA_PATH") + api_key = os.getenv("API_KEY") + api_secret = os.getenv("API_SECRET") + client_id = os.getenv("CLIENT_ID") + test_data_path = os.getenv("LABELLERR_TEST_DATA_PATH") if not all([api_key, api_secret, client_id]): - pytest.skip("Missing required environment variables (API_KEY, API_SECRET, CLIENT_ID)") + pytest.skip( + "Missing required environment variables (API_KEY, API_SECRET, CLIENT_ID)" + ) + pytest.skip( + "Missing required environment variables (API_KEY, API_SECRET, CLIENT_ID)" + ) return { - 'api_key': api_key, - 'api_secret': api_secret, - 'client_id': client_id, - 'test_data_path': test_data_path + "api_key": api_key, + "api_secret": api_secret, + "client_id": client_id, + "test_data_path": test_data_path, + "api_key": api_key, + "api_secret": api_secret, + "client_id": client_id, + "test_data_path": test_data_path, } @@ -73,9 +89,12 @@ def credentials(): def sdk_client(credentials): """Create SDK client instance""" client = LabellerrClient( - api_key=credentials['api_key'], - api_secret=credentials['api_secret'], - client_id=credentials['client_id'] + api_key=credentials["api_key"], + api_secret=credentials["api_secret"], + client_id=credentials["client_id"], + api_key=credentials["api_key"], + api_secret=credentials["api_secret"], + client_id=credentials["client_id"], ) yield client @@ -87,7 +106,8 @@ def sdk_client(credentials): @pytest.fixture(scope="session") def test_dataset_id(sdk_client, credentials): """Create a test dataset and return its ID""" - test_data_path = credentials.get('test_data_path') + test_data_path = credentials.get("test_data_path") + test_data_path = credentials.get("test_data_path") if not test_data_path or not os.path.exists(test_data_path): pytest.skip("Test data path not provided or does not exist") @@ -96,24 +116,26 @@ def test_dataset_id(sdk_client, credentials): upload_result = upload_folder_files_to_dataset( sdk_client, { - "client_id": credentials['client_id'], + "client_id": credentials["client_id"], + "client_id": credentials["client_id"], "folder_path": test_data_path, - "data_type": "image" - } + "data_type": "image", + }, + "data_type": "image", + }, ) connection_id = upload_result.get("connection_id") dataset_config = schemas.DatasetConfig( dataset_name=f"MCP Test Dataset {uuid.uuid4().hex[:8]}", data_type="image", - dataset_description="Created by MCP integration tests" + dataset_description="Created by MCP integration tests", + dataset_description="Created by MCP integration tests", ) dataset = dataset_ops.create_dataset_from_connection( - sdk_client, - dataset_config, - connection_id, - "local" + sdk_client, dataset_config, connection_id, "local" + sdk_client, dataset_config, connection_id, "local" ) dataset_id = dataset.dataset_id @@ -140,14 +162,14 @@ def test_template_id(sdk_client): question_type=QuestionType.bounding_box, required=True, options=[Option(option_name="#FF0000")], - color="#FF0000" + color="#FF0000", + color="#FF0000", ) ] params = CreateTemplateParams( - template_name=template_name, - data_type="image", - questions=questions + template_name=template_name, data_type="image", questions=questions + template_name=template_name, data_type="image", questions=questions ) template = template_ops.create_template(sdk_client, params) @@ -162,7 +184,8 @@ def test_project_id(sdk_client, test_dataset_id, test_template_id): rotations = schemas.RotationConfig( annotation_rotation_count=1, review_rotation_count=1, - client_review_rotation_count=1 + client_review_rotation_count=1, + client_review_rotation_count=1, ) params = schemas.CreateProjectParams( @@ -170,19 +193,16 @@ def test_project_id(sdk_client, test_dataset_id, test_template_id): data_type="image", rotations=rotations, use_ai=False, - created_by=None + created_by=None, + created_by=None, ) # Get dataset and template objects dataset = LabellerrDataset(sdk_client, test_dataset_id) template = LabellerrAnnotationTemplate(sdk_client, test_template_id) - project = project_ops.create_project( - sdk_client, - params, - [dataset], - template - ) + project = project_ops.create_project(sdk_client, params, [dataset], template) + project = project_ops.create_project(sdk_client, params, [dataset], template) return project.project_id @@ -191,6 +211,8 @@ def test_project_id(sdk_client, test_dataset_id, test_template_id): # Test Cases # ============================================================================= + + class TestSDKClientInitialization: """Test SDK client initialization""" @@ -212,7 +234,8 @@ class TestDatasetOperations: def test_create_dataset_with_folder(self, sdk_client, credentials): """Test creating a dataset by uploading a folder""" - test_data_path = credentials.get('test_data_path') + test_data_path = credentials.get("test_data_path") + test_data_path = credentials.get("test_data_path") if not test_data_path or not os.path.exists(test_data_path): pytest.skip("Test data path not provided") @@ -221,25 +244,26 @@ def test_create_dataset_with_folder(self, sdk_client, credentials): upload_result = upload_folder_files_to_dataset( sdk_client, { - "client_id": credentials['client_id'], + "client_id": credentials["client_id"], + "client_id": credentials["client_id"], "folder_path": test_data_path, - "data_type": "image" - } + "data_type": "image", + }, + "data_type": "image", + }, ) connection_id = upload_result.get("connection_id") assert connection_id is not None # Create dataset dataset_config = schemas.DatasetConfig( - dataset_name=f"Test Dataset {uuid.uuid4().hex[:8]}", - data_type="image" + dataset_name=f"Test Dataset {uuid.uuid4().hex[:8]}", data_type="image" + dataset_name=f"Test Dataset {uuid.uuid4().hex[:8]}", data_type="image" ) dataset = dataset_ops.create_dataset_from_connection( - sdk_client, - dataset_config, - connection_id, - "local" + sdk_client, dataset_config, connection_id, "local" + sdk_client, dataset_config, connection_id, "local" ) assert dataset.dataset_id is not None @@ -258,12 +282,16 @@ def test_get_dataset(self, sdk_client, test_dataset_id): def test_list_datasets(self, sdk_client): """Test listing datasets""" - datasets = list(dataset_ops.list_datasets( - sdk_client, - "image", - schemas.DataSetScope.client, - page_size=10 - )) + datasets = list( + dataset_ops.list_datasets( + sdk_client, "image", schemas.DataSetScope.client, page_size=10 + ) + ) + datasets = list( + dataset_ops.list_datasets( + sdk_client, "image", schemas.DataSetScope.client, page_size=10 + ) + ) assert isinstance(datasets, list) @@ -283,14 +311,14 @@ def test_create_annotation_template(self, sdk_client): question_type=QuestionType.bounding_box, required=True, options=[Option(option_name="#00FF00")], - color="#00FF00" + color="#00FF00", + color="#00FF00", ) ] params = CreateTemplateParams( - template_name=template_name, - data_type="image", - questions=questions + template_name=template_name, data_type="image", questions=questions + template_name=template_name, data_type="image", questions=questions ) template = template_ops.create_template(sdk_client, params) @@ -316,24 +344,20 @@ def test_create_project(self, sdk_client, test_dataset_id, test_template_id): rotations = schemas.RotationConfig( annotation_rotation_count=1, review_rotation_count=1, - client_review_rotation_count=1 + client_review_rotation_count=1, + client_review_rotation_count=1, ) params = schemas.CreateProjectParams( - project_name=project_name, - data_type="image", - rotations=rotations + project_name=project_name, data_type="image", rotations=rotations + project_name=project_name, data_type="image", rotations=rotations ) dataset = LabellerrDataset(sdk_client, test_dataset_id) template = LabellerrAnnotationTemplate(sdk_client, test_template_id) - project = project_ops.create_project( - sdk_client, - params, - [dataset], - template - ) + project = project_ops.create_project(sdk_client, params, [dataset], template) + project = project_ops.create_project(sdk_client, params, [dataset], template) assert project.project_id is not None @@ -372,7 +396,8 @@ def test_create_export(self, sdk_client, test_project_id): export_description="Created by integration tests", export_format="json", statuses=["accepted"], - export_destination=schemas.ExportDestination.LOCAL + export_destination=schemas.ExportDestination.LOCAL, + export_destination=schemas.ExportDestination.LOCAL, ) export = project.create_export(export_config) @@ -389,7 +414,8 @@ def test_check_export_status(self, sdk_client, test_project_id): export_description="Testing status check", export_format="json", statuses=["accepted"], - export_destination=schemas.ExportDestination.LOCAL + export_destination=schemas.ExportDestination.LOCAL, + export_destination=schemas.ExportDestination.LOCAL, ) export = project.create_export(export_config) @@ -408,7 +434,8 @@ class TestCompleteWorkflow: def test_full_workflow(self, sdk_client, credentials): """Test creating dataset -> template -> project""" - test_data_path = credentials.get('test_data_path') + test_data_path = credentials.get("test_data_path") + test_data_path = credentials.get("test_data_path") if not test_data_path or not os.path.exists(test_data_path): pytest.skip("Test data path not provided") @@ -417,23 +444,25 @@ def test_full_workflow(self, sdk_client, credentials): upload_result = upload_folder_files_to_dataset( sdk_client, { - "client_id": credentials['client_id'], + "client_id": credentials["client_id"], + "client_id": credentials["client_id"], "folder_path": test_data_path, - "data_type": "image" - } + "data_type": "image", + }, + "data_type": "image", + }, ) connection_id = upload_result.get("connection_id") dataset_config = schemas.DatasetConfig( dataset_name=f"Workflow Test Dataset {uuid.uuid4().hex[:8]}", - data_type="image" + data_type="image", + data_type="image", ) dataset = dataset_ops.create_dataset_from_connection( - sdk_client, - dataset_config, - connection_id, - "local" + sdk_client, dataset_config, connection_id, "local" + sdk_client, dataset_config, connection_id, "local" ) dataset_id = dataset.dataset_id @@ -446,14 +475,16 @@ def test_full_workflow(self, sdk_client, credentials): question_type=QuestionType.bounding_box, required=True, options=[Option(option_name="#FF00FF")], - color="#FF00FF" + color="#FF00FF", + color="#FF00FF", ) ] template_params = CreateTemplateParams( template_name=f"Workflow Test Template {uuid.uuid4().hex[:8]}", data_type="image", - questions=questions + questions=questions, + questions=questions, ) template = template_ops.create_template(sdk_client, template_params) @@ -462,20 +493,20 @@ def test_full_workflow(self, sdk_client, credentials): rotations = schemas.RotationConfig( annotation_rotation_count=1, review_rotation_count=1, - client_review_rotation_count=1 + client_review_rotation_count=1, + client_review_rotation_count=1, ) project_params = schemas.CreateProjectParams( project_name=f"Workflow Test Project {uuid.uuid4().hex[:8]}", data_type="image", - rotations=rotations + rotations=rotations, + rotations=rotations, ) project = project_ops.create_project( - sdk_client, - project_params, - [dataset], - template + sdk_client, project_params, [dataset], template + sdk_client, project_params, [dataset], template ) project_id = project.project_id diff --git a/tests/integration/test_mcp_tools.py b/tests/integration/test_mcp_tools.py index 66f8a6d..866ecee 100644 --- a/tests/integration/test_mcp_tools.py +++ b/tests/integration/test_mcp_tools.py @@ -7,11 +7,12 @@ import os import sys -import uuid import time -import pytest +import uuid from pathlib import Path +import pytest + # Mark all tests in this module as integration tests pytestmark = pytest.mark.integration @@ -25,34 +26,32 @@ except ImportError as e: pytest.skip( f"MCP server dependencies not installed: {e}. Install with: pip install -e '.[mcp]'", - allow_module_level=True + allow_module_level=True, ) @pytest.fixture(scope="session") def credentials(): """Load credentials from environment""" - api_key = os.getenv('API_KEY') - api_secret = os.getenv('API_SECRET') - client_id = os.getenv('CLIENT_ID') + api_key = os.getenv("API_KEY") + api_secret = os.getenv("API_SECRET") + client_id = os.getenv("CLIENT_ID") if not all([api_key, api_secret, client_id]): - pytest.skip("Missing required environment variables (API_KEY, API_SECRET, CLIENT_ID)") + pytest.skip( + "Missing required environment variables (API_KEY, API_SECRET, CLIENT_ID)" + ) - return { - 'api_key': api_key, - 'api_secret': api_secret, - 'client_id': client_id - } + return {"api_key": api_key, "api_secret": api_secret, "client_id": client_id} @pytest.fixture(scope="session") def mcp_server(credentials): """Create MCP server instance""" # Set env vars for MCP server code - os.environ['LABELLERR_API_KEY'] = credentials['api_key'] - os.environ['LABELLERR_API_SECRET'] = credentials['api_secret'] - os.environ['LABELLERR_CLIENT_ID'] = credentials['client_id'] + os.environ["LABELLERR_API_KEY"] = credentials["api_key"] + os.environ["LABELLERR_API_SECRET"] = credentials["api_secret"] + os.environ["LABELLERR_CLIENT_ID"] = credentials["client_id"] server = LabellerrMCPServer() yield server @@ -68,7 +67,9 @@ def test_dataset_id(mcp_server): import asyncio # List datasets and pick the first one - result = asyncio.run(mcp_server._handle_dataset_tool("dataset_list", {"data_type": "image"})) + result = asyncio.run( + mcp_server._handle_dataset_tool("dataset_list", {"data_type": "image"}) + ) datasets = result.get("response", {}).get("datasets", []) if not datasets: @@ -96,6 +97,7 @@ def test_project_id(mcp_server): # Test Project Management Tools (4 tools) # ============================================================================= + class TestProjectTools: """Test project management tools""" @@ -136,9 +138,9 @@ def test_project_create_with_existing_resources(self, mcp_server, test_dataset_i "question_type": "BoundingBox", "required": True, "options": [{"option_name": "#FF0000"}], - "color": "#FF0000" + "color": "#FF0000", } - ] + ], } template_result = asyncio.run( @@ -153,10 +155,12 @@ def test_project_create_with_existing_resources(self, mcp_server, test_dataset_i "created_by": "test@example.com", "dataset_id": test_dataset_id, "annotation_template_id": template_id, - "autolabel": False + "autolabel": False, } - result = asyncio.run(mcp_server._handle_project_tool("project_create", project_args)) + result = asyncio.run( + mcp_server._handle_project_tool("project_create", project_args) + ) assert "response" in result assert "project_id" in result["response"] @@ -171,11 +175,13 @@ def test_project_update_rotation(self, mcp_server, test_project_id): "rotation_config": { "annotation_rotation_count": 2, "review_rotation_count": 1, - "client_review_rotation_count": 1 - } + "client_review_rotation_count": 1, + }, } - result = asyncio.run(mcp_server._handle_project_tool("project_update_rotation", args)) + result = asyncio.run( + mcp_server._handle_project_tool("project_update_rotation", args) + ) assert "response" in result or "message" in result print(f"✓ project_update_rotation: Updated rotations for {test_project_id}") @@ -185,6 +191,7 @@ def test_project_update_rotation(self, mcp_server, test_project_id): # Test Dataset Management Tools (5 tools) # ============================================================================= + class TestDatasetTools: """Test dataset management tools""" @@ -220,7 +227,7 @@ def test_dataset_create(self, mcp_server): def test_dataset_upload_files(self, mcp_server): """Test dataset_upload_files tool (requires test files)""" # This test is skipped if no test files are available - test_files_dir = os.getenv('LABELLERR_TEST_DATA_PATH') + test_files_dir = os.getenv("LABELLERR_TEST_DATA_PATH") if not test_files_dir or not os.path.exists(test_files_dir): pytest.skip("Test data path not provided") @@ -231,37 +238,37 @@ def test_dataset_upload_files(self, mcp_server): test_files = [ os.path.join(test_files_dir, f) for f in os.listdir(test_files_dir) - if f.lower().endswith(('.jpg', '.jpeg', '.png')) - ][:2] # Take first 2 files + if f.lower().endswith((".jpg", ".jpeg", ".png")) + ][ + :2 + ] # Take first 2 files if not test_files: pytest.skip("No image files found in test data path") - args = { - "files": test_files, - "data_type": "image" - } + args = {"files": test_files, "data_type": "image"} - result = asyncio.run(mcp_server._handle_dataset_tool("dataset_upload_files", args)) + result = asyncio.run( + mcp_server._handle_dataset_tool("dataset_upload_files", args) + ) assert "connection_id" in result or "response" in result print(f"✓ dataset_upload_files: Uploaded {len(test_files)} files") def test_dataset_upload_folder(self, mcp_server): """Test dataset_upload_folder tool (requires test folder)""" - test_folder = os.getenv('LABELLERR_TEST_DATA_PATH') + test_folder = os.getenv("LABELLERR_TEST_DATA_PATH") if not test_folder or not os.path.exists(test_folder): pytest.skip("Test data path not provided") import asyncio - args = { - "folder_path": test_folder, - "data_type": "image" - } + args = {"folder_path": test_folder, "data_type": "image"} - result = asyncio.run(mcp_server._handle_dataset_tool("dataset_upload_folder", args)) + result = asyncio.run( + mcp_server._handle_dataset_tool("dataset_upload_folder", args) + ) assert "connection_id" in result or "response" in result print(f"✓ dataset_upload_folder: Uploaded folder {test_folder}") @@ -271,6 +278,7 @@ def test_dataset_upload_folder(self, mcp_server): # Test Annotation Tools (6 tools) # ============================================================================= + class TestAnnotationTools: """Test annotation tools""" @@ -289,7 +297,7 @@ def test_template_create(self, mcp_server): "question_type": "BoundingBox", "required": True, "options": [{"option_name": "#00FF00"}], - "color": "#00FF00" + "color": "#00FF00", }, { "question_number": 2, @@ -300,17 +308,21 @@ def test_template_create(self, mcp_server): "options": [ {"option_name": "Good"}, {"option_name": "Fair"}, - {"option_name": "Poor"} - ] - } - ] + {"option_name": "Poor"}, + ], + }, + ], } - result = asyncio.run(mcp_server._handle_annotation_tool("template_create", args)) + result = asyncio.run( + mcp_server._handle_annotation_tool("template_create", args) + ) assert "response" in result assert "template_id" in result["response"] - print(f"✓ template_create: Created template {result['response']['template_id']}") + print( + f"✓ template_create: Created template {result['response']['template_id']}" + ) def test_annotation_export(self, mcp_server, test_project_id): """Test annotation_export tool""" @@ -321,11 +333,13 @@ def test_annotation_export(self, mcp_server, test_project_id): "export_name": f"MCP Test Export {uuid.uuid4().hex[:6]}", "export_description": "Created by MCP integration tests", "export_format": "json", - "statuses": ["accepted", "review"] + "statuses": ["accepted", "review"], } try: - result = asyncio.run(mcp_server._handle_annotation_tool("annotation_export", args)) + result = asyncio.run( + mcp_server._handle_annotation_tool("annotation_export", args) + ) assert "response" in result # May return report_id or job_id @@ -347,7 +361,7 @@ def test_annotation_check_export_status(self, mcp_server, test_project_id): "export_name": f"MCP Status Test {uuid.uuid4().hex[:6]}", "export_description": "Testing status check", "export_format": "json", - "statuses": ["accepted"] + "statuses": ["accepted"], } try: @@ -360,17 +374,18 @@ def test_annotation_check_export_status(self, mcp_server, test_project_id): pytest.skip("Export did not return report_id") # Check status - args = { - "project_id": test_project_id, - "export_ids": [report_id] - } + args = {"project_id": test_project_id, "export_ids": [report_id]} result = asyncio.run( - mcp_server._handle_annotation_tool("annotation_check_export_status", args) + mcp_server._handle_annotation_tool( + "annotation_check_export_status", args + ) ) assert "status" in result or "response" in result - print(f"✓ annotation_check_export_status: Checked status for export {report_id}") + print( + f"✓ annotation_check_export_status: Checked status for export {report_id}" + ) except Exception as e: if "No files found" in str(e): pytest.skip(f"Project has no annotated files - {e}") @@ -387,7 +402,7 @@ def test_annotation_download_export(self, mcp_server, test_project_id): "export_name": f"MCP Download Test {uuid.uuid4().hex[:6]}", "export_description": "Testing download", "export_format": "json", - "statuses": ["accepted"] + "statuses": ["accepted"], } try: @@ -403,10 +418,7 @@ def test_annotation_download_export(self, mcp_server, test_project_id): time.sleep(2) # Try to download - args = { - "project_id": test_project_id, - "export_id": report_id - } + args = {"project_id": test_project_id, "export_id": report_id} asyncio.run( mcp_server._handle_annotation_tool("annotation_download_export", args) @@ -434,6 +446,7 @@ def test_annotation_upload_preannotations_async(self, mcp_server, test_project_i # Test Monitoring Tools (4 tools) # ============================================================================= + class TestMonitoringTools: """Test monitoring tools""" @@ -441,7 +454,9 @@ def test_monitor_system_health(self, mcp_server): """Test monitor_system_health tool""" import asyncio - result = asyncio.run(mcp_server._handle_monitoring_tool("monitor_system_health", {})) + result = asyncio.run( + mcp_server._handle_monitoring_tool("monitor_system_health", {}) + ) assert "status" in result assert result["status"] == "healthy" @@ -457,7 +472,9 @@ def test_monitor_active_operations(self, mcp_server): assert "active_operations" in result assert isinstance(result["active_operations"], list) - print(f"✓ monitor_active_operations: {len(result['active_operations'])} active operations") + print( + f"✓ monitor_active_operations: {len(result['active_operations'])} active operations" + ) def test_monitor_project_progress(self, mcp_server, test_project_id): """Test monitor_project_progress tool""" @@ -483,6 +500,7 @@ def test_monitor_job_status(self, mcp_server): # Test Query Tools (4 tools) # ============================================================================= + class TestQueryTools: """Test query tools""" @@ -491,7 +509,9 @@ def test_query_project_statistics(self, mcp_server, test_project_id): import asyncio args = {"project_id": test_project_id} - result = asyncio.run(mcp_server._handle_query_tool("query_project_statistics", args)) + result = asyncio.run( + mcp_server._handle_query_tool("query_project_statistics", args) + ) assert "project_id" in result or "statistics" in result print(f"✓ query_project_statistics: Retrieved stats for {test_project_id}") @@ -511,18 +531,24 @@ def test_query_operation_history(self, mcp_server): import asyncio args = {"limit": 5} - result = asyncio.run(mcp_server._handle_query_tool("query_operation_history", args)) + result = asyncio.run( + mcp_server._handle_query_tool("query_operation_history", args) + ) assert "operations" in result assert isinstance(result["operations"], list) - print(f"✓ query_operation_history: Retrieved {len(result['operations'])} operations") + print( + f"✓ query_operation_history: Retrieved {len(result['operations'])} operations" + ) def test_query_search_projects(self, mcp_server): """Test query_search_projects tool""" import asyncio args = {"query": "test"} - result = asyncio.run(mcp_server._handle_query_tool("query_search_projects", args)) + result = asyncio.run( + mcp_server._handle_query_tool("query_search_projects", args) + ) assert "results" in result or "projects" in result print("✓ query_search_projects: Search completed") @@ -532,6 +558,7 @@ def test_query_search_projects(self, mcp_server): # Test Complete Workflow # ============================================================================= + class TestCompleteWorkflow: """Test complete end-to-end workflow using MCP tools""" @@ -539,9 +566,9 @@ def test_full_project_creation_workflow(self, mcp_server, test_dataset_id): """Test creating a complete project from scratch""" import asyncio - print("\n" + "="*80) + print("\n" + "=" * 80) print("COMPLETE WORKFLOW TEST: Dataset → Template → Project") - print("="*80) + print("=" * 80) # Step 1: Use existing dataset (creating requires file upload) print("\n[1/3] Using existing dataset...") @@ -561,9 +588,9 @@ def test_full_project_creation_workflow(self, mcp_server, test_dataset_id): "question_type": "BoundingBox", "required": True, "options": [{"option_name": "#FF0000"}], - "color": "#FF0000" + "color": "#FF0000", } - ] + ], } template_result = asyncio.run( @@ -580,7 +607,7 @@ def test_full_project_creation_workflow(self, mcp_server, test_dataset_id): "created_by": "test@example.com", "dataset_id": dataset_id, "annotation_template_id": template_id, - "autolabel": False + "autolabel": False, } project_result = asyncio.run( @@ -600,20 +627,21 @@ def test_full_project_creation_workflow(self, mcp_server, test_dataset_id): assert project_details["response"]["annotation_template_id"] == template_id print(" ✓ Project verified successfully!") - print("\n" + "="*80) + print("\n" + "=" * 80) print("WORKFLOW TEST COMPLETED SUCCESSFULLY!") - print("="*80 + "\n") + print("=" * 80 + "\n") # ============================================================================= # Test Summary # ============================================================================= + def test_summary(): """Print test summary""" - print("\n" + "="*80) + print("\n" + "=" * 80) print("MCP SERVER INTEGRATION TEST SUMMARY") - print("="*80) + print("=" * 80) print("\nTested 23 MCP Tools:") print("\n Project Management (4):") print(" ✓ project_list") @@ -643,7 +671,7 @@ def test_summary(): print(" ✓ query_dataset_info") print(" ✓ query_operation_history") print(" ✓ query_search_projects") - print("\n" + "="*80 + "\n") + print("\n" + "=" * 80 + "\n") if __name__ == "__main__": diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 91923eb..a216839 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -5,34 +5,17 @@ in isolation using mocks and fixtures. """ +from unittest.mock import Mock, patch + import pytest from pydantic import ValidationError +from labellerr.core.annotation_templates import LabellerrAnnotationTemplate +from labellerr.core.datasets import LabellerrDataset from labellerr.core.exceptions import LabellerrError from labellerr.core.projects import create_project -from labellerr.core.projects.image_project import ImageProject -from labellerr.core.users.base import LabellerrUsers from labellerr.core.schemas.projects import CreateProjectParams, RotationConfig -from labellerr.core.annotation_templates import LabellerrAnnotationTemplate -from labellerr.core.datasets import LabellerrDataset -from unittest.mock import Mock, patch - - -@pytest.fixture -def project(client): - """Create a test project instance without making API calls""" - # Create a mock ImageProject instance directly, bypassing the metaclass factory - project_data = { - "project_id": "test_project_id", - "data_type": "image", - "attached_datasets": [], - } - # Use __new__ to create instance without calling __init__ through metaclass - proj = ImageProject.__new__(ImageProject) - proj.client = client - proj._LabellerrProject__project_id_input = "test_project_id" - proj._LabellerrProject__project_data = project_data - return proj +from labellerr.core.users.base import LabellerrUsers @pytest.fixture diff --git a/tests/unit/test_projects.py b/tests/unit/test_projects.py new file mode 100644 index 0000000..25118e2 --- /dev/null +++ b/tests/unit/test_projects.py @@ -0,0 +1,73 @@ +""" +Unit tests for Labellerr project functionality. +""" + +from unittest.mock import patch + +import pytest + +from labellerr.core.exceptions import LabellerrError +from labellerr.core.schemas import ExportsListResponse + + +@pytest.mark.unit +class TestListExports: + """Test cases for list_exports method""" + + def test_list_exports_returns_completed_and_in_progress_exports(self, project): + """Test that list_exports correctly returns both completed and in-progress exports""" + mock_response = { + "response": { + "completed": [{"report_id": "export-123"}], + "inProgress": [{"report_id": "export-456"}], + } + } + + with patch.object(project.client, "make_request", return_value=mock_response): + result = project.list_exports() + + assert isinstance(result, ExportsListResponse) + assert len(result.completed) == 1 + assert len(result.in_progress) == 1 + assert result.completed[0]["report_id"] == "export-123" + assert result.in_progress[0]["report_id"] == "export-456" + + def test_list_exports_handles_empty_exports(self, project): + """Test that list_exports handles case when no exports exist""" + mock_response = {"response": {"completed": [], "inProgress": []}} + + with patch.object(project.client, "make_request", return_value=mock_response): + result = project.list_exports() + + assert result.completed == [] + assert result.in_progress == [] + + def test_list_exports_raises_error_for_invalid_response(self, project): + """Test that list_exports raises LabellerrError when API returns invalid response""" + with patch.object(project.client, "make_request", return_value=None): + with pytest.raises(LabellerrError) as exc_info: + project.list_exports() + + assert "No response received from list exports API" in str(exc_info.value) + + def test_list_exports_raises_error_when_response_key_missing(self, project): + """Test that list_exports raises LabellerrError when 'response' key is missing""" + mock_response = {"error": "something went wrong"} + + with patch.object(project.client, "make_request", return_value=mock_response): + with pytest.raises(LabellerrError) as exc_info: + project.list_exports() + + assert "List exports API failed: something went wrong" in str( + exc_info.value + ) + + def test_list_exports_handles_missing_optional_fields(self, project): + """Test that list_exports handles response with missing optional fields gracefully""" + mock_response = {"response": {}} + + with patch.object(project.client, "make_request", return_value=mock_response): + result = project.list_exports() + + assert result.completed == [] + assert result.in_progress == []