Skip to content

Visual tools

The 23 visual tools that operate on the active image: geometry, intensity and contrast, edge and morphology, read-only measurement, and annotation. For descriptions of each tool and its effects, see Tools.

visual

Visual tools for radiology image analysis.

Provides zoom, crop, contrast adjustment, thresholding, flip, and rotate tools for interactive image analysis by VLMs.

This module combines: - Core image operations (zoom, crop, contrast, threshold, flip, rotate) - Tool wrappers for VLM integration - Tool factory (create_visual_tools)

zoom_image

zoom_image(
    image: Image,
    factor: float,
    config: ImageProcessingConfig | None = None,
) -> Image.Image

Return a zoomed version of image by scaling with factor.

Parameters:

Name Type Description Default
image Image

Input PIL Image

required
factor float

Zoom factor (must be in configured range)

required
config ImageProcessingConfig | None

Optional image config. If None, uses global default.

None

Returns:

Type Description
Image

Zoomed image

Raises:

Type Description
ValueError

If factor is out of valid range

Source code in src/gaze/tools/visual.py
@beartype
def zoom_image(
    image: Image.Image,
    factor: float,
    config: ImageProcessingConfig | None = None,
) -> Image.Image:
    """Return a zoomed version of *image* by scaling with *factor*.

    Args:
        image: Input PIL Image
        factor: Zoom factor (must be in configured range)
        config: Optional image config. If None, uses global default.

    Returns:
        Zoomed image

    Raises:
        ValueError: If factor is out of valid range
    """
    cfg = config or _get_image_config()

    if not cfg.min_zoom_factor <= factor <= cfg.max_zoom_factor:
        raise ValueError(
            f"factor must be in range [{cfg.min_zoom_factor}, {cfg.max_zoom_factor}], got {factor}"
        )

    width, height = image.size
    new_w = int(width * factor)
    new_h = int(height * factor)

    # Reject zooms that would exceed the maximum allowed dimension.
    max_dim = cfg.max_image_dimension
    if new_w > max_dim or new_h > max_dim:
        raise ValueError(
            f"Zoom would produce {new_w}x{new_h} which exceeds "
            f"max_image_dimension={max_dim}. Use a smaller factor or crop first."
        )

    # Ensure minimum size while preserving aspect ratio.
    # Per-axis clamping would distort the image, which is unacceptable
    # for diagnostic medical imaging where geometry must be faithful.
    min_sz = cfg.min_image_size
    if new_w < min_sz or new_h < min_sz:
        safe_w = max(1, new_w)
        safe_h = max(1, new_h)
        scale_up = max(min_sz / safe_w, min_sz / safe_h)
        new_w = max(min_sz, int(safe_w * scale_up))
        new_h = max(min_sz, int(safe_h * scale_up))

    return image.resize((new_w, new_h), Image.Resampling.LANCZOS)

crop_image

crop_image(
    image: Image,
    box: tuple[float, float, float, float],
    config: ImageProcessingConfig | None = None,
) -> Image.Image

Crop image using normalized coordinates (x1, y1, x2, y2) in range 0-1.

Parameters:

Name Type Description Default
image Image

Input PIL Image

required
box tuple[float, float, float, float]

Tuple of (x1, y1, x2, y2) normalized coordinates in range [0, 1] where x2 > x1 and y2 > y1

required
config ImageProcessingConfig | None

Optional image config. If None, uses global default.

None

Returns:

Type Description
Image

Cropped image

Raises:

Type Description
ValueError

If image is too small to crop, coordinates are out of range, or resulting crop would be too small

Source code in src/gaze/tools/visual.py
@beartype
def crop_image(
    image: Image.Image,
    box: tuple[float, float, float, float],
    config: ImageProcessingConfig | None = None,
) -> Image.Image:
    """Crop *image* using normalized coordinates (x1, y1, x2, y2) in range 0-1.

    Args:
        image: Input PIL Image
        box: Tuple of (x1, y1, x2, y2) normalized coordinates in range [0, 1]
             where x2 > x1 and y2 > y1
        config: Optional image config. If None, uses global default.

    Returns:
        Cropped image

    Raises:
        ValueError: If image is too small to crop, coordinates are out of range,
                   or resulting crop would be too small
    """
    cfg = config or _get_image_config()
    min_size = cfg.min_image_size

    width, height = image.size
    x1_norm, y1_norm, x2_norm, y2_norm = box

    if not all(0 <= coord <= 1 for coord in box):
        raise ValueError(f"All coordinates must be in range [0, 1], got box={box}")

    if x2_norm <= x1_norm or y2_norm <= y1_norm:
        raise ValueError(f"Invalid crop box: x2 must be > x1 and y2 must be > y1, got box={box}")

    if width < min_size or height < min_size:
        raise ValueError(
            f"Image too small to crop: {width}x{height}. "
            f"Minimum size is {min_size}x{min_size} pixels."
        )

    x1 = int(x1_norm * width)
    y1 = int(y1_norm * height)
    x2 = int(x2_norm * width)
    y2 = int(y2_norm * height)

    # Ensure coordinates are within image bounds
    x1 = max(0, min(x1, width - 1))
    y1 = max(0, min(y1, height - 1))
    x2 = max(x1 + 1, min(x2, width))
    y2 = max(y1 + 1, min(y2, height))

    crop_width = x2 - x1
    crop_height = y2 - y1
    if crop_width < min_size or crop_height < min_size:
        raise ValueError(
            f"Resulting crop region too small: {crop_width}x{crop_height} pixels. "
            f"Minimum size is {min_size}x{min_size} pixels."
        )

    return image.crop((x1, y1, x2, y2))

adjust_contrast

adjust_contrast(
    image: Image,
    factor: float,
    config: ImageProcessingConfig | None = None,
) -> Image.Image

Adjust contrast of image by factor.

Parameters:

Name Type Description Default
image Image

Input PIL Image

required
factor float

Contrast factor (must be in configured range). 1.0 = no change, >1.0 increases contrast, <1.0 decreases contrast.

required
config ImageProcessingConfig | None

Optional image config. If None, uses global default.

None

Returns:

Type Description
Image

Contrast-adjusted image

Raises:

Type Description
ValueError

If factor is out of valid range

Source code in src/gaze/tools/visual.py
@beartype
def adjust_contrast(
    image: Image.Image,
    factor: float,
    config: ImageProcessingConfig | None = None,
) -> Image.Image:
    """Adjust contrast of *image* by *factor*.

    Args:
        image: Input PIL Image
        factor: Contrast factor (must be in configured range).
                1.0 = no change, >1.0 increases contrast, <1.0 decreases contrast.
        config: Optional image config. If None, uses global default.

    Returns:
        Contrast-adjusted image

    Raises:
        ValueError: If factor is out of valid range
    """
    cfg = config or _get_image_config()

    if not cfg.min_contrast_factor <= factor <= cfg.max_contrast_factor:
        raise ValueError(
            f"factor must be in range [{cfg.min_contrast_factor}, {cfg.max_contrast_factor}], "
            f"got {factor}"
        )

    enhancer = ImageEnhance.Contrast(image)
    return enhancer.enhance(factor)

apply_intensity_threshold

apply_intensity_threshold(
    image: Image,
    lower: int,
    upper: int,
    config: ImageProcessingConfig | None = None,
) -> Image.Image

Apply intensity threshold to grayscale image and rescale to 0-255.

Parameters:

Name Type Description Default
image Image

Input PIL Image

required
lower int

Lower intensity bound (0-254)

required
upper int

Upper intensity bound (must be > lower, max 255)

required
config ImageProcessingConfig | None

Optional config for min_threshold_window. Uses global default if None.

None

Returns:

Type Description
Image

Thresholded grayscale image with intensities rescaled to 0-255

Raises:

Type Description
ValueError

If bounds are invalid or window width is below minimum

