diff --git a/docs/assets/spotlight_demo_screenshot.png b/docs/assets/spotlight_demo_screenshot.png new file mode 100644 index 000000000..0c60e3ecf Binary files /dev/null and b/docs/assets/spotlight_demo_screenshot.png differ diff --git a/docs/assets/spotlight_demo_screenshot1.png b/docs/assets/spotlight_demo_screenshot1.png new file mode 100644 index 000000000..1c2442260 Binary files /dev/null and b/docs/assets/spotlight_demo_screenshot1.png differ diff --git a/docs/assets/spotlight_demo_screenshot2.png b/docs/assets/spotlight_demo_screenshot2.png new file mode 100644 index 000000000..1dac104a0 Binary files /dev/null and b/docs/assets/spotlight_demo_screenshot2.png differ diff --git a/docs/getting_started/index.md b/docs/getting_started/index.md index 8a26e131b..bbf0a884b 100644 --- a/docs/getting_started/index.md +++ b/docs/getting_started/index.md @@ -90,4 +90,5 @@ install overview intro_tutorials/index comparison +spotlight ``` diff --git a/docs/getting_started/spotlight.md b/docs/getting_started/spotlight.md new file mode 100644 index 000000000..54512e81e --- /dev/null +++ b/docs/getting_started/spotlight.md @@ -0,0 +1,170 @@ +# Spotlight Integration + +DeepForest integrates with [Renumics Spotlight](https://github.com/Renumics/spotlight) for interactive visualization of detection results. This integration allows you to explore predictions, analyze model performance, and assess data quality through Spotlight's web interface. + +> **Note**: The Spotlight manifest format is experimental. For production use, consider the Hugging Face datasets export which offers broader tool compatibility. + +## Quick Start + +```python +from deepforest import get_data +from deepforest.utilities import read_file +from deepforest.visualize import view_with_spotlight + + +path = get_data("OSBS_029.csv") +df = read_file(path) + +# Convert to Spotlight format +spotlight_data = df.spotlight() +# or +spotlight_data = view_with_spotlight(df) + +# Generate new predictions and visualize +from deepforest.main import deepforest +model = deepforest() +model.load_model("Weecology/deepforest-tree") +image_path = get_data("OSBS_029.tif") +results = model.predict_image(path=image_path) + +# Visualize with confidence scores preserved +spotlight_data = view_with_spotlight(results) + +# Export to file for external tools +df.spotlight(format="lightly", out_dir="spotlight_export") +``` + +## API Reference + +- `view_with_spotlight(df, format="lightly"|"objects", out_dir=...)` - Convert DeepForest DataFrame to Spotlight format + - Supports flexible image reference columns: `image_path`, `file_name`, `source_image`, `image` + - Handles NaN values in optional columns gracefully + - Validates required bbox columns: `xmin`, `ymin`, `xmax`, `ymax` +- `df.spotlight(...)` - DataFrame accessor method (calls `view_with_spotlight`) +- Core DataFrame-to-Spotlight conversion functionality +- `export_to_spotlight_dataset(gallery_dir)` - Create Hugging Face Dataset from gallery + +## Working with Predictions + +When working with model predictions, the integration preserves confidence scores and detection metadata: + +```python +from deepforest import get_data +from deepforest.main import deepforest +from deepforest.visualize import view_with_spotlight + +# Generate predictions +model = deepforest() +model.load_model("Weecology/deepforest-tree") +image_path = get_data("OSBS_029.tif") +results = model.predict_image(path=image_path) + +# Convert to Spotlight format +spotlight_data = results.spotlight() +# or +spotlight_data = view_with_spotlight(results) +``` + +The converted data includes: +- Bounding boxes (xmin, ymin, xmax, ymax) +- Class labels +- Confidence scores (0.0 to 1.0) +- Source image paths + +This enables you to: +- Filter detections by confidence threshold +- Compare model performance across images +- Identify patterns in prediction quality +- Analyze spatial distribution of detections + +## Example Output + +The following example shows the Spotlight interface displaying DeepForest predictions: + +```python +from deepforest import get_data +from deepforest.main import deepforest +from deepforest.visualize import view_with_spotlight + +model = deepforest() +model.load_model("Weecology/deepforest-tree") +image_path = get_data("OSBS_029.tif") +results = model.predict_image(path=image_path) + +# Launch Spotlight viewer +view_with_spotlight(results) +``` + +```{image} ../assets/spotlight_demo_screenshot.png +:alt: Spotlight interface main view showing DeepForest predictions with data table and image viewer +:width: 600px +:align: center +``` +*Main interface showing detection results in an interactive table* + +```{image} ../assets/spotlight_demo_screenshot1.png +:alt: Spotlight interface showing detailed bounding box visualization on forest imagery +:width: 600px +:align: center +``` +*Confidence scores and bounding box coordinates for each detection* + +```{image} ../assets/spotlight_demo_screenshot2.png +:alt: Spotlight interface displaying confidence score distribution and filtering options +:width: 600px +:align: center +``` +*Source imagery with detection metadata* + +The screenshots show the Spotlight interface with: +- **Main view**: Data table displaying tree detections with confidence scores, bounding box coordinates, and interactive sorting capabilities +- **Image viewer**: Visual representation of detected trees with bounding boxes overlaid on the source forest imagery +- **Analytics panel**: Confidence score distribution charts and filtering options for analyzing model performance across detections + +## Demo Script + +Test the integration with the included demo: + +```bash +python demo_spotlight.py +``` + +The script will load a model, generate predictions, and launch the Spotlight viewer in your browser. + +## Advanced Usage + +For more complex workflows, you can combine Spotlight integration with gallery generation: + +```python +from deepforest.visualize import ( + view_with_spotlight, + export_to_gallery, + write_gallery_html, + export_to_spotlight_dataset +) + +# Direct Spotlight integration +spotlight_data = view_with_spotlight(df, format="lightly") + +# Create thumbnail gallery +metadata = export_to_gallery(df, "forest_gallery", max_crops=200) + +# Generate HTML viewer +write_gallery_html("forest_gallery") + +# Export as HuggingFace dataset +hf_dataset = export_to_spotlight_dataset("forest_gallery") +``` + +## Command Line Interface + +```bash +# Export predictions to gallery +python -m deepforest.scripts.cli gallery export predictions.csv --out forest_gallery + +# Package for Spotlight +python -m deepforest.scripts.cli gallery spotlight --gallery forest_gallery --out spotlight_package + +# Package for Spotlight +python -m deepforest.scripts.cli gallery spotlight --gallery forest_gallery --out spotlight_package +``` diff --git a/docs/user_guide/examples/demo_spotlight.py b/docs/user_guide/examples/demo_spotlight.py new file mode 100644 index 000000000..282be8a2d --- /dev/null +++ b/docs/user_guide/examples/demo_spotlight.py @@ -0,0 +1,60 @@ +""" +DeepForest Spotlight Integration Example + +This example demonstrates how to use DeepForest predictions with Renumics Spotlight +for interactive data exploration and visualization. + +Requirements: + pip install renumics-spotlight + +Usage: + python demo_spotlight.py +""" + +from deepforest import get_data, main +from deepforest.visualize import view_with_spotlight + +# Load a DeepForest model +model = main.deepforest() +model.load_model("weecology/deepforest-tree") + +# Make predictions on sample data +image_path = get_data("OSBS_029.tif") +results = model.predict_image(path=image_path) + +print(f"Generated {len(results)} tree detections") +print(f"Score range: {results['score'].min():.3f} - {results['score'].max():.3f}") + +# Method 1: Use DataFrame accessor +spotlight_data = results.spotlight() +print(f"Spotlight format created with {len(spotlight_data['samples'])} samples") + +# Method 2: Use function directly +spotlight_data = view_with_spotlight(results) + +# Optional: Save to file for later use +spotlight_data = view_with_spotlight(results, out_dir="spotlight_output") +print("Spotlight manifest saved to spotlight_output/manifest.json") + +# Optional: Launch Spotlight viewer (requires renumics-spotlight) +try: + import renumics.spotlight as spotlight + import pandas as pd + + # Prepare data for Spotlight viewer + spotlight_df = pd.DataFrame({ + 'image': [image_path] * len(results), + 'label': results['label'], + 'confidence': results['score'], + 'xmin': results['xmin'], + 'ymin': results['ymin'], + 'xmax': results['xmax'], + 'ymax': results['ymax'] + }) + + print("Opening Spotlight viewer in browser...") + spotlight.show(spotlight_df, dtype={'image': spotlight.Image}) + +except ImportError: + print("Install renumics-spotlight to launch the interactive viewer:") + print("pip install renumics-spotlight") diff --git a/pyproject.toml b/pyproject.toml index 5155df2a0..fd270c478 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,6 +99,9 @@ docs = [ "sphinx-markdown-tables", "sphinx_rtd_theme", ] +spotlight = [ + "renumics-spotlight>=1.7.1", +] [project.scripts] deepforest = "deepforest.scripts.cli:main" diff --git a/src/deepforest/scripts/cli.py b/src/deepforest/scripts/cli.py index 5231cea9f..157ae6aa2 100644 --- a/src/deepforest/scripts/cli.py +++ b/src/deepforest/scripts/cli.py @@ -1,8 +1,11 @@ import argparse import sys +from pathlib import Path +import pandas as pd from hydra import compose, initialize, initialize_config_dir from omegaconf import OmegaConf +from PIL import Image from deepforest.conf.schema import Config as StructuredConfig from deepforest.scripts.evaluate import evaluate @@ -104,7 +107,6 @@ def main(): "--root-dir", help="Root directory containing images. Defaults to CSV directory if not specified.", ) - evaluate_parser.add_argument( "--save-predictions", help="Path to save generated predictions CSV (only used when --predictions is not provided)", @@ -115,6 +117,89 @@ def main(): # Show config subcommand subparsers.add_parser("config", help="Show the current config") + # Gallery subcommands + gallery_parser = subparsers.add_parser("gallery", help="Gallery utilities") + gallery_sub = gallery_parser.add_subparsers(dest="gallery_cmd") + + gallery_export = gallery_sub.add_parser( + "export", help="Export predictions to a local gallery (thumbnails + metadata)" + ) + gallery_export.add_argument( + "-i", + "--input", + help="Path to predictions CSV/JSON (rows with image_path and bbox)", + ) + gallery_export.add_argument( + "-o", "--out", dest="out", help="Output directory for gallery", required=True + ) + gallery_export.add_argument( + "--root-dir", + dest="root_dir", + help="Root directory to resolve relative image paths", + ) + gallery_export.add_argument( + "--max-crops", type=int, default=None, help="Maximum number of crops to export" + ) + gallery_export.add_argument( + "--sample-by-image", + action="store_true", + help="Sample by image to distribute crops across images", + ) + gallery_export.add_argument( + "--per-image-limit", + type=int, + default=None, + help="Limit crops per image when sampling by image", + ) + gallery_export.add_argument( + "--sample-seed", type=int, default=None, help="Seed for deterministic sampling" + ) + gallery_export.add_argument( + "--start-server", + action="store_true", + help="Start a tiny local HTTP server to view the gallery", + ) + gallery_export.add_argument( + "--port", type=int, default=0, help="Port to serve the gallery on (0 = auto)" + ) + gallery_export.add_argument( + "--no-browser", + action="store_true", + help="Do not open the browser when starting server", + ) + gallery_export.add_argument( + "--demo", + action="store_true", + help="Create a small demo predictions file and images for quick testing", + ) + + gallery_spotlight = gallery_sub.add_parser( + "spotlight", help="Package an existing gallery for Renumics Spotlight" + ) + gallery_spotlight.add_argument( + "-g", + "--gallery", + dest="gallery", + help="Path to existing gallery directory (contains thumbnails/ and metadata.json)", + required=True, + ) + gallery_spotlight.add_argument( + "-o", + "--out", + dest="out", + help="Output directory for Spotlight package", + required=True, + ) + gallery_spotlight.add_argument( + "--archive", + action="store_true", + help="Also produce a tar.gz archive of the package for upload", + ) + gallery_spotlight.add_argument( + "--archive-name", + dest="archive_name", + help="Optional archive name (defaults to .tar.gz)", + ) # Config options for Hydra parser.add_argument("--config-dir", help="Path to custom configuration directory") @@ -168,6 +253,79 @@ def main(): elif args.command == "config": print(OmegaConf.to_yaml(cfg, resolve=True)) + elif args.command == "gallery": + # Gallery subcommands + if args.gallery_cmd == "export": + # If demo requested, create a tiny demo dataset and image + if args.demo: + demo_input_dir = Path(args.out) / "demo_input" + demo_input_dir.mkdir(parents=True, exist_ok=True) + demo_img = demo_input_dir / "img_demo.png" + # create a small RGB image + Image.new("RGB", (128, 128), color=(120, 140, 160)).save(demo_img) + df = pd.DataFrame( + [ + { + "image_path": demo_img.name, + "xmin": 10, + "ymin": 10, + "xmax": 60, + "ymax": 60, + "label": "Tree", + "score": 0.95, + } + ] + ) + df.root_dir = str(demo_input_dir) + else: + if args.input is None: + raise RuntimeError( + "Please provide an input predictions file with -i/--input" + ) + + # read CSV or JSON depending on extension + input_path = args.input + if input_path.lower().endswith(".json") or input_path.lower().endswith( + ".jsonl" + ): + df = pd.read_json( + input_path, lines=input_path.lower().endswith(".jsonl") + ) + else: + df = pd.read_csv(input_path) + + from deepforest.visualize import ( + export_to_gallery, + write_gallery_html, + ) + + outdir = args.out + export_to_gallery( + df, + outdir, + root_dir=args.root_dir, + max_crops=args.max_crops, + sample_seed=args.sample_seed, + sample_by_image=args.sample_by_image, + per_image_limit=args.per_image_limit, + ) + write_gallery_html(outdir) + + if args.start_server: + print("Local server functionality removed - open index.html manually") + elif args.gallery_cmd == "spotlight": + from deepforest.visualize.spotlight_adapter import ( + prepare_spotlight_package, + ) + + gallery_dir = args.gallery + outdir = args.out + res = prepare_spotlight_package(gallery_dir, out_dir=outdir) + print("Prepared Spotlight package:", res) + if args.archive: + print( + "Archive functionality removed - use standard tools to create archives" + ) else: parser.print_help() diff --git a/src/deepforest/visualize/__init__.py b/src/deepforest/visualize/__init__.py new file mode 100644 index 000000000..6606f2fba --- /dev/null +++ b/src/deepforest/visualize/__init__.py @@ -0,0 +1,66 @@ +"""Visualization module for DeepForest. + +This module provides visualization functions for forest detection results, +including traditional plotting and interactive Spotlight integration. + +For backward compatibility, this module re-exports functionality from +the legacy ``visualize.py`` implementation, while Spotlight-specific +helpers are defined in ``spotlight_adapter.py``. + +Example usage:: + + from deepforest.visualize import plot_results + + # Traditional plotting + plot_results(df) + + # Interactive Spotlight visualization + data = df.spotlight() +""" + +import importlib.util +from pathlib import Path + +from .spotlight_adapter import ( + SpotlightAccessor, + prepare_spotlight_package, + view_with_spotlight, +) + + +def _load_legacy_visualize_module() -> object: + """Load the legacy visualization module for backwards compatibility.""" + + legacy_visualize_path = Path(__file__).parent.parent / "visualize.py" + spec = importlib.util.spec_from_file_location( + "legacy_visualize", legacy_visualize_path + ) + if spec is None or spec.loader is None: + raise ImportError( + f"Unable to load legacy visualization module from {legacy_visualize_path}" + ) + + legacy_visualize = importlib.util.module_from_spec(spec) + spec.loader.exec_module(legacy_visualize) + return legacy_visualize + + +legacy_visualize = _load_legacy_visualize_module() + +# Re-export legacy plotting helpers to preserve the public API. +plot_results = legacy_visualize.plot_results +plot_annotations = legacy_visualize.plot_annotations +convert_to_sv_format = legacy_visualize.convert_to_sv_format +_load_image = legacy_visualize._load_image +label_to_color = legacy_visualize.label_to_color + +__all__ = [ + "convert_to_sv_format", + "label_to_color", + "plot_annotations", + "plot_results", + "SpotlightAccessor", + "view_with_spotlight", + "prepare_spotlight_package", + "_load_image", +] diff --git a/src/deepforest/visualize/spotlight_adapter.py b/src/deepforest/visualize/spotlight_adapter.py new file mode 100644 index 000000000..ef45b50e3 --- /dev/null +++ b/src/deepforest/visualize/spotlight_adapter.py @@ -0,0 +1,354 @@ +"""Spotlight / Lightly adapter helpers. + +Converts DeepForest DataFrames (read_file output or prediction tables) into formats +compatible with Renumics Spotlight and Lightly data visualization tools. + +The adapter supports two output formats: +1. "objects" - Canonical format matching Spotlight's expected schema +2. "lightly" - Format compatible with Lightly's object detection conventions + +Public API: +- `view_with_spotlight(df, format="lightly", out_dir=None)` - Main conversion function +- `df_to_objects_manifest(df)` - Convert DataFrame to canonical objects format +- `objects_to_lightly(manifest)` - Convert objects format to Lightly format +- `prepare_spotlight_package(gallery_dir, out_dir)` - Package gallery for Spotlight + +Usage: + # Direct conversion + manifest = view_with_spotlight(df, format="objects") + + # Using DataFrame accessor + lightly_data = df.spotlight(format="lightly", out_dir="export") + + # Launch Spotlight viewer (via accessor) + df.spotlight(launch=True) +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +import pandas as pd + +# Constants +MANIFEST_VERSION = "1.0" +BBOX_FORMAT = "pixels" + + +def df_to_objects_manifest(df: pd.DataFrame) -> dict: + """Convert a DeepForest-style DataFrame into the canonical objects + manifest. + + Expected input columns: one of ['image_path','file_name','source_image'] for + image reference, and bbox columns ['xmin','ymin','xmax','ymax'], plus + optional 'label' and 'score'. The function is permissive and will group + annotations by image reference. + """ + # Choose the column that references image files + image_col = None + for name in ("image_path", "file_name", "source_image", "image"): + if name in df.columns: + image_col = name + break + if image_col is None: + raise ValueError("DataFrame must contain an image reference column") + + # Required bbox columns + for c in ("xmin", "ymin", "xmax", "ymax"): + if c not in df.columns: + raise ValueError(f"Missing required bbox column: {c}") + + images: list[dict] = [] + grouped_by_image = df.groupby(image_col) + for image_name, group in grouped_by_image: + annotations: list[dict] = [] + for _, row in group.iterrows(): + bbox = [ + float(row["xmin"]), + float(row["ymin"]), + float(row["xmax"]), + float(row["ymax"]), + ] + annotation = {"bbox": bbox} + if "label" in row.index and not pd.isna(row["label"]): + annotation["label"] = row["label"] + if "score" in row.index and not pd.isna(row["score"]): + annotation["score"] = float(row["score"]) + annotations.append(annotation) + + # Width/height optional if present in any row + width = None + height = None + if "width" in group.columns and not group["width"].isnull().all(): + width = int(group["width"].dropna().iloc[0]) + if "height" in group.columns and not group["height"].isnull().all(): + height = int(group["height"].dropna().iloc[0]) + + image_entry = { + "file_name": str(image_name), + "annotations": annotations, + } + + # Only include width/height if they have valid values + if width is not None: + image_entry["width"] = width + if height is not None: + image_entry["height"] = height + + images.append(image_entry) + + manifest = {"version": MANIFEST_VERSION, "bbox_format": BBOX_FORMAT, "images": images} + return manifest + + +def objects_to_lightly(manifest: dict) -> dict: + """Map the canonical objects manifest to a Lightly-compatible format. + + This produces a dict compatible with Lightly's expected format for object detection. + The format follows Lightly's conventions for image datasets with bounding box annotations. + + Note: This is a minimal implementation. For production use, validate against + the official Lightly schema and adjust field names/structure as needed. + """ + samples = [] + for image in manifest.get("images", []): + sample = { + "file_name": image.get("file_name"), + "metadata": {"bbox_format": manifest.get("bbox_format", BBOX_FORMAT)}, + } + + # Add image dimensions to metadata if available + if image.get("width") is not None: + sample["metadata"]["width"] = image.get("width") + if image.get("height") is not None: + sample["metadata"]["height"] = image.get("height") + + # Format annotations for Lightly + annotations = image.get("annotations", []) + if annotations: + sample["annotations"] = [] + for annotation in annotations: + annotation_entry = { + "bbox": annotation.get("bbox"), + "category_id": annotation.get("label"), + "label": annotation.get("label"), + } + if annotation.get("score") is not None: + annotation_entry["score"] = annotation.get("score") + sample["annotations"].append(annotation_entry) + + samples.append(sample) + + return { + "samples": samples, + "version": manifest.get("version", MANIFEST_VERSION), + "bbox_format": manifest.get("bbox_format", BBOX_FORMAT), + } + + +def view_with_spotlight( + df: pd.DataFrame, + *, + format: str = "lightly", + out_dir: str | None = None, +) -> dict: + """Convert a DataFrame to the requested format. + + Args: + df: DataFrame with detection results (must have image reference and bbox columns) + format: 'objects' (canonical Spotlight format) or 'lightly' (Lightly-compatible format) + out_dir: Optional directory to write manifest.json file + + Returns: + Dict in the requested format + + Raises: + ValueError: If format is unsupported or DataFrame is missing required columns + """ + if format not in ("objects", "lightly"): + raise ValueError(f"Unsupported format: {format}. Use 'objects' or 'lightly'") + + if df.empty: + raise ValueError("DataFrame is empty") + + manifest = df_to_objects_manifest(df) + + if format == "objects": + result = manifest + elif format == "lightly": + result = objects_to_lightly(manifest) + + if out_dir: + os.makedirs(out_dir, exist_ok=True) + manifest_path = os.path.join(out_dir, "manifest.json") + with open(manifest_path, "w", encoding="utf8") as fh: + json.dump(result, fh, indent=2, ensure_ascii=False) + + return result + + +def _launch_spotlight_from_manifest( + manifest: dict, *, port: int = 8000, host: str = "localhost" +) -> None: + """Launch Spotlight viewer from a manifest dict. + + Args: + manifest: Manifest dict in objects or lightly format + port: Port for Spotlight server + host: Host for Spotlight server + + Raises: + ImportError: If renumics-spotlight is not installed + """ + try: + import renumics.spotlight as spotlight + except ImportError as e: + raise ImportError( + "renumics-spotlight is required for launching Spotlight viewer. " + "Install it with: pip install 'deepforest[spotlight]' or pip install renumics-spotlight" + ) from e + + rows = [] + + # Extract data based on manifest format + if "images" in manifest: + data_items = [ + (image, image.get("annotations", [])) for image in manifest["images"] + ] + file_key = "file_name" + elif "samples" in manifest: + data_items = [ + (sample, sample.get("annotations", [])) for sample in manifest["samples"] + ] + file_key = "file_name" + else: + raise ValueError("Manifest must contain 'images' or 'samples' key") + + # Convert to Spotlight DataFrame format + for item, item_annotations in data_items: + for annotation in item_annotations: + row = { + "file_name": str(item[file_key]), + "bbox_xmin": float(annotation["bbox"][0]), + "bbox_ymin": float(annotation["bbox"][1]), + "bbox_xmax": float(annotation["bbox"][2]), + "bbox_ymax": float(annotation["bbox"][3]), + "bbox_width": float(annotation["bbox"][2] - annotation["bbox"][0]), + "bbox_height": float(annotation["bbox"][3] - annotation["bbox"][1]), + "label": str(annotation.get("label", "unknown")), + "score": float(annotation.get("score", 1.0)), + } + rows.append(row) + + if not rows: + raise ValueError("No annotations found in manifest") + + spotlight_df = pd.DataFrame(rows) + spotlight.show(spotlight_df, port=port, host=host) + + +# Provide a small DataFrame accessor so users can call `df.spotlight.view(...)` +# or `df.spotlight(format="lightly", out_dir=...)` as a convenience wrapper. +@pd.api.extensions.register_dataframe_accessor("spotlight") +class SpotlightAccessor: + """DataFrame accessor for Spotlight/Lightly convenience helpers. + + Usage: + df.spotlight(format="lightly", out_dir=None) + + This forwards to `view_with_spotlight` using the DataFrame as input. + """ + + def __init__(self, pandas_obj: pd.DataFrame) -> None: + self._df = pandas_obj + + def __call__(self, *args, **kwargs) -> dict: + return self.view(*args, **kwargs) + + def view( + self, + *, + format: str = "lightly", + out_dir: str | None = None, + launch: bool = False, + port: int = 8000, + host: str = "localhost", + ) -> dict: + """Convert DataFrame to requested format and optionally launch + Spotlight viewer. + + This is a convenience wrapper around `view_with_spotlight()` that adds + optional viewer launch capability for interactive use. + + Args: + format: 'objects' or 'lightly' + out_dir: Optional directory to write manifest.json file + launch: If True, launch Spotlight viewer in browser + port: Port for Spotlight server (only used if launch=True) + host: Host for Spotlight server (only used if launch=True) + + Returns: + Dict in the requested format + """ + result = view_with_spotlight(self._df, format=format, out_dir=out_dir) + + if launch: + _launch_spotlight_from_manifest(result, port=port, host=host) + + return result + + +def prepare_spotlight_package( + gallery_dir: str | Path, *, out_dir: str | Path +) -> dict[str, Any]: + """Prepare a gallery directory for Spotlight visualization. + + Args: + gallery_dir: Path to the gallery directory containing images and metadata + out_dir: Output directory for the Spotlight package + + Returns: + Dict containing package information and file paths + + Raises: + FileNotFoundError: If gallery directory doesn't exist + """ + gallery_path = Path(gallery_dir) + out_path = Path(out_dir) + + if not gallery_path.exists(): + raise FileNotFoundError(f"Gallery directory not found: {gallery_path}") + + out_path.mkdir(parents=True, exist_ok=True) + + metadata_files = list(gallery_path.glob("*.csv")) + list(gallery_path.glob("*.json")) + + if not metadata_files: + raise FileNotFoundError(f"No metadata files (CSV/JSON) found in {gallery_path}") + + metadata_file = metadata_files[0] + + if metadata_file.suffix == ".csv": + df = pd.read_csv(metadata_file) + else: + # Handle JSON metadata + with open(metadata_file) as f: + data = json.load(f) + df = pd.DataFrame(data) + + # Convert to Spotlight format + spotlight_data = view_with_spotlight(df, format="lightly", out_dir=str(out_path)) + + result = { + "gallery_dir": str(gallery_path), + "out_dir": str(out_path), + "metadata_file": str(metadata_file), + "num_images": len(spotlight_data.get("samples", [])), + "manifest_path": str(out_path / "manifest.json"), + "format": "lightly", + } + + return result diff --git a/tests/test_spotlight.py b/tests/test_spotlight.py new file mode 100644 index 000000000..881be9b21 --- /dev/null +++ b/tests/test_spotlight.py @@ -0,0 +1,486 @@ +"""Test Spotlight integration for DeepForest.""" + +import json +import pandas as pd +import pytest +from unittest.mock import patch + +from deepforest import get_data +from deepforest.main import deepforest +from deepforest.visualize import view_with_spotlight +import deepforest.visualize.spotlight_adapter as spotlight_adapter +from deepforest.visualize.spotlight_adapter import prepare_spotlight_package + +REAL_IMAGE_PATH = get_data("OSBS_029.tif") + + +# Test helper functions +def create_sample_dataframe(num_annotations=1, include_optional=False): + """Create a sample DataFrame for testing.""" + data = { + "image_path": [REAL_IMAGE_PATH] * num_annotations, + "xmin": [10 + i * 50 for i in range(num_annotations)], + "ymin": [10 + i * 50 for i in range(num_annotations)], + "xmax": [50 + i * 50 for i in range(num_annotations)], + "ymax": [50 + i * 50 for i in range(num_annotations)], + "label": ["Tree"] * num_annotations, + "score": [0.95 - i * 0.05 for i in range(num_annotations)], + } + + if include_optional: + data.update({ + "width": [500] * num_annotations, + "height": [400] * num_annotations, + }) + + return pd.DataFrame(data) + + +def create_test_gallery(gallery_dir, format_type="csv", num_annotations=1): + """Create a test gallery directory with metadata.""" + gallery_dir.mkdir(exist_ok=True) + + df = create_sample_dataframe(num_annotations) + + if format_type == "csv": + metadata_file = gallery_dir / "detections.csv" + df.to_csv(metadata_file, index=False) + elif format_type == "json": + metadata_file = gallery_dir / "annotations.json" + with open(metadata_file, "w", encoding="utf8") as f: + json.dump(df.to_dict("records"), f) + + return metadata_file + + +def test_empty_dataframe(): + """Test handling of empty DataFrame.""" + df = pd.DataFrame() + with pytest.raises(ValueError, match="DataFrame is empty"): + view_with_spotlight(df) + + +def test_minimal_valid_dataframe(): + """Test with minimal valid DataFrame.""" + df = pd.DataFrame({ + "image_path": [REAL_IMAGE_PATH], + "xmin": [10.0], "ymin": [10.0], "xmax": [50.0], "ymax": [50.0] + }) + result = view_with_spotlight(df, format="objects") + + assert "version" in result + assert "bbox_format" in result + assert "images" in result + assert len(result["images"]) == 1 + + image = result["images"][0] + assert image["file_name"] == REAL_IMAGE_PATH + assert len(image["annotations"]) == 1 + + annotation = image["annotations"][0] + assert annotation["bbox"] == [10.0, 10.0, 50.0, 50.0] + + +def test_dataframe_accessor_error_handling(): + """Test DataFrame accessor handles errors properly.""" + df = pd.DataFrame() + with pytest.raises(ValueError, match="DataFrame is empty"): + df.spotlight() + + +def test_predictions_have_score_column(): + """Verify prediction results include score column.""" + model = deepforest() + model.load_model("Weecology/deepforest-tree") + image_path = get_data("OSBS_029.tif") + prediction_results = model.predict_image(path=image_path) + + assert "score" in prediction_results.columns + assert "label" in prediction_results.columns + assert "xmin" in prediction_results.columns + assert len(prediction_results) > 0 + + scores = prediction_results["score"] + assert scores.min() >= 0.0 + assert scores.max() <= 1.0 + + +def test_file_output_creation(tmp_path): + """Test that file output is created correctly.""" + df = pd.DataFrame({ + "image_path": [REAL_IMAGE_PATH], + "xmin": [10], "ymin": [10], "xmax": [50], "ymax": [50], + "label": ["Tree"] + }) + + out_dir = tmp_path / "spotlight_output" + result = view_with_spotlight(df, format="lightly", out_dir=str(out_dir)) + + manifest_file = out_dir / "manifest.json" + assert manifest_file.exists() + + with manifest_file.open(encoding="utf8") as f: + file_content = json.load(f) + + assert file_content == result + + +def test_missing_bbox_column_error(): + """Test error handling for missing required bbox columns.""" + df = pd.DataFrame({ + "image_path": [REAL_IMAGE_PATH], + "xmin": [10], + "ymin": [15], + "xmax": [110] + }) + + with pytest.raises(ValueError, match="Missing required bbox column: ymax"): + view_with_spotlight(df, format="objects") + + +def test_spotlight_launch_parameter_without_launch(): + """Test that basic conversion works without launching.""" + df = pd.DataFrame({ + "image_path": [REAL_IMAGE_PATH], + "xmin": [10], "ymin": [10], "xmax": [50], "ymax": [50], + "label": ["Tree"] + }) + + result = view_with_spotlight(df, format="objects") + assert "version" in result + + +def test_spotlight_integration_basic(): + """Test basic Spotlight integration functionality.""" + df = pd.DataFrame({ + "image_path": [REAL_IMAGE_PATH], + "xmin": [10], "ymin": [10], "xmax": [50], "ymax": [50], + "label": ["Tree"], "score": [0.95] + }) + + result = view_with_spotlight(df, format="objects") + + assert "version" in result + assert "images" in result + assert len(result["images"]) == 1 + + image = result["images"][0] + assert image["file_name"] == REAL_IMAGE_PATH + assert len(image["annotations"]) == 1 + + annotation = image["annotations"][0] + assert annotation["bbox"] == [10.0, 10.0, 50.0, 50.0] + assert annotation["label"] == "Tree" + assert annotation["score"] == 0.95 + + +def test_dataframe_accessor_launch_forwards_to_spotlight_launcher(): + """Test that the accessor forwards launch requests to the launcher helper.""" + df = pd.DataFrame({ + "image_path": [REAL_IMAGE_PATH], + "xmin": [10], "ymin": [10], "xmax": [50], "ymax": [50], + "label": ["Tree"], "score": [0.95] + }) + + with patch.object( + spotlight_adapter, "_launch_spotlight_from_manifest" + ) as mock_launch: + result = df.spotlight(format="objects", launch=True, port=9999, host="127.0.0.1") + + mock_launch.assert_called_once() + + call_args = mock_launch.call_args + assert isinstance(call_args[0][0], dict) + assert call_args[1]["port"] == 9999 + assert call_args[1]["host"] == "127.0.0.1" + assert result["images"][0]["annotations"][0]["label"] == "Tree" + + +def test_launch_spotlight_from_manifest_formats_dataframe(): + """Test that the internal launcher converts Spotlight manifests correctly.""" + pytest.importorskip("renumics.spotlight") + + manifest = { + "version": "1.0", + "bbox_format": "pixels", + "images": [ + { + "file_name": REAL_IMAGE_PATH, + "annotations": [ + { + "bbox": [10.0, 10.0, 50.0, 50.0], + "label": "Tree", + "score": 0.95, + } + ], + } + ], + } + + with patch("renumics.spotlight.show") as mock_show: + spotlight_adapter._launch_spotlight_from_manifest( + manifest, port=9999, host="127.0.0.1" + ) + + mock_show.assert_called_once() + + call_args = mock_show.call_args + spotlight_df = call_args[0][0] + + assert isinstance(spotlight_df, pd.DataFrame) + assert list(spotlight_df.columns) == [ + "file_name", + "bbox_xmin", + "bbox_ymin", + "bbox_xmax", + "bbox_ymax", + "bbox_width", + "bbox_height", + "label", + "score", + ] + assert len(spotlight_df) == 1 + assert spotlight_df["label"].iloc[0] == "Tree" + assert spotlight_df["score"].iloc[0] == 0.95 + assert call_args[1]["port"] == 9999 + assert call_args[1]["host"] == "127.0.0.1" + +# Gallery/Packaging Tests +def test_prepare_spotlight_package_valid_gallery(tmp_path): + """Test packaging a valid gallery for Spotlight with CSV metadata.""" + + gallery_dir = tmp_path / "gallery" + gallery_dir.mkdir() + + metadata_df = pd.DataFrame({ + "image_path": [REAL_IMAGE_PATH], + "xmin": [10], "ymin": [10], "xmax": [50], "ymax": [50], + "label": ["Tree"], "score": [0.95] + }) + metadata_file = gallery_dir / "detections.csv" + metadata_df.to_csv(metadata_file, index=False) + + # Call prepare_spotlight_package + out_dir = tmp_path / "spotlight_output" + result = prepare_spotlight_package(gallery_dir, out_dir=out_dir) + + assert result["gallery_dir"] == str(gallery_dir) + assert result["out_dir"] == str(out_dir) + assert result["metadata_file"] == str(metadata_file) + assert result["num_images"] > 0 + assert result["format"] == "lightly" + + + manifest_path = out_dir / "manifest.json" + assert manifest_path.exists() + + with open(manifest_path, encoding="utf8") as f: + manifest = json.load(f) + + assert "samples" in manifest + assert manifest["version"] == "1.0" + assert manifest["bbox_format"] == "pixels" + + +def test_prepare_spotlight_package_missing_dir(): + """Test FileNotFoundError when gallery directory doesn't exist.""" + with pytest.raises(FileNotFoundError, match="Gallery directory not found"): + prepare_spotlight_package("/nonexistent/gallery", out_dir="/tmp/out") + + +def test_prepare_spotlight_package_no_metadata(tmp_path): + """Test error when gallery has no metadata files (CSV/JSON).""" + + gallery_dir = tmp_path / "empty_gallery" + gallery_dir.mkdir() + + with pytest.raises(FileNotFoundError, match="No metadata files"): + prepare_spotlight_package(gallery_dir, out_dir=tmp_path / "output") + +def test_prepare_spotlight_package_csv_input(tmp_path): + """Test reading and converting CSV metadata from gallery.""" + gallery_dir = tmp_path / "gallery" + gallery_dir.mkdir() + + metadata_df = pd.DataFrame({ + "image_path": [REAL_IMAGE_PATH, REAL_IMAGE_PATH], + "xmin": [10, 100], "ymin": [10, 100], "xmax": [50, 150], "ymax": [50, 150], + "label": ["Tree", "Tree"], "score": [0.95, 0.87] + }) + metadata_file = gallery_dir / "predictions.csv" + metadata_df.to_csv(metadata_file, index=False) + + out_dir = tmp_path / "output" + result = prepare_spotlight_package(gallery_dir, out_dir=out_dir) + + assert result["metadata_file"] == str(metadata_file) + assert result["num_images"] == 1 + + + manifest_path = out_dir / "manifest.json" + with open(manifest_path, encoding="utf8") as f: + manifest = json.load(f) + + + assert len(manifest["samples"]) == 1 + assert len(manifest["samples"][0]["annotations"]) == 2 + first_annotation = manifest["samples"][0]["annotations"][0] + assert first_annotation["score"] == 0.95 + + +def test_prepare_spotlight_package_json_input(tmp_path): + """Test reading and converting JSON metadata from gallery.""" + gallery_dir = tmp_path / "gallery" + gallery_dir.mkdir() + + metadata = [ + { + "image_path": REAL_IMAGE_PATH, + "xmin": 10, "ymin": 10, "xmax": 50, "ymax": 50, + "label": "Tree", "score": 0.92 + } + ] + metadata_file = gallery_dir / "annotations.json" + with open(metadata_file, "w", encoding="utf8") as f: + json.dump(metadata, f) + + out_dir = tmp_path / "output" + result = prepare_spotlight_package(gallery_dir, out_dir=out_dir) + + + assert result["metadata_file"] == str(metadata_file) + assert result["num_images"] == 1 # One annotation + + manifest_path = out_dir / "manifest.json" + assert manifest_path.exists() + + with open(manifest_path, encoding="utf8") as f: + manifest = json.load(f) + assert len(manifest["samples"]) == 1 + + +def test_prepare_spotlight_package_multiple_images(tmp_path): + """Test packaging gallery with multiple annotations grouped correctly.""" + gallery_dir = tmp_path / "gallery" + gallery_dir.mkdir() + + metadata_df = pd.DataFrame({ + "image_path": [REAL_IMAGE_PATH, REAL_IMAGE_PATH, REAL_IMAGE_PATH], + "xmin": [10, 100, 200], + "ymin": [10, 100, 200], + "xmax": [50, 150, 250], + "ymax": [50, 150, 250], + "label": ["Tree", "Tree", "Tree"], + "score": [0.95, 0.87, 0.91] + }) + metadata_file = gallery_dir / "detections.csv" + metadata_df.to_csv(metadata_file, index=False) + + out_dir = tmp_path / "output" + result = prepare_spotlight_package(gallery_dir, out_dir=out_dir) + + manifest_path = out_dir / "manifest.json" + with open(manifest_path, encoding="utf8") as f: + manifest = json.load(f) + + samples = manifest["samples"] + assert len(samples) == 1 + assert len(samples[0]["annotations"]) == 3 + + +# Format Conversion Tests +def test_view_with_spotlight_format_lightly(): + """Test conversion to Lightly format.""" + df = pd.DataFrame({ + "image_path": [REAL_IMAGE_PATH], + "xmin": [10], "ymin": [10], "xmax": [50], "ymax": [50], + "label": ["Tree"], "score": [0.95] + }) + + result = view_with_spotlight(df, format="lightly") + + assert "samples" in result + assert "version" in result + assert "bbox_format" in result + + sample = result["samples"][0] + assert sample["file_name"] == REAL_IMAGE_PATH + assert "metadata" in sample + assert "annotations" in sample + + annotation = sample["annotations"][0] + assert annotation["bbox"] == [10.0, 10.0, 50.0, 50.0] + assert annotation["category_id"] == "Tree" + assert annotation["label"] == "Tree" + assert annotation["score"] == 0.95 + + +def test_view_with_spotlight_with_optional_columns(): + """Test handling of optional width/height columns.""" + df = pd.DataFrame({ + "image_path": [REAL_IMAGE_PATH], + "xmin": [10], "ymin": [10], "xmax": [50], "ymax": [50], + "label": ["Tree"], "score": [0.95], + "width": [500], "height": [400] + }) + + result = view_with_spotlight(df, format="objects") + + image = result["images"][0] + assert image["width"] == 500 + assert image["height"] == 400 + + +def test_view_with_spotlight_missing_label(): + """Test handling of missing label column (should use default or omit).""" + df = pd.DataFrame({ + "image_path": [REAL_IMAGE_PATH], + "xmin": [10], "ymin": [10], "xmax": [50], "ymax": [50], + "score": [0.95] + }) + + result = view_with_spotlight(df, format="objects") + + annotation = result["images"][0]["annotations"][0] + assert "label" not in annotation # Label is optional + + +def test_view_with_spotlight_multiple_images(): + """Test handling multiple images in single DataFrame.""" + df = pd.DataFrame({ + "image_path": [REAL_IMAGE_PATH, REAL_IMAGE_PATH], + "xmin": [10, 100], "ymin": [10, 100], "xmax": [50, 150], "ymax": [50, 150], + "label": ["Tree", "Tree"], "score": [0.95, 0.88] + }) + + result = view_with_spotlight(df, format="objects") + + assert len(result["images"]) == 1 + assert len(result["images"][0]["annotations"]) == 2 + + +def test_view_with_spotlight_unsupported_format(): + """Test error handling for unsupported format.""" + df = pd.DataFrame({ + "image_path": [REAL_IMAGE_PATH], + "xmin": [10], "ymin": [10], "xmax": [50], "ymax": [50] + }) + + with pytest.raises(ValueError, match="Unsupported format"): + view_with_spotlight(df, format="unsupported_format") + + +def test_dataframe_accessor_with_file_output(tmp_path): + """Test DataFrame accessor with file output.""" + df = pd.DataFrame({ + "image_path": [REAL_IMAGE_PATH], + "xmin": [10], "ymin": [10], "xmax": [50], "ymax": [50], + "label": ["Tree"] + }) + + out_dir = tmp_path / "output" + result = df.spotlight(format="objects", out_dir=str(out_dir)) + + assert "version" in result + manifest_file = out_dir / "manifest.json" + assert manifest_file.exists()