HapiHygi-Innovations's picture
Update app.py
2ed1cde verified
Raw
History Blame Contribute Delete
174 kB
# ===================================
# 🔁 PART 1: Silent Package Install and Library Imports
# ==================================
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", # 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
]
# 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 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 (fetched from environment variables)
import os
model_urls_p2 = {
"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 the models locally after download
model_paths_p2 = {
"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
}
# 👇 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): # 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}") # Use cached file to save bandwidth/time
# 👇 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)) # 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)) # 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)) # 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,
"model_rim": model_rim,
"model_coinref": model_coinref
}
# ================================
# 🚀 Load models once when the script starts
# ================================
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
# ================================
# 📦 Extract models and device for easy global access
# ================================
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
# ================================
# 📸 PART 3: Start new session for each user
# ================================
# ✅ 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 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] # 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)
# ================================
# ✅ 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(), # 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):
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
# --- 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
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() # 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 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, # 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 = [] # 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] # 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
# 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 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): # 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]) # Set column title
axes[i][0].text(-50, 128, rows[i], rotation=90, va='center') # Label row on left side
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 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 # 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
# 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) - 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) # 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 # 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' # 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.") # Need both rim edges
# Sort by area: outer will be largest, inner next
outer, inner = sorted(contours, key=cv2.contourArea, reverse=True)[:2]
# 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
# 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: # 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: # Ensure it points rightward
dir_right *= -1
# 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 # 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
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
# 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"
# 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",
"Right Inner": f"{fmt(rim_cm['right_inner'])} cm | {fmt(rim_inch['right_inner'])} in",
"Right Outer": f"{fmt(rim_cm['right_outer'])} cm | {fmt(rim_inch['right_outer'])} in",
}
# 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)]
):
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)
# 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'))
# Visualize final annotated image
plt.figure(figsize=(6, 6))
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)
draw_label(inner_right, rim_ui["Right Inner"], -offset)
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 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"
)
# 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 # 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
# 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.")
# Sort contours by area and select the two largest (outer and inner)
outer, inner = sorted(contours, key=cv2.contourArea, reverse=True)[:2]
# 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
# Ensure angle reflects long axis vertically
if MA < ma:
angle += 90
# 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 # 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 # 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
# 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
last_valid = None
for step in range(1, max_steps):
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
# 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 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_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_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",
"Right Inner": f"{fmt(rim_cm['right_inner'])} cm | {fmt(rim_inch['right_inner'])} in",
"Right Outer": f"{fmt(rim_cm['right_outer'])} cm | {fmt(rim_inch['right_outer'])} in",
}
# 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_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'))
# Display image with annotations
plt.figure(figsize=(6, 6))
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)
draw_label(inner_right, rim_ui["Right Inner"], -offset)
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) # Open buffer as image
# Formatted string summary of measurements
rim_str = (
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: 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)
# ================================
import gradio as gr
import numpy as np
import cv2
import math
from PIL import Image as PILImage_p8
from io import BytesIO
import matplotlib.pyplot as plt
# ============================
# 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 # 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 outside bounds, stop
break
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 if not found
# ============================
# 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 # 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]): # Out of bounds check
break
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 if not found
# ============================
# Main analysis function to compute rim height and visualize it
# ============================
def run_rim_height_analysis_p8(
_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, 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 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) # 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) # 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) # 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 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 # 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, # 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"] # 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
# --- 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 # 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): # 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 # Best span found is hole width in pixels
# --- Compute orientation angle of detected line ---
dir_line = pt_max_p9 - pt_min_p9
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) # Midpoint between two points
text_pos = (mid[0] - 30, mid[1] - 30) # Text offset for visibility
overlay = image.copy() # For semi-transparent text box
font = cv2.FONT_HERSHEY_SIMPLEX
(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) # Save annotated image to buffer
buf.seek(0)
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 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)
# =============================
from PIL import Image as PILImage_p10
from io import BytesIO
def compute_top_to_hole_distance_p10(
_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 # construct matrix from direction vectors
b = p2 - p1 # vector between starting points
if np.linalg.matrix_rank(A) < 2:
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) # 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.") # alert if intersection fails
return None, None, None, None, None, None, None
# --- 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
# --- 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() # 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), # 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
)
# ================================
# 🌀 PART 10: Ellipse-Based Rim Orientation (Closed Lid, Stateless)
# ================================
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) # 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] # Get all foreground pixels in row
if len(x_coords) == 0:
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 as 255-mask
# ----------------------
# 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 the rim mask after cleaning
binary_masks_p4["rim"]["closed"] = clean_mask
# --------------------------
# 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) # Take largest contour
assert len(rim_contour) >= 5, "❌ Need at least 5 points to fit an ellipse!"
# 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)]) # Center of ellipse
# ---------------------------
# 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 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 direction)
if dir_right[0] < 0:
dir_right *= -1
# 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
# --------------------------
# 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)
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")
plt.axis("off")
buf = BytesIO()
plt.savefig(buf, format="png")
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), # 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
)
# ================================
# 🧮 PART 11: Rim Height Along Downward Direction (Closed Rim, Stateless)
# ================================
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, # 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]) # furthest found point
elif pt2 is not None:
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]) # 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
# --- 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
# --- 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!" # 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) # 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
# --- 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 # 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()
# 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) # 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")
plt.axis("off")
buf = BytesIO()
plt.savefig(buf, format='png')
plt.close()
buf.seek(0)
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), # 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)
# ================================
# 🧱 PART 12: Draw Remaining Closed Lid Portion (Stateless)
# ================================
def draw_remaining_closed_portion_p12(
_trigger,
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) # 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 # 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() # 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 # 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) # 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), # 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
)
# ================================
# 🧱 PART 13: Top Rim Width Measurement (Stateless)
# ================================
def analyze_top_rim_width_p13(
_trigger,
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 # Adjust angle for image coordinate system
px_per_cm = ref_ratios_p5['closed']
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)
if steep: x0, y0, x1, y1 = y0, x0, y1, x1
swapped = False
if x0 > x1:
x0, x1 = x1, x0
y0, y1 = y1, y0
swapped = True
dx = x1 - x0
dy = abs(y1 - y0)
error = dx / 2
ystep = 1 if y0 < y1 else -1
y = y0
for x in range(x0, x1 + 1):
pt = (y, x) if steep else (x, y)
points.append(pt)
error -= dy
if error < 0:
y += ystep
error += dx
if swapped: points.reverse()
return points
# 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]))
if 0 <= x < w and 0 <= y < h and mask[y, x] == 1:
pt_top = (x, y)
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 for feedback
# -------------------------------
fig, ax = plt.subplots(figsize=(8, 8))
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 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 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: 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 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}" # Folder path inside the bucket
# Read uploaded file bytes
with open(zip_file.name, "rb") as f:
file_bytes = f.read()
# 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: 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
# 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}" # Target location in bucket
# Read file as binary
with open(zip_file.name, "rb") as f:
file_bytes = f.read()
# 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
# ===== Supabase Login Authentication =====
# def supa_login(email, password):
# try:
# # 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 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 chassis image data
# ================================
my_polygons = [
{
'points': [(0.56, 14.72), (0.56, 19.05), (19.5, 19.05), (19.5, 14.53)],
'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', # Same fill as above
'edgecolor': 'darkgreen',
'linewidth': 0,
'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', # Matches background
'edgecolor': 'brown',
'linewidth': 0,
'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 # Also renders on top
}
]
# ================================
# 🟡 Define chassis image data
# ================================
my_ellipses = [
{
'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 # 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', # Light red
'edgecolor': 'purple',
'linewidth': 0,
'zorder': 3 # Under the inner ellipse but above most polygons
}
]
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)
email = gr.Textbox(value="no_login_user", visible=False)
password = gr.Textbox(value="", 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"):
# --- 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/1qCwzaePLexOF8Ti-O-bhC-oPsB14lbFj/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")
wrong1 = gr.Image(value="./static/im1.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():
gr.Markdown("❌ Wrong")
wrong2 = gr.Image(value="./static/im2.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():
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" # 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"
# --- 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()
hole_width_inch_p9 = gr.State()
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()
top_to_hole_line_inch_p10 = gr.State()
angle_down_deg_p10 = gr.State()
angle_perp_deg_p10 = gr.State()
intersection_point_p10 = gr.State()
btn_ellipse_orient_p10 = gr.State()
ellipse_angle_deg_p10 = gr.State()
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()
rim_height_in_p11 = gr.State()
full_rim_height_cm_p11 = gr.State()
full_rim_height_in_p11 = gr.State()
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()
out_pt2 = gr.State()
out_dist_px = gr.State()
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], # Initialize models and device
outputs=[models_holes_p2, models_rim_p2, models_coinref_p2, device_p2],
queue=False # Avoid blocking queue during load
)
# Injecting custom CSS into the Gradio app using gr.HTML
gr.HTML("""<style>
/* Center-align title text */
#centered-title {
text-align: center;
width: 100%
}
/* Style for footer text */
#footer-text {
text-align: center;
font-size: 14px;
color: #666;
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #ddd;
line-height: 1.6
}
/* Custom styling for textarea input (monospace box) */
#pretty-box textarea {
font-size: 16px;
font-family: monospace;
border: 2px solid #ccc;
border-radius: 12px;
padding: 10px 14px;
resize: none;
height: 50px;
line-height: 1.4;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
min-width: 300px;
max-width: 150%;
}
/* Label styling inside the pretty-box */
#pretty-box label {
text-align: center;
font-size: 18px;
}
/* Utility box for fitting controls */
#fit-box {
min-width: 200px;
margin: auto;
}
/* Override Gradio block styling to make background transparent */
.gr-block.gr-group {
background-color: inherit !important;
padding: 16px;
}
#custom_footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
width: 100%;
padding: 10px 20px;
}
/* Add bottom margin/padding to main content so it's not overlapped */
body, .gradio-container, #root {
padding-bottom: 150px; /* Adjust based on your footer's height */
}
footer[class*="svelte"] {
display: none !important;
}
#footer-contact, #footer-left, #footer-right {
font-size: 14px;
line-height: 1.5;
}
#footer-contact a,
#footer-right a {
color: #61dafb; /* Stylish light-blue link color */
text-decoration: none;
}
#footer-contact a:hover,
#footer-right a:hover {
text-decoration: underline;
}
/* Logout row positioned to the right */
#logout-row {
display: flex;
justify-content: flex-end; /* ✅ Push content to the right */
padding: 10px;
}
/* Style for logout button */
#logout-btn {
background-color: #ef4444;
color: white;
font-weight: bold;
padding: 8px 16px;
border-radius: 6px;
max-width: 100px;
text-align: center;
}
</style>""")
# ===============================================
# 🔄 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]
# 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)") # 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)")
uploader2 = gr.UploadButton("Upload Image", file_types=["image"])
input2 = gr.Image(height=500, width=800, label="Preview", interactive=False, visible=False)
filename2 = gr.Markdown()
rotte2 = gr.Button("Rotate", visible=False)
with gr.Column():
gr.Markdown("### 📸 Closed Lid Toilet")
uploader3 = gr.UploadButton("Upload Image", file_types=["image"])
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) # If no file, return nothing and hide widgets
filepath = file_obj.name
img = Image.open(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() # Placeholder for later step output (stateful variable)
# =========================
# Next Step Trigger Section
# =========================
# Main button to trigger the full pipeline
with gr.Row():
run_pipeline_btn = gr.Button("🧠 'Let AI Do the Work'") # Calls processing logic
# Processing status placeholder
with gr.Row():
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
)
# Nicely formatted textbox for coin reference ratio value
with gr.Row():
with gr.Column():
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
)
with gr.Column():
z = "filler"
# ---------- Seat Dimensions ----------
with gr.Row(): # Title row for seat dimensions
seat = gr.Markdown("# Seat Dimensions:", elem_id="centered-title", visible=False)
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(): # 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(): # 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"
with gr.Column(elem_id="fit-box"):
rim_height_str = gr.Textbox(
label="📏 Rim Height:",
interactive=False,
elem_id='pretty-box',
visible=False
)
with gr.Column():
z = "filler"
# ---------- Hole Width ----------
with gr.Row(): # Title row for hole width
hw = gr.Markdown("# Hole Width:", elem_id="centered-title", visible=False)
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"
with gr.Column(elem_id="fit-box"):
hole_width_str = gr.Textbox(
label="🕳️ Hole Width:",
interactive=False,
elem_id='pretty-box',
visible=False
)
with gr.Column():
z = "filler"
# ---------- 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(): # 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"
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
)
with gr.Column():
z = "filler"
# ---------- 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(): # 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"
with gr.Column(elem_id="fit-box"):
direction_str = gr.Textbox(
label="🔄 Direction:",
interactive=False,
elem_id='pretty-box',
visible=False
)
with gr.Column():
z = "filler"
# ---------- 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(): # 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"
with gr.Column(elem_id="fit-box"):
total_height_str = gr.Textbox(
label="📏 Total Height:",
interactive=False,
elem_id='pretty-box',
visible=False
)
with gr.Column():
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"
with gr.Column(elem_id="fit-box"):
remaining_str = gr.Textbox(label="📏 Remaining Portion Dimensions:", interactive=False, elem_id='pretty-box', visible=False)
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"
with gr.Column(elem_id="fit-box"):
top_width_str = gr.Textbox(label="📏 Top Width:", interactive=False, elem_id='pretty-box', visible=False)
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')
with gr.Row():
with gr.Column():
result_image11 = gr.Image(label="Chassy Image", show_share_button=False, show_fullscreen_button=False, visible=False, interactive=False, show_download_button=False, container=False)
with gr.Column():
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: input a, b, c, d (predicted and actual)
with gr.Row():
with gr.Column():
a1 = gr.Number(label="a (Predicted)", interactive=False)
a2 = gr.Number(label="a (Actual)")
with gr.Column():
b1 = gr.Number(label="b (Predicted)", interactive=False)
b2 = gr.Number(label="b (Actual)")
with gr.Row():
with gr.Column():
c1 = gr.Number(label="c (Predicted)", interactive=False)
c2 = gr.Number(label="c (Actual)")
with gr.Column():
d1 = gr.Number(label="d (Predicted)", interactive=False)
d2 = gr.Number(label="d (Actual)")
# Second row: input e, f, g, h (predicted and actual)
with gr.Row():
with gr.Column():
e1 = gr.Number(label="e (Predicted)", interactive=False)
e2 = gr.Number(label="e (Actual)")
with gr.Column():
f1 = gr.Number(label="f (Predicted)", interactive=False)
f2 = gr.Number(label="f (Actual)")
with gr.Row():
with gr.Column():
g1 = gr.Number(label="g (Predicted)", interactive=False)
g2 = gr.Number(label="g (Actual)")
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():
gr.Markdown("🧾 Individual Error %", elem_id='centered-title')
result_json = gr.JSON(label="🧾 Individual Error %")
with gr.Column():
gr.Markdown("📉 Error Plot", elem_id='centered-title')
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')
with gr.Row():
with gr.Column():
result_image1 = gr.Image(label="Chassy Image", visible=False, interactive=False, show_download_button=False, container=False, show_share_button=False, show_fullscreen_button=False)
with gr.Column():
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 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 # 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),
"c": error(c2, c1),
"d": error(d2, d1),
"e": error(e2, e1),
"f": error(f2, f1),
"g": error(g2, g1),
"h": error(h2, h1)
}
# Compute valid numerical errors for average calculation
valid_errors = [
abs(gt - pred) / gt * 100
for gt, pred in [
(a2, a1), (b2, b1), (c2, c1), (d2, d1), (e2, e1), (f2, f1), (g2, g1), (h2, h1)
] 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:
errors.append(0)
else:
err = abs(pred - true) / true * 100
errors.append(err)
# Plotting error bars
fig, ax = plt.subplots(figsize=(8, 4))
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 Matplotlib plot to PIL image
buf = io.BytesIO()
plt.tight_layout()
plt.savefig(buf, format='png')
plt.close(fig)
buf.seek(0)
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 (only error plot for now)
image_dict = {"error plot": error_plot}
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")
# Save a detailed text summary of measurements
text_lines = [
"📋 Measurement Summary",
"----------------------",
"📏 Predicted Values:",
f"a: {a1}",
f"b: {b1}",
f"c: {c1}",
f"d: {d1}",
f"e: {e1}",
f"f: {f1}",
f"g: {g1}",
f"h: {h1}",
"",
"📏 Actual Values:",
f"a: {a2}",
f"b: {b2}",
f"c: {c2}",
f"d: {d2}",
f"e: {e2}",
f"f: {f2}",
f"g: {g2}",
f"h: {h2}",
"",
"📏 Average Error:",
f"average error %: {avg_error_text}",
]
summary_path = os.path.join(temp_dir, "error_analysis.txt")
with open(summary_path, "w", encoding="utf-8") as f:
f.write("\n".join(text_lines))
zipf.write(summary_path, arcname="error_analysis.txt")
return zip_path, gr.update(value="")
# 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, # 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') # 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') # 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) # Store for bounds computation
# Create and style the polygon patch
polygon = patches.Polygon(
shifted_points,
closed=True,
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) # 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'), # 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) # Add ellipse to plot
# ----------------- Auto-fit Axis Limits -----------------
if all_coords:
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)
# ----------------- 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
res = Image.open(filename) # Open saved image as PIL.Image
return gr.update(visible=True, value=res) # Return it in a Gradio-compatible format
# 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", # 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)],
'facecolor': '#E6ADAD',
'edgecolor': 'darkgreen',
'linewidth': 0,
'zorder': 1
},
{
'points': [(4.24, 18.74), (9.68, 18.74), (9.68, 17.34), (4.24, 17.34)],
'facecolor': '#f0f0f0',
'edgecolor': 'brown',
'linewidth': 0,
'zorder': 4
},
{
'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
}
])
# 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,
'facecolor': '#f0f0f0',
'edgecolor': 'blue',
'linewidth': 0,
'zorder': 4
},
{
'center_x': 10.0, 'center_y': 9.45, 'width': 14.0, 'height': 17.0, 'angle': 0,
'facecolor': '#E6ADAD',
'edgecolor': 'purple',
'linewidth': 0,
'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(
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 # If drawing is disabled, return nothing
# Setup figure and axes for plotting
fig, ax = plt.subplots(figsize=(20, 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 # Skip if no points defined
# 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,
facecolor=poly_info.get('facecolor', '#ADD8E6'),
edgecolor=poly_info.get('edgecolor', 'blue'),
linewidth=poly_info.get('linewidth', 2),
label=poly_info.get('label', 'Polygon'),
zorder=poly_info.get('zorder', 1)
)
ax.add_patch(polygon)
# Draw ellipses if provided
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)
# 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,
e_h,
angle=e_angle,
facecolor=ellipse_info.get('facecolor', '#f0f0f0'),
edgecolor=ellipse_info.get('edgecolor', '#f0f0f0'),
linewidth=ellipse_info.get('linewidth', 2),
label=ellipse_info.get('label', 'Ellipse'),
zorder=ellipse_info.get('zorder', 1)
)
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 in canonical form
ellipse_eq_sym = Eq((x - cx)**2 / a**2 + (y - cy)**2 / b**2, 1)
# 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 of equations: ellipse + polar line
solutions = solve([ellipse_eq_sym, polar_eq_sym], (x, y))
tangent_points = []
for sol in solutions:
# 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)
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 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 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 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 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
# --- 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)
# Define fixed center for ellipse
ellipse_center_x = 10
ellipse_center_y = 10
# 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), # 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 # Above other shapes with lower zorder
}
]
# Define ellipse shapes to overlay on canvas
ellipses = [
{
'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 # Top-most
},
{
'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
},
{
'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
},
{
'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
}
]
# 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 product doesn't fit, skip overlay
return None
# 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 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 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 transparent canvas same size as background
shifted_fg = Image.new("RGBA", bg.size, (255, 255, 255, 0))
# 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) # Paste using alpha mask
# 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)
# 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 # 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
# Minimum and maximum acceptable width values (inches)
min_width = 4.5 + hole_rad * 2
max_width = 11.52 - hole_rad * 2
# 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 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], # 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: 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(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
):
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:
# Dictionary mapping variable names to image objects (can be PIL or NumPy arrays)
image_dict = {
"input1": input1,
"input2": input2,
"input3": input3,
"gallery_segmentation_p4": gallery_segmentation_p4,
"out_ref_image_p5": out_ref_image_p5,
"out_rim_image_p6": out_rim_image_p6,
"out_rimellipse_image_p7": out_rimellipse_image_p7,
"rimheight_image_p8": rimheight_image_p8,
"holewidth_image_p9": holewidth_image_p9,
"rim_to_hole_img_p10": rim_to_hole_img_p10,
"ellipse_viz_image_p10": ellipse_viz_image_p10,
"rim_height_vis_p11": rim_height_vis_p11,
"remaining_lid_img_p12": remaining_lid_img_p12,
"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) # 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
# Prepare a text summary with all the measurement-related strings and variables
text_lines = [
"📋 Measurement Summary",
"----------------------",
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:", # Model-predicted values
f"a: {a1}",
f"b: {b1}",
f"c: {c1}",
f"d: {d1}",
f"e: {e1}",
f"f: {f1}",
f"g: {g1}",
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)) # 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, # Step 1: Segment and overlay masks for all models
inputs=[
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, # Gallery showing overlays
binary_masks_p4, # Segmentation masks
image_dict_p4, # Rotated/processed images
]).\
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 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, # Step 3: Measure open seat width (ellipse intersect)
inputs=[
image_dict_p4,
binary_masks_p4,
out_ref_ratios_p5
],
outputs=[
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_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, # 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, # Step 5: Analyze rim height using open seat image
inputs=[
btn_rimheight_p8, # Dummy trigger button
image_dict_p4,
binary_masks_p4,
out_ref_ratios_p5,
inner_top_p7,
dir_down_p7
],
outputs=[
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, # Step 6: Measure hole width perpendicular to rim
inputs=[
btn_measure_holewidth_p9, # Dummy trigger
binary_masks_p4,
out_ref_ratios_p5,
image_dict_p4,
dir_right_p7,
inner_top_p7
],
outputs=[
holewidth_image_p9, # Annotated result
hole_width_px_p9,
hole_width_cm_p9,
hole_width_inch_p9,
angle_deg_p9,
pt_min_p9, # Leftmost point
pt_max_p9, # Rightmost point
hole_width_str,
hw
]).\
then(fn=compute_top_to_hole_distance_p10, # Step 7: Vertical distance between top rim and hole
inputs=[
btn_top_to_hole_p10, # Trigger
inner_top_p8,
pt_min_p9,
pt_max_p9,
dir_down_p7,
out_ref_ratios_p5,
image_dict_p4
],
outputs=[
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,
angle_down_deg_p10,
angle_perp_deg_p10,
intersection_point_p10,
hole_to_top_str,
hr
]).\
then(fn=analyze_closed_rim_orientation_p10, # Step 8: Fit ellipse on closed rim to get orientation
inputs=[
btn_ellipse_orient_p10,
binary_masks_p4,
image_dict_p4
],
outputs=[
ellipse_viz_image_p10, # Ellipse fit result
ellipse_angle_deg_p10, # Angle of major axis
ellipse_center_p10,
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, # Step 9: Estimate full rim height using closed image
inputs=[
btn_rim_height_closed_p11,
binary_masks_p4,
input3,
ellipse_angle_deg_p10,
out_ref_ratios_p5,
rim_height_cm_p8
],
outputs=[
rim_height_vis_p11,
rim_height_cm_p11,
rim_height_in_p11,
full_rim_height_cm_p11,
full_rim_height_in_p11,
closed_remaining_cm_p11,
closed_remaining_in_p11,
pt_start_p11,
total_height_str,
th
]).\
then(fn=draw_remaining_closed_portion_p12, # Step 10: Draw estimated remaining closed portion
inputs=[
btn_draw_remaining_p12,
pt_start_p11,
closed_remaining_cm_p11,
out_ref_ratios_p5,
ellipse_dir_down_p10,
input3
],
outputs=[
remaining_lid_img_p12,
remaining_angle_deg_p12,
pt_start_p12,
pt_end_p12,
remaining_str,
tt
]).\
then(fn=analyze_top_rim_width_p13, # Step 11: Measure top closed rim width
inputs=[
btn_measure,
binary_masks_p4,
ellipse_center_p10,
ellipse_angle_deg_p10,
out_ref_ratios_p5,
input3,
],
outputs=[
out_image,
out_pt_top,
out_pt1,
out_pt2,
out_dist_px,
out_dist_cm,
out_dist_in,
out_angle_deg,
top_width_str,
wt
]).\
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
],
outputs=[download_all_file, progress]
).\
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:
# 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(): # Left spacer
# filler = ''
# with gr.Column(): # Center column with email input
# email = gr.Text(label="Email")
# with gr.Column(): # Right spacer
# filler = ''
# # Password input row
# with gr.Row():
# with gr.Column(): # Left spacer
# filler = ''
# with gr.Column(): # Center column with password input
# password = gr.Text(label="Password", type="password")
# with gr.Column(): # Right spacer
# filler = ''
# # Login button row
# with gr.Row():
# with gr.Column(): # Left spacer
# filler = ''
# with gr.Column(): # Center column with Login button
# login_btn = gr.Button("Login")
# with gr.Column(): # Right spacer
# filler = ''
# # Authentication status message row
# with gr.Row():
# with gr.Column(): # Left spacer
# filler = ''
# with gr.Column(): # Center column with status message
# auth_msg = gr.Textbox(label="Status", interactive=False)
# 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=True) as protected_content:
launch_main_app() # Call to external function that builds the full app
# # Attach login button to login handler
# login_btn.click(
# handle_login,
# inputs=[email, password],
# outputs=[auth_msg, protected_content, auth_state, 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)
# ================================
# 🔻 FOOTER SECTION
# ================================
with gr.Row(elem_id='custom_footer'):
with gr.Column():
# Line 1 - Centered contact info
gr.Markdown(" ")
gr.Markdown(" ")
gr.Markdown(
"<div style='text-align:center;'>For any queries, feel free to contact 📧 <a href='mailto:core.atsc@gmail.com'>core.atsc@gmail.com</a></div>",
elem_id="footer-contact"
)
# Line 2 - Left company info, Right creator info
with gr.Row():
with gr.Column(scale=1, min_width=300):
gr.Markdown(
"<div style='text-align:left;'>© 2025 HapiHygi Innovations Pvt. Ltd. &nbsp; All rights reserved.</div>",
elem_id="footer-left"
)
with gr.Column(scale=1, min_width=300):
gr.Markdown(
"""
<div style='text-align:right;'>
<a href="mailto:savaliyaheet19@gmail.com">👨‍💻</a>
<a href="https://github.com/heetsavaliya" target="_blank"><i>Created by:</i></a>
<a href="https://www.linkedin.com/in/heet-savaliya-03b863252/" target="_blank"><i>Heet Savaliya, PDEU</i></a>
</div>
""",
elem_id="footer-right"
)
# 🚀 Launch the full app
if __name__ == "__main__":
full_app_interface.launch()