Source code in src/gaze/tools/visual.py
@beartype
def apply_intensity_threshold(
    image: Image.Image,
    lower: int,
    upper: int,
    config: ImageProcessingConfig | None = None,
) -> Image.Image:
    """Apply intensity threshold to grayscale image and rescale to 0-255.

    Args:
        image: Input PIL Image
        lower: Lower intensity bound (0-254)
        upper: Upper intensity bound (must be > lower, max 255)
        config: Optional config for min_threshold_window. Uses global default if None.

    Returns:
        Thresholded grayscale image with intensities rescaled to 0-255

    Raises:
        ValueError: If bounds are invalid or window width is below minimum
    """
    if lower < 0:
        raise ValueError(f"lower must be >= 0, got {lower}")
    if upper > 255:
        raise ValueError(f"upper must be <= 255, got {upper}")
    if upper <= lower:
        raise ValueError(f"upper must be > lower, got lower={lower}, upper={upper}")

    cfg = config or get_config().image
    window_width = upper - lower
    if window_width < cfg.min_threshold_window:
        raise ValueError(
            f"Threshold window width {window_width} (upper={upper} - lower={lower}) "
            f"is below minimum {cfg.min_threshold_window}. "
            f"Narrow windows destroy diagnostic information."
        )

    gray = image.convert("L")
    arr = np.array(gray)
    arr = np.clip(arr, lower, upper)
    # Rescale to 0-255 with explicit clipping to prevent floating point overflow
    arr = np.clip((arr - lower) / (upper - lower) * 255, 0, 255).astype(np.uint8)
    return Image.fromarray(arr)

flip_horizontal

flip_horizontal(image: Image) -> Image.Image

Flip image horizontally (left-right mirror).

Source code in src/gaze/tools/visual.py
@beartype
def flip_horizontal(image: Image.Image) -> Image.Image:
    """Flip image horizontally (left-right mirror)."""
    return image.transpose(Image.Transpose.FLIP_LEFT_RIGHT)

flip_vertical

flip_vertical(image: Image) -> Image.Image

Flip image vertically (top-bottom mirror).

Source code in src/gaze/tools/visual.py
@beartype
def flip_vertical(image: Image.Image) -> Image.Image:
    """Flip image vertically (top-bottom mirror)."""
    return image.transpose(Image.Transpose.FLIP_TOP_BOTTOM)

rotate_90

rotate_90(
    image: Image, clockwise: bool = True
) -> Image.Image

Rotate image by 90 degrees.

Parameters:

Name Type Description Default
image Image

Input image

required
clockwise bool

If True, rotate clockwise; if False, rotate counter-clockwise

True

Returns:

Type Description
Image

Rotated image

Source code in src/gaze/tools/visual.py
@beartype
def rotate_90(image: Image.Image, clockwise: bool = True) -> Image.Image:
    """Rotate image by 90 degrees.

    Args:
        image: Input image
        clockwise: If True, rotate clockwise; if False, rotate counter-clockwise

    Returns:
        Rotated image
    """
    if clockwise:
        return image.transpose(Image.Transpose.ROTATE_270)
    return image.transpose(Image.Transpose.ROTATE_90)

adjust_brightness

adjust_brightness(
    image: Image,
    factor: float,
    config: ImageProcessingConfig | None = None,
) -> Image.Image

Adjust brightness of image by factor.

Parameters:

Name Type Description Default
image Image

Input PIL Image

required
factor float

Brightness factor (must be in configured range). 1.0 = no change, >1.0 increases brightness, <1.0 decreases.

required
config ImageProcessingConfig | None

Optional image config. If None, uses global default.

None

Returns:

Type Description
Image

Brightness-adjusted image

Raises:

Type Description
ValueError

If factor is out of valid range

Source code in src/gaze/tools/visual.py
@beartype
def adjust_brightness(
    image: Image.Image,
    factor: float,
    config: ImageProcessingConfig | None = None,
) -> Image.Image:
    """Adjust brightness of *image* by *factor*.

    Args:
        image: Input PIL Image
        factor: Brightness factor (must be in configured range).
                1.0 = no change, >1.0 increases brightness, <1.0 decreases.
        config: Optional image config. If None, uses global default.

    Returns:
        Brightness-adjusted image

    Raises:
        ValueError: If factor is out of valid range
    """
    cfg = config or _get_image_config()

    if not cfg.min_brightness_factor <= factor <= cfg.max_brightness_factor:
        raise ValueError(
            f"factor must be in range [{cfg.min_brightness_factor}, {cfg.max_brightness_factor}], "
            f"got {factor}"
        )

    enhancer = ImageEnhance.Brightness(image)
    return enhancer.enhance(factor)

adjust_sharpness

adjust_sharpness(
    image: Image,
    factor: float,
    config: ImageProcessingConfig | None = None,
) -> Image.Image

Adjust sharpness of image by factor.

Parameters:

Name Type Description Default
image Image

Input PIL Image

required
factor float

Sharpness factor (must be in configured range). 0.0 = blurred, 1.0 = original, >1.0 = sharpened.

required
config ImageProcessingConfig | None

Optional image config. If None, uses global default.

None

Returns:

Type Description
Image

Sharpness-adjusted image

Raises:

Type Description
ValueError

If factor is out of valid range

Source code in src/gaze/tools/visual.py
@beartype
def adjust_sharpness(
    image: Image.Image,
    factor: float,
    config: ImageProcessingConfig | None = None,
) -> Image.Image:
    """Adjust sharpness of *image* by *factor*.

    Args:
        image: Input PIL Image
        factor: Sharpness factor (must be in configured range).
                0.0 = blurred, 1.0 = original, >1.0 = sharpened.
        config: Optional image config. If None, uses global default.

    Returns:
        Sharpness-adjusted image

    Raises:
        ValueError: If factor is out of valid range
    """
    cfg = config or _get_image_config()

    if not cfg.min_sharpness_factor <= factor <= cfg.max_sharpness_factor:
        raise ValueError(
            f"factor must be in range [{cfg.min_sharpness_factor}, {cfg.max_sharpness_factor}], "
            f"got {factor}"
        )

    enhancer = ImageEnhance.Sharpness(image)
    return enhancer.enhance(factor)

equalize_histogram

equalize_histogram(image: Image) -> Image.Image

Equalize the histogram of image for improved contrast distribution.

Converts to grayscale first since brain MRI is inherently grayscale; per-channel equalization on RGB is meaningless for medical imaging.

Parameters:

Name Type Description Default
image Image

Input PIL Image

required

Returns:

Type Description
Image

Histogram-equalized grayscale image

Source code in src/gaze/tools/visual.py
@beartype
def equalize_histogram(image: Image.Image) -> Image.Image:
    """Equalize the histogram of *image* for improved contrast distribution.

    Converts to grayscale first since brain MRI is inherently grayscale;
    per-channel equalization on RGB is meaningless for medical imaging.

    Args:
        image: Input PIL Image

    Returns:
        Histogram-equalized grayscale image
    """
    gray = image.convert("L")
    return ImageOps.equalize(gray)

get_intensity_stats

get_intensity_stats(
    image: Image,
    box: tuple[float, float, float, float] | None = None,
) -> dict[str, object]

Compute intensity statistics over image or a sub-region.

Parameters:

Name Type Description Default
image Image

Input PIL Image

required
box tuple[float, float, float, float] | None

Optional normalized coordinates (x1, y1, x2, y2) in [0, 1] for a sub-region. If None, computes stats over the full image.

None

Returns:

Type Description
dict[str, object]

Dict with mean, std, min, max, median, and 10-bin histogram.

Raises:

Type Description
ValueError

If box coordinates are invalid

