Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,4 @@ tests/manual/data
README.roboflow.txt
*.zip
.DS_Store
.claude
115 changes: 115 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Development Commands

### Running Tests
```bash
python -m unittest
```

### Linting and Code Quality
```bash
# Format code with ruff
make style

# Check code quality (includes ruff and mypy)
make check_code_quality

# Individual commands
ruff format roboflow
ruff check roboflow --fix
mypy roboflow
```

### Building Documentation
```bash
# Install documentation dependencies
python -m pip install mkdocs mkdocs-material mkdocstrings mkdocstrings[python]

# Serve documentation locally
mkdocs serve
```

### Installing Development Environment
```bash
# Create virtual environment
python3 -m venv env
source env/bin/activate

# Install in editable mode with dev dependencies
pip install -e ".[dev]"

# Install pre-commit hooks
pip install pre-commit
pre-commit install
```

## Architecture Overview

The Roboflow Python SDK follows a hierarchical object model that mirrors the Roboflow platform structure:

### Core Components

1. **Roboflow** (`roboflow/__init__.py`) - Entry point and authentication
- Handles API key management and workspace initialization
- Provides `login()` for CLI authentication
- Creates workspace connections

2. **Workspace** (`roboflow/core/workspace.py`) - Manages Roboflow workspaces
- Lists and accesses projects
- Handles dataset uploads and model deployments
- Manages workspace-level operations

3. **Project** (`roboflow/core/project.py`) - Represents a computer vision project
- Manages project metadata and versions
- Handles image/annotation uploads
- Supports different project types (object-detection, classification, etc.)

4. **Version** (`roboflow/core/version.py`) - Dataset version management
- Downloads datasets in various formats
- Deploys models
- Provides access to trained models for inference

5. **Model Classes** (`roboflow/models/`) - Type-specific inference models
- `ObjectDetectionModel` - Bounding box predictions
- `ClassificationModel` - Image classification
- `InstanceSegmentationModel` - Pixel-level segmentation
- `SemanticSegmentationModel` - Class-based segmentation
- `KeypointDetectionModel` - Keypoint predictions

### API Adapters

- **rfapi** (`roboflow/adapters/rfapi.py`) - Low-level API communication
- **deploymentapi** (`roboflow/adapters/deploymentapi.py`) - Model deployment operations

### CLI Interface

The `roboflow` command line tool (`roboflow/roboflowpy.py`) provides:
- Authentication: `roboflow login`
- Dataset operations: `roboflow download`, `roboflow upload`, `roboflow import`
- Inference: `roboflow infer`
- Project/workspace management: `roboflow project`, `roboflow workspace`

### Key Design Patterns

1. **Hierarchical Access**: Always access objects through their parent (Workspace → Project → Version → Model)
2. **API Key Flow**: API key is passed down through the object hierarchy
3. **Format Flexibility**: Supports multiple dataset formats (YOLO, COCO, Pascal VOC, etc.)
4. **Batch Operations**: Upload and download operations support concurrent processing

## Project Configuration

- **Python Version**: 3.8+
- **Main Dependencies**: See `requirements.txt`
- **Entry Point**: `roboflow=roboflow.roboflowpy:main`
- **Code Style**: Enforced by ruff with Google docstring convention
- **Type Checking**: mypy configured for Python 3.8

## Important Notes

- API keys are stored in `~/.config/roboflow/config.json` (Unix) or `~/roboflow/config.json` (Windows)
- The SDK supports both hosted inference (Roboflow platform) and local inference (via Roboflow Inference)
- Pre-commit hooks automatically run formatting and linting checks
- Test files intentionally excluded from linting: `tests/manual/debugme.py`
2 changes: 1 addition & 1 deletion roboflow/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from roboflow.models import CLIPModel, GazeModel # noqa: F401
from roboflow.util.general import write_line

__version__ = "1.1.64"
__version__ = "1.1.65"


