import os
import sys
from pathlib import Path
from collections import deque, Counter
import colorsys
import math
import tkinter as tk
from tkinter import colorchooser, filedialog, messagebox, ttk

try:
    from PIL import Image, ImageTk
except ImportError:
    root = tk.Tk()
    root.withdraw()
    messagebox.showerror(
        "Missing Dependency",
        "Pillow is not installed.\n\nInstall it with:\npython -m pip install pillow"
    )
    raise


SUPPORTED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}


CHROMA_KEY_PRESETS = {
    "Bright Green": "#00ff00",
    "Green": "#008000",
    "Lime Green": "#32cd32",
    "Neon Green": "#39ff14",
    "Bright Blue": "#0096ff",
    "Blue": "#0000ff",
    "Cyan": "#00ffff",
    "Bright Orange": "#ff8000",
    "Orange": "#ffa500",
    "Bright Pink": "#ff1493",
    "Magenta": "#ff00ff",
    "Bright Purple": "#bf00ff",
    "Purple": "#800080",
    "Black": "#000000",
    "White": "#ffffff",
}

CHROMA_KEY_PRESET_NAMES = list(CHROMA_KEY_PRESETS.keys())

PIPELINE_ORDER_BACKGROUND_FIRST = "Background removal → Upscale (recommended)"
PIPELINE_ORDER_UPSCALE_FIRST = "Upscale → Background removal (test)"
PIPELINE_ORDER_CHOICES = (
    PIPELINE_ORDER_BACKGROUND_FIRST,
    PIPELINE_ORDER_UPSCALE_FIRST,
)


# ------------------------------------------------------------
# Small color helpers
# ------------------------------------------------------------

def rgb_to_hsv_degrees(r, g, b):
    h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
    return h * 360.0, s * 255.0, v * 255.0


def circular_hue_distance(a, b):
    diff = abs(a - b) % 360.0
    return min(diff, 360.0 - diff)


def parse_hex_color(text, label="Manual guide color"):
    text = text.strip()
    if not text:
        return None

    if text.startswith("#"):
        text = text[1:]

    if len(text) != 6:
        raise ValueError(f"{label} must be blank or a 6-digit hex color like #ffff00.")

    try:
        r = int(text[0:2], 16)
        g = int(text[2:4], 16)
        b = int(text[4:6], 16)
    except ValueError:
        raise ValueError(f"{label} must be blank or a 6-digit hex color like #ffff00.")

    return r, g, b


# ------------------------------------------------------------
# Main removal function
# ------------------------------------------------------------

