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(
- """
-