AMontiB commited on
Commit
40598e4
·
1 Parent(s): d91c77b
Files changed (8) hide show
  1. CFA.py +50 -47
  2. JPEG_Ghost.py +54 -51
  3. PRNU.py +18 -16
  4. README.md +30 -14
  5. app.py +35 -25
  6. requirements.txt +6 -44
  7. shadow.py +159 -116
  8. shadows.py +0 -353
CFA.py CHANGED
@@ -122,50 +122,53 @@ def analyze_region(original_image: np.ndarray, box_coords: tuple):
122
  print(f"4. Analysis complete in {time.time() - start_time:.2f} seconds.")
123
  return fig
124
 
125
- # --- Build the Gradio Interface using Blocks ---
126
- with gr.Blocks(theme=gr.themes.Soft()) as demo:
127
- # State variables store data (like the original image) between user interactions
128
- original_image_state = gr.State()
129
- box_coords_state = gr.State(value=(0, 0))
130
-
131
- gr.Markdown("# 🖼️ Image Patch Analyzer")
132
- gr.Markdown(
133
- "**Instructions:**\n"
134
- "1. **Upload** an image.\n"
135
- "2. **Click** anywhere on the image to move the 128x128 selection box.\n"
136
- "3. Press the **Analyze Region** button to start processing."
137
- )
138
-
139
- with gr.Row():
140
- image_display = gr.Image(type="numpy", label="Selection Canvas")
141
- output_plot = gr.Plot(label="Analysis Results")
142
-
143
- analyze_button = gr.Button("Analyze Region", variant="primary")
144
-
145
- # --- Wire up the event listeners ---
146
-
147
- # 1. When a new image is uploaded, call on_upload_image
148
- image_display.upload(
149
- fn=on_upload_image,
150
- inputs=[image_display],
151
- outputs=[image_display, original_image_state, box_coords_state]
152
- )
153
-
154
- # 2. When the user clicks the image, call move_selection_box
155
- image_display.select(
156
- fn=move_selection_box,
157
- inputs=[original_image_state],
158
- outputs=[image_display, box_coords_state]
159
- )
160
-
161
- # 3. When the user clicks the analyze button, call analyze_region
162
- analyze_button.click(
163
- fn=analyze_region,
164
- inputs=[original_image_state, box_coords_state],
165
- outputs=[output_plot],
166
- # Show a progress bar during analysis
167
- show_progress="full"
168
- )
169
-
170
- # --- Launch the App ---
171
- demo.launch()
 
 
 
 
122
  print(f"4. Analysis complete in {time.time() - start_time:.2f} seconds.")
123
  return fig
124
 
