Skip to content

Annotator

AnnotatorApp

AnnotatorApp(root: Tk | None = None)

Main application class for the Boxlab Annotator GUI.

The AnnotatorApp provides a complete graphical interface for viewing, editing, and auditing object detection datasets. It supports COCO and YOLO formats, multi-split datasets, annotation editing, and audit workflows.

The application consists of several key components: - Image list panel (left): Browse and filter images - Annotation canvas (center): View and edit annotations - Control panel (center bottom): Navigation and mode controls - Info panel (right): Display image metadata and statistics - Menu bar: File operations and settings - Status bar: Current operation status

Features
  • Import/export COCO and YOLO datasets
  • Visual annotation editing with drag-to-resize
  • Multi-split support (train/val/test)
  • Audit mode with approve/reject workflow
  • Workspace persistence (.cyw files)
  • Auto-backup on crashes
  • Image tagging system
  • Keyboard shortcuts for efficiency

Parameters:

Name Type Description Default
root Tk | None

Optional Tkinter root window. If None, creates a new Tk instance.

None

Attributes:

Name Type Description
root

The main Tkinter window.

controller

AnnotationController managing dataset state.

canvas

AnnotationCanvas for displaying and editing images.

control_panel

ControlPanel for navigation and mode controls.

image_list_panel

ImageListPanel for browsing images.

info_panel

InfoPanel for displaying metadata.

backup_dir

Directory for auto-backup files.

status_var

Tkinter StringVar for status bar text.

Example
from boxlab.annotator import AnnotatorApp

# Create and run the application
app = AnnotatorApp()
app.run()
Example
# Create with custom root window
import tkinter as tk

root = tk.Tk()
root.geometry("1920x1080")

app = AnnotatorApp(root)
app.run()

Initialize the annotation application.

Parameters:

Name Type Description Default
root Tk | None

Optional Tkinter root window. If None, creates new instance.

None
Source code in boxlab/annotator/__init__.py
def __init__(self, root: tk.Tk | None = None):
    """Initialize the annotation application.

    Args:
        root: Optional Tkinter root window. If None, creates new instance.
    """
    self.root = root or tk.Tk()

    # Initialize controller
    self.controller = AnnotationController()

    self._update_window_title()

    # Start maximized
    self.root.state("zoomed")

    # Modern theme
    style = ttk.Style()
    if "vista" in style.theme_names():
        style.theme_use("vista")
    elif "clam" in style.theme_names():
        style.theme_use("clam")

    # Setup auto-backup directory
    self.backup_dir = pathlib.Path.home() / ".boxlab" / "backups"
    self.backup_dir.mkdir(parents=True, exist_ok=True)

    self._setup_exception_handler()
    self._setup_menu()
    self._setup_layout()
    self._setup_bindings()

    # Handle window close
    self.root.protocol("WM_DELETE_WINDOW", self.on_closing)

    logger.info("Annotator application initialized")

Functions

delete_current_image

delete_current_image() -> None

Delete current image with confirmation.

Source code in boxlab/annotator/__init__.py
def delete_current_image(self) -> None:
    """Delete current image with confirmation."""
    current_id = self.controller.get_current_image_id()
    if not current_id:
        messagebox.showwarning("Warning", "No image selected")
        return

    # Get image info for confirmation
    img_info = self.controller.get_image_info(current_id)
    filename = img_info.file_name if img_info else current_id

    # Confirmation dialog
    response = messagebox.askyesno(
        "Delete Image",
        f"Are you sure you want to delete this image?\n\n"
        f"Filename: {filename}\n"
        f"ID: {current_id}\n\n"
        "This will remove the image from the dataset.\n"
        "This action cannot be undone.",
        icon="warning",
    )

    if not response:
        return

    # Delete the image
    if self.controller.delete_current_image():
        # Get next image to display
        next_id = self.controller.get_next_image_after_delete()

        if next_id:
            # Load next image
            self.load_image(next_id)
            self.image_list_panel.select_image(next_id)
            self.update_counter()
            self.status_var.set(f"✓ Image deleted: {filename}")
        else:
            # No more images in this split
            self.canvas.clear()
            self.info_panel.clear()
            self.update_counter()
            self.status_var.set(f"✓ Image deleted: {filename} (no more images)")
            messagebox.showinfo(
                "Split Empty", f"No more images in the '{self.controller.current_split}' split."
            )

        # Update image list panel
        if self.controller.current_split:
            images = self.controller.get_images_in_split(self.controller.current_split)
            self.image_list_panel.set_images(images)

        # Mark workspace as modified
        self._update_window_title()

    else:
        messagebox.showerror("Error", "Failed to delete image")
        self.status_var.set("❌ Failed to delete image")

import_dataset

import_dataset() -> None

Import dataset from COCO, YOLO, or raw image directory.

Shows ImportDialog for format selection and path input, then loads the dataset in a background thread with progress indication.