def check_key(api_key, model, notebook, num_retries=0):
Expand Down
15 changes: 10 additions & 5 deletions roboflow/core/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,10 +301,11 @@ def upload_dataset(
""" # noqa: E501 // docs
if dataset_format != "NOT_USED":
print("Warning: parameter 'dataset_format' is deprecated and will be removed in a future release")
parsed_dataset = folderparser.parsefolder(dataset_path)
project, created = self._get_or_create_project(
project_id=project_name, license=project_license, type=project_type
)
is_classification = project.type == "classification"
parsed_dataset = folderparser.parsefolder(dataset_path, is_classification=is_classification)
if created:
print(f"Created project {project.id}")
else:
Expand Down Expand Up @@ -361,15 +362,19 @@ def _save_annotation(image_id, imagedesc):

annotationdesc = imagedesc.get("annotationfile")
if isinstance(annotationdesc, dict):
if annotationdesc.get("rawText"):
if annotationdesc.get("type") == "classification_folder":
annotation_path = annotationdesc.get("classification_label")
elif annotationdesc.get("rawText"):
annotation_path = annotationdesc
else:
elif annotationdesc.get("file"):
annotation_path = f"{location}{annotationdesc['file']}"
labelmap = annotationdesc.get("labelmap")
labelmap = annotationdesc.get("labelmap")

if isinstance(labelmap, str):
labelmap = load_labelmap(labelmap)
else:

# If annotation_path is still None at this point, then no annotation will be saved.
if annotation_path is None:
return None, None

annotation, upload_time, _retry_attempts = project.save_annotation(
Expand Down
17 changes: 16 additions & 1 deletion roboflow/util/folderparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def _patch_sep(filename):
return filename.replace("\\", "/")


def parsefolder(folder):
def parsefolder(folder, is_classification=False):
folder = _patch_sep(folder).strip().rstrip("/")
if not os.path.exists(folder):
raise Exception(f"folder does not exist. {folder}")
Expand All @@ -36,6 +36,8 @@ def parsefolder(folder):
if not _map_annotations_to_images_1to1(images, annotations):
annotations = _loadAnnotations(folder, annotations)
_map_annotations_to_images_1tomany(images, annotations)
if is_classification:
_infer_classification_labels_from_folders(images)
return {
"location": folder,
"images": images,
Expand Down Expand Up @@ -299,3 +301,16 @@ def _list_map(my_list, key):
for i in my_list:
d.setdefault(i[key], []).append(i)
return d


def _infer_classification_labels_from_folders(images):
for image in images:
if image.get("annotationfile"):
continue
dirname = image.get("dirname", "").strip("/")
if not dirname or dirname == ".":
# Skip images in root directory or invalid paths
continue
class_name = os.path.basename(dirname)
if class_name and class_name != ".":
image["annotationfile"] = {"classification_label": class_name, "type": "classification_folder"}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Synthetic Corrosion Dataset > 2022-08-16 10:23am
https://universe.roboflow.com/classification/synthetic-corrosion-dataset

Provided by Roboflow
License: CC BY 4.0
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
88 changes: 88 additions & 0 deletions tests/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -454,3 +454,91 @@ def test_project_upload_dataset(self):
finally:
for mock in mocks.values():
mock.stop()

def test_classification_dataset_upload(self):
from roboflow.util import folderparser

classification_folder = "tests/datasets/corrosion-singlelabel-classification"
# Parse with classification flag to get inferred annotations
parsed_dataset = folderparser.parsefolder(classification_folder, is_classification=True)

# Create a mock project with classification type
self.project.type = "classification"
annotation_calls = []

def capture_annotation_calls(annotation_path, **kwargs):
annotation_calls.append({"annotation_path": annotation_path, "image_id": kwargs.get("image_id")})
return ({"success": True}, 0.1, 0)

mocks = {
"parser": patch("roboflow.core.workspace.folderparser.parsefolder", return_value=parsed_dataset),
"upload": patch(
"roboflow.core.workspace.Project.upload_image",
return_value=({"id": "test-id", "success": True}, 0.1, 0),
),
"save_annotation": patch(
"roboflow.core.workspace.Project.save_annotation", side_effect=capture_annotation_calls
),
"get_project": patch(
"roboflow.core.workspace.Workspace._get_or_create_project", return_value=(self.project, False)
),
}
mock_objects = {}
for name, mock in mocks.items():
mock_objects[name] = mock.start()
try:
self.workspace.upload_dataset(dataset_path=classification_folder, project_name=PROJECT_NAME, num_workers=1)
self.assertEqual(mock_objects["upload"].call_count, 10)
self.assertEqual(len(annotation_calls), 10)

corrosion_count = sum(1 for call in annotation_calls if call["annotation_path"] == "Corrosion")
no_corrosion_count = sum(1 for call in annotation_calls if call["annotation_path"] == "no-corrosion")
self.assertEqual(corrosion_count, 5)
self.assertEqual(no_corrosion_count, 5)

for call in annotation_calls:
self.assertIn(call["annotation_path"], ["Corrosion", "no-corrosion"])
finally:
for mock in mocks.values():
mock.stop()

def test_classification_edge_cases(self):
edge_case_dataset = [
# These should not get annotations
{"file": "root_img.jpg", "split": "train", "dirname": "/"},
{"file": "dot_img.jpg", "split": "train", "dirname": "/."},
# These should get annotations from folder structure
{
"file": "nested.jpg",
"split": "train",
"dirname": "/train/defects/rust/severe",
"annotationfile": {"type": "classification_folder", "classification_label": "severe"},
},
{
"file": "normal.jpg",
"split": "train",
"dirname": "/train/good",
"annotationfile": {"type": "classification_folder", "classification_label": "good"},
},
]
self.project.type = "classification"
annotation_calls = []

def capture_annotation_calls(annotation_path, **kwargs):
annotation_calls.append(annotation_path)
return ({"success": True}, 0.1, 0)

test_dataset = self._create_test_dataset(edge_case_dataset)
mocks = self._setup_upload_dataset_mocks(
test_dataset=test_dataset, save_annotation_side_effect=capture_annotation_calls
)
for mock in mocks.values():
mock.start()
try:
self.workspace.upload_dataset(dataset_path="/test/dataset", project_name=PROJECT_NAME, num_workers=1)
self.assertEqual(len(annotation_calls), 2)
self.assertIn("severe", annotation_calls)
self.assertIn("good", annotation_calls)
finally:
for mock in mocks.values():
mock.stop()
20 changes: 20 additions & 0 deletions tests/util/test_folderparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,26 @@ def test_paligemma_format(self):
)
assert testImage["annotationfile"]["rawText"] == expected

def test_parse_classification_folder_structure(self):
classification_folder = f"{thisdir}/../datasets/corrosion-singlelabel-classification"
parsed = folderparser.parsefolder(classification_folder, is_classification=False)
for img in parsed["images"]:
self.assertIsNone(img.get("annotationfile"))

parsed_classification = folderparser.parsefolder(classification_folder, is_classification=True)
corrosion_images = [i for i in parsed_classification["images"] if "Corrosion" in i["dirname"]]
self.assertTrue(len(corrosion_images) > 0)
for img in corrosion_images:
self.assertIsNotNone(img.get("annotationfile"))
self.assertEqual(img["annotationfile"]["type"], "classification_folder")
self.assertEqual(img["annotationfile"]["classification_label"], "Corrosion")
no_corrosion_images = [i for i in parsed_classification["images"] if "no-corrosion" in i["dirname"]]
self.assertTrue(len(no_corrosion_images) > 0)
for img in no_corrosion_images:
self.assertIsNotNone(img.get("annotationfile"))
self.assertEqual(img["annotationfile"]["type"], "classification_folder")
self.assertEqual(img["annotationfile"]["classification_label"], "no-corrosion")


def _assertJsonMatchesFile(actual, filename):
with open(filename) as file:
Expand Down