Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Consider tkinter as stand-alone annotation interface #506

Open
kallewesterling opened this issue Sep 25, 2024 · 1 comment
Open

Consider tkinter as stand-alone annotation interface #506

kallewesterling opened this issue Sep 25, 2024 · 1 comment
Labels
enhancement New feature or request new feature

Comments

@kallewesterling
Copy link
Contributor

kallewesterling commented Sep 25, 2024

This is a quick prototype that I was working on today

import tkinter as tk
from tkinter import filedialog
from PIL import Image, ImageTk
import json
import os
from pathlib import Path

PATH_WITH_PATCHES = "ipywidgets-test-patches"
PER_ROW = 4
ROWS = 3


class AnnotationTool:
    def __init__(self, root, username="#user#"):
        self.username = username

        self.root = root
        self.root.title("Image Annotation Tool")

        # Read settings file for labels
        self.settings_file = "settings.json"
        self.load_settings()

        # Load annotations
        self.load_annotations()
        annotated_files = [x[0] for x in self.image_labels]

        # Load images (replace with your own paths or file loader)
        self.images = [
            str(x)
            for x in Path(PATH_WITH_PATCHES).glob("*.png")
            if not str(x) in annotated_files
        ]

        self.load_images()

        # Track which images are currently visible (starting images)
        self.current_images = self.images[: PER_ROW * ROWS]

        # Add instructional text
        self.add_instruction_text()

        # Layout images in a grid
        self.create_image_grid()

        # Add status bar at the bottom
        self.add_status_bar()

    def load_settings(self):
        """Load the labels from the settings file."""
        if os.path.exists(self.settings_file):
            with open(self.settings_file, "r") as file:
                self.labels = json.load(file)
        else:
            self.labels = {
                "true": "Label1",
                "false": "Label2",
            }  # Default labels
        print(f"Loaded labels: {self.labels}")

    def load_images(self):
        """Load and resize the images."""
        self.tk_images = []
        for img_path in self.images:
            image = Image.open(img_path).resize((100, 100))
            self.tk_images.append(ImageTk.PhotoImage(image))

    def add_instruction_text(self):
        """Add a label to display instructional text."""
        instructions = (
            "Click an image to annotate it as 'True'.\n"
            "Hold the Command key (Mac) or Ctrl key (Windows) and click to annotate as 'False'.\n"
            "The next image will appear after a click."
        )

        self.instruction_label = tk.Label(
            self.root,
            text=instructions,
            font=("Helvetica", 12),
            justify="left",
        )
        self.instruction_label.grid(row=0, column=0, columnspan=3, pady=10)

    def create_image_grid(self):
        """Display images in a 3x3 grid and set up event handlers."""
        self.labels_widgets = []

        for i in range(PER_ROW * ROWS):
            row, col = divmod(i, PER_ROW)
            label = tk.Label(
                self.root,
                image=self.tk_images[i],
                borderwidth=2,
                relief="solid",
            )
            label.grid(row=row + 1, column=col, padx=5, pady=5)

            # Save reference to the label and bind mouse events
            self.labels_widgets.append(label)
            label.bind(
                "<Button-1>",
                lambda event, idx=i: self.annotate_image(event, idx),
            )

    def annotate_image(self, event, index):
        """Annotate an image based on a click event."""
        if (
            event.state & 0x0004
        ):  # Command key held down (for MacOS) or 'Ctrl' for other systems
            label = self.labels["false"]
        else:
            label = self.labels["true"]

        self.image_labels.append((str(self.images[index]), label))
        print(f"Annotated {self.images[index]} as {label}")

        # Save annotation after each click
        self.save_annotations()

        # Remove the clicked image from the queue and show the next one
        self.show_next_image(index)

        # Update the status bar with the annotated image and label
        self.update_status_bar(self.images[index], label)

    def load_annotations(self):
        """Load annotations from a JSON file."""
        if os.path.exists(self.annotation_file):
            with open(self.annotation_file, "r") as file:
                self.image_labels = json.load(file)
        else:
            self.image_labels = []

        print(f"Loaded annotations: {self.image_labels}")

    def save_annotations(self):
        """Save annotations to a JSON file."""
        with open(self.annotation_file, "w") as file:
            json.dump(self.image_labels, file)

        print(f"Annotations saved: {self.image_labels}")

    def show_next_image(self, index):
        """Show the next image in the queue after an image is clicked."""
        # Remove the current image and load the next image in the queue
        if self.images:
            next_image_index = len(self.image_labels)
            if next_image_index < len(self.images):
                # Replace the clicked image with the next one in the list
                next_image = self.tk_images[next_image_index]
                self.labels_widgets[index].configure(image=next_image)
                self.labels_widgets[index].image = (
                    next_image  # To prevent image garbage collection
                )
            else:
                # If no more images, hide the label
                self.labels_widgets[index].grid_forget()

    def add_status_bar(self):
        """Create a status bar at the bottom that shows the last annotated image and label."""
        self.status_frame = tk.Frame(self.root, relief="sunken", bd=2)
        self.status_frame.grid(row=5, column=0, columnspan=3, sticky="we")

        # Placeholder for image
        self.status_image_label = tk.Label(self.status_frame)
        self.status_image_label.grid(row=0, column=0, padx=10)

        # Placeholder for text
        self.status_text_label = tk.Label(
            self.status_frame,
            text="No image annotated yet",
            font=("Helvetica", 10),
        )
        self.status_text_label.grid(row=0, column=1, padx=10)

    def update_status_bar(self, image_path, label):
        """Update the status bar with a small version of the last annotated image and its label."""
        # Load and resize the image to 50x50 for the status bar
        small_image = Image.open(image_path).resize((50, 50))
        tk_small_image = ImageTk.PhotoImage(small_image)

        # Update the image label in the status bar
        self.status_image_label.configure(image=tk_small_image)
        self.status_image_label.image = (
            tk_small_image  # Prevent garbage collection
        )

        # Update the text label in the status bar
        self.status_text_label.configure(text=f"Last annotation: {label}")

    @property
    def annotation_file(self):
        return f"{self.username}_annotations.json"


if __name__ == "__main__":
    root = tk.Tk()
    tool = AnnotationTool(root)
    root.mainloop()
@kallewesterling
Copy link
Contributor Author

If we could split out the logic for (a) saving annotations and (b) into different code pieces (objects/methods/functions), we could implement different tools for doing the annotation. tkinter could be one, LabelStudio another, Zooniverse a third... 🤔

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request new feature
Projects
Status: Upcoming
Development

No branches or pull requests

1 participant