HaanIlango commited on
Commit
d9d9108
·
verified ·
1 Parent(s): e67466c

Upload 4 files

Browse files
Files changed (4) hide show
  1. README.md +6 -7
  2. app.py +282 -0
  3. packages.txt +1 -0
  4. requirements.txt +6 -0
README.md CHANGED
@@ -1,14 +1,13 @@
1
  ---
2
  title: Design Analyzer
3
- emoji: 🐠
4
- colorFrom: gray
5
- colorTo: blue
6
  sdk: gradio
7
- sdk_version: 6.20.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
- short_description: Helps With Your design
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
1
  ---
2
  title: Design Analyzer
3
+ emoji: 🎯
4
+ colorFrom: blue
5
+ colorTo: yellow
6
  sdk: gradio
 
 
7
  app_file: app.py
8
  pinned: false
 
9
  ---
10
 
11
+ # Design Analyzer
12
+ Upload a design to check where attention is drawn, plus text contrast and readability.
13
+ Powered by MSI-Net (Kroner et al., Neural Networks 2020).
app.py ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cv2
2
+ import numpy as np
3
+ import gradio as gr
4
+ from PIL import Image
5
+ import tensorflow as tf
6
+ import keras
7
+ from huggingface_hub import snapshot_download
8
+ import pytesseract
9
+
10
+ _msi_model = None
11
+
12
+
13
+ def load_msi_net():
14
+ global _msi_model
15
+ if _msi_model is None:
16
+ hf_dir = snapshot_download(repo_id="alexanderkroner/MSI-Net")
17
+ _msi_model = keras.layers.TFSMLayer(hf_dir, call_endpoint="serving_default")
18
+ return _msi_model
19
+
20
+
21
+ def get_target_shape(original_shape):
22
+ ar = original_shape[0] / original_shape[1]
23
+ square_mode = abs(ar - 1.0)
24
+ landscape_mode = abs(ar - 240 / 320)
25
+ portrait_mode = abs(ar - 320 / 240)
26
+ best = min(square_mode, landscape_mode, portrait_mode)
27
+ if best == square_mode:
28
+ return (320, 320)
29
+ elif best == landscape_mode:
30
+ return (240, 320)
31
+ else:
32
+ return (320, 240)
33
+
34
+
35
+ def preprocess_input(input_image, target_shape):
36
+ t = tf.expand_dims(input_image, axis=0)
37
+ t = tf.image.resize(t, target_shape, preserve_aspect_ratio=True)
38
+ vp = target_shape[0] - t.shape[1]
39
+ hp = target_shape[1] - t.shape[2]
40
+ v1, v2 = vp // 2, vp - vp // 2
41
+ h1, h2 = hp // 2, hp - hp // 2
42
+ t = tf.pad(t, [[0, 0], [v1, v2], [h1, h2], [0, 0]])
43
+ return t, [v1, v2], [h1, h2]
44
+
45
+
46
+ def postprocess_output(output_tensor, vp, hp, original_shape):
47
+ output_tensor = output_tensor[:, vp[0]:output_tensor.shape[1] - vp[1], hp[0]:output_tensor.shape[2] - hp[1], :]
48
+ output_tensor = tf.image.resize(output_tensor, original_shape)
49
+ return output_tensor.numpy().squeeze()
50
+
51
+
52
+ def compute_saliency_map(pil_image):
53
+ model = load_msi_net()
54
+ input_image = np.array(pil_image.convert("RGB"), dtype=np.float32)
55
+ original_shape = input_image.shape[:2]
56
+ target_shape = get_target_shape(original_shape)
57
+ input_tensor, v_pad, h_pad = preprocess_input(input_image, target_shape)
58
+ raw_output = model(input_tensor)
59
+ output_tensor = list(raw_output.values())[0] if isinstance(raw_output, dict) else raw_output
60
+ saliency = postprocess_output(output_tensor, v_pad, h_pad, original_shape)
61
+ saliency = saliency.astype(np.float32)
62
+ saliency -= saliency.min()
63
+ if saliency.max() > 0:
64
+ saliency /= saliency.max()
65
+ return saliency
66
+
67
+
68
+ def render_heatmap_overlay(img_bgr, saliency_map, alpha=0.45):
69
+ heat_u8 = (saliency_map * 255).astype(np.uint8)
70
+ heat_color = cv2.applyColorMap(heat_u8, cv2.COLORMAP_JET)
71
+ return cv2.addWeighted(heat_color, alpha, img_bgr, 1 - alpha, 0)
72
+
73
+
74
+ def compute_attention_scores(saliency_map):
75
+ flat = np.sort(saliency_map.flatten())
76
+ n = len(flat)
77
+ cum = np.cumsum(flat)
78
+ gini = (n + 1 - 2 * np.sum(cum) / (cum[-1] + 1e-8)) / n
79
+ focus_score = float(gini) * 100
80
+ spread_score = float((saliency_map >= 0.5).mean()) * 100
81
+ return {"focus_score": round(focus_score, 1), "spread_score": round(spread_score, 1)}
82
+
83
+
84
+ def compute_overall_grade(focus_score, spread_score, read):
85
+ readability_score = max(0, 100 - read['contrast_issues'] * 15 - read['small_text_issues'] * 10)
86
+ total = round(0.55 * focus_score + 0.20 * spread_score + 0.25 * readability_score)
87
+ total = max(0, min(100, total))
88
+ if total >= 75:
89
+ grade = "Good"
90
+ elif total >= 50:
91
+ grade = "Fair"
92
+ else:
93
+ grade = "Needs Work"
94
+ return total, grade
95
+
96
+
97
+ def generate_recommendations(saliency_map, scores, read, img_h, img_w):
98
+ tips = []
99
+ bottom_band = saliency_map[int(img_h * 0.85):, :]
100
+ bottom_avg = bottom_band.mean()
101
+ if bottom_avg < 0.25:
102
+ tips.append("Your bottom section (often where CTAs or contact info sit) is getting low attention. Consider bolder color contrast or larger text there.")
103
+
104
+ if scores['focus_score'] < 40:
105
+ tips.append("Attention is scattered with no clear focal point. Consider making one element (headline, product, or offer) visually dominant.")
106
+ elif scores['focus_score'] > 90:
107
+ tips.append("Attention is very narrowly focused on one spot - double check other important elements (logo, CTA) are not being ignored.")
108
+
109
+ if scores['spread_score'] < 3:
110
+ tips.append("Very little of the design pulls attention beyond the main hotspot. Secondary elements may go unnoticed by viewers.")
111
+
112
+ if read['contrast_issues'] > 0:
113
+ tips.append(f"Fix contrast on {read['contrast_issues']} text element(s) - darken text or lighten its background to reach 4.5:1 minimum.")
114
+ if read['small_text_issues'] > 0:
115
+ tips.append(f"Increase size on {read['small_text_issues']} text element(s) - text under ~1.2% of image height risks being unreadable.")
116
+
117
+ if not tips:
118
+ tips.append("No major issues detected - this design is in solid shape.")
119
+
120
+ return tips
121
+
122
+
123
+ def identify_hotspots(saliency_map, img_h, img_w, top_n=4):
124
+ threshold = np.percentile(saliency_map, 85)
125
+ binary = (saliency_map >= threshold).astype(np.uint8)
126
+ kernel = np.ones((25, 25), np.uint8)
127
+ binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)
128
+ num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(binary, connectivity=8)
129
+ zones = []
130
+ for i in range(1, num_labels):
131
+ area = stats[i, cv2.CC_STAT_AREA]
132
+ if area < (img_h * img_w * 0.005):
133
+ continue
134
+ cx, cy = centroids[i]
135
+ h_pos = "top" if cy < img_h * 0.33 else ("bottom" if cy > img_h * 0.66 else "middle")
136
+ w_pos = "left" if cx < img_w * 0.33 else ("right" if cx > img_w * 0.66 else "center")
137
+ avg_intensity = float(saliency_map[labels == i].mean())
138
+ zones.append({"position": f"{h_pos}-{w_pos}", "area_pct": round(float(area / (img_h * img_w)) * 100, 1),
139
+ "intensity": round(avg_intensity * 100, 1)})
140
+ zones.sort(key=lambda z: -z["intensity"])
141
+ return zones[:top_n]
142
+
143
+
144
+ def relative_luminance(rgb):
145
+ def chan(c):
146
+ c = c / 255.0
147
+ return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
148
+ r, g, b = rgb
149
+ return 0.2126 * chan(r) + 0.7152 * chan(g) + 0.0722 * chan(b)
150
+
151
+
152
+ def contrast_ratio(rgb1, rgb2):
153
+ l1 = relative_luminance(rgb1)
154
+ l2 = relative_luminance(rgb2)
155
+ lighter, darker = max(l1, l2), min(l1, l2)
156
+ return (lighter + 0.05) / (darker + 0.05)
157
+
158
+
159
+ def sample_text_and_bg_color(img_arr, x, y, w, h):
160
+ pad = max(2, int(h * 0.3))
161
+ y0, y1 = max(0, y - pad), min(img_arr.shape[0], y + h + pad)
162
+ x0, x1 = max(0, x - pad), min(img_arr.shape[1], x + w + pad)
163
+ region = img_arr[y0:y1, x0:x1]
164
+ gray = cv2.cvtColor(region, cv2.COLOR_RGB2GRAY)
165
+ thresh = np.median(gray)
166
+ dark_mask = gray < thresh
167
+ light_mask = ~dark_mask
168
+ dark_px = region[dark_mask]
169
+ light_px = region[light_mask]
170
+ if len(dark_px) == 0 or len(light_px) == 0:
171
+ return None, None
172
+ text_color = tuple(np.median(dark_px, axis=0).astype(int))
173
+ bg_color = tuple(np.median(light_px, axis=0).astype(int))
174
+ return text_color, bg_color
175
+
176
+
177
+ def analyze_readability(pil_img):
178
+ img_arr = np.array(pil_img.convert("RGB"))
179
+ img_h = img_arr.shape[0]
180
+ ocr_data = pytesseract.image_to_data(img_arr, output_type=pytesseract.Output.DICT)
181
+ words_checked = 0
182
+ contrast_issues = 0
183
+ small_text_issues = 0
184
+ issue_lines = []
185
+ for i in range(len(ocr_data['text'])):
186
+ word = ocr_data['text'][i].strip()
187
+ conf = int(ocr_data['conf'][i])
188
+ if not word or conf < 60 or len(word) < 2:
189
+ continue
190
+ x, y, w, h = ocr_data['left'][i], ocr_data['top'][i], ocr_data['width'][i], ocr_data['height'][i]
191
+ if w < 3 or h < 3:
192
+ continue
193
+ words_checked += 1
194
+ text_color, bg_color = sample_text_and_bg_color(img_arr, x, y, w, h)
195
+ if text_color is None:
196
+ continue
197
+ ratio = contrast_ratio(text_color, bg_color)
198
+ size_pct = (h / img_h) * 100
199
+ if ratio < 4.5:
200
+ contrast_issues += 1
201
+ if len(issue_lines) < 5:
202
+ issue_lines.append(f"- Low contrast on \"{word}\" ({ratio:.1f}:1, needs 4.5:1 minimum)")
203
+ if size_pct < 1.2:
204
+ small_text_issues += 1
205
+ if len(issue_lines) < 5:
206
+ issue_lines.append(f"- Small text \"{word}\" ({size_pct:.1f}% of image height)")
207
+ return {"words_checked": words_checked, "contrast_issues": contrast_issues,
208
+ "small_text_issues": small_text_issues, "issue_lines": issue_lines}
209
+
210
+
211
+ def analyze(pil_image, overlay_strength):
212
+ if pil_image is None:
213
+ return None, "Upload an image first."
214
+ pil_rgb = pil_image.convert("RGB")
215
+ img_bgr = cv2.cvtColor(np.array(pil_rgb), cv2.COLOR_RGB2BGR)
216
+ h, w = img_bgr.shape[:2]
217
+
218
+ saliency_map = compute_saliency_map(pil_rgb)
219
+ overlay_bgr = render_heatmap_overlay(img_bgr, saliency_map, alpha=overlay_strength)
220
+ overlay_rgb = cv2.cvtColor(overlay_bgr, cv2.COLOR_BGR2RGB)
221
+
222
+ scores = compute_attention_scores(saliency_map)
223
+ zones = identify_hotspots(saliency_map, h, w)
224
+ read = analyze_readability(pil_rgb)
225
+ total_score, grade = compute_overall_grade(scores['focus_score'], scores['spread_score'], read)
226
+ tips = generate_recommendations(saliency_map, scores, read, h, w)
227
+
228
+ summary = f"## Grade: {grade} — {total_score}/100\n\n"
229
+
230
+ summary += "**Top Attention Areas:**\n"
231
+ if zones:
232
+ for z in zones:
233
+ summary += f"- {z['position'].replace('-', ' ').title()} ({z['intensity']}/100 intensity, {z['area_pct']}% of design)\n"
234
+ else:
235
+ summary += "- Attention is fairly even across the design, no strong single hotspot.\n"
236
+
237
+ summary += (
238
+ f"\n**Scores:** Focus {scores['focus_score']}/100 &nbsp;•&nbsp; "
239
+ f"Spread {scores['spread_score']}/100 &nbsp;•&nbsp; "
240
+ f"Readability: "
241
+ )
242
+ if read['words_checked'] == 0:
243
+ summary += "no text detected\n"
244
+ elif read['contrast_issues'] == 0 and read['small_text_issues'] == 0:
245
+ summary += "clean (0 issues)\n"
246
+ else:
247
+ summary += f"{read['contrast_issues'] + read['small_text_issues']} issue(s) found\n"
248
+
249
+ if read['issue_lines']:
250
+ summary += "\n**Readability details:**\n" + "\n".join(read['issue_lines']) + "\n"
251
+
252
+ summary += "\n**How to Improve:**\n"
253
+ for tip in tips:
254
+ summary += f"- {tip}\n"
255
+
256
+ summary += (
257
+ "\n---\n"
258
+ "**Methodology:** Attention prediction powered by MSI-Net, a peer-reviewed saliency model "
259
+ "(Kroner et al., *Neural Networks*, 2020, "
260
+ "[DOI: 10.1016/j.neunet.2020.05.004](https://doi.org/10.1016/j.neunet.2020.05.004)), "
261
+ "evaluated on the MIT/Tübingen Saliency Benchmark - the same standard used by commercial "
262
+ "tools like Attention Insight. This is a data-informed prediction, not a substitute for "
263
+ "live user testing."
264
+ )
265
+
266
+ return Image.fromarray(overlay_rgb), summary
267
+
268
+
269
+ with gr.Blocks(title="Design Analyzer") as demo:
270
+ gr.Markdown("# Design Analyzer")
271
+ gr.Markdown("Upload a design to check where attention is drawn, plus text contrast and readability.")
272
+ with gr.Row():
273
+ with gr.Column():
274
+ image_input = gr.Image(type="pil", label="Upload design")
275
+ strength_slider = gr.Slider(0.1, 0.8, value=0.45, step=0.05, label="Heatmap overlay strength")
276
+ analyze_btn = gr.Button("Analyze", variant="primary")
277
+ with gr.Column():
278
+ image_output = gr.Image(label="Heatmap result")
279
+ text_output = gr.Markdown()
280
+ analyze_btn.click(fn=analyze, inputs=[image_input, strength_slider], outputs=[image_output, text_output])
281
+
282
+ demo.launch()
packages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ tesseract-ocr
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ opencv-contrib-python-headless
2
+ pytesseract
3
+ tensorflow
4
+ keras
5
+ huggingface_hub
6
+ gradio