Source code in boxlab/annotator/__init__.py
def import_dataset(self) -> None:
    """Import dataset from COCO, YOLO, or raw image directory.

    Shows ImportDialog for format selection and path input, then
    loads the dataset in a background thread with progress
    indication.
    """
    if not self._check_unsaved_changes():
        return

    dialog = ImportDialog(self.root)  # type: ignore
    result = dialog.show()

    if result:
        format_type = result["format"]
        path = result["path"]
        splits = result["splits"]
        initial_tags = result.get("tags", [])

        # Show loading dialog
        import threading

        from boxlab.annotator.dialogs import LoadingDialog

        loading = LoadingDialog(self.root, "Importing Dataset")  # type: ignore
        loading.update_message(f"Loading {format_type.upper()} dataset...")
        loading.update_status(f"Reading from {path}")

        # Use threading to avoid blocking
        error_container: list[None | BaseException] = [None]

        def do_import_thread() -> None:
            try:
                if format_type == "raw":
                    categories = result.get("categories", [])
                    self.controller.load_dataset(
                        path, format_type, splits, categories, initial_tags
                    )
                else:
                    self.controller.load_dataset(
                        path, format_type, splits, initial_tags=initial_tags
                    )

            except Exception as e:
                error_container[0] = e
                logger.error(f"Failed to load dataset: {e}", exc_info=True)

        def check_thread_done(thread: threading.Thread, loading_dialog: LoadingDialog) -> None:
            def _check_thread_done(*_args: t.Any) -> None:
                check_thread_done(thread, loading_dialog)

            if thread.is_alive():
                self.root.after(100, _check_thread_done, "fuck")
            else:
                loading_dialog.close()

                if error_container[0]:
                    messagebox.showerror(
                        "Error", f"Failed to load dataset:\n{error_container[0]}"
                    )
                    self.status_var.set("Ready")
                else:
                    loading_dialog.update_status("Updating UI...")

                    self.controller.workspace_path = None
                    self.controller.set_workspace_modified(True)
                    self._update_window_title()

                    self.update_after_load()

                    self.status_var.set(
                        f"✓ Loaded {self.controller.total_images()} images from {len(splits)} split(s)"
                    )

        load_thread = threading.Thread(target=do_import_thread, daemon=True)
        load_thread.start()

        def _check_thread_done(*_args: t.Any) -> None:
            check_thread_done(load_thread, loading)

        self.root.after(100, _check_thread_done, "fuck")

export_dataset

export_dataset() -> None

Export dataset to COCO or YOLO format.

Shows ExportDialog for format and naming options, then exports the dataset. Generates audit report JSON if in audit mode.

Source code in boxlab/annotator/__init__.py
def export_dataset(self) -> None:
    """Export dataset to COCO or YOLO format.

    Shows ExportDialog for format and naming options, then exports
    the dataset. Generates audit report JSON if in audit mode.
    """
    if not self.controller.has_dataset():
        messagebox.showwarning("Warning", "No dataset loaded")
        return

    if self.controller.audit_mode:
        stats = self.controller.get_audit_statistics()
        if stats["pending"] > 0:
            response = messagebox.askyesno(
                "Pending Audits",
                f"There are {stats['pending']} images pending audit.\n\n"
                "Do you want to continue exporting?",
            )
            if not response:
                return

    dialog = ExportDialog(self.root)  # type: ignore
    result = dialog.show()

    if result:
        output_dir = result["output_dir"]
        format_type = result["format"]
        naming = result["naming"]
        split_ratio = result.get("split_ratio")

        self.status_var.set(f"Exporting to {format_type.upper()} format...")
        self.root.update()

        try:
            self.controller.export_dataset(output_dir, format_type, naming, split_ratio)

            self.status_var.set(f"✓ Exported to {output_dir}")

            if self.controller.audit_mode:
                import pathlib

                report_path = pathlib.Path(output_dir) / "audit_report.json"
                self.controller.generate_audit_report_json(str(report_path))

                messagebox.showinfo(
                    "Success",
                    f"Dataset exported successfully to:\n{output_dir}\n\n"
                    f"Audit report (JSON) saved to:\n{report_path}",
                )
            else:
                messagebox.showinfo(
                    "Success", f"Dataset exported successfully to:\n{output_dir}"
                )

        except Exception as e:
            messagebox.showerror("Error", f"Failed to export dataset:\n{e}")
            self.status_var.set("Ready")
            logger.error(f"Failed to export dataset: {e}", exc_info=True)

load_workspace

load_workspace() -> None

Load workspace from .cyw file.

Prompts for workspace file, loads it in background thread, and restores the complete application state including annotations, tags, and audit status.

Source code in boxlab/annotator/__init__.py
def load_workspace(self) -> None:
    """Load workspace from .cyw file.

    Prompts for workspace file, loads it in background thread, and
    restores the complete application state including annotations,
    tags, and audit status.
    """
    if not self._check_unsaved_changes():
        return

    filepath = filedialog.askopenfilename(
        filetypes=[("Boxlab Workspace", "*.cyw"), ("All Files", "*.*")],
        title="Open Workspace",
    )

    if filepath:
        import threading

        from boxlab.annotator.dialogs import LoadingDialog

        loading = LoadingDialog(self.root, "Loading Workspace")  # type: ignore
        loading.update_message("Loading workspace...")
        loading.update_status(f"Reading {filepath}")

        error_container: list[BaseException | None] = [None]

        def do_load_thread() -> None:
            try:
                self.controller.load_workspace(filepath)
            except Exception as e:
                error_container[0] = e
                logger.error(f"Failed to load workspace: {e}", exc_info=True)

        def check_thread_done(thread: threading.Thread, loading_dialog: LoadingDialog) -> None:
            def _check_thread_done(*_args: t.Any) -> None:
                check_thread_done(thread, loading_dialog)

            if thread.is_alive():
                self.root.after(100, _check_thread_done, "fuck")
            else:
                loading_dialog.close()

                if error_container[0]:
                    messagebox.showerror(
                        "Error", f"Failed to load workspace:\n{error_container[0]}"
                    )
                else:
                    self._update_window_title()
                    self.update_after_load()

                    current_id = self.controller.get_current_image_id()
                    if current_id:
                        self.load_image(current_id)

                    self.status_var.set("✓ Workspace loaded")
                    messagebox.showinfo("Success", f"Workspace loaded from:\n{filepath}")

        load_thread = threading.Thread(target=do_load_thread, daemon=True)
        load_thread.start()

        def _check_thread_done(*_args: t.Any) -> None:
            check_thread_done(load_thread, loading)

        self.root.after(100, _check_thread_done, "fuck")

