"""Analyze compression videos by fitting parabola shapes to particle contours."""

import os
import sys
import gc
import tkinter as tk
from tkinter import filedialog, messagebox
from pathlib import Path
from typing import Tuple, Optional, Dict, Any

import cv2
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from PIL import Image, ImageTk

# File dialog settings and starting parameters for the interactive workflow.
SUPPORTED_VIDEO_FORMATS = [
    ("Video files", "*.mp4 *.avi *.mov *.mkv *.MP4 *.AVI *.MOV *.MKV"),
    ("All files", "*.*"),
]

DEFAULT_PREPROCESS_PARAMS = {
    "clahe_clip": 2.0,
    "clahe_tile_grid": (8, 8),
    "blur_kernel": (5, 5),
    "apply_sharpen": False,
    "apply_otsu_thresh": False,
    "otsu_scale": 1.0,
    "otsu_offset": 0,
}

DEFAULT_PARABOLA_PARAMS = {"canny_low": 50, "canny_high": 150}
RAW_VIDEO_DIR = Path("raw_videos")
PROJECT_DIR = Path(__file__).resolve().parent
ANALYSIS_OUTPUT_DIR = PROJECT_DIR / "output_parabola_fits"


def failed_detection_result(overlay: np.ndarray):
    """Return a consistent empty result for frames where fitting fails."""
    return (0.0, 0.0, 0.0, 0.0, None, overlay, None, [])


def select_video_file() -> str:
    """Open file picker dialog to select a video file."""
    root = tk.Tk()
    root.withdraw()
    # Keep the file picker close to the expected input videos, but still allow
    # running the script from a fresh folder where raw_videos does not exist yet.
    initial_dir = RAW_VIDEO_DIR if RAW_VIDEO_DIR.exists() else Path(".")
    file_path = filedialog.askopenfilename(
        title="Select a video file",
        initialdir=str(initial_dir),
        filetypes=SUPPORTED_VIDEO_FORMATS,
    )
    if not file_path:
        print("No video selected. Exiting.")
        sys.exit()
    return file_path


def select_crop_area(img: np.ndarray) -> Tuple[int, int, int, int]:
    """Let the user draw a rectangle on the image for cropping."""
    r = cv2.selectROI("Select Crop Area", img, fromCenter=False, showCrosshair=True)
    cv2.destroyWindow("Select Crop Area")
    x, y, w, h = r
    cv2.destroyAllWindows()
    cv2.waitKey(1)
    return (int(x), int(y), int(x + w), int(y + h))


def preprocess_image(
    img: np.ndarray,
    crop_rect: Optional[Tuple[int, int, int, int]] = None,
    clahe_clip: float = 2.0,
    clahe_tile_grid: Tuple[int, int] = (8, 8),
    blur_kernel: Tuple[int, int] = (5, 5),
    apply_sharpen: bool = False,
    apply_otsu_thresh: bool = False,
    otsu_scale: float = 1.0,
    otsu_offset: int = 0,
) -> np.ndarray:
    """
    Preprocess an image with the given parameters.

    Args:
        img: Input image (BGR or grayscale)
        crop_rect: Optional (x1, y1, x2, y2) cropping coordinates
        clahe_clip: CLAHE clip limit
        clahe_tile_grid: CLAHE tile grid size
        blur_kernel: Gaussian blur kernel size
        apply_sharpen: Whether to apply sharpening
        apply_otsu_thresh: Whether to apply Otsu thresholding
        otsu_scale: Otsu threshold scaling factor
        otsu_offset: Otsu threshold offset

    Returns:
        Processed image (grayscale)
    """
    # All downstream contour logic works on the cropped region only, so crop
    # before grayscale conversion and filtering.
    if crop_rect is not None:
        x1, y1, x2, y2 = crop_rect
        img = img[y1:y2, x1:x2]

    img_gray = (
        cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if len(img.shape) == 3 else img.copy()
    )

    # CLAHE improves local contrast, which makes the particle boundary easier
    # to pick up with Canny edge detection.
    clahe = cv2.createCLAHE(clipLimit=clahe_clip, tileGridSize=clahe_tile_grid)
    img_clahe = clahe.apply(img_gray)

    # A small blur suppresses pixel noise without removing the main boundary.
    img_blur = cv2.GaussianBlur(img_clahe, blur_kernel, 0)

    # Optional sharpening
    if apply_sharpen:
        kernel_sharp = np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]])
        img_proc = cv2.filter2D(img_blur, -1, kernel_sharp)
    else:
        img_proc = img_blur

    # Optional Otsu thresholding
    if apply_otsu_thresh:
        otsu_thresh, _ = cv2.threshold(
            img_proc, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU
        )
        adjusted_thresh = np.clip(otsu_thresh * otsu_scale + otsu_offset, 0, 255)
        _, img_proc = cv2.threshold(img_proc, adjusted_thresh, 255, cv2.THRESH_BINARY)

    return img_proc