125
+ def build_demo():
126
+ # --- Build the Gradio Interface using Blocks ---
127
+ with gr.Blocks(theme=gr.themes.Soft()) as demo:
128
+ # State variables store data (like the original image) between user interactions
129
+ original_image_state = gr.State()
130
+ box_coords_state = gr.State(value=(0, 0))
131
+
132
+ gr.Markdown("# 🖼️ Image Patch Analyzer")
133
+ gr.Markdown(
134
+ "**Instructions:**\n"
135
+ "1. **Upload** an image.\n"
136
+ "2. **Click** anywhere on the image to move the 128x128 selection box.\n"
137
+ "3. Press the **Analyze Region** button to start processing."
138
+ )
139
+
140
+ with gr.Row():
141
+ image_display = gr.Image(type="numpy", label="Selection Canvas")
142
+ output_plot = gr.Plot(label="Analysis Results")
143
+
144
+ analyze_button = gr.Button("Analyze Region", variant="primary")
145
+
146
+ # --- Wire up the event listeners ---
147
+
148
+ # 1. When a new image is uploaded, call on_upload_image
149
+ image_display.upload(
150
+ fn=on_upload_image,
151
+ inputs=[image_display],
152
+ outputs=[image_display, original_image_state, box_coords_state]
153
+ )
154
+
155
+ # 2. When the user clicks the image, call move_selection_box
156
+ image_display.select(
157
+ fn=move_selection_box,
158
+ inputs=[original_image_state],
159
+ outputs=[image_display, box_coords_state]
160
+ )
161
+
162
+ # 3. When the user clicks the analyze button, call analyze_region
163
+ analyze_button.click(
164
+ fn=analyze_region,
165
+ inputs=[original_image_state, box_coords_state],
166
+ outputs=[output_plot],
167
+ # Show a progress bar during analysis
168
+ show_progress="full"
169
+ )
170
+ return demo
171
+
172
+ if __name__ == "__main__":
173
+ app = build_demo()
174
+ app.launch()
JPEG_Ghost.py CHANGED
@@ -96,54 +96,57 @@ def run_analysis(original_image: np.ndarray, box_coords: tuple, qf1: int, qf2: i
96
 
97
  return im_composite, fig
98
 
99
- # --- Build the Gradio Interface ---
100
-
101
- with gr.Blocks(theme=gr.themes.Soft()) as demo:
102
- gr.Markdown("# 🕵️ JPEG Double Compression Analyzer")
103
- gr.Markdown(
104
- "**Instructions:**\n"
105
- "1. **Upload** an image.\n"
106
- "2. **Click** on the image to move the 256x256 selection box.\n"
107
- "3. Press **Analyze Image** to process the selected region."
108
- )
109
-
110
- original_image_state = gr.State()
111
- box_coords_state = gr.State()
112
-
113
- with gr.Row():
114
- with gr.Column(scale=1):
115
- gr.Markdown("### 1. Inputs")
116
- image_display = gr.Image(type="numpy", label="Upload Image & Click to Select")
117
- qf1_slider = gr.Slider(minimum=1, maximum=100, value=70, step=1, label="QF1: Background Quality")
118
- qf2_slider = gr.Slider(minimum=1, maximum=100, value=85, step=1, label="QF2: Final Composite Quality")
119
- gr.Markdown("#### Analysis QF Range")
120
- with gr.Row():
121
- qf_start_slider = gr.Slider(minimum=50, maximum=100, value=50, step=5, label="Start")
122
- qf_end_slider = gr.Slider(minimum=50, maximum=100, value=90, step=5, label="End")
123
- analyze_button = gr.Button("Analyze Image", variant="primary")
124
-
125
- with gr.Column(scale=2):
126
- gr.Markdown("### 2. Results")
127
- composite_image_display = gr.Image(type="numpy", label="Generated Composite Image")
128
- difference_plot_display = gr.Plot(label="Difference Maps")
129
-
130
- # Event Listeners
131
- image_display.upload(
132
- fn=on_upload_image,
133
- inputs=[image_display],
134
- outputs=[image_display, original_image_state, box_coords_state]
135
- )
136
-
137
- image_display.select(
138
- fn=move_selection_box,
139
- inputs=[original_image_state],
140
- outputs=[image_display, box_coords_state]
141
- )
142
-
143
- analyze_button.click(
144
- fn=run_analysis,
145
- inputs=[original_image_state, box_coords_state, qf1_slider, qf2_slider, qf_start_slider, qf_end_slider],
146
- outputs=[composite_image_display, difference_plot_display]
147
- )
148
-
149
- demo.launch(debug=True)
 
 
 
 
96
 
97
  return im_composite, fig
98
 
99
+ def build_demo():
100
+ # --- Build the Gradio Interface ---
101
+ with gr.Blocks(theme=gr.themes.Soft()) as demo:
102
+ gr.Markdown("# 🕵️ JPEG Double Compression Analyzer")
103
+ gr.Markdown(
104
+ "**Instructions:**\n"
105
+ "1. **Upload** an image.\n"
106
+ "2. **Click** on the image to move the 256x256 selection box.\n"
107
+ "3. Press **Analyze Image** to process the selected region."
108
+ )
109
+
110
+ original_image_state = gr.State()
111
+ box_coords_state = gr.State()
112
+
113
+ with gr.Row():
114
+ with gr.Column(scale=1):
115
+ gr.Markdown("### 1. Inputs")
116
+ image_display = gr.Image(type="numpy", label="Upload Image & Click to Select")
117
+ qf1_slider = gr.Slider(minimum=1, maximum=100, value=70, step=1, label="QF1: Background Quality")
118
+ qf2_slider = gr.Slider(minimum=1, maximum=100, value=85, step=1, label="QF2: Final Composite Quality")
119
+ gr.Markdown("#### Analysis QF Range")
120
+ with gr.Row():
121
+ qf_start_slider = gr.Slider(minimum=50, maximum=100, value=50, step=5, label="Start")
122
+ qf_end_slider = gr.Slider(minimum=50, maximum=100, value=90, step=5, label="End")
123
+ analyze_button = gr.Button("Analyze Image", variant="primary")
124
+
125
+ with gr.Column(scale=2):
126
+ gr.Markdown("### 2. Results")
127
+ composite_image_display = gr.Image(type="numpy", label="Generated Composite Image")
128
+ difference_plot_display = gr.Plot(label="Difference Maps")
129
+
130
+ # Event Listeners
131
+ image_display.upload(
132
+ fn=on_upload_image,
133
+ inputs=[image_display],
134
+ outputs=[image_display, original_image_state, box_coords_state]
135
+ )
136
+
137
+ image_display.select(
138
+ fn=move_selection_box,
139
+ inputs=[original_image_state],
140
+ outputs=[image_display, box_coords_state]
141
+ )
142
+
143
+ analyze_button.click(
144
+ fn=run_analysis,
145
+ inputs=[original_image_state, box_coords_state, qf1_slider, qf2_slider, qf_start_slider, qf_end_slider],
146
+ outputs=[composite_image_display, difference_plot_display]
147
+ )
148
+ return demo
149
+
150
+ if __name__ == "__main__":
151
+ app = build_demo()
152
+ app.launch(debug=True)
PRNU.py CHANGED
@@ -114,19 +114,21 @@ def analyze_image_forgery(fingerprint_file, input_image):
114
  return fig1, fig2
115
 
116
  # --- Create and Launch the Gradio Interface ---
117
- iface = gr.Interface(
118
- fn=analyze_image_forgery,
119
- inputs=[
120
- gr.File(label="Upload Camera Fingerprint (.dat file)"),
121
- gr.Image(type="numpy", label="Upload Image to Analyze")
122
- ],
123
- outputs=[
124
- gr.Plot(label="PCE Map"),
125
- gr.Plot(label="Analyzed Image")
126
- ],
127
- title="📸 PRNU-Based Image Forgery Detector",
128
- description=description
129
- )
130
-
131
- # Launch the app locally
132
- iface.launch()
 
 
 
114
  return fig1, fig2
115
 
116
  # --- Create and Launch the Gradio Interface ---
117
+ def build_demo():
118
+ return gr.Interface(
119
+ fn=analyze_image_forgery,
120
+ inputs=[
121
+ gr.File(label="Upload Camera Fingerprint (.dat file)"),
122
+ gr.Image(type="numpy", label="Upload Image to Analyze")
123
+ ],
124
+ outputs=[
125
+ gr.Plot(label="PCE Map"),
126
+ gr.Plot(label="Analyzed Image")
127
+ ],
128
+ title="📸 PRNU-Based Image Forgery Detector",
129
+ description=description
130
+ )
131
+
132
+ if __name__ == "__main__":
133
+ iface = build_demo()
134
+ iface.launch()
README.md CHANGED
@@ -1,19 +1,8 @@
1
- ---
2
- title: Digital Image Forensics Toolkit
3
- emoji: 🕵️‍♂️
4
- colorFrom: blue
5
- colorTo: indigo
6
- sdk: gradio
7
- sdk_version: "5.49.1"
8
- app_file: app.py
9
- pinned: false
10
- ---
11
-
12
  # Digital Image Forensics Toolkit 🕵️‍♂️
13
 
14
- This space provides a collection of fundamental digital forensic algorithms designed to detect "cheap fakes"—image manipulations created with standard editing software.
15
 
16
- These methods are effective for identifying traditional forgeries such as splicing, copy-move, and inconsistent lighting. Select a tool from the tabs above to get started.
17
 
18
  ---
19
 
@@ -33,5 +22,32 @@ Uses the **Photo Response Non-Uniformity (PRNU)** pattern, a unique noise finger
33
  ### ☀️ Shadow Consistency Analysis (`shadows.py`)
34
  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.
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
-
 
 
 
 
 
 
 
 
 
 
 
 
1
  # Digital Image Forensics Toolkit 🕵️‍♂️
2
 
3
+ This repository provides a collection of fundamental digital forensic algorithms designed to detect "cheap fakes"—image manipulations created with standard editing software like Photoshop or GIMP. The tools are implemented in Python and deployed on a local server using a [Gradio](https://www.gradio.app/) web interface.
4
 
5
+ These methods are effective for identifying traditional forgeries such as splicing, copy-move, and inconsistent lighting.
6
 
7
  ---
8
 
 
22
  ### ☀️ Shadow Consistency Analysis (`shadows.py`)
23
  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.
24
 
25
+ ---
26
+
27
+ ## ⚙️ Getting Started
28
+
29
+ ### Prerequisites
30
+ * Python 3.10+
31
+ * pip
32
+
33
+ ### Installation
34
+
35
+ pip install -r requirements.txt
36
+
37
+ ### Run locally
38
+
39
+ python app.py
40
+
41
+ ---
42
+
43
+ ## 🚀 Deploying to Hugging Face Spaces
44
+
45
+ This repo is ready for a Gradio Space.
46
+
47
+ - Set the Space SDK to: Gradio
48
+ - Set the entry point to: `app.py`
49
+ - Hardware: CPU is sufficient
50
+
51
+ The `requirements.txt` already includes all dependencies.
52
 
53
+ ---
app.py CHANGED
@@ -1,28 +1,38 @@
1
  import gradio as gr
2
 
3
- # Import the UI-creation functions from your tool scripts
4
- import CFA as CFA_tool
5
- import JPEG_Ghost as JPEG_Ghost_tool
6
- import PRNU as PRNU_tool
7
- import shadow as shadows_tool
8
-
9
- # Create the tabbed interface
10
- demo = gr.TabbedInterface(
11
- interface_list=[
12
- CFA_tool.create_ui(),
13
- JPEG_Ghost_tool.create_ui(),
14
- PRNU_tool.create_ui(),
15
- shadows_tool.build_gradio_interface()
16
- ],
17
- tab_names=[
18
- "🎨 CFA Analysis",
19
- "👻 JPEG Ghost",
20
- "📸 PRNU Analysis",
21
- "☀️ Shadow Analysis"
22
- ],
23
- title="Digital Image Forensics Toolkit 🕵️‍♂️"
24
- )
25
-
26
- # Launch the app
 
 
 
 
 
 
27
  if __name__ == "__main__":
28
- demo.launch()
 
 
 
 
 
1
  import gradio as gr
2
 
3
+ # Import build functions from the tools (they are guarded to not auto-launch)
4
+ import CFA
5
+ import JPEG_Ghost
6
+ import PRNU
7
+ import shadows
8
+
9
+
10
+ def build_app():
11
+ with gr.Blocks(theme=gr.themes.Soft()) as demo:
12
+ gr.Markdown("# 🔍 Image Forensics Toolkit")
13
+ gr.Markdown(
14
+ "Use the tabs below to run different non-AI image forensics methods."
15
+ )
16
+
17
+ with gr.Tabs():
18
+ with gr.TabItem("CFA Artifacts"):
19
+ CFA.build_demo().render()
20
+
21
+ with gr.TabItem("JPEG Ghost / Double Compression"):
22
+ JPEG_Ghost.build_demo().render()
23
+
24
+ with gr.TabItem("PRNU (Camera Fingerprint)"):
25
+ PRNU.build_demo().render()
26
+
27
+ with gr.TabItem("Vanishing Points (Shadows/Geometry)"):
28
+ shadows.build_gradio_interface().render()
29
+
30
+ return demo
31
+
32
+
33
  if __name__ == "__main__":
34
+ app = build_app()
35
+ app.queue()
36
+ app.launch()
37
+
38
+
requirements.txt CHANGED
@@ -1,47 +1,9 @@
1
- asttokens==2.4.1
2
- backcall==0.2.0
3
- colorama==0.4.6
4
- comm==0.2.2
5
- contourpy==1.1.1
6
- cycler==0.12.1
7
- debugpy==1.8.8
8
- decorator==5.1.1
9
- executing==2.1.0
10
- fonttools==4.55.0
11
- imagecodecs==2023.3.16
12
- imageio==2.35.1
13
- importlib-metadata==8.5.0
14
- importlib-resources==6.4.5
15
- ipykernel==6.29.5
16
- ipython==8.12.3
17
- jedi==0.19.2
18
- jupyter-client==8.6.3
19
- jupyter-core==5.7.2
20
- kiwisolver==1.4.7
21
- lazy-loader==0.4
22
- matplotlib==3.7.5
23
- matplotlib-inline==0.1.7
24
- nest-asyncio==1.6.0
25
- networkx==3.1
26
  numpy==1.24.4
27
- opencv-python==4.10.0.84
28
- packaging==24.2
29
- parso==0.8.4
30
- pickleshare==0.7.5
31
  pillow==10.4.0
32
- platformdirs==4.3.6
33
- prompt-toolkit==3.0.48
34
- psutil==6.1.0
35
- pure-eval==0.2.3
36
- pygments==2.18.0
37
- pyparsing==3.1.4
38
- python-dateutil==2.9.0.post0
39
- PyWavelets==1.4.1
40
  scikit-image==0.21.0
41
- scipy==1.10.1
42
- six==1.16.0
43
- tornado==6.4.1
44
- gradio==5.49.1
45
- huggingface_hub
46
- fastapi
47
- uvicorn
 
1
+ gradio==5.49.1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  numpy==1.24.4
3
+ scipy==1.10.1
 
 
 
4
  pillow==10.4.0
5
+ matplotlib==3.7.5
6
+ imageio==2.35.1
 
 
 
 
 
 
7
  scikit-image==0.21.0
8
+ opencv-python==4.10.0.84
9
+ PyWavelets==1.4.1
 
 
 
 
 
shadow.py CHANGED
@@ -1,11 +1,36 @@
1
  """
2
- Gradio app for shadow analysis with vanishing-point selection tool
3
- Updated for Gradio 4.x compatibility
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  """
5
 
 
6
  import math
7
  import numpy as np
8
- from PIL import Image, ImageDraw
9
  import gradio as gr
10
  from scipy.optimize import minimize
11
 
@@ -20,11 +45,13 @@ def build_line_from_points(p1, p2):
20
  c = x1 * y2 - y1 * x2
21
  return np.array([a, b, c], dtype=float)
22
 
 
23
  def distance_point_to_line(pt, line):
24
  x, y = pt
25
  a, b, c = line
26
  return abs(a * x + b * y + c) / math.hypot(a, b)
27
 
 
28
  def total_distances(x, lines, noise_lines):
29
  """Sum of distances from candidate point x to all lines and noise lines."""
30
  pt = x
@@ -35,6 +62,7 @@ def total_distances(x, lines, noise_lines):
35
  s += distance_point_to_line(pt, Ln)
36
  return s
37
 
 
38
  def add_noise_lines_for_line(p1, p2, n=4, sigma=1.0):
39
  """Create a list of "noise" lines by jittering the endpoints slightly."""
40
  noise_lines = []
@@ -47,19 +75,41 @@ def add_noise_lines_for_line(p1, p2, n=4, sigma=1.0):
47
  # ------------------------- Drawing utilities ------------------------------
48
 
49
  def draw_overlay(base_pil, yellow_lines, red_lines, yellow_points, red_points, vps=None):
50
- """Return a new PIL image with overlays drawn: lines, points and vanishing points."""
 
 
 
 
 
51
  img = base_pil.copy().convert("RGBA")
52
  draw = ImageDraw.Draw(img)
53
 
 
54
  def draw_point(pt, color, r=4):
55
  x, y = pt
56
  draw.ellipse((x - r, y - r, x + r, y + r), fill=color, outline=color)
57
 
58
- def draw_line_by_points(p1, p2, color, width=2):
59
- draw.line((p1[0], p1[1], p2[0], p2[1]), fill=color, width=width)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
  # Draw yellow lines
62
  for idx, ((p1, p2), L) in enumerate(zip(yellow_points, yellow_lines)):
 
63
  draw_line_segment_from_line(L, img.size, color=(255, 215, 0, 200), draw=draw)
64
  draw_point(p1, (255, 215, 0, 255))
65
  draw_point(p2, (255, 215, 0, 255))
@@ -79,26 +129,36 @@ def draw_overlay(base_pil, yellow_lines, red_lines, yellow_points, red_points, v
79
 
80
  return img.convert("RGB")
81
 
 
82
  def draw_line_segment_from_line(line, image_size, draw=None, color=(255, 255, 0, 255)):
83
- """Given line coefficients and image size, draw a segment across the image bounds."""
 
 
84
  W, H = image_size
85
  a, b, c = line
86
  points = []
87
- # intersection with edges
88
  if abs(b) > 1e-9:
89
- y = -c / b
90
  points.append((0, y))
 
 
91
  y = -(a * W + c) / b
92
  points.append((W, y))
 
93
  if abs(a) > 1e-9:
94
- x = -c / a
95
  points.append((x, 0))
 
 
96
  x = -(b * H + c) / a
97
  points.append((x, H))
98
 
99
  # keep only points within the image bounds
100
  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]
101
  if len(pts_in) >= 2 and draw is not None:
 
 
102
  pts_in = sorted(pts_in, key=lambda p: (p[0], p[1]))
103
  pA = pts_in[0]
104
  pB = pts_in[-1]
@@ -106,22 +166,52 @@ def draw_line_segment_from_line(line, image_size, draw=None, color=(255, 255, 0,
106
 
107
  # ------------------------- Gradio app callbacks ---------------------------
108
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  def on_mode_change(mode, image, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs):
110
- """Switch drawing mode between 'yellow', 'red' or None."""
 
 
 
111
  return (image, mode, [], y_lines, r_lines, y_pairs, r_pairs)
112
 
113
- def on_image_click(evt: gr.SelectData, image, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs):
114
- """Called when user clicks on the image in Gradio 4.x"""
115
- if image is None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  return image, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs
117
 
118
- x, y = evt.index
119
-
120
- # Convert to list if needed
121
  current_points = list(current_points) if current_points is not None else []
122
  current_points.append((x, y))
123
 
124
- # If we have two points, create a line
125
  if len(current_points) >= 2 and current_mode in ("yellow", "red"):
126
  p1 = current_points[-2]
127
  p2 = current_points[-1]
@@ -136,51 +226,44 @@ def on_image_click(evt: gr.SelectData, image, current_mode, current_points, y_li
136
  r_pairs = list(r_pairs) if r_pairs is not None else []
137
  r_lines.append(L)
138
  r_pairs.append((p1, p2))
139
- # Reset current points for next line
140
- current_points = []
141
-
142
- # Redraw overlay
143
- if isinstance(image, np.ndarray):
144
- base_pil = Image.fromarray(image)
145
- else:
146
- base_pil = image
147
-
148
  out = draw_overlay(base_pil, y_lines or [], r_lines or [], y_pairs or [], r_pairs or [], vps=None)
149
- out_np = np.array(out)
150
 
151
- return out_np, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs
 
152
 
153
  def compute_vanishing_points(image, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs):
154
- """Compute vanishing points for both color groups."""
155
- if image is None:
156
- return image, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs
157
 
158
- if isinstance(image, np.ndarray):
159
- img_pil = Image.fromarray(image)
160
- else:
161
- img_pil = image
162
 
163
  vps = {"yellow": None, "red": None}
164
 
165
- # Process yellow group
166
  if y_lines and len(y_lines) > 1:
167
  lines_arr = np.array(y_lines)
 
168
  inters = []
169
  for i in range(len(lines_arr) - 1):
170
  for j in range(i + 1, len(lines_arr)):
171
  try:
172
- ip = np.linalg.solve(
173
- np.array([[lines_arr[i][0], lines_arr[i][1]], [lines_arr[j][0], lines_arr[j][1]]]),
174
- -np.array([lines_arr[i][2], lines_arr[j][2]])
175
- )
176
  inters.append(ip)
177
  except Exception:
178
  pass
179
  if inters:
180
  p0 = np.mean(inters, axis=0)
181
  else:
 
182
  p0 = np.array([img_pil.width / 2, img_pil.height / 2])
183
 
 
184
  noise = []
185
  for (p1, p2) in y_pairs:
186
  noise += add_noise_lines_for_line(p1, p2, n=4, sigma=2.0)
@@ -188,17 +271,15 @@ def compute_vanishing_points(image, current_mode, current_points, y_lines, r_lin
188
  res = minimize(lambda x: total_distances(x, lines_arr, noise), p0, method='Powell')
189
  vps['yellow'] = (float(res.x[0]), float(res.x[1]))
190
 
191
- # Process red group
192
  if r_lines and len(r_lines) > 1:
193
  lines_arr = np.array(r_lines)
194
  inters = []
195
  for i in range(len(lines_arr) - 1):
196
  for j in range(i + 1, len(lines_arr)):
197
  try:
198
- ip = np.linalg.solve(
199
- np.array([[lines_arr[i][0], lines_arr[i][1]], [lines_arr[j][0], lines_arr[j][1]]]),
200
- -np.array([lines_arr[i][2], lines_arr[j][2]])
201
- )
202
  inters.append(ip)
203
  except Exception:
204
  pass
@@ -215,49 +296,29 @@ def compute_vanishing_points(image, current_mode, current_points, y_lines, r_lin
215
  vps['red'] = (float(res.x[0]), float(res.x[1]))
216
 
217
  out = draw_overlay(img_pil, y_lines or [], r_lines or [], y_pairs or [], r_pairs or [], vps=vps)
218
- out_np = np.array(out)
219
 
220
- return out_np, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs
221
 
222
  def reset_all(image, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs):
223
- """Reset all states."""
224
- if image is not None:
225
- if isinstance(image, np.ndarray):
226
- return image, None, [], [], [], [], []
227
- else:
228
- return np.array(image), None, [], [], [], [], []
229
- return image, None, [], [], [], [], []
230
 
231
  # ------------------------------ Build Blocks ------------------------------
232
 
233
  def build_gradio_interface():
234
  with gr.Blocks() as demo:
235
- gr.Markdown("# Shadow Analysis - Vanishing Point Detection")
236
-
237
  with gr.Row():
238
- img_in = gr.Image(
239
- label="Upload image and click to add points",
240
- type="numpy",
241
- interactive=True,
242
- height=600
243
- )
244
  with gr.Column():
245
- start_y = gr.Button("Start Yellow Lines")
246
- start_r = gr.Button("Start Red Lines")
247
  none_btn = gr.Button("Stop Drawing")
248
- compute_btn = gr.Button("Compute Vanishing Points")
249
- reset_btn = gr.Button("Reset All")
250
-
251
- gr.Markdown("""
252
- **Instructions:**
253
- 1. Upload an image
254
- 2. Click 'Start Yellow' or 'Start Red' to choose line color
255
- 3. Click on the image to add points (2 points = 1 line)
256
- 4. Add at least 2 lines per color group
257
- 5. Click 'Compute Vanishing Points' to analyze
258
- """)
259
-
260
- # State variables
261
  current_mode = gr.State(None)
262
  current_points = gr.State([])
263
  y_lines = gr.State([])
@@ -265,46 +326,28 @@ def build_gradio_interface():
265
  y_pairs = gr.State([])
266
  r_pairs = gr.State([])
267
 
268
- # Event handlers
269
- start_y.click(
270
- fn=on_mode_change,
271
- inputs=[gr.State("yellow"), img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs],
272
- outputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs]
273
- )
274
-
275
- start_r.click(
276
- fn=on_mode_change,
277
- inputs=[gr.State("red"), img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs],
278
- outputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs]
279
- )
280
-
281
- none_btn.click(
282
- fn=on_mode_change,
283
- inputs=[gr.State(None), img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs],
284
- outputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs]
285
- )
286
-
287
- # Image click event - updated for Gradio 4.x
288
- img_in.select(
289
- fn=on_image_click,
290
- inputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs],
291
- outputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs]
292
- )
293
-
294
- compute_btn.click(
295
- fn=compute_vanishing_points,
296
- inputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs],
297
- outputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs]
298
- )
299
-
300
- reset_btn.click(
301
- fn=reset_all,
302
- inputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs],
303
- outputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs]
304
- )
305
 