save_workspace

save_workspace() -> None

Save current workspace to associated .cyw file.

If no workspace path is set, delegates to save_workspace_as().

Source code in boxlab/annotator/__init__.py
def save_workspace(self) -> None:
    """Save current workspace to associated .cyw file.

    If no workspace path is set, delegates to save_workspace_as().
    """
    if not self.controller.has_dataset():
        messagebox.showwarning("Warning", "No dataset loaded")
        return

    if not self.controller.workspace_path:
        self.save_workspace_as()
        return

    try:
        self.controller.save_workspace()
        self._update_window_title()
        self.status_var.set("✓ Workspace saved")
    except Exception as e:
        messagebox.showerror("Error", f"Failed to save workspace:\n{e}")
        logger.error(f"Failed to save workspace: {e}", exc_info=True)

save_workspace_as

save_workspace_as() -> None

Save workspace to a new .cyw file.

Prompts for file location and saves complete workspace state.

Source code in boxlab/annotator/__init__.py
def save_workspace_as(self) -> None:
    """Save workspace to a new .cyw file.

    Prompts for file location and saves complete workspace state.
    """
    if not self.controller.has_dataset():
        messagebox.showwarning("Warning", "No dataset loaded")
        return

    filepath = filedialog.asksaveasfilename(
        defaultextension=".cyw",
        filetypes=[("Boxlab Workspace", "*.cyw"), ("All Files", "*.*")],
        title="Save Workspace As",
    )

    if filepath:
        try:
            self.controller.save_workspace(filepath)
            self._update_window_title()
            self.status_var.set(f"✓ Workspace saved: {filepath}")
            messagebox.showinfo("Success", f"Workspace saved to:\n{filepath}")
        except Exception as e:
            messagebox.showerror("Error", f"Failed to save workspace:\n{e}")
            logger.error(f"Failed to save workspace: {e}", exc_info=True)

next_image

next_image() -> None

Navigate to next image in current split.

Source code in boxlab/annotator/__init__.py
def next_image(self) -> None:
    """Navigate to next image in current split."""
    next_id = self.controller.next_image()
    if next_id:
        self.load_image(next_id)
        self.image_list_panel.select_image(next_id)
        self.update_counter()

prev_image

prev_image() -> None

Navigate to previous image in current split.

Source code in boxlab/annotator/__init__.py
def prev_image(self) -> None:
    """Navigate to previous image in current split."""
    prev_id = self.controller.prev_image()
    if prev_id:
        self.load_image(prev_id)
        self.image_list_panel.select_image(prev_id)
        self.update_counter()

load_image

load_image(image_id: str) -> None

Load and display image with annotations.

Parameters:

Name Type Description Default
image_id str

ID of the image to load.

required
Source code in boxlab/annotator/__init__.py
def load_image(self, image_id: str) -> None:
    """Load and display image with annotations.

    Args:
        image_id: ID of the image to load.
    """
    try:
        image_info = self.controller.get_image_info(image_id)
        annotations = self.controller.get_annotations(image_id)

        if image_info and image_info.path and image_info.path.exists():
            from PIL import Image

            img = Image.open(image_info.path)

            self.canvas.display_image(img, annotations, self.controller.get_categories())

            audit_status = None
            if self.controller.audit_mode:
                audit_status = self.controller.get_audit_status(image_id)
                comment = self.controller.get_audit_comment(image_id)
                self.control_panel.update_audit_status(audit_status)
                self.control_panel.set_audit_comment(comment)

            tags = self.controller.get_image_tags(image_id)
            self.info_panel.set_current_tags(tags)

            self.info_panel.update_image_info(
                image_info=image_info,
                annotations=annotations,
                source=self.controller.get_image_source(image_id),
                audit_status=audit_status,
            )

            self.status_var.set(f"🖼 {image_info.file_name}")

    except Exception as e:
        messagebox.showerror("Error", f"Failed to load image:\n{e}")
        logger.error(f"Failed to load image {image_id}: {e}", exc_info=True)

on_image_selected

on_image_selected(image_id: str) -> None

Handle image selection from list panel.

Parameters:

Name Type Description Default
image_id str

ID of the selected image.

required
Source code in boxlab/annotator/__init__.py
def on_image_selected(self, image_id: str) -> None:
    """Handle image selection from list panel.

    Args:
        image_id: ID of the selected image.
    """
    if self.controller.current_split:
        images = self.controller.image_ids_by_split.get(self.controller.current_split, [])
        try:
            self.controller.current_index = images.index(image_id)
            self.load_image(image_id)
            self.update_counter()
        except ValueError:
            pass