def detect_base_line(
    img_gray: np.ndarray,
    low_threshold: int = 50,
    high_threshold: int = 150,
    fixed_base: Optional[int] = -1,
) -> Tuple[int, np.ndarray]:
    """
    Find the lowest edge in img_gray, return its y-coordinate plus
    an RGB preview with the green baseline overlaid.

    Args:
        img_gray: Input grayscale image
        low_threshold: Canny edge detection low threshold
        high_threshold: Canny edge detection high threshold

    Returns:
        Tuple of (lowest y-coordinate, visualization image)
    """
    edges = cv2.Canny(img_gray, low_threshold, high_threshold)
    edges = cv2.dilate(edges, None, iterations=1)
    edges = cv2.erode(edges, None, iterations=1)

    # The baseline is the lowest detected edge unless the GUI provides a fixed
    # baseline value through the slider.
    contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    lowest_y = -1

    if fixed_base > 0:
        lowest_y = fixed_base

    else:
        for cnt in contours:
            ys = cnt[:, 0, 1]
            if ys.size and ys.max() > lowest_y:
                lowest_y = int(ys.max())

    # Create visualization
    canvas = cv2.cvtColor(img_gray, cv2.COLOR_GRAY2BGR)
    if lowest_y >= 0:
        cv2.line(canvas, (0, lowest_y), (img_gray.shape[1], lowest_y), (0, 255, 0), 2)

    return lowest_y, canvas


def normalize_cnt(cnt):
    """Normalize OpenCV contour arrays to an (N, 2) float array."""
    if cnt.ndim == 3 and cnt.shape[1] == 1 and cnt.shape[2] == 2:
        return cnt[:, 0, :].astype(float)
    return cnt.astype(float)


def first_split_idx(cnt, base_y, closed=False):
    """
    Returns the first split index where the contour crosses y = base_y.
    If the crossing is between i and i+1, returns i+1.
    If a point lies exactly on base_y, that point's index is returned.
    If no crossing is found, returns None.
    """
    pts = normalize_cnt(cnt)
    N = len(pts)
    if N < 2:
        return None

    # Walk along the contour until a segment crosses the baseline. This gives
    # a stable split point for separating the upper and lower contour arcs.
    rng = range(N) if closed else range(N - 1)
    for i in rng:
        j = (i + 1) % N
        y0, y1 = pts[i, 1], pts[j, 1]
        f0, f1 = y0 - base_y, y1 - base_y

        if f0 == 0:
            return i

        if (f0 * f1) < 0 or (f1 == 0):
            return j

    return None


def pick_upper_part(cnt, base_y, closed=False):
    """Keep the part of a contour above the baseline."""
    cnt = normalize_cnt(cnt)
    idx = first_split_idx(cnt, base_y, closed=closed)
    if idx is None:
        return cnt

    # Image coordinates grow downward, so the visually upper contour has the
    # smaller mean y-value.
    part1 = cnt[:idx]
    part2 = cnt[idx:]

    if closed:
        part2 = np.vstack([cnt[idx:], cnt[:idx]])

    mean1 = part1[:, 1].mean() if len(part1) else np.inf
    mean2 = part2[:, 1].mean() if len(part2) else np.inf

    return part1 if mean1 < mean2 else part2