Source code in src/gaze/tools/visual.py
@beartype
def get_intensity_stats(
    image: Image.Image,
    box: tuple[float, float, float, float] | None = None,
) -> dict[str, object]:
    """Compute intensity statistics over *image* or a sub-region.

    Args:
        image: Input PIL Image
        box: Optional normalized coordinates (x1, y1, x2, y2) in [0, 1] for a sub-region.
             If None, computes stats over the full image.

    Returns:
        Dict with mean, std, min, max, median, and 10-bin histogram.

    Raises:
        ValueError: If box coordinates are invalid
    """
    gray = np.array(image.convert("L"))

    if box is not None:
        x1_n, y1_n, x2_n, y2_n = box
        if not all(0 <= c <= 1 for c in box):
            raise ValueError(f"All box coordinates must be in [0, 1], got {box}")
        if x2_n <= x1_n or y2_n <= y1_n:
            raise ValueError(f"Invalid box: x2 must be > x1 and y2 must be > y1, got {box}")
        h, w = gray.shape
        x1 = int(x1_n * w)
        y1 = int(y1_n * h)
        x2 = max(x1 + 1, int(x2_n * w))
        y2 = max(y1 + 1, int(y2_n * h))
        gray = gray[y1:y2, x1:x2]

    histogram, _ = np.histogram(gray, bins=10, range=(0, 255))
    return {
        "mean": float(np.mean(gray)),
        "std": float(np.std(gray)),
        "min": int(np.min(gray)),
        "max": int(np.max(gray)),
        "median": float(np.median(gray)),
        "histogram": histogram.tolist(),
    }

measure_distance

measure_distance(
    image: Image,
    point1: tuple[float, float],
    point2: tuple[float, float],
) -> dict[str, object]

Measure Euclidean distance between two points on image.

Parameters:

Name Type Description Default
image Image

Input PIL Image (used for dimension scaling)

required
point1 tuple[float, float]

Normalized (x, y) coordinates in [0, 1]

required
point2 tuple[float, float]

Normalized (x, y) coordinates in [0, 1]

required

Returns:

Type Description
dict[str, object]

Dict with distance_pixels, point1_pixels, point2_pixels, image_size.

Raises:

Type Description
ValueError

If coordinates are out of [0, 1] range

Source code in src/gaze/tools/visual.py
@beartype
def measure_distance(
    image: Image.Image,
    point1: tuple[float, float],
    point2: tuple[float, float],
) -> dict[str, object]:
    """Measure Euclidean distance between two points on *image*.

    Args:
        image: Input PIL Image (used for dimension scaling)
        point1: Normalized (x, y) coordinates in [0, 1]
        point2: Normalized (x, y) coordinates in [0, 1]

    Returns:
        Dict with distance_pixels, point1_pixels, point2_pixels, image_size.

    Raises:
        ValueError: If coordinates are out of [0, 1] range
    """
    for name, pt in [("point1", point1), ("point2", point2)]:
        if not (0 <= pt[0] <= 1 and 0 <= pt[1] <= 1):
            raise ValueError(f"{name} coordinates must be in [0, 1], got {pt}")

    w, h = image.size
    p1_px = (point1[0] * w, point1[1] * h)
    p2_px = (point2[0] * w, point2[1] * h)
    dist = ((p2_px[0] - p1_px[0]) ** 2 + (p2_px[1] - p1_px[1]) ** 2) ** 0.5

    return {
        "distance_pixels": float(dist),
        "point1_pixels": p1_px,
        "point2_pixels": p2_px,
        "image_size": (w, h),
    }

draw_grid_overlay

draw_grid_overlay(
    image: Image,
    divisions: int,
    config: ImageProcessingConfig | None = None,
) -> Image.Image

Draw a labeled grid overlay on image.

Draws a divisions x divisions grid with cell labels (A1, B2, etc.). Green lines and yellow text on black background for readability on MRI. Works on a copy; does not mutate the input.

Parameters:

Name Type Description Default
image Image

Input PIL Image

required
divisions int

Number of grid divisions per axis (rows and columns)

required
config ImageProcessingConfig | None

Optional image config for max_grid_divisions. If None, uses global default.

None

Returns:

Type Description
Image

Image with grid overlay (RGB mode)

Raises:

Type Description
ValueError

If divisions is out of valid range

Source code in src/gaze/tools/visual.py
@beartype
def draw_grid_overlay(
    image: Image.Image,
    divisions: int,
    config: ImageProcessingConfig | None = None,
) -> Image.Image:
    """Draw a labeled grid overlay on *image*.

    Draws a divisions x divisions grid with cell labels (A1, B2, etc.).
    Green lines and yellow text on black background for readability on MRI.
    Works on a copy; does not mutate the input.

    Args:
        image: Input PIL Image
        divisions: Number of grid divisions per axis (rows and columns)
        config: Optional image config for max_grid_divisions. If None, uses global default.

    Returns:
        Image with grid overlay (RGB mode)

    Raises:
        ValueError: If divisions is out of valid range
    """
    cfg = config or _get_image_config()

    if divisions < 2:
        raise ValueError(f"divisions must be >= 2, got {divisions}")
    if divisions > cfg.max_grid_divisions:
        raise ValueError(f"divisions must be <= {cfg.max_grid_divisions}, got {divisions}")

    # Work on RGB copy
    result = image.convert("RGB").copy()
    draw = ImageDraw.Draw(result)
    w, h = result.size
    font = ImageFont.load_default()

    # Draw grid lines
    grid_color = (0, 255, 0)  # green
    for i in range(1, divisions):
        x = int(i * w / divisions)
        draw.line([(x, 0), (x, h)], fill=grid_color, width=1)
        y = int(i * h / divisions)
        draw.line([(0, y), (w, y)], fill=grid_color, width=1)

    # Label cells
    label_color = (255, 255, 0)  # yellow
    bg_color = (0, 0, 0)  # black
    for row in range(divisions):
        for col in range(divisions):
            label = f"{chr(65 + col)}{row + 1}"
            cx = int((col + 0.5) * w / divisions)
            cy = int((row + 0.5) * h / divisions)
            bbox = font.getbbox(label)
            tw = bbox[2] - bbox[0]
            th = bbox[3] - bbox[1]
            tx = cx - tw // 2
            ty = cy - th // 2
            draw.rectangle([tx - 1, ty - 1, tx + tw + 1, ty + th + 1], fill=bg_color)
            draw.text((tx, ty), label, fill=label_color, font=font)

    return result

detect_edges

detect_edges(
    image: Image, method: str = "sobel"
) -> Image.Image

Detect edges in image using Sobel or Laplacian operators.

Parameters:

Name Type Description Default
image Image

Input PIL Image

required
method str

Edge detection method ("sobel" or "laplacian")

'sobel'

Returns:

Type Description
Image

Grayscale edge map image

Raises:

Type Description
ValueError

If method is not recognized

Source code in src/gaze/tools/visual.py
@beartype
def detect_edges(
    image: Image.Image,
    method: str = "sobel",
) -> Image.Image:
    """Detect edges in *image* using Sobel or Laplacian operators.

    Args:
        image: Input PIL Image
        method: Edge detection method ("sobel" or "laplacian")

    Returns:
        Grayscale edge map image

    Raises:
        ValueError: If method is not recognized
    """
    if method not in ("sobel", "laplacian"):
        raise ValueError(f"method must be 'sobel' or 'laplacian', got {method!r}")

    gray = np.array(image.convert("L"), dtype=np.float64)

    if method == "sobel":
        # Sobel kernels
        kx = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=np.float64)
        ky = np.array([[-1, -2, -1], [0, 0, 0], [1, 2, 1]], dtype=np.float64)
        # Pad to handle borders
        padded = np.pad(gray, 1, mode="edge")
        h, w = gray.shape
        gx = np.zeros_like(gray)
        gy = np.zeros_like(gray)
        for i in range(3):
            for j in range(3):
                gx += kx[i, j] * padded[i : i + h, j : j + w]
                gy += ky[i, j] * padded[i : i + h, j : j + w]
        magnitude = np.sqrt(gx**2 + gy**2)
    else:  # laplacian
        kernel = np.array([[0, 1, 0], [1, -4, 1], [0, 1, 0]], dtype=np.float64)
        padded = np.pad(gray, 1, mode="edge")
        h, w = gray.shape
        result = np.zeros_like(gray)
        for i in range(3):
            for j in range(3):
                result += kernel[i, j] * padded[i : i + h, j : j + w]
        magnitude = np.abs(result)

    # Normalize to 0-255
    if magnitude.max() > 0:
        magnitude = magnitude / magnitude.max() * 255
    return Image.fromarray(magnitude.astype(np.uint8))