306
  return demo
307
 
 
308
  if __name__ == '__main__':
309
  demo = build_gradio_interface()
310
- demo.launch()
 
 
1
  """
2
+ Gradio app to replicate the interactive vanishing-point selection tool
3
+ from the supplied matplotlib script, implemented for gradio==3.50.2.
4
+
5
+ How it works (UI):
6
+ - Upload an image.
7
+ - Click "Start Yellow" or "Start Red" to enter a drawing mode for that line group.
8
+ - Click on the image to add points. Two consecutive clicks make a line.
9
+ - You can add as many lines as you want for each color.
10
+ - Press "Compute vanishing points" to run optimization (scipy.minimize) for
11
+ each color group and display the vanishing points and overlayed lines.
12
+ - Reset clears all state.
13
+
14
+ Requirements:
15
+ - gradio==3.50.2
16
+ - numpy
17
+ - scipy
18
+ - pillow
19
+
20
+ Run:
21
+ pip install gradio==3.50.2 numpy scipy pillow
22
+ python grad_io_gradio_app.py
23
+
24
+ Note: This implementation uses the Image.select event which behaves correctly
25
+ in gradio 3.50.2 (it provides pixel coordinates of the clicked point). If you
26
+ use a newer Gradio version, the event behavior might differ.
27
+
28
  """