def detect_lower_parabola(
    orig_bgr: np.ndarray,
    proc_img: np.ndarray,
    base_y: int,
    low_threshold: int = 50,
    high_threshold: int = 150,
    initial_volume: Optional[float] = None,
    initial_half_height: Optional[float] = None,
    initial_diameter: Optional[float] = None,
    frame_no: int = -1,
    output_dir: Optional[str] = None,
) -> Tuple[float, float, float, float, Optional[float], np.ndarray, Optional[float], list]:
    """
    Fit left and right parabolas to the lower particle contour.

    Returns diameter, volume, area, height, Poisson ratio, overlay image,
    vertical strain, and the two fitted polynomial coefficient arrays.
    """
    _, w = proc_img.shape[:2]
    mid = w // 2
    overlay = orig_bgr.copy()
    overlay_bnw = cv2.cvtColor(proc_img, cv2.COLOR_GRAY2BGR)
    coefs, half_heights = [], []
    wide_pts = []

    fit_pts = []

    # The particle is treated as two mirrored halves around the image midpoint.
    # Each half gets its own x=f(y) parabola fit.
    for side, x_off in (("left", 0), ("right", mid)):
        half_gray = proc_img[:, x_off : x_off + mid]

        # Edges are detected per half so left and right contours cannot merge
        # into one contour across the centerline.
        edges = cv2.Canny(half_gray, low_threshold, high_threshold)
        edges = cv2.morphologyEx(
            edges,
            cv2.MORPH_CLOSE,
            cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)),
            iterations=1,
        )

        cnts, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
        if not cnts:
            return failed_detection_result(overlay)

        # Ignore small fragments so the fit follows the particle boundary.
        valid_cnts = [
            (c, np.mean(c.reshape(-1, 2)[:, 1])) for c in cnts if len(c) > 100
        ]

        if not valid_cnts:
            return failed_detection_result(overlay)

        # Prefer the large contour whose vertical center is closest to the
        # baseline, because that is usually the lower particle boundary.
        cnt, _ = min(valid_cnts, key=lambda x: abs(x[1] - base_y))
        cnt = cnt.reshape(-1, 2)

        # Shift contours back into full cropped-image coordinates before
        # drawing and saving them.
        cnts_shifted = [c.copy() for c in cnts]
        for c in cnts_shifted:
            c[:, 0, 0] += x_off

        cv2.drawContours(overlay, cnts_shifted, -1, (255, 255, 0), 2)
        cv2.drawContours(overlay_bnw, cnts_shifted, -1, (0, 255, 255), 2)

        # Only use the part of the contour above the baseline; points below it
        # are compression-plate/background artifacts for this measurement.
        cnt = pick_upper_part(cnt, base_y - 10, closed=False)

        # The widest point anchors the bottom of each side fit.
        idx = cnt[:, 0].argmin() if side == "left" else cnt[:, 0].argmax()
        wx, wy = cnt[idx]
        wx += x_off
        wide_pts.append((wx, wy))
        half_heights.append(wy)

        mask = cnt[:, 1] > wy
        pts = cnt[mask]

        if len(pts) > 0:
            pts_shifted = pts.copy()
            pts_shifted[:, 0] += x_off

            # These are the actual contour points used for the polynomial fit
            # and later saved to the .npz files.
            fit_pts.append(pts_shifted)
            contour_for_cv = pts_shifted.reshape(-1, 1, 2).astype(np.int32)

            cv2.polylines(
                overlay,
                [contour_for_cv],
                isClosed=False,
                color=(0, 255, 0),
                thickness=2,
            )
        else:
            return failed_detection_result(overlay)

        if pts.shape[0] < 5:
            return failed_detection_result(overlay)

        # Weight the widest point so the parabola stays anchored at the edge.
        vertex_pt = np.array([[wx, wy]], dtype=float)
        pts_ext = np.vstack([pts_shifted, np.tile(vertex_pt, (50, 1))])

        ys, xs = pts_ext[:, 1], pts_ext[:, 0]
        coef = np.polyfit(ys, xs, 2)

        coefs.append(coef)

        y_vals = np.linspace(wy, base_y, 200).astype(int)
        x_vals = np.polyval(coef, y_vals).astype(int)

        # Draw the fitted parabola on both the color overlay and the diagnostic
        # black-and-white overlay.
        for i in range(len(y_vals) - 1):
            p1 = (x_vals[i], y_vals[i])
            p2 = (x_vals[i + 1], y_vals[i + 1])
            cv2.line(overlay, p1, p2, (255, 0, 0), 2)
            cv2.line(overlay_bnw, p1, p2, (255, 0, 0), 2)

        # Red markers show the exact contour points that fed the fit.
        for point in contour_for_cv:
            x, y = int(point[0][0]), int(point[0][1])
            cv2.drawMarker(
                overlay_bnw,
                (x, y),
                (0, 0, 255),
                markerType=cv2.MARKER_CROSS,
                markerSize=6,
                thickness=1,
            )

    if len(fit_pts) != 2 or len(coefs) != 2:
        return failed_detection_result(overlay)

    # Diameter is the distance between the widest left and right fit anchors.
    d_left = mid - wide_pts[0][0]
    d_right = wide_pts[1][0] - mid
    total_diameter = float(d_left + d_right)

    mean_wy = float(np.mean(half_heights))
    y_int = np.linspace(mean_wy, base_y, 200)
    xL = np.polyval(coefs[0], y_int)
    xR = np.polyval(coefs[1], y_int)
    width_y = xR - xL

    # Area integrates the fitted width over height. Volume assumes stacked
    # elliptical slices, using width as the slice diameter.
    total_area = abs(np.trapz(width_y, y_int))
    total_volume = (np.pi / 4.0) * abs(np.trapz(width_y**2, y_int))
    mean_half_height = float(base_y - mean_wy)

    strain = None
    pr = None
    if initial_diameter and initial_half_height:
        # Compare every frame with the first-frame reference geometry.
        dd = total_diameter - initial_diameter
        dh = initial_half_height - mean_half_height
        if initial_diameter and dh:
            strain = dh / initial_half_height
            pr = (dd / initial_diameter) / (dh / initial_half_height)

    pts_left, pts_right = fit_pts
    coef_left, coef_right = coefs[0], coefs[1]

    if frame_no > 0:
        npz_dir = output_dir or "output_parabola_fits"
        # The .npz files are used by compare_experiment_simulation_shape.py to
        # recreate fitted curves without rerunning video analysis.
        save_run_npz(
            os.path.join(npz_dir, f"frame_{frame_no:04d}.npz"),
            orig_bgr,
            pts_left=pts_left,
            pts_right=pts_right,
            coef_left=coef_left,
            coef_right=coef_right,
            base_y=base_y,
            upper_y=mean_wy,
            mid_img=mid,
        )

    return (
        total_diameter,
        total_volume,
        total_area,
        mean_half_height,
        pr,
        overlay,
        strain,
        coefs,
    )