on_split_changed

on_split_changed(split: str) -> None

Handle split selection change.

Parameters:

Name Type Description Default
split str

Name of the selected split (e.g., "train", "val", "test").

required
Source code in boxlab/annotator/__init__.py
def on_split_changed(self, split: str) -> None:
    """Handle split selection change.

    Args:
        split: Name of the selected split (e.g., "train", "val", "test").
    """
    self.controller.set_current_split(split)

    images = self.controller.get_images_in_split(split)
    self.image_list_panel.set_images(images)

    if self.controller.audit_mode:
        split_audit_map = {}
        for img_id, _ in images:
            split_audit_map[img_id] = self.controller.get_audit_status(img_id)

        self.image_list_panel.set_audit_status_map(split_audit_map)

        audit_stats = self.controller.get_audit_statistics()
        self.info_panel.update_dataset_info(self.controller.get_dataset_info(), audit_stats)

    if images:
        self.load_image(images[0][0])

    self.update_counter()

on_annotations_changed

on_annotations_changed() -> None

Handle annotation modifications on canvas.

Source code in boxlab/annotator/__init__.py
def on_annotations_changed(self) -> None:
    """Handle annotation modifications on canvas."""
    current_id = self.controller.get_current_image_id()
    if current_id:
        annotations = self.canvas.get_annotations()
        self.controller.update_annotations(current_id, annotations)

        image_info = self.controller.get_image_info(current_id)
        if image_info:
            self.info_panel.update_image_info(
                image_info=image_info,
                annotations=annotations,
                source=self.controller.get_image_source(current_id),
            )

    self._update_window_title()
    self.status_var.set("⚠️ Unsaved changes")

on_edit_mode_changed

on_edit_mode_changed(enabled: bool) -> None

Handle edit mode toggle.

Parameters:

Name Type Description Default
enabled bool

Whether edit mode is enabled.

required
Source code in boxlab/annotator/__init__.py
def on_edit_mode_changed(self, enabled: bool) -> None:
    """Handle edit mode toggle.

    Args:
        enabled: Whether edit mode is enabled.
    """
    self.canvas.set_edit_mode(enabled)
    mode_text = "ON" if enabled else "OFF"
    self.status_var.set(f"Edit mode: {mode_text}")

on_category_changed

on_category_changed(category_id: int | None) -> None

Handle category selection change.

Parameters:

Name Type Description Default
category_id int | None

Selected category ID, or None.

required
Source code in boxlab/annotator/__init__.py
def on_category_changed(self, category_id: int | None) -> None:
    """Handle category selection change.

    Args:
        category_id: Selected category ID, or None.
    """
    self.canvas.set_current_category(category_id)

on_tags_changed

on_tags_changed(tags: list[str]) -> None

Handle tags change for current image.

Parameters:

Name Type Description Default
tags list[str]

List of tag strings.

required
Source code in boxlab/annotator/__init__.py
def on_tags_changed(self, tags: list[str]) -> None:
    """Handle tags change for current image.

    Args:
        tags: List of tag strings.
    """
    current_id = self.controller.get_current_image_id()

    if current_id:
        self.controller.set_image_tags(current_id, tags)

        self.info_panel.set_current_tags(tags)
        self._update_window_title()
        self.status_var.set("⚠️ Unsaved changes")

on_new_tag_created

on_new_tag_created(tag: str) -> None

Handle new tag creation.

Parameters:

Name Type Description Default
tag str

New tag string.

required
Source code in boxlab/annotator/__init__.py
def on_new_tag_created(self, tag: str) -> None:
    """Handle new tag creation.

    Args:
        tag: New tag string.
    """
    self.controller.add_tag(tag)
    self.info_panel.set_available_tags(self.controller.available_tags)
    self._update_window_title()
    self.status_var.set(f"✓ New tag created: {tag}")

on_audit_mode_changed

on_audit_mode_changed(enabled: bool) -> None

Handle audit mode toggle.

Parameters:

Name Type Description Default
enabled bool

Whether audit mode is enabled.

required
Source code in boxlab/annotator/__init__.py
def on_audit_mode_changed(self, enabled: bool) -> None:
    """Handle audit mode toggle.

    Args:
        enabled: Whether audit mode is enabled.
    """
    self.controller.enable_audit_mode(enabled)

    self.image_list_panel.show_audit_filter(enabled)

    if enabled:
        complete_audit_map = {}
        for split_images in self.controller.image_ids_by_split.values():
            for img_id in split_images:
                complete_audit_map[img_id] = self.controller.get_audit_status(img_id)

        self.image_list_panel.set_audit_status_map(complete_audit_map)

        audit_stats = self.controller.get_audit_statistics()
        self.info_panel.update_dataset_info(self.controller.get_dataset_info(), audit_stats)

        current_id = self.controller.get_current_image_id()
        if current_id:
            status = self.controller.get_audit_status(current_id)
            self.control_panel.update_audit_status(status)

    mode_text = "ON" if enabled else "OFF"
    self.status_var.set(f"Audit mode: {mode_text}")

approve_current

approve_current() -> None

Approve current image and move to next.