29
 
30
+ import io
31
  import math
32
  import numpy as np
33
+ from PIL import Image, ImageDraw, ImageFont
34
  import gradio as gr
35
  from scipy.optimize import minimize
36
 
 
45
  c = x1 * y2 - y1 * x2
46
  return np.array([a, b, c], dtype=float)
47
 
48
+
49
  def distance_point_to_line(pt, line):
50
  x, y = pt
51
  a, b, c = line
52
  return abs(a * x + b * y + c) / math.hypot(a, b)
53
 
54
+
55
  def total_distances(x, lines, noise_lines):
56
  """Sum of distances from candidate point x to all lines and noise lines."""
57
  pt = x
 
62
  s += distance_point_to_line(pt, Ln)
63
  return s
64
 
65
+
66
  def add_noise_lines_for_line(p1, p2, n=4, sigma=1.0):
67
  """Create a list of "noise" lines by jittering the endpoints slightly."""
68
  noise_lines = []
 
75
  # ------------------------- Drawing utilities ------------------------------
76
 
77
  def draw_overlay(base_pil, yellow_lines, red_lines, yellow_points, red_points, vps=None):
78
+ """Return a new PIL image with overlays drawn: lines, points and vanishing points.
79
+
80
+ - yellow_lines, red_lines: lists of line coefficients
81
+ - yellow_points, red_points: lists of tuples (p1, p2) for each line
82
+ - vps: dict with keys 'yellow' and 'red' for vanishing points (x,y)
83
+ """
84
  img = base_pil.copy().convert("RGBA")
