← Function reference

src/beambot/mcp_server/point_selector_gui.py

API source snapshot · 2026-09-24

#!/usr/bin/env python3
"""Select an image pixel for the MCP get_point_3d tool using Tkinter and Pillow.

Usage:
    python3 point_selector_gui.py <image_path> [--x 500] [--y 300] [--title "Point Selector"]

One JSON line on stdout, in original image coordinates:
    Confirmed: {"pixel_x": 485, "pixel_y": 412, "confirmed": true}
    Cancelled: {"confirmed": false}
"""

import argparse
import json
import os
import subprocess as _sp
import sys
import tkinter as tk
from pathlib import Path

from PIL import Image, ImageDraw, ImageTk


def _ensure_display():
    """Fill missing DISPLAY and XAUTHORITY for X11 access."""
    if not os.environ.get("DISPLAY"):
        try:
            result = _sp.run(
                ["bash", "-c",
                 "cat /proc/$(pgrep -u $USER -x gnome-shell || "
                 "pgrep -u $USER -x Xwayland || echo 1)/environ "
                 "2>/dev/null | tr '\\0' '\\n' | grep ^DISPLAY= | "
                 "head -1 | cut -d= -f2"],
                capture_output=True, text=True, timeout=3,
            )
            os.environ["DISPLAY"] = result.stdout.strip() or ":1"
        except Exception:
            os.environ["DISPLAY"] = ":1"

    if not os.environ.get("XAUTHORITY"):
        for candidate in [
            Path(f"/run/user/{os.getuid()}/gdm/Xauthority"),
            Path.home() / ".Xauthority",
        ]:
            if candidate.exists():
                os.environ["XAUTHORITY"] = str(candidate)
                break


class PointSelector:
    """Display an image and confirm or cancel a pixel selection."""

    CROSSHAIR_SIZE = 20
    MAX_WINDOW_W = 1280
    MAX_WINDOW_H = 800

    def __init__(
        self,
        image_path: str,
        initial_point: tuple[int, int] | None = None,
        title: str = "Point Selector",
    ):
        self.pil_image = Image.open(image_path)
        self.img_w, self.img_h = self.pil_image.size
        self.title = title
        # Selection uses original image pixels.
        self.current_point = initial_point
        self.result: dict | None = None

        # Fit the image without upscaling.
        self.scale = min(
            self.MAX_WINDOW_W / self.img_w,
            self.MAX_WINDOW_H / self.img_h,
            1.0,
        )
        self.disp_w = int(self.img_w * self.scale)
        self.disp_h = int(self.img_h * self.scale)

        self._base_display = self.pil_image.resize(
            (self.disp_w, self.disp_h), Image.LANCZOS,
        )

    def _draw_crosshair(self) -> ImageTk.PhotoImage:
        """Render the selection overlay on the scaled image."""
        img = self._base_display.copy()
        if self.current_point is not None:
            draw = ImageDraw.Draw(img)
            # Convert selection coordinates to display pixels.
            dx = int(self.current_point[0] * self.scale)
            dy = int(self.current_point[1] * self.scale)
            s = self.CROSSHAIR_SIZE

            draw.line([(dx - s, dy), (dx + s, dy)], fill="red", width=2)
            draw.line([(dx, dy - s), (dx, dy + s)], fill="red", width=2)

            label = f"({self.current_point[0]}, {self.current_point[1]})"
            draw.text((dx + 12, dy - 18), label, fill="white")
            draw.text((dx + 11, dy - 19), label, fill="black")

        return ImageTk.PhotoImage(img)

    def run(self) -> dict:
        """Return confirmed coordinates or a cancellation result."""
        root = tk.Tk()
        root.title(self.title)
        root.configure(bg="#282828")

        banner = tk.Label(
            root,
            text="Click to select point.  Enter/Space = confirm,  Esc = cancel",
            bg="#282828",
            fg="#cccccc",
            font=("monospace", 11),
            pady=8,
        )
        banner.pack(fill=tk.X)

        canvas = tk.Canvas(
            root, width=self.disp_w, height=self.disp_h,
            highlightthickness=0, bg="black",
        )
        canvas.pack()

        # Keep the PhotoImage alive for Tk.
        self._tk_photo = self._draw_crosshair()
        canvas_image = canvas.create_image(0, 0, anchor=tk.NW, image=self._tk_photo)

        def _update_display():
            self._tk_photo = self._draw_crosshair()
            canvas.itemconfig(canvas_image, image=self._tk_photo)

        def _on_click(event):
            # Map clicks back to original image pixels and clamp to bounds.
            orig_x = int(event.x / self.scale)
            orig_y = int(event.y / self.scale)
            orig_x = max(0, min(orig_x, self.img_w - 1))
            orig_y = max(0, min(orig_y, self.img_h - 1))
            self.current_point = (orig_x, orig_y)
            _update_display()

        def _on_confirm(event=None):
            if self.current_point is not None:
                self.result = {
                    "pixel_x": self.current_point[0],
                    "pixel_y": self.current_point[1],
                    "confirmed": True,
                }
                root.destroy()

        def _on_cancel(event=None):
            self.result = {"confirmed": False}
            root.destroy()

        canvas.bind("<Button-1>", _on_click)
        root.bind("<Return>", _on_confirm)
        root.bind("<space>", _on_confirm)
        root.bind("<Escape>", _on_cancel)
        root.protocol("WM_DELETE_WINDOW", _on_cancel)

        # Center the window.
        root.update_idletasks()
        sw = root.winfo_screenwidth()
        sh = root.winfo_screenheight()
        x = (sw - self.disp_w) // 2
        y = (sh - self.disp_h) // 2
        root.geometry(f"+{x}+{y}")

        root.mainloop()
        return self.result or {"confirmed": False}


def main():
    _ensure_display()
    parser = argparse.ArgumentParser(description="Point selector GUI")
    parser.add_argument("image_path", help="Path to the image file")
    parser.add_argument("--x", type=int, default=None, help="Initial X coordinate")
    parser.add_argument("--y", type=int, default=None, help="Initial Y coordinate")
    parser.add_argument("--title", default="Point Selector", help="Window title")
    args = parser.parse_args()

    initial_point = None
    if args.x is not None and args.y is not None:
        initial_point = (args.x, args.y)

    try:
        selector = PointSelector(args.image_path, initial_point, args.title)
        result = selector.run()
    except Exception as e:
        result = {"confirmed": False, "error": str(e)}
        print(json.dumps(result))
        sys.exit(1)

    print(json.dumps(result))


if __name__ == "__main__":
    main()