Spaces:
Running
Running
| 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() | |