85
  draw = ImageDraw.Draw(img)
86
 
87
+ # helpers
88
  def draw_point(pt, color, r=4):
89
  x, y = pt
90
  draw.ellipse((x - r, y - r, x + r, y + r), fill=color, outline=color)
91
 
92
+ def draw_line_by_points(p1, p2, color, width=2, dash=False):
93
+ # we just draw a straight segment connecting endpoints
94
+ if dash:
95
+ # dashed line: draw small segments
96
+ x1, y1 = p1
97
+ x2, y2 = p2
98
+ segs = 40
99
+ for i in range(segs):
100
+ t0 = i / segs
101
+ t1 = (i + 0.5) / segs
102
+ xa = x1 * (1 - t0) + x2 * t0
103
+ ya = y1 * (1 - t0) + y2 * t0
104
+ xb = x1 * (1 - t1) + x2 * t1
105
+ yb = y1 * (1 - t1) + y2 * t1
106
+ draw.line((xa, ya, xb, yb), fill=color, width=width)
107
+ else:
108
+ draw.line((p1[0], p1[1], p2[0], p2[1]), fill=color, width=width)
109
 
110
  # Draw yellow lines
111
  for idx, ((p1, p2), L) in enumerate(zip(yellow_points, yellow_lines)):
112
+ # draw long extents of line by projecting to image bounds
113
  draw_line_segment_from_line(L, img.size, color=(255, 215, 0, 200), draw=draw)
114
  draw_point(p1, (255, 215, 0, 255))
115
  draw_point(p2, (255, 215, 0, 255))
 
129
 
130
  return img.convert("RGB")
131
 
132
+
133
  def draw_line_segment_from_line(line, image_size, draw=None, color=(255, 255, 0, 255)):
134
+ """Given line coefficients and image size, draw a segment across the image bounds.
135
+ This draws directly using ImageDraw if 'draw' is provided.
136
+ """
137
  W, H = image_size
138
  a, b, c = line
139
  points = []
140
+ # intersection with left edge x=0
141
  if abs(b) > 1e-9:
142
+ y = -(a * 0 + c) / b
143
  points.append((0, y))
144
+ # right edge x=W
145
+ if abs(b) > 1e-9:
146
  y = -(a * W + c) / b
147
  points.append((W, y))
148
+ # top edge y=0 --> a x + c = 0
149
  if abs(a) > 1e-9:
150
+ x = -(b * 0 + c) / a
151
  points.append((x, 0))
152
+ # bottom edge y=H
153
+ if abs(a) > 1e-9:
154
  x = -(b * H + c) / a
155
  points.append((x, H))
156
 
157
  # keep only points within the image bounds
158
  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]
159
  if len(pts_in) >= 2 and draw is not None:
160
+ # pick two extreme points
161
+ # sort by x coordinate
162
  pts_in = sorted(pts_in, key=lambda p: (p[0], p[1]))
163
  pA = pts_in[0]
164
  pB = pts_in[-1]
 
166
 
167
  # ------------------------- Gradio app callbacks ---------------------------
168
 
169
+ # We'll store states in gr.State objects:
170
+ # - current_mode: None | 'yellow' | 'red'
171
+ # - current_points: list of pending points (len 0 or 1 waiting for second click)
172
+ # - yellow_lines: list of (A,B,C)
173
+ # - red_lines: list of (A,B,C)
174
+ # - yellow_points_pairs: list of ((p1,p2))
175
+ # - red_points_pairs: list of ((p1,p2))
176
+
177
+
178
+ def init_states():
179
+ return None, [], [], [], [], []
180
+
181
+
182
  def on_mode_change(mode, image, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs):
183
+ """Switch drawing mode between 'yellow', 'red' or None.
184
+ Returns image (unchanged) and updated states.
185
+ """
186
+ # Just update the mode state. Clear any pending single point.
187
  return (image, mode, [], y_lines, r_lines, y_pairs, r_pairs)
188
 