compute_symmetry_diff

compute_symmetry_diff(image: Image) -> Image.Image

Compute left-right symmetry difference map.

Flips the image horizontally and computes the absolute pixel-wise difference, highlighting asymmetric regions (potential pathology).

Parameters:

Name Type Description Default
image Image

Input PIL Image

required

Returns:

Type Description
Image

Grayscale difference map (bright = asymmetric regions)

Source code in src/gaze/tools/visual.py
@beartype
def compute_symmetry_diff(image: Image.Image) -> Image.Image:
    """Compute left-right symmetry difference map.

    Flips the image horizontally and computes the absolute pixel-wise
    difference, highlighting asymmetric regions (potential pathology).

    Args:
        image: Input PIL Image

    Returns:
        Grayscale difference map (bright = asymmetric regions)
    """
    gray = np.array(image.convert("L"), dtype=np.float64)
    flipped = np.fliplr(gray)
    diff = np.abs(gray - flipped)
    # Normalize to 0-255
    if diff.max() > 0:
        diff = diff / diff.max() * 255
    return Image.fromarray(diff.astype(np.uint8))

annotate_region

annotate_region(
    image: Image,
    box: tuple[float, float, float, float],
    color: str = "red",
    label: str | None = None,
) -> Image.Image

Draw a bounding box annotation on image.

Works on a copy; does not mutate the input.

Parameters:

Name Type Description Default
image Image

Input PIL Image

required
box tuple[float, float, float, float]

Normalized coordinates (x1, y1, x2, y2) in [0, 1]

required
color str

Box color name (e.g. "red", "green", "yellow")

'red'
label str | None

Optional text label drawn above the box

None

Returns:

Type Description
Image

Annotated image (RGB)

Raises:

Type Description
ValueError

If box coordinates are invalid

Source code in src/gaze/tools/visual.py
@beartype
def annotate_region(
    image: Image.Image,
    box: tuple[float, float, float, float],
    color: str = "red",
    label: str | None = None,
) -> Image.Image:
    """Draw a bounding box annotation on *image*.

    Works on a copy; does not mutate the input.

    Args:
        image: Input PIL Image
        box: Normalized coordinates (x1, y1, x2, y2) in [0, 1]
        color: Box color name (e.g. "red", "green", "yellow")
        label: Optional text label drawn above the box

    Returns:
        Annotated image (RGB)

    Raises:
        ValueError: If box coordinates are invalid
    """
    if not all(0 <= c <= 1 for c in box):
        raise ValueError(f"All box coordinates must be in [0, 1], got {box}")
    x1_n, y1_n, x2_n, y2_n = box
    if x2_n <= x1_n or y2_n <= y1_n:
        raise ValueError(f"Invalid box: x2 must be > x1 and y2 must be > y1, got {box}")

    result = image.convert("RGB").copy()
    w, h = result.size
    draw = ImageDraw.Draw(result)

    x1 = int(x1_n * w)
    y1 = int(y1_n * h)
    x2 = int(x2_n * w)
    y2 = int(y2_n * h)

    draw.rectangle([x1, y1, x2, y2], outline=color, width=2)

    if label:
        font = ImageFont.load_default()
        bbox = font.getbbox(label)
        tw = bbox[2] - bbox[0]
        th = bbox[3] - bbox[1]
        # Draw label background above box
        label_y = max(0, y1 - th - 4)
        draw.rectangle([x1, label_y, x1 + tw + 4, label_y + th + 4], fill=color)
        draw.text((x1 + 2, label_y + 2), label, fill="white", font=font)

    return result

invert_image

invert_image(image: Image) -> Image.Image

Invert pixel intensities (negative image).

Useful for toggling between standard and inverted display modes common in radiology viewers.

Parameters:

Name Type Description Default
image Image

Input PIL Image

required

Returns:

Type Description
Image

Inverted grayscale image

Source code in src/gaze/tools/visual.py
@beartype
def invert_image(image: Image.Image) -> Image.Image:
    """Invert pixel intensities (negative image).

    Useful for toggling between standard and inverted display modes
    common in radiology viewers.

    Args:
        image: Input PIL Image

    Returns:
        Inverted grayscale image
    """
    return ImageOps.invert(image.convert("L"))

apply_window_level

apply_window_level(
    image: Image,
    center: int | None = None,
    width: int | None = None,
    preset: str | None = None,
) -> Image.Image

Apply clinical window/level to image.

Either provide center+width or a preset name. MRI presets (8-bit): brain, flair, t2, stroke, posterior_fossa. CT presets (Hounsfield units): ct_brain, ct_subdural, ct_bone, ct_soft_tissue, ct_stroke, ct_posterior_fossa.

Parameters:

Name Type Description Default
image Image

Input PIL Image

required
center int | None

Window center intensity

None
width int | None

Window width

None
preset str | None

Clinical preset name (overrides center/width)

None

Returns:

Type Description
Image

Windowed grayscale image

Raises:

Type Description
ValueError

If neither preset nor center+width provided, or invalid preset

Source code in src/gaze/tools/visual.py
@beartype
def apply_window_level(
    image: Image.Image,
    center: int | None = None,
    width: int | None = None,
    preset: str | None = None,
) -> Image.Image:
    """Apply clinical window/level to *image*.

    Either provide center+width or a preset name. MRI presets (8-bit):
    brain, flair, t2, stroke, posterior_fossa.
    CT presets (Hounsfield units): ct_brain, ct_subdural, ct_bone,
    ct_soft_tissue, ct_stroke, ct_posterior_fossa.

    Args:
        image: Input PIL Image
        center: Window center intensity
        width: Window width
        preset: Clinical preset name (overrides center/width)

    Returns:
        Windowed grayscale image

    Raises:
        ValueError: If neither preset nor center+width provided, or invalid preset
    """
    if preset is not None:
        if preset not in WINDOW_PRESETS:
            raise ValueError(
                f"Unknown preset {preset!r}. Available: {sorted(WINDOW_PRESETS.keys())}"
            )
        if center is not None or width is not None:
            logger.warning(
                "window_level: preset={!r} overrides center={}/width={}",
                preset,
                center,
                width,
            )
        center, width = WINDOW_PRESETS[preset]
    elif center is None or width is None:
        raise ValueError("Must provide either preset or both center and width")

    # Safety floor: ALL window widths (including presets) must meet the
    # minimum.  Presets are curated to comply; if one is below the floor
    # it indicates a configuration error, not an intentional override.
    cfg = _get_image_config()
    if width < cfg.min_window_width:
        raise ValueError(
            f"width must be >= {cfg.min_window_width}, got {width}. "
            f"Very narrow windows destroy diagnostic information."
        )

    lower = center - width / 2
    upper = center + width / 2

    gray = np.array(image.convert("L"), dtype=np.float64)

    # Check that the window meaningfully covers the image's actual data range.
    # CT presets (e.g. bone: center=400, width=1800) applied to 8-bit images
    # compress the output to very few levels, producing misleading results.
    # Skip for uniform images (img_min == img_max): the result is deterministic
    # regardless of window settings and there is no information to destroy.
    img_min, img_max = float(gray.min()), float(gray.max())
    if img_min < img_max:
        effective_lower = max(lower, img_min)
        effective_upper = min(upper, img_max)
        if effective_upper <= effective_lower:
            raise ValueError(
                f"Window [center={center}, width={width}] does not overlap with "
                f"image intensity range [{img_min:.0f}, {img_max:.0f}]. "
                f"No data would be visible."
            )
        effective_levels = int((effective_upper - effective_lower) / (upper - lower) * 255)
        if effective_levels < cfg.min_window_width:
            raise ValueError(
                f"Window [center={center}, width={width}] compresses image range "
                f"[{img_min:.0f}, {img_max:.0f}] to only {effective_levels} output "
                f"levels (minimum: {cfg.min_window_width}). Use a narrower window "
                f"suited to this image's bit depth."
            )

    gray = np.clip(gray, lower, upper)
    gray = (gray - lower) / (upper - lower) * 255 if upper > lower else np.zeros_like(gray)
    return Image.fromarray(np.clip(gray, 0, 255).astype(np.uint8))