def remove_border_connected_dark_background(
    input_path,
    output_path,
    threshold=8,
    remove_inner_blobs=True,
    blob_tolerance=2,
    min_blob_area=64,
    min_blob_width=8,
    min_blob_height=8,
    blob_feather=2,
    use_guide_outline=True,
    remove_guide_outline=False,
    guide_manual_color="",
    guide_hue_tolerance=28,
    guide_min_saturation=60,
    guide_min_value=45,
    guide_boundary_radius=5,
    guide_required_boundary_percent=50,
    output_background_color="",
):
    """
    Creates real PNG transparency from a black-background character render.

    Stage 1:
        Find dark pixels connected to the outer image border. These are always
        verified background and will be removed.

    Stage 2:
        If guide-outline detection is OFF, inner black blobs work like the older
        tool: large dark blobs matching the sampled background are removed.

        If guide-outline detection is ON, inner black blobs are only removed when
        their outside boundary is surrounded by the detected guide outline color.
        Merely touching the guide color is NOT enough. This lets the renderer's
        outline act as the cut guide, so dark artwork on a dark background is
        protected.

    Stage 3:
        Optional: remove the guide outline last, after it has been used to
        validate blobs. When guide outline deletion is OFF, the guide outline is
        left visible in the output and is used only as a detection guide.

    This does NOT use AI, segmentation, rembg, or checkerboard transparency.
    It preserves source files and writes real transparent PNG output.
    """

    input_path = Path(input_path)
    output_path = Path(output_path)

    img = Image.open(input_path).convert("RGBA")
    width, height = img.size
    pixels = img.load()
    output_background_rgb = parse_hex_color(output_background_color, "Background color")
    removed_pixel = (*output_background_rgb, 255) if output_background_rgb else (0, 0, 0, 0)

    # ------------------------------------------------------------
    # Shared neighborhood helpers
    # ------------------------------------------------------------

    def neighbor_offsets(radius):
        offsets = []
        radius = max(1, int(radius))
        for dy in range(-radius, radius + 1):
            for dx in range(-radius, radius + 1):
                if dx == 0 and dy == 0:
                    continue
                if max(abs(dx), abs(dy)) <= radius:
                    offsets.append((dx, dy))
        return offsets

    adjacent_offsets = neighbor_offsets(1)
    guide_radius = max(1, int(guide_boundary_radius))
    guide_offsets = neighbor_offsets(guide_radius)

    # ------------------------------------------------------------
    # Stage 1: find dark pixels connected to the canvas border
    # ------------------------------------------------------------

    outer_background = [[False for _ in range(width)] for _ in range(height)]
    queue = deque()

    def pixel_darkness(x, y):
        r, g, b, a = pixels[x, y]
        return max(r, g, b)

    def is_dark_for_outer_background(x, y):
        r, g, b, a = pixels[x, y]
        if a == 0:
            return False
        return max(r, g, b) <= threshold

    def try_add_outer(x, y):
        if x < 0 or y < 0 or x >= width or y >= height:
            return

        if outer_background[y][x]:
            return

        if is_dark_for_outer_background(x, y):
            outer_background[y][x] = True
            queue.append((x, y))

    # Seed only from image borders.
    for x in range(width):
        try_add_outer(x, 0)
        try_add_outer(x, height - 1)

    for y in range(height):
        try_add_outer(0, y)
        try_add_outer(width - 1, y)

    # Flood fill through dark pixels connected to the border.
    while queue:
        x, y = queue.popleft()
        try_add_outer(x + 1, y)
        try_add_outer(x - 1, y)
        try_add_outer(x, y + 1)
        try_add_outer(x, y - 1)

    # ------------------------------------------------------------
    # Sample the actual removed background darkness range
    # ------------------------------------------------------------

    sampled_darkness_values = []

    for y in range(height):
        for x in range(width):
            if outer_background[y][x]:
                sampled_darkness_values.append(pixel_darkness(x, y))

    if sampled_darkness_values:
        sampled_min = min(sampled_darkness_values)
        sampled_max = max(sampled_darkness_values)
    else:
        sampled_min = 0
        sampled_max = threshold

    inner_blob_cutoff = min(255, sampled_max + blob_tolerance)

    # ------------------------------------------------------------
    # Detect guide outline color BEFORE inner-blob decisions
    # ------------------------------------------------------------

    guide_mask = [[False for _ in range(width)] for _ in range(height)]
    guide_remove_mask = [[False for _ in range(width)] for _ in range(height)]
    guide_blacken_mask = [[False for _ in range(width)] for _ in range(height)]

    guide_detection_mode = "OFF"
    guide_detected_rgb = None
    guide_detected_hsv = None
    guide_color_kind = "OFF"
    guide_candidate_pixels = 0
    guide_removed_pixels = 0
    guide_blackened_pixels = 0
    guide_removed_components = 0
    guide_blackened_components = 0

    if use_guide_outline or remove_guide_outline:
        manual_rgb = parse_hex_color(guide_manual_color)

        # Learn the outline from saturated/bright pixels near the already-verified
        # outside background. This keeps batch mode automatic and avoids needing a
        # color picker for normal use.
        boundary_samples = []

        if manual_rgb is None:
            for y in range(height):
                for x in range(width):
                    if outer_background[y][x]:
                        continue

                    r, g, b, a = pixels[x, y]
                    if a == 0:
                        continue

                    if max(r, g, b) <= threshold:
                        continue

                    h, s, v = rgb_to_hsv_degrees(r, g, b)

                    near_outer_background = False
                    for dx, dy in guide_offsets:
                        nx = x + dx
                        ny = y + dy
                        if nx < 0 or ny < 0 or nx >= width or ny >= height:
                            continue
                        if outer_background[ny][nx]:
                            near_outer_background = True
                            break

                    if near_outer_background:
                        boundary_samples.append((r, g, b, h, s, v))

            guide_candidate_pixels = len(boundary_samples)

            if boundary_samples:
                # Neutral bright outlines, such as white or light gray, have low
                # saturation, so the old hue-only detector could miss them and
                # then delete interior art by accident. Detect neutral outlines
                # first when they are common near the verified outside background.
                neutral_samples = []
                for r, g, b, h, s, v in boundary_samples:
                    channel_spread = max(r, g, b) - min(r, g, b)
                    if v >= 130 and channel_spread <= 100:
                        neutral_samples.append((r, g, b, h, s, v))

                if len(neutral_samples) >= max(40, int(len(boundary_samples) * 0.08)):
                    avg_r = sum(item[0] for item in neutral_samples) / len(neutral_samples)
                    avg_g = sum(item[1] for item in neutral_samples) / len(neutral_samples)
                    avg_b = sum(item[2] for item in neutral_samples) / len(neutral_samples)
                    guide_detected_rgb = (int(round(avg_r)), int(round(avg_g)), int(round(avg_b)))
                    guide_detected_hsv = rgb_to_hsv_degrees(*guide_detected_rgb)
                    guide_detection_mode = "AUTO"
                    guide_color_kind = "NEUTRAL"
                else:
                    chromatic_samples = []
                    for r, g, b, h, s, v in boundary_samples:
                        if s >= guide_min_saturation and v >= guide_min_value:
                            chromatic_samples.append((r, g, b, h, s, v))

                    if chromatic_samples:
                        hue_bin_size = max(4, int(max(4, guide_hue_tolerance)))
                        hue_counter = Counter()

                        for r, g, b, h, s, v in chromatic_samples:
                            hue_bin = int(h // hue_bin_size)
                            hue_counter[hue_bin] += 1

                        best_bin, best_count = hue_counter.most_common(1)[0]
                        selected_hue_center = (best_bin + 0.5) * hue_bin_size

                        selected = []
                        for r, g, b, h, s, v in chromatic_samples:
                            if circular_hue_distance(h, selected_hue_center) <= guide_hue_tolerance:
                                selected.append((r, g, b, h, s, v))

                        if selected:
                            avg_r = sum(item[0] for item in selected) / len(selected)
                            avg_g = sum(item[1] for item in selected) / len(selected)
                            avg_b = sum(item[2] for item in selected) / len(selected)
                            guide_detected_rgb = (int(round(avg_r)), int(round(avg_g)), int(round(avg_b)))
                            guide_detected_hsv = rgb_to_hsv_degrees(*guide_detected_rgb)
                            guide_detection_mode = "AUTO"
                            guide_color_kind = "CHROMATIC"
        else:
            guide_detected_rgb = manual_rgb
            guide_detected_hsv = rgb_to_hsv_degrees(*manual_rgb)
            guide_detection_mode = "MANUAL"
            mr, mg, mb = manual_rgb
            guide_color_kind = "NEUTRAL" if (max(manual_rgb) - min(manual_rgb) <= 100 and max(manual_rgb) >= 130) else "CHROMATIC"

        if guide_detected_hsv is not None:
            target_h, target_s, target_v = guide_detected_hsv

            def is_guide_color(x, y):
                if outer_background[y][x]:
                    return False

                r, g, b, a = pixels[x, y]
                if a == 0:
                    return False

                h, s, v = rgb_to_hsv_degrees(r, g, b)

                if guide_color_kind == "NEUTRAL":
                    # White/gray guide outlines do not have a useful hue.
                    # Match by brightness and channel similarity instead.
                    channel_spread = max(r, g, b) - min(r, g, b)
                    tr, tg, tb = guide_detected_rgb
                    rgb_distance = ((r - tr) ** 2 + (g - tg) ** 2 + (b - tb) ** 2) ** 0.5
                    return v >= 120 and channel_spread <= 115 and rgb_distance <= 115

                if s < guide_min_saturation or v < guide_min_value:
                    return False

                # Hue handles anti-aliased colored guide edges. Saturation/value floors
                # help avoid dark art pixels. The guide color is assumed unused
                # by the real artwork.
                return circular_hue_distance(h, target_h) <= guide_hue_tolerance

            for y in range(height):
                for x in range(width):
                    if is_guide_color(x, y):
                        guide_mask[y][x] = True

    def blob_is_surrounded_by_guide(blob_pixels):
        """
        Strict guide mode for interior blobs.

        A central dark blob is removable only when its perimeter is covered by
        the guide outline. A single touch-point is not enough, because that can
        accidentally delete black character details on a black background.

        The check looks at boundary pixels of the dark blob. For each boundary
        pixel, we ask: is guide color nearby? If enough of the boundary is
        covered, the blob is accepted as guided background.
        """
        if not use_guide_outline:
            return True, 100, 0, 0

        # Safety rule: when guide mode is ON but no guide color is found,
        # do NOT fall back to deleting every dark interior blob.
        # The guide is the authority for interior cuts.
        if guide_detected_hsv is None:
            return False, 0, 0, 0

        blob_set = set(blob_pixels)
        boundary_pixels = set()

        for x, y in blob_pixels:
            for dx, dy in adjacent_offsets:
                nx = x + dx
                ny = y + dy

                if nx < 0 or ny < 0 or nx >= width or ny >= height:
                    boundary_pixels.add((x, y))
                    continue

                if (nx, ny) not in blob_set:
                    boundary_pixels.add((x, y))
                    break

        if not boundary_pixels:
            return False, 0, 0, 0

        guided_boundary_pixels = 0
        guided_sectors = set()

        # Sector check: a true circled/circumferenced hole should have guide
        # evidence spread around the blob, not only on one side. This prevents
        # deleting large dark artwork just because one edge happens to touch
        # a white/yellow outline.
        center_x = sum(x for x, y in blob_pixels) / len(blob_pixels)
        center_y = sum(y for x, y in blob_pixels) / len(blob_pixels)

        for x, y in boundary_pixels:
            has_guide_nearby = False

            for dx, dy in guide_offsets:
                nx = x + dx
                ny = y + dy

                if nx < 0 or ny < 0 or nx >= width or ny >= height:
                    continue

                if guide_mask[ny][nx]:
                    has_guide_nearby = True
                    break

            if has_guide_nearby:
                guided_boundary_pixels += 1
                angle = (math.atan2(y - center_y, x - center_x) + math.pi) / (2 * math.pi)
                guided_sectors.add(int(angle * 8) % 8)

        coverage = int(round((guided_boundary_pixels / len(boundary_pixels)) * 100))
        required = max(1, min(100, int(guide_required_boundary_percent)))
        required_sectors = 6

        return (coverage >= required and len(guided_sectors) >= required_sectors), coverage, guided_boundary_pixels, len(boundary_pixels)

    # ------------------------------------------------------------
    # Stage 2: remove interior blobs matching sampled background
    # ------------------------------------------------------------

    inner_blob_mask = [[False for _ in range(width)] for _ in range(height)]
    removed_blob_count = 0
    skipped_unoutlined_blob_count = 0
    skipped_unoutlined_blob_pixels = 0

    if remove_inner_blobs:
        visited = [[False for _ in range(width)] for _ in range(height)]

        def is_inner_background_candidate(x, y):
            if outer_background[y][x]:
                return False

            r, g, b, a = pixels[x, y]

            if a == 0:
                return False

            return max(r, g, b) <= inner_blob_cutoff

        def collect_blob(start_x, start_y):
            blob_pixels = []
            q = deque()

            visited[start_y][start_x] = True
            q.append((start_x, start_y))

            min_x = start_x
            max_x = start_x
            min_y = start_y
            max_y = start_y

            while q:
                x, y = q.popleft()
                blob_pixels.append((x, y))

                if x < min_x:
                    min_x = x
                if x > max_x:
                    max_x = x
                if y < min_y:
                    min_y = y
                if y > max_y:
                    max_y = y

                neighbors = (
                    (x + 1, y),
                    (x - 1, y),
                    (x, y + 1),
                    (x, y - 1),
                )

                for nx, ny in neighbors:
                    if nx < 0 or ny < 0 or nx >= width or ny >= height:
                        continue

                    if visited[ny][nx]:
                        continue

                    if is_inner_background_candidate(nx, ny):
                        visited[ny][nx] = True
                        q.append((nx, ny))

            blob_width = max_x - min_x + 1
            blob_height = max_y - min_y + 1

            return blob_pixels, blob_width, blob_height

        for y in range(height):
            for x in range(width):
                if visited[y][x]:
                    continue

                if not is_inner_background_candidate(x, y):
                    visited[y][x] = True
                    continue

                blob_pixels, blob_width, blob_height = collect_blob(x, y)
                blob_area = len(blob_pixels)

                is_large_enough = (
                    blob_area >= min_blob_area
                    and blob_width >= min_blob_width
                    and blob_height >= min_blob_height
                )

                if not is_large_enough:
                    continue

                if use_guide_outline:
                    # When guide detection is ON, the outline is the authority
                    # for interior cuts. A blob must be surrounded by the guide
                    # outline, not merely touching it. Unoutlined central dark
                    # blobs are assumed to be part of the dragon/art and preserved.
                    is_guided, guide_coverage, guided_boundary, total_boundary = blob_is_surrounded_by_guide(blob_pixels)
                    if not is_guided:
                        skipped_unoutlined_blob_count += 1
                        skipped_unoutlined_blob_pixels += blob_area
                        continue

                removed_blob_count += 1
                for bx, by in blob_pixels:
                    inner_blob_mask[by][bx] = True

    # Feathering is user-controlled. In guide mode it only expands blobs that
    # have already passed the outline-surrounded test.
    blob_feather = max(0, int(blob_feather))
    if blob_feather > 0 and any(any(row) for row in inner_blob_mask):
        feather_offsets = neighbor_offsets(blob_feather)
        expanded_inner_blob_mask = [row[:] for row in inner_blob_mask]

        for y in range(height):
            for x in range(width):
                if not inner_blob_mask[y][x]:
                    continue

                for dx, dy in feather_offsets:
                    nx = x + dx
                    ny = y + dy
                    if nx < 0 or ny < 0 or nx >= width or ny >= height:
                        continue
                    if outer_background[ny][nx]:
                        continue
                    expanded_inner_blob_mask[ny][nx] = True

        inner_blob_mask = expanded_inner_blob_mask

    # Verified removal mask = outer background + accepted inner black blobs.
    removal_mask = [[False for _ in range(width)] for _ in range(height)]
    for y in range(height):
        for x in range(width):
            if outer_background[y][x] or inner_blob_mask[y][x]:
                removal_mask[y][x] = True

    # ------------------------------------------------------------
    # Stage 3: process guide outline color LAST
    # ------------------------------------------------------------

    if remove_guide_outline and guide_detected_hsv is not None:
        def touches_removal_mask(x, y, radius=None):
            if radius is None:
                radius = guide_radius
            for dy in range(-radius, radius + 1):
                for dx in range(-radius, radius + 1):
                    nx = x + dx
                    ny = y + dy
                    if nx < 0 or ny < 0 or nx >= width or ny >= height:
                        continue
                    if removal_mask[ny][nx]:
                        return True
            return False

        visited_guide = [[False for _ in range(width)] for _ in range(height)]

        def collect_guide_component(start_x, start_y):
            q = deque()
            comp = []
            touches_verified_removal = False

            visited_guide[start_y][start_x] = True
            q.append((start_x, start_y))

            while q:
                x, y = q.popleft()
                comp.append((x, y))

                if touches_removal_mask(x, y, radius=guide_radius):
                    touches_verified_removal = True

                for dx, dy in adjacent_offsets:
                    nx = x + dx
                    ny = y + dy
                    if nx < 0 or ny < 0 or nx >= width or ny >= height:
                        continue

                    if visited_guide[ny][nx]:
                        continue

                    if guide_mask[ny][nx]:
                        visited_guide[ny][nx] = True
                        q.append((nx, ny))

            return comp, touches_verified_removal

        for y in range(height):
            for x in range(width):
                if visited_guide[y][x] or not guide_mask[y][x]:
                    continue

                component, touches_verified_removal = collect_guide_component(x, y)

                if touches_verified_removal:
                    guide_removed_components += 1
                    for gx, gy in component:
                        guide_remove_mask[gy][gx] = True
                else:
                    guide_blackened_components += 1
                    for gx, gy in component:
                        guide_blacken_mask[gy][gx] = True

    # ------------------------------------------------------------
    # Apply transparency and stray-guide blackening
    # ------------------------------------------------------------

    removed_outer_pixels = 0
    removed_inner_pixels = 0

    for y in range(height):
        for x in range(width):
            if outer_background[y][x]:
                pixels[x, y] = removed_pixel
                removed_outer_pixels += 1
            elif inner_blob_mask[y][x]:
                pixels[x, y] = removed_pixel
                removed_inner_pixels += 1
            elif guide_remove_mask[y][x]:
                pixels[x, y] = removed_pixel
                guide_removed_pixels += 1
            elif guide_blacken_mask[y][x]:
                r, g, b, a = pixels[x, y]
                pixels[x, y] = (0, 0, 0, a)
                guide_blackened_pixels += 1

    output_path.parent.mkdir(parents=True, exist_ok=True)
    img.save(output_path, "PNG")

    return {
        "sampled_background_min": sampled_min,
        "sampled_background_max": sampled_max,
        "inner_blob_cutoff": inner_blob_cutoff,
        "removed_outer_pixels": removed_outer_pixels,
        "removed_inner_pixels": removed_inner_pixels,
        "removed_blob_count": removed_blob_count,
        "blob_feather": blob_feather,
        "skipped_unoutlined_blob_count": skipped_unoutlined_blob_count,
        "skipped_unoutlined_blob_pixels": skipped_unoutlined_blob_pixels,
        "use_guide_outline": use_guide_outline,
        "remove_guide_outline": remove_guide_outline,
        "guide_detection_mode": guide_detection_mode,
        "guide_detected_rgb": guide_detected_rgb,
        "guide_color_kind": guide_color_kind,
        "guide_candidate_pixels": guide_candidate_pixels,
        "guide_required_boundary_percent": guide_required_boundary_percent,
        "guide_removed_pixels": guide_removed_pixels,
        "guide_blackened_pixels": guide_blackened_pixels,
        "guide_removed_components": guide_removed_components,
        "guide_blackened_components": guide_blackened_components,
        "output_background_color": output_background_color.strip() or "transparent",
    }




# ------------------------------------------------------------
# Chroma-key / green-screen removal function
# ------------------------------------------------------------

def clamp(value, low, high):
    return max(low, min(high, value))


def rgb_distance(a, b):
    return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2) ** 0.5


def hex_from_rgb(rgb):
    return "#{:02x}{:02x}{:02x}".format(*rgb)