def save_run_npz(
    path,
    overlay_bgr,
    pts_left,
    pts_right,
    coef_left,
    coef_right,
    base_y,
    upper_y,
    mid_img,
):
    """Save the fitted contour points and coefficients for later plotting."""
    np.savez_compressed(
        path,
        overlay_bgr=overlay_bgr,
        pts_left=pts_left,
        pts_right=pts_right,
        coef_left=coef_left,
        coef_right=coef_right,
        base_y=base_y,
        upper_y=upper_y,
        mid_img=mid_img,
    )


class PreprocessingUI:
    """GUI for adjusting preprocessing and parabola detection parameters."""

    def __init__(
        self, original_frame: np.ndarray, crop_rect: Tuple[int, int, int, int]
    ):
        self.original_frame = original_frame
        self.crop_rect = crop_rect
        self.params = {
            "preprocess_params": DEFAULT_PREPROCESS_PARAMS.copy(),
            "parabola_params": DEFAULT_PARABOLA_PARAMS.copy(),
            "baseline_y": None,
        }

        self.setup_ui()

    def setup_ui(self):
        """Initialize the UI components."""
        self.cleanup_existing_windows()

        # Tkinter owns the parameter-selection window. OpenCV owns the crop
        # window, so cleanup_existing_windows keeps the two systems from
        # leaving stale windows behind.
        self.win = tk.Tk()
        self.win.title("Preprocessing & Parabola Settings")
        self.win.grid_rowconfigure(0, weight=1)
        self.win.grid_columnconfigure(0, weight=1)

        # Main container frame
        self.main_frame = tk.Frame(self.win)
        self.main_frame.grid(row=0, column=0, sticky="nsew", padx=10, pady=10)

        # Preview image
        blank_img = Image.new("RGB", (1200, 400), "black")
        self.preview_img = ImageTk.PhotoImage(blank_img)
        self.preview_label = tk.Label(self.main_frame, image=self.preview_img)
        self.preview_label.grid(row=0, column=0, columnspan=3, pady=(0, 10))

        # Create control widgets
        self.create_controls()

        # Button frame
        self.create_button_frame()

        # Set up event bindings
        self.setup_bindings()

        # Initial update and window management
        self.update_preview()
        self.win.protocol("WM_DELETE_WINDOW", self.on_window_close)
        self.win.lift()
        self.win.attributes("-topmost", True)
        self.win.after_idle(lambda: self.win.attributes("-topmost", False))

    def cleanup_existing_windows(self):
        """Clean up any existing OpenCV/Tkinter windows."""
        cv2.destroyAllWindows()
        cv2.waitKey(1)
        if tk._default_root is not None:
            tk._default_root.destroy()
        gc.collect()

    def create_controls(self):
        """Create all the control widgets."""
        # Preprocessing controls frame
        controls_frame = tk.Frame(self.main_frame)
        controls_frame.grid(row=1, column=0, sticky="nsew")

        # CLAHE controls change local contrast before blur and edge detection.
        tk.Label(controls_frame, text="CLAHE Clip:").grid(row=0, column=0, sticky="w")
        self.clahe_clip = tk.Scale(
            controls_frame, from_=0.1, to=10.0, resolution=0.1, orient=tk.HORIZONTAL
        )
        self.clahe_clip.set(self.params["preprocess_params"]["clahe_clip"])
        self.clahe_clip.grid(row=0, column=1, sticky="ew")

        tk.Label(controls_frame, text="Tile Grid:").grid(row=1, column=0, sticky="w")
        tile_frame = tk.Frame(controls_frame)
        tile_frame.grid(row=1, column=1, sticky="ew")
        self.tile_x = tk.Scale(
            tile_frame, from_=1, to=64, resolution=1, orient=tk.HORIZONTAL
        )
        self.tile_x.set(self.params["preprocess_params"]["clahe_tile_grid"][0])
        self.tile_x.pack(side=tk.LEFT, expand=True)
        self.tile_y = tk.Scale(
            tile_frame, from_=1, to=64, resolution=1, orient=tk.HORIZONTAL
        )
        self.tile_y.set(self.params["preprocess_params"]["clahe_tile_grid"][1])
        self.tile_y.pack(side=tk.LEFT, expand=True)

        # Blur controls set the Gaussian kernel. The scale uses odd values
        # because OpenCV requires odd Gaussian kernel dimensions.
        tk.Label(controls_frame, text="Blur Kernel:").grid(row=2, column=0, sticky="w")
        self.blur_kernel = tk.Scale(
            controls_frame, from_=1, to=31, resolution=2, orient=tk.HORIZONTAL
        )
        self.blur_kernel.set(self.params["preprocess_params"]["blur_kernel"][0])
        self.blur_kernel.grid(row=2, column=1, sticky="ew")

        # Otsu thresholding is optional because some videos fit better with
        # grayscale edge detection and some benefit from binarization.
        self.otsu_var = tk.BooleanVar(
            value=self.params["preprocess_params"]["apply_otsu_thresh"]
        )
        tk.Checkbutton(
            controls_frame, text="Apply Otsu Threshold", variable=self.otsu_var
        ).grid(row=3, column=0, columnspan=2, sticky="w")

        tk.Label(controls_frame, text="Otsu Scale:").grid(row=4, column=0, sticky="w")
        self.otsu_scale = tk.Scale(
            controls_frame, from_=0.5, to=2.0, resolution=0.05, orient=tk.HORIZONTAL
        )
        self.otsu_scale.set(self.params["preprocess_params"]["otsu_scale"])
        self.otsu_scale.grid(row=4, column=1, sticky="ew")

        tk.Label(controls_frame, text="Otsu Offset:").grid(row=5, column=0, sticky="w")
        self.otsu_offset = tk.Scale(
            controls_frame, from_=-50, to=50, resolution=1, orient=tk.HORIZONTAL
        )
        self.otsu_offset.set(self.params["preprocess_params"]["otsu_offset"])
        self.otsu_offset.grid(row=5, column=1, sticky="ew")

        # Canny thresholds are separate from preprocessing so the user can tune
        # edge sensitivity after the image already looks reasonable.
        parabola_frame = tk.LabelFrame(self.main_frame, text="Parabola Detection")
        parabola_frame.grid(row=1, column=2, sticky="nsew", padx=10)

        tk.Label(parabola_frame, text="Canny Low:").grid(row=0, column=0, sticky="w")
        self.canny_low = tk.Scale(parabola_frame, from_=1, to=100, orient=tk.HORIZONTAL)
        self.canny_low.set(self.params["parabola_params"]["canny_low"])
        self.canny_low.grid(row=0, column=1, sticky="ew")

        tk.Label(parabola_frame, text="Canny High:").grid(row=1, column=0, sticky="w")
        self.canny_high = tk.Scale(
            parabola_frame, from_=50, to=200, orient=tk.HORIZONTAL
        )
        self.canny_high.set(self.params["parabola_params"]["canny_high"])
        self.canny_high.grid(row=1, column=1, sticky="ew")

        # The baseline and parabola preview toggles let the user inspect the
        # effect of settings before processing the full video.
        self.baseline_var = tk.BooleanVar()
        tk.Checkbutton(
            controls_frame, text="Show Baseline", variable=self.baseline_var
        ).grid(row=6, column=0, columnspan=2, sticky="w")

        self.parabola_var = tk.BooleanVar()
        tk.Checkbutton(
            parabola_frame, text="Show Parabola", variable=self.parabola_var
        ).grid(row=2, column=0, columnspan=2, sticky="w")

        tk.Label(controls_frame, text="Baseline:").grid(row=7, column=0, sticky="w")
        self.baseline_slider = tk.Scale(
            controls_frame, from_=0, to=1000, resolution=1, orient=tk.HORIZONTAL
        )
        self.baseline_slider.set(self.crop_rect[3] + 10)
        self.baseline_slider.grid(row=7, column=1, sticky="ew")

        self.sharpen_var = tk.BooleanVar(
            value=self.params["preprocess_params"]["apply_sharpen"]
        )
        tk.Checkbutton(
            controls_frame, text="Apply Sharpening", variable=self.sharpen_var
        ).grid(row=8, column=0, columnspan=2, sticky="w")

    def create_button_frame(self):
        """Create the button frame with save button."""
        btn_frame = tk.Frame(self.main_frame)
        btn_frame.grid(row=2, column=0, columnspan=3, pady=10)
        tk.Button(btn_frame, text="Save and Close", command=self.on_save).pack(
            side=tk.RIGHT, padx=10
        )

    def setup_bindings(self):
        """Set up event bindings for controls."""
        for widget in [
            self.clahe_clip,
            self.tile_x,
            self.tile_y,
            self.blur_kernel,
            self.otsu_scale,
            self.otsu_offset,
            self.canny_low,
            self.canny_high,
            self.baseline_slider,
        ]:
            widget.configure(command=lambda *_: self.update_preview())

        for var in [
            self.baseline_var,
            self.parabola_var,
            self.sharpen_var,
            self.otsu_var,
        ]:
            var.trace_add("write", lambda *_: self.update_preview())

    def update_preview(self, *args):
        """Update the preview image based on current settings."""
        try:
            # Recompute preprocessing from slider values on every interaction,
            # so the preview always represents the saved settings.
            proc = preprocess_image(
                self.original_frame,
                crop_rect=self.crop_rect,
                clahe_clip=self.clahe_clip.get(),
                clahe_tile_grid=(self.tile_x.get(), self.tile_y.get()),
                blur_kernel=(self.blur_kernel.get(), self.blur_kernel.get()),
                apply_sharpen=self.sharpen_var.get(),
                apply_otsu_thresh=self.otsu_var.get(),
                otsu_scale=self.otsu_scale.get(),
                otsu_offset=self.otsu_offset.get(),
            )

            # The preview shows original crop beside processed image; OpenCV is
            # BGR, while PIL/Tkinter expects RGB.
            x1, y1, x2, y2 = self.crop_rect
            orig_crop = self.original_frame[y1:y2, x1:x2]
            orig_crop_rgb = cv2.cvtColor(orig_crop, cv2.COLOR_BGR2RGB)
            display_proc = (
                cv2.cvtColor(proc, cv2.COLOR_GRAY2RGB)
                if proc.ndim == 2
                else proc.copy()
            )

            # Baseline detection uses the fixed slider value when provided, then
            # optionally runs a quick parabola fit for visual confirmation.
            if self.baseline_var.get() or self.parabola_var.get():
                try:
                    baseline_y, baseline_img = detect_base_line(
                        proc,
                        low_threshold=self.canny_low.get(),
                        high_threshold=self.canny_high.get(),
                        fixed_base=self.baseline_slider.get(),
                    )
                    self.params["baseline_y"] = baseline_y
                    if self.baseline_var.get():
                        display_proc = baseline_img

                except Exception as e:
                    print(f"Baseline detection failed: {str(e)}")
                    self.params["baseline_y"] = None

            if self.parabola_var.get() and self.params.get("baseline_y", -1) >= 0:
                # This is a preview-only fit; reference values are None because
                # strain and Poisson ratio are calculated during full processing.
                results = detect_lower_parabola(
                    orig_crop,
                    proc,
                    self.params["baseline_y"],
                    low_threshold=self.canny_low.get(),
                    high_threshold=self.canny_high.get(),
                    initial_volume=None,
                    initial_half_height=None,
                    initial_diameter=None,
                )
                _, _, _, _, _, overlay, _, _ = results
                combo = np.hstack([orig_crop_rgb, display_proc, overlay])
            else:
                combo = np.hstack([orig_crop_rgb, display_proc])

            # Downscale the combined preview so it fits comfortably in the GUI.
            img_pil = Image.fromarray(combo)
            img_pil.thumbnail((1200, 400))
            new_preview = ImageTk.PhotoImage(img_pil)
            self.preview_label.configure(image=new_preview)
            self.preview_label.image = new_preview

        except Exception as e:
            messagebox.showerror("Error", f"Failed to update preview:\n{str(e)}")

    def on_save(self):
        """Save current parameters and close the UI."""
        # Store plain Python values, not Tkinter variable objects, so the same
        # dictionary can be reused during batch frame processing.
        self.params["preprocess_params"] = {
            "clahe_clip": self.clahe_clip.get(),
            "clahe_tile_grid": (self.tile_x.get(), self.tile_y.get()),
            "blur_kernel": (self.blur_kernel.get(), self.blur_kernel.get()),
            "apply_sharpen": self.sharpen_var.get(),
            "apply_otsu_thresh": self.otsu_var.get(),
            "otsu_scale": self.otsu_scale.get(),
            "otsu_offset": self.otsu_offset.get(),
        }
        self.params["parabola_params"] = {
            "canny_low": self.canny_low.get(),
            "canny_high": self.canny_high.get(),
        }
        self.win.quit()

    def on_window_close(self):
        """Handle window close event."""
        messagebox.showwarning("Warning", "Please use the 'Save and Close' button")

    def run(self) -> Dict[str, Any]:
        """Run the UI and return the parameters."""
        self.win.mainloop()
        self.win.destroy()
        return self.params


