diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -2,79 +2,117 @@ # ๐Ÿ” PART 1: Silent Package Install and Library Imports # ================================ -import subprocess, sys +import subprocess, sys # Used for running pip commands and accessing the Python interpreter +# Function to install all required Python packages silently def install_packages_p1(): + # List of packages required for segmentation, image processing, UI, and Supabase DB access packages_p1 = [ - "segmentation_models_pytorch", "opencv-python-headless", "matplotlib", - "numpy", "torch", "torchvision", "albumentations", "gradio", "supabase" + "segmentation_models_pytorch", # Pre-trained segmentation architectures using PyTorch + "opencv-python-headless", # OpenCV without GUI (headless, suitable for servers) + "matplotlib", # For plotting graphs or visualizations (debugging, dev) + "numpy", # Numerical array computations + "torch", # PyTorch for deep learning model operations + "torchvision", # Common datasets/models/transforms for vision + "albumentations", # Fast image augmentations library + "gradio", # Web UI library for model demos/apps + "supabase" # Supabase Python client for auth/database ] - subprocess.run([sys.executable, "-m", "pip", "install", *packages_p1], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + # Run pip install silently (no stdout/stderr) for the given packages + subprocess.run( + [sys.executable, "-m", "pip", "install", *packages_p1], # Construct pip install command + stdout=subprocess.DEVNULL, # Suppress standard output + stderr=subprocess.DEVNULL # Suppress error output + ) +# Call the package installation function install_packages_p1() + +# Print success message after installing packages (visible to the user) print("โœ… Downloaded all necessary libraries successfully (silent mode)") -import os, cv2, numpy as np, torch, urllib.request -import albumentations as A -import segmentation_models_pytorch as smp -from matplotlib import pyplot as plt -from PIL import Image as PILImage_p3 -from albumentations.pytorch import ToTensorV2 -import gradio as gr -from torch import tensor +# ================================ +# ๐Ÿ“ฆ Import Required Python Libraries +# ================================ + +# Standard and third-party libraries used across the app + +import os # File and directory operations +import cv2 # OpenCV for image processing +import numpy as np # Numerical computations and array handling +import torch # PyTorch for ML models +import urllib.request # To download remote resources/files if needed + +import albumentations as A # Image augmentations +from albumentations.pytorch import ToTensorV2 # Converts numpy arrays to PyTorch tensors + +import segmentation_models_pytorch as smp # Deep segmentation architectures +from matplotlib import pyplot as plt # Optional: for visual debugging or plotting + +from PIL import Image as PILImage_p3 # Pillow for image manipulation (renamed for consistency across parts) + +import gradio as gr # UI frontend for serving interactive demos + +from torch import tensor # Explicit import of `tensor` class (optional but handy) # ================================ # ๐Ÿง  PART 2: Download and Load All Segmentation Models # ================================ -# ๐Ÿ‘‡ Define GitHub URLs for models +# ๐Ÿ‘‡ Define GitHub URLs for models (fetched from environment variables) import os model_urls_p2 = { - "toilet_holes": os.getenv("TOILET_HOLES_URL"), - "toilet_rim": os.getenv("TOILET_RIM_URL"), - "coin_5": os.getenv("COIN_URL") + "toilet_holes": os.getenv("TOILET_HOLES_URL"), # URL for the toilet holes segmentation model + "toilet_rim": os.getenv("TOILET_RIM_URL"), # URL for the toilet rim segmentation model + "coin_5": os.getenv("COIN_URL") # URL for the 5-rupee coin segmentation model } -# ๐Ÿ‘‡ Define filenames to save locally +# ๐Ÿ‘‡ Define filenames to save the models locally after download model_paths_p2 = { - "toilet_holes": "toilet_holes_segmentation_model.pth", - "toilet_rim": "toilet_rim_segmentation_model.pth", - "coin_5": "5coin_segmentation_model.pth" + "toilet_holes": "toilet_holes_segmentation_model.pth", # Local filename for toilet holes model + "toilet_rim": "toilet_rim_segmentation_model.pth", # Local filename for toilet rim model + "coin_5": "5coin_segmentation_model.pth" # Local filename for coin model } -# ๐Ÿ‘‡ Download function +# ๐Ÿ‘‡ Function to download model file only if it doesn't already exist locally def download_model_if_needed_p2(url, filename): - if not os.path.exists(filename): - print(f"โฌ‡๏ธ Downloading {filename}...") - urllib.request.urlretrieve(url, filename) - print(f"โœ… Downloaded: {filename}") + if not os.path.exists(filename): # Check if model file is already cached + print(f"โฌ‡๏ธ Downloading {filename}...") # Inform user + urllib.request.urlretrieve(url, filename) # Download the file from the URL + print(f"โœ… Downloaded: {filename}") # Confirm success else: - print(f"๐ŸŸข Found cached model: {filename}") + print(f"๐ŸŸข Found cached model: {filename}") # Use cached file to save bandwidth/time -# ๐Ÿ‘‡ Load all models and return them +# ๐Ÿ‘‡ Function to download (if needed) and load all three segmentation models def load_models_p2(): + # Step 1: Ensure all required models are downloaded for key in model_paths_p2: download_model_if_needed_p2(model_urls_p2[key], model_paths_p2[key]) + # Step 2: Choose device (GPU if available, otherwise CPU) device = 'cuda' if torch.cuda.is_available() else 'cpu' + # Step 3: Load Toilet Holes Model model_holes = smp.Unet("resnet18", encoder_weights="imagenet", in_channels=3, classes=1) - model_holes.load_state_dict(torch.load(model_paths_p2["toilet_holes"], map_location=device)) - model_holes.to(device).eval() - print("โœ… Loaded: Toilet Holes Identification") + model_holes.load_state_dict(torch.load(model_paths_p2["toilet_holes"], map_location=device)) # Load weights + model_holes.to(device).eval() # Move to device and set to evaluation mode + print("โœ… Loaded: Toilet Holes Identification") # Confirm success + # Step 4: Load Toilet Rim Model model_rim = smp.Unet("resnet18", encoder_weights="imagenet", in_channels=3, classes=1) - model_rim.load_state_dict(torch.load(model_paths_p2["toilet_rim"], map_location=device)) - model_rim.to(device).eval() - print("โœ… Loaded: Toilet Rim Identification") + model_rim.load_state_dict(torch.load(model_paths_p2["toilet_rim"], map_location=device)) # Load weights + model_rim.to(device).eval() # Move to device and set to evaluation mode + print("โœ… Loaded: Toilet Rim Identification") # Confirm success + # Step 5: Load 5-Rupee Coin Reference Model model_coinref = smp.Unet("resnet18", encoder_weights="imagenet", in_channels=3, classes=1) - model_coinref.load_state_dict(torch.load(model_paths_p2["coin_5"], map_location=device)) - model_coinref.to(device).eval() - print("โœ… Loaded: 5 Coin Identification") + model_coinref.load_state_dict(torch.load(model_paths_p2["coin_5"], map_location=device)) # Load weights + model_coinref.to(device).eval() # Move to device and set to evaluation mode + print("โœ… Loaded: 5 Coin Identification") # Confirm success + # Step 6: Return all models and device as a dictionary return { "device": device, "model_holes": model_holes, @@ -82,328 +120,416 @@ def load_models_p2(): "model_coinref": model_coinref } -# Load models once when the script starts -print("Loading models...") -global_models_and_device = load_models_p2() -print("Models loaded successfully.") +# ================================ +# ๐Ÿš€ Load models once when the script starts +# ================================ -# Extract individual components for easier access -GLOBAL_DEVICE = global_models_and_device["device"] -GLOBAL_HOLES = global_models_and_device["model_holes"] -GLOBAL_RIM = global_models_and_device["model_rim"] -GLOBAL_COIN = global_models_and_device["model_coinref"] -models_dict = global_models_and_device +print("Loading models...") # Notify start of loading +global_models_and_device = load_models_p2() # Load models and store in global variable +print("Models loaded successfully.") # Notify completion # ================================ -# ๐Ÿ“ธ PART 3: Upload, Rotate & Store (Multi-user Safe) +# ๐Ÿ“ฆ Extract models and device for easy global access # ================================ -from PIL import Image as PILImage -import numpy as np - -def rotate_image_p3(image_p3, angle_p3): - if image_p3 is None: - return None - - if isinstance(image_p3, np.ndarray): - image_p3 = PILImage.fromarray(image_p3) - - elif hasattr(image_p3, 'read'): # file-like object - image_p3 = PILImage.open(image_p3) - - return image_p3.rotate(-angle_p3, expand=True) +GLOBAL_DEVICE = global_models_and_device["device"] # CUDA or CPU +GLOBAL_HOLES = global_models_and_device["model_holes"] # Toilet holes model +GLOBAL_RIM = global_models_and_device["model_rim"] # Toilet rim model +GLOBAL_COIN = global_models_and_device["model_coinref"] # Coin model +models_dict = global_models_and_device # Dictionary holding all components -def submit_and_store_all_images_p3(img1, angle1, img2, angle2, img3, angle3): - image_rotated1 = rotate_image_p3(img1, angle1) - image_rotated2 = rotate_image_p3(img2, angle2) - image_rotated3 = rotate_image_p3(img3, angle3) - return image_rotated1, image_rotated2, image_rotated3, angle1, angle2, angle3 +# ================================ +# ๐Ÿ“ธ PART 3: Start new session for each user +# ================================ -import gradio as gr -import os -import uuid -from PIL import Image -from datetime import datetime -import numpy as np +# โœ… Import required modules +from PIL import Image as PILImage # PIL for image handling (renamed as PILImage to avoid naming conflict) +import numpy as np # For numerical operations (may be used in image manipulation later) +import gradio as gr # Gradio for building the web UI +import os # For file/folder path handling +import uuid # To generate unique session identifiers +from datetime import datetime # (Optional) could be used for timestamped folders or logs -BASE_DIR = "user_uploads" -os.makedirs(BASE_DIR, exist_ok=True) +# โœ… Base directory where all user sessions will be stored +BASE_DIR = "user_uploads" # All user sessions will go under this root folder +os.makedirs(BASE_DIR, exist_ok=True) # Create the base folder if it doesn't already exist +# โœ… Function to initialize a new session folder with a unique ID def init_session(): - session_id = str(uuid.uuid4())[:8] # short unique ID - session_path = os.path.join(BASE_DIR, session_id) - os.makedirs(session_path, exist_ok=True) - return session_id - -# ๐Ÿ’พ Save uploaded images to the session folder -def handle_upload(img1, img2, img3, session_id): - saved_files = [] - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - - if img1 is not None: - if isinstance(img1, np.ndarray): - img1 = PILImage.fromarray(img1) - filename = f"{timestamp}_noseat.png" - file_path = os.path.join(BASE_DIR, session_id, filename) - img1.save(file_path) - saved_files.append(filename) - - if img2 is not None: - if isinstance(img2, np.ndarray): - img2 = PILImage.fromarray(img2) - filename = f"{timestamp}_seat.png" - file_path = os.path.join(BASE_DIR, session_id, filename) - img2.save(file_path) - saved_files.append(filename) - - if img3 is not None: - if isinstance(img3, np.ndarray): - img3 = PILImage.fromarray(img3) - filename = f"{timestamp}_closed.png" - file_path = os.path.join(BASE_DIR, session_id, filename) - img3.save(file_path) - saved_files.append(filename) - - if saved_files: - return f"โœ… Saved: {', '.join(saved_files)}" - else: - return "โš ๏ธ No images were uploaded." + session_id = str(uuid.uuid4())[:8] # Generate a short unique session ID using UUID (8 characters) + session_path = os.path.join(BASE_DIR, session_id) # Path for this session's folder + os.makedirs(session_path, exist_ok=True) # Create a directory for the session + return session_id # Return the session ID (used to reference the folder in other parts) # ================================ # ๐ŸŽฏ PART 4: Segmentation & Overlay (Multi-user Safe) # ================================ -from torchvision import transforms -from PIL import Image as PILImage_p4 -from io import BytesIO +# โœ… Import necessary libraries +from torchvision import transforms # For image preprocessing +from PIL import Image as PILImage_p4 # PIL for handling images (renamed to avoid conflict with other parts) +from io import BytesIO # To store matplotlib plots as in-memory images +# โœ… Define transformation for input images (to Tensor and Normalize) transform_p4 = transforms.Compose([ - transforms.ToTensor(), - transforms.Normalize([0.5]*3, [0.5]*3) + transforms.ToTensor(), # Convert image to tensor format (C x H x W) + transforms.Normalize([0.5]*3, [0.5]*3) # Normalize RGB channels to range [-1, 1] ]) +# โœ… Function to predict mask for a given PIL image using a PyTorch model def predict_mask_p4(model, device, image_pil): - if image_pil is None: raise ValueError("โŒ Image missing!") - image_np = np.array(image_pil) - if len(image_np.shape) != 3 or image_np.shape[2] != 3: - raise ValueError("โŒ Must be RGB image") + model.eval() + if image_pil is None: + raise ValueError("โŒ Image missing!") + # --- Normalize input --- + if isinstance(image_pil, str) and os.path.exists(image_pil): + image_pil = PILImage.open(image_pil) + elif hasattr(image_pil, "read"): # file-like object + image_pil.seek(0) + image_pil = PILImage.open(image_pil) + elif isinstance(image_pil, PILImage.Image): + image_pil = image_pil + elif isinstance(image_pil, np.ndarray): + if image_pil.ndim == 2: # grayscale to RGB + image_pil = np.stack([image_pil]*3, axis=-1) + elif image_pil.ndim == 3 and image_pil.shape[2] == 4: # RGBA + image_pil = image_pil[:, :, :3] + image_pil = PILImage.fromarray(image_pil.astype(np.uint8)) + else: + raise ValueError("โŒ Unsupported image input type.")# Input validation - device2 = 'cuda' if torch.cuda.is_available() else 'cpu' + # --- Ensure RGB --- + if image_pil.mode != 'RGB': + image_pil = image_pil.convert('RGB') + + image_np = np.array(image_pil) # Convert PIL image to NumPy array + + if len(image_np.shape) != 3 or image_np.shape[2] != 3: + raise ValueError("โŒ Must be RGB image") # Ensure it's a 3-channel RGB image + original_size = (image_np.shape[1], image_np.shape[0]) # Store original size for resizing back later + resized = cv2.resize(image_np, (256, 256), interpolation=cv2.INTER_LINEAR) # Resize to model input size - original_size = (image_np.shape[1], image_np.shape[0]) - resized = cv2.resize(image_np, (256, 256)) - tensor = transform_p4(resized).unsqueeze(0).to(device2) - with torch.no_grad(): - out = model(tensor) - pred = torch.sigmoid(out).squeeze().cpu().numpy() - mask = (pred > 0.5).astype(np.uint8) - return cv2.resize(mask, original_size, interpolation=cv2.INTER_NEAREST) + tensor = transform_p4(resized).unsqueeze(0).to(device.value) # Apply transform and add batch dimension + with torch.no_grad(): # Disable gradient computation for inference + out = model(tensor) # Forward pass + pred = torch.sigmoid(out).squeeze().cpu().numpy() # Apply sigmoid + remove batch/channel dims + mask = (pred > 0.5).astype(np.uint8) # Threshold to binary mask + + return cv2.resize(mask, original_size, interpolation=cv2.INTER_NEAREST) # Resize mask back to original size + +# โœ… Function to apply a color overlay to the masked region of the input image def create_overlay_p4(image_pil, mask, color=(255, 0, 0)): - image_np = np.array(image_pil).copy() - overlay = image_np.copy() - overlay[mask == 1] = color - return overlay + image_np = np.array(image_pil).copy() # Convert image to NumPy array + overlay = image_np.copy() # Duplicate for overlay + overlay[mask == 1] = color # Color only where mask is 1 + return overlay # Return overlaid image +# โœ… Function to segment and overlay results on all 3 views: open_noseat, open_seat, closed def segment_and_overlay_all_p4(img1, img2, img3, model_holes_p2, model_rim_p2, model_coinref_p2, device_p2): - # Get models and device from state - model_coin = model_coinref_p2 - model_holes = model_holes_p2 - model_rim = model_rim_p2 - device = device_p2 + # Get models and device from input state + model_coin = model_coinref_p2 # Coin segmentation model + model_holes = model_holes_p2 # Toilet holes segmentation model + model_rim = model_rim_p2 # Toilet rim segmentation model + device = device_p2 # Inference device (CPU or GPU) + # Organize images by view name image_dict = { - 'open_noseat': img1, - 'open_seat': img2, - 'closed': img3 + 'open_noseat': img1, # View 1 + 'open_seat': img2, # View 2 + 'closed': img3 # View 3 } + # Initialize mask containers for each class and view binary_masks = {'ref': {}, 'holes': {}, 'rim': {}} - grid = [] + grid = [] # To hold overlays for all images in grid format + # Loop through all three input views for key in ['open_noseat', 'open_seat', 'closed']: - img = image_dict[key] - mask_ref = predict_mask_p4(model_coin, device, img) - mask_holes = predict_mask_p4(model_holes, device, img) - mask_rim = predict_mask_p4(model_rim, device, img) + img = image_dict[key] # Get image for current view + + # Predict each of the 3 masks using respective models + mask_ref = predict_mask_p4(model_coin, device, img) # Reference object (coin) + mask_holes = predict_mask_p4(model_holes, device, img) # Holes + mask_rim = predict_mask_p4(model_rim, device, img) # Rim + # Store masks in dictionary by type and view binary_masks['ref'][key] = mask_ref binary_masks['holes'][key] = mask_holes binary_masks['rim'][key] = mask_rim - overlay_ref = create_overlay_p4(img, mask_ref, (0, 255, 0)) - overlay_holes = create_overlay_p4(img, mask_holes, (255, 0, 0)) - overlay_rim = create_overlay_p4(img, mask_rim, (0, 0, 255)) + # Create overlays for each mask on top of original image + overlay_ref = create_overlay_p4(img, mask_ref, (0, 255, 0)) # Green for coin + overlay_holes = create_overlay_p4(img, mask_holes, (255, 0, 0)) # Red for holes + overlay_rim = create_overlay_p4(img, mask_rim, (0, 0, 255)) # Blue for rim + # Append original + overlays to grid grid.append([np.array(img), overlay_ref, overlay_holes, overlay_rim]) - # Create overlay image grid - fig, axes = plt.subplots(3, 4, figsize=(18, 12)) - titles = ["Original", "Ref Coin", "Holes", "Rim"] - rows = ["No Seat", "With Seat", "Closed"] + # โœ… Create a 3x4 visualization grid using matplotlib + fig, axes = plt.subplots(3, 4, figsize=(18, 12)) # 3 rows (views) x 4 columns (original + 3 overlays) + titles = ["Original", "Ref Coin", "Holes", "Rim"] # Column titles + rows = ["No Seat", "With Seat", "Closed"] # Row titles - for i in range(3): - for j in range(4): - axes[i][j].imshow(grid[i][j]) - axes[i][j].axis('off') + for i in range(3): # Rows + for j in range(4): # Columns + axes[i][j].imshow(grid[i][j]) # Show image + axes[i][j].axis('off') # Hide axis if i == 0: - axes[i][j].set_title(titles[j]) - axes[i][0].text(-50, 128, rows[i], rotation=90, va='center') + axes[i][j].set_title(titles[j]) # Set column title + axes[i][0].text(-50, 128, rows[i], rotation=90, va='center') # Label row on left side - plt.tight_layout() - buf = BytesIO() - fig.savefig(buf, format='png') - plt.close(fig) - buf.seek(0) - overlay_grid_image = PILImage_p4.open(buf) + plt.tight_layout() # Prevent overlaps + buf = BytesIO() # Create in-memory buffer + fig.savefig(buf, format='png') # Save figure to buffer as PNG + plt.close(fig) # Close the plot to free memory + buf.seek(0) # Move to start of buffer + + overlay_grid_image = PILImage_p4.open(buf) # Open saved plot as PIL image - return overlay_grid_image, binary_masks, image_dict + return overlay_grid_image, binary_masks, image_dict # Return image grid, masks, and original image dict # ================================ # ๐Ÿ’ฐ PART 5: โ‚น5 Coin Detection & px/cm Ratio (Multi-user Safe) # ================================ -from PIL import Image as PILImage_p5 -from io import BytesIO -import matplotlib.pyplot as plt -import numpy as np -import cv2 -import gradio as gr +from PIL import Image as PILImage_p5 # For image handling +from io import BytesIO # For in-memory buffer image storage +import matplotlib.pyplot as plt # For plotting the result overlays +import numpy as np # For numerical operations on images +import cv2 # OpenCV for image processing +import gradio as gr # Gradio for UI elements -reduce_radius_px_p5 = 1 +# Small buffer to reduce fitted ellipse size (to correct overestimation of edge) +reduce_radius_px_p5 = 0 + +# Actual diameter of โ‚น5 coin in centimeters real_diameter_cm_p5 = 2.3 +# Function to detect โ‚น5 coin in each image, estimate its diameter in pixels, +# and compute pixel-per-cm ratio for accurate real-world measurements def detect_and_plot_reference_p5(image_dict, mask_dict): + # Dictionary to store calculated px/cm ratios per image ref_ratios = {} + # Set up a 1-row, 3-column matplotlib plot to show results for each image fig, axes = plt.subplots(1, 3, figsize=(15, 5)) + + # Process all three images: open without seat, open with seat, closed for i, key in enumerate(['open_noseat', 'open_seat', 'closed']): + # Convert PIL image to NumPy array image = np.array(image_dict[key]).copy() + + # Retrieve the binary mask for the โ‚น5 coin mask = mask_dict['ref'][key] + # Convert binary mask to 8-bit format for contour finding mask_u8 = (mask * 255).astype(np.uint8) + + # Find contours in the mask (external only) contours, _ = cv2.findContours(mask_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + # If any contour is detected if contours: + # Choose the largest contour by area (assumed to be the โ‚น5 coin) largest = max(contours, key=cv2.contourArea) + + # Only fit ellipse if the contour has enough points (at least 5) + if len(largest) >= 5: + ellipse = cv2.fitEllipse(largest) + (cx, cy), (major_axis, minor_axis), angle = ellipse + + # Reduce both axes slightly to avoid overestimation + major_axis = max(major_axis - 2 * reduce_radius_px_p5, 1) + minor_axis = max(minor_axis - 2 * reduce_radius_px_p5, 1) + + # Average the two axes to estimate the diameter + avg_diameter_px = (major_axis + minor_axis) / 2 + + # Calculate pixel per centimeter ratio + px_per_cm = avg_diameter_px / real_diameter_cm_p5 + + # Save the computed ratio + ref_ratios[key] = px_per_cm + else: + # If ellipse fitting isn't possible, mark as None + ref_ratios[key] = None + + # Draw a green circle around the coin using min enclosing circle (x, y), radius = cv2.minEnclosingCircle(largest) radius = max(radius - reduce_radius_px_p5, 0) - cv2.circle(image, (int(x) - 2, int(y) - 1), int(radius), (0, 255, 0), 2) - ref_ratios[key] = (2 * radius) / real_diameter_cm_p5 + cv2.circle(image, (int(x) - 1, int(y) - 2), int(radius), (0, 255, 0), 2) else: + # If no contour found, set ratio as None ref_ratios[key] = None + # Show result image in the subplot axes[i].imshow(image) axes[i].axis('off') + + # Set title with calculated px/cm ratio or indicate failure axes[i].set_title(f"{key}\n{ref_ratios[key]:.2f} px/cm" if ref_ratios[key] else f"{key}\nNot Detected") + # Store the matplotlib figure into a PNG image buffer buf = BytesIO() plt.tight_layout() fig.savefig(buf, format='png') - plt.close(fig) + plt.close(fig) # Close plot to free memory buf.seek(0) + + # Convert buffer image to PIL Image for display in Gradio result_img = PILImage_p5.open(buf) - + + # Construct a reference text summary for display in textbox ref_str = ( f"Open (No Seat):\n{ref_ratios['open_noseat']:4.2f}px/cm\n" f"\nOpen (With Seat):\n{ref_ratios['open_seat']:4.2f}px/cm\n" f"\nClosed:\n{ref_ratios['closed']:4.2f}px/cm" ) + # Return: + # - Updated image with overlays, + # - px/cm ratios for each image, + # - Text summary of ratios, + # - Indicator to show text output component return gr.update(value=result_img, visible=True), ref_ratios, gr.update(value=ref_str, visible=True), gr.update(visible=True) # ================================ # ๐Ÿ“ PART 6: Rim Measurement + Visualization (Multi-user Safe) # ================================ -from PIL import Image as PILImage_p6 -from io import BytesIO -import math -import numpy as np -import cv2 -import matplotlib.pyplot as plt +from PIL import Image as PILImage_p6 # For working with final PIL image output +from io import BytesIO # To handle in-memory image buffer +import math # For trigonometric calculations +import numpy as np # For numerical operations and arrays +import cv2 # OpenCV for image processing +import matplotlib.pyplot as plt # For plotting annotated visuals +import gradio as gr # Gradio for UI components +# Function to analyze inner and outer rim from the mask using ellipse and directional probing def analyze_rim_intersections_p6(image_dict, mask_dict, ref_ratios): - image_key = 'open_seat' - mask = mask_dict['rim'][image_key] - mask_u8 = (mask * 255).astype(np.uint8) + image_key = 'open_seat' # Only operate on open seat image + mask = mask_dict['rim'][image_key] # Get rim mask for selected image + mask_u8 = (mask * 255).astype(np.uint8) # Convert binary mask to 8-bit for OpenCV + # Find all contours in the rim mask contours, _ = cv2.findContours(mask_u8, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) if len(contours) < 2: - raise ValueError("โŒ Need both inner and outer contours.") - - outer, inner = sorted(contours, key=cv2.contourArea, reverse=True)[:2] + raise ValueError("โŒ Need both inner and outer contours.") # Need both rim edges - M = cv2.moments(inner) - cx = int(M["m10"] / M["m00"]) - cy = int(M["m01"] / M["m00"]) - center = np.array([cx, cy]) + # Sort by area: outer will be largest, inner next + outer, inner = sorted(contours, key=cv2.contourArea, reverse=True)[:2] - # ๐ŸŸข Use outer ellipse for angle, and ensure major axis is down - if len(outer) < 5: - raise ValueError("โŒ Outer contour too small for ellipse fitting.") - ellipse = cv2.fitEllipse(outer) - (xc, yc), (MA, ma), angle = ellipse # MA = major axis length, ma = minor + # Ensure inner contour has enough points to fit an ellipse + if len(inner) < 5: + raise ValueError("โŒ Inner contour too small for ellipse fitting.") + + ellipse = cv2.fitEllipse(inner) + (xc, yc), (MA, ma), angle = ellipse # Center, axes, and angle from ellipse fit + # Swap angle to be vertical major axis if needed if MA < ma: - angle += 90 # major axis must be the longer one, rotate if not + angle += 90 + # Convert angle to radians and compute unit direction vector pointing 'down' angle_rad = np.deg2rad(angle) dir_down = np.array([math.cos(angle_rad), math.sin(angle_rad)]) - if dir_down[1] < 0: - dir_down *= -1 # ensure it points downward + if dir_down[1] < 0: # Ensure it points downward in image + dir_down *= -1 + # Get orthogonal 'right' direction by rotating 90 degrees clockwise dir_right = np.array([-dir_down[1], dir_down[0]]) - if dir_right[0] < 0: + if dir_right[0] < 0: # Ensure it points rightward dir_right *= -1 - def get_intersections(mask, center, direction, max_steps=1000): + # Compute bounding points and center from inner contour + topmost = tuple(inner[inner[:, :, 1].argmin()][0]) + bottommost = tuple(inner[inner[:, :, 1].argmax()][0]) + center_y = (topmost[1] + bottommost[1]) // 2 + + leftmost = tuple(inner[inner[:, :, 0].argmin()][0]) + rightmost = tuple(inner[inner[:, :, 0].argmax()][0]) + center_x = (leftmost[0] + rightmost[0]) // 2 + + center = np.array([center_x, center_y]) # Final center point of rim + + # Function to find intersection of a ray in a direction with the mask + def get_intersections(mask, center, direction, max_steps=7000): prev = mask[int(center[1]), int(center[0])] outer_pt, inner_pt = None, None + last_valid = None + for step in range(1, max_steps): pt = center + step * direction x, y = int(round(pt[0])), int(round(pt[1])) if not (0 <= x < mask.shape[1] and 0 <= y < mask.shape[0]): - break + break # Stop if point goes out of bounds val = mask[y, x] + last_valid = np.array([x, y]) + + # Detect transition from background to mask (outer edge) if outer_pt is None and val == 1 and prev == 0: outer_pt = np.array([x, y]) + # Detect transition from mask to background (inner edge) elif outer_pt is not None and val == 0 and prev == 1: inner_pt = np.array([x, y]) break prev = val - return inner_pt, outer_pt + if inner_pt is None: # If no edge found, use last seen point + inner_pt = last_valid + return outer_pt, inner_pt + + # Probe down and right directions from center to find outer and inner rim points inner_down, outer_down = get_intersections(mask, center, dir_down) inner_right, outer_right = get_intersections(mask, center, dir_right) + # Helper function to calculate Euclidean distance def dist(a, b): return np.linalg.norm(a - b) if a is not None and b is not None else None - d_down_inner_px = dist(center, inner_down) + # Compute distances from center to points (in pixels) d_down_outer_px = dist(center, outer_down) + d_down_inner_px = dist(center, inner_down) d_right_inner_px = dist(center, inner_right) d_right_outer_px = dist(center, outer_right) + # Rim width = outer - inner in both directions + rim_width_down = d_down_outer_px - d_down_inner_px + rim_width_right = d_right_outer_px - d_right_inner_px + + # Get pixel/cm ratio for conversion px_per_cm = ref_ratios[image_key] + + # Conversion lambdas from pixels to cm/inch to_cm = lambda px: px / px_per_cm if px is not None else None to_in = lambda px: px / px_per_cm / 2.54 if px is not None else None + + # Formatter for readable output fmt = lambda val: f"{val:.2f}" if val else "N/A" - # Measurements + # Dictionary of dimensions in cm rim_cm = { "down_inner": to_cm(d_down_inner_px), "down_outer": to_cm(d_down_outer_px), "right_inner": to_cm(d_right_inner_px), "right_outer": to_cm(d_right_outer_px), + "width_down": to_cm(rim_width_down), + "width_right": to_cm(rim_width_right) } + + # Dictionary of dimensions in inches rim_inch = { "down_inner": to_in(d_down_inner_px), "down_outer": to_in(d_down_outer_px), "right_inner": to_in(d_right_inner_px), "right_outer": to_in(d_right_outer_px), + "width_down": to_in(rim_width_down), + "width_right": to_in(rim_width_right) } + + # Text labels for Gradio UI rim_ui = { "Down Inner": f"{fmt(rim_cm['down_inner'])} cm | {fmt(rim_inch['down_inner'])} in", "Down Outer": f"{fmt(rim_cm['down_outer'])} cm | {fmt(rim_inch['down_outer'])} in", @@ -411,13 +537,16 @@ def analyze_rim_intersections_p6(image_dict, mask_dict, ref_ratios): "Right Outer": f"{fmt(rim_cm['right_outer'])} cm | {fmt(rim_inch['right_outer'])} in", } - # Visualization + # Load the image to draw results on pil_img = image_dict[image_key] if pil_img is None: raise ValueError("โŒ No image found for analysis!") image_vis = np.array(pil_img) + # Draw center point cv2.circle(image_vis, center, 4, (255, 255, 0), 5) + + # Draw measurement lines and dots for pt, color in zip( [outer_down, inner_down, outer_right, inner_right], [(255, 0, 0), (0, 255, 0), (255, 0, 255), (255, 255, 0)] @@ -425,20 +554,20 @@ def analyze_rim_intersections_p6(image_dict, mask_dict, ref_ratios): if pt is not None: cv2.line(image_vis, center, pt, color, 2) cv2.circle(image_vis, pt, 4, color, 5) + + # Draw directional arrows for orientation cv2.arrowedLine(image_vis, center, (center + dir_down * 100).astype(int), (0, 255, 0), 2) cv2.arrowedLine(image_vis, center, (center + dir_right * 100).astype(int), (0, 0, 255), 2) - # Labels with matplotlib + # Helper to draw label near point def draw_label(pt, label, offset): if pt is not None: pos = pt + offset - plt.text(pos[0], pos[1], label, fontsize=9, color='white', - ha='center', va='center', - bbox=dict(facecolor='black', alpha=0.6, boxstyle='round,pad=0.3')) + plt.text(pos[0], pos[1], label, fontsize=9, color='white', ha='center', va='center', bbox=dict(facecolor='black', alpha=0.6, boxstyle='round,pad=0.3')) + # Visualize final annotated image plt.figure(figsize=(6, 6)) plt.imshow(image_vis) - plt.title("Rim Widths from Inner Center") offset = np.array([0, 70]) draw_label(inner_down, rim_ui["Down Inner"], -offset) draw_label(outer_down, rim_ui["Down Outer"], -offset) @@ -446,104 +575,170 @@ def analyze_rim_intersections_p6(image_dict, mask_dict, ref_ratios): draw_label(outer_right, rim_ui["Right Outer"], offset) plt.axis("off") + # Convert plot to PIL image for Gradio buf = BytesIO() plt.savefig(buf, format='png') plt.close() buf.seek(0) vis_img = PILImage_p6.open(buf) - + + # Build textual summary of all measurements seat_str = ( - f"Down Inner:\n{d_down_inner_px:4.1f}px | {rim_cm['down_inner']:4.2f}cm | {rim_inch['down_inner']:4.2f}in\n" - f"\nDown Outer:\n{d_down_outer_px:4.1f}px | {rim_cm['down_outer']:4.2f}cm | {rim_inch['down_outer']:4.2f}in\n" - f"\nRight Inner:\n{d_right_inner_px:4.1f}px | {rim_cm['right_inner']:4.2f}cm | {rim_inch['right_inner']:4.2f}in\n" - f"\nRight Outer:\n{d_right_outer_px:4.1f}px | {rim_cm['right_outer']:4.2f}cm | {rim_inch['right_outer']:4.2f}in\n" + f"Down Inner Diameter:\n{d_down_inner_px*2:4.1f}px | {rim_cm['down_inner']*2:4.2f}cm | {rim_inch['down_inner']*2:4.2f}in\n" + f"\nRight Inner Diameter:\n{d_right_inner_px*2:4.1f}px | {rim_cm['right_inner']*2:4.2f}cm | {rim_inch['right_inner']*2:4.2f}in\n" + f"\nRight Outer Diameter:\n{d_right_outer_px*2:4.1f}px | {rim_cm['right_outer']*2:4.2f}cm | {rim_inch['right_outer']*2:4.2f}in\n" f"\nDown Angle:\n{(angle - 90) % 360:4.1f}ยฐ\n" - f"\nRight Angle:\n{angle:4.1f}ยฐ" + f"\nRight Angle:\n{angle:4.1f}ยฐ\n" + f"\nWidth Down:\n{rim_width_down:4.1f}px | {rim_cm['width_down']:4.2f}cm | {rim_inch['width_down']:4.2f}in\n" + f"\nWidth Right:\n{rim_width_right:4.1f}px | {rim_cm['width_right']:4.2f}cm | {rim_inch['width_right']:4.2f}in" ) + # Return: + # - Annotated image update + # - Rim values for UI + # - Rim values in cm + # - Rim values in inches + # - Full summary text + # - Show result components return gr.update(value=vis_img, visible=True), rim_ui, rim_cm, rim_inch, gr.update(value=seat_str, visible=True), gr.update(visible=True) # ================================ # ๐Ÿ“ PART 7: Ellipse-Based Rim Width Measurement from Image (open_noseat) # ================================ -from PIL import Image as PILImage_p7 -from io import BytesIO -import numpy as np -import matplotlib.pyplot as plt -import cv2 -import math +from PIL import Image as PILImage_p7 # PIL for image manipulation +from io import BytesIO # To handle image bytes buffer +import math # For trigonometric functions +import numpy as np # For array and math operations +import cv2 # OpenCV for contour/ellipse analysis +import matplotlib.pyplot as plt # For image plotting and annotation -def analyze_rim_ellipse_red_p7(image_dict, mask_dict, ref_ratios): - image_key = 'open_noseat' - mask = mask_dict['rim'][image_key] - image = np.array(image_dict[image_key]).copy() - mask_u8 = (mask * 255).astype(np.uint8) +# function to analyze rim angles and calculate dimensions (down and right diameters) +def analyze_rim_intersections_p7(image_dict, mask_dict, ref_ratios): + image_key = 'open_noseat' # Key used to fetch image/mask + mask = mask_dict['rim'][image_key] # Get binary mask for rim + mask_u8 = (mask * 255).astype(np.uint8) # Convert mask to 8-bit image for OpenCV + # Find contours in the mask contours, _ = cv2.findContours(mask_u8, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) + + # Ensure we have both inner and outer contours if len(contours) < 2: - raise ValueError("โŒ Need both inner and outer contours in red-marked image.") + raise ValueError("โŒ Need both inner and outer contours.") + + # Sort contours by area and select the two largest (outer and inner) + outer, inner = sorted(contours, key=cv2.contourArea, reverse=True)[:2] - outer_contour, inner_contour = sorted(contours, key=cv2.contourArea, reverse=True)[:2] - if len(inner_contour) < 5: - raise ValueError("โŒ Need at least 5 points to fit ellipse!") + # Ensure inner contour is large enough for ellipse fitting + if len(inner) < 5: + raise ValueError("โŒ Outer contour too small for ellipse fitting.") + + # Fit an ellipse to the inner contour + ellipse = cv2.fitEllipse(inner) + (xc, yc), (MA, ma), angle = ellipse # center, major axis, minor axis, and rotation angle - ellipse = cv2.fitEllipse(inner_contour) - (center_x, center_y), (major_axis, minor_axis), angle_deg = ellipse - angle_deg += 90 - center = np.array([int(center_x), int(center_y)]) - center_p7 = center + # Ensure angle reflects long axis vertically + if MA < ma: + angle += 90 - # Compute direction vectors - angle_rad = np.deg2rad(angle_deg) + # Convert angle to radians and compute direction vectors + angle_rad = np.deg2rad(angle) dir_down = np.array([math.cos(angle_rad), math.sin(angle_rad)]) - if dir_down[1] < 0: dir_down *= -1 + if dir_down[1] < 0: + dir_down *= -1 # Flip to ensure it points down + + # Right direction is perpendicular to down direction dir_right = np.array([-dir_down[1], dir_down[0]]) - if dir_right[0] < 0: dir_right *= -1 + if dir_right[0] < 0: + dir_right *= -1 # Flip to ensure rightward direction + + # Compute vertical center between top and bottom points + topmost = tuple(inner[inner[:, :, 1].argmin()][0]) + bottommost = tuple(inner[inner[:, :, 1].argmax()][0]) + center_y = (topmost[1] + bottommost[1]) // 2 - def get_intersections(mask, center, direction, max_steps=1000): + # Compute horizontal center between left and right points + leftmost = tuple(inner[inner[:, :, 0].argmin()][0]) + rightmost = tuple(inner[inner[:, :, 0].argmax()][0]) + center_x = (leftmost[0] + rightmost[0]) // 2 + + center = np.array([center_x, center_y]) # Geometric center + + # function to get intersections of the line with the mask to get inner and outer points + def get_intersections(mask, center, direction, max_steps=7000): + prev = mask[int(center[1]), int(center[0])] # Start from center outer_pt, inner_pt = None, None - prev = mask[int(center[1]), int(center[0])] + last_valid = None + for step in range(1, max_steps): - pt = center + step * direction + pt = center + step * direction # Move along the direction x, y = int(round(pt[0])), int(round(pt[1])) + + # Stop if point is outside image bounds if not (0 <= x < mask.shape[1] and 0 <= y < mask.shape[0]): break + val = mask[y, x] + last_valid = np.array([x, y]) + + # Detect outer boundary if outer_pt is None and val == 1 and prev == 0: outer_pt = np.array([x, y]) + # Detect inner boundary after crossing outer elif outer_pt is not None and val == 0 and prev == 1: inner_pt = np.array([x, y]) break prev = val + + # If no inner found, use last valid point + if inner_pt is None: + inner_pt = last_valid return outer_pt, inner_pt + # Get inner/outer points in both down and right directions inner_down, outer_down = get_intersections(mask, center, dir_down) inner_right, outer_right = get_intersections(mask, center, dir_right) + # function to calculate distance between two points def dist(a, b): return np.linalg.norm(a - b) if a is not None and b is not None else None - d_down_inner = dist(center, inner_down) - d_down_outer = dist(center, outer_down) - d_right_inner = dist(center, inner_right) - d_right_outer = dist(center, outer_right) + # Compute distances from center to inner and outer rim boundaries + d_down_outer_px = dist(center, outer_down) + d_down_inner_px = dist(center, inner_down) + d_right_inner_px = dist(center, inner_right) + d_right_outer_px = dist(center, outer_right) + + # Rim thickness = outer - inner distances + rim_width_down = d_down_outer_px - d_down_inner_px + rim_width_right = d_right_outer_px - d_right_inner_px + + # Conversion factors: pixels per cm px_per_cm = ref_ratios[image_key] - to_cm = lambda px: px / px_per_cm if px else None - to_in = lambda px: px / px_per_cm / 2.54 if px else None - fmt = lambda val: f"{val:.2f}" if val else "N/A" + to_cm = lambda px: px / px_per_cm if px is not None else None + to_in = lambda px: px / px_per_cm / 2.54 if px is not None else None + fmt = lambda val: f"{val:.2f}" if val else "N/A" # formatted output + # Measurements in cm rim_cm = { - "down_inner": to_cm(d_down_inner), - "down_outer": to_cm(d_down_outer), - "right_inner": to_cm(d_right_inner), - "right_outer": to_cm(d_right_outer), + "down_inner": to_cm(d_down_inner_px), + "down_outer": to_cm(d_down_outer_px), + "right_inner": to_cm(d_right_inner_px), + "right_outer": to_cm(d_right_outer_px), + "width_down": to_cm(rim_width_down), + "width_right": to_cm(rim_width_right) } + + # Measurements in inches rim_inch = { - "down_inner": to_in(d_down_inner), - "down_outer": to_in(d_down_outer), - "right_inner": to_in(d_right_inner), - "right_outer": to_in(d_right_outer), + "down_inner": to_in(d_down_inner_px), + "down_outer": to_in(d_down_outer_px), + "right_inner": to_in(d_right_inner_px), + "right_outer": to_in(d_right_outer_px), + "width_down": to_in(rim_width_down), + "width_right": to_in(rim_width_right) } + + # String for user interface display rim_ui = { "Down Inner": f"{fmt(rim_cm['down_inner'])} cm | {fmt(rim_inch['down_inner'])} in", "Down Outer": f"{fmt(rim_cm['down_outer'])} cm | {fmt(rim_inch['down_outer'])} in", @@ -551,28 +746,38 @@ def analyze_rim_ellipse_red_p7(image_dict, mask_dict, ref_ratios): "Right Outer": f"{fmt(rim_cm['right_outer'])} cm | {fmt(rim_inch['right_outer'])} in", } - # Visualization - cv2.circle(image, center, 4, (255, 255, 0), 5) + # Get original image + pil_img = image_dict[image_key] + if pil_img is None: + raise ValueError("โŒ No image found for analysis!") + image_vis = np.array(pil_img) + + # Draw central point + cv2.circle(image_vis, center.astype(int), 4, (255, 255, 0), 5) + + # Draw lines and circles for each measurement point for pt, color in zip( [outer_down, inner_down, outer_right, inner_right], [(255, 0, 0), (0, 255, 0), (255, 0, 255), (255, 255, 0)] ): if pt is not None: - cv2.line(image, center, pt, color, 2) - cv2.circle(image, pt, 4, color, 5) - cv2.arrowedLine(image, center, (center + dir_down * 100).astype(int), (0, 255, 0), 2) - cv2.arrowedLine(image, center, (center + dir_right * 100).astype(int), (0, 0, 255), 2) + cv2.line(image_vis, center.astype(int), pt, color, 2) + cv2.circle(image_vis, pt, 4, color, 5) + # Draw direction arrows for reference + cv2.arrowedLine(image_vis, center.astype(int), (center + dir_down * 100).astype(int), (0, 255, 0), 2) + cv2.arrowedLine(image_vis, center.astype(int), (center + dir_right * 100).astype(int), (0, 0, 255), 2) + + # function to draw labels on the image def draw_label(pt, label, offset): if pt is not None: pos = pt + offset - plt.text(pos[0], pos[1], label, fontsize=9, color='white', - ha='center', va='center', - bbox=dict(facecolor='black', alpha=0.6, boxstyle='round,pad=0.3')) + plt.text(pos[0], pos[1], label, fontsize=9, color='white', ha='center', va='center', + bbox=dict(facecolor='black', alpha=0.6, boxstyle='round,pad=0.3')) + # Display image with annotations plt.figure(figsize=(6, 6)) - plt.imshow(image) - plt.title("Rim Intersections from Inner Center (Using Ellipse)") + plt.imshow(image_vis) offset = np.array([0, 70]) draw_label(inner_down, rim_ui["Down Inner"], -offset) draw_label(outer_down, rim_ui["Down Outer"], -offset) @@ -580,22 +785,36 @@ def analyze_rim_ellipse_red_p7(image_dict, mask_dict, ref_ratios): draw_label(outer_right, rim_ui["Right Outer"], offset) plt.axis("off") + # Save visualization to memory buffer buf = BytesIO() plt.savefig(buf, format='png') plt.close() buf.seek(0) - vis_img = PILImage_p7.open(buf) + vis_img = PILImage_p7.open(buf) # Open buffer as image + # Formatted string summary of measurements rim_str = ( - f"Down Inner:\n{d_down_inner:4.1f}px | {rim_cm['down_inner']:4.2f}cm | {rim_inch['down_inner']:4.2f}in\n" - f"\nDown Outer:\n{d_down_outer:4.1f}px | {rim_cm['down_outer']:4.2f}cm | {rim_inch['down_outer']:4.2f}in\n" - f"\nRight Inner:\n{d_right_inner:4.1f}px | {rim_cm['right_inner']:4.2f}cm | {rim_inch['right_inner']:4.2f}in\n" - f"\nRight Outer:\n{d_right_outer:4.1f}px | {rim_cm['right_outer']:4.2f}cm | {rim_inch['right_outer']:4.2f}in\n" - f"\nDown Angle:\n{(angle_deg - 90) % 360:4.1f}ยฐ\n" - f"\nRight Angle:\n{angle_deg:4.1f}ยฐ" + f"Down Inner Diameter:\n{d_down_inner_px*2:4.1f}px | {rim_cm['down_inner']*2:4.2f}cm | {rim_inch['down_inner']*2:4.2f}in\n" + f"\nRight Inner Diameter:\n{d_right_inner_px*2:4.1f}px | {rim_cm['right_inner']*2:4.2f}cm | {rim_inch['right_inner']*2:4.2f}in\n" + f"\nRight Outer Diameter:\n{d_right_outer_px*2:4.1f}px | {rim_cm['right_outer']*2:4.2f}cm | {rim_inch['right_outer']*2:4.2f}in\n" + f"\nDown Angle:\n{(angle - 90) % 360:4.1f}ยฐ\n" + f"\nRight Angle:\n{angle:4.1f}ยฐ\n" + f"\nWidth Down:\n{rim_width_down:4.1f}px | {rim_cm['width_down']:4.2f}cm | {rim_inch['width_down']:4.2f}in\n" + f"\nWidth Right:\n{rim_width_right:4.1f}px | {rim_cm['width_right']:4.2f}cm | {rim_inch['width_right']:4.2f}in\n" ) - return gr.update(value=vis_img, visible=True), rim_ui, rim_cm, rim_inch, center.tolist(), dir_down.tolist(), dir_right.tolist(), gr.update(value=rim_str, visible=True), gr.update(visible=True) + # Return: image update, UI string, numeric outputs, vector info, visibility updates + return ( + gr.update(value=vis_img, visible=True), # Annotated image + rim_ui, # Display text (cm/in) + rim_cm, # Numerical values in cm + rim_inch, # Numerical values in inches + center.tolist(), # Center coordinates as list + dir_down.tolist(), # Down direction vector + dir_right.tolist(), # Right direction vector + gr.update(value=rim_str, visible=True), # Measurement summary text + gr.update(visible=True) # Toggle visibility flag + ) # ================================ # ๐Ÿงฉ PART 8: Rim Height Analyzer (Stateless, Session-Safe) @@ -609,185 +828,240 @@ from PIL import Image as PILImage_p8 from io import BytesIO import matplotlib.pyplot as plt -def find_inner_top_p8(mask, center, direction, max_steps=1000): - prev = mask[int(center[1]), int(center[0])] +# ============================ +# Helper function to find the inner top point of the rim (searching in "down" direction) +# ============================ +def find_inner_top_p8(mask, center, direction, max_steps=7000): + prev = mask[int(center[1]), int(center[0])] # Get initial pixel value at center for step in range(1, max_steps): - pt = center + step * direction + pt = center + step * direction # Step along the direction vector x, y = int(round(pt[0])), int(round(pt[1])) - if not (0 <= x < mask.shape[1] and 0 <= y < mask.shape[0]): + if not (0 <= x < mask.shape[1] and 0 <= y < mask.shape[0]): # If outside bounds, stop break - val = mask[y, x] - if prev == 1 and val == 0: - return np.array([x, y]) + val = mask[y, x] # Get pixel value at new location + if prev == 1 and val == 0: # Detect the transition from rim (1) to background (0) + return np.array([x, y]) # Return the topmost point of inner rim prev = val - return None + return None # Return None if not found -def find_outer_bottom_p8(mask, center, direction, max_steps=1000): - prev = mask[int(center[1]), int(center[0])] +# ============================ +# Helper function to find the outer bottom point of the rim (searching in "up" direction) +# ============================ +def find_outer_bottom_p8(mask, center, direction, max_steps=7000): + prev = mask[int(center[1]), int(center[0])] # Get initial pixel value for step in range(1, max_steps): - pt = center - step * direction + pt = center - step * direction # Step in opposite direction x, y = int(round(pt[0])), int(round(pt[1])) - if not (0 <= x < mask.shape[1] and 0 <= y < mask.shape[0]): + if not (0 <= x < mask.shape[1] and 0 <= y < mask.shape[0]): # Out of bounds check break - val = mask[y, x] - if prev == 0 and val == 1: - return np.array([x, y]) + val = mask[y, x] # Pixel value at new point + if prev == 0 and val == 1: # Transition from background (0) to rim (1) + return np.array([x, y]) # Return bottommost outer rim point prev = val - return None + return None # Return None if not found +# ============================ +# Main analysis function to compute rim height and visualize it +# ============================ def run_rim_height_analysis_p8( - _trigger_button, - image_dict_p4, - binary_masks_p4, - ref_ratios_p5, - center_p7, - dir_down_p7 + _trigger_button, # Dummy input for Gradio button triggering + image_dict_p4, # Dictionary of input images (session state) + binary_masks_p4, # Dictionary of binary masks (rim) + ref_ratios_p5, # Reference pixel/cm conversion ratio + center_p7, # Center point for analysis + dir_down_p7 # Downward vector for analysis ): + # Load image and relevant mask image = np.array(image_dict_p4["open_noseat"]).copy() mask = binary_masks_p4["rim"]["open_noseat"] px_per_cm = ref_ratios_p5["open_noseat"] + + # Convert center and direction to numpy arrays center_pt = np.array(center_p7) dir_vec = np.array(dir_down_p7) + # Compute angles for display raw_angle = np.rad2deg(math.atan2(dir_vec[1], dir_vec[0])) down_angle_deg = (450 - raw_angle) % 360 perp_angle_deg = (down_angle_deg + 90) % 360 + # Get the rim top and bottom using mask edge transitions inner_top = find_outer_bottom_p8(mask, center_pt, dir_vec) outer_bottom = find_inner_top_p8(mask, center_pt, dir_vec) + # If any point is missing, abort and inform the user if inner_top is None or outer_bottom is None: - return "โš ๏ธ Could not find both rim points", None, None, None, None, None + return "โš ๏ธ Could not find both rim points", None, None, None, None, None, None, None + # Calculate pixel distance between points (rim height) rim_height_px = np.linalg.norm(outer_bottom - inner_top) rim_height_cm = rim_height_px / px_per_cm rim_height_in = rim_height_cm / 2.54 - # --- Visualization --- - cv2.circle(image, tuple(inner_top), 5, (0, 255, 255), -1) - cv2.circle(image, tuple(outer_bottom), 5, (255, 255, 0), -1) - cv2.line(image, tuple(inner_top), tuple(outer_bottom), (0, 0, 255), 2) + # ------------------------------------ + # Visualization of points and line + # ------------------------------------ + # Mark points + cv2.circle(image, tuple(inner_top), 5, (0, 255, 255), -1) # Yellow inner top + cv2.circle(image, tuple(outer_bottom), 5, (255, 255, 0), -1) # Cyan outer bottom + # Draw connecting line + cv2.line(image, tuple(inner_top), tuple(outer_bottom), (0, 0, 255), 2) # Red line + # Draw direction arrow for debugging arrow_end = (center_pt + dir_vec * 100).astype(int) - cv2.arrowedLine(image, center_pt.astype(int), arrow_end, (0, 255, 0), 2) + cv2.arrowedLine(image, center_pt.astype(int), arrow_end, (0, 255, 0), 2) # Green arrow + # Add text overlay with rim height label = f"{rim_height_px:.1f}px | {rim_height_cm:.2f}cm | {rim_height_in:.2f}in" mid = ((inner_top + outer_bottom) / 2).astype(int) text_pos = (mid[0] + 10, mid[1] - 10) + + # Draw background rectangle behind text for readability overlay = image.copy() font = cv2.FONT_HERSHEY_SIMPLEX (tw, th), _ = cv2.getTextSize(label, font, 1, 2) rect_start = (text_pos[0] - 10, text_pos[1] - th - 10) rect_end = (text_pos[0] + tw + 10, text_pos[1] + 10) - cv2.rectangle(overlay, rect_start, rect_end, (0, 0, 0), -1) - cv2.addWeighted(overlay, 0.5, image, 0.5, 0, image) + cv2.rectangle(overlay, rect_start, rect_end, (0, 0, 0), -1) # Black background + cv2.addWeighted(overlay, 0.5, image, 0.5, 0, image) # Blend it with image + + # Final text overlay cv2.putText(image, label, text_pos, font, 1, (255, 255, 255), 2, cv2.LINE_AA) + # Convert final OpenCV image to PIL for Gradio buf = BytesIO() - plt.imsave(buf, image) + plt.imsave(buf, image) # Save with matplotlib to buffer buf.seek(0) rim_result_image_p8 = PILImage_p8.open(buf) - + + # Format rim height string for display in output textbox rim_str = ( f"Rim Height:\n{rim_height_px:4.1f}px | {rim_height_cm:4.2f}cm | {rim_height_in:4.2f}in\n\nDown Angle:\n{down_angle_deg:4.1f}ยฐ" - ) + ) - return label, gr.update(value=rim_result_image_p8, visible=True), rim_height_px, rim_height_cm, rim_height_in, inner_top.tolist(), gr.update(value=rim_str, visible=True), gr.update(visible=True) + # Return outputs for Gradio UI: label string, annotated image, raw values, and updated States + return ( + label, # label string + gr.update(value=rim_result_image_p8, visible=True), # image with annotations + rim_height_px, # rim height in pixels + rim_height_cm, # rim height in centimeters + rim_height_in, # rim height in inches + inner_top.tolist(), # top point of rim (to pass to later stages) + gr.update(value=rim_str, visible=True), # rim result text box + gr.update(visible=True) # signal visibility of any dependent outputs + ) # ============================ # ๐Ÿ•ณ๏ธ PART 9: Hole Width Measurement (Stateless) # ============================ -from PIL import Image as PILImage_p9 -from io import BytesIO +from PIL import Image as PILImage_p9 # Import PIL for image handling +from io import BytesIO # For in-memory image saving/loading +# Function to compute the perpendicular width of a hole (e.g. toilet bowl opening) def analyze_hole_width_perpendicular_p9( - _trigger, - binary_masks_p4, - ref_ratios_p5, - image_dict_p4, - dir_right_p7, - center_p7 + _trigger, # Dummy trigger input to enable button-based execution + binary_masks_p4, # Dictionary containing binary segmentation masks + ref_ratios_p5, # Reference pixel-per-cm conversion ratios + image_dict_p4, # Dictionary of input images by category + dir_right_p7, # Direction vector (e.g. right axis from ellipse) + center_p7 # Ellipse center point (not used here, but may be useful contextually) ): # --- Input setup --- - mask = binary_masks_p4["holes"]["open_noseat"] - mask_u8 = (mask * 255).astype(np.uint8) - ys, xs = np.where(mask == 1) - points = np.stack([xs, ys], axis=1).astype(float) + mask = binary_masks_p4["holes"]["open_noseat"] # Get binary mask for the hole (open_noseat type) + mask_u8 = (mask * 255).astype(np.uint8) # Convert binary mask to uint8 for image ops + ys, xs = np.where(mask == 1) # Get coordinates of all white pixels (non-zero) + points = np.stack([xs, ys], axis=1).astype(float) # Stack as Nx2 float array of points - dir_scan = np.array(dir_right_p7) - dir_scan = dir_scan / np.linalg.norm(dir_scan) - dir_perp = np.array([-dir_scan[1], dir_scan[0]]) + # --- Compute perpendicular direction --- + dir_scan = np.array(dir_right_p7) # Use provided right direction vector + dir_scan = dir_scan / np.linalg.norm(dir_scan) # Normalize to unit vector + dir_perp = np.array([-dir_scan[1], dir_scan[0]]) # Compute perpendicular direction # --- Project points on perpendicular axis --- - projs = points @ dir_perp - proj_min, proj_max = np.min(projs), np.max(projs) + projs = points @ dir_perp # Project each point on perpendicular axis + proj_min, proj_max = np.min(projs), np.max(projs) # Get min and max projection values + # --- Find widest span along perpendicular lines --- best_dist = -1 pt_min_p9 = None pt_max_p9 = None - for offset in np.arange(proj_min, proj_max, 1.0): - mask_line = np.abs(projs - offset) < 0.5 - line_points = points[mask_line] - if len(line_points) >= 2: - line_proj = line_points @ dir_scan - i_min = np.argmin(line_proj) - i_max = np.argmax(line_proj) - d = np.linalg.norm(line_points[i_max] - line_points[i_min]) - if d > best_dist: + for offset in np.arange(proj_min, proj_max, 1.0): # Slide line across projection axis + mask_line = np.abs(projs - offset) < 0.5 # Select nearby points close to this offset + line_points = points[mask_line] # Get actual coordinates for those points + if len(line_points) >= 2: # Only consider if line has โ‰ฅ 2 points + line_proj = line_points @ dir_scan # Project onto scanning direction + i_min = np.argmin(line_proj) # Get point with min projection + i_max = np.argmax(line_proj) # Get point with max projection + d = np.linalg.norm(line_points[i_max] - line_points[i_min]) # Compute distance + if d > best_dist: # If it's the longest so far, save it best_dist = d pt_min_p9 = line_points[i_min] pt_max_p9 = line_points[i_max] + # --- Handle edge case where no line pair found --- if pt_min_p9 is None or pt_max_p9 is None: return None, None, None, None, None, None, None # --- Compute pixel width --- - hole_width_px_p9 = best_dist + hole_width_px_p9 = best_dist # Best span found is hole width in pixels - # --- Orientation angle --- + # --- Compute orientation angle of detected line --- dir_line = pt_max_p9 - pt_min_p9 - dir_line = dir_line / np.linalg.norm(dir_line) - angle_rad = np.arctan2(dir_line[1], dir_line[0]) - angle_deg_p9 = (450 - np.rad2deg(angle_rad)) % 360 - - # --- Convert to real-world units --- - px_per_cm = ref_ratios_p5["open_noseat"] - cm_per_px = 1.0 / px_per_cm - hole_width_cm_p9 = hole_width_px_p9 * cm_per_px - hole_width_inch_p9 = hole_width_cm_p9 / 2.54 - - # --- Visualization --- - image = np.array(image_dict_p4["open_noseat"]).copy() - cv2.circle(image, pt_min_p9.astype(int), 5, (0, 255, 0), -1) - cv2.circle(image, pt_max_p9.astype(int), 5, (0, 0, 255), -1) - cv2.line(image, pt_min_p9.astype(int), pt_max_p9.astype(int), (255, 255, 0), 2) - + dir_line = dir_line / np.linalg.norm(dir_line) # Normalize + angle_rad = np.arctan2(dir_line[1], dir_line[0]) # Angle in radians + angle_deg_p9 = (450 - np.rad2deg(angle_rad)) % 360 # Convert to degrees (clockwise from up) + + # --- Convert width from pixels to cm/inch --- + px_per_cm = ref_ratios_p5["open_noseat"] # Get px/cm ratio + cm_per_px = 1.0 / px_per_cm # Inverse gives cm/px + hole_width_cm_p9 = hole_width_px_p9 * cm_per_px # Convert to cm + hole_width_inch_p9 = hole_width_cm_p9 / 2.54 # Convert to inches + + # --- Visualization on image --- + image = np.array(image_dict_p4["open_noseat"]).copy() # Copy image for annotation + cv2.circle(image, pt_min_p9.astype(int), 5, (0, 255, 0), -1) # Draw green circle at pt1 + cv2.circle(image, pt_max_p9.astype(int), 5, (0, 0, 255), -1) # Draw red circle at pt2 + cv2.line(image, pt_min_p9.astype(int), pt_max_p9.astype(int), (255, 255, 0), 2) # Yellow line + + # --- Prepare label text --- label = f"{hole_width_px_p9:.1f}px | {hole_width_cm_p9:.2f}cm | {hole_width_inch_p9:.2f}in" - mid = ((pt_min_p9 + pt_max_p9) / 2).astype(int) - text_pos = (mid[0] - 30, mid[1] - 30) + mid = ((pt_min_p9 + pt_max_p9) / 2).astype(int) # Midpoint between two points + text_pos = (mid[0] - 30, mid[1] - 30) # Text offset for visibility - overlay = image.copy() + overlay = image.copy() # For semi-transparent text box font = cv2.FONT_HERSHEY_SIMPLEX - (tw, th), _ = cv2.getTextSize(label, font, 1, 2) - rect_start = (text_pos[0] - 10, text_pos[1] - th - 10) - rect_end = (text_pos[0] + tw + 10, text_pos[1] + 10) - cv2.rectangle(overlay, rect_start, rect_end, (0, 0, 0), -1) - cv2.addWeighted(overlay, 0.7, image, 0.3, 0, image) - cv2.putText(image, label, text_pos, font, 1, (255, 255, 255), 2) - - # --- Return PIL image --- + (tw, th), _ = cv2.getTextSize(label, font, 1, 2) # Get text width/height + rect_start = (text_pos[0] - 10, text_pos[1] - th - 10) # Top-left of background rectangle + rect_end = (text_pos[0] + tw + 10, text_pos[1] + 10) # Bottom-right of rectangle + cv2.rectangle(overlay, rect_start, rect_end, (0, 0, 0), -1) # Draw black box behind text + cv2.addWeighted(overlay, 0.7, image, 0.3, 0, image) # Blend with original + cv2.putText(image, label, text_pos, font, 1, (255, 255, 255), 2) # Final text on image + + # --- Save result image to in-memory buffer --- buf = BytesIO() - plt.imsave(buf, image) + plt.imsave(buf, image) # Save annotated image to buffer buf.seek(0) - hole_result_image_p9 = PILImage_p9.open(buf) + hole_result_image_p9 = PILImage_p9.open(buf) # Load back as PIL image + # --- Prepare measurement string for display --- hole_width_str = ( f"Hole Width:\n{hole_width_px_p9:4.1f}px | {hole_width_cm_p9:4.2f}cm | {hole_width_inch_p9:4.2f}in\n" f"\nOrientation Angle:\n{angle_deg_p9:4.1f}ยฐ" ) - return gr.update(value=hole_result_image_p9, visible=True), hole_width_px_p9, hole_width_cm_p9, hole_width_inch_p9, angle_deg_p9, pt_min_p9.tolist(), pt_max_p9.tolist(), gr.update(value=hole_width_str, visible=True), gr.update(visible=True) + # --- Return multiple outputs for Gradio UI --- + return ( + gr.update(value=hole_result_image_p9, visible=True), # Annotated image output + hole_width_px_p9, # Raw width in pixels + hole_width_cm_p9, # Width in cm + hole_width_inch_p9, # Width in inches + angle_deg_p9, # Orientation angle + pt_min_p9.tolist(), # Point 1 coords + pt_max_p9.tolist(), # Point 2 coords + gr.update(value=hole_width_str, visible=True), # Text summary + gr.update(visible=True) # Trigger visibility for some UI element + ) # ============================= # ๐Ÿงฉ PART 10: Rim-to-Hole Line Distance (Stateless) @@ -797,87 +1071,102 @@ from PIL import Image as PILImage_p10 from io import BytesIO def compute_top_to_hole_distance_p10( - _trigger, - inner_top_p8, - pt_min_p9, - pt_max_p9, - dir_down_p7, - ref_ratios_p5, - image_dict_p4 + _trigger, # dummy trigger to force execution in Gradio pipeline + inner_top_p8, # topmost point on the inner rim (from Part 8) + pt_min_p9, # one end of the horizontal hole line (from Part 9) + pt_max_p9, # other end of the horizontal hole line (from Part 9) + dir_down_p7, # direction vector pointing downward from rim (from Part 7) + ref_ratios_p5, # px/cm ratios per view (from Part 5) + image_dict_p4 # original rotated images (from Part 4) ): + # Helper function to find intersection point between two parametric lines def line_intersection_p10(p1, d1, p2, d2): - A = np.array([d1, -d2]).T - b = p2 - p1 + A = np.array([d1, -d2]).T # construct matrix from direction vectors + b = p2 - p1 # vector between starting points if np.linalg.matrix_rank(A) < 2: - return None - t_s = np.linalg.lstsq(A, b, rcond=None)[0] - return p1 + t_s[0] * d1 + return None # lines are parallel; no intersection + t_s = np.linalg.lstsq(A, b, rcond=None)[0] # solve A*[t, s] = b + return p1 + t_s[0] * d1 # return intersection point along line 1 + # Convert input points and vectors to numpy arrays for math operations pt_min = np.array(pt_min_p9) pt_max = np.array(pt_max_p9) inner_top = np.array(inner_top_p8) dir_down = np.array(dir_down_p7) + # Compute the direction vector of the hole line (horizontal across hole) dir_hole_line = pt_max - pt_min - dir_hole_line = dir_hole_line / np.linalg.norm(dir_hole_line) + dir_hole_line = dir_hole_line / np.linalg.norm(dir_hole_line) # normalize + + # Compute intersection point between inner rim line and hole line intersection_point = line_intersection_p10(inner_top, dir_down, pt_min, dir_hole_line) if intersection_point is None: - print("โŒ Lines are parallel.") + print("โŒ Lines are parallel.") # alert if intersection fails return None, None, None, None, None, None, None - # --- Distance --- - dist_px = np.linalg.norm(intersection_point - inner_top) - px_per_cm = ref_ratios_p5["open_noseat"] - cm_per_px = 1.0 / px_per_cm - dist_cm = dist_px * cm_per_px - dist_inch = dist_cm / 2.54 + # --- Distance Calculation --- + dist_px = np.linalg.norm(intersection_point - inner_top) # pixel distance + px_per_cm = ref_ratios_p5["open_noseat"] # px/cm for current view + dist_cm = dist_px / px_per_cm # convert to cm + dist_inch = dist_cm / 2.54 # convert to inches - # --- Angles --- + # --- Angle Calculations --- + # Convert angle of rim direction to degrees (clockwise from top) angle_down_deg = (450 - np.rad2deg(np.arctan2(dir_down[1], dir_down[0]))) % 360 + # Convert angle of hole line direction to degrees vec = dir_hole_line angle_perp_deg = (450 - np.rad2deg(np.arctan2(vec[1], vec[0]))) % 360 # --- Visualization --- - image = np.array(image_dict_p4["open_noseat"]).copy() - cv2.circle(image, inner_top.astype(int), 4, (0, 255, 0), 5) - cv2.circle(image, intersection_point.astype(int), 4, (0, 0, 255), 5) - cv2.line(image, inner_top.astype(int), intersection_point.astype(int), (255, 255, 0), 2) - cv2.line(image, pt_min.astype(int), pt_max.astype(int), (255, 0, 255), 1) + image = np.array(image_dict_p4["open_noseat"]).copy() # load view image + # Draw points and lines + cv2.circle(image, inner_top.astype(int), 4, (0, 255, 0), 5) # green: inner top point + cv2.circle(image, intersection_point.astype(int), 4, (0, 0, 255), 5) # red: intersection point + cv2.line(image, inner_top.astype(int), intersection_point.astype(int), (255, 255, 0), 2) # yellow: vertical + cv2.line(image, pt_min.astype(int), pt_max.astype(int), (255, 0, 255), 1) # magenta: hole width line + + # Add measurement text label = f"{dist_px:.1f}px | {dist_cm:.2f}cm | {dist_inch:.2f}in" mid = ((inner_top + intersection_point) / 2).astype(int) text_pos = (mid[0] + 10, mid[1] - 10) + # Draw black background box behind text for readability overlay = image.copy() (tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 1, 2) rect_start = (text_pos[0] - 10, text_pos[1] - th - 10) rect_end = (text_pos[0] + tw + 10, text_pos[1] + 10) cv2.rectangle(overlay, rect_start, rect_end, (0, 0, 0), -1) cv2.addWeighted(overlay, 0.7, image, 0.3, 0, image) + + # Add label text in white cv2.putText(image, label, text_pos, cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2) + # Save resulting image to a PIL object buf = BytesIO() plt.imsave(buf, image) buf.seek(0) rim_result_image_p10 = PILImage_p10.open(buf) - + + # Text summary output rim_to_hole_str = ( f"Inner Rim to Hole Distance:\n{dist_px:4.1f}px | {dist_cm:4.2f}cm | {dist_inch:4.2f}in\n" f"\nRim Direction Angle (Down):\n{angle_down_deg:4.1f}ยฐ\n" f"\nHole Width Angle:\n{angle_perp_deg:4.1f}ยฐ" ) + # Return everything needed for Gradio UI return ( - gr.update(value=rim_result_image_p10, visible=True), - dist_px, - dist_cm, - dist_inch, - angle_down_deg, - angle_perp_deg, - intersection_point.tolist(), - gr.update(value=rim_to_hole_str, visible=True), - gr.update(visible=True) + gr.update(value=rim_result_image_p10, visible=True), # image with overlays + dist_px, # raw distance in pixels + dist_cm, # distance in cm + dist_inch, # distance in inches + angle_down_deg, # angle of downward rim direction + angle_perp_deg, # angle of hole width line + intersection_point.tolist(), # intersection point as list + gr.update(value=rim_to_hole_str, visible=True), # summary string + gr.update(visible=True) # show result box ) # ================================ @@ -888,84 +1177,115 @@ from PIL import Image as PILImage_p10 from io import BytesIO def analyze_closed_rim_orientation_p10(_trigger, binary_masks_p4, image_dict_p4): + # --------------------------------------------------------------- + # Subfunction to remove top portion of the mask based on angle + # --------------------------------------------------------------- def remove_top_based_on_angle(mask, center, angle_deg, threshold): - mask = (mask > 0).astype(np.uint8) + mask = (mask > 0).astype(np.uint8) # Ensure binary format h, w = mask.shape cx, cy = center angle_rad = np.deg2rad(angle_deg) + # Direction vector along the angle dx = math.cos(angle_rad) dy = math.sin(angle_rad) + + # Perpendicular direction (used for computing distances from axis) perp_dx = -dy perp_dy = dx + # Iterate over all rows in the mask for y in range(h): - x_coords = np.where(mask[y] == 1)[0] + x_coords = np.where(mask[y] == 1)[0] # Get all foreground pixels in row if len(x_coords) == 0: - continue + continue # Skip if row is empty distances = [] for x in x_coords: px, py = x, y dxp = px - cx dyp = py - cy + # Distance of (x,y) from ellipse axis using dot product with perpendicular vector dist = abs(dxp * perp_dx + dyp * perp_dy) distances.append(dist) if max(distances) < threshold: + # If max distance is small, likely part of the top region โ†’ remove mask[y, x_coords] = 0 else: + # Stop removing when actual rim area is reached break - return mask * 255 + return mask * 255 # Return as 255-mask - # --- Cleaning --- - mask = binary_masks_p4["rim"]["closed"] - center_estimate = (150, 220) + # ---------------------- + # Step 1: Clean the mask + # ---------------------- + mask = binary_masks_p4["rim"]["closed"] # Get closed image rim mask + center_estimate = (150, 220) # Approx center for removal reference clean_mask = remove_top_based_on_angle(mask, center_estimate, 23, 30) - # Update original mask + # Update the rim mask after cleaning binary_masks_p4["rim"]["closed"] = clean_mask - # --- Ellipse Fitting --- - mask_u8 = (clean_mask * 255).astype(np.uint8) + # -------------------------- + # Step 2: Fit ellipse on mask + # -------------------------- + mask_u8 = (clean_mask * 255).astype(np.uint8) # Convert to 8-bit image contours, _ = cv2.findContours(mask_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) assert contours, "โŒ No contours found in closed rim mask!" - rim_contour = max(contours, key=cv2.contourArea) + + rim_contour = max(contours, key=cv2.contourArea) # Take largest contour assert len(rim_contour) >= 5, "โŒ Need at least 5 points to fit an ellipse!" - ellipse = cv2.fitEllipse(rim_contour) + # Fit ellipse to contour + ellipse = cv2.fitEllipse(rim_contour) (center_x, center_y), (major_axis, minor_axis), angle_deg = ellipse - ellipse_center = np.array([int(center_x), int(center_y)]) + ellipse_center = np.array([int(center_x), int(center_y)]) # Center of ellipse - angle_deg += 90 # Align long axis as vertical + # --------------------------- + # Step 3: Calculate directions + # --------------------------- + angle_deg += 90 # Rotate so long axis is considered vertical angle_rad = math.radians(angle_deg) + + # Calculate downward vector (unit vector) dir_down = np.array([math.cos(angle_rad), math.sin(angle_rad)]) dir_down /= np.linalg.norm(dir_down) - # โœ… Ensure dir_down points downward (positive Y) + + # โœ… Ensure dir_down is pointing downward (positive Y direction) if dir_down[1] < 0: dir_down *= -1 + # Get rightward direction as perpendicular vector to dir_down dir_right = np.array([-dir_down[1], dir_down[0]]) - - # โœ… Ensure dir_right points rightward (positive X) + + # โœ… Ensure dir_right points rightward (positive X direction) if dir_right[0] < 0: dir_right *= -1 - - # Assuming dir_down is a 2D unit vector [x, y] + + # Recompute angle from dir_down to correct it for rendering angle_rad_back = math.atan2(dir_down[1], dir_down[0]) angle_deg = math.degrees(angle_rad_back) + # Calculate final angle in degrees (adjusted to 0โ€“360ยฐ range) ellipse_angle_deg = (450 - angle_deg) % 360 - # --- Visualization --- - image = np.array(image_dict_p4["closed"]).copy() - cv2.circle(image, ellipse_center, 4, (255, 255, 0), 5) + # -------------------------- + # Step 4: Visualize results + # -------------------------- + image = np.array(image_dict_p4["closed"]).copy() # Get original closed image + cv2.circle(image, ellipse_center, 4, (255, 255, 0), 5) # Draw center point + + # Draw downward direction (green) pt_down = (ellipse_center + dir_down * 100).astype(int) - pt_right = (ellipse_center + dir_right * 100).astype(int) cv2.arrowedLine(image, ellipse_center, pt_down, (0, 255, 0), 3) + + # Draw rightward direction (cyan) + pt_right = (ellipse_center + dir_right * 100).astype(int) cv2.arrowedLine(image, ellipse_center, pt_right, (0, 255, 255), 3) + # Convert visualized image to displayable PNG plt.figure(figsize=(6, 6)) plt.imshow(image) plt.title("Toilet Rim Orientation using Ellipse Fitting") @@ -975,22 +1295,28 @@ def analyze_closed_rim_orientation_p10(_trigger, binary_masks_p4, image_dict_p4) plt.close() buf.seek(0) ellipse_viz_image = PILImage_p10.open(buf) - + + # -------------------------- + # Step 5: Prepare output text + # -------------------------- closed_orientation_str = ( f"Downward Direction Angle:\n{ellipse_angle_deg:4.1f}ยฐ\n" f"\nPerpendicular Direction Angle:\n{(ellipse_angle_deg + 90) % 360:4.1f}ยฐ\n" f"\nEllipse Center:\n({ellipse_center[0]}, {ellipse_center[1]})" ) + # -------------------------- + # Step 6: Return results + # -------------------------- return ( - gr.update(value=ellipse_viz_image, visible=True), - ellipse_angle_deg, - ellipse_center.tolist(), - dir_down.tolist(), - dir_right.tolist(), - binary_masks_p4, # return updated mask - gr.update(value=closed_orientation_str, visible=True), - gr.update(visible=True) + gr.update(value=ellipse_viz_image, visible=True), # Displayed image + ellipse_angle_deg, # Angle of downward direction + ellipse_center.tolist(), # Center coordinates + dir_down.tolist(), # Downward unit vector + dir_right.tolist(), # Rightward unit vector + binary_masks_p4, # Updated binary masks + gr.update(value=closed_orientation_str, visible=True), # Text summary + gr.update(visible=True) # Make text box visible ) # ================================ @@ -1000,86 +1326,100 @@ def analyze_closed_rim_orientation_p10(_trigger, binary_masks_p4, image_dict_p4) from PIL import Image as PILImage_p11 from io import BytesIO +# Main function to analyze rim height along vertical direction on closed toilet seat def analyze_rim_height_on_closed_p11( - _trigger, - binary_masks_p4, - image_closed_lid_rotated_p3, - ellipse_angle_deg_p10, - ref_ratios_p5, - rim_height_cm_p8 + _trigger, # Dummy trigger input to control Gradio interaction + binary_masks_p4, # Dictionary of segmentation masks (rim masks in this case) + image_closed_lid_rotated_p3, # Rotated image for closed-lid condition + ellipse_angle_deg_p10, # Angle of fitted ellipse (used to determine vertical direction) + ref_ratios_p5, # Pixel per cm reference for conversion + rim_height_cm_p8 # Precomputed rim height (open) from earlier stage ): + # Utility function to trace along a vector direction from a center point + # and find the furthest foreground (non-zero) points on both sides of the direction def find_extreme_points_along_line(mask, center, direction, max_steps=2000): H, W = mask.shape pt1 = pt2 = None + # Move forward from center along direction vector for step in range(1, max_steps): pt = center + step * direction x, y = int(round(pt[0])), int(round(pt[1])) if not (0 <= x < W and 0 <= y < H): break if mask[y, x] > 0: - pt2 = np.array([x, y]) + pt2 = np.array([x, y]) # furthest found point elif pt2 is not None: - break + break # exit once we leave the foreground area + # Move backward from center along opposite direction for step in range(1, max_steps): pt = center - step * direction x, y = int(round(pt[0])), int(round(pt[1])) if not (0 <= x < W and 0 <= y < H): break if mask[y, x] > 0: - pt1 = np.array([x, y]) + pt1 = np.array([x, y]) # furthest found point elif pt1 is not None: break return pt1, pt2 + # Utility function to calculate Euclidean distance between two points def dist(a, b): return np.linalg.norm(a - b) if a is not None and b is not None else None - # --- Mask & Image --- - mask = binary_masks_p4["rim"]["closed"] - image = np.array(image_closed_lid_rotated_p3) + # --- Step 1: Get mask and image --- + mask = binary_masks_p4["rim"]["closed"] # Binary mask for closed rim + image = np.array(image_closed_lid_rotated_p3) # Convert PIL image to NumPy array - # --- Center --- - mask_u8 = (mask * 255).astype(np.uint8) + # --- Step 2: Compute center of the mask contour --- + mask_u8 = (mask * 255).astype(np.uint8) # Convert to 8-bit mask for contour detection contours, _ = cv2.findContours(mask_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) - assert contours, "โŒ No contours found in closed rim mask!" - cnt = max(contours, key=cv2.contourArea) - M = cv2.moments(cnt) - cx = int(M["m10"] / M["m00"]) - cy = int(M["m01"] / M["m00"]) - center = np.array([cx, cy]) - - # --- Direction Vector from Ellipse --- - angle_rad = np.deg2rad((450 - ellipse_angle_deg_p10) % 360) - dir_vec = np.array([math.cos(angle_rad), math.sin(angle_rad)]) - + assert contours, "โŒ No contours found in closed rim mask!" # Raise error if no contours + cnt = max(contours, key=cv2.contourArea) # Largest contour + M = cv2.moments(cnt) # Calculate image moments + cx = int(M["m10"] / M["m00"]) # X coordinate of centroid + cy = int(M["m01"] / M["m00"]) # Y coordinate of centroid + center = np.array([cx, cy]) # Center point + + # --- Step 3: Determine direction vector using ellipse angle --- + angle_rad = np.deg2rad((450 - ellipse_angle_deg_p10) % 360) # Convert to radian (corrected for rotation) + dir_vec = np.array([math.cos(angle_rad), math.sin(angle_rad)]) # Unit vector in direction + + # --- Step 4: Trace extreme points along the direction vector --- pt_start, pt_end = find_extreme_points_along_line(mask, center, dir_vec) - rim_height_px = dist(pt_start, pt_end) - px_per_cm = ref_ratios_p5['closed'] - full_rim_height_cm = rim_height_px / px_per_cm - full_rim_height_in = full_rim_height_cm / 2.54 + rim_height_px = dist(pt_start, pt_end) # Height in pixels + px_per_cm = ref_ratios_p5['closed'] # Pixel-per-cm ratio + full_rim_height_cm = rim_height_px / px_per_cm # Total rim height in cm + full_rim_height_in = full_rim_height_cm / 2.54 # Convert to inches - rim_height_cm = rim_height_cm_p8 - rim_height_in = rim_height_cm / 2.54 + # --- Step 5: Use rim height from open seat to compute overlap --- + rim_height_cm = rim_height_cm_p8 # Rim height from open seat (in cm) + rim_height_in = rim_height_cm / 2.54 # Convert to inches - closed_remaining_cm = full_rim_height_cm - rim_height_cm - closed_remaining_in = closed_remaining_cm / 2.54 + closed_remaining_cm = full_rim_height_cm - rim_height_cm # Height still covered by lid + closed_remaining_in = closed_remaining_cm / 2.54 # Convert to inches + # --- Step 6: Visualize the result if both points were found --- if pt_start is not None and pt_end is not None: vis = image.copy() - cv2.circle(vis, tuple(center), 4, (255, 255, 0), -1) - cv2.circle(vis, tuple(pt_start), 5, (0, 0, 255), -1) - cv2.circle(vis, tuple(pt_end), 5, (0, 255, 0), -1) - cv2.line(vis, tuple(pt_start), tuple(pt_end), (0, 255, 255), 2) + # Draw points and line on visualization + cv2.circle(vis, tuple(center), 4, (255, 255, 0), -1) # center (yellow) + cv2.circle(vis, tuple(pt_start), 5, (0, 0, 255), -1) # start point (red) + cv2.circle(vis, tuple(pt_end), 5, (0, 255, 0), -1) # end point (green) + cv2.line(vis, tuple(pt_start), tuple(pt_end), (0, 255, 255), 2) # vertical line (cyan) + # Create overlay label label = f"{rim_height_px:.1f}px | {full_rim_height_cm:.2f}cm | {full_rim_height_in:.2f}in" mid_point = ((pt_start + pt_end) / 2).astype(int) text_pos = (mid_point[0] + 10, mid_point[1] - 10) + + # Create label background box overlay = vis.copy() (text_w, text_h), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 1, 2) rect_start = (text_pos[0] - 10, text_pos[1] - text_h - 10) rect_end = (text_pos[0] + text_w + 10, text_pos[1] + 10) - cv2.rectangle(overlay, rect_start, rect_end, (0, 0, 0), -1) - cv2.addWeighted(overlay, 0.7, vis, 0.3, 0, vis) + cv2.rectangle(overlay, rect_start, rect_end, (0, 0, 0), -1) # background + cv2.addWeighted(overlay, 0.7, vis, 0.3, 0, vis) # apply overlay cv2.putText(vis, label, text_pos, cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2) + # Save visualization to in-memory image plt.figure(figsize=(6, 6)) plt.imshow(vis) plt.title("Rim Height on Closed Lid") @@ -1088,28 +1428,31 @@ def analyze_rim_height_on_closed_p11( plt.savefig(buf, format='png') plt.close() buf.seek(0) - rim_height_vis = PILImage_p11.open(buf) - - pt_start_p11 = pt_start + rim_height_vis = PILImage_p11.open(buf) # PIL Image for output + pt_start_p11 = pt_start # Store start point + + # Final string to show in UI rim_closed_str = ( f"Total Height:\n{rim_height_px:4.1f}px | {full_rim_height_cm:4.2f}cm | {full_rim_height_in:4.2f}in" ) + # Return all values to Gradio UI return ( - gr.update(value=rim_height_vis, visible=True), - rim_height_cm, - rim_height_in, - full_rim_height_cm, - full_rim_height_in, - closed_remaining_cm, - closed_remaining_in, - pt_start_p11, - gr.update(value=rim_closed_str, visible=True), - gr.update(visible=True) + gr.update(value=rim_height_vis, visible=True), # Updated visualization + rim_height_cm, # Open rim height (cm) + rim_height_in, # Open rim height (in) + full_rim_height_cm, # Closed rim full height (cm) + full_rim_height_in, # Closed rim full height (in) + closed_remaining_cm, # Height still hidden by lid (cm) + closed_remaining_in, # Height still hidden by lid (in) + pt_start_p11, # Topmost point on closed rim + gr.update(value=rim_closed_str, visible=True), # Text summary for UI + gr.update(visible=True) # Enable output group in Gradio ) else: + # If points couldn't be found, return empty outputs print("โš ๏ธ Could not find valid intersection points.") return (None, None, None, None, None, None, None, None, None, None) @@ -1119,50 +1462,52 @@ def analyze_rim_height_on_closed_p11( def draw_remaining_closed_portion_p12( _trigger, - pt_start_p11, - closed_remaining_cm_p11, - ref_ratios_p5, - ellipse_dir_down_p10, - image_closed_lid_rotated_p3 + pt_start_p11, # Starting point from Part 11 (top of closed rim) + closed_remaining_cm_p11, # Remaining distance (in cm) to be drawn + ref_ratios_p5, # Dictionary with px/cm ratios for all image types + ellipse_dir_down_p10, # Unit vector pointing downward from ellipse analysis (Part 10) + image_closed_lid_rotated_p3 # The closed lid image, rotated for alignment ): # --- Clone and compute points --- - pt_start = np.array(pt_start_p11) - dir_down = np.array(ellipse_dir_down_p10) - remaining_cm = closed_remaining_cm_p11 - px_per_cm = ref_ratios_p5['closed'] - remaining_px = remaining_cm * px_per_cm - pt_end = (pt_start + dir_down * remaining_px).astype(int) + pt_start = np.array(pt_start_p11) # Convert input start point to NumPy array + dir_down = np.array(ellipse_dir_down_p10) # Direction unit vector from ellipse (downward) + remaining_cm = closed_remaining_cm_p11 # Distance to draw, in cm + px_per_cm = ref_ratios_p5['closed'] # Get pixel-to-cm conversion for closed image + remaining_px = remaining_cm * px_per_cm # Convert remaining cm to pixels + pt_end = (pt_start + dir_down * remaining_px).astype(int) # Compute end point using direction and distance # --- Compute angle --- - vec = pt_end - pt_start - angle_rad = np.arctan2(vec[1], vec[0]) - remaining_angle_deg = (450 - np.rad2deg(angle_rad)) % 360 + vec = pt_end - pt_start # Vector between start and end point + angle_rad = np.arctan2(vec[1], vec[0]) # Angle in radians using arctangent of vector + remaining_angle_deg = (450 - np.rad2deg(angle_rad)) % 360 # Convert to clockwise angle in degrees (0ยฐ at top) # --- Draw on image --- - image = np.array(image_closed_lid_rotated_p3).copy() - cv2.line(image, tuple(pt_start), tuple(pt_end), (0, 0, 255), 3) # red - cv2.circle(image, tuple(pt_start), 5, (0, 255, 0), -1) # green - cv2.circle(image, tuple(pt_end), 5, (255, 0, 0), -1) # blue + image = np.array(image_closed_lid_rotated_p3).copy() # Make a copy of the rotated image to draw on + cv2.line(image, tuple(pt_start), tuple(pt_end), (0, 0, 255), 3) # Draw red line from start to end + cv2.circle(image, tuple(pt_start), 5, (0, 255, 0), -1) # Draw green circle at start + cv2.circle(image, tuple(pt_end), 5, (255, 0, 0), -1) # Draw blue circle at end # --- Add label --- - remaining_in = remaining_cm / 2.54 - label = f"({remaining_cm:.2f} cm) | ({remaining_in:.2f} in)" - text_pos = (pt_end[0] + 10, pt_end[1] - 10) + remaining_in = remaining_cm / 2.54 # Convert cm to inches + label = f"({remaining_cm:.2f} cm) | ({remaining_in:.2f} in)" # Create label with both units + text_pos = (pt_end[0] + 10, pt_end[1] - 10) # Position label near end point cv2.putText(image, label, text_pos, cv2.FONT_HERSHEY_SIMPLEX, - 1, (0, 0, 255), 2, cv2.LINE_AA) - + 1, (0, 0, 255), 2, cv2.LINE_AA) # Draw label text in red + + # --- Compose info string for UI --- closed_remaining_str = ( f"Remaining Portion Length:\n{remaining_cm:4.2f}cm | {remaining_in:4.2f}in\n" f"\nDirection Angle:\n{remaining_angle_deg:4.1f}ยฐ" ) + # --- Return image + values for display --- return ( - gr.update(value=(PILImage_p11.fromarray(image)), visible=True), - remaining_angle_deg, - pt_start, - pt_end, - gr.update(value=closed_remaining_str, visible=True), - gr.update(visible=True) + gr.update(value=(PILImage_p11.fromarray(image)), visible=True), # Show annotated image + remaining_angle_deg, # Angle of the drawn segment + pt_start, # Start point + pt_end, # End point + gr.update(value=closed_remaining_str, visible=True), # Display stats string + gr.update(visible=True) # Make result components visible ) # ================================ @@ -1171,21 +1516,24 @@ def draw_remaining_closed_portion_p12( def analyze_top_rim_width_p13( _trigger, - binary_masks_p4, - ellipse_center_p10, - ellipse_angle_deg_p10, - ref_ratios_p5, - image_closed_lid_rotated_p3 + binary_masks_p4, # Dictionary containing binary masks, here we use the 'rim' mask for the closed lid image + ellipse_center_p10, # Center of fitted ellipse (from Part 10) + ellipse_angle_deg_p10, # Angle of fitted ellipse in degrees (from Part 10) + ref_ratios_p5, # Reference px/cm ratios (from Part 5) + image_closed_lid_rotated_p3 # Rotated closed-lid image for overlay (from Part 3) ): import matplotlib.pyplot as plt from PIL import Image as PILImage_p13 + from io import BytesIO + # Extract the necessary variables from inputs mask = binary_masks_p4['rim']['closed'] center = np.array(ellipse_center_p10) - angle_deg = (450 - ellipse_angle_deg_p10) % 360 + angle_deg = (450 - ellipse_angle_deg_p10) % 360 # Adjust angle for image coordinate system px_per_cm = ref_ratios_p5['closed'] - image = np.array(image_closed_lid_rotated_p3).copy() + image = np.array(image_closed_lid_rotated_p3).copy() # Convert PIL image to NumPy array + # Bresenhamโ€™s line algorithm for drawing a line between two points def bresenham_line(x0, y0, x1, y1): points = [] steep = abs(y1 - y0) > abs(x1 - x0) @@ -1210,15 +1558,20 @@ def analyze_top_rim_width_p13( if swapped: points.reverse() return points - def find_top_and_perpendicular_extremes(mask, center, angle_deg, perp_halfwidth=700, max_up_scan=1500): - mask = (mask > 0).astype(np.uint8) + # Core logic to find the topmost rim point and the width across it + def find_top_and_perpendicular_extremes(mask, center, angle_deg, perp_halfwidth=7000, max_up_scan=6500): + mask = (mask > 0).astype(np.uint8) # Ensure binary mask h, w = mask.shape angle_rad = np.deg2rad(angle_deg) dx, dy = np.cos(angle_rad), np.sin(angle_rad) + + # Upward direction is negative direction of ellipse angle up_dir = np.array([-dx, -dy]) + # Perpendicular to rim direction (cross-section) perp_dir = np.array([-dy, dx]) pt_top = None + # Scan upward from center pixel to find the first mask pixel for i in range(max_up_scan): pt = center + up_dir * i x, y = int(round(pt[0])), int(round(pt[1])) @@ -1227,294 +1580,297 @@ def analyze_top_rim_width_p13( if pt_top is None: raise ValueError("No mask pixel found when scanning upward from center.") + # Slight offset downward for more reliable width reading pt_top = (pt_top[0], int(pt_top[1] + h * 0.02)) + + # Generate line endpoints left and right of pt_top along the perpendicular axis left_pt = (int(round(pt_top[0] - perp_dir[0] * perp_halfwidth)), int(round(pt_top[1] - perp_dir[1] * perp_halfwidth))) right_pt = (int(round(pt_top[0] + perp_dir[0] * perp_halfwidth)), int(round(pt_top[1] + perp_dir[1] * perp_halfwidth))) + # Draw a line across the rim at the top point to find edges line_pts = bresenham_line(left_pt[0], left_pt[1], right_pt[0], right_pt[1]) valid_pts = [pt for pt in line_pts if 0 <= pt[0] < w and 0 <= pt[1] < h and mask[pt[1], pt[0]] == 1] + if len(valid_pts) < 2: raise ValueError("Not enough mask pixels found along perpendicular line.") + + # Take first and last valid pixels on rim as width endpoints pt1, pt2 = valid_pts[0], valid_pts[-1] dist_px = np.linalg.norm(np.array(pt2) - np.array(pt1)) return pt_top, pt1, pt2, dist_px + # Run the function to find points and distance pt_top, pt1, pt2, dist_px = find_top_and_perpendicular_extremes(mask, center, angle_deg) + + # Convert from pixels to cm and inches dist_cm = dist_px / px_per_cm dist_in = dist_cm / 2.54 + + # Calculate angle of this actual width vector for reference vec = np.array(pt2) - np.array(pt1) angle_rad_actual = np.arctan2(vec[1], vec[0]) angle_deg_actual = (450 - np.rad2deg(angle_rad_actual)) % 360 - # --- Visualization --- + # ------------------------------- + # ๐Ÿ–ผ Visualization for feedback + # ------------------------------- fig, ax = plt.subplots(figsize=(8, 8)) - ax.imshow(image) - ax.plot([pt1[0], pt2[0]], [pt1[1], pt2[1]], 'r-', linewidth=1) - ax.scatter(*pt1, color='lime', s=20) - ax.scatter(*pt2, color='cyan', s=20) + ax.imshow(image) # Show image + ax.plot([pt1[0], pt2[0]], [pt1[1], pt2[1]], 'r-', linewidth=1) # Draw width line + ax.scatter(*pt1, color='lime', s=20) # Start point + ax.scatter(*pt2, color='cyan', s=20) # End point + + # Display measurements in the middle of the line mid_x = (pt1[0] + pt2[0]) / 2 mid_y = (pt1[1] + pt2[1]) / 2 ax.text(mid_x, mid_y + 100, f"{dist_px:.1f}px | {dist_cm:.2f}cm | {dist_in:.2f}in", fontsize=10, color='white', bbox=dict(facecolor='black', alpha=0.6)) ax.set_title("Top Rim Width Measurement") ax.axis('off') + + # Save plot as image buf = BytesIO() plt.savefig(buf, format='png') plt.close() buf.seek(0) vis_image = PILImage_p13.open(buf) - + + # Return readable output string for textbox top_rim_str = ( f"Top Rim Width:\n{dist_px:4.1f}px | {dist_cm:4.2f}cm | {dist_in:4.2f}in\n" f"\nOrientation Angle:\n{angle_deg_actual % 180:4.1f}ยฐ" ) - return gr.update(value=vis_image, visible=True), pt_top, pt1, pt2, dist_px, dist_cm, dist_in, angle_deg_actual, gr.update(value=top_rim_str, visible=True), gr.update(visible=True) - + # Return everything required for UI + return ( + gr.update(value=vis_image, visible=True), # Output image + pt_top, # Topmost point + pt1, pt2, # Width line endpoints + dist_px, dist_cm, dist_in, # Measurements + angle_deg_actual, # Measured orientation + gr.update(value=top_rim_str, visible=True), # Text output + gr.update(visible=True) # Control for showing card or section + ) # ================================ # ๐Ÿงฉ FINAL PART: Combined App Launcher # ================================ +# This part contains logic for launching the final combined Gradio app, +# including Supabase authentication, file upload functionality, and login logic. + import gradio as gr import os from supabase import create_client, Client from datetime import datetime -# Supabase setup -SUPABASE_URL = os.getenv("SUPABASE_URL") -SUPABASE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY") -SUPABASE_BUCKET = os.getenv("SUPABASE_BUCKET_NAME") +# ========== Supabase Configuration ========== +# Load environment variables for Supabase project +SUPABASE_URL = os.getenv("SUPABASE_URL") # Supabase project URL +SUPABASE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY") # Service role key +SUPABASE_BUCKET = os.getenv("SUPABASE_BUCKET_NAME") # Bucket name to upload ZIPs +# Check if keys are present, else raise an error if not SUPABASE_URL or not SUPABASE_KEY: raise Exception("Supabase keys not set properly!") +# Create the Supabase client supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY) -# ===== Upload Function ===== +# ===== Upload Function: App Output ZIP ===== def upload_zip(email, zip_file): + # Validate presence of both email and file if not email or not zip_file: print("โŒ Email and ZIP file are required.") return gr.update(visible=True) + # Ensure it's a .zip file if not zip_file.name.endswith('.zip'): print("โŒ Only ZIP files are allowed.") return gr.update(visible=True) - # Create filename: email_timestamp.zip + # Create a unique filename using email and timestamp timestamp = datetime.now().isoformat().replace(":", "-").split(".")[0] safe_email = email.replace("@", "_at_").replace(".", "_") filename = f"{safe_email}_{timestamp}.zip" - path_in_bucket = f"zips/{filename}" + path_in_bucket = f"zips/{filename}" # Folder path inside the bucket - # Read file as bytes + # Read uploaded file bytes with open(zip_file.name, "rb") as f: file_bytes = f.read() - # Upload to Supabase Storage + # Upload file to Supabase storage response = supabase.storage.from_(SUPABASE_BUCKET).upload( path_in_bucket, file_bytes, {"content-type": "application/zip"} ) + + # Log upload response print(response) return gr.update(visible=True) -# ===== Upload Function ===== +# ===== Upload Function: Error ZIP Upload ===== def upload_zip_error(email, zip_file): + # Check email and zip are both provided if not email or not zip_file: print("โŒ Email and ZIP file are required.") return + # Ensure it's a ZIP file if not zip_file.name.endswith('.zip'): print("โŒ Only ZIP files are allowed.") return - # Create filename: email_timestamp.zip + # Generate timestamped safe filename timestamp = datetime.now().isoformat().replace(":", "-").split(".")[0] safe_email = email.replace("@", "_at_").replace(".", "_") filename = f"error_analysis_{safe_email}_{timestamp}.zip" - path_in_bucket = f"zips/{filename}" + path_in_bucket = f"zips/{filename}" # Target location in bucket - # Read file as bytes + # Read file as binary with open(zip_file.name, "rb") as f: file_bytes = f.read() - # Upload to Supabase Storage + # Upload the error ZIP to Supabase storage response = supabase.storage.from_(SUPABASE_BUCKET).upload( path_in_bucket, file_bytes, {"content-type": "application/zip"} ) + # Print result to console print(response) return -# Auth functions +# ===== Supabase Login Authentication ===== def supa_login(email, password): try: - # Query for user with matching email + # Query Supabase users table for user with given email result = supabase.table("users").select("*").eq("email", email).execute() users = result.data + # If no such user exists if not users: return "โŒ No user found with that email", False + # Validate password user = users[0] if user["password"] == password: return "โœ… Login successful!", True else: return "โŒ Incorrect password", False except Exception as e: + # Handle any exceptions that occur during login return f"โŒ Login error: {e}", True -import matplotlib.pyplot as plt -import matplotlib.patches as patches -import numpy as np -from PIL import Image -import os - -def draw_shapes_with_zorder( - polygons_data=None, - ellipses_data=None, - filename="multiple_shapes.png", - fig_size=(10, 8), - transparent_bg=False -): - fig, ax = plt.subplots(figsize=fig_size) - ax.set_aspect('equal', adjustable='box') - ax.set_axis_off() - ax.set_facecolor('#f0f0f0') - - min_x, max_x = float('inf'), float('-inf') - min_y, max_y = float('inf'), float('-inf') - - all_coords = [] - if polygons_data: - for poly_info in polygons_data: - points = poly_info.get('points') - if points: - all_coords.extend(points) - polygon = patches.Polygon( - points, - closed=True, - facecolor=poly_info.get('facecolor', '#ADD8E6'), - edgecolor=poly_info.get('edgecolor', 'blue'), - linewidth=poly_info.get('linewidth', 2), - zorder=poly_info.get('zorder', 1) - ) - ax.add_patch(polygon) - - if ellipses_data: - for ellipse_info in ellipses_data: - e_cx = ellipse_info.get('center_x') - e_cy = ellipse_info.get('center_y') - e_w = ellipse_info.get('width') - e_h = ellipse_info.get('height') - e_angle = ellipse_info.get('angle', 0) - - if e_cx is None or e_cy is None or e_w is None or e_h is None: - continue - - all_coords.append((e_cx - e_w / 2, e_cy - e_h / 2)) - all_coords.append((e_cx + e_w / 2, e_cy + e_h / 2)) - all_coords.append((e_cx - e_w / 2, e_cy + e_h / 2)) - all_coords.append((e_cx + e_w / 2, e_cy - e_h / 2)) - - ellipse = patches.Ellipse( - (e_cx, e_cy), - e_w, - e_h, - angle=e_angle, - facecolor=ellipse_info.get('facecolor', '#FFD700'), - edgecolor=ellipse_info.get('edgecolor', 'orange'), - linewidth=ellipse_info.get('linewidth', 2), - zorder=ellipse_info.get('zorder', 1) - ) - ax.add_patch(ellipse) - - if all_coords: - coords_array = np.array(all_coords) - min_x, min_y = np.min(coords_array, axis=0) - max_x, max_y = np.max(coords_array, axis=0) - - padding_x = (max_x - min_x) * 0.2 if (max_x - min_x) > 0 else 1.0 - padding_y = (max_y - min_y) * 0.2 if (max_y - min_y) > 0 else 1.0 - - ax.set_xlim(min_x - padding_x, max_x + padding_x) - ax.set_ylim(min_y - padding_y, max_y + padding_y) - else: - ax.set_xlim(0, 10) - ax.set_ylim(0, 8) - - plt.savefig(filename, dpi=300, bbox_inches='tight', pad_inches=0, transparent=transparent_bg) - plt.close(fig) # Close to avoid memory leak - - return Image.open(filename) # Return as PIL.Image object for Gradio - -# --- Example Usage --- +# Import necessary libraries +import matplotlib.pyplot as plt # For plotting +import matplotlib.patches as patches # For drawing shapes like polygons and ellipses +import numpy as np # For numerical computations +from PIL import Image # For image saving/loading as PIL object +import os # For file operations -# Define data for multiple polygons +# ================================ +# ๐ŸŸซ Define chassis image data +# ================================ my_polygons = [ { 'points': [(0.56, 14.72), (0.56, 19.05), (19.5, 19.05), (19.5, 14.53)], - 'facecolor': "#E6ADAD", - 'edgecolor': 'brown', - 'linewidth': 0, - 'zorder': 2 + 'facecolor': "#E6ADAD", # Light red fill + 'edgecolor': 'brown', # Border color (not visible if linewidth is 0) + 'linewidth': 0, # No border + 'zorder': 2 # Renders above background but below zorder=3 or 4 }, { 'points': [(3, 14.72), (3, 9.45), (17, 9.45), (17, 14.6)], - 'facecolor': '#E6ADAD', + 'facecolor': '#E6ADAD', # Same fill as above 'edgecolor': 'darkgreen', 'linewidth': 0, - 'zorder': 1 + 'zorder': 1 # Rendered below most elements }, { 'points': [(4.24, 18.74), (9.68, 18.74), (9.68, 17.34), (4.24, 17.34)], - 'facecolor': '#f0f0f0', + 'facecolor': '#f0f0f0', # Matches background 'edgecolor': 'brown', 'linewidth': 0, - 'zorder': 4 + 'zorder': 4 # Highest priority, renders on top }, { 'points': [(12.25, 18.74), (15.88, 18.74), (15.88, 17.34), (12.25, 17.34)], 'facecolor': '#f0f0f0', 'edgecolor': 'darkgreen', 'linewidth': 0, - 'zorder': 4 + 'zorder': 4 # Also renders on top } ] -# Define data for multiple ellipses +# ================================ +# ๐ŸŸก Define chassis image data +# ================================ my_ellipses = [ { - 'center_x': 10.0, 'center_y': 9.45, 'width': 9.0, 'height': 12.0, 'angle': 0, - 'facecolor': '#f0f0f0', + 'center_x': 10.0, 'center_y': 9.45, + 'width': 9.0, 'height': 12.0, 'angle': 0, + 'facecolor': '#f0f0f0', # Same as background 'edgecolor': 'blue', 'linewidth': 0, - 'zorder': 4 + 'zorder': 4 # Render on top of all lower z-order shapes }, { - 'center_x': 10.0, 'center_y': 9.45, 'width': 14.0, 'height': 17.0, 'angle': 0, - 'facecolor': '#E6ADAD', + 'center_x': 10.0, 'center_y': 9.45, + 'width': 14.0, 'height': 17.0, 'angle': 0, + 'facecolor': '#E6ADAD', # Light red 'edgecolor': 'purple', 'linewidth': 0, - 'zorder': 3 + 'zorder': 3 # Under the inner ellipse but above most polygons } ] -# Final app logic (your current full app can go here) +def generate_reload_js(email, password): + # JS that reloads the page with email and password in the hash + email_enc = urllib.parse.quote(email) + password_enc = urllib.parse.quote(password) + return gr.update(_js=f"() => location.href = location.origin + location.pathname + '#email={email_enc}&password={password_enc}'") + +# ================================ +# ๐Ÿš€ Final App Launch Logic +# ================================ +# Preload email/password if found in URL fragment (e.g., #email=...&password=...) +def restore_from_url(url): + parsed = urllib.parse.urlparse(url) + query = urllib.parse.parse_qs(parsed.fragment or parsed.query) + email = query.get("email", [""])[0] + password = query.get("password", [""])[0] + return email, password + def launch_main_app(): import gradio as gr + import urllib.parse + url_box = gr.Textbox(visible=False) + + # On app load, prefill email/password from URL + full_app_interface.load(restore_from_url, [url_box], [email, password]) + # Main container column with gr.Column("๐Ÿš€ Main App"): - # Your current Gradio blocks, tabs, buttons go here + + # --- Row for Logout Button --- + with gr.Row(elem_id="logout-row"): + logout_btn = gr.Button("Logout", elem_id="logout-btn") + + # JS to reload the app on logout click (clears session) + logout_btn.click(fn=None, js="() => location.reload()") + + # --- App Title and Intro --- gr.Markdown("# ๐Ÿšฝ Smart Toilet Image Checker", elem_id="centered-title") gr.Markdown("Just upload your toilet photos โ€” then sit back and watch the AI work its magic!", elem_id="centered-title") gr.Markdown("๐Ÿ“˜ **New here?** If you're not sure how to use this app, please check out the [step-by-step instructions](https://drive.google.com/file/d/1FmdJBWi046iscXAXlAh321Iyc5w8RHcc/view?usp=sharing).") + # Create a Gradio session state for current user interaction session_state = gr.State(init_session()) - + + # --- Example Upload Section --- with gr.Row(): gr.Markdown("### ๐Ÿ“ท How to Upload Images Correctly (Example)") + # Show 3 wrong upload examples and 1 correct example with gr.Row(): with gr.Column(): gr.Markdown("โŒ Wrong") @@ -1526,30 +1882,43 @@ def launch_main_app(): gr.Markdown("โŒ Wrong") wrong3 = gr.Image(value="./static/im3.jpg", show_share_button=False, show_fullscreen_button=False, show_label=False, interactive=False, height=200, show_download_button=False, container=False) + # Display correct upload example in center with gr.Row(): with gr.Column(): - z="filler" + z = "filler" # Just spacing with gr.Column(): gr.Markdown("โœ… Correct") correct = gr.Image(value="./static/im4.jpg", show_share_button=False, show_fullscreen_button=False, show_label=False, interactive=False, height=200, show_download_button=False, container=False) with gr.Column(): - z="filler" + z = "filler" + + # --- Session-wide Global State Declarations (Gradio-safe) --- + # These maintain state across tab interactions + # Part 4 binary_masks_p4 = gr.State() image_dict_p4 = gr.State() + + # Part 5 out_ref_ratios_p5 = gr.State() + + # Part 7 (Rim ellipse measurements) out_rimellipse_ui_p7 = gr.State() out_rimellipse_cm_p7 = gr.State() out_rimellipse_inch_p7 = gr.State() inner_top_p7 = gr.State() dir_down_p7 = gr.State() dir_right_p7 = gr.State() + + # Part 8 btn_rimheight_p8 = gr.State() rimheight_text_p8 = gr.State() rim_height_px_p8 = gr.State() rim_height_cm_p8 = gr.State() rim_height_inch_p8 = gr.State() inner_top_p8 = gr.State() + + # Part 9 btn_measure_holewidth_p9 = gr.State() hole_width_px_p9 = gr.State() hole_width_cm_p9 = gr.State() @@ -1557,6 +1926,8 @@ def launch_main_app(): angle_deg_p9 = gr.State() pt_min_p9 = gr.State() pt_max_p9 = gr.State() + + # Part 10 btn_top_to_hole_p10 = gr.State() top_to_hole_line_px_p10 = gr.State() top_to_hole_line_cm_p10 = gr.State() @@ -1569,6 +1940,8 @@ def launch_main_app(): ellipse_center_p10 = gr.State() ellipse_dir_down_p10 = gr.State() ellipse_dir_right_p10 = gr.State() + + # Part 11 (Closed lid rim) updated_binary_masks_p4 = gr.State() btn_rim_height_closed_p11 = gr.State() rim_height_cm_p11 = gr.State() @@ -1578,10 +1951,14 @@ def launch_main_app(): closed_remaining_cm_p11 = gr.State() closed_remaining_in_p11 = gr.State() pt_start_p11 = gr.State() + + # Part 12 (Remaining portion drawing) btn_draw_remaining_p12 = gr.State() remaining_angle_deg_p12 = gr.State() pt_start_p12 = gr.State() pt_end_p12 = gr.State() + + # Generic measurement tool btn_measure = gr.State() out_pt_top = gr.State() out_pt1 = gr.State() @@ -1590,29 +1967,41 @@ def launch_main_app(): out_dist_cm = gr.State() out_dist_in = gr.State() out_angle_deg = gr.State() + + # Part 6 rim overlay results out_rim_measurements_p6 = gr.State() out_rim_measurements_cm_p6 = gr.State() out_rim_measurements_inch_p6 = gr.State() + + # Part 2 Models (Segmentation models) models_holes_p2 = gr.State(None) models_rim_p2 = gr.State(None) models_coinref_p2 = gr.State(None) device_p2 = gr.State(None) + + # Optional debugging masks (commented) gallery_segmentation_p4 = gr.State() + + # For previewing polygons/ellipses in mask viewer polygons, ellipses = gr.State(), gr.State() polygons1, ellipses1 = gr.State(), gr.State() - + + # --- Load models and device globally at app load --- full_app_interface.load( - fn=lambda: [GLOBAL_HOLES, GLOBAL_RIM, GLOBAL_COIN, device_p2], + fn=lambda: [GLOBAL_HOLES, GLOBAL_RIM, GLOBAL_COIN, device_p2], # Initialize models and device outputs=[models_holes_p2, models_rim_p2, models_coinref_p2, device_p2], - queue=False # This event usually doesn't need to be queued + queue=False # Avoid blocking queue during load ) + # Injecting custom CSS into the Gradio app using gr.HTML gr.HTML("""""") + # =============================================== + # ๐Ÿ”„ Function to rotate an uploaded image live + # =============================================== def rotate_image_live(img): + # If no image is provided, return None if img is None: return None + # Import required libraries import numpy as np import cv2 from PIL import Image + # Convert PIL image to NumPy array img_np = np.array(img) + + # Get height and width of the image h, w = img_np.shape[:2] - # Transpose shape for 90-degree rotation (clockwise) + # Rotate image 90 degrees clockwise rotated = cv2.rotate(img_np, cv2.ROTATE_90_CLOCKWISE) + # Convert back to PIL image and return return Image.fromarray(rotated) import gradio as gr from PIL import Image import os + # Layout section with 3 image upload columns (for different toilet conditions) with gr.Row(): with gr.Column(): - gr.Markdown("### ๐Ÿ“ธ Open Toilet (No Seat)") - uploader1 = gr.UploadButton("Upload Image", file_types=["image"]) - input1 = gr.Image(height=500, width=800, label="Preview", interactive=False, visible=False) - filename1 = gr.Markdown() - rotte1 = gr.Button("Rotate", visible=False) + gr.Markdown("### ๐Ÿ“ธ Open Toilet (No Seat)") # Section heading + uploader1 = gr.UploadButton("Upload Image", file_types=["image"]) # Upload button + input1 = gr.Image(height=500, width=800, label="Preview", interactive=False, visible=False) # Image display (initially hidden) + filename1 = gr.Markdown() # To show filename + rotte1 = gr.Button("Rotate", visible=False) # Rotate button (initially hidden) with gr.Column(): gr.Markdown("### ๐Ÿ“ธ Open Toilet (With Seat)") @@ -1728,137 +2136,315 @@ def launch_main_app(): input3 = gr.Image(height=500, width=800, label="Preview", interactive=False, visible=False) filename3 = gr.Markdown() rotte3 = gr.Button("Rotate", visible=False) - + + # Utility function to resize an image while keeping aspect ratio + def resize_keep_aspect(image, target_size): + """ + Resize image to fit within target_size (width, height), + keeping aspect ratio. Returns same format (PIL.Image or numpy). + """ + is_numpy = isinstance(image, np.ndarray) # Check input type + if is_numpy: + image = Image.fromarray(image) # Convert to PIL if needed + + image = image.copy() # Avoid modifying original + image.thumbnail(target_size, Image.LANCZOS) # Resize with high quality filter + + if is_numpy: + return np.array(image) # Return in original format + else: + return image + + # Function to handle uploaded image, resize, and extract filename def load_image_and_filename(file_obj): if file_obj is None: - return None, "", gr.update(visible=False), gr.update(visible=False) + return None, "", gr.update(visible=False), gr.update(visible=False) # If no file, return nothing and hide widgets + filepath = file_obj.name img = Image.open(filepath) - filename = os.path.basename(filepath) + img = resize_keep_aspect(img, (960, 1280)) # Resize image to max bounds + + filename = os.path.basename(filepath) # Extract name from path return img, f"๐Ÿ“ Filename: {filename}", gr.update(visible=True), gr.update(visible=True) + # Set upload callbacks for all 3 image uploaders uploader1.upload(load_image_and_filename, inputs=uploader1, outputs=[input1, filename1, input1, rotte1]) uploader2.upload(load_image_and_filename, inputs=uploader2, outputs=[input2, filename2, input2, rotte2]) uploader3.upload(load_image_and_filename, inputs=uploader3, outputs=[input3, filename3, input3, rotte3]) - + + # Rotate buttons for each image โ€” rotate image when clicked rotte1.click(fn=rotate_image_live, inputs=[input1], outputs=input1) rotte2.click(fn=rotate_image_live, inputs=[input2], outputs=input2) rotte3.click(fn=rotate_image_live, inputs=[input3], outputs=input3) - output = gr.State() + output = gr.State() # Placeholder for later step output (stateful variable) - # Step 2 onwards (Auto-triggered after Part 3) + # ========================= + # Next Step Trigger Section + # ========================= + + # Main button to trigger the full pipeline with gr.Row(): - run_pipeline_btn = gr.Button("๐Ÿง  'Let AI Do the Work'") - run_pipeline_btn.click( - fn=handle_upload, - inputs=[input1, input2, input3, session_state], - outputs=output - ) + run_pipeline_btn = gr.Button("๐Ÿง  'Let AI Do the Work'") # Calls processing logic + + # Processing status placeholder with gr.Row(): - process = gr.Markdown("") - with gr.Column(visible=False) as group_to_show: + process = gr.Markdown("") # Will show dynamic messages/status + # Hidden output section (only shown after pipeline is run) + with gr.Column(visible=False) as group_to_show: + + # Coin reference detection result header with gr.Row(): ref = gr.Markdown("# Coin Reference Detection:", elem_id="centered-title", visible=False) + + # Output image for coin reference detection with gr.Row(): - out_ref_image_p5 = gr.Image(label="๐Ÿช™ Coin Reference Detection", show_share_button=False, show_fullscreen_button=False, height=600, width=800, visible=False, interactive=False, show_download_button=False, container=False) + out_ref_image_p5 = gr.Image( + label="๐Ÿช™ Coin Reference Detection", + show_share_button=False, + show_fullscreen_button=False, + height=600, + width=800, + visible=False, + interactive=False, + show_download_button=False, + container=False + ) + + # Nicely formatted textbox for coin reference ratio value with gr.Row(): with gr.Column(): - z="filler" + z = "filler" # Just padding / alignment element with gr.Column(elem_id="fit-box"): - ref_ratios_str = gr.Textbox(label="๐Ÿช™ Coin Reference Ratios", interactive=False, elem_id='pretty-box', visible=False) + ref_ratios_str = gr.Textbox( + label="๐Ÿช™ Coin Reference Ratios", + interactive=False, + elem_id='pretty-box', + visible=False + ) with gr.Column(): - z="filler" + z = "filler" - with gr.Row(): + # ---------- Seat Dimensions ---------- + with gr.Row(): # Title row for seat dimensions seat = gr.Markdown("# Seat Dimensions:", elem_id="centered-title", visible=False) - with gr.Row(): - out_rim_image_p6 = gr.Image(label="๐Ÿ“ Rim Width", height=600, show_share_button=False, show_fullscreen_button=False, width=800, visible=False, interactive=False, show_download_button=False, container=False) - with gr.Row(): - with gr.Column(): - z="filler" - with gr.Column(elem_id="fit-box"): - seat_measurement_str = gr.Textbox(label="๐Ÿ“ Seat Dimensions:", interactive=False, elem_id='pretty-box', visible=False) - with gr.Column(): - z="filler" - with gr.Row(): + with gr.Row(): # Image row for rim width (seat view) + out_rim_image_p6 = gr.Image( + label="๐Ÿ“ Rim Width", + height=600, + width=800, + show_share_button=False, + show_fullscreen_button=False, + show_download_button=False, + interactive=False, + visible=False, + container=False, + ) + + with gr.Row(): # Centered measurement display for seat + with gr.Column(): # Left spacer + z = "filler" + with gr.Column(elem_id="fit-box"): # Center column for measurement text + seat_measurement_str = gr.Textbox( + label="๐Ÿ“ Seat Dimensions:", + interactive=False, + elem_id='pretty-box', + visible=False + ) + with gr.Column(): # Right spacer + z = "filler" + + # ---------- Rim Dimensions ---------- + with gr.Row(): # Title row for rim dimensions rim = gr.Markdown("# Rim Dimensions:", elem_id="centered-title", visible=False) - with gr.Row(): - out_rimellipse_image_p7 = gr.Image(label="๐Ÿ“ Rim Dimensions", show_share_button=False, show_fullscreen_button=False, height=600, width=800, visible=False, interactive=False, show_download_button=False, container=False) - with gr.Row(): - with gr.Column(): - z="filler" - with gr.Column(elem_id="fit-box"): - rim_measurement_str = gr.Textbox(label="๐Ÿ“ Rim Dimensions:", interactive=False, elem_id='pretty-box', visible=False) - with gr.Column(): - z="filler" - with gr.Row(): + with gr.Row(): # Image showing ellipse-based rim dimensioning + out_rimellipse_image_p7 = gr.Image( + label="๐Ÿ“ Rim Dimensions", + height=600, + width=800, + show_share_button=False, + show_fullscreen_button=False, + show_download_button=False, + interactive=False, + visible=False, + container=False, + ) + + with gr.Row(): # Centered measurement display for rim dimensions + with gr.Column(): # Left spacer + z = "filler" + with gr.Column(elem_id="fit-box"): # Measurement text + rim_measurement_str = gr.Textbox( + label="๐Ÿ“ Rim Dimensions:", + interactive=False, + elem_id='pretty-box', + visible=False + ) + with gr.Column(): # Right spacer + z = "filler" + + # ---------- Rim Height (Inner Top to Outer Bottom) ---------- + with gr.Row(): # Title row for height from inner to outer rim inlen = gr.Markdown("# Length of Rim (Inner Top to Outer Bottom):", elem_id="centered-title", visible=False) - with gr.Row(): - rimheight_image_p8 = gr.Image(label="๐Ÿ“ Red Line Rim", show_share_button=False, show_fullscreen_button=False, height=600, width=800, visible=False, interactive=False, show_download_button=False, container=False) - with gr.Row(): + + with gr.Row(): # Red line visualization image + rimheight_image_p8 = gr.Image( + label="๐Ÿ“ Red Line Rim", + height=600, + width=800, + show_share_button=False, + show_fullscreen_button=False, + show_download_button=False, + interactive=False, + visible=False, + container=False, + ) + + with gr.Row(): # Measurement display for rim height with gr.Column(): - z="filler" + z = "filler" with gr.Column(elem_id="fit-box"): - rim_height_str = gr.Textbox(label="๐Ÿ“ Rim Height:", interactive=False, elem_id='pretty-box', visible=False) + rim_height_str = gr.Textbox( + label="๐Ÿ“ Rim Height:", + interactive=False, + elem_id='pretty-box', + visible=False + ) with gr.Column(): - z="filler" + z = "filler" - with gr.Row(): + # ---------- Hole Width ---------- + with gr.Row(): # Title row for hole width hw = gr.Markdown("# Hole Width:", elem_id="centered-title", visible=False) - with gr.Row(): - holewidth_image_p9 = gr.Image(label="๐Ÿ•ณ๏ธ Hole Width", show_share_button=False, show_fullscreen_button=False, height=600, width=800, visible=False, interactive=False, show_download_button=False, container=False) - with gr.Row(): + + with gr.Row(): # Image showing hole width + holewidth_image_p9 = gr.Image( + label="๐Ÿ•ณ๏ธ Hole Width", + height=600, + width=800, + show_share_button=False, + show_fullscreen_button=False, + show_download_button=False, + interactive=False, + visible=False, + container=False, + ) + + with gr.Row(): # Measurement display for hole width with gr.Column(): - z="filler" + z = "filler" with gr.Column(elem_id="fit-box"): - hole_width_str = gr.Textbox(label="๐Ÿ•ณ๏ธ Hole Width:", interactive=False, elem_id='pretty-box', visible=False) + hole_width_str = gr.Textbox( + label="๐Ÿ•ณ๏ธ Hole Width:", + interactive=False, + elem_id='pretty-box', + visible=False + ) with gr.Column(): - z="filler" + z = "filler" - with gr.Row(): + # ---------- Rim to Hole Top Distance ---------- + with gr.Row(): # Title for rim to hole top hr = gr.Markdown("# Distance from Holes to Top of Inner Rim:", elem_id="centered-title", visible=False) - with gr.Row(): - rim_to_hole_img_p10 = gr.Image(label="โฌ‡๏ธ Rim Line (Open)", show_share_button=False, show_fullscreen_button=False, height=600, width=800, visible=False, interactive=False, show_download_button=False, container=False) - with gr.Row(): + + with gr.Row(): # Image showing arrow from hole to rim top + rim_to_hole_img_p10 = gr.Image( + label="โฌ‡๏ธ Rim Line (Open)", + height=600, + width=800, + show_share_button=False, + show_fullscreen_button=False, + show_download_button=False, + interactive=False, + visible=False, + container=False, + ) + + with gr.Row(): # Measurement display for rim to hole distance with gr.Column(): - z="filler" + z = "filler" with gr.Column(elem_id="fit-box"): - hole_to_top_str = gr.Textbox(label="โฌ‡๏ธ Rim to Hole Dimensions:", interactive=False, elem_id='pretty-box', visible=False) + hole_to_top_str = gr.Textbox( + label="โฌ‡๏ธ Rim to Hole Dimensions:", + interactive=False, + elem_id='pretty-box', + visible=False + ) with gr.Column(): - z="filler" + z = "filler" - with gr.Row(): + # ---------- Closed Lid Direction ---------- + with gr.Row(): # Title for toilet orientation cl = gr.Markdown("# Direction of Closed Lid Toilet:", elem_id="centered-title", visible=False) - with gr.Row(): - ellipse_viz_image_p10 = gr.Image(label="๐Ÿ”„ Ellipse Arrows", show_share_button=False, show_fullscreen_button=False, height=600, width=800, visible=False, interactive=False, show_download_button=False, container=False) - with gr.Row(): + + with gr.Row(): # Arrows on ellipse image + ellipse_viz_image_p10 = gr.Image( + label="๐Ÿ”„ Ellipse Arrows", + height=600, + width=800, + show_share_button=False, + show_fullscreen_button=False, + show_download_button=False, + interactive=False, + visible=False, + container=False, + ) + + with gr.Row(): # Measurement display for toilet orientation with gr.Column(): - z="filler" + z = "filler" with gr.Column(elem_id="fit-box"): - direction_str = gr.Textbox(label="๐Ÿ”„ Direction:", interactive=False, elem_id='pretty-box', visible=False) + direction_str = gr.Textbox( + label="๐Ÿ”„ Direction:", + interactive=False, + elem_id='pretty-box', + visible=False + ) with gr.Column(): - z="filler" + z = "filler" - with gr.Row(): + # ---------- Total Height ---------- + with gr.Row(): # Title for total toilet height th = gr.Markdown("# Total Height of Entire Toilet:", elem_id="centered-title", visible=False) - with gr.Row(): - rim_height_vis_p11 = gr.Image(label="๐Ÿ“ Rim Height on Closed", show_share_button=False, show_fullscreen_button=False, height=600, width=800, visible=False, interactive=False, show_download_button=False, container=False) - with gr.Row(): + + with gr.Row(): # Image showing toilet height on closed lid + rim_height_vis_p11 = gr.Image( + label="๐Ÿ“ Rim Height on Closed", + height=600, + width=800, + show_share_button=False, + show_fullscreen_button=False, + show_download_button=False, + interactive=False, + visible=False, + container=False, + ) + + with gr.Row(): # Measurement display for total height with gr.Column(): - z="filler" + z = "filler" with gr.Column(elem_id="fit-box"): - total_height_str = gr.Textbox(label="๐Ÿ“ Total Height:", interactive=False, elem_id='pretty-box', visible=False) + total_height_str = gr.Textbox( + label="๐Ÿ“ Total Height:", + interactive=False, + elem_id='pretty-box', + visible=False + ) with gr.Column(): - z="filler" + z = "filler" + # Title for remaining lid to holes measurement with gr.Row(): tt = gr.Markdown("# Distance from Top Portion of Toilet to Holes:", elem_id="centered-title", visible=False) + + # Display image showing remaining lid portion with gr.Row(): remaining_lid_img_p12 = gr.Image(label="๐Ÿ“ Remaining Lid Portion", show_share_button=False, show_fullscreen_button=False, height=600, width=800, visible=False, interactive=False, show_download_button=False, container=False) + + # Show predicted measurement for remaining portion with gr.Row(): with gr.Column(): z="filler" @@ -1867,10 +2453,15 @@ def launch_main_app(): with gr.Column(): z="filler" + # Title for top rim width with gr.Row(): wt = gr.Markdown("# Width of Top part of Toilet:", elem_id="centered-title", visible=False) + + # Image showing top rim width with gr.Row(): out_image = gr.Image(label="๐Ÿ“ Top Rim Width", show_share_button=False, show_fullscreen_button=False, height=600, width=800, visible=False, interactive=False, show_download_button=False, container=False) + + # Show top width measurement string with gr.Row(): with gr.Column(): z="filler" @@ -1879,12 +2470,15 @@ def launch_main_app(): with gr.Column(): z="filler" + # Hidden file components for downloadable results or error files download_all_file = gr.File(visible=False) download_all_error = gr.File(visible=False) - + + # Hidden session state holders (used for pipeline steps/status) res_stat1 = gr.State() step1 = gr.State() - + + # Final result section (chassy, toilet, overlapped image and result text) with gr.Column(): with gr.Row(): result_status1 = gr.Textbox(label="", visible=False, interactive=False, elem_id='pretty-box') @@ -1895,25 +2489,33 @@ def launch_main_app(): result_image21 = gr.Image(label="Toilet Image", show_share_button=False, show_fullscreen_button=False, visible=False, interactive=False, show_download_button=False, container=False) with gr.Column(): result_image31 = gr.Image(label="Overlapped Image", show_share_button=False, show_fullscreen_button=False, visible=False, interactive=False, show_download_button=False, container=False) - + + # Hidden column: entire error analysis and comparison UI with gr.Column(visible=False) as col: + # Title gr.Markdown("## ๐ŸŽฏ Prediction Accuracy Analysis", elem_id="centered-title") + # Subheader for reference diagram with gr.Row(): gr.Markdown("### ๐Ÿ–ผ๏ธ Reference Diagram", elem_id="centered-title") + + # Static reference image for measurement guidance with gr.Row(): ref_image = gr.Image(value="./static/reference.jpg", interactive=False, label="Measurement Guide", show_share_button=False, show_fullscreen_button=False, height=600, show_download_button=False, container=False) + # Unit selection (inches or cm) unit_dropdown = gr.Radio(choices=["in", "cm"], label="Select Unit", value="in") + # Subheader for comparison table with gr.Row(): gr.Markdown("### ๐Ÿงพ Predicted vs Actual Values Table", elem_id='centered-title') + # Subheader for predicted vs actual plot with gr.Row(): gr.Markdown("### ๐Ÿ“Š Predicted vs Actual Comparison") with gr.Column(): - # First row: a, b, c, d + # First row: input a, b, c, d (predicted and actual) with gr.Row(): with gr.Column(): a1 = gr.Number(label="a (Predicted)", interactive=False) @@ -1928,8 +2530,8 @@ def launch_main_app(): with gr.Column(): d1 = gr.Number(label="d (Predicted)", interactive=False) d2 = gr.Number(label="d (Actual)") - - # Second row: e, f, g, h + + # Second row: input e, f, g, h (predicted and actual) with gr.Row(): with gr.Column(): e1 = gr.Number(label="e (Predicted)", interactive=False) @@ -1944,11 +2546,15 @@ def launch_main_app(): with gr.Column(): h1 = gr.Number(label="h (Predicted)", interactive=False) h2 = gr.Number(label="h (Actual)") - + + # Submit button to trigger error analysis submit_btn = gr.Button("๐ŸŽฏ Evaluate Accuracy") + + # Display progress/status of error evaluation with gr.Row(): progress = gr.Markdown("") - + + # Hidden section for error result and visualization with gr.Column(visible=False) as error: with gr.Row(): with gr.Column(): @@ -1959,10 +2565,12 @@ def launch_main_app(): error_plot = gr.Image(label="๐Ÿ“‰ Error Plot", height=400, width=600, interactive=False, visible=False, show_download_button=False, container=False, show_share_button=False, show_fullscreen_button=False) with gr.Row(): avg_error_text = gr.Textbox(label="๐ŸŽฏ Average Error %", interactive=False, elem_id='centered-title') - + + # Hidden states to hold pipeline results and flow tracking res_stat = gr.State() step = gr.State() - + + # Final output images and status (chassy, toilet, overlay) with gr.Column(): with gr.Row(): result_status = gr.Textbox(label="", visible=False, interactive=False, elem_id='pretty-box') @@ -1973,40 +2581,54 @@ def launch_main_app(): result_image2 = gr.Image(label="Toilet Image", visible=False, interactive=False, show_download_button=False, container=False, show_share_button=False, show_fullscreen_button=False) with gr.Column(): result_image3 = gr.Image(label="Overlapped Image", visible=False, interactive=False, show_download_button=False, container=False, show_share_button=False, show_fullscreen_button=False) - + + # When Run Pipeline button is clicked: show the group section run_pipeline_btn.click(fn=lambda: gr.update(visible=True), outputs=group_to_show) + + # Update the processing message while running the pipeline run_pipeline_btn.click(fn=lambda: gr.update(value="๐Ÿ”„ Processing... (Please Wait)"), outputs=process) + + # When submit button is clicked: show calculation status submit_btn.click(fn=lambda: gr.update(value="๐Ÿ”„ Calculating Error Percentages... (Please Wait)"), outputs=progress) import gradio as gr - + + # Function to compute predicted measurements based on selected unit def get_predictions_by_unit(unit, closed_remaining_in, top_to_hole_line_inch, - hole_width_inch, out_rimellipse_inch, rim_height_inch, out_dist_in): - # Compute values in inches + hole_width_inch, out_rimellipse_inch, rim_height_inch, out_dist_in): + # Compute each parameter in inches a1 = abs(closed_remaining_in - top_to_hole_line_inch) b1 = top_to_hole_line_inch c1 = hole_width_inch - d1 = float(out_rimellipse_inch["right_inner"]) * 2 - e1 = float(out_rimellipse_inch["down_inner"]) * 2 - f1 = float(out_rimellipse_inch["right_outer"]) * 2 + d1 = float(out_rimellipse_inch["right_inner"]) * 2 # Inner ellipse width + e1 = float(out_rimellipse_inch["down_inner"]) * 2 # Inner ellipse height + f1 = float(out_rimellipse_inch["right_outer"]) * 2 # Outer ellipse width g1 = rim_height_inch h1 = out_dist_in + # If user selected inches, return raw inch values if unit == "in": return [round(a1, 2), round(b1, 2), round(c1, 2), round(d1, 2), round(e1, 2), round(f1, 2), round(g1, 2), round(h1, 2)] + [gr.update(value="โœ… Process Complete.")] + # If user selected cm, convert inch values to cm and return elif unit == "cm": return [round(x * 2.54, 2) for x in [a1, b1, c1, d1, e1, f1, g1, h1]] + [gr.update(value="โœ… Process Complete.")] + # If unknown unit, return default zeros else: return [0.0] * 8 + [gr.update(value="โœ… Process Complete.")] + # Function to compare predicted and ground truth measurements and compute percentage error def compare_measurements(a1, b1, c1, d1, e1, f1, g1, h1, a2, b2, c2, d2, e2, f2, g2, h2): + # Convert all values to float (if they are passed as strings) a2, b2, c2, d2, e2, f2, g2, h2 = float(a2), float(b2), float(c2), float(d2), float(e2), float(f2), float(g2), float(h2) a1, b1, c1, d1, e1, f1, g1, h1 = float(a1), float(b1), float(c1), float(d1), float(e1), float(f1), float(g1), float(h1) + + # Helper function to compute error percentage def error(gt, pred): if gt == 0: return "N/A" return f"{abs(gt - pred) / gt * 100:.2f}%" + # Calculate individual errors for each parameter errors = { "a": error(a2, a1), "b": error(b2, b1), @@ -2018,6 +2640,7 @@ def launch_main_app(): "h": error(h2, h1) } + # Compute valid numerical errors for average calculation valid_errors = [ abs(gt - pred) / gt * 100 for gt, pred in [ @@ -2025,28 +2648,33 @@ def launch_main_app(): ] if gt != 0 ] + # Calculate average error across all valid parameters avg_error = f"{sum(valid_errors)/len(valid_errors):.2f}%" if valid_errors else "N/A" return errors, f"โœ… Average Error: {avg_error}" + # Wrapper function to update predictions whenever unit or inputs change def update_preds(unit, closed_remaining_in_p11, top_to_hole_line_inch_p10, hole_width_inch_p9, out_rimellipse_inch_p7, rim_height_inch_p8, out_dist_in): return get_predictions_by_unit(unit, closed_remaining_in_p11, top_to_hole_line_inch_p10,hole_width_inch_p9, out_rimellipse_inch_p7,rim_height_inch_p8, out_dist_in) - + + # Set up the Gradio interaction: when dropdown changes, update prediction values unit_dropdown.change( fn=update_preds, inputs=[unit_dropdown, closed_remaining_in_p11, top_to_hole_line_inch_p10, hole_width_inch_p9, out_rimellipse_inch_p7, rim_height_inch_p8, out_dist_in], outputs=[a1, b1, c1, d1, e1, f1, g1, h1, process] ) - + import matplotlib.pyplot as plt from PIL import Image import io - + + # Function to generate error bar graph comparing predicted vs actual values def plot_error_graph(a2, b2, c2, d2, e2, f2, g2, h2, a1, b1, c1, d1, e1, f1, g1, h1): labels = list("abcdefgh") pred_vals = [float(x) for x in [a1, b1, c1, d1, e1, f1, g1, h1]] true_vals = [float(x) for x in [a2, b2, c2, d2, e2, f2, g2, h2]] + # Calculate percentage error for each parameter errors = [] for pred, true in zip(pred_vals, true_vals): if true == 0: @@ -2055,15 +2683,15 @@ def launch_main_app(): err = abs(pred - true) / true * 100 errors.append(err) - # Plotting + # Plotting error bars fig, ax = plt.subplots(figsize=(8, 4)) - ax.bar(labels, errors, color="#f05a28") + ax.bar(labels, errors, color="#f05a28") # Use orange for bars ax.set_ylim(0, max(errors) * 1.2 if errors else 1) ax.set_ylabel("Error (%)") ax.set_xlabel("Parameter") ax.set_title("Individual Error per Parameter") - # Convert plot to PIL Image + # Convert Matplotlib plot to PIL image buf = io.BytesIO() plt.tight_layout() plt.savefig(buf, format='png') @@ -2072,19 +2700,22 @@ def launch_main_app(): img = Image.open(buf) return gr.update(value=img, visible=True) - + + # Function to download all results in a zipped file: image + text summary def download_all_results(a1, b1, c1, d1, e1, f1, g1, h1, a2, b2, c2, d2, e2, f2, g2, h2, avg_error_text, error_plot): + # Skip download if no valid error if "N/A" in avg_error_text: return None, gr.update(value="") + import os, zipfile, tempfile from PIL import Image + # Create temporary directory to store files before zipping temp_dir = tempfile.mkdtemp() zip_path = os.path.join(tempfile.gettempdir(), "error_all_results.zip") with zipfile.ZipFile(zip_path, "w") as zipf: - - # Save images if present + # Save images (only error plot for now) image_dict = {"error plot": error_plot} for name, img in image_dict.items(): @@ -2095,7 +2726,7 @@ def launch_main_app(): img.save(img_path) zipf.write(img_path, arcname=f"{name}.png") - # Save a combined text summary + # Save a detailed text summary of measurements text_lines = [ "๐Ÿ“‹ Measurement Summary", "----------------------", @@ -2129,110 +2760,129 @@ def launch_main_app(): return zip_path, gr.update(value="") - import matplotlib.pyplot as plt - import matplotlib.patches as patches - import numpy as np - from PIL import Image - import os + # Import necessary libraries + import matplotlib.pyplot as plt # For creating plots and figures + import matplotlib.patches as patches # For drawing polygons and ellipses on plot + import numpy as np # For numerical operations (array min/max etc.) + from PIL import Image # For image I/O with Pillow + import os # For interacting with the file system def draw_shapes_with_zorder( - polygons_data=None, - ellipses_data=None, - res_stat=True, - step=0, - out_ref_ratios_p5=None, - filename="chasis_image.png", - fig_size=(20, 15), - transparent_bg=False + polygons_data=None, # List of dictionaries representing polygons (each with 'points', 'facecolor', etc.) + ellipses_data=None, # List of dictionaries representing ellipses (each with 'center_x', 'width', etc.) + res_stat=True, # Boolean to control whether to draw anything + step=0, # Vertical shift step (multiplied by reference ratio) + out_ref_ratios_p5=None, # Dictionary with reference ratios, e.g., {'closed': 1.0} + filename="chasis_image.png", # Output filename for the saved figure + fig_size=(18.1, 15), # Size of the matplotlib figure + transparent_bg=False # Whether to make the saved image have transparent background ): + # If result status is False, return None and skip drawing if not res_stat: return None + # Compute vertical shift based on the 'step' and 'closed' reference ratio stepsize = step * out_ref_ratios_p5.get('closed', 1.0) if out_ref_ratios_p5 else 1.0 + # Create figure and axis for drawing fig, ax = plt.subplots(figsize=fig_size) - ax.set_aspect('equal', adjustable='box') - ax.set_axis_off() - ax.set_facecolor('#f0f0f0') + ax.set_aspect('equal', adjustable='box') # Maintain equal aspect ratio + ax.set_axis_off() # Hide axes + ax.set_facecolor('#f0f0f0') # Light gray background + # Initialize bounding box tracking variables min_x, max_x = float('inf'), float('-inf') min_y, max_y = float('inf'), float('-inf') + # List to store all coordinates for bounding box computation all_coords = [] + + # ----------------- Draw Polygons ----------------- if polygons_data: for poly_info in polygons_data: - points = poly_info.get('points') + points = poly_info.get('points') # Get points of the polygon if points: + # Apply vertical shift (stepsize) to all y-coordinates shifted_points = [(x, y + stepsize) for x, y in points] - all_coords.extend(shifted_points) + all_coords.extend(shifted_points) # Store for bounds computation + + # Create and style the polygon patch polygon = patches.Polygon( shifted_points, closed=True, - facecolor=poly_info.get('facecolor', '#ADD8E6'), - edgecolor=poly_info.get('edgecolor', 'blue'), - linewidth=poly_info.get('linewidth', 2), - zorder=poly_info.get('zorder', 1) + facecolor=poly_info.get('facecolor', '#ADD8E6'), # Default face color = light blue + edgecolor=poly_info.get('edgecolor', 'blue'), # Default edge color = blue + linewidth=poly_info.get('linewidth', 2), # Default border width + zorder=poly_info.get('zorder', 1) # Layering order ) - ax.add_patch(polygon) + ax.add_patch(polygon) # Add polygon to plot + # ----------------- Draw Ellipses ----------------- if ellipses_data: for ellipse_info in ellipses_data: + # Extract and apply vertical shift to ellipse center e_cx = ellipse_info.get('center_x') e_cy = ellipse_info.get('center_y') + stepsize e_w = ellipse_info.get('width') e_h = ellipse_info.get('height') e_angle = ellipse_info.get('angle', 0) + # Skip if any critical ellipse parameter is missing if e_cx is None or e_cy is None or e_w is None or e_h is None: continue + # Add ellipse bounding box corners to coord list for computing limits all_coords.append((e_cx - e_w / 2, e_cy - e_h / 2)) all_coords.append((e_cx + e_w / 2, e_cy + e_h / 2)) all_coords.append((e_cx - e_w / 2, e_cy + e_h / 2)) all_coords.append((e_cx + e_w / 2, e_cy - e_h / 2)) + # Create and style the ellipse patch ellipse = patches.Ellipse( (e_cx, e_cy), e_w, e_h, angle=e_angle, - facecolor=ellipse_info.get('facecolor', '#FFD700'), - edgecolor=ellipse_info.get('edgecolor', 'orange'), - linewidth=ellipse_info.get('linewidth', 2), - zorder=ellipse_info.get('zorder', 1) + facecolor=ellipse_info.get('facecolor', '#FFD700'), # Default face color = gold + edgecolor=ellipse_info.get('edgecolor', 'orange'), # Default edge color = orange + linewidth=ellipse_info.get('linewidth', 2), # Default border width + zorder=ellipse_info.get('zorder', 1) # Layering order ) - ax.add_patch(ellipse) + ax.add_patch(ellipse) # Add ellipse to plot + # ----------------- Auto-fit Axis Limits ----------------- if all_coords: - coords_array = np.array(all_coords) - min_x, min_y = np.min(coords_array, axis=0) - max_x, max_y = np.max(coords_array, axis=0) + coords_array = np.array(all_coords) # Convert list to NumPy array + min_x, min_y = np.min(coords_array, axis=0) # Find min x, y + max_x, max_y = np.max(coords_array, axis=0) # Find max x, y + # Add 20% padding around bounding box padding_x = (max_x - min_x) * 0.2 if (max_x - min_x) > 0 else 1.0 padding_y = (max_y - min_y) * 0.2 if (max_y - min_y) > 0 else 1.0 + # Set axis limits accordingly ax.set_xlim(min_x - padding_x, max_x + padding_x) ax.set_ylim(min_y - padding_y, max_y + padding_y) else: + # Default axis range when no shapes present ax.set_xlim(0, 10) ax.set_ylim(0, 8) - plt.savefig(filename, dpi=300, bbox_inches='tight', pad_inches=0, transparent=transparent_bg) - plt.close(fig) # Close to avoid memory leak - - res = Image.open(filename) # Return as PIL.Image object for Gradio - return gr.update(visible=True, value=res) + # ----------------- Save and Return Image ----------------- + plt.savefig(filename, dpi=300, bbox_inches='tight', pad_inches=0, transparent=transparent_bg) # Save figure + plt.close(fig) # Always close the figure to avoid memory leaks - # --- Example Usage --- + res = Image.open(filename) # Open saved image as PIL.Image + return gr.update(visible=True, value=res) # Return it in a Gradio-compatible format - # Define data for multiple polygons + # State to store multiple polygon definitions my_polygons = gr.State([ { 'points': [(0.56, 14.72), (0.56, 19.05), (19.5, 19.05), (19.5, 14.53)], - 'facecolor': "#E6ADAD", - 'edgecolor': 'brown', - 'linewidth': 0, - 'zorder': 2 + 'facecolor': "#E6ADAD", # Fill color + 'edgecolor': 'brown', # Border color + 'linewidth': 0, # No border line + 'zorder': 2 # Drawing order }, { 'points': [(3, 14.72), (3, 9.45), (17, 9.45), (17, 14.6)], @@ -2257,7 +2907,7 @@ def launch_main_app(): } ]) - # Define data for multiple ellipses + # State to store multiple ellipse definitions my_ellipses = gr.State([ { 'center_x': 10.0, 'center_y': 9.45, 'width': 9.0, 'height': 12.0, 'angle': 0, @@ -2274,50 +2924,62 @@ def launch_main_app(): 'zorder': 3 } ]) - + + # Required imports import matplotlib.pyplot as plt import matplotlib.patches as patches import numpy as np from PIL import Image import os + # ========================= + # Function to draw the custom shapes (polygons & ellipses) with vertical shift support + # ========================= def draw_shapes_with_zorder2( - polygons_data=None, - ellipses_data=None, - res_stat=True, - step=0, out_ref_ratios_p5=None, - filename="toilet_image.png", - title="Custom Shapes Drawing", + a,b,g, + polygons_data=None, # List of polygon specs + ellipses_data=None, # List of ellipse specs + res_stat=True, # Whether to proceed with drawing or not + step=0, # Step used to calculate vertical shift + out_ref_ratios_p5=None, # Dictionary for px/cm conversion for scaling + filename="toilet_image.png", # Output image file path + title="Custom Shapes Drawing" # Plot title (unused but helpful for debug) ): if not res_stat: - return None + return None # If drawing is disabled, return nothing - fig, ax = plt.subplots(figsize=(20, 15)) - ax.set_axis_off() - ax.set_aspect('equal', adjustable='box') - ax.set_facecolor('#f0f0f0') + # Setup figure and axes for plotting + fig, ax = plt.subplots(figsize=(a+b+g, 15)) + ax.set_axis_off() # Remove axis lines + ax.set_aspect('equal', adjustable='box') # Keep equal aspect ratio + ax.set_facecolor('#f0f0f0') # Background color + # Determine vertical pixel shift from step using reference ratio ratio = out_ref_ratios_p5.get("closed", 1.0) if out_ref_ratios_p5 else 1.0 vertical_shift = int(step * ratio) + # Bounding box variables min_x, max_x = float('inf'), float('-inf') min_y, max_y = float('inf'), float('-inf') + # Draw polygons if provided if polygons_data: for poly_info in polygons_data: points = poly_info.get('points') if not points: - continue + continue # Skip if no points defined - # Apply vertical shift to y-coordinates - shifted_points = [(x, y - vertical_shift) for (x, y) in points] + # Shift each point vertically + shifted_points = [(x, (y - vertical_shift)) for (x, y) in points] + # Update bounding box for x, y in shifted_points: min_x = min(min_x, x) max_x = max(max_x, x) min_y = min(min_y, y) max_y = max(max_y, y) + # Create and add polygon patch polygon = patches.Polygon( shifted_points, closed=True, @@ -2329,6 +2991,7 @@ def launch_main_app(): ) ax.add_patch(polygon) + # Draw ellipses if provided if ellipses_data: for ellipse_info in ellipses_data: e_cx = ellipse_info.get('center_x') @@ -2337,16 +3000,20 @@ def launch_main_app(): e_h = ellipse_info.get('height') e_angle = ellipse_info.get('angle', 0) + # Skip if required values are missing if e_cx is None or e_cy is None or e_w is None or e_h is None: continue + # Apply vertical shift e_cy_shifted = e_cy - vertical_shift + # Update bounding box min_x = min(min_x, e_cx - e_w / 2) max_x = max(max_x, e_cx + e_w / 2) min_y = min(min_y, e_cy_shifted - e_h / 2) max_y = max(max_y, e_cy_shifted + e_h / 2) + # Create and add ellipse patch ellipse = patches.Ellipse( (e_cx, e_cy_shifted), e_w, @@ -2360,247 +3027,324 @@ def launch_main_app(): ) ax.add_patch(ellipse) + # Adjust axis limits using computed bounding box if min_x != float('inf') and max_x != float('-inf'): padding_x = (max_x - min_x) * 0.2 if (max_x - min_x) > 0 else 1.0 padding_y = (max_y - min_y) * 0.2 if (max_y - min_y) > 0 else 1.0 ax.set_xlim(min_x - padding_x, max_x + padding_x) ax.set_ylim(min_y - padding_y, max_y + padding_y) else: + # Default limits if nothing was drawn ax.set_xlim(0, 10) ax.set_ylim(0, 8) + # Save the resulting image to disk plt.savefig(filename, dpi=300, bbox_inches='tight') plt.close(fig) + # Load saved image into PIL and return for Gradio UI res = Image.open(filename) return gr.update(visible=True, value=res) + # Import symbolic computation tools from sympy import sympy from sympy import symbols, Eq, solve, N + # Function to find the points of tangency from an external point (px, py) + # to an ellipse centered at (cx, cy) with axes lengths a and b def find_tangent_points_from_external(px, py, cx, cy, a, b): + # Define symbolic variables x, y = symbols('x y') - # Ellipse equation + # Ellipse equation in canonical form ellipse_eq_sym = Eq((x - cx)**2 / a**2 + (y - cy)**2 / b**2, 1) - # Polar equation for (px, py) + # Equation for polar line from point (px, py) to the ellipse + # This represents the tangent condition polar_eq_sym = Eq( (x - cx) * (px - cx) / a**2 + (y - cy) * (py - cy) / b**2, 1 ) - # Solve the system to find tangent points + # Solve the system of equations: ellipse + polar line solutions = solve([ellipse_eq_sym, polar_eq_sym], (x, y)) tangent_points = [] for sol in solutions: - # Check the type of solution to handle both dict and tuple cases + # Two types of solutions might be returned: dict or tuple + if isinstance(sol, dict): # If solution is a dictionary {x: val_x, y: val_y} if all(s.is_real for s in sol.values()): + # Only accept real solutions tangent_points.append((float(N(sol[x])), float(N(sol[y])))) + elif isinstance(sol, tuple) and len(sol) == 2: # If solution is a tuple (val_x, val_y) - # Make sure sol[0] and sol[1] are SymPy expressions before calling .is_real if sol[0].is_real and sol[1].is_real: tangent_points.append((float(N(sol[0])), float(N(sol[1])))) + # Return the list of real tangent points return tangent_points + # Wrapper function to compute one "left" and one "right" tangent point + # from two external points to a given ellipse def get_specific_tangent_points( center_x, center_y, width, height, point1_x, point1_y, point2_x, point2_y ): + # Convert width and height to ellipse semi-axes a = width / 2 b = height / 2 - # Find tangent points for P1 + # Find all tangent points from external point 1 tangent_points_p1 = find_tangent_points_from_external(point1_x, point1_y, center_x, center_y, a, b) - # Find tangent points for P2 + # Find all tangent points from external point 2 tangent_points_p2 = find_tangent_points_from_external(point2_x, point2_y, center_x, center_y, a, b) - # --- Select the "left" tangent point for P1 --- - # Sort by x-coordinate to easily pick left/right - tangent_points_p1.sort(key=lambda p: p[0]) - selected_tangent_point_p1 = tangent_points_p1[0] # The one with smaller x-coordinate is "left" + # --- Select the "left" tangent point for point 1 --- + tangent_points_p1.sort(key=lambda p: p[0]) # Sort by x to pick leftmost + selected_tangent_point_p1 = tangent_points_p1[0] - # --- Select the "right" tangent point for P2 --- - tangent_points_p2.sort(key=lambda p: p[0]) - selected_tangent_point_p2 = tangent_points_p2[1] # The one with larger x-coordinate is "right" + # --- Select the "right" tangent point for point 2 --- + tangent_points_p2.sort(key=lambda p: p[0]) # Sort by x to pick rightmost + selected_tangent_point_p2 = tangent_points_p2[1] + # Return the selected tangent points return selected_tangent_point_p2, selected_tangent_point_p1 - # --- Example Usage --- + # --- Main function for drawing and guiding polygon visualization --- def guide(a2, b2, c2, d2, e2, f2, g2, h2, res_stat): + # If toggle is off, return nothing if not res_stat: return None, None + # Convert all inputs to float for computation a2, b2, c2, d2, e2, f2, g2, h2 = float(a2), float(b2), float(c2), float(d2), float(e2), float(f2), float(g2), float(h2) - # Ellipse parameters + # Define fixed center for ellipse ellipse_center_x = 10 ellipse_center_y = 10 - ellipse_width = f2 # a = 5 - ellipse_height = g2 # b = 3 - # External points (always above the ellipse, not exactly on top of center) - point_1_x, point_1_y = 10 - (h2/2), 10 + (e2/2) + a2 + b2 + # Ellipse dimensions + ellipse_width = f2 # major axis length (a*2) + ellipse_height = g2 # minor axis length (b*2) + # Define external points above the ellipse for tangents + point_1_x, point_1_y = 10 - (h2/2), 10 + (e2/2) + a2 + b2 point_2_x, point_2_y = 10 + (h2/2), 10 + (e2/2) + a2 + b2 + # Get the left and right tangent points tangent_points = get_specific_tangent_points( ellipse_center_x, ellipse_center_y, ellipse_width, ellipse_height, point_1_x, point_1_y, point_2_x, point_2_y ) + # Define polygon (irregular quadrilateral) from external and tangent points polygons = [ { - 'points': [(10 - (h2/2), 10 + (e2/2) + a2 + b2), (10 + (h2/2), 10 + (e2/2) + a2 + b2), (tangent_points[0][0], tangent_points[0][1]), (tangent_points[1][0], tangent_points[1][1])], - 'facecolor': '#ADD8E6', # Peach - 'edgecolor': 'brown', - 'linewidth': 0, + 'points': [ + (10 - (h2/2), 10 + (e2/2) + a2 + b2), # External point 1 (left) + (10 + (h2/2), 10 + (e2/2) + a2 + b2), # External point 2 (right) + (tangent_points[0][0], tangent_points[0][1]), # Right tangent + (tangent_points[1][0], tangent_points[1][1]) # Left tangent + ], + 'facecolor': '#ADD8E6', # Light blue fill + 'edgecolor': 'brown', # Border color + 'linewidth': 0, # No border line 'label': 'Irregular Quad', - 'zorder': 2 # This will be on top of the green triangle + 'zorder': 2 # Above other shapes with lower zorder } ] - # Define data for multiple ellipses + # Define ellipse shapes to overlay on canvas ellipses = [ { - 'center_x': 10.0, 'center_y': 10, 'width': d2, 'height': e2, 'angle': 0, - 'facecolor': '#f0f0f0', # Light blue + 'center_x': 10.0, + 'center_y': 10, + 'width': d2, # Outer ellipse width + 'height': e2, # Outer ellipse height + 'angle': 0, + 'facecolor': '#f0f0f0', # Very light gray 'edgecolor': 'blue', 'linewidth': 0, 'label': 'Rotated Ellipse', - 'zorder': 4 # This will be on top of both polygons + 'zorder': 4 # Top-most }, { - 'center_x': 10.0, 'center_y': 10, 'width': f2, 'height': g2, 'angle': 0, - 'facecolor': '#ADD8E6', # Pink + 'center_x': 10.0, + 'center_y': 10, + 'width': f2, # Inner ellipse width + 'height': g2, # Inner ellipse height + 'angle': 0, + 'facecolor': '#ADD8E6', # Light blue 'edgecolor': 'purple', 'linewidth': 0, 'label': 'Small Circle', - 'zorder': 3 # This will be on top of everything else + 'zorder': 3 }, { - 'center_x': 10 - (c2/2) + 0.75, 'center_y': 10 + (e2/2) + b2, 'width': 0.75, 'height': 0.75, 'angle': 0, - 'facecolor': '#f0f0f0', # Light blue + 'center_x': 10 - (c2/2) + 0.75, + 'center_y': 10 + (e2/2) + b2, + 'width': 0.75, + 'height': 0.75, + 'angle': 0, + 'facecolor': '#f0f0f0', 'edgecolor': 'blue', 'linewidth': 0, 'label': 'Rotated Ellipse', - 'zorder': 4 # This will be on top of both polygons + 'zorder': 4 }, { - 'center_x': 10 + (c2/2) - 0.75, 'center_y': 10 + (e2/2) + b2, 'width': 0.75, 'height': 0.75, 'angle': 0, - 'facecolor': '#f0f0f0', # Pink + 'center_x': 10 + (c2/2) - 0.75, + 'center_y': 10 + (e2/2) + b2, + 'width': 0.75, + 'height': 0.75, + 'angle': 0, + 'facecolor': '#f0f0f0', 'edgecolor': 'purple', 'linewidth': 0, 'label': 'Small Circle', - 'zorder': 4 # This will be on top of everything else + 'zorder': 4 } ] + + # Return both shapes: polygon and ellipses return polygons, ellipses from PIL import Image + # Function to overlay two images (foreground onto background) with centering and opacity def overlay_images_centered(background_path, foreground_path, res_stat, step, out_ref_ratios_p5): - if not res_stat: + if not res_stat: # If product doesn't fit, skip overlay return None - # Load images from NumPy arrays + # Convert NumPy arrays to PIL Images and ensure they're in RGBA format bg = Image.fromarray(background_path).convert("RGBA") fg = Image.fromarray(foreground_path).convert("RGBA") - # Make #f0f0f0 pixels transparent + # Make specific light gray color (#f0f0f0) transparent in the foreground new_data = [ (255, 255, 255, 0) if pixel[:3] == (240, 240, 240) else pixel for pixel in fg.getdata() ] fg.putdata(new_data) - # Get sizes + # Get width and height of both images bg_w, bg_h = bg.size fg_w, fg_h = fg.size + + # Retrieve real-world px/cm ratio from previous part for accurate shift ratio = out_ref_ratios_p5.get("closed", 1.0) + + # Vertical shift is scaled by this ratio to maintain real-world measurement vertical_shift = int(step * ratio) - # Create a blank transparent image (same size as background) + # Create a transparent canvas same size as background shifted_fg = Image.new("RGBA", bg.size, (255, 255, 255, 0)) - # Center the foreground, shifted UP by vertical_shift pixels + # Calculate position to center the foreground and shift vertically upwards pos_x = (bg_w - fg_w) // 2 pos_y = (bg_h - fg_h) // 2 - vertical_shift - shifted_fg.paste(fg, (pos_x, pos_y), fg) + shifted_fg.paste(fg, (pos_x, pos_y), fg) # Paste using alpha mask - # Reduce opacity to 50% + # Reduce opacity of foreground to 50% by adjusting the alpha channel alpha = shifted_fg.split()[3].point(lambda a: int(a * 0.5)) shifted_fg.putalpha(alpha) - # Composite foreground onto background + # Overlay the semi-transparent foreground onto the background result = Image.alpha_composite(bg, shifted_fg).convert("RGB") + # Return the final image and make it visible in the UI return gr.update(visible=True, value=result) - + + # Logic to determine if product fits user's washroom based on input parameters def checkstatus(b2, c2, g2): - hole_rad = 0.375 - max_range = 18.1 + hole_rad = 0.375 # Radius of the reference hole (in inches) + max_range = 18.1 # Maximum allowed top clearance (inches) + + # Calculate top and bottom vertical bounds of fit area top_val = max_range - 0.31 - hole_rad - bottom_val = top_val - 1.4 + (hole_rad * 2) + bottom_val = top_val - 1.4 + hole_rad * 2 + + # Minimum and maximum acceptable width values (inches) min_width = 4.5 + hole_rad max_width = 11.52 - hole_rad + # Loop through step values from 0.0 to 0.5 in 0.05 increments max_step = 0.5 step_size = 0.05 - steps = int(max_step / step_size) + 1 # Includes 0.0 step + steps = int(max_step / step_size) + 1 # Includes step=0 for i in range(steps): step = i * step_size + # Check if input values fall within allowed fit boundaries if float(bottom_val) < float(g2) + float(b2) + float(step) < float(top_val) and float(min_width) < float(c2) < float(max_width): msg = "โœ… Our Product can fit in your Washroom." return gr.update(value=msg, visible=True), True, step + # If not within any valid range, declare not fit msg = "โŒ Unfortunately we cannot fit our product in your washroom." return gr.update(value=msg, visible=True), False, None - + + # On submit: compare measurements, generate JSON, and plot error graph submit_btn.click( fn=lambda *args: ( - compare_measurements(*args)[0], - compare_measurements(*args)[1], - (plot_error_graph(*args)) + compare_measurements(*args)[0], # JSON result + compare_measurements(*args)[1], # Average error text + (plot_error_graph(*args)) # Error plot ), inputs=[a1, b1, c1, d1, e1, f1, g1, h1, a2, b2, c2, d2, e2, f2, g2, h2], outputs=[result_json, avg_error_text, error_plot] ).\ - then(fn=download_all_results, + then(fn=download_all_results, # Then: prepare downloadable zip with results and update progress inputs=[a1, b1, c1, d1, e1, f1, g1, h1, a2, b2, c2, d2, e2, f2, g2, h2, avg_error_text, error_plot], outputs=[download_all_error, progress]).\ - then(upload_zip_error, inputs=[email, download_all_error], outputs=[]).\ - then(checkstatus, inputs=[b2, c2, g2], outputs=[result_status, res_stat, step]).\ - then(fn=draw_shapes_with_zorder, inputs=[my_polygons, my_ellipses, res_stat, step, out_ref_ratios_p5], outputs=[result_image1]).\ - then(fn=guide, inputs=[a2, b2, c2, d2, e2, f2, g2, h2, res_stat], outputs=[polygons, ellipses]).\ - then(fn=draw_shapes_with_zorder2, inputs=[polygons, ellipses, res_stat, step, out_ref_ratios_p5], outputs=[result_image2]).\ - then(fn=overlay_images_centered, inputs=[result_image1, result_image2, res_stat, step, out_ref_ratios_p5], outputs=[result_image3]) - + then(upload_zip_error, # Then: upload the error ZIP to email + inputs=[email, download_all_error], outputs=[]).\ + then(checkstatus, # Then: check if the product fits and determine optimal vertical step + inputs=[b2, c2, g2], outputs=[result_status, res_stat, step]).\ + then(fn=draw_shapes_with_zorder, # Then: draw background result image using input shape data + inputs=[my_polygons, my_ellipses, res_stat, step, out_ref_ratios_p5], outputs=[result_image1]).\ + then(fn=guide, # Then: generate guide overlay polygons and ellipses for fitting aid + inputs=[a2, b2, c2, d2, e2, f2, g2, h2, res_stat], outputs=[polygons, ellipses]).\ + then(fn=draw_shapes_with_zorder2, # Then: draw the guide shapes onto the second image + inputs=[a2, b2, g2, polygons, ellipses, res_stat, step, out_ref_ratios_p5], outputs=[result_image2]).\ + then(fn=overlay_images_centered, # Then: overlay guide image onto base result to create final preview + inputs=[result_image1, result_image2, res_stat, step, out_ref_ratios_p5], outputs=[result_image3]) + + # On submit, also reveal the general error message area in UI submit_btn.click(fn=lambda: gr.update(visible=True), inputs=[], outputs=[error]) def download_all_results_combined( - input1, input2, input3, gallery_segmentation_p4, out_ref_image_p5, ref_ratios_str, out_rim_image_p6, seat_measurement_str, - out_rimellipse_image_p7, rim_measurement_str, rimheight_image_p8, rim_height_str, holewidth_image_p9, hole_width_str, - rim_to_hole_img_p10, hole_to_top_str, ellipse_viz_image_p10, direction_str, rim_height_vis_p11, total_height_str, - remaining_lid_img_p12, remaining_str, out_image, top_width_str, a1, b1, c1, d1, e1, f1, g1, h1 + input1, input2, input3, + gallery_segmentation_p4, out_ref_image_p5, ref_ratios_str, + out_rim_image_p6, seat_measurement_str, + out_rimellipse_image_p7, rim_measurement_str, + rimheight_image_p8, rim_height_str, + holewidth_image_p9, hole_width_str, + rim_to_hole_img_p10, hole_to_top_str, + ellipse_viz_image_p10, direction_str, + rim_height_vis_p11, total_height_str, + remaining_lid_img_p12, remaining_str, + out_image, top_width_str, + a1, b1, c1, d1, e1, f1, g1, h1 ): import os, zipfile, tempfile from PIL import Image + # Create a temporary directory to store intermediate image/text files temp_dir = tempfile.mkdtemp() + + # Define the path for the final zip file inside system temp directory zip_path = os.path.join(tempfile.gettempdir(), "all_results.zip") + # Start creating a zip archive with zipfile.ZipFile(zip_path, "w") as zipf: - # Save images if present + # Dictionary mapping variable names to image objects (can be PIL or NumPy arrays) image_dict = { "input1": input1, "input2": input2, @@ -2618,30 +3362,31 @@ def launch_main_app(): "out_image": out_image, } + # Loop through each image, save it as PNG if valid, and add it to the zip archive for name, img in image_dict.items(): if isinstance(img, np.ndarray): - img = Image.fromarray(img) - if isinstance(img, Image.Image): - img_path = os.path.join(temp_dir, f"{name}.png") - img.save(img_path) - zipf.write(img_path, arcname=f"{name}.png") + img = Image.fromarray(img) # Convert NumPy array to PIL Image if needed + if isinstance(img, Image.Image): # Only process if it's a valid PIL Image + img_path = os.path.join(temp_dir, f"{name}.png") # Temp image path + img.save(img_path) # Save image to temp directory + zipf.write(img_path, arcname=f"{name}.png") # Add to zip with name.png - # Save a combined text summary + # Prepare a text summary with all the measurement-related strings and variables text_lines = [ "๐Ÿ“‹ Measurement Summary", "----------------------", - f"ref_ratios_str:\n{ref_ratios_str}", - f"seat_measurement_str:\n{seat_measurement_str}", - f"rim_measurement_str:\n{rim_measurement_str}", - f"rim_height_str:\n{rim_height_str}", - f"hole_width_str:\n{hole_width_str}", - f"hole_to_top_str:\n{hole_to_top_str}", - f"direction_str:\n{direction_str}", - f"total_height_str:\n{total_height_str}", - f"remaining_str:\n{remaining_str}", - f"top_width_str:\n{top_width_str}", + f"ref_ratios_str:\n{ref_ratios_str}", # Reference ratios from Part 5 + f"seat_measurement_str:\n{seat_measurement_str}", # Seat width (Part 6) + f"rim_measurement_str:\n{rim_measurement_str}", # Rim ellipse (Part 7) + f"rim_height_str:\n{rim_height_str}", # Rim height (Part 8) + f"hole_width_str:\n{hole_width_str}", # Hole width (Part 9) + f"hole_to_top_str:\n{hole_to_top_str}", # Rim to top (Part 10) + f"direction_str:\n{direction_str}", # Direction analysis (Part 10) + f"total_height_str:\n{total_height_str}", # Rim-to-ground height (Part 11) + f"remaining_str:\n{remaining_str}", # Remaining lid space (Part 12) + f"top_width_str:\n{top_width_str}", # Top width (Part 13) "", - "๐Ÿ“ Predicted Values:", + "๐Ÿ“ Predicted Values:", # Model-predicted values f"a: {a1}", f"b: {b1}", f"c: {c1}", @@ -2652,74 +3397,79 @@ def launch_main_app(): f"h: {h1}", "", ] + + # Save the text summary into a .txt file inside the temporary directory summary_path = os.path.join(temp_dir, "summary.txt") with open(summary_path, "w", encoding="utf-8") as f: - f.write("\n".join(text_lines)) + f.write("\n".join(text_lines)) # Write summary contents line-by-line + + # Add the summary.txt file to the zip archive zipf.write(summary_path, arcname="summary.txt") + # Return path to the created zip file and reset a Gradio state (usually download message) return zip_path, gr.update(value="") # ๐Ÿ”— Pipeline chaining starting from Part 4 - run_pipeline_btn.click(fn=segment_and_overlay_all_p4, + run_pipeline_btn.click(fn=segment_and_overlay_all_p4, # Step 1: Segment and overlay masks for all models inputs=[ - input1, - input2, - input3, - models_holes_p2, - models_rim_p2, - models_coinref_p2, - device_p2 + input1, # Open seat image + input2, # Open no seat image + input3, # Closed lid image + models_holes_p2, # Hole model + models_rim_p2, # Rim model + models_coinref_p2, # Coin/matchbox reference model + device_p2 # Device: 'cuda' or 'cpu' ], outputs=[ - gallery_segmentation_p4, - binary_masks_p4, - image_dict_p4, + gallery_segmentation_p4, # Gallery showing overlays + binary_masks_p4, # Segmentation masks + image_dict_p4, # Rotated/processed images ]).\ - then(fn=detect_and_plot_reference_p5, + then(fn=detect_and_plot_reference_p5, # Step 2: Detect coin/matchbox and calculate px/cm ratios inputs=[ image_dict_p4, binary_masks_p4 ], outputs=[ - out_ref_image_p5, # overlay image - out_ref_ratios_p5, # ref_ratios to pass to next part - ref_ratios_str, - ref + out_ref_image_p5, # Overlay image with reference + out_ref_ratios_p5, # Dictionary with px/cm ratios + ref_ratios_str, # Stringified ratios for display + ref # Raw reference size ]).\ - then(fn=analyze_rim_intersections_p6, + then(fn=analyze_rim_intersections_p6, # Step 3: Measure open seat width (ellipse intersect) inputs=[ image_dict_p4, binary_masks_p4, out_ref_ratios_p5 ], outputs=[ - out_rim_image_p6, - out_rim_measurements_p6, - out_rim_measurements_cm_p6, - out_rim_measurements_inch_p6, - seat_measurement_str, - seat + out_rim_image_p6, # Image with measurement lines + out_rim_measurements_p6, # Raw measurements in px + out_rim_measurements_cm_p6, # Converted to cm + out_rim_measurements_inch_p6, # Converted to inches + seat_measurement_str, # Display string for seat + seat # Measurement object ]).\ - then(fn=analyze_rim_ellipse_red_p7, + then(fn=analyze_rim_intersections_p7, # Step 4: Measure inner ellipse rim width inputs=[ image_dict_p4, binary_masks_p4, out_ref_ratios_p5 ], outputs=[ - out_rimellipse_image_p7, - out_rimellipse_ui_p7, - out_rimellipse_cm_p7, - out_rimellipse_inch_p7, - inner_top_p7, - dir_down_p7, - dir_right_p7, - rim_measurement_str, - rim + out_rimellipse_image_p7, # Visual result + out_rimellipse_ui_p7, # UI plot + out_rimellipse_cm_p7, # cm value + out_rimellipse_inch_p7, # inch value + inner_top_p7, # Topmost point of inner rim + dir_down_p7, # Direction vector (down) + dir_right_p7, # Direction vector (right) + rim_measurement_str, # Display string + rim # Measurement object ]).\ - then(fn=run_rim_height_analysis_p8, + then(fn=run_rim_height_analysis_p8, # Step 5: Analyze rim height using open seat image inputs=[ - btn_rimheight_p8, # Trigger button as dummy input + btn_rimheight_p8, # Dummy trigger button image_dict_p4, binary_masks_p4, out_ref_ratios_p5, @@ -2727,18 +3477,18 @@ def launch_main_app(): dir_down_p7 ], outputs=[ - rimheight_text_p8, - rimheight_image_p8, - rim_height_px_p8, - rim_height_cm_p8, - rim_height_inch_p8, - inner_top_p8, - rim_height_str, - inlen + rimheight_text_p8, # Textual explanation + rimheight_image_p8, # Annotated image + rim_height_px_p8, # px height + rim_height_cm_p8, # cm height + rim_height_inch_p8, # inch height + inner_top_p8, # Same as p7, but passed onward + rim_height_str, # Display string + inlen # Measurement object ]).\ - then(fn=analyze_hole_width_perpendicular_p9, + then(fn=analyze_hole_width_perpendicular_p9, # Step 6: Measure hole width perpendicular to rim inputs=[ - btn_measure_holewidth_p9, # trigger button dummy input + btn_measure_holewidth_p9, # Dummy trigger binary_masks_p4, out_ref_ratios_p5, image_dict_p4, @@ -2746,19 +3496,19 @@ def launch_main_app(): inner_top_p7 ], outputs=[ - holewidth_image_p9, + holewidth_image_p9, # Annotated result hole_width_px_p9, hole_width_cm_p9, hole_width_inch_p9, angle_deg_p9, - pt_min_p9, - pt_max_p9, + pt_min_p9, # Leftmost point + pt_max_p9, # Rightmost point hole_width_str, hw ]).\ - then(fn=compute_top_to_hole_distance_p10, + then(fn=compute_top_to_hole_distance_p10, # Step 7: Vertical distance between top rim and hole inputs=[ - btn_top_to_hole_p10, # trigger button + btn_top_to_hole_p10, # Trigger inner_top_p8, pt_min_p9, pt_max_p9, @@ -2767,7 +3517,7 @@ def launch_main_app(): image_dict_p4 ], outputs=[ - rim_to_hole_img_p10, + rim_to_hole_img_p10, # Annotated image top_to_hole_line_px_p10, top_to_hole_line_cm_p10, top_to_hole_line_inch_p10, @@ -2777,23 +3527,23 @@ def launch_main_app(): hole_to_top_str, hr ]).\ - then(fn=analyze_closed_rim_orientation_p10, + then(fn=analyze_closed_rim_orientation_p10, # Step 8: Fit ellipse on closed rim to get orientation inputs=[ - btn_ellipse_orient_p10, # trigger + btn_ellipse_orient_p10, binary_masks_p4, image_dict_p4 ], outputs=[ - ellipse_viz_image_p10, - ellipse_angle_deg_p10, + ellipse_viz_image_p10, # Ellipse fit result + ellipse_angle_deg_p10, # Angle of major axis ellipse_center_p10, - ellipse_dir_down_p10, - ellipse_dir_right_p10, - updated_binary_masks_p4, + ellipse_dir_down_p10, # New downward vector + ellipse_dir_right_p10, # New rightward vector + updated_binary_masks_p4, # Updated with filtered contours direction_str, cl ]).\ - then(fn=analyze_rim_height_on_closed_p11, + then(fn=analyze_rim_height_on_closed_p11, # Step 9: Estimate full rim height using closed image inputs=[ btn_rim_height_closed_p11, binary_masks_p4, @@ -2814,7 +3564,7 @@ def launch_main_app(): total_height_str, th ]).\ - then(fn=draw_remaining_closed_portion_p12, + then(fn=draw_remaining_closed_portion_p12, # Step 10: Draw estimated remaining closed portion inputs=[ btn_draw_remaining_p12, pt_start_p11, @@ -2831,7 +3581,7 @@ def launch_main_app(): remaining_str, tt ]).\ - then(fn=analyze_top_rim_width_p13, + then(fn=analyze_top_rim_width_p13, # Step 11: Measure top closed rim width inputs=[ btn_measure, binary_masks_p4, @@ -2852,120 +3602,169 @@ def launch_main_app(): top_width_str, wt ]).\ - then(fn=update_preds, - inputs=[unit_dropdown, closed_remaining_in_p11, top_to_hole_line_inch_p10, hole_width_inch_p9, out_rimellipse_inch_p7, rim_height_inch_p8, out_dist_in], - outputs=[a1, b1, c1, d1, e1, f1, g1, h1, process]).\ - then(fn=download_all_results_combined, + then(fn=update_preds, # Step 12: Compute final values and update UI + inputs=[ + unit_dropdown, + closed_remaining_in_p11, + top_to_hole_line_inch_p10, + hole_width_inch_p9, + out_rimellipse_inch_p7, + rim_height_inch_p8, + out_dist_in + ], + outputs=[ + a1, b1, c1, d1, e1, f1, g1, h1, process + ]).\ + then(fn=download_all_results_combined, # Step 13: Prepare downloadable zip inputs=[ - input1, input2, input3, gallery_segmentation_p4, out_ref_image_p5, ref_ratios_str, out_rim_image_p6, seat_measurement_str, - out_rimellipse_image_p7, rim_measurement_str, rimheight_image_p8, rim_height_str, holewidth_image_p9, hole_width_str, - rim_to_hole_img_p10, hole_to_top_str, ellipse_viz_image_p10, direction_str, rim_height_vis_p11, total_height_str, - remaining_lid_img_p12, remaining_str, out_image, top_width_str, a1, b1, c1, d1, e1, f1, g1, h1 + input1, input2, input3, + gallery_segmentation_p4, + out_ref_image_p5, ref_ratios_str, + out_rim_image_p6, seat_measurement_str, + out_rimellipse_image_p7, rim_measurement_str, + rimheight_image_p8, rim_height_str, + holewidth_image_p9, hole_width_str, + rim_to_hole_img_p10, hole_to_top_str, + ellipse_viz_image_p10, direction_str, + rim_height_vis_p11, total_height_str, + remaining_lid_img_p12, remaining_str, + out_image, top_width_str, + a1, b1, c1, d1, e1, f1, g1, h1 ], - outputs=[download_all_file, progress]).\ - then(upload_zip, inputs=[email, download_all_file], outputs=[col]).\ - then(checkstatus, inputs=[b1, c1, g1], outputs=[result_status1, res_stat1, step1]).\ - then(fn=draw_shapes_with_zorder, inputs=[my_polygons, my_ellipses, res_stat1, step1, out_ref_ratios_p5], outputs=[result_image11]).\ - then(fn=guide, inputs=[a1, b1, c1, d1, e1, f1, g1, h1, res_stat1], outputs=[polygons1, ellipses1]).\ - then(fn=draw_shapes_with_zorder2, inputs=[polygons1, ellipses1, res_stat1, step1, out_ref_ratios_p5], outputs=[result_image21]).\ - then(fn=overlay_images_centered, inputs=[result_image11, result_image21, res_stat1, step1, out_ref_ratios_p5], outputs=[result_image31]) - + outputs=[download_all_file, progress] + ).\ + then(upload_zip, # Step 14: Upload the result to remote storage (e.g. Supabase/GDrive) + inputs=[email, download_all_file], + outputs=[col] + ).\ + then(checkstatus, # Step 15: Verify that all required outputs are valid + inputs=[b1, c1, g1], + outputs=[result_status1, res_stat1, step1] + ).\ + then(fn=draw_shapes_with_zorder, # Step 16: Draw polygons/ellipses (step 1) + inputs=[my_polygons, my_ellipses, res_stat1, step1, out_ref_ratios_p5], + outputs=[result_image11] + ).\ + then(fn=guide, # Step 17: Generate additional shapes to draw (step 2) + inputs=[a1, b1, c1, d1, e1, f1, g1, h1, res_stat1], + outputs=[polygons1, ellipses1] + ).\ + then(fn=draw_shapes_with_zorder2, # Step 18: Draw second layer of shapes + inputs=[a1, b1, g1, polygons1, ellipses1, res_stat1, step1, out_ref_ratios_p5], + outputs=[result_image21] + ).\ + then(fn=overlay_images_centered, # Step 19: Final overlay combining result_image11 and 21 + inputs=[result_image11, result_image21, res_stat1, step1, out_ref_ratios_p5], + outputs=[result_image31]) + +# Create a Gradio Blocks interface titled "๐Ÿงช Toilet Segmentation & Measurement App" with gr.Blocks(title="๐Ÿงช Toilet Segmentation & Measurement App") as full_app_interface: - auth_state = gr.State(False) # Used to track login state + + # Define a Gradio State to store whether the user is authenticated or not + auth_state = gr.State(False) # Initially set to False (not logged in) + # ================================ + # ๐Ÿ” LOGIN SECTION + # ================================ with gr.Column("๐Ÿ” Login") as login: + # Section Title gr.Markdown("### Login", elem_id='centered-title') + + # Email input row with gr.Row(): - with gr.Column(): + with gr.Column(): # Left spacer filler = '' - with gr.Column(): + with gr.Column(): # Center column with email input email = gr.Text(label="Email") - with gr.Column(): + with gr.Column(): # Right spacer filler = '' + + # Password input row with gr.Row(): - with gr.Column(): + with gr.Column(): # Left spacer filler = '' - with gr.Column(): + with gr.Column(): # Center column with password input password = gr.Text(label="Password", type="password") - with gr.Column(): + with gr.Column(): # Right spacer filler = '' + # Login button row with gr.Row(): - with gr.Column(): + with gr.Column(): # Left spacer filler = '' - with gr.Column(): + with gr.Column(): # Center column with Login button login_btn = gr.Button("Login") - with gr.Column(): + with gr.Column(): # Right spacer filler = '' + # Authentication status message row with gr.Row(): - with gr.Column(): + with gr.Column(): # Left spacer filler = '' - with gr.Column(): + with gr.Column(): # Center column with status message auth_msg = gr.Textbox(label="Status", interactive=False) - with gr.Column(): + with gr.Column(): # Right spacer filler = '' + # Function to handle login logic def handle_login(email, password): + # Call Supabase login helper with credentials msg, success = supa_login(email, password) + # Return: message, show main app if success, update state, hide login if success return msg, gr.update(visible=success), success, gr.update(visible=not success) + # ================================ + # ๐ŸŒ MAIN APP SECTION (hidden by default) + # ================================ with gr.Column(visible=False) as protected_content: - launch_main_app() + launch_main_app() # Call to external function that builds the full app - login_btn.click(handle_login, inputs=[email, password], outputs=[auth_msg, protected_content, auth_state, login]) + # Attach login button to login handler + login_btn.click( + handle_login, + inputs=[email, password], + outputs=[auth_msg, protected_content, auth_state, login] + ) - # Automatically show full app after login + # Automatically toggle visibility of main app when authentication state changes def toggle_app(auth): return gr.update(visible=auth) + # Bind state change to toggle visibility auth_state.change(toggle_app, inputs=auth_state, outputs=protected_content) - - with gr.Row(): - gr.Markdown("") - with gr.Row(): - gr.Markdown("") - with gr.Row(): - gr.Markdown("\nFor any queries, Feel free to contact ๐Ÿ“ง core.atsc@gmail.com", elem_id='centered-title') - with gr.Row(): - gr.Markdown( - """ - --- - """) - - with gr.Row(): - with gr.Column(scale=1, min_width=200): - with gr.Row(): - gr.Image( - value="static/logo.png", # Replace with your actual logo path or URL - height=40, - width=40, - show_label=False, - show_download_button=False, - interactive=False, - container=False, - show_share_button=False, - show_fullscreen_button=False, - scale=0 - ) - gr.Markdown( - "ยฉ 2025 HapiHygi Innovations Pvt. Ltd.
All rights reserved.", - elem_id="footer-left", - ) - with gr.Column(scale=2, min_width=400): + # ================================ + # ๐Ÿ”ป FOOTER SECTION + # ================================ + with gr.Row(elem_id='custom_footer'): + with gr.Column(): + # Line 1 - Centered contact info + gr.Markdown(" ") + gr.Markdown(" ") gr.Markdown( - """ -
- ๐Ÿ‘จโ€๐Ÿ’ป - Created by - Heet Savaliya, - PDEU -
- """, - elem_id="footer-right", + "
For any queries, feel free to contact ๐Ÿ“ง core.atsc@gmail.com
", + elem_id="footer-contact" ) - -# ๐Ÿš€ Launch App + + # Line 2 - Left company info, Right creator info + with gr.Row(): + with gr.Column(scale=1, min_width=300): + gr.Markdown( + "
ยฉ 2025 HapiHygi Innovations Pvt. Ltd.   All rights reserved.
", + elem_id="footer-left" + ) + with gr.Column(scale=1, min_width=300): + gr.Markdown( + """ +
+ ๐Ÿ‘จโ€๐Ÿ’ป + Created by: + Heet Savaliya, PDEU +
+ """, + elem_id="footer-right" + ) + +# ๐Ÿš€ Launch the full app if __name__ == "__main__": full_app_interface.launch()