189
+
190
+ def on_image_select(sel: gr.SelectData, image, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs):
191
+ """Called when user clicks on the image. sel.index gives (x, y) in pixels.
192
+
193
+ We append the point, and when there are 2 points we form a line and add to the
194
+ corresponding color list. We then redraw overlays and return the updated image and states.
195
+ """
196
+ # sel may contain relative coords depending on gradio version; here we expect .index
197
+ if sel is None:
198
+ return image, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs
199
+
200
+ idx = getattr(sel, "index", None)
201
+ # Some versions wrap coordinates as [x, y], some as (x, y)
202
+ if idx is None:
203
+ # fallback: try .data or .value
204
+ idx = getattr(sel, "data", None) or getattr(sel, "value", None)
205
+ if not idx:
206
  return image, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs
207
 
208
+ x, y = int(idx[0]), int(idx[1])
209
+
210
+ # append to current_points
211
  current_points = list(current_points) if current_points is not None else []
212
  current_points.append((x, y))
213
 
214
+ # if we have two points, create a line
215
  if len(current_points) >= 2 and current_mode in ("yellow", "red"):
216
  p1 = current_points[-2]
217
  p2 = current_points[-1]
 
226
  r_pairs = list(r_pairs) if r_pairs is not None else []
227
  r_lines.append(L)
228
  r_pairs.append((p1, p2))
229
+
230
+ # redraw overlay image
231
+ base_pil = Image.fromarray(image) if not isinstance(image, Image.Image) else image
 
 
 
 
 
 
232
  out = draw_overlay(base_pil, y_lines or [], r_lines or [], y_pairs or [], r_pairs or [], vps=None)
 
233
 
234
+ return out, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs
235
+
236
 
237
  def compute_vanishing_points(image, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs):
238
+ """Compute vanishing points for both color groups, draw them and return annotated image.
 
 
239
 
240
+ For each group: if there are >1 lines, compute intersections and use mean intersection
241
+ as initial guess; then minimize sum of distances to lines + noise-lines.
242
+ """
243
+ img_pil = Image.fromarray(image) if not isinstance(image, Image.Image) else image
244
 
245
  vps = {"yellow": None, "red": None}
246
 
247
+ # process yellow group
248
  if y_lines and len(y_lines) > 1:
249
  lines_arr = np.array(y_lines)
250
+ # intersections
251
  inters = []
252
  for i in range(len(lines_arr) - 1):
253
  for j in range(i + 1, len(lines_arr)):
254
  try:
255
+ ip = np.linalg.solve(np.array([[lines_arr[i][0], lines_arr[i][1]],[lines_arr[j][0], lines_arr[j][1]]]),
256
+ -np.array([lines_arr[i][2], lines_arr[j][2]]))
 
 
257
  inters.append(ip)
258
  except Exception:
259
  pass
260
  if inters:
261
  p0 = np.mean(inters, axis=0)
262
  else:
263
+ # fallback: center of image
264
  p0 = np.array([img_pil.width / 2, img_pil.height / 2])
265
 
266
+ # noise lines
267
  noise = []
268
  for (p1, p2) in y_pairs:
269
  noise += add_noise_lines_for_line(p1, p2, n=4, sigma=2.0)
 
271
  res = minimize(lambda x: total_distances(x, lines_arr, noise), p0, method='Powell')
272
  vps['yellow'] = (float(res.x[0]), float(res.x[1]))
273
 
274
+ # process red group
275
  if r_lines and len(r_lines) > 1:
276
  lines_arr = np.array(r_lines)
277
  inters = []
278
  for i in range(len(lines_arr) - 1):
279
  for j in range(i + 1, len(lines_arr)):
280
  try:
281
+ ip = np.linalg.solve(np.array([[lines_arr[i][0], lines_arr[i][1]],[lines_arr[j][0], lines_arr[j][1]]]),
282
+ -np.array([lines_arr[i][2], lines_arr[j][2]]))
 
 
283
  inters.append(ip)
284
  except Exception:
285
  pass
 
296
  vps['red'] = (float(res.x[0]), float(res.x[1]))
297
 
298
  out = draw_overlay(img_pil, y_lines or [], r_lines or [], y_pairs or [], r_pairs or [], vps=vps)
299
+ return out, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs
300
 
 
301
 
302
  def reset_all(image, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs):
303
+ base_pil = Image.fromarray(image) if not isinstance(image, Image.Image) else image
304
+ return base_pil, None, [], [], [], [], []
 
 
 
 
 
305
 
306
  # ------------------------------ Build Blocks ------------------------------
307
 
308
  def build_gradio_interface():
309
  with gr.Blocks() as demo:
310
+ gr.Markdown("# grad.io Vanishing-point picker (Gradio 3.50.2 sample)")
 
311
  with gr.Row():
312
+ img_in = gr.Image(label="Upload image and then click to add points", type="numpy", interactive=True, height=800)
 
 
 
 
 
313
  with gr.Column():
314
+ start_y = gr.Button("Start Yellow")
315
+ start_r = gr.Button("Start Red")
316
  none_btn = gr.Button("Stop Drawing")
317
+ compute_btn = gr.Button("Compute vanishing points")
318
+ reset_btn = gr.Button("Reset")
319
+ gr.Markdown("\nClick the image to add points. Two points => one line. Add at least 2 lines per group to compute a vanishing point.")
320
+
321
+ # states
 
 
 
 
 
 
 
 
322
  current_mode = gr.State(None)
323
  current_points = gr.State([])
324
  y_lines = gr.State([])
 
326
  y_pairs = gr.State([])
327
  r_pairs = gr.State([])
328
 
329
+ # link buttons to mode change
330
+ start_y.click(on_mode_change, inputs=[gr.State("yellow"), img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs],
331
+ outputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs])
332
+ start_r.click(on_mode_change, inputs=[gr.State("red"), img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs],
333
+ outputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs])
334
+ none_btn.click(on_mode_change, inputs=[gr.State(None), img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs],
335
+ outputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs])
336
+
337
+ # image select event
338
+ img_in.select(on_image_select, inputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs],
339
+ outputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs])
340
+
341
+ compute_btn.click(compute_vanishing_points, inputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs],
342
+ outputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs])
343
+
344
+ reset_btn.click(reset_all, inputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs],
345
+ outputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
 
347
  return demo
348
 
349
+
350
  if __name__ == '__main__':
351
  demo = build_gradio_interface()