Source code in boxlab/annotator/__init__.py
def approve_current(self) -> None:
    """Approve current image and move to next."""
    if not self.controller.audit_mode:
        return

    current_id = self.controller.get_current_image_id()
    if not current_id:
        return

    self.controller.set_audit_status(current_id, "approved")
    self.status_var.set("✓ Image approved")

    complete_audit_map = {}
    current_split_images = self.controller.get_images_in_split(
        self.controller.current_split or ""
    )
    for img_id, _ in current_split_images:
        complete_audit_map[img_id] = self.controller.get_audit_status(img_id)

    self.image_list_panel.set_audit_status_map(complete_audit_map)

    audit_stats = self.controller.get_audit_statistics()
    self.info_panel.update_dataset_info(self.controller.get_dataset_info(), audit_stats)

    next_id = self.controller.next_image()

    if next_id:
        self.load_image(next_id)
        self.image_list_panel.select_image(next_id)
        self.update_counter()

        status = self.controller.get_audit_status(next_id)
        self.control_panel.update_audit_status(status)

reject_current

reject_current() -> None

Reject current image and move to next.

Source code in boxlab/annotator/__init__.py
def reject_current(self) -> None:
    """Reject current image and move to next."""
    if not self.controller.audit_mode:
        return

    current_id = self.controller.get_current_image_id()
    if not current_id:
        return

    self.controller.set_audit_status(current_id, "rejected")
    self.status_var.set("✗ Image rejected")

    complete_audit_map = {}
    current_split_images = self.controller.get_images_in_split(
        self.controller.current_split or ""
    )
    for img_id, _ in current_split_images:
        complete_audit_map[img_id] = self.controller.get_audit_status(img_id)

    self.image_list_panel.set_audit_status_map(complete_audit_map)

    audit_stats = self.controller.get_audit_statistics()
    self.info_panel.update_dataset_info(self.controller.get_dataset_info(), audit_stats)

    next_id = self.controller.next_image()

    if next_id:
        self.load_image(next_id)
        self.image_list_panel.select_image(next_id)
        self.update_counter()

        status = self.controller.get_audit_status(next_id)
        self.control_panel.update_audit_status(status)

on_audit_comment_changed

on_audit_comment_changed(comment: str) -> None

Handle audit comment change.

Parameters:

Name Type Description Default
comment str

Comment text.

required
Source code in boxlab/annotator/__init__.py
def on_audit_comment_changed(self, comment: str) -> None:
    """Handle audit comment change.

    Args:
        comment: Comment text.
    """
    current_id = self.controller.get_current_image_id()
    if current_id:
        self.controller.set_audit_comment(current_id, comment)
        self._update_window_title()

show_audit_report

show_audit_report() -> None

Show audit report in a text dialog.

