Spaces:
Sleeping
Sleeping
| """ | |
| Gradio app to replicate the interactive vanishing-point selection tool | |
| from the supplied matplotlib script, implemented for gradio==3.50.2. | |
| How it works (UI): | |
| - Upload an image. | |
| - Click "Start Yellow" or "Start Red" to enter a drawing mode for that line group. | |
| - Click on the image to add points. Two consecutive clicks make a line. | |
| - You can add as many lines as you want for each color. | |
| - Press "Compute vanishing points" to run optimization (scipy.minimize) for | |
| each color group and display the vanishing points and overlayed lines. | |
| - Reset clears all state. | |
| Requirements: | |
| - gradio==3.50.2 | |
| - numpy | |
| - scipy | |
| - pillow | |
| Run: | |
| pip install gradio==3.50.2 numpy scipy pillow | |
| python grad_io_gradio_app.py | |
| Note: This implementation uses the Image.select event which behaves correctly | |
| in gradio 3.50.2 (it provides pixel coordinates of the clicked point). If you | |
| use a newer Gradio version, the event behavior might differ. | |
| """ | |
| import io | |
| import math | |
| import numpy as np | |
| from PIL import Image, ImageDraw, ImageFont | |
| import gradio as gr | |
| from scipy.optimize import minimize | |
| # ------------------------ Helper math functions --------------------------- | |
| def build_line_from_points(p1, p2): | |
| """Return line coefficients (A, B, C) for Ax + By + C = 0 given two points.""" | |
| x1, y1 = p1 | |
| x2, y2 = p2 | |
| a = y1 - y2 | |
| b = x2 - x1 | |
| c = x1 * y2 - y1 * x2 | |
| return np.array([a, b, c], dtype=float) | |
| def distance_point_to_line(pt, line): | |
| x, y = pt | |
| a, b, c = line | |
| return abs(a * x + b * y + c) / math.hypot(a, b) | |
| def total_distances(x, lines, noise_lines): | |
| """Sum of distances from candidate point x to all lines and noise lines.""" | |
| pt = x | |
| s = 0.0 | |
| for L in lines: | |
| s += distance_point_to_line(pt, L) | |
| for Ln in noise_lines: | |
| s += distance_point_to_line(pt, Ln) | |
| return s | |
| def add_noise_lines_for_line(p1, p2, n=4, sigma=1.0): | |
| """Create a list of "noise" lines by jittering the endpoints slightly.""" | |
| noise_lines = [] | |
| for _ in range(n): | |
| p1n = (p1[0] + np.random.normal(0, sigma), p1[1] + np.random.normal(0, sigma)) | |
| p2n = (p2[0] + np.random.normal(0, sigma), p2[1] + np.random.normal(0, sigma)) | |
| noise_lines.append(build_line_from_points(p1n, p2n)) | |
| return noise_lines | |
| # ------------------------- Drawing utilities ------------------------------ | |
| def draw_overlay(base_pil, yellow_lines, red_lines, yellow_points, red_points, vps=None): | |
| """Return a new PIL image with overlays drawn: lines, points and vanishing points. | |
| - yellow_lines, red_lines: lists of line coefficients | |
| - yellow_points, red_points: lists of tuples (p1, p2) for each line | |
| - vps: dict with keys 'yellow' and 'red' for vanishing points (x,y) | |
| """ | |
| img = base_pil.copy().convert("RGBA") | |
| draw = ImageDraw.Draw(img) | |
| # helpers | |
| def draw_point(pt, color, r=4): | |
| x, y = pt | |
| draw.ellipse((x - r, y - r, x + r, y + r), fill=color, outline=color) | |
| def draw_line_by_points(p1, p2, color, width=2, dash=False): | |
| # we just draw a straight segment connecting endpoints | |
| if dash: | |
| # dashed line: draw small segments | |
| x1, y1 = p1 | |
| x2, y2 = p2 | |
| segs = 40 | |
| for i in range(segs): | |
| t0 = i / segs | |
| t1 = (i + 0.5) / segs | |
| xa = x1 * (1 - t0) + x2 * t0 | |
| ya = y1 * (1 - t0) + y2 * t0 | |
| xb = x1 * (1 - t1) + x2 * t1 | |
| yb = y1 * (1 - t1) + y2 * t1 | |
| draw.line((xa, ya, xb, yb), fill=color, width=width) | |
| else: | |
| draw.line((p1[0], p1[1], p2[0], p2[1]), fill=color, width=width) | |
| # Draw yellow lines | |
| for idx, ((p1, p2), L) in enumerate(zip(yellow_points, yellow_lines)): | |
| # draw long extents of line by projecting to image bounds | |
| draw_line_segment_from_line(L, img.size, color=(255, 215, 0, 200), draw=draw) | |
| draw_point(p1, (255, 215, 0, 255)) | |
| draw_point(p2, (255, 215, 0, 255)) | |
| # Draw red lines | |
| for idx, ((p1, p2), L) in enumerate(zip(red_points, red_lines)): | |
| draw_line_segment_from_line(L, img.size, color=(255, 64, 64, 200), draw=draw) | |
| draw_point(p1, (255, 64, 64, 255)) | |
| draw_point(p2, (255, 64, 64, 255)) | |
| # Draw vanishing points if present | |
| if vps is not None: | |
| if "yellow" in vps and vps["yellow"] is not None: | |
| draw_point(vps["yellow"], (255, 215, 0, 255), r=6) | |
| if "red" in vps and vps["red"] is not None: | |
| draw_point(vps["red"], (255, 64, 64, 255), r=6) | |
| return img.convert("RGB") | |
| def draw_line_segment_from_line(line, image_size, draw=None, color=(255, 255, 0, 255)): | |
| """Given line coefficients and image size, draw a segment across the image bounds. | |
| This draws directly using ImageDraw if 'draw' is provided. | |
| """ | |
| W, H = image_size | |
| a, b, c = line | |
| points = [] | |
| # intersection with left edge x=0 | |
| if abs(b) > 1e-9: | |
| y = -(a * 0 + c) / b | |
| points.append((0, y)) | |
| # right edge x=W | |
| if abs(b) > 1e-9: | |
| y = -(a * W + c) / b | |
| points.append((W, y)) | |
| # top edge y=0 --> a x + c = 0 | |
| if abs(a) > 1e-9: | |
| x = -(b * 0 + c) / a | |
| points.append((x, 0)) | |
| # bottom edge y=H | |
| if abs(a) > 1e-9: | |
| x = -(b * H + c) / a | |
| points.append((x, H)) | |
| # keep only points within the image bounds | |
| pts_in = [(x, y) for (x, y) in points if -W * 0.1 <= x <= W * 1.1 and -H * 0.1 <= y <= H * 1.1] | |
| if len(pts_in) >= 2 and draw is not None: | |
| # pick two extreme points | |
| # sort by x coordinate | |
| pts_in = sorted(pts_in, key=lambda p: (p[0], p[1])) | |
| pA = pts_in[0] | |
| pB = pts_in[-1] | |
| draw.line((pA[0], pA[1], pB[0], pB[1]), fill=color, width=2) | |
| # ------------------------- Gradio app callbacks --------------------------- | |
| # We'll store states in gr.State objects: | |
| # - current_mode: None | 'yellow' | 'red' | |
| # - current_points: list of pending points (len 0 or 1 waiting for second click) | |
| # - yellow_lines: list of (A,B,C) | |
| # - red_lines: list of (A,B,C) | |
| # - yellow_points_pairs: list of ((p1,p2)) | |
| # - red_points_pairs: list of ((p1,p2)) | |
| def init_states(): | |
| return None, [], [], [], [], [] | |
| def on_mode_change(mode, image, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs): | |
| """Switch drawing mode between 'yellow', 'red' or None. | |
| Returns image (unchanged) and updated states. | |
| """ | |
| # Just update the mode state. Clear any pending single point. | |
| return (image, mode, [], y_lines, r_lines, y_pairs, r_pairs) | |
| def on_image_select(sel: gr.SelectData, image, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs): | |
| """Called when user clicks on the image. sel.index gives (x, y) in pixels. | |
| We append the point, and when there are 2 points we form a line and add to the | |
| corresponding color list. We then redraw overlays and return the updated image and states. | |
| """ | |
| # sel may contain relative coords depending on gradio version; here we expect .index | |
| if sel is None: | |
| return image, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs | |
| idx = getattr(sel, "index", None) | |
| # Some versions wrap coordinates as [x, y], some as (x, y) | |
| if idx is None: | |
| # fallback: try .data or .value | |
| idx = getattr(sel, "data", None) or getattr(sel, "value", None) | |
| if not idx: | |
| return image, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs | |
| x, y = int(idx[0]), int(idx[1]) | |
| # append to current_points | |
| current_points = list(current_points) if current_points is not None else [] | |
| current_points.append((x, y)) | |
| # if we have two points, create a line | |
| if len(current_points) >= 2 and current_mode in ("yellow", "red"): | |
| p1 = current_points[-2] | |
| p2 = current_points[-1] | |
| L = build_line_from_points(p1, p2) | |
| if current_mode == "yellow": | |
| y_lines = list(y_lines) if y_lines is not None else [] | |
| y_pairs = list(y_pairs) if y_pairs is not None else [] | |
| y_lines.append(L) | |
| y_pairs.append((p1, p2)) | |
| else: | |
| r_lines = list(r_lines) if r_lines is not None else [] | |
| r_pairs = list(r_pairs) if r_pairs is not None else [] | |
| r_lines.append(L) | |
| r_pairs.append((p1, p2)) | |
| # redraw overlay image | |
| base_pil = Image.fromarray(image) if not isinstance(image, Image.Image) else image | |
| out = draw_overlay(base_pil, y_lines or [], r_lines or [], y_pairs or [], r_pairs or [], vps=None) | |
| return out, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs | |
| def compute_vanishing_points(image, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs): | |
| """Compute vanishing points for both color groups, draw them and return annotated image. | |
| For each group: if there are >1 lines, compute intersections and use mean intersection | |
| as initial guess; then minimize sum of distances to lines + noise-lines. | |
| """ | |
| img_pil = Image.fromarray(image) if not isinstance(image, Image.Image) else image | |
| vps = {"yellow": None, "red": None} | |
| # process yellow group | |
| if y_lines and len(y_lines) > 1: | |
| lines_arr = np.array(y_lines) | |
| # intersections | |
| inters = [] | |
| for i in range(len(lines_arr) - 1): | |
| for j in range(i + 1, len(lines_arr)): | |
| try: | |
| ip = np.linalg.solve(np.array([[lines_arr[i][0], lines_arr[i][1]],[lines_arr[j][0], lines_arr[j][1]]]), | |
| -np.array([lines_arr[i][2], lines_arr[j][2]])) | |
| inters.append(ip) | |
| except Exception: | |
| pass | |
| if inters: | |
| p0 = np.mean(inters, axis=0) | |
| else: | |
| # fallback: center of image | |
| p0 = np.array([img_pil.width / 2, img_pil.height / 2]) | |
| # noise lines | |
| noise = [] | |
| for (p1, p2) in y_pairs: | |
| noise += add_noise_lines_for_line(p1, p2, n=4, sigma=2.0) | |
| res = minimize(lambda x: total_distances(x, lines_arr, noise), p0, method='Powell') | |
| vps['yellow'] = (float(res.x[0]), float(res.x[1])) | |
| # process red group | |
| if r_lines and len(r_lines) > 1: | |
| lines_arr = np.array(r_lines) | |
| inters = [] | |
| for i in range(len(lines_arr) - 1): | |
| for j in range(i + 1, len(lines_arr)): | |
| try: | |
| ip = np.linalg.solve(np.array([[lines_arr[i][0], lines_arr[i][1]],[lines_arr[j][0], lines_arr[j][1]]]), | |
| -np.array([lines_arr[i][2], lines_arr[j][2]])) | |
| inters.append(ip) | |
| except Exception: | |
| pass | |
| if inters: | |
| p0 = np.mean(inters, axis=0) | |
| else: | |
| p0 = np.array([img_pil.width / 2, img_pil.height / 2]) | |
| noise = [] | |
| for (p1, p2) in r_pairs: | |
| noise += add_noise_lines_for_line(p1, p2, n=4, sigma=2.0) | |
| res = minimize(lambda x: total_distances(x, lines_arr, noise), p0, method='Powell') | |
| vps['red'] = (float(res.x[0]), float(res.x[1])) | |
| out = draw_overlay(img_pil, y_lines or [], r_lines or [], y_pairs or [], r_pairs or [], vps=vps) | |
| return out, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs | |
| def reset_all(image, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs, original_image): | |
| """Reset all state and return the original base image without any overlays.""" | |
| # Use original_image if available, otherwise use the current image | |
| #if original_image is not None: | |
| base_pil = Image.fromarray(original_image) if not isinstance(original_image, Image.Image) else original_image | |
| #else: | |
| # # Fallback: use current image (might have overlays, but better than nothing) | |
| # base_pil = Image.fromarray(image) if not isinstance(image, Image.Image) else image | |
| # Convert PIL image to numpy array for Gradio (since type="numpy") | |
| base_np = np.array(base_pil.convert("RGB")) | |
| return base_np, None, [], [], [], [], [] | |
| # ------------------------------ Build Blocks ------------------------------ | |
| def build_gradio_interface(): | |
| with gr.Blocks() as demo: | |
| gr.Markdown("# ππ Shadow Consistency Analysis πππ") | |
| gr.Markdown("A utility for verifying geometric consistency of shadows in an image. By projecting vanishing points, it helps determine if all shadows correspond to a single, coherent light source. This method is based on principles of perspective and can be useful for analyzing both traditional manipulations and AI-generated images.") | |
| with gr.Row(): | |
| img_in = gr.Image(label="Upload image and then click to add points", type="numpy", interactive=True, height=800) | |
| with gr.Column(): | |
| start_y = gr.Button("Start Yellow Line") | |
| start_r = gr.Button("Start Red Line") | |
| none_btn = gr.Button("Stop Drawing") | |
| compute_btn = gr.Button("Compute vanishing points") | |
| reset_btn = gr.Button("Reset Figure and Clear Lines") | |
| gr.Markdown("\nClick the image to add points. Two points => one line. Add at least 2 lines per group to compute a vanishing point.") | |
| # states | |
| current_mode = gr.State(None) | |
| current_points = gr.State([]) | |
| y_lines = gr.State([]) | |
| r_lines = gr.State([]) | |
| y_pairs = gr.State([]) | |
| r_pairs = gr.State([]) | |
| original_image = gr.State(None) # Store original base image without overlays | |
| # Store original image when uploaded (not when programmatically changed) | |
| def store_original(img, orig): | |
| """Store the original image when a new image is uploaded.""" | |
| if img is not None: | |
| # Make a copy to ensure it doesn't get modified | |
| if isinstance(img, np.ndarray): | |
| return img.copy() | |
| return img # Store the new image as original | |
| return orig # Keep existing original if no new image | |
| img_in.upload(store_original, inputs=[img_in, original_image], outputs=[original_image]) | |
| # link buttons to mode change | |
| start_y.click(on_mode_change, inputs=[gr.State("yellow"), img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs], | |
| outputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs]) | |
| start_r.click(on_mode_change, inputs=[gr.State("red"), img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs], | |
| outputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs]) | |
| none_btn.click(on_mode_change, inputs=[gr.State(None), img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs], | |
| outputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs]) | |
| # image select event | |
| img_in.select(on_image_select, inputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs], | |
| outputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs]) | |
| compute_btn.click(compute_vanishing_points, inputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs], | |
| outputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs]) | |
| reset_btn.click(reset_all, inputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs, original_image], | |
| outputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs]) | |
| return demo | |
| if __name__ == '__main__': | |
| demo = build_gradio_interface() | |
| demo.queue() | |
| demo.launch() | |