adaptive_equalize

adaptive_equalize(
    image: Image,
    clip_limit: float = 2.0,
    tile_size: int = 8,
    config: ImageProcessingConfig | None = None,
) -> Image.Image

Apply Contrast Limited Adaptive Histogram Equalization (CLAHE).

Operates on local tiles for better local contrast than global equalization. Particularly useful for brain MRI white matter lesions.

Parameters:

Name Type Description Default
image Image

Input PIL Image

required
clip_limit float

Histogram clip limit (higher = more contrast)

2.0
tile_size int

Tile grid size (image divided into tile_size x tile_size tiles)

8
config ImageProcessingConfig | None

Optional config for clip_limit bounds

None

Returns:

Type Description
Image

CLAHE-processed grayscale image

Raises:

Type Description
ValueError

If clip_limit or tile_size is out of range

Source code in src/gaze/tools/visual.py
@beartype
def adaptive_equalize(
    image: Image.Image,
    clip_limit: float = 2.0,
    tile_size: int = 8,
    config: ImageProcessingConfig | None = None,
) -> Image.Image:
    """Apply Contrast Limited Adaptive Histogram Equalization (CLAHE).

    Operates on local tiles for better local contrast than global equalization.
    Particularly useful for brain MRI white matter lesions.

    Args:
        image: Input PIL Image
        clip_limit: Histogram clip limit (higher = more contrast)
        tile_size: Tile grid size (image divided into tile_size x tile_size tiles)
        config: Optional config for clip_limit bounds

    Returns:
        CLAHE-processed grayscale image

    Raises:
        ValueError: If clip_limit or tile_size is out of range
    """
    cfg = config or _get_image_config()

    if not cfg.min_clahe_clip_limit <= clip_limit <= cfg.max_clahe_clip_limit:
        raise ValueError(
            f"clip_limit must be in [{cfg.min_clahe_clip_limit}, {cfg.max_clahe_clip_limit}], "
            f"got {clip_limit}"
        )
    if tile_size < 2 or tile_size > cfg.max_clahe_tile_size:
        raise ValueError(f"tile_size must be in [2, {cfg.max_clahe_tile_size}], got {tile_size}")

    gray = np.array(image.convert("L"), dtype=np.float64)
    h, w = gray.shape

    # Compute tile dimensions
    th = max(1, h // tile_size)
    tw = max(1, w // tile_size)
    n_bins = 256

    # Build per-tile CDFs
    cdfs = np.zeros((tile_size, tile_size, n_bins))
    for ty in range(tile_size):
        for tx in range(tile_size):
            y0 = ty * th
            x0 = tx * tw
            y1 = h if ty == tile_size - 1 else (ty + 1) * th
            x1 = w if tx == tile_size - 1 else (tx + 1) * tw
            tile = gray[y0:y1, x0:x1].astype(np.uint8)
            hist, _ = np.histogram(tile, bins=n_bins, range=(0, 255))

            # Clip histogram
            n_pixels = tile.size
            clip_count = max(1, int(clip_limit * n_pixels / n_bins))
            excess = np.sum(np.maximum(hist - clip_count, 0))
            hist = np.minimum(hist, clip_count)
            hist += excess // n_bins  # redistribute excess uniformly

            # Compute CDF
            cdf = hist.cumsum()
            if cdf[-1] > 0:
                cdf = cdf / cdf[-1] * 255
            cdfs[ty, tx] = cdf

    # Map each pixel using bilinear interpolation of tile CDFs (vectorized)
    py_coords = np.arange(h, dtype=np.float64)
    px_coords = np.arange(w, dtype=np.float64)
    # Shape: (h, w) via broadcasting
    fy = (py_coords[:, np.newaxis] / th) - 0.5
    fx = (px_coords[np.newaxis, :] / tw) - 0.5

    ty0 = np.clip(np.floor(fy).astype(np.intp), 0, tile_size - 1)
    ty1 = np.clip(ty0 + 1, 0, tile_size - 1)
    tx0 = np.clip(np.floor(fx).astype(np.intp), 0, tile_size - 1)
    tx1 = np.clip(tx0 + 1, 0, tile_size - 1)

    # Interpolation weights — zero when clamped to same tile index
    wy = np.where(ty0 != ty1, fy - np.floor(fy), 0.0)
    wx = np.where(tx0 != tx1, fx - np.floor(fx), 0.0)

    val = gray.astype(np.intp)

    # Gather CDF lookups for all four neighbours: cdfs[tile_y, tile_x, pixel_val]
    v00 = cdfs[ty0, tx0, val]
    v01 = cdfs[ty0, tx1, val]
    v10 = cdfs[ty1, tx0, val]
    v11 = cdfs[ty1, tx1, val]

    top = v00 * (1 - wx) + v01 * wx
    bot = v10 * (1 - wx) + v11 * wx
    result = top * (1 - wy) + bot * wy

    return Image.fromarray(np.clip(result, 0, 255).astype(np.uint8))

compute_intensity_profile

compute_intensity_profile(
    image: Image,
    point1: tuple[float, float],
    point2: tuple[float, float],
) -> dict[str, object]

Sample pixel intensities along a line between two points.

Uses Bresenham-style sampling for accurate pixel traversal.

Parameters:

Name Type Description Default
image Image

Input PIL Image

required
point1 tuple[float, float]

Start point as normalized (x, y) in [0, 1]

required
point2 tuple[float, float]

End point as normalized (x, y) in [0, 1]

required

Returns:

Type Description
dict[str, object]

Dict with profile (list of intensities), positions, stats, and pixel coords.

Raises:

Type Description
ValueError

If coordinates are out of [0, 1]

Source code in src/gaze/tools/visual.py
@beartype
def compute_intensity_profile(
    image: Image.Image,
    point1: tuple[float, float],
    point2: tuple[float, float],
) -> dict[str, object]:
    """Sample pixel intensities along a line between two points.

    Uses Bresenham-style sampling for accurate pixel traversal.

    Args:
        image: Input PIL Image
        point1: Start point as normalized (x, y) in [0, 1]
        point2: End point as normalized (x, y) in [0, 1]

    Returns:
        Dict with profile (list of intensities), positions, stats, and pixel coords.

    Raises:
        ValueError: If coordinates are out of [0, 1]
    """
    for name, pt in [("point1", point1), ("point2", point2)]:
        if not (0 <= pt[0] <= 1 and 0 <= pt[1] <= 1):
            raise ValueError(f"{name} coordinates must be in [0, 1], got {pt}")

    gray = np.array(image.convert("L"))
    h, w = gray.shape

    x0 = int(point1[0] * (w - 1))
    y0 = int(point1[1] * (h - 1))
    x1 = int(point2[0] * (w - 1))
    y1 = int(point2[1] * (h - 1))

    # Sample along the line using linear interpolation
    n_samples = max(abs(x1 - x0), abs(y1 - y0), 1) + 1
    xs = np.linspace(x0, x1, n_samples).astype(int)
    ys = np.linspace(y0, y1, n_samples).astype(int)
    xs = np.clip(xs, 0, w - 1)
    ys = np.clip(ys, 0, h - 1)

    # Vectorized fancy-index lookup — avoids per-pixel Python overhead.
    intensity_arr = gray[ys, xs]
    intensities = intensity_arr.tolist()

    return {
        "profile": intensities,
        "n_samples": n_samples,
        "mean": float(np.mean(intensity_arr)),
        "std": float(np.std(intensity_arr)),
        "min": int(np.min(intensity_arr)),
        "max": int(np.max(intensity_arr)),
        "point1_pixels": (x0, y0),
        "point2_pixels": (x1, y1),
    }

denoise_gaussian

denoise_gaussian(
    image: Image,
    sigma: float,
    config: ImageProcessingConfig | None = None,
) -> Image.Image

Apply Gaussian blur for noise reduction.

Parameters:

Name Type Description Default
image Image

Input PIL Image

required
sigma float

Gaussian kernel standard deviation

required
config ImageProcessingConfig | None

Optional config for sigma bounds

None

Returns:

Type Description
Image

Denoised image

Raises:

Type Description
ValueError

If sigma is out of range

Source code in src/gaze/tools/visual.py
@beartype
def denoise_gaussian(
    image: Image.Image,
    sigma: float,
    config: ImageProcessingConfig | None = None,
) -> Image.Image:
    """Apply Gaussian blur for noise reduction.

    Args:
        image: Input PIL Image
        sigma: Gaussian kernel standard deviation
        config: Optional config for sigma bounds

    Returns:
        Denoised image

    Raises:
        ValueError: If sigma is out of range
    """
    cfg = config or _get_image_config()

    if not cfg.min_gaussian_sigma <= sigma <= cfg.max_gaussian_sigma:
        raise ValueError(
            f"sigma must be in [{cfg.min_gaussian_sigma}, {cfg.max_gaussian_sigma}], got {sigma}"
        )

    return image.filter(ImageFilter.GaussianBlur(radius=sigma))

morphological_op

morphological_op(
    image: Image,
    operation: str,
    iterations: int = 1,
    threshold_value: int | None = None,
    config: ImageProcessingConfig | None = None,
) -> Image.Image

Apply morphological operation (erode, dilate, open, close).

Uses PIL's MinFilter (erosion) and MaxFilter (dilation). If threshold_value is provided, first binarizes the image at that intensity.

Parameters:

Name Type Description Default
image Image

Input PIL Image

required
operation str

One of "erode", "dilate", "open", "close"

required
iterations int

Number of times to apply the operation

1
threshold_value int | None

Optional intensity threshold for binarization (0-255)

None
config ImageProcessingConfig | None

Optional config for max iterations

None

Returns:

Type Description
Image

Processed grayscale image

Raises:

Type Description
ValueError

If operation is invalid or iterations out of range

Source code in src/gaze/tools/visual.py
@beartype
def morphological_op(
    image: Image.Image,
    operation: str,
    iterations: int = 1,
    threshold_value: int | None = None,
    config: ImageProcessingConfig | None = None,
) -> Image.Image:
    """Apply morphological operation (erode, dilate, open, close).

    Uses PIL's MinFilter (erosion) and MaxFilter (dilation). If threshold_value
    is provided, first binarizes the image at that intensity.

    Args:
        image: Input PIL Image
        operation: One of "erode", "dilate", "open", "close"
        iterations: Number of times to apply the operation
        threshold_value: Optional intensity threshold for binarization (0-255)
        config: Optional config for max iterations

    Returns:
        Processed grayscale image

    Raises:
        ValueError: If operation is invalid or iterations out of range
    """
    cfg = config or _get_image_config()

    valid_ops = ("erode", "dilate", "open", "close")
    if operation not in valid_ops:
        raise ValueError(f"operation must be one of {valid_ops}, got {operation!r}")
    if iterations < 1 or iterations > cfg.max_morphological_iterations:
        raise ValueError(
            f"iterations must be in [1, {cfg.max_morphological_iterations}], got {iterations}"
        )

    result = image.convert("L")

    # Optional binarization
    if threshold_value is not None:
        if not 0 <= threshold_value <= 255:
            raise ValueError(f"threshold_value must be in [0, 255], got {threshold_value}")
        arr = np.array(result)
        arr = ((arr >= threshold_value) * 255).astype(np.uint8)
        result = Image.fromarray(arr)

    def _erode(img: Image.Image) -> Image.Image:
        return img.filter(ImageFilter.MinFilter(3))

    def _dilate(img: Image.Image) -> Image.Image:
        return img.filter(ImageFilter.MaxFilter(3))

    ops: dict[str, list[Callable[[Image.Image], Image.Image]]] = {
        "erode": [_erode],
        "dilate": [_dilate],
        "open": [_erode, _dilate],  # erosion then dilation
        "close": [_dilate, _erode],  # dilation then erosion
    }

    for _ in range(iterations):
        for op_fn in ops[operation]:
            result = op_fn(result)

    return result

create_visual_tools

create_visual_tools(
    disabled_tools: set[str] | None = None,
    config: ImageProcessingConfig | None = None,
) -> list[Tool]

Create the standard set of visual tools for image analysis.

Parameters:

Name Type Description Default
disabled_tools set[str] | None

Set of tool names to exclude from the returned list

None
config ImageProcessingConfig | None

Image processing config for schema ranges. If None, uses global default.

None

Returns:

Type Description
list[Tool]

List of Tool objects ready for registration with ToolRegistry

Source code in src/gaze/tools/visual.py
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
@beartype
def create_visual_tools(
    disabled_tools: set[str] | None = None,
    config: ImageProcessingConfig | None = None,
) -> list[Tool]:
    """Create the standard set of visual tools for image analysis.

    Args:
        disabled_tools: Set of tool names to exclude from the returned list
        config: Image processing config for schema ranges. If None, uses global default.

    Returns:
        List of Tool objects ready for registration with ToolRegistry
    """
    cfg = config or _get_image_config()
    disabled = disabled_tools or set()
    tools: list[Tool] = []

    # Generate prompt docs from config so ranges stay in sync
    zoom_prompt_doc = (
        f"**zoom** - Magnify the image (factor: {cfg.min_zoom_factor}-{cfg.max_zoom_factor})"
    )
    contrast_prompt_doc = (
        f"**adjust_contrast** - Enhance contrast "
        f"(factor: {cfg.min_contrast_factor}-{cfg.max_contrast_factor})"
    )

    if "zoom" not in disabled:
        tools.append(
            Tool(
                name="zoom",
                description="Magnify image for detail analysis. Use 2.0-4.0 factor.",
                parameters={
                    "factor": {
                        "type": "number",
                        "description": "Zoom factor: 1.0=unchanged, 2.0=2x zoom.",
                        "minimum": cfg.min_zoom_factor,
                        "maximum": cfg.max_zoom_factor,
                    }
                },
                execute=_execute_zoom,
                requires_image=True,
                prompt_documentation=zoom_prompt_doc,
                category="visual",
            )
        )

    if "crop" not in disabled:
        tools.append(
            Tool(
                name="crop",
                description="Extract rectangular region for focused analysis.",
                parameters={
                    "box": {
                        "type": "array",
                        "description": "Box [x1,y1,x2,y2] normalized (0-1) coordinates.",
                        "items": {"type": "number", "minimum": 0, "maximum": 1},
                        "minItems": 4,
                        "maxItems": 4,
                    }
                },
                execute=_execute_crop,
                requires_image=True,
                prompt_documentation=_CROP_PROMPT_DOC,
                category="visual",
            )
        )

    if "adjust_contrast" not in disabled:
        tools.append(
            Tool(
                name="adjust_contrast",
                description=(
                    "Enhance or reduce image contrast to better distinguish tissue "
                    "boundaries and subtle findings."
                ),
                parameters={
                    "factor": {
                        "type": "number",
                        "description": (
                            "Contrast factor: 1.0=no change, >1.0=increase, <1.0=decrease."
                        ),
                        "minimum": cfg.min_contrast_factor,
                        "maximum": cfg.max_contrast_factor,
                    }
                },
                execute=_execute_contrast,
                requires_image=True,
                prompt_documentation=contrast_prompt_doc,
                category="visual",
            )
        )

    if "adjust_brightness" not in disabled:
        brightness_prompt_doc = (
            f"**adjust_brightness** - Adjust brightness "
            f"(factor: {cfg.min_brightness_factor}-{cfg.max_brightness_factor})"
        )
        tools.append(
            Tool(
                name="adjust_brightness",
                description=(
                    "Adjust image brightness (window level). "
                    "1.0 = no change, >1.0 = brighter, <1.0 = darker."
                ),
                parameters={
                    "factor": {
                        "type": "number",
                        "description": (
                            "Brightness factor: 1.0=no change, >1.0=brighter, <1.0=darker."
                        ),
                        "minimum": cfg.min_brightness_factor,
                        "maximum": cfg.max_brightness_factor,
                    }
                },
                execute=_execute_brightness,
                requires_image=True,
                prompt_documentation=brightness_prompt_doc,
                category="visual",
            )
        )

    if "adjust_sharpness" not in disabled:
        sharpness_prompt_doc = (
            f"**adjust_sharpness** - Adjust sharpness "
            f"(factor: {cfg.min_sharpness_factor}-{cfg.max_sharpness_factor})"
        )
        tools.append(
            Tool(
                name="adjust_sharpness",
                description=(
                    "Adjust image sharpness. 0.0 = blurred, 1.0 = original, >1.0 = sharpened."
                ),
                parameters={
                    "factor": {
                        "type": "number",
                        "description": "Sharpness factor: 0.0=blurred, 1.0=original, >1.0=sharper.",
                        "minimum": cfg.min_sharpness_factor,
                        "maximum": cfg.max_sharpness_factor,
                    }
                },
                execute=_execute_sharpness,
                requires_image=True,
                prompt_documentation=sharpness_prompt_doc,
                category="visual",
            )
        )

    if "threshold" not in disabled:
        tools.append(
            Tool(
                name="threshold",
                description=(
                    "Apply intensity windowing to highlight specific tissue types or abnormalities."
                ),
                parameters={
                    "lower": {
                        "type": "integer",
                        "description": "Lower intensity bound (0-254).",
                        "minimum": 0,
                        "maximum": 254,
                    },
                    "upper": {
                        "type": "integer",
                        "description": "Upper intensity bound (1-255). Must be > lower.",
                        "minimum": 1,
                        "maximum": 255,
                    },
                },
                execute=_execute_threshold,
                requires_image=True,
                prompt_documentation=_THRESHOLD_PROMPT_DOC,
                category="visual",
            )
        )

    if "window_level" not in disabled:
        presets_list = ", ".join(sorted(WINDOW_PRESETS.keys()))
        window_prompt_doc = (
            f"**window_level** - Clinical windowing, converts to grayscale. "
            f"MRI presets (brain, flair, t2, stroke, posterior_fossa) are for 8-bit images. "
            f"CT presets (ct_*) assume Hounsfield units. "
            f"Must provide EITHER preset ({presets_list}) OR both center and width."
        )
        tools.append(
            Tool(
                name="window_level",
                description=(
                    "Apply clinical window/level settings. "
                    "Use preset names ("
                    + ", ".join(sorted(WINDOW_PRESETS.keys()))
                    + ") or specify center and width."
                ),
                parameters={
                    "center": {
                        "type": "integer",
                        "description": "Window center intensity. Required if no preset.",
                        "default": None,
                    },
                    "width": {
                        "type": "integer",
                        "description": "Window width. Required if no preset.",
                        "default": None,
                    },
                    "preset": {
                        "type": "string",
                        "description": "Clinical window preset name.",
                        "enum": sorted(WINDOW_PRESETS.keys()),
                        "default": None,
                    },
                },
                execute=_execute_window_level,
                requires_image=True,
                prompt_documentation=window_prompt_doc,
                category="visual",
            )
        )

    if "equalize_histogram" not in disabled:
        tools.append(
            Tool(
                name="equalize_histogram",
                description=(
                    "Equalize intensity histogram for improved contrast distribution (grayscale). "
                    "Replaces current image — call reset() before final answer."
                ),
                parameters={},
                execute=_execute_equalize,
                requires_image=True,
                prompt_documentation=_EQUALIZE_PROMPT_DOC,
                category="visual",
            )
        )

    if "adaptive_equalize" not in disabled:
        clahe_prompt_doc = (
            f"**adaptive_equalize** - CLAHE local contrast "
            f"(clip_limit: {cfg.min_clahe_clip_limit}-{cfg.max_clahe_clip_limit})"
        )
        tools.append(
            Tool(
                name="adaptive_equalize",
                description=(
                    "Contrast Limited Adaptive Histogram Equalization (CLAHE). "
                    "Better local contrast than global equalization."
                ),
                parameters={
                    "clip_limit": {
                        "type": "number",
                        "description": "Histogram clip limit (higher = more contrast).",
                        "minimum": cfg.min_clahe_clip_limit,
                        "maximum": cfg.max_clahe_clip_limit,
                        "default": 2.0,
                    },
                    "tile_size": {
                        "type": "integer",
                        "description": "Tile grid size for local processing.",
                        "minimum": 2,
                        "maximum": cfg.max_clahe_tile_size,
                        "default": 8,
                    },
                },
                execute=_execute_adaptive_equalize,
                requires_image=True,
                prompt_documentation=clahe_prompt_doc,
                category="visual",
            )
        )

    if "detect_edges" not in disabled:
        tools.append(
            Tool(
                name="detect_edges",
                description=(
                    "Detect edges using Sobel or Laplacian operators for boundary delineation. "
                    "Replaces current image — call reset() before final answer."
                ),
                parameters={
                    "method": {
                        "type": "string",
                        "description": "Edge detection method.",
                        "enum": ["sobel", "laplacian"],
                        "default": "sobel",
                    }
                },
                execute=_execute_detect_edges,
                requires_image=True,
                prompt_documentation=(
                    "**detect_edges** - Edge detection, converts to grayscale "
                    "(method: sobel/laplacian). REPLACES current image — call reset() before "
                    "final answer"
                ),
                category="visual",
            )
        )

    if "denoise" not in disabled:
        denoise_prompt_doc = (
            f"**denoise** - Gaussian noise reduction "
            f"(sigma: {cfg.min_gaussian_sigma}-{cfg.max_gaussian_sigma}). "
            f"REPLACES current image — call reset() before final answer"
        )
        tools.append(
            Tool(
                name="denoise",
                description=(
                    "Apply Gaussian blur for noise reduction. "
                    "Replaces current image — call reset() before final answer."
                ),
                parameters={
                    "sigma": {
                        "type": "number",
                        "description": "Gaussian kernel sigma (higher = more smoothing).",
                        "minimum": cfg.min_gaussian_sigma,
                        "maximum": cfg.max_gaussian_sigma,
                    }
                },
                execute=_execute_denoise,
                requires_image=True,
                prompt_documentation=denoise_prompt_doc,
                category="visual",
            )
        )

    if "morphological" not in disabled:
        morph_prompt_doc = (
            f"**morphological** - Morphological ops, converts to grayscale "
            f"(operation: erode/dilate/open/close, "
            f"iterations: 1-{cfg.max_morphological_iterations}). "
            f"REPLACES current image — call reset() before final answer"
        )
        tools.append(
            Tool(
                name="morphological",
                description=(
                    "Morphological operations for mask cleanup after thresholding. "
                    "erode=shrink, dilate=expand, open=remove noise, close=fill holes. "
                    "Replaces current image — call reset() before final answer."
                ),
                parameters={
                    "operation": {
                        "type": "string",
                        "description": "Morphological operation to apply.",
                        "enum": ["erode", "dilate", "open", "close"],
                    },
                    "iterations": {
                        "type": "integer",
                        "description": "Number of iterations.",
                        "minimum": 1,
                        "maximum": cfg.max_morphological_iterations,
                        "default": 1,
                    },
                    "threshold_value": {
                        "type": "integer",
                        "description": "Optional: binarize at this intensity first (0-255).",
                        "minimum": 0,
                        "maximum": 255,
                        "default": None,
                    },
                },
                execute=_execute_morphological,
                requires_image=True,
                prompt_documentation=morph_prompt_doc,
                category="visual",
            )
        )

    if "get_intensity_stats" not in disabled:
        tools.append(
            Tool(
                name="get_intensity_stats",
                description=(
                    "Compute intensity statistics (mean, std, min, max, median, histogram) "
                    "over the full image or a sub-region."
                ),
                parameters={
                    "box": {
                        "type": "array",
                        "description": "Optional [x1,y1,x2,y2] normalized (0-1) sub-region.",
                        "items": {"type": "number", "minimum": 0, "maximum": 1},
                        "minItems": 4,
                        "maxItems": 4,
                        "default": None,
                    }
                },
                execute=_execute_intensity_stats,
                requires_image=True,
                prompt_documentation=_INTENSITY_STATS_PROMPT_DOC,
                category="visual",
            )
        )

    if "intensity_profile" not in disabled:
        tools.append(
            Tool(
                name="intensity_profile",
                description=(
                    "Sample pixel intensities along a line between two points. "
                    "Differentiates cyst (sharp drop) vs tumor (gradual) vs edema."
                ),
                parameters={
                    "point1": {
                        "type": "array",
                        "description": "Start point [x, y] in normalized (0-1) coordinates.",
                        "items": {"type": "number", "minimum": 0, "maximum": 1},
                        "minItems": 2,
                        "maxItems": 2,
                    },
                    "point2": {
                        "type": "array",
                        "description": "End point [x, y] in normalized (0-1) coordinates.",
                        "items": {"type": "number", "minimum": 0, "maximum": 1},
                        "minItems": 2,
                        "maxItems": 2,
                    },
                },
                execute=_execute_intensity_profile,
                requires_image=True,
                prompt_documentation=_INTENSITY_PROFILE_PROMPT_DOC,
                category="visual",
            )
        )

    if "symmetry_diff" not in disabled:
        tools.append(
            Tool(
                name="symmetry_diff",
                description=(
                    "Compute left-right symmetry difference map. "
                    "Bright regions indicate asymmetry (potential pathology). "
                    "Replaces current image — call reset() before final answer."
                ),
                parameters={},
                execute=_execute_symmetry_diff,
                requires_image=True,
                prompt_documentation=_SYMMETRY_DIFF_PROMPT_DOC,
                category="visual",
            )
        )

    if "invert" not in disabled:
        tools.append(
            Tool(
                name="invert",
                description=(
                    "Invert pixel intensities (negative image). "
                    "Toggling display mode can reveal findings hidden in one polarity. "
                    "Replaces current image — call reset() before final answer."
                ),
                parameters={},
                execute=_execute_invert,
                requires_image=True,
                prompt_documentation=_INVERT_PROMPT_DOC,
                category="visual",
            )
        )

    if "annotate_region" not in disabled:
        tools.append(
            Tool(
                name="annotate_region",
                description=(
                    "Draw a bounding box overlay on the image for propose-and-verify localization."
                ),
                parameters={
                    "box": {
                        "type": "array",
                        "description": "Box [x1,y1,x2,y2] normalized (0-1) coordinates.",
                        "items": {"type": "number", "minimum": 0, "maximum": 1},
                        "minItems": 4,
                        "maxItems": 4,
                    },
                    "color": {
                        "type": "string",
                        "description": "Box outline color.",
                        "enum": ["red", "green", "yellow", "blue", "white", "cyan", "magenta"],
                        "default": "red",
                    },
                    "label": {
                        "type": "string",
                        "description": "Optional text label above the box.",
                        "default": None,
                    },
                },
                execute=_execute_annotate_region,
                requires_image=True,
                prompt_documentation=_ANNOTATE_REGION_PROMPT_DOC,
                category="visual",
            )
        )

    if "flip_horizontal" not in disabled:
        tools.append(
            Tool(
                name="flip_horizontal",
                description="Mirror the image left-right for bilateral symmetry assessment.",
                parameters={},
                execute=_execute_flip_horizontal,
                requires_image=True,
                prompt_documentation=_FLIP_HORIZONTAL_PROMPT_DOC,
                category="visual",
            )
        )

    if "flip_vertical" not in disabled:
        tools.append(
            Tool(
                name="flip_vertical",
                description="Mirror the image top-bottom for orientation standardization.",
                parameters={},
                execute=_execute_flip_vertical,
                requires_image=True,
                prompt_documentation=_FLIP_VERTICAL_PROMPT_DOC,
                category="visual",
            )
        )

    if "rotate" not in disabled:
        tools.append(
            Tool(
                name="rotate",
                description="Rotate image by 90 degrees clockwise or counter-clockwise.",
                parameters={
                    "clockwise": {
                        "type": "boolean",
                        "description": "If true, rotate clockwise; if false, counter-clockwise.",
                        "default": True,
                    }
                },
                execute=_execute_rotate,
                requires_image=True,
                prompt_documentation=_ROTATE_PROMPT_DOC,
                category="visual",
            )
        )

    if "show_grid" not in disabled:
        grid_prompt_doc = (
            "**show_grid** - Overlay labeled grid, visual only "
            f"(divisions: 2-{cfg.max_grid_divisions})"
        )
        tools.append(
            Tool(
                name="show_grid",
                description="Overlay a labeled grid for spatial reference (e.g. A1, B2).",
                parameters={
                    "divisions": {
                        "type": "integer",
                        "description": "Grid divisions per axis (rows and columns).",
                        "minimum": 2,
                        "maximum": cfg.max_grid_divisions,
                    }
                },
                execute=_execute_show_grid,
                requires_image=True,
                prompt_documentation=grid_prompt_doc,
                category="visual",
            )
        )

    if "measure" not in disabled:
        tools.append(
            Tool(
                name="measure",
                description="Measure Euclidean distance between two points in pixels.",
                parameters={
                    "point1": {
                        "type": "array",
                        "description": "First point [x, y] in normalized (0-1) coordinates.",
                        "items": {"type": "number", "minimum": 0, "maximum": 1},
                        "minItems": 2,
                        "maxItems": 2,
                    },
                    "point2": {
                        "type": "array",
                        "description": "Second point [x, y] in normalized (0-1) coordinates.",
                        "items": {"type": "number", "minimum": 0, "maximum": 1},
                        "minItems": 2,
                        "maxItems": 2,
                    },
                },
                execute=_execute_measure,
                requires_image=True,
                prompt_documentation=_MEASURE_PROMPT_DOC,
                category="visual",
            )
        )

    if "reset" not in disabled:
        tools.append(
            Tool(
                name="reset",
                description="Return to the original full image, discarding all modifications.",
                parameters={},
                execute=_execute_reset,
                requires_image=True,
                prompt_documentation=_RESET_PROMPT_DOC,
                category="visual",
            )
        )

    return tools