G-Paris
Initialize Space with LFS for images
19f31ed
import gradio as gr
import numpy as np
import colorsys
from PIL import Image, ImageDraw, ImageFont
def draw_boxes_on_image(image, boxes, labels, pending_point=None, crop_box=None):
"""Helper to draw boxes and pending point on image."""
if image is None: return None
out_img = image.copy()
draw = ImageDraw.Draw(out_img)
w, h = image.size
# Draw existing boxes
for box, label in zip(boxes, labels):
color = "#00FF00" if label == 1 else "#FF0000" # Green for Include, Red for Exclude
draw.rectangle(box, outline=color, width=3)
# Draw crop box if exists
if crop_box:
draw.rectangle(crop_box, outline="blue", width=3)
# Add label
draw.text((crop_box[0], crop_box[1]-15), "CROP", fill="blue")
# Draw pending point if exists
if pending_point:
x, y = pending_point
r = 5
draw.ellipse((x-r, y-r, x+r, y+r), fill="yellow", outline="black")
# Draw crosshair guides
draw.line([(0, y), (w, y)], fill="cyan", width=1)
draw.line([(x, 0), (x, h)], fill="cyan", width=1)
return out_img
def format_box_list(boxes, labels):
"""Format boxes for display in Dataframe (Editable)."""
data = []
for i, box in enumerate(boxes):
lbl = "Include" if labels[i] == 1 else "Exclude"
# [Delete?, Type, x1, y1, x2, y2]
data.append([False, lbl, box[0], box[1], box[2], box[3]])
return data
def format_crop_box(crop_box):
"""Format crop box for display in Dataframe."""
if not crop_box:
return []
# [Delete?, x1, y1, x2, y2]
return [[False, crop_box[0], crop_box[1], crop_box[2], crop_box[3]]]
def draw_candidates(image: Image.Image, candidates: list, selected_indices: set | int | None = None):
"""
Draws all candidates on the image with ID labels.
- selected_indices: If provided (set, list, or int), highlights these candidates and dims others.
If None, all are shown as active candidates.
"""
if image is None: return None
# Normalize selected_indices to a set or None
if selected_indices is not None:
if isinstance(selected_indices, int):
selected_indices = {selected_indices}
elif isinstance(selected_indices, list):
selected_indices = set(selected_indices)
elif not isinstance(selected_indices, set):
# Fallback
selected_indices = None
# Work on RGBA for transparency
canvas = image.convert("RGBA")
overlay = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
# Load font
try:
font = ImageFont.truetype("arial.ttf", 24)
except:
try:
font = ImageFont.truetype("DejaVuSans-Bold.ttf", 24)
except:
font = ImageFont.load_default()
for idx, obj in enumerate(candidates):
if obj.binary_mask is None: continue
# Determine style based on selection
is_selected = (selected_indices is not None) and (idx in selected_indices)
# If nothing is selected (None), all are "active".
# If something is selected, only selected ones are active/highlighted.
is_active = (selected_indices is None) or is_selected
if is_active:
# Generate unique color for this index using Golden Ratio for distinctness
hue = (idx * 0.618033988749895) % 1
r, g, b = colorsys.hsv_to_rgb(hue, 1.0, 1.0)
base_rgb = (int(r*255), int(g*255), int(b*255))
if selected_indices is None:
# Default candidate view - use unique colors
fill_color = (*base_rgb, 100)
else:
# Selected view - use unique colors (more opaque)
fill_color = (*base_rgb, 160)
text_color = (255, 255, 255, 255)
else:
# Dimmed Color (Grayed out)
fill_color = (128, 128, 128, 30)
text_color = (200, 200, 200, 100)
# 1. Draw Mask
# Create a mask image for this object
mask_uint8 = (obj.binary_mask * 255).astype(np.uint8)
mask_layer = Image.fromarray(mask_uint8, mode='L')
# Colorize mask
colored_mask = Image.new("RGBA", canvas.size, fill_color)
overlay.paste(colored_mask, (0, 0), mask_layer)
# 2. Draw ID at Centroid
y_indices, x_indices = np.where(obj.binary_mask)
if len(y_indices) > 0:
cy = int(np.mean(y_indices))
cx = int(np.mean(x_indices))
label = str(idx + 1)
# Draw text background for readability
bbox = draw.textbbox((cx, cy), label, font=font, anchor="mm")
# Add padding
draw.rectangle([bbox[0]-4, bbox[1]-4, bbox[2]+4, bbox[3]+4], fill=(0, 0, 0, 160))
draw.text((cx, cy), label, font=font, fill=text_color, anchor="mm")
# Composite
return Image.alpha_composite(canvas, overlay).convert("RGB")
def parse_dataframe(df_data):
"""Parse dataframe back to boxes and labels."""
boxes = []
labels = []
# Handle if df_data is None or empty
if df_data is None:
return [], []
# Check if it's a pandas DataFrame
if hasattr(df_data, 'values'):
if df_data.empty:
return [], []
values = df_data.values.tolist()
else:
if not df_data:
return [], []
values = df_data
for row in values:
# row[0] is Delete? (bool)
# row[1] is Type (str)
# row[2-5] are coords
lbl = 1 if row[1] == "Include" else 0
try:
# Ensure coords are ints
box = [int(float(row[2])), int(float(row[3])), int(float(row[4])), int(float(row[5]))]
boxes.append(box)
labels.append(lbl)
except (ValueError, TypeError, IndexError):
continue # Skip invalid rows
return boxes, labels
def parse_crop_dataframe(df_data):
"""Parse dataframe back to crop box."""
if df_data is None: return None
values = []
if hasattr(df_data, 'values'):
if df_data.empty: return None
values = df_data.values.tolist()
else:
values = df_data
if not values: return None
# Take the first valid row
for row in values:
# row[0] is Delete?
if row[0]: return None # Deleted
try:
# row[1-4] are coords (since no Type column)
box = [int(float(row[1])), int(float(row[2])), int(float(row[3])), int(float(row[4]))]
return box
except:
continue
return None
def on_dataframe_change(df_data, clean_img, crop_box):
"""Handle changes in the dataframe (edits)."""
if clean_img is None: return gr.update(), [], []
boxes, labels = parse_dataframe(df_data)
vis_img = draw_boxes_on_image(clean_img, boxes, labels, None, crop_box)
return vis_img, boxes, labels
def on_crop_dataframe_change(df_data, clean_img, boxes, labels):
"""Handle changes in the crop dataframe."""
if clean_img is None: return gr.update(), None
crop_box = parse_crop_dataframe(df_data)
vis_img = draw_boxes_on_image(clean_img, boxes, labels, None, crop_box)
return vis_img, crop_box
def delete_checked_boxes(df_data, clean_img, crop_box):
"""Delete boxes that are checked."""
if clean_img is None: return [], [], gr.update(), gr.update()
new_boxes = []
new_labels = []
values = []
if df_data is not None:
if hasattr(df_data, 'values'):
values = df_data.values.tolist()
else:
values = df_data
# Filter
if values:
for row in values:
is_deleted = row[0]
if not is_deleted:
lbl = 1 if row[1] == "Include" else 0
try:
box = [int(float(row[2])), int(float(row[3])), int(float(row[4])), int(float(row[5]))]
new_boxes.append(box)
new_labels.append(lbl)
except:
pass
vis_img = draw_boxes_on_image(clean_img, new_boxes, new_labels, None, crop_box)
new_df = format_box_list(new_boxes, new_labels)
return new_boxes, new_labels, new_df, vis_img
def on_upload(files):
"""Handle image upload (list of files)."""
if not files:
return None, [], [], None
# files is a list of file paths (strings) or file objects depending on Gradio version/config
# With file_count="multiple", it's usually a list of temp paths.
# If it's a single file (legacy check), wrap it
if not isinstance(files, list):
files = [files]
# Extract paths
paths = []
for f in files:
if isinstance(f, str):
paths.append(f)
elif hasattr(f, 'name'):
paths.append(f.name)
# Import controller inside function to avoid circular import
from .controller import controller
first_image = controller.load_playlist(paths)
return first_image, [], [], None # clean_img, boxes, labels, pending_pt
def on_input_image_select(evt: gr.SelectData, pending_pt, boxes, labels, click_effect, clean_img, crop_box):
"""Handle click on input image to define boxes or crop."""
if clean_img is None: return gr.update(), pending_pt, boxes, labels, gr.update(), crop_box, gr.update()
x, y = evt.index
if pending_pt is None:
# First point
new_pending = (x, y)
# Draw point
vis_img = draw_boxes_on_image(clean_img, boxes, labels, new_pending, crop_box)
return vis_img, new_pending, boxes, labels, gr.update(), crop_box, gr.update()
else:
# Second point - Finalize box or crop
x1, y1 = pending_pt
x2, y2 = x, y
# Create box [x_min, y_min, x_max, y_max]
bbox = [min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2)]
if click_effect == "Crop Initial Image":
# Update crop box (overwrite)
new_crop_box = bbox
vis_img = draw_boxes_on_image(clean_img, boxes, labels, None, new_crop_box)
new_crop_df = format_crop_box(new_crop_box)
return vis_img, None, boxes, labels, gr.update(), new_crop_box, new_crop_df
else:
# Add to list (Include/Exclude)
lbl = 1 if click_effect == "Include Area" else 0
new_boxes = boxes + [bbox]
new_labels = labels + [lbl]
# Draw all
vis_img = draw_boxes_on_image(clean_img, new_boxes, new_labels, None, crop_box)
# Update dataframe
new_df = format_box_list(new_boxes, new_labels)
return vis_img, None, new_boxes, new_labels, new_df, crop_box, gr.update()
def undo_last_click(pending_pt, boxes, labels, clean_img, crop_box):
"""Undo the last click or remove the last box."""
if clean_img is None: return gr.update(), None, boxes, labels, gr.update(), crop_box, gr.update()
# Case 1: Pending point exists (user clicked once) -> Clear it
if pending_pt is not None:
# Redraw only boxes
vis_img = draw_boxes_on_image(clean_img, boxes, labels, None, crop_box)
return vis_img, None, boxes, labels, gr.update(), crop_box, gr.update()
# Case 2: No pending point, but boxes exist -> Remove last box
# Note: We don't undo crop box here easily unless we track history.
# For now, let's assume undo only affects boxes stack.
if boxes:
boxes.pop()
labels.pop()
vis_img = draw_boxes_on_image(clean_img, boxes, labels, None, crop_box)
new_df = format_box_list(boxes, labels)
return vis_img, None, boxes, labels, new_df, crop_box, gr.update()
# Case 3: Nothing to undo
return gr.update(), None, boxes, labels, gr.update(), crop_box, gr.update()