Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 88 additions & 34 deletions SDK_test.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
Expand All @@ -26,11 +37,11 @@
"source": [
"from dotenv import dotenv_values\n",
"\n",
"config = dotenv_values(\".env\")\n",
"config = dotenv_values(\"../dev.env\")\n",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Quality Issue: The notebook contains hardcoded QA credentials in the config keys (QA_API_KEY, QA_API_SECRET, QA_CLIENT_ID). While these are loaded from env file, it suggests this notebook is configured for QA environment which shouldn't be in the main branch.

Recommendation: Use generic key names or document that this is a development/testing notebook.

"\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\"]"
]
},
{
Expand All @@ -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)"
]
},
{
Expand All @@ -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"
]
},
{
Expand Down
4 changes: 2 additions & 2 deletions labellerr/core/constants.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
BASE_URL = "https://api.labellerr.com"
BASE_URL = "https://api-gateway-qcb3iv2gaa-uc.a.run.app"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical: Production URL Changed to QA Environment

This changes the base URL from production (https://api.labellerr.com) to what appears to be a QA/staging environment.

Issues:

  1. All SDK users will hit the QA environment instead of production
  2. This breaks existing production integrations
  3. No environment-based configuration

Recommendation:

import os

BASE_URL = os.getenv("LABELLERR_BASE_URL", "https://api.labellerr.com")

Or use a proper config system that allows override for testing while defaulting to production.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical: Hardcoded QA Environment URL

This changes the BASE_URL to a QA environment in production code. This should:

  1. Be reverted to the production URL
  2. Use environment variables for different environments
  3. Have proper configuration management
Suggested change
BASE_URL = "https://api-gateway-qcb3iv2gaa-uc.a.run.app"
BASE_URL = os.getenv("LABELLERR_BASE_URL", "https://api.labellerr.com")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL SECURITY ISSUE: The BASE_URL is hardcoded to a QA/staging environment (api-gateway-qcb3iv2gaa-uc.a.run.app). This should NOT be committed to the main branch as it will affect all production users.

Recommendation:

  • Revert this to the production URL (https://api.labellerr.com)
  • Use environment variables or configuration files for environment-specific URLs
  • Add a clear warning comment if this is intentionally for testing

ALLOWED_ORIGINS = "https://pro.labellerr.com"


Expand All @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion labellerr/core/datasets/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
4 changes: 2 additions & 2 deletions labellerr/core/datasets/video_dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Performance: Generator Converted to List

Converting the generator to a list loads all video files into memory. For large datasets, this could cause memory issues.

Consider:

  1. Keep as generator and process in batches
  2. Add pagination support
  3. Stream process the files
# Process in batches
for video_file in self.fetch_files():
    # Process one at a time
    video_file.download_create_video_auto_cleanup()


if not video_files:
print("No video files found in dataset")
Expand Down
78 changes: 57 additions & 21 deletions labellerr/core/files/video_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential Bug: Using a hardcoded default FPS of 25 could cause issues. If the actual video has different FPS and this default is used in calculations, it will result in incorrect frame timing.

Recommendation: Consider raising an error if FPS is not available in metadata rather than silently falling back to a default, or at minimum log a warning.


def get_frames(self, frame_start: int = 0, frame_end: int | None = None):
"""
Retrieve video frames data from Labellerr API.
Expand All @@ -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(
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good Improvement: Better Folder Naming

Good enhancement to use descriptive folder names with dataset+file+name structure. This makes it much easier to identify videos in the file system.

Minor suggestion: Consider documenting this naming convention in the class docstring or module documentation for users.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Quality Issue: The folder naming logic is overly complex and repeated in multiple places (lines 124-132, 292-298, 315-322, 359-363).

Recommendation: Extract this into a private method:

def _get_folder_name(self) -> str:
    if self.dataset_id and self.file_name:
        base_name = os.path.splitext(self.file_name)[0]
        return f"{self.dataset_id}+{self.file_id}+{base_name}"
    elif self.dataset_id:
        return f"{self.dataset_id}+{self.file_id}"
    else:
        return self.file_id

This reduces code duplication and makes maintenance easier.

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:
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good Fix: Typo Corrected

Nice catch fixing the typo from "datastets" to "datasets" in the default parameter!

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in default path: "Labellerr_datastets" should be "Labellerr_datasets" (missing 'a').

):
"""
Download frames, create video, and automatically clean up temporary frames.
Expand All @@ -258,36 +275,52 @@ 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:
print(
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:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐛 Potential Bug: String Formatting Error

Line 316 has incorrect string literal - the closing brace should be outside the string:

# Current (incorrect):
print("{'='*60}\n")  # This prints: {'='*60}

# Should be:
print(f"{'='*60}\n")  # This prints: ====================...

Or simply:

print(f"{'='*60}\n")

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
Expand All @@ -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"],
Expand All @@ -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)
Expand Down
Loading
Loading