352
+ demo.queue()
353
+ demo.launch()
shadows.py DELETED
@@ -1,353 +0,0 @@
1
- """
2
- Gradio app to replicate the interactive vanishing-point selection tool
3
- from the supplied matplotlib script, implemented for gradio==3.50.2.
4
-
5
- How it works (UI):
6
- - Upload an image.
7
- - Click "Start Yellow" or "Start Red" to enter a drawing mode for that line group.
8
- - Click on the image to add points. Two consecutive clicks make a line.
9
- - You can add as many lines as you want for each color.
10
- - Press "Compute vanishing points" to run optimization (scipy.minimize) for
11
- each color group and display the vanishing points and overlayed lines.
12
- - Reset clears all state.
13
-
14
- Requirements:
15
- - gradio==3.50.2
16
- - numpy
17
- - scipy
18
- - pillow
19
-
20
- Run:
21
- pip install gradio==3.50.2 numpy scipy pillow
22
- python grad_io_gradio_app.py
23
-
24
- Note: This implementation uses the Image.select event which behaves correctly
25
- in gradio 3.50.2 (it provides pixel coordinates of the clicked point). If you
26
- use a newer Gradio version, the event behavior might differ.
27
-
28
- """
29
-
30
- import io
31
- import math
32
- import numpy as np
33
- from PIL import Image, ImageDraw, ImageFont
34
- import gradio as gr
35
- from scipy.optimize import minimize
36
-
37
- # ------------------------ Helper math functions ---------------------------
38
-
39
- def build_line_from_points(p1, p2):
40
- """Return line coefficients (A, B, C) for Ax + By + C = 0 given two points."""
41
- x1, y1 = p1
42
- x2, y2 = p2
43
- a = y1 - y2
44
- b = x2 - x1
45
- c = x1 * y2 - y1 * x2
46
- return np.array([a, b, c], dtype=float)
47
-
48
-
49
- def distance_point_to_line(pt, line):
50
- x, y = pt
51
- a, b, c = line
52
- return abs(a * x + b * y + c) / math.hypot(a, b)
53
-
54
-
55
- def total_distances(x, lines, noise_lines):
56
- """Sum of distances from candidate point x to all lines and noise lines."""
57
- pt = x
58
- s = 0.0
59
- for L in lines:
60
- s += distance_point_to_line(pt, L)
61
- for Ln in noise_lines:
62
- s += distance_point_to_line(pt, Ln)
63
- return s
64
-
65
-
66
- def add_noise_lines_for_line(p1, p2, n=4, sigma=1.0):
67
- """Create a list of "noise" lines by jittering the endpoints slightly."""
68
- noise_lines = []
69
- for _ in range(n):
70
- p1n = (p1[0] + np.random.normal(0, sigma), p1[1] + np.random.normal(0, sigma))
71
- p2n = (p2[0] + np.random.normal(0, sigma), p2[1] + np.random.normal(0, sigma))
72
- noise_lines.append(build_line_from_points(p1n, p2n))
73
- return noise_lines
74
-
75
- # ------------------------- Drawing utilities ------------------------------
76
-
77
- def draw_overlay(base_pil, yellow_lines, red_lines, yellow_points, red_points, vps=None):
78
- """Return a new PIL image with overlays drawn: lines, points and vanishing points.
79
-
80
- - yellow_lines, red_lines: lists of line coefficients
81
- - yellow_points, red_points: lists of tuples (p1, p2) for each line
82
- - vps: dict with keys 'yellow' and 'red' for vanishing points (x,y)
83
- """
84
- img = base_pil.copy().convert("RGBA")
85
- draw = ImageDraw.Draw(img)
86
-
87
- # helpers
88
- def draw_point(pt, color, r=4):
89
- x, y = pt
90
- draw.ellipse((x - r, y - r, x + r, y + r), fill=color, outline=color)
91
-
92
- def draw_line_by_points(p1, p2, color, width=2, dash=False):
93
- # we just draw a straight segment connecting endpoints
94
- if dash:
95
- # dashed line: draw small segments
96
- x1, y1 = p1
97
- x2, y2 = p2
98
- segs = 40
99
- for i in range(segs):
100
- t0 = i / segs
101
- t1 = (i + 0.5) / segs
102
- xa = x1 * (1 - t0) + x2 * t0
103
- ya = y1 * (1 - t0) + y2 * t0
104
- xb = x1 * (1 - t1) + x2 * t1
105
- yb = y1 * (1 - t1) + y2 * t1
106
- draw.line((xa, ya, xb, yb), fill=color, width=width)
107
- else:
108
- draw.line((p1[0], p1[1], p2[0], p2[1]), fill=color, width=width)
109
-
110
- # Draw yellow lines
111
- for idx, ((p1, p2), L) in enumerate(zip(yellow_points, yellow_lines)):
112
- # draw long extents of line by projecting to image bounds
113
- draw_line_segment_from_line(L, img.size, color=(255, 215, 0, 200), draw=draw)
114
- draw_point(p1, (255, 215, 0, 255))
115
- draw_point(p2, (255, 215, 0, 255))
116
-
117
- # Draw red lines
118
- for idx, ((p1, p2), L) in enumerate(zip(red_points, red_lines)):
119
- draw_line_segment_from_line(L, img.size, color=(255, 64, 64, 200), draw=draw)
120
- draw_point(p1, (255, 64, 64, 255))
121
- draw_point(p2, (255, 64, 64, 255))
122
-
123
- # Draw vanishing points if present
124
- if vps is not None:
125
- if "yellow" in vps and vps["yellow"] is not None:
126
- draw_point(vps["yellow"], (255, 215, 0, 255), r=6)
127
- if "red" in vps and vps["red"] is not None:
128
- draw_point(vps["red"], (255, 64, 64, 255), r=6)
129
-
130
- return img.convert("RGB")
131
-
132
-
133
- def draw_line_segment_from_line(line, image_size, draw=None, color=(255, 255, 0, 255)):
134
- """Given line coefficients and image size, draw a segment across the image bounds.
135
- This draws directly using ImageDraw if 'draw' is provided.
136
- """
137
- W, H = image_size
138
- a, b, c = line
139
- points = []
140
- # intersection with left edge x=0
141
- if abs(b) > 1e-9:
142
- y = -(a * 0 + c) / b
143
- points.append((0, y))
144
- # right edge x=W
145
- if abs(b) > 1e-9:
146
- y = -(a * W + c) / b
147
- points.append((W, y))
148
- # top edge y=0 --> a x + c = 0
149
- if abs(a) > 1e-9:
150
- x = -(b * 0 + c) / a
151
- points.append((x, 0))
152
- # bottom edge y=H
153
- if abs(a) > 1e-9:
154
- x = -(b * H + c) / a
155
- points.append((x, H))
156
-
157
- # keep only points within the image bounds
158
- 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]
159
- if len(pts_in) >= 2 and draw is not None:
160
- # pick two extreme points
161
- # sort by x coordinate
162
- pts_in = sorted(pts_in, key=lambda p: (p[0], p[1]))
163
- pA = pts_in[0]
164
- pB = pts_in[-1]
165
- draw.line((pA[0], pA[1], pB[0], pB[1]), fill=color, width=2)
166
-
167
- # ------------------------- Gradio app callbacks ---------------------------
168
-
169
- # We'll store states in gr.State objects:
170
- # - current_mode: None | 'yellow' | 'red'
171
- # - current_points: list of pending points (len 0 or 1 waiting for second click)
172
- # - yellow_lines: list of (A,B,C)
173
- # - red_lines: list of (A,B,C)
174
- # - yellow_points_pairs: list of ((p1,p2))
175
- # - red_points_pairs: list of ((p1,p2))
176
-
177
-
178
- def init_states():
179
- return None, [], [], [], [], []
180
-
181
-
182
- def on_mode_change(mode, image, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs):
183
- """Switch drawing mode between 'yellow', 'red' or None.
184
- Returns image (unchanged) and updated states.
185
- """
186
- # Just update the mode state. Clear any pending single point.
187
- return (image, mode, [], y_lines, r_lines, y_pairs, r_pairs)
188
-
189
-
190
- def on_image_select(sel: gr.SelectData, image, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs):
191
- """Called when user clicks on the image. sel.index gives (x, y) in pixels.
192
-
193
- We append the point, and when there are 2 points we form a line and add to the
194
- corresponding color list. We then redraw overlays and return the updated image and states.
195
- """
196
- # sel may contain relative coords depending on gradio version; here we expect .index
197
- if sel is None:
198
- return image, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs
199
-
200
- idx = getattr(sel, "index", None)
201
- # Some versions wrap coordinates as [x, y], some as (x, y)
202
- if idx is None:
203
- # fallback: try .data or .value
204
- idx = getattr(sel, "data", None) or getattr(sel, "value", None)
205
- if not idx:
206
- return image, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs
207
-
208
- x, y = int(idx[0]), int(idx[1])
209
-
210
- # append to current_points
211
- current_points = list(current_points) if current_points is not None else []
212
- current_points.append((x, y))
213
-
214
- # if we have two points, create a line
215
- if len(current_points) >= 2 and current_mode in ("yellow", "red"):
216
- p1 = current_points[-2]
217
- p2 = current_points[-1]
218
- L = build_line_from_points(p1, p2)
219
- if current_mode == "yellow":
220
- y_lines = list(y_lines) if y_lines is not None else []
221
- y_pairs = list(y_pairs) if y_pairs is not None else []
222
- y_lines.append(L)
223
- y_pairs.append((p1, p2))
224
- else:
225
- r_lines = list(r_lines) if r_lines is not None else []
226
- r_pairs = list(r_pairs) if r_pairs is not None else []
227
- r_lines.append(L)
228
- r_pairs.append((p1, p2))
229
-
230
- # redraw overlay image
231
- base_pil = Image.fromarray(image) if not isinstance(image, Image.Image) else image
232
- out = draw_overlay(base_pil, y_lines or [], r_lines or [], y_pairs or [], r_pairs or [], vps=None)
233
-
234
- return out, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs
235
-
236
-
237
- def compute_vanishing_points(image, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs):
238
- """Compute vanishing points for both color groups, draw them and return annotated image.
239
-
240
- For each group: if there are >1 lines, compute intersections and use mean intersection
241
- as initial guess; then minimize sum of distances to lines + noise-lines.
242
- """
243
- img_pil = Image.fromarray(image) if not isinstance(image, Image.Image) else image
244
-
245
- vps = {"yellow": None, "red": None}
246
-
247
- # process yellow group
248
- if y_lines and len(y_lines) > 1:
249
- lines_arr = np.array(y_lines)
250
- # intersections
251
- inters = []
252
- for i in range(len(lines_arr) - 1):
253
- for j in range(i + 1, len(lines_arr)):
254
- try:
255
- ip = np.linalg.solve(np.array([[lines_arr[i][0], lines_arr[i][1]],[lines_arr[j][0], lines_arr[j][1]]]),
256
- -np.array([lines_arr[i][2], lines_arr[j][2]]))
257
- inters.append(ip)
258
- except Exception:
259
- pass
260
- if inters:
261
- p0 = np.mean(inters, axis=0)
262
- else:
263
- # fallback: center of image
264
- p0 = np.array([img_pil.width / 2, img_pil.height / 2])
265
-
266
- # noise lines
267
- noise = []
268
- for (p1, p2) in y_pairs:
269
- noise += add_noise_lines_for_line(p1, p2, n=4, sigma=2.0)
270
-
271
- res = minimize(lambda x: total_distances(x, lines_arr, noise), p0, method='Powell')
272
- vps['yellow'] = (float(res.x[0]), float(res.x[1]))
273
-
274
- # process red group
275
- if r_lines and len(r_lines) > 1:
276
- lines_arr = np.array(r_lines)
277
- inters = []
278
- for i in range(len(lines_arr) - 1):
279
- for j in range(i + 1, len(lines_arr)):
280
- try:
281
- ip = np.linalg.solve(np.array([[lines_arr[i][0], lines_arr[i][1]],[lines_arr[j][0], lines_arr[j][1]]]),
282
- -np.array([lines_arr[i][2], lines_arr[j][2]]))
283
- inters.append(ip)
284
- except Exception:
285
- pass
286
- if inters:
287
- p0 = np.mean(inters, axis=0)
288
- else:
289
- p0 = np.array([img_pil.width / 2, img_pil.height / 2])
290
-
291
- noise = []
292
- for (p1, p2) in r_pairs:
293
- noise += add_noise_lines_for_line(p1, p2, n=4, sigma=2.0)
294
-
295
- res = minimize(lambda x: total_distances(x, lines_arr, noise), p0, method='Powell')
296
- vps['red'] = (float(res.x[0]), float(res.x[1]))
297
-
298
- out = draw_overlay(img_pil, y_lines or [], r_lines or [], y_pairs or [], r_pairs or [], vps=vps)
299
- return out, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs
300
-
301
-
302
- def reset_all(image, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs):
303
- base_pil = Image.fromarray(image) if not isinstance(image, Image.Image) else image
304
- return base_pil, None, [], [], [], [], []
305
-
306
- # ------------------------------ Build Blocks ------------------------------
307
-
308
- def build_gradio_interface():
309
- with gr.Blocks() as demo:
310
- gr.Markdown("# grad.io — Vanishing-point picker (Gradio 3.50.2 sample)")
311
- with gr.Row():
312
- img_in = gr.Image(label="Upload image and then click to add points", type="numpy", interactive=True, height=800)
313
- with gr.Column():
314
- start_y = gr.Button("Start Yellow")
315
- start_r = gr.Button("Start Red")
316
- none_btn = gr.Button("Stop Drawing")
317
- compute_btn = gr.Button("Compute vanishing points")
318
- reset_btn = gr.Button("Reset")
319
- gr.Markdown("\nClick the image to add points. Two points => one line. Add at least 2 lines per group to compute a vanishing point.")
320
-
321
- # states
322
- current_mode = gr.State(None)
323
- current_points = gr.State([])
324
- y_lines = gr.State([])
325
- r_lines = gr.State([])
326
- y_pairs = gr.State([])
327
- r_pairs = gr.State([])
328
-
329
- # link buttons to mode change
330
- start_y.click(on_mode_change, inputs=[gr.State("yellow"), img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs],
331
- outputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs])
332
- start_r.click(on_mode_change, inputs=[gr.State("red"), img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs],
333
- outputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs])
334
- none_btn.click(on_mode_change, inputs=[gr.State(None), img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs],
335
- outputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs])
336
-
337
- # image select event
338
- img_in.select(on_image_select, inputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs],
339
- outputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs])
340
-
341
- compute_btn.click(compute_vanishing_points, inputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs],
342
- outputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs])
343
-
344
- reset_btn.click(reset_all, inputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs],
345
- outputs=[img_in, current_mode, current_points, y_lines, r_lines, y_pairs, r_pairs])
346
-
347
- return demo
348
-
349
-
350
- if __name__ == '__main__':
351
- demo = build_gradio_interface()
352
- demo.queue()
353
- demo.launch()