def auto_sample_chroma_key_from_borders(img):
    """
    Samples likely green-screen pixels from the image border.

    This is intentionally biased toward bright/saturated green so the default
    workflow works with renderer-created green backgrounds without the user
    needing to understand the exact key color.
    """
    rgb_img = img.convert("RGB")
    width, height = rgb_img.size
    pixels = rgb_img.load()

    samples = []
    border_thickness = max(2, min(width, height) // 32)

    for y in range(height):
        for x in range(width):
            if not (x < border_thickness or y < border_thickness or x >= width - border_thickness or y >= height - border_thickness):
                continue
            r, g, b = pixels[x, y]
            # Bright green-screen candidate, including slightly shaded renderer greens.
            if g >= 100 and g >= r + 35 and g >= b + 35 and g >= int(max(r, b) * 1.25):
                samples.append((r, g, b))

    if not samples:
        return (0, 255, 0), 0

    samples.sort(key=lambda c: c[1], reverse=True)
    # Ignore the very brightest sparkle/noise outliers and average the common border color.
    keep_count = max(1, min(len(samples), max(50, len(samples) // 2)))
    kept = samples[:keep_count]
    avg = tuple(int(round(sum(c[i] for c in kept) / len(kept))) for i in range(3))
    return avg, len(samples)


def remove_border_connected_chroma_key(
    input_path,
    output_path,
    key_color="",
    auto_sample_key=True,
    tolerance=64,
    softness=24,
    remove_inner_matching=False,
    output_background_color="",
    despill_enabled=True,
    despill_strength=48,
):
    """
    Removes a chroma-key background without globally subtracting the key color
    from the artwork.

    IMPORTANT BEHAVIOR:
    - This is background removal, not color removal.
    - The default removes only chroma-colored pixels connected to the image edge.
    - Matching pixels are made fully transparent; the tool does not make the
      whole image semi-transparent.
    - Edge cleanup is a connected fringe-removal pass. It starts from the
      already-removed background and flood-fills through adjacent chroma-fringe
      pixels.
    - Spill cleanup (despill) works on pixels that stay visible. It neutralizes
      leftover key-color tint on edge pixels that touch the removed background.
    - Connectivity uses 8-neighbor walking so the background can pass through
      thin gaps and diagonals in railings and similar cutout shapes.
    """
    input_path = Path(input_path)
    output_path = Path(output_path)

    img = Image.open(input_path).convert("RGBA")
    width, height = img.size
    pixels = img.load()

    if auto_sample_key or not key_color.strip():
        target_rgb, auto_sample_count = auto_sample_chroma_key_from_borders(img)
        key_mode = "AUTO"
    else:
        target_rgb = parse_hex_color(key_color, "Chroma key color")
        auto_sample_count = 0
        key_mode = "MANUAL"

    tolerance = int(clamp(int(tolerance), 0, 441))
    softness = int(clamp(int(softness), 0, 128))
    despill_strength = int(clamp(int(despill_strength), 0, 100))
    edge_cutoff = int(clamp(tolerance + softness, 0, 441))

    output_background_rgb = parse_hex_color(output_background_color, "Background color")
    removed_pixel = (*output_background_rgb, 255) if output_background_rgb else None

    target_h, target_s, target_v = rgb_to_hsv_degrees(*target_rgb)
    target_is_chromatic = target_s >= 50 and target_v >= 50
    adjacent8 = ((1,0),(-1,0),(0,1),(0,-1),(1,1),(-1,1),(1,-1),(-1,-1))

    def color_distance_at(x, y):
        r, g, b, a = pixels[x, y]
        if a == 0:
            return None
        return rgb_distance((r, g, b), target_rgb)

    def hard_match(x, y):
        dist = color_distance_at(x, y)
        return dist is not None and dist <= tolerance

    def chroma_fringe_match(x, y):
        r, g, b, a = pixels[x, y]
        if a == 0:
            return False

        dist = rgb_distance((r, g, b), target_rgb)
        if dist <= edge_cutoff:
            return True

        if not target_is_chromatic:
            return False

        h, s, v = rgb_to_hsv_degrees(r, g, b)
        if s < 20 or v < 12:
            return False

        hue_gate = clamp(18 + softness * 0.40, 18, 62)
        if circular_hue_distance(h, target_h) > hue_gate:
            return False

        key_weights = [channel / 255.0 for channel in target_rgb]
        max_weight = max(key_weights) if max(key_weights) > 0 else 1.0
        active_channels = [i for i, weight in enumerate(key_weights) if weight >= max(0.35, max_weight * 0.55)]
        if not active_channels:
            active_channels = [max(range(3), key=lambda i: target_rgb[i])]
        inactive_channels = [i for i in range(3) if i not in active_channels]

        channels = [r, g, b]
        active_avg = sum(channels[i] for i in active_channels) / len(active_channels)
        inactive_avg = sum(channels[i] for i in inactive_channels) / len(inactive_channels) if inactive_channels else 0
        dominance_needed = max(4, 22 - softness * 0.10)
        if active_avg < inactive_avg + dominance_needed:
            return False

        return True

    removal_mask = [[False for _ in range(width)] for _ in range(height)]
    queue = deque()

    def try_add_hard(x, y):
        if x < 0 or y < 0 or x >= width or y >= height:
            return
        if removal_mask[y][x]:
            return
        if hard_match(x, y):
            removal_mask[y][x] = True
            queue.append((x, y))

    for x in range(width):
        try_add_hard(x, 0)
        try_add_hard(x, height - 1)
    for y in range(height):
        try_add_hard(0, y)
        try_add_hard(width - 1, y)

    while queue:
        x, y = queue.popleft()
        for dx, dy in adjacent8:
            try_add_hard(x + dx, y + dy)

    edge_cleanup_pixels = 0
    if softness > 0:
        edge_queue = deque()
        for y in range(height):
            for x in range(width):
                if removal_mask[y][x]:
                    edge_queue.append((x, y))

        def try_add_edge(x, y):
            nonlocal edge_cleanup_pixels
            if x < 0 or y < 0 or x >= width or y >= height:
                return
            if removal_mask[y][x]:
                return
            if chroma_fringe_match(x, y):
                removal_mask[y][x] = True
                edge_cleanup_pixels += 1
                edge_queue.append((x, y))

        while edge_queue:
            x, y = edge_queue.popleft()
            for dx, dy in adjacent8:
                try_add_edge(x + dx, y + dy)

    interior_blob_count = 0
    inner_removed_pixels = 0
    if remove_inner_matching:
        seen = [[False for _ in range(width)] for _ in range(height)]

        for sy in range(height):
            for sx in range(width):
                if removal_mask[sy][sx] or seen[sy][sx] or not hard_match(sx, sy):
                    continue

                component = []
                q = deque([(sx, sy)])
                seen[sy][sx] = True

                while q:
                    x, y = q.popleft()
                    component.append((x, y))
                    for dx, dy in adjacent8:
                        nx = x + dx
                        ny = y + dy
                        if nx < 0 or ny < 0 or nx >= width or ny >= height:
                            continue
                        if seen[ny][nx] or removal_mask[ny][nx]:
                            continue
                        if hard_match(nx, ny):
                            seen[ny][nx] = True
                            q.append((nx, ny))

                if component:
                    interior_blob_count += 1
                    inner_removed_pixels += len(component)
                    for x, y in component:
                        removal_mask[y][x] = True

    removed_pixels = 0
    for y in range(height):
        for x in range(width):
            if not removal_mask[y][x]:
                continue

            if removed_pixel is not None:
                pixels[x, y] = removed_pixel
            else:
                r, g, b, a = pixels[x, y]
                pixels[x, y] = (r, g, b, 0)

            removed_pixels += 1

    despill_pixels = 0
    if despill_enabled and despill_strength > 0 and target_is_chromatic:
        key_weights = [channel / 255.0 for channel in target_rgb]
        max_weight = max(key_weights) if max(key_weights) > 0 else 1.0
        active_channels = [i for i, weight in enumerate(key_weights) if weight >= max(0.35, max_weight * 0.55)]
        if not active_channels:
            active_channels = [max(range(3), key=lambda i: target_rgb[i])]
        inactive_channels = [i for i in range(3) if i not in active_channels]

        radius = 1 if despill_strength < 24 else 2 if despill_strength < 60 else 3
        neighbor_offsets = [
            (dx, dy)
            for dy in range(-radius, radius + 1)
            for dx in range(-radius, radius + 1)
            if not (dx == 0 and dy == 0)
        ]
        hue_gate = clamp(22 + despill_strength * 0.45, 22, 85)
        max_reduction = 4 + despill_strength * 0.95
        residual_allowance = max(0, int(round(8 - despill_strength * 0.07)))

        for y in range(height):
            for x in range(width):
                if removal_mask[y][x]:
                    continue

                r, g, b, a = pixels[x, y]
                if a == 0:
                    continue

                touches_removed = False
                for dx, dy in neighbor_offsets:
                    nx = x + dx
                    ny = y + dy
                    if nx < 0 or ny < 0 or nx >= width or ny >= height:
                        continue
                    if removal_mask[ny][nx]:
                        touches_removed = True
                        break
                if not touches_removed:
                    continue

                h, s, v = rgb_to_hsv_degrees(r, g, b)
                hue_dist = circular_hue_distance(h, target_h)
                if hue_dist > hue_gate or s < 8 or v < 6:
                    continue

                channels = [r, g, b]
                active_avg = sum(channels[i] for i in active_channels) / len(active_channels)
                other_avg = sum(channels[i] for i in inactive_channels) / len(inactive_channels) if inactive_channels else 0
                excess = active_avg - other_avg
                if excess <= 2:
                    continue

                hue_factor = 1.0 - min(1.0, hue_dist / max(1.0, hue_gate))
                reduction_budget = min(excess, max_reduction * (0.35 + 0.65 * hue_factor))
                total_active_weight = sum(max(0.001, key_weights[i]) for i in active_channels)

                new_channels = channels[:]
                for i in active_channels:
                    share = max(0.001, key_weights[i]) / total_active_weight
                    new_channels[i] = int(round(max(0, new_channels[i] - reduction_budget * share)))

                new_other_avg = sum(new_channels[i] for i in inactive_channels) / len(inactive_channels) if inactive_channels else 0
                for i in active_channels:
                    new_channels[i] = min(new_channels[i], int(round(new_other_avg + residual_allowance)))

                if tuple(new_channels) != (r, g, b):
                    pixels[x, y] = (new_channels[0], new_channels[1], new_channels[2], a)
                    despill_pixels += 1

    output_path.parent.mkdir(parents=True, exist_ok=True)
    img.save(output_path, "PNG")

    return {
        "key_mode": key_mode,
        "key_rgb": target_rgb,
        "key_hex": hex_from_rgb(target_rgb),
        "auto_sample_count": auto_sample_count,
        "tolerance": tolerance,
        "softness": softness,
        "edge_cutoff": edge_cutoff,
        "removed_pixels": removed_pixels,
        "inner_removed_pixels": inner_removed_pixels,
        "interior_blob_count": interior_blob_count,
        "edge_cleanup_pixels": edge_cleanup_pixels,
        "despill_enabled": bool(despill_enabled),
        "despill_strength": despill_strength,
        "despill_pixels": despill_pixels,
        "partial_pixels": 0,
        "remove_inner_matching": remove_inner_matching,
        "output_background_color": output_background_color.strip() or "transparent",
    }

# ------------------------------------------------------------
# Safe upscale function
# ------------------------------------------------------------

def safe_upscale_image(
    input_path,
    output_path,
    scale_factor=2,
    resample_method="LANCZOS",
    fill_transparent_color="",
):
    """
    Deterministically upscales an image using Pillow only.

    This does not use AI, segmentation, denoising, sharpening, or redraw.
    It preserves alpha unless a fill color is supplied.
    """

    input_path = Path(input_path)
    output_path = Path(output_path)

    img = Image.open(input_path).convert("RGBA")
    width, height = img.size

    scale_factor = int(scale_factor)
    if scale_factor < 1:
        raise ValueError("Scale factor must be 1 or higher.")

    new_size = (width * scale_factor, height * scale_factor)

    resample_lookup = {
        "NEAREST": Image.Resampling.NEAREST,
        "BICUBIC": Image.Resampling.BICUBIC,
        "LANCZOS": Image.Resampling.LANCZOS,
    }
    resample_name = str(resample_method).upper()
    resample = resample_lookup.get(resample_name, Image.Resampling.LANCZOS)
    if resample_name not in resample_lookup:
        resample_name = "LANCZOS"

    upscaled = img.resize(new_size, resample=resample)

    fill_rgb = parse_hex_color(fill_transparent_color, "Transparent fill color")

    if fill_rgb:
        filled = Image.new("RGBA", upscaled.size, (*fill_rgb, 255))
        filled.alpha_composite(upscaled)
        upscaled = filled

    output_path.parent.mkdir(parents=True, exist_ok=True)
    upscaled.save(output_path, "PNG")

    return {
        "original_width": width,
        "original_height": height,
        "new_width": new_size[0],
        "new_height": new_size[1],
        "scale_factor": scale_factor,
        "resample_method": resample_name,
        "filled_transparency": bool(fill_rgb),
        "fill_color": fill_transparent_color.strip() or "transparent",
    }


def save_rgba_with_optional_fill(input_path, output_path, fill_transparent_color=""):
    """
    Save an RGBA image as PNG, optionally compositing transparent pixels over a solid color.
    Used when upscale already happened earlier in the pipeline and the final step is background removal.
    """
    input_path = Path(input_path)
    output_path = Path(output_path)
    img = Image.open(input_path).convert("RGBA")

    fill_rgb = parse_hex_color(fill_transparent_color, "Transparent fill color")
    if fill_rgb:
        filled = Image.new("RGBA", img.size, (*fill_rgb, 255))
        filled.alpha_composite(img)
        img = filled

    output_path.parent.mkdir(parents=True, exist_ok=True)
    img.save(output_path, "PNG")

    return {
        "width": img.size[0],
        "height": img.size[1],
        "filled_transparency": bool(fill_rgb),
        "fill_color": fill_transparent_color.strip() or "transparent",
    }

# ------------------------------------------------------------
# File collection
# ------------------------------------------------------------

def collect_images(input_path, recursive=False):
    input_path = Path(input_path)

    if input_path.is_file():
        if input_path.suffix.lower() in SUPPORTED_EXTENSIONS:
            return [input_path]
        return []

    if input_path.is_dir():
        if recursive:
            return [
                p for p in input_path.rglob("*")
                if p.is_file() and p.suffix.lower() in SUPPORTED_EXTENSIONS
            ]

        return [
            p for p in input_path.iterdir()
            if p.is_file() and p.suffix.lower() in SUPPORTED_EXTENSIONS
        ]

    return []


# ------------------------------------------------------------
# Desktop app
# ------------------------------------------------------------

class TransparentBackgroundApp:
    def __init__(self, root):
        self.root = root
        self.root.title("NeoSakura Chroma + Guide + Safe Upscale Tool - v5.7")
        self.root.geometry("820x670")
        self.root.minsize(740, 580)

        self.input_path = tk.StringVar()
        self.output_path = tk.StringVar()

        self.threshold = tk.IntVar(value=8)
        self.recursive = tk.BooleanVar(value=False)
        self.multi_threshold = tk.BooleanVar(value=False)
        self.output_background_color = tk.StringVar(value="")

        self.use_chroma_processing = tk.BooleanVar(value=True)
        self.chroma_key_color = tk.StringVar(value="#00ff00")
        self.chroma_key_preset = tk.StringVar(value="Bright Green")
        self.chroma_auto_sample = tk.BooleanVar(value=True)
        self.chroma_tolerance = tk.IntVar(value=64)
        self.chroma_softness = tk.IntVar(value=24)
        self.chroma_despill = tk.BooleanVar(value=True)
        self.chroma_despill_strength = tk.IntVar(value=48)
        self.chroma_remove_inner = tk.BooleanVar(value=False)
        self.chroma_output_background_color = tk.StringVar(value="")

        self.remove_inner_blobs = tk.BooleanVar(value=True)
        self.blob_tolerance = tk.IntVar(value=2)
        self.min_blob_area = tk.IntVar(value=64)
        self.min_blob_width = tk.IntVar(value=8)
        self.min_blob_height = tk.IntVar(value=8)
        self.blob_feather = tk.IntVar(value=2)

        self.use_guide_processing = tk.BooleanVar(value=False)
        self.use_guide_outline = tk.BooleanVar(value=True)
        self.remove_guide_outline = tk.BooleanVar(value=False)
        self.guide_manual_color = tk.StringVar(value="")
        self.guide_hue_tolerance = tk.IntVar(value=28)
        self.guide_min_saturation = tk.IntVar(value=60)
        self.guide_min_value = tk.IntVar(value=45)
        self.guide_boundary_radius = tk.IntVar(value=5)
        self.guide_required_boundary_percent = tk.IntVar(value=50)

        self.use_upscale_processing = tk.BooleanVar(value=False)
        self.upscale_factor = tk.IntVar(value=2)
        self.upscale_resample = tk.StringVar(value="LANCZOS")
        self.upscale_fill_transparent = tk.BooleanVar(value=False)
        self.upscale_fill_color = tk.StringVar(value="")
        self.pipeline_order = tk.StringVar(value=PIPELINE_ORDER_BACKGROUND_FIRST)

        self.configure_styles()
        self.build_ui()
        self.output_background_color.trace_add("write", lambda *_: self.update_background_color_swatch())
        self.chroma_key_color.trace_add("write", lambda *_: self.update_chroma_key_swatch())
        self.chroma_key_preset.trace_add("write", lambda *_: self.apply_chroma_key_preset())
        self.chroma_output_background_color.trace_add("write", lambda *_: self.update_chroma_output_swatch())
        self.upscale_fill_color.trace_add("write", lambda *_: self.update_upscale_fill_color_swatch())

    def configure_styles(self):
        self.root.option_add("*Font", "SegoeUI 9")
        style = ttk.Style()
        for theme in ("vista", "clam", "default"):
            try:
                style.theme_use(theme)
                break
            except tk.TclError:
                pass

        bg = "#f5f7fb"
        card = "#ffffff"
        line = "#d8dfeb"
        text = "#1f2937"
        subtext = "#5b6472"
        accent = "#3b82f6"

        self.root.configure(bg=bg)

        style.configure("App.TFrame", background=bg)
        style.configure("Card.TFrame", background=card)
        style.configure("TLabel", background=bg, foreground=text)
        style.configure("Subtle.TLabel", background=bg, foreground=subtext)
        style.configure("Card.TLabel", background=card, foreground=text)
        style.configure("CardSubtle.TLabel", background=card, foreground=subtext)
        style.configure("Title.TLabel", background=bg, foreground=text, font=("Segoe UI Semibold", 12))
        style.configure("Section.TLabelframe", background=bg, borderwidth=1, relief="solid")
        style.configure("Section.TLabelframe.Label", background=bg, foreground=text, font=("Segoe UI Semibold", 9))
        style.configure("TCheckbutton", background=card)
        style.configure("TNotebook", background=bg, borderwidth=0)
        style.configure("TNotebook.Tab", padding=(12, 7), font=("Segoe UI", 9))
        style.map("TNotebook.Tab", background=[("selected", card)], foreground=[("selected", text)])
        style.configure("TButton", padding=(10, 6))
        style.configure("Primary.TButton", padding=(12, 8), font=("Segoe UI Semibold", 10))
        style.configure("Small.TButton", padding=(8, 4))
        style.configure("TLabelframe", background=bg)
        style.configure("TLabelframe.Label", background=bg, foreground=text)
        style.configure("TEntry", padding=4)
        style.configure("TCombobox", padding=4)
        style.configure("TProgressbar", thickness=10)
        self._colors = {"bg": bg, "card": card, "line": line, "text": text, "subtext": subtext, "accent": accent}

    def bind_spinbox_mousewheel(self, spinbox):
        def on_mousewheel(event):
            if getattr(event, "num", None) == 5 or getattr(event, "delta", 0) < 0:
                spinbox.invoke("buttondown")
            else:
                spinbox.invoke("buttonup")
            return "break"

        spinbox.bind("<MouseWheel>", on_mousewheel)
        spinbox.bind("<Button-4>", on_mousewheel)
        spinbox.bind("<Button-5>", on_mousewheel)
        return spinbox

    def make_spin(self, parent, variable, from_, to, width=7):
        spin = ttk.Spinbox(parent, from_=from_, to=to, textvariable=variable, width=width)
        self.bind_spinbox_mousewheel(spin)
        return spin

    def add_setting_row(self, parent, row, label, widget, hint="", columns=4):
        ttk.Label(parent, text=label, style="Card.TLabel").grid(row=row, column=0, sticky="w", padx=(10, 8), pady=4)
        widget.grid(row=row, column=1, sticky="w", padx=(0, 12), pady=4)
        if hint:
            ttk.Label(parent, text=hint, style="CardSubtle.TLabel").grid(row=row, column=2, columnspan=max(1, columns-2), sticky="w", pady=4)

    def build_ui(self):
        bg = self._colors["bg"]
        card = self._colors["card"]
        line = self._colors["line"]

        shell = ttk.Frame(self.root, style="App.TFrame", padding=(12, 10, 12, 10))
        shell.pack(fill="both", expand=True)

        header = ttk.Frame(shell, style="App.TFrame")
        header.pack(fill="x", pady=(0, 8))
        ttk.Label(header, text="NeoSakura Chroma + Guide + Safe Upscale Tool", style="Title.TLabel").pack(anchor="w")
        ttk.Label(
            header,
            text="Choose Chroma Key, Guide Cut, and/or Safe Upscale. One Process Images button runs the selected steps and order. No AI required.",
            style="Subtle.TLabel"
        ).pack(anchor="w", pady=(2, 0))

        top_actions = ttk.Frame(shell, style="App.TFrame")
        top_actions.pack(fill="x", pady=(0, 8))
        ttk.Label(
            top_actions,
            text="Source files are never changed. Outputs are new PNG files.",
            style="Subtle.TLabel"
        ).pack(side="left", anchor="w")
        ttk.Button(
            top_actions,
            text="Process Images",
            style="Primary.TButton",
            command=self.process_selected_steps
        ).pack(side="right")
        ttk.Button(
            top_actions,
            text="Clear Log",
            style="Small.TButton",
            command=self.clear_log
        ).pack(side="right", padx=(0, 8))

        notebook = ttk.Notebook(shell)
        notebook.pack(fill="both", expand=True)

        run_tab = ttk.Frame(notebook, style="App.TFrame", padding=8)
        chroma_tab = ttk.Frame(notebook, style="App.TFrame", padding=8)
        guide_tab = ttk.Frame(notebook, style="App.TFrame", padding=8)
        background_tab = guide_tab  # Old black-background controls now live inside the Guide tab.
        upscale_tab = ttk.Frame(notebook, style="App.TFrame", padding=8)

        notebook.add(run_tab, text="Run")
        notebook.add(chroma_tab, text="Chroma Key")
        notebook.add(guide_tab, text="Guide")
        notebook.add(upscale_tab, text="Upscale")

        # ---------------- Run tab ----------------
        run_card = tk.Frame(run_tab, bg=card, highlightbackground=line, highlightthickness=1, bd=0)
        run_card.pack(fill="x")
        run_inner = ttk.Frame(run_card, style="Card.TFrame", padding=10)
        run_inner.pack(fill="both", expand=True)
        run_inner.columnconfigure(1, weight=1)

        ttk.Label(run_inner, text="Input", style="Card.TLabel").grid(row=0, column=0, sticky="w", pady=(0, 4), padx=(0, 8))
        ttk.Entry(run_inner, textvariable=self.input_path).grid(row=0, column=1, sticky="ew", pady=(0, 4))
        input_buttons = ttk.Frame(run_inner, style="Card.TFrame")
        input_buttons.grid(row=0, column=2, padx=(8, 0), pady=(0, 4), sticky="e")
        ttk.Button(input_buttons, text="Image", style="Small.TButton", command=self.choose_image).pack(side="left", padx=(0, 4))
        ttk.Button(input_buttons, text="Folder", style="Small.TButton", command=self.choose_folder).pack(side="left")

        ttk.Label(run_inner, text="Output", style="Card.TLabel").grid(row=1, column=0, sticky="w", pady=4, padx=(0, 8))
        ttk.Entry(run_inner, textvariable=self.output_path).grid(row=1, column=1, sticky="ew", pady=4)
        ttk.Button(run_inner, text="Choose", style="Small.TButton", command=self.choose_output).grid(row=1, column=2, padx=(8, 0), pady=4, sticky="e")

        toggles = ttk.Frame(run_inner, style="Card.TFrame")
        toggles.grid(row=2, column=0, columnspan=3, sticky="w", pady=(4, 0))
        ttk.Checkbutton(toggles, text="Process subfolders", variable=self.recursive).pack(side="left", padx=(0, 16))
        ttk.Checkbutton(toggles, text="Threshold comparison (6, 8, 10, 12)", variable=self.multi_threshold).pack(side="left")

        order_row = ttk.Frame(run_inner, style="Card.TFrame")
        order_combo = ttk.Combobox(
            order_row,
            textvariable=self.pipeline_order,
            values=PIPELINE_ORDER_CHOICES,
            state="readonly",
            width=42,
        )
        order_combo.pack(side="left")
        self.add_setting_row(
            run_inner,
            3,
            "Processing order",
            order_row,
            "Recommended removes the background first. Upscale-first is useful for testing; NEAREST is safest there."
        )

        self.progress = ttk.Progressbar(shell, orient="horizontal", mode="determinate")
        self.progress.pack(fill="x")

        log_card = tk.Frame(shell, bg=card, highlightbackground=line, highlightthickness=1, bd=0)
        log_card.pack(fill="both", expand=True, pady=(8, 0))
        log_inner = ttk.Frame(log_card, style="Card.TFrame", padding=(10, 8, 10, 10))
        log_inner.pack(fill="both", expand=True)
        ttk.Label(log_inner, text="Processing Log", style="Card.TLabel").pack(anchor="w", pady=(0, 6))
        log_wrap = ttk.Frame(log_inner, style="Card.TFrame")
        log_wrap.pack(fill="both", expand=True)
        self.log = tk.Text(
            log_wrap,
            height=16,
            wrap="word",
            bg="#fbfcfe",
            fg="#1f2937",
            relief="flat",
            borderwidth=0,
            highlightthickness=0,
            padx=8,
            pady=8,
            font=("Consolas", 9)
        )
        log_scroll = ttk.Scrollbar(log_wrap, orient="vertical", command=self.log.yview)
        self.log.configure(yscrollcommand=log_scroll.set)
        self.log.pack(side="left", fill="both", expand=True)
        log_scroll.pack(side="right", fill="y")

        # ---------------- Chroma Key tab ----------------
        chroma_card = tk.Frame(chroma_tab, bg=card, highlightbackground=line, highlightthickness=1, bd=0)
        chroma_card.pack(fill="both", expand=True)
        chroma_inner = ttk.Frame(chroma_card, style="Card.TFrame", padding=10)
        chroma_inner.pack(fill="both", expand=True)

        chroma_box = ttk.LabelFrame(chroma_inner, text="Green screen / chroma key", style="Section.TLabelframe")
        chroma_box.pack(fill="x", pady=(0, 10))
        chroma_frame = ttk.Frame(chroma_box, style="Card.TFrame", padding=6)
        chroma_frame.pack(fill="x")

        ttk.Checkbutton(
            chroma_frame,
            text="Use Chroma Key removal in Process Images",
            variable=self.use_chroma_processing
        ).grid(row=0, column=0, columnspan=3, sticky="w", padx=10, pady=(2, 4))

        ttk.Checkbutton(
            chroma_frame,
            text="Auto-sample key color from image border (recommended default)",
            variable=self.chroma_auto_sample
        ).grid(row=1, column=0, columnspan=3, sticky="w", padx=10, pady=(2, 8))

        preset_row = ttk.Frame(chroma_frame, style="Card.TFrame")
        preset_combo = ttk.Combobox(
            preset_row,
            textvariable=self.chroma_key_preset,
            values=CHROMA_KEY_PRESET_NAMES,
            width=18,
            state="readonly",
        )
        preset_combo.pack(side="left")
        ttk.Button(preset_row, text="Use Preset", style="Small.TButton", command=self.use_selected_chroma_preset).pack(side="left", padx=(6, 0))
        ttk.Button(preset_row, text="Auto", style="Small.TButton", command=self.use_chroma_auto_sample).pack(side="left", padx=(6, 0))
        self.add_setting_row(chroma_frame, 2, "Key preset", preset_row, "Pick from the NeoSakura chroma-key palette. Use Preset turns off auto-sample and uses this exact color.")

        key_row = ttk.Frame(chroma_frame, style="Card.TFrame")
        key_entry = ttk.Entry(key_row, textvariable=self.chroma_key_color, width=12)
        key_entry.pack(side="left")
        ttk.Button(key_row, text="Pick", style="Small.TButton", command=self.choose_chroma_key_color).pack(side="left", padx=(6, 0))
        ttk.Button(key_row, text="Sample Image", style="Small.TButton", command=self.sample_chroma_key_from_image).pack(side="left", padx=(6, 0))
        self.chroma_key_swatch = tk.Label(key_row, text="#00ff00", bg="#00ff00", fg="#111111", width=12, anchor="center")
        self.chroma_key_swatch.pack(side="left", padx=(8, 0))
        self.add_setting_row(chroma_frame, 3, "Key color", key_row, "Manual hex color. Sample Image lets you click the exact background color.")

        row2 = ttk.Frame(chroma_frame, style="Card.TFrame")
        row2.grid(row=4, column=0, columnspan=3, sticky="ew", padx=10, pady=4)
        ttk.Label(row2, text="Tolerance", style="Card.TLabel").pack(side="left")
        self.make_spin(row2, self.chroma_tolerance, 0, 441).pack(side="left", padx=(8, 18))
        ttk.Label(row2, text="Edge cleanup", style="Card.TLabel").pack(side="left")
        self.make_spin(row2, self.chroma_softness, 0, 128).pack(side="left", padx=(8, 18))

        row2b = ttk.Frame(chroma_frame, style="Card.TFrame")
        row2b.grid(row=5, column=0, columnspan=3, sticky="ew", padx=10, pady=4)
        ttk.Checkbutton(
            row2b,
            text="Spill cleanup (despill)",
            variable=self.chroma_despill
        ).pack(side="left")
        ttk.Label(row2b, text="Strength", style="Card.TLabel").pack(side="left", padx=(18, 0))
        self.make_spin(row2b, self.chroma_despill_strength, 0, 100).pack(side="left", padx=(8, 0))

        ttk.Checkbutton(
            chroma_frame,
            text="Remove interior blobs: also remove matching chroma areas NOT connected to the image edge",
            variable=self.chroma_remove_inner
        ).grid(row=6, column=0, columnspan=3, sticky="w", padx=10, pady=(6, 4))

        chroma_bg_row = ttk.Frame(chroma_frame, style="Card.TFrame")
        chroma_bg_entry = ttk.Entry(chroma_bg_row, textvariable=self.chroma_output_background_color, width=12)
        chroma_bg_entry.pack(side="left")
        ttk.Button(chroma_bg_row, text="Pick", style="Small.TButton", command=self.choose_chroma_output_color).pack(side="left", padx=(6, 0))
        ttk.Button(chroma_bg_row, text="Bright Green", style="Small.TButton", command=lambda: self.chroma_output_background_color.set("#00ff00")).pack(side="left", padx=(6, 0))
        ttk.Button(chroma_bg_row, text="Clear", style="Small.TButton", command=lambda: self.chroma_output_background_color.set("")).pack(side="left", padx=(6, 0))
        self.chroma_output_swatch = tk.Label(chroma_bg_row, text="transparent", bg=self._colors["card"], fg=self._colors["subtext"], width=12, anchor="center")
        self.chroma_output_swatch.pack(side="left", padx=(8, 0))
        self.add_setting_row(chroma_frame, 7, "Output background", chroma_bg_row, "Blank saves real transparency. Set a color only if you want to fill removed pixels.")

        ttk.Label(
            chroma_frame,
            text=(
                "Default behavior is border-connected chroma-key background removal: the tool removes only the key-colored background touching the canvas edges, "
                "and matching pixels are cut fully transparent instead of making the artwork semi-transparent. Tolerance controls the hard background match. "
                "Edge cleanup removes extra connected fringe pixels. Spill cleanup fixes leftover key-color tint on pixels that remain visible, such as rails, thin outlines, and water edges."
            ),
            style="CardSubtle.TLabel",
            wraplength=670,
            justify="left"
        ).grid(row=8, column=0, columnspan=3, sticky="w", padx=10, pady=(8, 2))

        # ---------------- Background tab ----------------
        bg_card = tk.Frame(background_tab, bg=card, highlightbackground=line, highlightthickness=1, bd=0)
        bg_card.pack(fill="both", expand=True)
        bg_inner = ttk.Frame(bg_card, style="Card.TFrame", padding=10)
        bg_inner.pack(fill="both", expand=True)
        bg_inner.columnconfigure(2, weight=1)

        main_box = ttk.LabelFrame(bg_inner, text="Guide Cut / black-background removal", style="Section.TLabelframe")
        main_box.pack(fill="x", pady=(0, 10))
        main_frame = ttk.Frame(main_box, style="Card.TFrame", padding=6)
        main_frame.pack(fill="x")
        ttk.Checkbutton(
            main_frame,
            text="Use Guide Cut / black-background removal in Process Images",
            variable=self.use_guide_processing
        ).grid(row=0, column=0, columnspan=3, sticky="w", padx=10, pady=(2, 8))
        self.add_setting_row(main_frame, 1, "Main threshold", self.make_spin(main_frame, self.threshold, 0, 255), "Default 8. Safer 6. Stronger 10–12.")
        bg_color_row = ttk.Frame(main_frame, style="Card.TFrame")
        bg_color_entry = ttk.Entry(bg_color_row, textvariable=self.output_background_color, width=12)
        bg_color_entry.pack(side="left")
        ttk.Button(bg_color_row, text="Pick", style="Small.TButton", command=self.choose_background_color).pack(side="left", padx=(6, 0))
        ttk.Button(bg_color_row, text="Clear", style="Small.TButton", command=self.clear_background_color).pack(side="left", padx=(6, 0))
        self.bg_color_swatch = tk.Label(bg_color_row, text="transparent", bg=self._colors["card"], fg=self._colors["subtext"], width=12, anchor="center")
        self.bg_color_swatch.pack(side="left", padx=(8, 0))
        self.add_setting_row(main_frame, 2, "Background color", bg_color_row, "Blank keeps transparent output. Enter hex like #1a1a1a or use Pick.")

        blob_box = ttk.LabelFrame(bg_inner, text="Guide Cut interior dark blobs", style="Section.TLabelframe")
        blob_box.pack(fill="x")
        blob_frame = ttk.Frame(blob_box, style="Card.TFrame", padding=6)
        blob_frame.pack(fill="x")

        ttk.Checkbutton(
            blob_frame,
            text="Remove interior black blobs that match the sampled background",
            variable=self.remove_inner_blobs
        ).grid(row=0, column=0, columnspan=3, sticky="w", padx=10, pady=(2, 8))
        self.add_setting_row(blob_frame, 1, "Blob tolerance", self.make_spin(blob_frame, self.blob_tolerance, 0, 255), "Default 2.")
        self.add_setting_row(blob_frame, 2, "Minimum blob area", self.make_spin(blob_frame, self.min_blob_area, 1, 999999), "Default 64 px.")

        row3 = ttk.Frame(blob_frame, style="Card.TFrame")
        row3.grid(row=3, column=0, columnspan=3, sticky="ew", padx=10, pady=4)
        ttk.Label(row3, text="Minimum width", style="Card.TLabel").pack(side="left")
        self.make_spin(row3, self.min_blob_width, 1, 99999).pack(side="left", padx=(8, 18))
        ttk.Label(row3, text="Minimum height", style="Card.TLabel").pack(side="left")
        self.make_spin(row3, self.min_blob_height, 1, 99999).pack(side="left", padx=(8, 18))
        ttk.Label(row3, text="Blob feather", style="Card.TLabel").pack(side="left")
        self.make_spin(row3, self.blob_feather, 0, 64).pack(side="left", padx=(8, 0))

        ttk.Label(
            blob_frame,
            text="Blob feather stays on in guide mode. It expands only blobs that were already accepted for removal.",
            style="CardSubtle.TLabel"
        ).grid(row=4, column=0, columnspan=3, sticky="w", padx=10, pady=(4, 2))

        # ---------------- Guide tab ----------------
        guide_card = tk.Frame(guide_tab, bg=card, highlightbackground=line, highlightthickness=1, bd=0)
        guide_card.pack(fill="both", expand=True)
        guide_inner = ttk.Frame(guide_card, style="Card.TFrame", padding=10)
        guide_inner.pack(fill="both", expand=True)

        guide_box = ttk.LabelFrame(guide_inner, text="Guide outline", style="Section.TLabelframe")
        guide_box.pack(fill="x", pady=(0, 10))
        guide_frame = ttk.Frame(guide_box, style="Card.TFrame", padding=6)
        guide_frame.pack(fill="x")

        ttk.Checkbutton(
            guide_frame,
            text="Use the renderer guide outline to decide which interior blobs get cut",
            variable=self.use_guide_outline
        ).grid(row=0, column=0, columnspan=3, sticky="w", padx=10, pady=(2, 6))

        ttk.Checkbutton(
            guide_frame,
            text="Also delete / blacken guide outline pixels after cutting",
            variable=self.remove_guide_outline
        ).grid(row=1, column=0, columnspan=3, sticky="w", padx=10, pady=(0, 8))

        self.add_setting_row(guide_frame, 2, "Manual guide color", ttk.Entry(guide_frame, textvariable=self.guide_manual_color, width=12), "Optional. Leave blank for auto-detect.")
        self.add_setting_row(guide_frame, 3, "Hue tolerance", self.make_spin(guide_frame, self.guide_hue_tolerance, 1, 180), "Default 28.")

        row4 = ttk.Frame(guide_frame, style="Card.TFrame")
        row4.grid(row=4, column=0, columnspan=3, sticky="ew", padx=10, pady=4)
        ttk.Label(row4, text="Min saturation", style="Card.TLabel").pack(side="left")
        self.make_spin(row4, self.guide_min_saturation, 0, 255).pack(side="left", padx=(8, 18))
        ttk.Label(row4, text="Min value", style="Card.TLabel").pack(side="left")
        self.make_spin(row4, self.guide_min_value, 0, 255).pack(side="left", padx=(8, 18))
        ttk.Label(row4, text="Boundary radius", style="Card.TLabel").pack(side="left")
        self.make_spin(row4, self.guide_boundary_radius, 1, 8).pack(side="left", padx=(8, 0))

        self.add_setting_row(guide_frame, 5, "Required outline coverage %", self.make_spin(guide_frame, self.guide_required_boundary_percent, 1, 100), "Lower if obvious outlined holes are being missed.")

        ttk.Label(
            guide_frame,
            text=(
                "Guide mode rule: border-connected black background is always removed. "
                "Interior black blobs are removed only when the guide outline surrounds their boundary."
            ),
            style="CardSubtle.TLabel",
            wraplength=650,
            justify="left"
        ).grid(row=6, column=0, columnspan=3, sticky="w", padx=10, pady=(6, 2))

        # ---------------- Upscale tab ----------------
        upscale_card = tk.Frame(upscale_tab, bg=card, highlightbackground=line, highlightthickness=1, bd=0)
        upscale_card.pack(fill="both", expand=True)
        upscale_inner = ttk.Frame(upscale_card, style="Card.TFrame", padding=10)
        upscale_inner.pack(fill="both", expand=True)

        upscale_box = ttk.LabelFrame(upscale_inner, text="Safe upscale", style="Section.TLabelframe")
        upscale_box.pack(fill="x", pady=(0, 10))
        upscale_frame = ttk.Frame(upscale_box, style="Card.TFrame", padding=6)
        upscale_frame.pack(fill="x")

        ttk.Checkbutton(
            upscale_frame,
            text="Use Safe Upscale in Process Images",
            variable=self.use_upscale_processing
        ).grid(row=0, column=0, columnspan=3, sticky="w", padx=10, pady=(2, 8))

        factor_combo = ttk.Combobox(
            upscale_frame,
            textvariable=self.upscale_factor,
            values=(2, 3, 4),
            state="readonly",
            width=7,
        )
        self.add_setting_row(upscale_frame, 1, "Scale factor", factor_combo, "2x default. Uses Pillow only; no AI redraw.")

        resample_combo = ttk.Combobox(
            upscale_frame,
            textvariable=self.upscale_resample,
            values=("LANCZOS", "BICUBIC", "NEAREST"),
            state="readonly",
            width=12,
        )
        self.add_setting_row(upscale_frame, 2, "Resampling", resample_combo, "Lanczos for most art, Bicubic softer, Nearest for pixel art.")

        ttk.Checkbutton(
            upscale_frame,
            text="Fill transparent pixels after upscale",
            variable=self.upscale_fill_transparent
        ).grid(row=3, column=0, columnspan=3, sticky="w", padx=10, pady=(6, 4))

        fill_row = ttk.Frame(upscale_frame, style="Card.TFrame")
        fill_entry = ttk.Entry(fill_row, textvariable=self.upscale_fill_color, width=12)
        fill_entry.pack(side="left")
        ttk.Button(fill_row, text="Pick", style="Small.TButton", command=self.choose_upscale_fill_color).pack(side="left", padx=(6, 0))
        ttk.Button(fill_row, text="Bright Green", style="Small.TButton", command=lambda: self.upscale_fill_color.set("#00ff00")).pack(side="left", padx=(6, 0))
        ttk.Button(fill_row, text="Clear", style="Small.TButton", command=self.clear_upscale_fill_color).pack(side="left", padx=(6, 0))
        self.upscale_fill_swatch = tk.Label(fill_row, text="transparent", bg=self._colors["card"], fg=self._colors["subtext"], width=12, anchor="center")
        self.upscale_fill_swatch.pack(side="left", padx=(8, 0))
        self.add_setting_row(upscale_frame, 4, "Fill color", fill_row, "Blank preserves alpha. Use #00ff00 for bright green chroma-key output.")

        ttk.Label(
            upscale_frame,
            text=(
                "Safe Upscale is deterministic: it only resizes pixels, preserves the original file, "
                "and can run after background removal in the same Process Images job."
            ),
            style="CardSubtle.TLabel",
            wraplength=670,
            justify="left"
        ).grid(row=7, column=0, columnspan=3, sticky="w", padx=10, pady=(8, 2))

    def clear_log(self):
        self.log.delete("1.0", "end")

    def choose_image(self):
        path = filedialog.askopenfilename(
            title="Choose image",
            filetypes=[
                ("Image files", "*.png *.jpg *.jpeg *.webp"),
                ("All files", "*.*"),
            ]
        )

        if path:
            self.input_path.set(path)
            if not self.output_path.get():
                self.output_path.set(str(Path(path).parent / "transparent_output"))

    def choose_folder(self):
        path = filedialog.askdirectory(title="Choose input folder")
        if path:
            self.input_path.set(path)
            if not self.output_path.get():
                self.output_path.set(str(Path(path) / "transparent_output"))

    def choose_output(self):
        path = filedialog.askdirectory(title="Choose output folder")
        if path:
            self.output_path.set(path)

    def normalize_hex_text(self, text):
        rgb = parse_hex_color(text, "Background color")
        if not rgb:
            return ""
        return "#{:02x}{:02x}{:02x}".format(*rgb)

    def apply_chroma_key_preset(self):
        # Keep the hex field and swatch in sync with the selected preset,
        # but do not automatically turn off auto-sampling just because the
        # combobox value changed during UI setup. The Use Preset button does that.
        preset_name = self.chroma_key_preset.get().strip()
        hex_value = CHROMA_KEY_PRESETS.get(preset_name)
        if hex_value:
            self.chroma_key_color.set(hex_value)

    def use_selected_chroma_preset(self):
        preset_name = self.chroma_key_preset.get().strip()
        hex_value = CHROMA_KEY_PRESETS.get(preset_name)
        if not hex_value:
            messagebox.showerror("Invalid Preset", "Choose a chroma-key preset first.")
            return
        self.chroma_key_color.set(hex_value)
        self.chroma_auto_sample.set(False)
        self.write_log(f"Chroma key preset selected: {preset_name} = {hex_value}")

    def use_chroma_auto_sample(self):
        self.chroma_auto_sample.set(True)
        self.write_log("Chroma key auto-sample enabled. The tool will sample the image border.")

    def choose_chroma_key_color(self):
        initial = self.chroma_key_color.get().strip() or "#00ff00"
        try:
            initial = self.normalize_hex_text(initial) or "#00ff00"
        except ValueError:
            initial = "#00ff00"
        _rgb, hex_value = colorchooser.askcolor(color=initial, title="Choose chroma key color")
        if hex_value:
            self.chroma_key_color.set(hex_value.lower())
            self.chroma_auto_sample.set(False)

    def choose_chroma_output_color(self):
        initial = self.chroma_output_background_color.get().strip() or "#00ff00"
        try:
            initial = self.normalize_hex_text(initial) or "#00ff00"
        except ValueError:
            initial = "#00ff00"
        _rgb, hex_value = colorchooser.askcolor(color=initial, title="Choose chroma output background color")
        if hex_value:
            self.chroma_output_background_color.set(hex_value.lower())

    def update_color_swatch(self, widget_name, value, empty_text="transparent"):
        if not hasattr(self, widget_name):
            return
        widget = getattr(self, widget_name)
        try:
            normalized = self.normalize_hex_text(value)
        except ValueError:
            widget.configure(text="invalid", bg="#fff0f0", fg="#9a1b1b")
            return

        if not normalized:
            widget.configure(text=empty_text, bg=self._colors["card"], fg=self._colors["subtext"])
            return

        r, g, b = parse_hex_color(normalized, "Color")
        luminance = (0.299 * r) + (0.587 * g) + (0.114 * b)
        fg = "#111111" if luminance >= 150 else "#ffffff"
        widget.configure(text=normalized, bg=normalized, fg=fg)

    def update_chroma_key_swatch(self):
        self.update_color_swatch("chroma_key_swatch", self.chroma_key_color.get().strip(), "auto")

    def update_chroma_output_swatch(self):
        self.update_color_swatch("chroma_output_swatch", self.chroma_output_background_color.get().strip(), "transparent")

    def get_first_input_image_path(self):
        input_text = self.input_path.get().strip()
        if not input_text:
            return None
        input_path = Path(input_text)
        if input_path.is_file() and input_path.suffix.lower() in SUPPORTED_EXTENSIONS:
            return input_path
        if input_path.is_dir():
            images = collect_images(input_path, recursive=self.recursive.get())
            return images[0] if images else None
        return None

    def sample_chroma_key_from_image(self):
        image_path = self.get_first_input_image_path()
        if not image_path:
            messagebox.showerror("No Image", "Choose an input image first, then click Sample Image.")
            return

        try:
            img = Image.open(image_path).convert("RGB")
        except Exception as exc:
            messagebox.showerror("Open Failed", f"Could not open image:\n{exc}")
            return

        width, height = img.size
        max_w, max_h = 900, 650
        scale = min(max_w / width, max_h / height, 1.0)
        preview_size = (max(1, int(width * scale)), max(1, int(height * scale)))
        preview = img.resize(preview_size, Image.Resampling.LANCZOS)

        window = tk.Toplevel(self.root)
        window.title("Click the green background to sample it")
        window.configure(bg=self._colors["bg"])
        window.geometry(f"{preview_size[0] + 28}x{preview_size[1] + 88}")

        instruction = ttk.Label(
            window,
            text="Click the background color you want removed. This turns off auto-sample for manual control.",
            style="Subtle.TLabel",
            wraplength=max(300, preview_size[0])
        )
        instruction.pack(fill="x", padx=10, pady=(8, 6))

        canvas = tk.Canvas(window, width=preview_size[0], height=preview_size[1], highlightthickness=1, highlightbackground=self._colors["line"])
        canvas.pack(padx=10, pady=(0, 10))
        photo = ImageTk.PhotoImage(preview)
        canvas.create_image(0, 0, image=photo, anchor="nw")
        canvas.image = photo

        def on_click(event):
            src_x = int(clamp(round(event.x / scale), 0, width - 1)) if scale else event.x
            src_y = int(clamp(round(event.y / scale), 0, height - 1)) if scale else event.y
            rgb = img.getpixel((src_x, src_y))
            self.chroma_key_color.set(hex_from_rgb(rgb))
            self.chroma_auto_sample.set(False)
            self.write_log(f"Sampled chroma key color from {image_path.name} at ({src_x}, {src_y}): {hex_from_rgb(rgb)} RGB {rgb}")
            window.destroy()

        canvas.bind("<Button-1>", on_click)

    def choose_background_color(self):
        initial = self.output_background_color.get().strip() or "#000000"
        try:
            initial = self.normalize_hex_text(initial) or "#000000"
        except ValueError:
            initial = "#000000"
        _rgb, hex_value = colorchooser.askcolor(color=initial, title="Choose background color")
        if hex_value:
            self.output_background_color.set(hex_value.lower())

    def clear_background_color(self):
        self.output_background_color.set("")

    def choose_upscale_fill_color(self):
        initial = self.upscale_fill_color.get().strip() or "#00ff00"
        try:
            initial = self.normalize_hex_text(initial) or "#00ff00"
        except ValueError:
            initial = "#00ff00"
        _rgb, hex_value = colorchooser.askcolor(color=initial, title="Choose upscale fill color")
        if hex_value:
            self.upscale_fill_color.set(hex_value.lower())

    def clear_upscale_fill_color(self):
        self.upscale_fill_color.set("")
        self.upscale_fill_transparent.set(False)

    def update_upscale_fill_color_swatch(self):
        if not hasattr(self, "upscale_fill_swatch"):
            return
        text = self.upscale_fill_color.get().strip()
        try:
            normalized = self.normalize_hex_text(text)
        except ValueError:
            self.upscale_fill_swatch.configure(text="invalid", bg="#fff0f0", fg="#9a1b1b")
            return

        if not normalized:
            self.upscale_fill_swatch.configure(text="transparent", bg=self._colors["card"], fg=self._colors["subtext"])
            return

        r, g, b = parse_hex_color(normalized, "Transparent fill color")
        luminance = (0.299 * r) + (0.587 * g) + (0.114 * b)
        fg = "#111111" if luminance >= 150 else "#ffffff"
        self.upscale_fill_swatch.configure(text=normalized, bg=normalized, fg=fg)

    def update_background_color_swatch(self):
        if not hasattr(self, "bg_color_swatch"):
            return
        text = self.output_background_color.get().strip()
        try:
            normalized = self.normalize_hex_text(text)
        except ValueError:
            self.bg_color_swatch.configure(text="invalid", bg="#fff0f0", fg="#9a1b1b")
            return

        if not normalized:
            self.bg_color_swatch.configure(text="transparent", bg=self._colors["card"], fg=self._colors["subtext"])
            return

        r, g, b = parse_hex_color(normalized, "Background color")
        luminance = (0.299 * r) + (0.587 * g) + (0.114 * b)
        fg = "#111111" if luminance >= 150 else "#ffffff"
        self.bg_color_swatch.configure(text=normalized, bg=normalized, fg=fg)

    def write_log(self, message):
        self.log.insert("end", message + "\n")
        self.log.see("end")
        self.root.update_idletasks()


    def validate_common_paths_and_images(self):
        input_text = self.input_path.get().strip()
        output_text = self.output_path.get().strip()

        if not input_text:
            messagebox.showerror("Missing Input", "Choose an input image or folder.")
            return None

        if not output_text:
            messagebox.showerror("Missing Output", "Choose an output folder.")
            return None

        input_path = Path(input_text)
        output_root = Path(output_text)

        if not input_path.exists():
            messagebox.showerror("Input Not Found", f"The input path does not exist:\n{input_path}")
            return None

        images = collect_images(input_path, recursive=self.recursive.get())

        if not images:
            messagebox.showwarning(
                "No Images Found",
                "No supported images were found.\nSupported: PNG, JPG, JPEG, WEBP"
            )
            return None

        return input_path, output_root, images

    def validate_upscale_settings(self):
        try:
            scale_factor = int(self.upscale_factor.get())
        except (TypeError, ValueError):
            messagebox.showerror("Invalid Scale Factor", "Scale factor must be 2, 3, or 4.")
            return None

        if scale_factor not in (2, 3, 4):
            messagebox.showerror("Invalid Scale Factor", "Scale factor must be 2, 3, or 4.")
            return None

        resample_method = self.upscale_resample.get().strip().upper() or "LANCZOS"
        if resample_method not in {"LANCZOS", "BICUBIC", "NEAREST"}:
            messagebox.showerror("Invalid Resampling", "Resampling must be LANCZOS, BICUBIC, or NEAREST.")
            return None

        fill_color = ""
        if self.upscale_fill_transparent.get():
            fill_color = self.upscale_fill_color.get().strip()
            if not fill_color:
                messagebox.showerror(
                    "Missing Fill Color",
                    "Enter a fill color like #00ff00, or turn off 'Fill transparent pixels after upscale'."
                )
                return None
            try:
                parse_hex_color(fill_color, "Transparent fill color")
            except ValueError as e:
                messagebox.showerror("Invalid Fill Color", str(e))
                return None

        return scale_factor, resample_method, fill_color

    def build_pipeline_suffix(self, threshold=None):
        parts = []

        upscale_first = (
            self.use_upscale_processing.get()
            and self.pipeline_order.get() == PIPELINE_ORDER_UPSCALE_FIRST
            and (self.use_chroma_processing.get() or self.use_guide_processing.get())
        )

        if self.use_upscale_processing.get() and upscale_first:
            try:
                parts.append(f"upscaled_{int(self.upscale_factor.get())}x")
            except Exception:
                parts.append("upscaled")
        if self.use_chroma_processing.get():
            parts.append("chroma")
        if self.use_guide_processing.get():
            parts.append("guide")
            if threshold is not None and self.multi_threshold.get():
                parts.append(f"t{threshold}")
        if self.use_upscale_processing.get() and not upscale_first:
            try:
                parts.append(f"upscaled_{int(self.upscale_factor.get())}x")
            except Exception:
                parts.append("upscaled")
        return "_" + "_".join(parts) if parts else "_processed"

    def process_selected_steps(self):
        use_chroma = self.use_chroma_processing.get()
        use_guide = self.use_guide_processing.get()
        use_upscale = self.use_upscale_processing.get()

        if not (use_chroma or use_guide or use_upscale):
            messagebox.showerror(
                "Nothing Selected",
                "Turn on at least one tab: Chroma Key, Guide, or Upscale."
            )
            return

        validated = self.validate_common_paths_and_images()
        if validated is None:
            return
        _input_path, output_root, images = validated

        if use_upscale:
            upscale_settings = self.validate_upscale_settings()
            if upscale_settings is None:
                return
            scale_factor, resample_method, fill_color = upscale_settings
        else:
            scale_factor = None
            resample_method = None
            fill_color = ""

        upscale_first = (
            use_upscale
            and self.pipeline_order.get() == PIPELINE_ORDER_UPSCALE_FIRST
            and (use_chroma or use_guide)
        )

        thresholds = [self.threshold.get()]
        if use_guide and self.multi_threshold.get():
            thresholds = [6, 8, 10, 12]

        total_jobs = len(images) * len(thresholds)
        self.progress["maximum"] = total_jobs
        self.progress["value"] = 0
        self.clear_log()

        enabled_steps = []
        if upscale_first:
            enabled_steps.append("Safe Upscale")
        if use_chroma:
            enabled_steps.append("Chroma Key")
        if use_guide:
            enabled_steps.append("Guide Cut")
        if use_upscale and not upscale_first:
            enabled_steps.append("Safe Upscale")

        self.write_log(f"Found {len(images)} image(s).")
        self.write_log("Pipeline: " + " -> ".join(enabled_steps))
        if upscale_first and resample_method != "NEAREST":
            self.write_log("Note: Upscale-first can blur chroma edges with LANCZOS/BICUBIC. For this experiment, NEAREST is the safest resampling option.")
        if use_chroma:
            self.write_log(f"Chroma auto-sample: {'ON' if self.chroma_auto_sample.get() else 'OFF'}")
            self.write_log(f"Chroma key color: {self.chroma_key_color.get().strip() or 'blank'}")
            self.write_log(f"Chroma tolerance: {self.chroma_tolerance.get()}")
            self.write_log(f"Chroma edge cleanup: {self.chroma_softness.get()}")
            self.write_log(f"Chroma spill cleanup: {'ON' if self.chroma_despill.get() else 'OFF'} (strength {self.chroma_despill_strength.get()})")
        if use_guide:
            self.write_log(f"Guide threshold(s): {', '.join(str(t) for t in thresholds)}")
            self.write_log(f"Use guide outline: {'ON' if self.use_guide_outline.get() else 'OFF'}")
            self.write_log(f"Remove guide outline: {'ON' if self.remove_guide_outline.get() else 'OFF'}")
        if use_upscale:
            self.write_log(f"Upscale: {scale_factor}x {resample_method}; fill {fill_color or 'transparent'}")
        self.write_log("Starting...\n")

        done = 0
        import tempfile
        with tempfile.TemporaryDirectory(prefix="neosakura_pipeline_") as tmpdir:
            tmp_root = Path(tmpdir)
            for threshold in thresholds:
                for image_path in images:
                    try:
                        current_path = image_path
                        step_index = 0

                        if self.multi_threshold.get() and use_guide:
                            output_folder = output_root / f"t{threshold}"
                        else:
                            output_folder = output_root
                        output_path = output_folder / f"{image_path.stem}{self.build_pipeline_suffix(threshold)}.png"

                        self.write_log(f"Processing {image_path.name}...")

                        if use_upscale and upscale_first:
                            step_index += 1
                            upscale_out = tmp_root / f"{image_path.stem}_{threshold}_{step_index}_upscale_first.png"
                            stats = safe_upscale_image(
                                input_path=current_path,
                                output_path=upscale_out,
                                scale_factor=scale_factor,
                                resample_method=resample_method,
                                fill_transparent_color="",
                            )
                            current_path = upscale_out
                            self.write_log(
                                f"  Upscale first: {stats['original_width']}x{stats['original_height']} -> "
                                f"{stats['new_width']}x{stats['new_height']} using {stats['resample_method']}"
                            )

                        if use_chroma:
                            step_index += 1
                            chroma_out = tmp_root / f"{image_path.stem}_{threshold}_{step_index}_chroma.png"
                            stats = remove_border_connected_chroma_key(
                                input_path=current_path,
                                output_path=chroma_out,
                                key_color=self.chroma_key_color.get(),
                                auto_sample_key=self.chroma_auto_sample.get(),
                                tolerance=self.chroma_tolerance.get(),
                                softness=self.chroma_softness.get(),
                                remove_inner_matching=self.chroma_remove_inner.get(),
                                output_background_color=self.chroma_output_background_color.get(),
                                despill_enabled=self.chroma_despill.get(),
                                despill_strength=self.chroma_despill_strength.get(),
                            )
                            current_path = chroma_out
                            self.write_log(
                                f"  Chroma: key {stats['key_hex']} ({stats['key_mode']}), "
                                f"removed {stats['removed_pixels']} px, edge cleanup {stats['edge_cleanup_pixels']} px, "
                                f"spill cleanup {stats['despill_pixels']} px"
                            )

                        if use_guide:
                            step_index += 1
                            guide_out = tmp_root / f"{image_path.stem}_{threshold}_{step_index}_guide.png"
                            stats = remove_border_connected_dark_background(
                                input_path=current_path,
                                output_path=guide_out,
                                threshold=threshold,
                                remove_inner_blobs=self.remove_inner_blobs.get(),
                                blob_tolerance=self.blob_tolerance.get(),
                                min_blob_area=self.min_blob_area.get(),
                                min_blob_width=self.min_blob_width.get(),
                                min_blob_height=self.min_blob_height.get(),
                                blob_feather=self.blob_feather.get(),
                                use_guide_outline=self.use_guide_outline.get(),
                                remove_guide_outline=self.remove_guide_outline.get(),
                                guide_manual_color=self.guide_manual_color.get(),
                                guide_hue_tolerance=self.guide_hue_tolerance.get(),
                                guide_min_saturation=self.guide_min_saturation.get(),
                                guide_min_value=self.guide_min_value.get(),
                                guide_boundary_radius=self.guide_boundary_radius.get(),
                                guide_required_boundary_percent=self.guide_required_boundary_percent.get(),
                                output_background_color=self.output_background_color.get(),
                            )
                            current_path = guide_out
                            self.write_log(
                                f"  Guide: threshold {threshold}, outer {stats['removed_outer_pixels']} px, "
                                f"inner {stats['removed_inner_pixels']} px, blobs {stats['removed_blob_count']}"
                            )

                        if use_upscale and not upscale_first:
                            stats = safe_upscale_image(
                                input_path=current_path,
                                output_path=output_path,
                                scale_factor=scale_factor,
                                resample_method=resample_method,
                                fill_transparent_color=fill_color,
                            )
                            self.write_log(
                                f"  Upscale: {stats['original_width']}x{stats['original_height']} -> "
                                f"{stats['new_width']}x{stats['new_height']}, fill {stats['fill_color']}"
                            )
                        else:
                            stats = save_rgba_with_optional_fill(
                                input_path=current_path,
                                output_path=output_path,
                                fill_transparent_color=fill_color if use_upscale else "",
                            )
                            if use_upscale and upscale_first:
                                self.write_log(
                                    f"  Final save: {stats['width']}x{stats['height']}, fill {stats['fill_color']}"
                                )

                        self.write_log(f"Saved: {output_path}")
                        self.write_log("")

                    except Exception as e:
                        self.write_log(f"ERROR processing {image_path}: {e}")
                        self.write_log("")

                    done += 1
                    self.progress["value"] = done
                    self.root.update_idletasks()

        self.write_log("Finished.")
        messagebox.showinfo("Done", "Selected image processing finished.")

    def process_chroma_key(self):
        input_text = self.input_path.get().strip()
        output_text = self.output_path.get().strip()

        if not input_text:
            messagebox.showerror("Missing Input", "Choose an input image or folder.")
            return

        if not output_text:
            messagebox.showerror("Missing Output", "Choose an output folder.")
            return

        input_path = Path(input_text)
        output_root = Path(output_text)

        if not input_path.exists():
            messagebox.showerror("Input Not Found", f"The input path does not exist:\n{input_path}")
            return

        images = collect_images(input_path, recursive=self.recursive.get())

        if not images:
            messagebox.showwarning(
                "No Images Found",
                "No supported images were found.\nSupported: PNG, JPG, JPEG, WEBP"
            )
            return

        total_jobs = len(images)
        done = 0
        self.progress["maximum"] = total_jobs
        self.progress["value"] = 0
        self.clear_log()

        self.write_log(f"Found {len(images)} image(s).")
        self.write_log("Mode: Chroma Key")
        self.write_log(f"Auto-sample from border: {'ON' if self.chroma_auto_sample.get() else 'OFF'}")
        self.write_log(f"Manual key color: {self.chroma_key_color.get().strip() or 'blank'}")
        self.write_log(f"Tolerance: {self.chroma_tolerance.get()}")
        self.write_log(f"Edge cleanup: {self.chroma_softness.get()}")
        self.write_log(f"Spill cleanup: {'ON' if self.chroma_despill.get() else 'OFF'} (strength {self.chroma_despill_strength.get()})")
        self.write_log(f"Remove interior blobs: {'ON' if self.chroma_remove_inner.get() else 'OFF'}")
        self.write_log(f"Output background fill: {self.chroma_output_background_color.get().strip() or 'transparent'}")
        self.write_log("Starting...\n")

        for image_path in images:
            try:
                output_path = output_root / f"{image_path.stem}_chroma_transparent.png"
                self.write_log(f"Removing chroma key from {image_path.name}...")
                stats = remove_border_connected_chroma_key(
                    input_path=image_path,
                    output_path=output_path,
                    key_color=self.chroma_key_color.get(),
                    auto_sample_key=self.chroma_auto_sample.get(),
                    tolerance=self.chroma_tolerance.get(),
                    softness=self.chroma_softness.get(),
                    remove_inner_matching=self.chroma_remove_inner.get(),
                    output_background_color=self.chroma_output_background_color.get(),
                    despill_enabled=self.chroma_despill.get(),
                    despill_strength=self.chroma_despill_strength.get(),
                )
                self.write_log(f"Saved: {output_path}")
                self.write_log(
                    "Stats: "
                    f"key {stats['key_hex']} RGB {stats['key_rgb']} ({stats['key_mode']}), "
                    f"border samples {stats['auto_sample_count']}, "
                    f"removed pixels {stats['removed_pixels']}, "
                    f"edge cleanup pixels {stats['edge_cleanup_pixels']}, "
                    f"spill cleanup pixels {stats['despill_pixels']}, "
                    f"inner removed {stats['inner_removed_pixels']}"
                )
                self.write_log("")
            except Exception as e:
                self.write_log(f"ERROR removing chroma key from {image_path}: {e}")
                self.write_log("")

            done += 1
            self.progress["value"] = done
            self.root.update_idletasks()

        self.write_log("Finished.")
        messagebox.showinfo("Done", "Chroma-key transparent PNG export finished.")


    def process(self):
        input_text = self.input_path.get().strip()
        output_text = self.output_path.get().strip()

        if not input_text:
            messagebox.showerror("Missing Input", "Choose an input image or folder.")
            return

        if not output_text:
            messagebox.showerror("Missing Output", "Choose an output folder.")
            return

        input_path = Path(input_text)
        output_root = Path(output_text)

        if not input_path.exists():
            messagebox.showerror("Input Not Found", f"The input path does not exist:\n{input_path}")
            return

        images = collect_images(input_path, recursive=self.recursive.get())

        if not images:
            messagebox.showwarning(
                "No Images Found",
                "No supported images were found.\nSupported: PNG, JPG, JPEG, WEBP"
            )
            return

        thresholds = [6, 8, 10, 12] if self.multi_threshold.get() else [self.threshold.get()]

        total_jobs = len(images) * len(thresholds)
        done = 0

        self.progress["maximum"] = total_jobs
        self.progress["value"] = 0
        self.clear_log()

        self.write_log(f"Found {len(images)} image(s).")
        self.write_log(f"Main threshold(s): {', '.join(str(t) for t in thresholds)}")
        self.write_log(f"Interior blob removal: {'ON' if self.remove_inner_blobs.get() else 'OFF'}")
        self.write_log(f"Blob tolerance: {self.blob_tolerance.get()}")
        self.write_log(f"Minimum blob area: {self.min_blob_area.get()}")
        self.write_log(f"Minimum blob width: {self.min_blob_width.get()}")
        self.write_log(f"Minimum blob height: {self.min_blob_height.get()}")
        self.write_log(f"Blob feather: {self.blob_feather.get()} px")
        self.write_log(f"Guide outline detection: {'ON' if self.use_guide_outline.get() else 'OFF'}")
        self.write_log(f"Delete guide outline after cut: {'ON' if self.remove_guide_outline.get() else 'OFF'}")
        self.write_log(f"Guide manual color: {self.guide_manual_color.get().strip() or 'AUTO'}")
        self.write_log(f"Guide hue tolerance: {self.guide_hue_tolerance.get()}")
        self.write_log(f"Guide minimum saturation: {self.guide_min_saturation.get()}")
        self.write_log(f"Guide minimum value: {self.guide_min_value.get()}")
        self.write_log(f"Guide boundary radius: {self.guide_boundary_radius.get()}")
        self.write_log(f"Required outline coverage: {self.guide_required_boundary_percent.get()}%")
        self.write_log(f"Background fill color: {self.output_background_color.get().strip() or 'transparent'}")
        self.write_log("Starting...\n")

        for threshold in thresholds:
            for image_path in images:
                try:
                    if self.multi_threshold.get():
                        threshold_folder = output_root / f"t{threshold}"
                        output_path = threshold_folder / f"{image_path.stem}_t{threshold}_transparent.png"
                    else:
                        output_path = output_root / f"{image_path.stem}_transparent.png"

                    self.write_log(f"Processing {image_path.name} at threshold {threshold}...")

                    stats = remove_border_connected_dark_background(
                        input_path=image_path,
                        output_path=output_path,
                        threshold=threshold,
                        remove_inner_blobs=self.remove_inner_blobs.get(),
                        blob_tolerance=self.blob_tolerance.get(),
                        min_blob_area=self.min_blob_area.get(),
                        min_blob_width=self.min_blob_width.get(),
                        min_blob_height=self.min_blob_height.get(),
                        blob_feather=self.blob_feather.get(),
                        use_guide_outline=self.use_guide_outline.get(),
                        remove_guide_outline=self.remove_guide_outline.get(),
                        guide_manual_color=self.guide_manual_color.get(),
                        guide_hue_tolerance=self.guide_hue_tolerance.get(),
                        guide_min_saturation=self.guide_min_saturation.get(),
                        guide_min_value=self.guide_min_value.get(),
                        guide_boundary_radius=self.guide_boundary_radius.get(),
                        guide_required_boundary_percent=self.guide_required_boundary_percent.get(),
                        output_background_color=self.output_background_color.get(),
                    )

                    self.write_log(f"Saved: {output_path}")
                    self.write_log(
                        "Stats: "
                        f"background range {stats['sampled_background_min']}–{stats['sampled_background_max']}, "
                        f"inner cutoff {stats['inner_blob_cutoff']}, "
                        f"outer pixels removed {stats['removed_outer_pixels']}, "
                        f"inner blobs removed {stats['removed_blob_count']}, "
                        f"inner pixels removed {stats['removed_inner_pixels']}, "
                        f"unoutlined blobs preserved {stats['skipped_unoutlined_blob_count']}"
                    )

                    if stats["guide_detection_mode"] != "OFF":
                        detected = stats["guide_detected_rgb"]
                        detected_text = f"RGB {detected}" if detected else "not found"
                        self.write_log(
                            "Guide: "
                            f"mode {stats['guide_detection_mode']}, "
                            f"detected {detected_text}, "
                            f"kind {stats.get('guide_color_kind', 'OFF')}, "
                            f"boundary candidates {stats['guide_candidate_pixels']}, "
                            f"required outline coverage {stats['guide_required_boundary_percent']}%, "
                            f"components removed {stats['guide_removed_components']}, "
                            f"guide pixels removed {stats['guide_removed_pixels']}, "
                            f"stray components blackened {stats['guide_blackened_components']}, "
                            f"stray pixels blackened {stats['guide_blackened_pixels']}"
                        )

                    self.write_log("")

                except Exception as e:
                    self.write_log(f"ERROR processing {image_path}: {e}")
                    self.write_log("")

                done += 1
                self.progress["value"] = done
                self.root.update_idletasks()

        self.write_log("Finished.")
        messagebox.showinfo("Done", "Transparent PNG export finished.")


    def process_safe_upscale(self):
        input_text = self.input_path.get().strip()
        output_text = self.output_path.get().strip()

        if not input_text:
            messagebox.showerror("Missing Input", "Choose an input image or folder.")
            return

        if not output_text:
            messagebox.showerror("Missing Output", "Choose an output folder.")
            return

        input_path = Path(input_text)
        output_root = Path(output_text)

        if not input_path.exists():
            messagebox.showerror("Input Not Found", f"The input path does not exist:\n{input_path}")
            return

        images = collect_images(input_path, recursive=self.recursive.get())

        if not images:
            messagebox.showwarning(
                "No Images Found",
                "No supported images were found.\nSupported: PNG, JPG, JPEG, WEBP"
            )
            return

        try:
            scale_factor = int(self.upscale_factor.get())
        except (TypeError, ValueError):
            messagebox.showerror("Invalid Scale Factor", "Scale factor must be 2, 3, or 4.")
            return

        if scale_factor not in (2, 3, 4):
            messagebox.showerror("Invalid Scale Factor", "Scale factor must be 2, 3, or 4.")
            return

        resample_method = self.upscale_resample.get().strip().upper() or "LANCZOS"
        if resample_method not in {"LANCZOS", "BICUBIC", "NEAREST"}:
            messagebox.showerror("Invalid Resampling", "Resampling must be LANCZOS, BICUBIC, or NEAREST.")
            return

        fill_color = ""
        if self.upscale_fill_transparent.get():
            fill_color = self.upscale_fill_color.get().strip()
            if not fill_color:
                messagebox.showerror(
                    "Missing Fill Color",
                    "Enter a fill color like #00ff00, or turn off 'Fill transparent pixels after upscale'."
                )
                return
            try:
                parse_hex_color(fill_color, "Transparent fill color")
            except ValueError as e:
                messagebox.showerror("Invalid Fill Color", str(e))
                return

        self.progress["maximum"] = len(images)
        self.progress["value"] = 0
        self.clear_log()

        self.write_log(f"Found {len(images)} image(s).")
        self.write_log("Mode: Safe Upscale")
        self.write_log(f"Scale factor: {scale_factor}x")
        self.write_log(f"Resampling: {resample_method}")
        self.write_log(f"Fill transparency: {'ON' if fill_color else 'OFF'}")
        if fill_color:
            self.write_log(f"Fill color: {fill_color}")
        self.write_log("Starting...\n")

        done = 0

        for image_path in images:
            try:
                output_path = output_root / f"{image_path.stem}_upscaled_{scale_factor}x.png"

                self.write_log(f"Upscaling {image_path.name}...")

                stats = safe_upscale_image(
                    input_path=image_path,
                    output_path=output_path,
                    scale_factor=scale_factor,
                    resample_method=resample_method,
                    fill_transparent_color=fill_color,
                )

                self.write_log(f"Saved: {output_path}")
                self.write_log(
                    "Stats: "
                    f"{stats['original_width']}x{stats['original_height']} -> "
                    f"{stats['new_width']}x{stats['new_height']}, "
                    f"{stats['scale_factor']}x, "
                    f"{stats['resample_method']}, "
                    f"fill {stats['fill_color']}"
                )
                self.write_log("")

            except Exception as e:
                self.write_log(f"ERROR upscaling {image_path}: {e}")
                self.write_log("")

            done += 1
            self.progress["value"] = done
            self.root.update_idletasks()

        self.write_log("Finished.")
        messagebox.showinfo("Done", "Safe upscale export finished.")



def main():
    root = tk.Tk()
    app = TransparentBackgroundApp(root)
    root.mainloop()


if __name__ == "__main__":
    main()