def process_first_frame(
    frame: np.ndarray, crop_rect: Tuple[int, int, int, int]
) -> Tuple[Dict, float, float, float]:
    """
    Process the first frame to establish baseline parameters.

    Args:
        frame: First video frame
        crop_rect: Cropping coordinates (x1, y1, x2, y2)

    Returns:
        Tuple containing:
        - Processing parameters
        - Initial diameter
        - Initial volume
        - Initial height
    """
    # The first frame defines the crop, baseline, preprocessing settings, and
    # undeformed reference geometry used for all later frame comparisons.
    ui = PreprocessingUI(frame, crop_rect)
    params = ui.run()

    if params is None or params.get("baseline_y") is None:
        print("You must close the UI with 'Save and Close' after choosing a baseline.")
        sys.exit(1)

    fixed_baseline_y = params["baseline_y"]
    print(f"Fixed baseline at y = {fixed_baseline_y}")
    print("All chosen parameters:", params)

    # Recreate the exact processed image that was previewed in the GUI.
    processed_frame = preprocess_image(
        frame, crop_rect=crop_rect, **params["preprocess_params"]
    )

    # Fit the first frame once to establish initial diameter/height/volume.
    x1, y1, x2, y2 = crop_rect
    orig_crop = frame[y1:y2, x1:x2]

    initial_diameter, initial_volume, initial_area, initial_height, _, _, _, _ = (
        detect_lower_parabola(
            orig_bgr=orig_crop,
            proc_img=processed_frame,
            base_y=fixed_baseline_y,
            low_threshold=params["parabola_params"]["canny_low"],
            high_threshold=params["parabola_params"]["canny_high"],
        )
    )

    print("Initial measurements:")
    print(f"- Diameter: {initial_diameter:.1f}")
    print(f"- Height:   {initial_height:.1f}")
    print(f"- Volume:   {initial_volume:.1f}")

    # Give the user a final sanity-check view of the crop and chosen baseline
    # before the long full-video loop begins.
    _, baseline_overlay = detect_base_line(processed_frame, fixed_base=fixed_baseline_y)
    fig, ax = plt.subplots(1, 2, figsize=(8, 4))
    ax[0].imshow(cv2.cvtColor(orig_crop, cv2.COLOR_BGR2RGB))
    ax[0].set_title("Original Crop")
    ax[1].imshow(
        baseline_overlay if baseline_overlay is not None else processed_frame,
        cmap=(None if baseline_overlay is not None else "gray"),
    )
    ax[1].set_title("Processed + Baseline")
    plt.show()

    return params, initial_diameter, initial_volume, initial_height


