diff --git a/SDK_test.ipynb b/SDK_test.ipynb index 8ef6d1b..46d9d5f 100644 --- a/SDK_test.ipynb +++ b/SDK_test.ipynb @@ -2,17 +2,28 @@ "cells": [ { "cell_type": "code", - "execution_count": 6, + "execution_count": 1, "id": "171b00fb", "metadata": {}, "outputs": [], "source": [ "from labellerr.client import LabellerrClient\n", + "from dotenv import load_dotenv\n", + "import os\n", + "\n", + "# For Labellerr Dataset \n", "from labellerr.core.datasets import create_dataset_from_local, LabellerrDataset\n", + "from labellerr.core.schemas import DatasetConfig\n", + "\n", + "# For Annotation Template\n", "from labellerr.core.annotation_templates import create_template\n", - "from labellerr.core.projects import create_project\n", - "# from labellerr.core.schemas import * remove this code\n", - "from labellerr.core.schemas.annotation_templates import AnnotationQuestion, QuestionType, CreateTemplateParams\n", + "from labellerr.core.schemas.annotation_templates import AnnotationQuestion, QuestionType, CreateTemplateParams, DatasetDataType\n", + "# from template_helper import create_questions_from_prompts\n", + "import uuid\n", + "\n", + "# For labellerr Project\n", + "from labellerr.core.projects import create_project, LabellerrProject\n", + "from labellerr.core.schemas.projects import CreateProjectParams, RotationConfig\n", "\n", "import uuid\n" ] @@ -26,11 +37,11 @@ "source": [ "from dotenv import dotenv_values\n", "\n", - "config = dotenv_values(\".env\")\n", + "config = dotenv_values(\"../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\"]" ] }, { @@ -41,7 +52,9 @@ "outputs": [], "source": [ "# Initialize client\n", - "client = LabellerrClient(api_key=API_KEY, api_secret=API_SECRET, client_id=CLIENT_ID)\n" + "CLIENT = LabellerrClient(api_key=API_KEY, \n", + " api_secret=API_SECRET, \n", + " client_id=CLIENT_ID)" ] }, { @@ -51,56 +64,97 @@ "metadata": {}, "outputs": [], "source": [ - "img_dataset_path = r\"D:\\Professional\\GitHub\\LABIMP-8041\\sample_img_dataset\"" + "img_dataset_path = r\"D:\\Professional\\Labellerr_SDK\\sample_img\"" ] }, { "cell_type": "code", - "execution_count": 15, - "id": "cd8b2606", + "execution_count": 12, + "id": "f5e214a7", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Template created successfully with ID: 3979b246-b366-46e6-9120-13dee6542600\n" + ] + } + ], "source": [ - "# 1. Create dataset\n", + "template = create_template(\n", + " client=CLIENT,\n", + " params=CreateTemplateParams(\n", + " template_name=\"Test Template\",\n", + " data_type=DatasetDataType.image,\n", + " questions=[AnnotationQuestion(\n", + " question_number=1,\n", + " question=\"test question\",\n", + " question_id=str(uuid.uuid4()),\n", + " question_type=QuestionType.polygon,\n", + " required=True,\n", + " color=\"red\"\n", + " )]\n", + " )\n", + ")\n", "\n", + "if template.annotation_template_id is not None:\n", + " print(f\"Template created successfully with ID: {template.annotation_template_id}\")\n", + "else:\n", + " print(\"Failed to create template\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cd8b2606", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Waiting for dataset to be processed\n", + "Dataset processed\n", + "Dataset ID: 64d10546-a5d7-41ee-9a16-299eb09813de\n" + ] + } + ], + "source": [ "dataset = create_dataset_from_local(\n", - " client=client,\n", - " dataset_config=DatasetConfig(dataset_name=\"My Dataset\", data_type=\"image\"),\n", - " folder_to_upload=img_dataset_path\n", - ")\n" + " client=CLIENT,\n", + " dataset_config=DatasetConfig(dataset_name=\"test_dataset\", \n", + " data_type=\"image\"),\n", + " folder_to_upload=img_dataset_path,\n", + " )\n", + "\n", + "print(\"Waiting for dataset to be processed\")\n", + "dataset.status()\n", + "print(\"Dataset processed\")\n", + "\n", + "print(\"Dataset ID: \", dataset.dataset_id)\n", + "print(\"Files count: \", dataset.files_count)" ] }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 6, "id": "e25cd3c6", "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "'1c8b2a05-0321-44fd-91e3-2ea911382cf9'" + "112" ] }, - "execution_count": 16, + "execution_count": 6, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "dataset.dataset_id" - ] - }, - { - "cell_type": "raw", - "id": "f4f315e0", - "metadata": { - "vscode": { - "languageId": "raw" - } - }, - "source": [ - "'1c8b2a05-0321-44fd-91e3-2ea911382cf9'" + "dataset.files_count" ] }, { 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/utils.py b/labellerr/core/datasets/utils.py index cf1737d..791a138 100644 --- a/labellerr/core/datasets/utils.py +++ b/labellerr/core/datasets/utils.py @@ -5,8 +5,8 @@ from typing import List, Union from .. import client_utils, constants, gcs -from ..exceptions import LabellerrError from ..client import LabellerrClient +from ..exceptions import LabellerrError def get_total_folder_file_count_and_total_size(folder_path, data_type): diff --git a/labellerr/core/datasets/video_dataset.py b/labellerr/core/datasets/video_dataset.py index 6e8538e..a7ccf11 100644 --- a/labellerr/core/datasets/video_dataset.py +++ b/labellerr/core/datasets/video_dataset.py @@ -21,8 +21,8 @@ def download(self): print(f"# Starting batch video processing for dataset: {self.dataset_id}") print(f"{'#'*70}\n") - # Fetch all video files - video_files = self.fetch_files() + # Fetch all video files (convert generator to list) + video_files = list(self.fetch_files()) if not video_files: print("No video files found in dataset") diff --git a/labellerr/core/files/video_file.py b/labellerr/core/files/video_file.py index cb97956..88a0774 100644 --- a/labellerr/core/files/video_file.py +++ b/labellerr/core/files/video_file.py @@ -35,6 +35,11 @@ def total_frames(self): """Get total number of frames in the video.""" return self.metadata.get("total_frames", 0) + @property + def fps(self): + """Get frames per second of the video.""" + return self.metadata.get("fps", 25) + def get_frames(self, frame_start: int = 0, frame_end: int | None = None): """ Retrieve video frames data from Labellerr API. @@ -61,6 +66,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 +121,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 +220,11 @@ 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 and self.metadata.get("fps"): + output_file = f"{self.dataset_id}+{self.file_id}+{self.file_name}+FPS{self.metadata.get('fps')}.mp4" + else: + raise ValueError("output_file must be provided") # FFmpeg command command = [ @@ -235,7 +252,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 +275,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 +309,18 @@ 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]+FPS[fps] 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 and self.fps: + # Remove extension from file_name if present, then add FPS and .mp4 + base_name = os.path.splitext(self.file_name)[0] + video_filename = ( + f"{self.dataset_id}+{self.file_id}+{base_name}+FPS{self.fps}.mp4" + ) + else: + raise ValueError("dataset_id, file_name, and fps metadata are required") + 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 +337,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 +346,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 0ca1c87..e17e8ac 100644 --- a/labellerr/core/projects/__init__.py +++ b/labellerr/core/projects/__init__.py @@ -1,21 +1,22 @@ import json import uuid +from concurrent.futures import ThreadPoolExecutor +from typing import List import requests + from labellerr import LabellerrClient from .. import client_utils, constants, schemas +from ..annotation_templates import LabellerrAnnotationTemplate from ..datasets import LabellerrDataset from ..exceptions import LabellerrError from .audio_project import AudioProject as LabellerrAudioProject +from .base import LabellerrProject from .document_project import DocucmentProject as LabellerrDocumentProject from .image_project import ImageProject as LabellerrImageProject -from .video_project import VideoProject as LabellerrVideoProject from .text_project import TextProject as LabellerrTextProject -from .base import LabellerrProject -from ..annotation_templates import LabellerrAnnotationTemplate -from typing import List -from concurrent.futures import ThreadPoolExecutor +from .video_project import VideoProject as LabellerrVideoProject __all__ = [ "LabellerrProject", 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 1839463..4aecca2 100644 --- a/labellerr/core/schemas/__init__.py +++ b/labellerr/core/schemas/__init__.py @@ -14,18 +14,29 @@ # Import from autolabel.typings for backward compatibility from labellerr.core.autolabel.typings import * # noqa: F403, F401 +# Export annotation templates +from labellerr.core.schemas.annotation_templates import ( + AnnotationQuestion, + CreateTemplateParams, + Option, + QuestionType, +) + +# 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 +54,9 @@ UploadFilesParams, ) +# Export schemas +from labellerr.core.schemas.exports import CreateExportParams, ExportDestination + # File operation schemas from labellerr.core.schemas.files import BulkAssignFilesParams, ListFileParams @@ -50,7 +64,6 @@ from labellerr.core.schemas.projects import ( CreateLocalExportParams, CreateProjectParams, - CreateTemplateParams, Question, RotationConfig, ) @@ -141,4 +154,5 @@ "AnnotationQuestion", "Option", "QuestionType", + "CreateTemplateParams", ] 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/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/notebooks/SDK.ipynb b/labellerr/notebooks/SDK.ipynb index 1efd28f..dbb841f 100644 --- a/labellerr/notebooks/SDK.ipynb +++ b/labellerr/notebooks/SDK.ipynb @@ -5,25 +5,42 @@ "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": null, + "id": "be12bf3f", + "metadata": {}, + "outputs": [], + "source": [ + "# !pip install kagglehub ipywidgets" + ] + }, + { + "cell_type": "code", + "execution_count": 16, "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, CreateExportParams\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 +48,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 +62,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 +73,390 @@ "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[\"QA_API_KEY\"]\n", + "api_secret = config[\"QA_API_SECRET\"]\n", + "client_id = config[\"QA_CLIENT_ID\"]\n", + "email = config[\"QA_EMAIL\"]\n", "\n", - "api_key = config[\"API_KEY\"]\n", - "api_secret = config[\"API_SECRET\"]\n", - "client_id = config[\"CLIENT_ID\"]" + "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": "e05889d7", "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\"" + "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": 9, + "id": "52e00dbc", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 9, + "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": 10, + "id": "c1e2e2f3", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'es_multimodal_index': False,\n", + " 'metadata': {},\n", + " 'dataset_id': 'a0d93479-4667-4574-b781-a530a6a243b9',\n", + " 'origin': 'https://pro.labellerr.com',\n", + " 'created_at': 1766944223994,\n", + " 'description': '',\n", + " 'created_by': '21dbbb.d54ba24efab37ba6b6c6f58916',\n", + " 'client_id': '79',\n", + " 'tags': [],\n", + " 'data_type': 'video',\n", + " 'name': 'VIDEO_DATASET | size-2',\n", + " 'extraction_quality': ['normal'],\n", + " 'updated_at': 1766944307325,\n", + " 'progress': 'Processing 0/2 files',\n", + " 'files_count': 2,\n", + " 'status_code': 300,\n", + " 'video_processing_job_id': '5a4ef8c6-fb46-450a-b9ff-73bfe2f162a0',\n", + " 'es_index_status': 501,\n", + " 'video_processing_job': {'job_id': '5a4ef8c6-fb46-450a-b9ff-73bfe2f162a0',\n", + " 'status_code': 200,\n", + " 'updated_at': 1766944322254}}" + ] + }, + "execution_count": 10, + "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 | size-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": 11, + "id": "3d82b343", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "2" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "dataset.dataset_id\n", + "dataset.files_count" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5205f618", "metadata": {}, "outputs": [], "source": [ - "client = LabellerrClient(api_key, api_secret, client_id) \n", - "dataset = LabellerrDataset(client, dataset_id, project_id)" + "# dataset = LabellerrDataset(client=client,\n", + "# dataset_id='a0d93479-4667-4574-b781-a530a6a243b9')" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "d21ec493", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'es_multimodal_index': False,\n", + " 'metadata': {},\n", + " 'dataset_id': 'a0d93479-4667-4574-b781-a530a6a243b9',\n", + " 'origin': 'https://pro.labellerr.com',\n", + " 'created_at': 1766944223994,\n", + " 'description': '',\n", + " 'created_by': '21dbbb.d54ba24efab37ba6b6c6f58916',\n", + " 'client_id': '79',\n", + " 'tags': [],\n", + " 'data_type': 'video',\n", + " 'name': 'VIDEO_DATASET | size-2',\n", + " 'extraction_quality': ['normal'],\n", + " 'progress': 'Processing 0/2 files',\n", + " 'files_count': 2,\n", + " 'status_code': 300,\n", + " 'video_processing_job_id': '5a4ef8c6-fb46-450a-b9ff-73bfe2f162a0',\n", + " 'updated_at': 1766944676206,\n", + " 'status': 'none',\n", + " 'es_index_status': 300,\n", + " 'video_processing_job': {'job_id': '5a4ef8c6-fb46-450a-b9ff-73bfe2f162a0',\n", + " 'status_code': 300,\n", + " 'updated_at': 1766944676068}}" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "dataset.status()" + ] + }, + { + "cell_type": "markdown", + "id": "1a1a37ec", + "metadata": {}, + "source": [ + "### Create Labellerr Annotation Template" ] }, { "cell_type": "code", "execution_count": 5, - "id": "7b6a7052", + "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": 6, + "id": "7e281c33", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'85032be4-da4c-49d9-84a4-a24a2d6757ce'" + ] + }, + "execution_count": 6, + "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": 7, + "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": 8, + "id": "724c67cc", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'winifred_cute_bobcat_46128'" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "video_project.project_id" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f74cba85", + "metadata": {}, + "outputs": [], + "source": [ + "# video_project = LabellerrProject(client=client,\n", + "# project_id='winifred_cute_bobcat_46128')" + ] + }, + { + "cell_type": "markdown", + "id": "0d806eaf", + "metadata": {}, + "source": [ + "---\n", + "## ***Download Labellerr Indexed Dataset***" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "6558a9e9", "metadata": {}, "outputs": [ { @@ -134,71 +465,104 @@ "text": [ "\n", "######################################################################\n", - "# Starting batch video processing for dataset: 16257fd6-b91b-4d00-a680-9ece9f3f241c\n", + "# Starting batch video processing for dataset: a0d93479-4667-4574-b781-a530a6a243b9\n", "######################################################################\n", "\n", - "Total file IDs extracted: 1\n", "\n", - "Creating LabellerrFile instances for 1 files...\n", - "Successfully created 1 LabellerrFile instances\n", + "Processing 2 video files...\n", + "\n", + "\n", + "Starting download of 2 files...\n", + "\n", + "============================================================\n", + "Processing file: b50c92ff-4bfb-40f7-9752-0871428d65ce\n", + "============================================================\n", + "\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 389 frames...\n", + "Frames downloaded: 389/389 (389 successful, 0 failed)\n", "\n", - "Processing 1 video files...\n", + "[4/4] Creating video from frames...\n", + "Running command: ffmpeg -y -start_number 0 -framerate 30 -i ./Labellerr_datasets\\a0d93479-4667-4574-b781-a530a6a243b9+b50c92ff-4bfb-40f7-9752-0871428d65ce+seafood_1280p\\%d.jpg -c:v libx264 -pix_fmt yuv420p ./Labellerr_datasets\\a0d93479-4667-4574-b781-a530a6a243b9+b50c92ff-4bfb-40f7-9752-0871428d65ce+seafood_1280p+FPS29.mp4\n", + "Video saved as ./Labellerr_datasets\\a0d93479-4667-4574-b781-a530a6a243b9+b50c92ff-4bfb-40f7-9752-0871428d65ce+seafood_1280p+FPS29.mp4\n", "\n", + "Cleaning up temporary frames...\n", + "Removed temporary frames folder: ./Labellerr_datasets\\a0d93479-4667-4574-b781-a530a6a243b9+b50c92ff-4bfb-40f7-9752-0871428d65ce+seafood_1280p\n", "\n", - "Starting download of 1 files...\n", + "============================================================\n", + "Processing complete!\n", + "Video saved to: ./Labellerr_datasets\\a0d93479-4667-4574-b781-a530a6a243b9+b50c92ff-4bfb-40f7-9752-0871428d65ce+seafood_1280p+FPS29.mp4\n", + "============================================================\n", "\n", + "Files processed: 1/2 (1 successful, 0 failed)\n", "============================================================\n", - "Processing file: c44f38f6-0186-436f-8c2d-ffb50a539c76\n", + "Processing file: 9bf1ee94-ab41-435a-8935-38f6213b05f9\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 1572)...\n", + "Retrieved 1572 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 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_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\\a0d93479-4667-4574-b781-a530a6a243b9+9bf1ee94-ab41-435a-8935-38f6213b05f9+butterflies_960p\\%d.jpg -c:v libx264 -pix_fmt yuv420p ./Labellerr_datasets\\a0d93479-4667-4574-b781-a530a6a243b9+9bf1ee94-ab41-435a-8935-38f6213b05f9+butterflies_960p+FPS29.mp4\n", + "Video saved as ./Labellerr_datasets\\a0d93479-4667-4574-b781-a530a6a243b9+9bf1ee94-ab41-435a-8935-38f6213b05f9+butterflies_960p+FPS29.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\\a0d93479-4667-4574-b781-a530a6a243b9+9bf1ee94-ab41-435a-8935-38f6213b05f9+butterflies_960p\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\\a0d93479-4667-4574-b781-a530a6a243b9+9bf1ee94-ab41-435a-8935-38f6213b05f9+butterflies_960p+FPS29.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': 'b50c92ff-4bfb-40f7-9752-0871428d65ce',\n", + " 'dataset_id': 'a0d93479-4667-4574-b781-a530a6a243b9',\n", + " 'video_path': './Labellerr_datasets\\\\a0d93479-4667-4574-b781-a530a6a243b9+b50c92ff-4bfb-40f7-9752-0871428d65ce+seafood_1280p+FPS29.mp4',\n", + " 'output_folder': './Labellerr_datasets',\n", + " 'frames_downloaded': 389,\n", + " 'frames_failed': 0,\n", + " 'failed_frames_info': []},\n", + " {'status': 'success',\n", + " 'file_id': '9bf1ee94-ab41-435a-8935-38f6213b05f9',\n", + " 'dataset_id': 'a0d93479-4667-4574-b781-a530a6a243b9',\n", + " 'video_path': './Labellerr_datasets\\\\a0d93479-4667-4574-b781-a530a6a243b9+9bf1ee94-ab41-435a-8935-38f6213b05f9+butterflies_960p+FPS29.mp4',\n", + " 'output_folder': './Labellerr_datasets',\n", + " 'frames_downloaded': 1572,\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 +570,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 +595,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": 33, + "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,84 +633,129 @@ "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": 8, "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": 9, "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": 10, "id": "a3052f25", "metadata": {}, "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO:pyscenedetect:Detecting scenes...\n" + ] + }, { "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: a0d93479-4667-4574-b781-a530a6a243b9+9bf1ee94-ab41-435a-8935-38f6213b05f9+butterflies_960p+FPS29.mp4\n", + "----------------------------------------------------------------------\n", + "Detecting scene changes...\n", + "Detected 4 scene changes\n", + "Total frames in video: 1572\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "INFO:pyscenedetect:Detecting scenes...\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Extracting first frame (frame 0)...\n", + "Successfully extracted 5 frames to pyscene_detect\n", + "✓ Successfully extracted 5 frames\n", + "\n", + "[2/2] Processing: a0d93479-4667-4574-b781-a530a6a243b9+b50c92ff-4bfb-40f7-9752-0871428d65ce+seafood_1280p+FPS29.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": 11, + "id": "c83cf401", "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'.\\\\pyscene_detect'" + ] + }, + "execution_count": 11, + "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,358 +763,366 @@ "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": 12, + "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": 13, - "id": "1b364362", + "id": "d530faed", "metadata": {}, "outputs": [ { - "name": "stdout", - "output_type": "stream", - "text": [ - "Found 52 image files\n" - ] + "data": { + "text/plain": [ + "'3c5f6f14-66b7-4fca-b2ad-3a6b9bf0901e'" + ] + }, + "execution_count": 13, + "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": null, + "id": "e0793ba6", + "metadata": {}, + "outputs": [], + "source": [ + "# dataset = LabellerrDataset(client=client,\n", + "# dataset_id='3c5f6f14-66b7-4fca-b2ad-3a6b9bf0901e')" + ] + }, + { + "cell_type": "markdown", + "id": "756764dd", + "metadata": {}, + "source": [ + "### Create Annotation template of Keyframe Image Project" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "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": 16, + "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']" + "'42951455-12a1-44b6-8be9-6a287e676b74'" ] }, - "execution_count": 14, + "execution_count": 16, "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": 17, + "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": 18, + "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'" + "'melanie_external_perch_91510'" ] }, - "execution_count": 16, + "execution_count": 18, "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": null, + "id": "55c824a7", "metadata": {}, "outputs": [], "source": [ - "new_dataset_id = '6a680901-fe81-49f0-9120-bb754d63a341'" + "# img_project = LabellerrProject(client=client,\n", + "# project_id='melanie_external_perch_91510')" ] }, { "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": "markdown", + "id": "85d88826", + "metadata": {}, + "source": [ + "### Create Export" ] }, { "cell_type": "code", "execution_count": null, - "id": "b71d2aa0", + "id": "ccf0e882", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "{'status': [{'report_id': 'FsxNCB26Q31KBBOqpUw5',\n", + " 'export_status': 'Created',\n", + " 'is_completed': True,\n", + " 'download_url': {'url': 'https://storage.googleapis.com/labellerr-export-dev/6b18c5db-4af3-4f8f-89b2-67d3d0bbf16b.json?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=local-dev%40labellerrdev.iam.gserviceaccount.com%2F20251229%2Fauto%2Fstorage%2Fgoog4_request&X-Goog-Date=20251229T104644Z&X-Goog-Expires=3600&X-Goog-SignedHeaders=host&response-content-type=json&response-content-disposition=attachment%3B%20filename%3D%22export-%23FsxNCB26Q31KBBOqpUw5.json%22&X-Goog-Signature=97171d6082ac7d0184d73fe0a65227676b13731997b23e373d8f28ff2bb76ea8eddcbbb6398030937abe7575fd4cd246ed7e0c474e2c597726057d06779bc113de7876d0dbaba573a714b65f1bf287dc9a8f1d4905e1537341ad58089c60cf3403dffbd2ef7eae54d43b3ce8440297a5274159a44a170d8f64cbba6bd48ca0176647aa997f53be89e56ae65c65b517625f260b5fc8ea888afb85495181d1474afd816b92b3ad79d3e3d5e4cc57f53aa279c2dd0e347b33aa863b0b8e53c5d3f5019f46aeed832af9bbb9f5b7cf70a3706ff2ea8c2af45e5b73e3b0092dd53fa2be617b7a6061509812d747bb332c0e1d260544788089d68e234f49b218f8bdce',\n", + " 'expires_at': 1767008804304}}]}" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ - "# creeate the annotation guideline template\n", + "export_config = CreateExportParams(\n", + " export_name=\"TEST Export\",\n", + " export_description=\"Export of all accepted annotations\",\n", + " export_format=\"coco_json\",\n", + " statuses=['review', 'r_assigned','client_review', 'cr_assigned','accepted']\n", + " )\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" + "export = img_project.create_local_export(export_config)\n", + "\n", + "response_data = export.status()" ] }, { "cell_type": "code", - "execution_count": null, - "id": "83565ec3", + "execution_count": 18, + "id": "220eaf7a", "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "'FsxNCB26Q31KBBOqpUw5'" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ - "# create the image annotation project\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" + "export.report_id" ] }, { "cell_type": "code", - "execution_count": 31, - "id": "9f682f4f", + "execution_count": 30, + "id": "15e63494", "metadata": {}, "outputs": [ { - "name": "stdout", - "output_type": "stream", - "text": [ - "Project created successfully!\n", - "Project ID: sherri_puny_rattlesnake_84247\n" - ] + "data": { + "text/plain": [ + "[{'report_id': 'FsxNCB26Q31KBBOqpUw5',\n", + " 'export_status': 'Created',\n", + " 'is_completed': True,\n", + " 'download_url': {'url': 'https://storage.googleapis.com/labellerr-export-dev/6b18c5db-4af3-4f8f-89b2-67d3d0bbf16b.json?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=local-dev%40labellerrdev.iam.gserviceaccount.com%2F20251229%2Fauto%2Fstorage%2Fgoog4_request&X-Goog-Date=20251229T110322Z&X-Goog-Expires=3600&X-Goog-SignedHeaders=host&response-content-type=json&response-content-disposition=attachment%3B%20filename%3D%22export-%23FsxNCB26Q31KBBOqpUw5.json%22&X-Goog-Signature=d43ea1543020fa538b6d81383061d8e96f27ed301f47b7be24324dcdfd1993d9ddf54bbca9d20790b058897264c871625952e31201822895b781abf60ace05c273b0424e4546f837e955e0ee8330b9574b0bb7ae4738a89358776471b647ff2975900df0470065a5861a66be7c7b8f3f4cd3cd8548a34dac507767857e9a4f555f4ac50c6e6387860fec898ee0675c11a76018f3dc54bfc9729485d149d70f12cf1ae27c7a2a2f83ac31137aa8efecd3903f3b8046fa6ddb2e821e7a97c4d2be69a48a61ae2e476fb3cf0b77ac112c6661e062840eab66576aaf74fc24c0d6fd8059ea264629fb792b9dc4cdef528f6626d24e31aefb52a3a1e92d520780f638',\n", + " 'expires_at': 1767009802274}}]" + ] + }, + "execution_count": 30, + "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']" + "response_data['status']" ] }, { "cell_type": "markdown", - "id": "8645aa60", + "id": "8f0611f5", "metadata": {}, "source": [ - "## 6. Performing Annotations of Image Project" + "### Download the Annotation" ] }, { "cell_type": "code", - "execution_count": null, - "id": "d6eea565", + "execution_count": 31, + "id": "1d5455c3", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "✓ Downloaded: export_FsxNCB26Q31KBBOqpUw5.json\n", + "Export saved at: export_FsxNCB26Q31KBBOqpUw5.json\n" + ] + } + ], "source": [ - "# annotations of image project on labellerr platform" + "download_url = response_data['status'][0]['download_url']['url']\n", + "\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}\")" ] }, { "cell_type": "markdown", - "id": "8f0611f5", + "id": "18529760", "metadata": {}, "source": [ - "### Exporting the Annotation Data" + "---\n", + "## ***Uploading KeyFrames Pre-Annotation to Video Project***" ] }, { - "cell_type": "code", - "execution_count": null, - "id": "b78ad296", + "cell_type": "markdown", + "id": "5c5ed594", "metadata": {}, - "outputs": [], "source": [ - "# code to export the annotations from image project\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", - ")" + "### Converting Annotation JSON to required format" ] }, { - "cell_type": "markdown", - "id": "18529760", + "cell_type": "code", + "execution_count": 34, + "id": "14b59cef", "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Video JSON saved to: Video_Keyframe_annot.json\n" + ] + } + ], "source": [ - "## 7. Uploading annotations to Video Project" + "video_annotations = coco_to_video_json(export_json_path)" ] }, { @@ -690,25 +1130,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": 36, "id": "df6b3ac7", "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'message': '200: Success', 'response': {'job_type': 'pre-annotations', 'metadata': {'files_not_updated': [], 'videos_processed': [{'file_name': 'butterflies_960p.mp4', 'frames_processed': 15, 'status': 'success', 'file_id': '9bf1ee94-ab41-435a-8935-38f6213b05f9', 'total_annotations': 15}, {'file_id': 'b50c92ff-4bfb-40f7-9752-0871428d65ce', 'file_name': 'seafood_1280p.mp4', 'frames_processed': 8, 'total_annotations': 8, 'status': 'success'}], 'activity_id': '772a2520-e994-40c3-b512-820ada998af8', 'questions_ignored': []}, 'project_id': 'winifred_cute_bobcat_46128', 'updated_at': 1767006996651, 'status': 'completed', 'activity_id': '772a2520-e994-40c3-b512-820ada998af8', 'created_by': '21dbbb.d54ba24efab37ba6b6c6f58916', 'created_at': 1767006844798, 'job_id': '772a2520-e994-40c3-b512-820ada998af8'}, '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 +1173,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.18" + "version": "3.12.0" } }, "nbformat": 4, diff --git a/labellerr/notebooks/test_coco_to_video.py b/labellerr/notebooks/test_coco_to_video.py new file mode 100644 index 0000000..48603bb --- /dev/null +++ b/labellerr/notebooks/test_coco_to_video.py @@ -0,0 +1,28 @@ +""" +Test script to convert COCO JSON export to Video JSON format. +Tests the import from labellerr.services.video_sampling module. +""" + +from labellerr.services.video_sampling import coco_to_video_json + +# Input COCO JSON file path +coco_json_path = r"D:\Professional\Labellerr_SDK\SDKPython\labellerr\notebooks\export-#huy0VWY14med4McdKd6h.json" + +# Output Video JSON file path +output_path = r"D:\Professional\Labellerr_SDK\SDKPython\labellerr\notebooks\video_keyframe_annotations.json" + +# Convert COCO to Video JSON format +# FPS will be extracted from filenames if available +# Falls back to default_fps=25 if not found in filename +video_annotations = coco_to_video_json( + coco_json_path=coco_json_path, output_path=output_path, default_fps=25 +) + +print(f"\n✅ Conversion complete!") +print(f"📁 Output saved to: {output_path}") +print(f"📊 Total videos processed: {len(video_annotations)}") + +# Print summary for each video +for video in video_annotations: + print(f"\n 🎬 {video['file_name']}") + print(f" Annotations: {len(video['annotations'])}") diff --git a/labellerr/notebooks/test_preannotation_api.py b/labellerr/notebooks/test_preannotation_api.py new file mode 100644 index 0000000..e10a8e8 --- /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 = "caryl_geographical_turkey_21445" +VIDEO_JSON_FILE_PATH = r"D:\Professional\Labellerr_SDK\SDKPython\labellerr\notebooks\video_keyframe_annotations.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..31006cf 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, int]: + """ + Extract video name, frame number, and FPS from keyframe filename. + + Format: {dataset_id}+{file_id}+{video_name}+FPS{fps}+frame_{frame_number}.jpg + Example: 15908795-09eb-4cdb-a39b-8689f8f936e5+471163aa-19dc-4bc7-9aee-04780591281a+butterflies_960p+FPS29+frame_1064.jpg + Returns: ("butterflies_960p.mp4", 1064, 29) + + Args: + filename: The keyframe filename + + Returns: + Tuple of (video_name, frame_number, fps) + + 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 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 FPS from the parts (format: FPS{number}) + fps = 25 # Default FPS + video_name_part = None + + for i, part in enumerate(parts): + fps_match = re.match(r"FPS(\d+)$", part) + if fps_match: + fps = int(fps_match.group(1)) + # Video name is the part before FPS + if i > 0: + video_name_part = parts[i - 1] + break + + # If no FPS found, use the second-to-last part as video name (old format) + if video_name_part is None: + video_name_part = parts[-2] + + video_name = f"{video_name_part}.mp4" + + return video_name, frame_number, fps + + +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", + default_fps: int = 25, +) -> 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 + default_fps: Default frames per second if not found in filename (default: 25) + + 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, frame number, and FPS + try: + video_name, frame_number, fps = _extract_video_name_and_frame( + image["file_name"] + ) + except ValueError as e: + print(f"Warning: Skipping annotation - {e}") + fps = default_fps # Use default if extraction fails + 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"] = [] + + # Create a new answer group for each annotation + # This allows multiple annotations on the same frame + new_answer_group = {"startFrame": frame_number, "frames": {}} + + # Add frame data + frame_data = { + "frame": frame_number, + "answer": answer_data, + "isManualAnnotation": True, + "fps": fps, + } + + new_answer_group["frames"][str(frame_number)] = frame_data + video_annotations[video_key]["annotations"][question_key]["answer"].append( + new_answer_group + ) + + # 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}")