Source code in boxlab/annotator/__init__.py
def show_audit_report(self) -> None:
    """Show audit report in a text dialog."""
    if not self.controller.audit_mode:
        messagebox.showinfo("Info", "Audit mode is not enabled")
        return

    report = self.controller.generate_audit_report()

    dialog = tk.Toplevel(self.root)
    dialog.title("Audit Report")
    dialog.geometry("600x400")
    dialog.transient(self.root)  # type: ignore

    dialog.update_idletasks()
    x = (dialog.winfo_screenwidth() // 2) - (600 // 2)
    y = (dialog.winfo_screenheight() // 2) - (400 // 2)
    dialog.geometry(f"+{x}+{y}")

    text_frame = ttk.Frame(dialog, padding=10)
    text_frame.pack(fill="both", expand=True)

    text = tk.Text(text_frame, wrap="word", font=("Consolas", 10))
    scrollbar = ttk.Scrollbar(text_frame, command=text.yview)
    text.config(yscrollcommand=scrollbar.set)

    text.pack(side="left", fill="both", expand=True)
    scrollbar.pack(side="right", fill="y")

    text.insert("1.0", report)
    text.config(state="disabled")

    ttk.Button(dialog, text="Close", command=dialog.destroy, width=12).pack(pady=10)

export_audit_report

export_audit_report() -> None

Export audit report to JSON file.

Source code in boxlab/annotator/__init__.py
def export_audit_report(self) -> None:
    """Export audit report to JSON file."""
    if not self.controller.audit_mode:
        messagebox.showinfo("Info", "Audit mode is not enabled")
        return

    filepath = filedialog.asksaveasfilename(
        defaultextension=".json",
        filetypes=[("JSON Files", "*.json"), ("All Files", "*.*")],
        title="Export Audit Report (JSON)",
    )

    if filepath:
        try:
            self.controller.generate_audit_report_json(filepath)
            self.status_var.set(f"✓ Audit report exported: {filepath}")
            messagebox.showinfo("Success", f"Audit report (JSON) exported to:\n{filepath}")
        except Exception as e:
            messagebox.showerror("Error", f"Failed to export report:\n{e}")
            logger.error(f"Failed to export report: {e}", exc_info=True)

open_current_dashboard

open_current_dashboard() -> None

Open Streamlit dashboard for current audit state.

Source code in boxlab/annotator/__init__.py
def open_current_dashboard(self) -> None:
    """Open Streamlit dashboard for current audit state."""
    if not self.controller.audit_mode:
        messagebox.showinfo("Info", "Audit mode is not enabled")
        return

    import subprocess
    import tempfile
    import threading

    # Export current audit state to temp file
    with tempfile.NamedTemporaryFile(
        mode="w",
        suffix=".json",
        delete=False,
        prefix="boxlab_audit_",
    ) as temp_file:
        temp_path = temp_file.name

    try:
        self.controller.generate_audit_report_json(temp_path)

        # Get dashboard path
        dashboard_path = pathlib.Path(__file__).parent / "streamlit" / "audit.py"

        if not dashboard_path.exists():
            messagebox.showerror("Error", f"Dashboard not found: {dashboard_path}")
            return

        # Show dialog with server info
        dialog = tk.Toplevel(self.root)
        dialog.title("Audit Dashboard Server")
        dialog.geometry("500x300")
        dialog.transient(self.root)

        # Center dialog
        dialog.update_idletasks()
        x = (dialog.winfo_screenwidth() // 2) - 250
        y = (dialog.winfo_screenheight() // 2) - 150
        dialog.geometry(f"+{x}+{y}")

        main_frame = ttk.Frame(dialog, padding=20)
        main_frame.pack(fill="both", expand=True)

        ttk.Label(
            main_frame, text="🚀 Starting Audit Dashboard...", font=("Segoe UI", 12, "bold")
        ).pack(pady=(0, 10))

        status_var = tk.StringVar(value="Initializing server...")
        status_label = ttk.Label(main_frame, textvariable=status_var)
        status_label.pack(pady=10)

        # Server info
        info_frame = ttk.LabelFrame(main_frame, text="Server Information", padding=10)
        info_frame.pack(fill="both", expand=True, pady=10)

        url_var = tk.StringVar(value="Starting...")
        ttk.Label(info_frame, text="Local URL:", font=("Segoe UI", 9, "bold")).pack(anchor="w")
        url_label = ttk.Label(info_frame, textvariable=url_var, foreground="blue")
        url_label.pack(anchor="w", pady=(0, 10))

        network_var = tk.StringVar(value="Starting...")
        ttk.Label(info_frame, text="Network URL:", font=("Segoe UI", 9, "bold")).pack(
            anchor="w"
        )
        network_label = ttk.Label(info_frame, textvariable=network_var, foreground="blue")
        network_label.pack(anchor="w")

        ttk.Label(
            info_frame,
            text="💡 Share the Network URL with your team to access the dashboard",
            font=("Segoe UI", 8),
            foreground="gray",
        ).pack(pady=(10, 0))

        # Buttons
        button_frame = ttk.Frame(main_frame)
        button_frame.pack(pady=(10, 0))

        process_container: dict[str, subprocess.Popen[str] | None] = {"process": None}

        def stop_server() -> None:
            if process_container["process"]:
                process_container["process"].terminate()
            dialog.destroy()

        def open_browser() -> None:
            import webbrowser

            url = url_var.get()
            if url.startswith("http"):
                webbrowser.open(url)

        open_btn = ttk.Button(button_frame, text="Open in Browser", command=open_browser)
        open_btn.pack(side="left", padx=5)
        open_btn.config(state="disabled")

        ttk.Button(button_frame, text="Stop Server", command=stop_server).pack(
            side="left", padx=5
        )

        dialog.protocol("WM_DELETE_WINDOW", stop_server)

        # Start server in background
        def start_server() -> None:
            try:
                # Start streamlit server
                cmd = [
                    sys.executable,
                    "-m",
                    "streamlit",
                    "run",
                    str(dashboard_path),
                    "--server.headless=true",
                    "--server.port=8501",
                    "--",
                    temp_path,
                ]

                process = subprocess.Popen(
                    cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1
                )

                process_container["process"] = process

                # Parse output for URLs
                import socket

                hostname = socket.gethostname()
                local_ip = socket.gethostbyname(hostname)

                local_url = "http://localhost:8501"
                network_url = f"http://{local_ip}:8501"

                # Update UI
                dialog.after(2000, status_var.set, "✅ Server is running!")
                dialog.after(2000, url_var.set, local_url)
                dialog.after(2000, network_var.set, network_url)
                dialog.after(2000, lambda _: open_btn.config(state="normal"), "fuck")

                # Auto-open browser
                import webbrowser

                dialog.after(3000, webbrowser.open, local_url, 0, True)

            except Exception as e:
                dialog.after(0, status_var.set, f"❌ Error: {e}")
                logger.error(f"Failed to start dashboard: {e}", exc_info=True)

        # Start server thread
        server_thread = threading.Thread(target=start_server, daemon=True)
        server_thread.start()

    except Exception as e:
        messagebox.showerror("Error", f"Failed to start dashboard:\n{e}")
        logger.error(f"Failed to start dashboard: {e}", exc_info=True)

open_file_dashboard

open_file_dashboard() -> None

Open Streamlit dashboard for an external audit report file.

Source code in boxlab/annotator/__init__.py
def open_file_dashboard(self) -> None:
    """Open Streamlit dashboard for an external audit report
    file."""
    import subprocess

    # Select JSON file
    filepath = filedialog.askopenfilename(
        title="Select Audit Report", filetypes=[("JSON Files", "*.json"), ("All Files", "*.*")]
    )

    if not filepath:
        return

    # Verify it's a valid audit report
    try:
        with pathlib.Path(filepath).open("r", encoding="utf-8") as f:
            report = json.load(f)
            if "metadata" not in report or "images" not in report:
                messagebox.showerror("Error", "Invalid audit report format")
                return
    except Exception as e:
        messagebox.showerror("Error", f"Failed to load report:\n{e}")
        return

    # Get dashboard path
    dashboard_path = pathlib.Path(__file__).parent / "streamlit" / "audit.py"

    if not dashboard_path.exists():
        messagebox.showerror("Error", f"Dashboard not found: {dashboard_path}")
        return

    # Same dialog as open_current_dashboard but with filepath
    # (copy the dialog code from above, just use filepath instead of temp_path)

    # Simplified version: just open in browser
    try:
        cmd = [
            sys.executable,
            "-m",
            "streamlit",
            "run",
            str(dashboard_path),
            "--server.headless=false",
            "--",
            filepath,
        ]

        subprocess.Popen(cmd)

        messagebox.showinfo(
            "Dashboard",
            f"Opening dashboard for:\n{pathlib.Path(filepath).name}\n\n"
            "The dashboard will open in your default browser.",
        )

    except Exception as e:
        messagebox.showerror("Error", f"Failed to start dashboard:\n{e}")
        logger.error(f"Failed to start dashboard: {e}", exc_info=True)

update_after_load

update_after_load() -> None

Update UI after loading dataset or workspace.

Source code in boxlab/annotator/__init__.py
def update_after_load(self) -> None:
    """Update UI after loading dataset or workspace."""
    splits = self.controller.get_splits()
    current_split = splits[0] if splits else None

    if current_split:
        self.control_panel.set_splits(splits, current_split)
        self.on_split_changed(current_split)

    categories = self.controller.get_categories()
    self.control_panel.set_categories(categories)

    self.info_panel.set_available_tags(self.controller.available_tags)

    self.info_panel.update_dataset_info(self.controller.get_dataset_info())

    if self.controller.audit_mode:
        self.image_list_panel.show_audit_filter(True)
        self.image_list_panel.set_audit_status_map(self.controller.audit_status)
        self.control_panel.on_audit_mode_toggle()

update_counter

update_counter() -> None

Update image counter display.

Source code in boxlab/annotator/__init__.py
def update_counter(self) -> None:
    """Update image counter display."""
    current = self.controller.get_current_index()
    total = self.controller.get_split_size()
    self.control_panel.update_counter(current, total)

delete_selected

delete_selected() -> None

Delete selected annotation from canvas.

Source code in boxlab/annotator/__init__.py
def delete_selected(self) -> None:
    """Delete selected annotation from canvas."""
    if self.canvas.delete_selected():
        self.status_var.set("⚠️ Annotation deleted - Unsaved changes")

undo

undo() -> None

Undo last annotation change on canvas.

Source code in boxlab/annotator/__init__.py
def undo(self) -> None:
    """Undo last annotation change on canvas."""
    if self.canvas.undo():
        self.status_var.set("↶ Undo - Unsaved changes")

zoom_in

zoom_in() -> None

Zoom in on canvas.

Source code in boxlab/annotator/__init__.py
def zoom_in(self) -> None:
    """Zoom in on canvas."""
    self.canvas.zoom_in()

zoom_out

zoom_out() -> None

Zoom out on canvas.

Source code in boxlab/annotator/__init__.py
def zoom_out(self) -> None:
    """Zoom out on canvas."""
    self.canvas.zoom_out()

reset_zoom

reset_zoom() -> None

Reset canvas zoom to 100%.

Source code in boxlab/annotator/__init__.py
def reset_zoom(self) -> None:
    """Reset canvas zoom to 100%."""
    self.canvas.reset_zoom()

show_shortcuts

show_shortcuts() -> None

Show keyboard shortcuts help dialog.

Source code in boxlab/annotator/__init__.py
    def show_shortcuts(self) -> None:
        """Show keyboard shortcuts help dialog."""
        shortcuts = """
Keyboard Shortcuts:

Navigation:
\t\t\tPrevious image
\t\t\tNext image

View:
  Ctrl + MouseWheel\t\tZoom in/out
  MouseWheel\t\tScroll vertically
  Shift + MouseWheel\tScroll horizontally
  Ctrl + 0\t\t\tReset zoom
  Middle Button Drag\t\tPan view

Editing:
  Delete\t\t\tDelete selected annotation
  Shift + Delete\t\tDelete current image
  Ctrl + Z\t\t\tUndo last change
  Right Click\t\tShow delete menu
  Drag Corners\t\tResize (diagonal)
  Drag Edges\t\tResize (horizontal/vertical)
  Click BBox\t\tSelect annotation

Audit:
  F1\t\t\tApprove current image
  F2\t\t\tReject current image

File:
  Ctrl + O\t\t\tOpen workspace
  Ctrl + S\t\t\tSave workspace
  Ctrl + Shift + S\t\tSave workspace as
"""
        messagebox.showinfo("Keyboard Shortcuts", shortcuts)

show_about

show_about() -> None

Show about dialog with version information.

Source code in boxlab/annotator/__init__.py
    def show_about(self) -> None:
        """Show about dialog with version information."""
        about_text = f"""
Boxlab Annotator
Version {__version__}

A simple tool for viewing and editing
object detection datasets in COCO and YOLO formats.

Features:
• Import COCO/YOLO datasets
• View and edit bounding boxes
• Multi-split support
• Export with flexible naming
"""
        messagebox.showinfo("About", about_text)

on_closing

on_closing() -> None

Handle window close event with unsaved changes check.

Source code in boxlab/annotator/__init__.py
def on_closing(self) -> None:
    """Handle window close event with unsaved changes check."""
    if not self._check_unsaved_changes():
        return
    self.root.quit()

run

run() -> None

Start the application main loop.

Source code in boxlab/annotator/__init__.py
def run(self) -> None:
    """Start the application main loop."""
    logger.info("Starting annotator application")
    self.root.mainloop()

options: show_root_heading: true show_source: false heading_level: 2 members_order: source show_signature_annotations: true separate_signature: true members: - init - run - import_dataset - export_dataset - load_workspace - save_workspace - save_workspace_as - next_image - prev_image - approve_current - reject_current

Overview

The Boxlab Annotator is a desktop GUI application for viewing, editing, and auditing object detection datasets. It provides an intuitive interface for working with COCO and YOLO format datasets, with features designed for efficient annotation workflows.

Key Features

Dataset Management

  • Import COCO and YOLO format datasets
  • Import raw image directories
  • Export to COCO or YOLO with flexible naming strategies
  • Multi-split support (train/val/test)
  • Workspace persistence (.cyw files)

Annotation Editing

  • Visual bounding box display
  • Drag-to-resize with corner and edge handles
  • Point-and-click selection
  • Delete and undo operations
  • Category assignment
  • Real-time validation

Audit Workflow

  • Approve/reject images
  • Add audit comments
  • Filter by audit status
  • Generate audit reports
  • Track audit statistics
  • JSON report export

Additional Features

  • Image tagging system
  • Auto-backup on crashes
  • Keyboard shortcuts
  • Zoom and pan
  • Status indicators
  • Image metadata display

Application Layout

┌──────────────────────────────────────────────────────────────┐
│ Menu Bar (File | View | Audit | Help)                        │
├─────────────┬───────────────────────────────┬────────────────┤
│             │                               │                │
│   Image     │        Canvas                 │   Info Panel   │
│   List      │     (Annotations)             │   - Metadata   │
│   Panel     │                               │   - Tags       │
│   - Splits  │                               │   - Stats      │
│   - Filter  │                               │   - Audit      │
│             │                               │                │
│             ├───────────────────────────────┤                │
│             │    Control Panel              │                │
│             │  [◀ Prev] [Split] [Next ▶]    │                │
│             │  [Edit] [Category] [Audit]    │                │
├─────────────┴───────────────────────────────┴────────────────┤
│ Status Bar                                                   │
└──────────────────────────────────────────────────────────────┘

Quick Start

Basic Usage

from boxlab.annotator import AnnotatorApp

# Create and run the application
app = AnnotatorApp()
app.run()

Command Line

# Run from command line
python -m boxlab annotator

# Or use the installed command
boxlab annotator

Workflows

Import and View Workflow

  1. Launch the annotator
  2. File → Import Dataset...
  3. Select format (COCO/YOLO/Raw)
  4. Choose dataset path
  5. Select splits to load
  6. Browse images with arrow keys or image list

Annotation Editing Workflow

  1. Import dataset
  2. Enable Edit Mode (Control Panel)
  3. Select category
  4. Click and drag to create bbox
  5. Drag corners/edges to resize
  6. Click bbox to select
  7. Press Delete to remove
  8. Ctrl+Z to undo
  9. File → Save Workspace

Audit Workflow

  1. Import dataset
  2. Enable Audit Mode (Control Panel)
  3. Review current image
  4. Press F1 to approve or F2 to reject
  5. Add comments (optional)
  6. Automatically moves to next image
  7. View progress in Info Panel
  8. Export audit report when complete

Workspace Management

  1. Work on dataset with edits/audits
  2. File → Save Workspace (Ctrl+S)
  3. Saves as .cyw file
  4. File → Open Workspace (Ctrl+O)
  5. Restores complete state

Keyboard Shortcuts

  • Previous image
  • Next image

View

  • Ctrl + Mouse Wheel Zoom in/out
  • Mouse Wheel Scroll vertically
  • Shift + Mouse Wheel Scroll horizontally
  • Ctrl + 0 Reset zoom
  • Middle Mouse Drag Pan view

Editing

  • Delete Delete selected annotation
  • Ctrl + Z Undo last change
  • Right Click Show context menu
  • Drag corners: Resize diagonally
  • Drag edges: Resize horizontally/vertically
  • Click bbox: Select annotation

Audit

  • F1 Approve current image
  • F2 Reject current image

File

  • Ctrl + O Open workspace
  • Ctrl + S Save workspace
  • Ctrl + Shift + S Save workspace as

Auto-Backup

The annotator automatically creates backups in case of crashes:

  • Backup location: ~/.boxlab/backups/
  • Triggered on uncaught exceptions
  • Includes all annotations and audit status
  • Displayed in error dialog
  • Load via File → Open Workspace

File Formats

Workspace Files (.cyw)

Workspace files preserve:

  • Dataset structure and metadata
  • All annotations (original + edits)
  • Audit status and comments
  • Image tags
  • Current view state

Audit Reports (JSON)

{
  "generated_at": "2025-01-16T13:51:01Z",
  "total_images": 1000,
  "approved": 850,
  "rejected": 100,
  "pending": 50,
  "images": [
    {
      "image_id": "001",
      "file_name": "image1.jpg",
      "status": "approved",
      "comment": "Good quality",
      "audited_at": "2025-01-16T13:45:00Z"
    }
  ]
}

See Also