def process_video_frames(
    video_path: str,
    crop_rect: Tuple[int, int, int, int],
    params: Dict[str, Any],
    initial_diameter: float,
    initial_volume: float,
    initial_height: float,
) -> pd.DataFrame:
    """
    Process all frames in the video and save results.

    Args:
        video_path: Path to video file
        crop_rect: Cropping coordinates (x1, y1, x2, y2)
        params: Processing parameters from first frame
        initial_diameter: Initial diameter measurement
        initial_volume: Initial volume measurement
        initial_height: Initial height measurement

    Returns:
        DataFrame containing all measurements
    """
    # Keep analysis output at the project level, beside raw_videos, so raw input
    # files and generated measurement files stay separated.
    output_dir = ANALYSIS_OUTPUT_DIR
    output_dir.mkdir(parents=True, exist_ok=True)

    # Process frames sequentially. frame_idx is used for output filenames and
    # as the frame number in the measurement spreadsheet.
    video = cv2.VideoCapture(video_path)
    data_list = []
    frame_idx = 0
    x1, y1, x2, y2 = crop_rect

    while frame_idx < 2000:
        ret, frame = video.read()
        if not ret:
            break

        # Apply exactly the same preprocessing settings chosen on the first
        # frame, preserving a consistent measurement pipeline.
        proc = preprocess_image(
            frame, crop_rect=crop_rect, **params["preprocess_params"]
        )

        # Detection uses the processed crop, while overlays are drawn on the
        # original crop so saved images remain interpretable.
        orig_crop = frame[y1:y2, x1:x2]
        results = detect_lower_parabola(
            orig_bgr=orig_crop,
            proc_img=proc,
            base_y=params["baseline_y"],
            low_threshold=params["parabola_params"]["canny_low"],
            high_threshold=params["parabola_params"]["canny_high"],
            initial_volume=initial_volume,
            initial_half_height=initial_height,
            initial_diameter=initial_diameter,
            frame_no=frame_idx,
            output_dir=output_dir,
        )

        # Failed detections are skipped instead of written as misleading zeros.
        if results is None or len(results) != 8 or results[0] == 0.0:
            print(f"Skipping frame {frame_idx} - detection failed", end="\r")
            frame_idx += 1
            continue

        width, volume, area, height, poisson, overlay, strain, coefs = results

        # One overlay image per successful frame makes it possible to audit the
        # fitted contour behind any suspicious spreadsheet value.
        out_path = os.path.join(output_dir, f"frame_{frame_idx:04d}.png")
        cv2.imwrite(out_path, overlay)

        # Keep all scalar measurements in memory, then periodically flush them
        # to Excel so progress is not lost during long videos.
        data_list.append(
            {
                "Frame": frame_idx,
                "Width": width,
                "Height": height,
                "Volume": volume,
                "Area": area,
                "Strain_Height": strain,
                "PoissonRatio": poisson if poisson is not None else np.nan,
                "BaselineY": params["baseline_y"],
                "InitialDiameter": initial_diameter,
                "InitialHeight": initial_height,
                "InitialVolume": initial_volume,
            }
        )

        # Frequent saves make partial results available even if processing is
        # interrupted before the final frame.
        if frame_idx % 10 == 0:
            df = pd.DataFrame(data_list)
            excel_path = output_dir / "parabola_measurements.xlsx"
            df.to_excel(excel_path, index=False)

        frame_idx += 1
        print(f"Processed frame {frame_idx}", end="\r")

    video.release()

    # Write the complete spreadsheet after the last readable frame.
    df = pd.DataFrame(data_list)
    excel_path = output_dir / "parabola_measurements.xlsx"
    df.to_excel(excel_path, index=False)

    print(f"\nDone! {frame_idx} frames processed.")
    print(f"Data saved to {excel_path}")

    return df


def main():
    # 1. Select video file.
    video_path = select_video_file()
    print(f"Processing video: {video_path}")

    # 2. Read the first frame for ROI selection and parameter tuning.
    video = cv2.VideoCapture(video_path)
    ok, frame = video.read()
    if not ok:
        print("Cannot read video file")
        video.release()
        sys.exit()
    video.release()

    # 3. Select ROI for cropping.
    crop_rect = select_crop_area(frame)
    print(f"Crop area selected: {crop_rect}")

    # 4. Process first frame to get parameters and reference geometry.
    params, initial_diameter, initial_volume, initial_height = process_first_frame(
        frame, crop_rect
    )

    # 5. Process all video frames with the fixed settings.
    process_video_frames(
        video_path=video_path,
        crop_rect=crop_rect,
        params=params,
        initial_diameter=initial_diameter,
        initial_volume=initial_volume,
        initial_height=initial_height,
    )


if __name__ == "__main__":
    main()
