HaanIlango commited on
Commit
1a202cc
·
verified ·
1 Parent(s): f6513fa

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +795 -0
app.py ADDED
@@ -0,0 +1,795 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ import io
10
+ import math
11
+ import os
12
+ import re
13
+ import tempfile
14
+ from collections import defaultdict
15
+ import matplotlib
16
+ matplotlib.use("Agg")
17
+ import matplotlib.pyplot as plt
18
+ from reportlab.lib.pagesizes import letter
19
+ from reportlab.lib.units import inch
20
+ from reportlab.lib.styles import getSampleStyleSheet
21
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image as RLImage
22
+
23
+ _msi_model = None
24
+ LOGO_PATH = "logo.png"
25
+
26
+
27
+ def add_watermark(pil_img, logo_path=LOGO_PATH, scale=0.20, opacity=0.95):
28
+ if not os.path.exists(logo_path):
29
+ return pil_img
30
+ try:
31
+ base = pil_img.convert("RGBA")
32
+ logo = Image.open(logo_path).convert("RGBA")
33
+ bbox = logo.getbbox()
34
+ if bbox:
35
+ logo = logo.crop(bbox)
36
+ img_w, img_h = base.size
37
+ logo_w = max(50, int(img_w * scale))
38
+ logo_ratio = logo.height / logo.width
39
+ logo_h = int(logo_w * logo_ratio)
40
+ logo_resized = logo.resize((logo_w, logo_h), Image.LANCZOS)
41
+
42
+ r, g, b, a = logo_resized.split()
43
+ a = a.point(lambda p: int(p * opacity))
44
+ logo_resized = Image.merge("RGBA", (r, g, b, a))
45
+
46
+ margin = max(10, int(img_w * 0.015))
47
+ pos = (img_w - logo_w - margin, img_h - logo_h - margin)
48
+
49
+ base.paste(logo_resized, pos, logo_resized)
50
+ return base.convert("RGB")
51
+ except Exception:
52
+ return pil_img
53
+
54
+
55
+ def load_msi_net():
56
+ global _msi_model
57
+ if _msi_model is None:
58
+ hf_dir = snapshot_download(repo_id="alexanderkroner/MSI-Net")
59
+ _msi_model = keras.layers.TFSMLayer(hf_dir, call_endpoint="serving_default")
60
+ return _msi_model
61
+
62
+
63
+ def get_target_shape(original_shape):
64
+ ar = original_shape[0] / original_shape[1]
65
+ square_mode = abs(ar - 1.0)
66
+ landscape_mode = abs(ar - 240 / 320)
67
+ portrait_mode = abs(ar - 320 / 240)
68
+ best = min(square_mode, landscape_mode, portrait_mode)
69
+ if best == square_mode:
70
+ return (320, 320)
71
+ elif best == landscape_mode:
72
+ return (240, 320)
73
+ else:
74
+ return (320, 240)
75
+
76
+
77
+ def preprocess_input(input_image, target_shape):
78
+ t = tf.expand_dims(input_image, axis=0)
79
+ t = tf.image.resize(t, target_shape, preserve_aspect_ratio=True)
80
+ vp = target_shape[0] - t.shape[1]
81
+ hp = target_shape[1] - t.shape[2]
82
+ v1, v2 = vp // 2, vp - vp // 2
83
+ h1, h2 = hp // 2, hp - hp // 2
84
+ t = tf.pad(t, [[0, 0], [v1, v2], [h1, h2], [0, 0]])
85
+ return t, [v1, v2], [h1, h2]
86
+
87
+
88
+ def postprocess_output(output_tensor, vp, hp, original_shape):
89
+ output_tensor = output_tensor[:, vp[0]:output_tensor.shape[1] - vp[1], hp[0]:output_tensor.shape[2] - hp[1], :]
90
+ output_tensor = tf.image.resize(output_tensor, original_shape)
91
+ return output_tensor.numpy().squeeze()
92
+
93
+
94
+ def compute_text_boost_map(pil_img):
95
+ img_arr = np.array(pil_img.convert("RGB"))
96
+ h, w = img_arr.shape[:2]
97
+ data = pytesseract.image_to_data(img_arr, config="--psm 11", output_type=pytesseract.Output.DICT)
98
+ text_map = np.zeros((h, w), dtype=np.float32)
99
+ for i in range(len(data['text'])):
100
+ word = data['text'][i].strip()
101
+ tw_, th_ = data['width'][i], data['height'][i]
102
+ if not word or th_ < 8 or tw_ < 5:
103
+ continue
104
+ x, y = data['left'][i], data['top'][i]
105
+ weight = min(1.0, (th_ / h) * 6)
106
+ pad = int(th_ * 0.15)
107
+ y0, y1 = max(0, y - pad), min(h, y + th_ + pad)
108
+ x0, x1 = max(0, x - pad), min(w, x + tw_ + pad)
109
+ text_map[y0:y1, x0:x1] = np.maximum(text_map[y0:y1, x0:x1], weight)
110
+ return text_map
111
+
112
+
113
+ def compute_saliency_map(pil_image):
114
+ model = load_msi_net()
115
+ input_image = np.array(pil_image.convert("RGB"), dtype=np.float32)
116
+ original_shape = input_image.shape[:2]
117
+ target_shape = get_target_shape(original_shape)
118
+ input_tensor, v_pad, h_pad = preprocess_input(input_image, target_shape)
119
+ raw_output = model(input_tensor)
120
+ output_tensor = list(raw_output.values())[0] if isinstance(raw_output, dict) else raw_output
121
+ msi_saliency = postprocess_output(output_tensor, v_pad, h_pad, original_shape)
122
+ msi_saliency = msi_saliency.astype(np.float32)
123
+ msi_saliency -= msi_saliency.min()
124
+ if msi_saliency.max() > 0:
125
+ msi_saliency /= msi_saliency.max()
126
+
127
+ text_boost = compute_text_boost_map(pil_image)
128
+ combined = 0.7 * msi_saliency + 0.3 * text_boost
129
+ return combined
130
+
131
+
132
+ def render_heatmap_overlay(img_bgr, saliency_map, alpha=0.45):
133
+ heat_u8 = (saliency_map * 255).astype(np.uint8)
134
+ heat_color = cv2.applyColorMap(heat_u8, cv2.COLORMAP_JET)
135
+ return cv2.addWeighted(heat_color, alpha, img_bgr, 1 - alpha, 0)
136
+
137
+
138
+ def compute_attention_scores(saliency_map):
139
+ flat = np.sort(saliency_map.flatten())
140
+ n = len(flat)
141
+ cum = np.cumsum(flat)
142
+ gini = (n + 1 - 2 * np.sum(cum) / (cum[-1] + 1e-8)) / n
143
+ focus_score = float(gini) * 100
144
+ spread_score = float((saliency_map >= 0.5).mean()) * 100
145
+ return {"focus_score": round(focus_score, 1), "spread_score": round(spread_score, 1)}
146
+
147
+
148
+ def compute_readability_score(read):
149
+ if read['words_checked'] == 0:
150
+ return None
151
+ penalty = 12 * math.sqrt(read['contrast_issues']) + 8 * math.sqrt(read['small_text_issues'])
152
+ return round(max(0, 100 - penalty), 1)
153
+
154
+
155
+ def coverage_grade_score(spread_score):
156
+ if spread_score < 40:
157
+ return 100.0
158
+ return max(0.0, 100.0 - (spread_score - 40) * 2)
159
+
160
+
161
+ def compute_overall_grade(focus_score, spread_score, readability_score):
162
+ coverage_score = coverage_grade_score(spread_score)
163
+ if readability_score is None:
164
+ total = round((0.55 / 0.75) * focus_score + (0.20 / 0.75) * coverage_score)
165
+ else:
166
+ total = round(0.55 * focus_score + 0.20 * coverage_score + 0.25 * readability_score)
167
+ total = max(0, min(100, total))
168
+ if total >= 75:
169
+ grade = "Strong"
170
+ elif total >= 50:
171
+ grade = "Good Start"
172
+ else:
173
+ grade = "Room to Grow"
174
+ return total, grade
175
+
176
+
177
+ def explain_grade(focus_score, spread_score, readability_score, read):
178
+ coverage_score = coverage_grade_score(spread_score)
179
+ weighted_coverage_deficit = 0.20 * max(0, 100 - coverage_score)
180
+ if readability_score is None:
181
+ if weighted_coverage_deficit > 0.55 * max(0, 100 - focus_score):
182
+ return "based on attention alone (this design's text couldn't be reliably read, so readability wasn't scored) - mainly because attention is spread too widely without settling anywhere"
183
+ elif focus_score < 50:
184
+ return "based on attention alone (this design's text couldn't be reliably read, so readability wasn't scored) - mainly because attention is spread out without one clear focal point"
185
+ elif focus_score > 90:
186
+ return "based on attention alone (this design's text couldn't be reliably read, so readability wasn't scored) - attention is very tightly focused on one spot"
187
+ else:
188
+ return "based on attention alone - this design's text couldn't be reliably read, so readability wasn't included"
189
+ total_issues = read['contrast_issues'] + read['small_text_issues']
190
+ weighted_focus_deficit = 0.55 * max(0, 100 - focus_score)
191
+ weighted_read_deficit = 0.25 * max(0, 100 - readability_score)
192
+
193
+ if weighted_coverage_deficit > weighted_focus_deficit and weighted_coverage_deficit > weighted_read_deficit:
194
+ return "mainly because attention is spread too widely across the design without settling anywhere"
195
+ elif weighted_read_deficit >= weighted_focus_deficit and total_issues > 0:
196
+ return "mainly due to text readability - some text is harder to read than ideal"
197
+ elif focus_score < 50:
198
+ return "mainly because attention is spread out, without one clear focal point"
199
+ elif focus_score > 90:
200
+ return "attention is very tightly focused on one spot - worth checking other key elements (logo, CTA) aren't being missed"
201
+ else:
202
+ return "a mix of small readability and focus factors - see details below"
203
+
204
+
205
+ def compute_hierarchy(text_sizes):
206
+ if len(text_sizes) < 2:
207
+ return None
208
+ largest = max(text_sizes)
209
+ smallest = min(text_sizes)
210
+ ratio = largest / max(smallest, 0.1)
211
+ score = round(min(100, ratio * 20), 1)
212
+ return score, ratio
213
+
214
+
215
+ def hierarchy_description(ratio):
216
+ if ratio < 1.5:
217
+ return "text sizes are very similar - there's no single element that clearly leads the eye"
218
+ elif ratio < 2.5:
219
+ return "there's some size variation, but the hierarchy could be stronger"
220
+ else:
221
+ return "clear size hierarchy - one element leads, the rest support it"
222
+
223
+
224
+ def compute_balance(saliency_map):
225
+ h, w = saliency_map.shape
226
+ left = saliency_map[:, :w // 2].mean()
227
+ right = saliency_map[:, w // 2:].mean()
228
+ top = saliency_map[:h // 2, :].mean()
229
+ bottom = saliency_map[h // 2:, :].mean()
230
+ horiz_diff = abs(left - right) / max(left + right, 1e-6)
231
+ vert_diff = abs(top - bottom) / max(top + bottom, 1e-6)
232
+ imbalance = (horiz_diff + vert_diff) / 2
233
+ score = round(max(0, 100 - imbalance * 200), 1)
234
+ return score
235
+
236
+
237
+ def balance_description(score):
238
+ if score >= 75:
239
+ return "well balanced - visual weight is evenly distributed"
240
+ elif score >= 50:
241
+ return "somewhat uneven - one side carries noticeably more visual weight"
242
+ else:
243
+ return "heavily lopsided - attention is pulled hard toward one side or corner"
244
+
245
+
246
+ def detect_price_info(all_words):
247
+ joined = " ".join(all_words)
248
+ price_pattern = r'[\$£€¥]\s?\d[\d,]*(\.\d{1,2})?|\b\d[\d,]*(\.\d{1,2})?\s?(SGD|USD|GBP|EUR|dollars?|cents?)\b'
249
+ return bool(re.search(price_pattern, joined, re.IGNORECASE))
250
+
251
+
252
+ def detect_location_info(all_words):
253
+ joined = " ".join(all_words)
254
+ location_pattern = (
255
+ r'\b(venue|address|location|street|st\.|road|rd\.|avenue|ave\.|'
256
+ r'blvd|drive|dr\.|www\.|\.com|\.sg|\.net)\b'
257
+ )
258
+ postal_pattern = r'\bS?\d{6}\b'
259
+ return bool(re.search(location_pattern, joined, re.IGNORECASE)) or bool(re.search(postal_pattern, joined))
260
+
261
+
262
+ def coverage_description(spread_score):
263
+ if spread_score < 15:
264
+ return "concentrated on one clear focal point - that's usually a good sign, not a problem"
265
+ elif spread_score < 40:
266
+ return "landing mostly in a couple of areas, which is fairly typical"
267
+ else:
268
+ return "spread fairly widely across the design"
269
+
270
+
271
+ def create_score_chart(focus_score, spread_score, readability_score, hierarchy_score=None, balance_score=None):
272
+ labels = ["Clear Focal\nPoint", "Attention\nCoverage"]
273
+ values = [focus_score, spread_score]
274
+ if readability_score is not None:
275
+ labels.append("Readability")
276
+ values.append(readability_score)
277
+ if hierarchy_score is not None:
278
+ labels.append("Visual\nHierarchy")
279
+ values.append(hierarchy_score)
280
+ if balance_score is not None:
281
+ labels.append("Balance")
282
+ values.append(balance_score)
283
+ colors = []
284
+ for v in values:
285
+ if v >= 75:
286
+ colors.append("#4CAF50")
287
+ elif v >= 50:
288
+ colors.append("#FFC107")
289
+ else:
290
+ colors.append("#F44336")
291
+ fig, ax = plt.subplots(figsize=(5, 3), dpi=100)
292
+ bars = ax.barh(labels, values, color=colors)
293
+ ax.set_xlim(0, 100)
294
+ ax.set_xlabel("Score out of 100")
295
+ ax.invert_yaxis()
296
+ for bar, v in zip(bars, values):
297
+ ax.text(min(v + 2, 92), bar.get_y() + bar.get_height() / 2, f"{v:.0f}", va="center", fontsize=10)
298
+ ax.set_title("Scores at a Glance")
299
+ fig.tight_layout()
300
+ buf = io.BytesIO()
301
+ fig.savefig(buf, format="png")
302
+ plt.close(fig)
303
+ buf.seek(0)
304
+ return Image.open(buf).convert("RGB")
305
+
306
+
307
+ def generate_recommendations(saliency_map, scores, read, img_h, img_w):
308
+ tips = []
309
+ bottom_band = saliency_map[int(img_h * 0.85):, :]
310
+ bottom_avg = bottom_band.mean()
311
+ if bottom_avg < 0.25:
312
+ tips.append("Your bottom section (often where CTAs or contact info sit) is getting low attention. Consider bolder color contrast or larger text there.")
313
+
314
+ if scores['focus_score'] < 40:
315
+ tips.append("Attention is scattered with no clear focal point. Consider making one element (headline, product, or offer) visually dominant.")
316
+ elif scores['focus_score'] > 90:
317
+ tips.append("Attention is very narrowly focused on one spot - double check other important elements (logo, CTA) are not being ignored.")
318
+
319
+ if read['contrast_issues'] > 0:
320
+ if read['contrast_issues'] > 5:
321
+ tips.append(f"A quick win: {read['contrast_issues']} text elements could read more clearly. The good news - the pattern (see white boxes marked \"Low contrast\" on the image) suggests one color choice affects most of them, so a single tweak will likely fix most at once.")
322
+ else:
323
+ tips.append(f"{read['contrast_issues']} text element(s) could be easier to read - see the white boxes marked \"Low contrast\" on the image above, then darken the text or lighten its background.")
324
+ if read['small_text_issues'] > 0:
325
+ if read['small_text_issues'] > 5:
326
+ tips.append(f"Another quick win: {read['small_text_issues']} text elements are a bit small. Likely one font-size setting affects most of them, so this is usually a fast fix.")
327
+ else:
328
+ tips.append(f"{read['small_text_issues']} text element(s) are a bit small - see the white boxes marked \"Small text\" on the image above.")
329
+
330
+ if not tips:
331
+ tips.append("No major issues detected - this design is in solid shape.")
332
+
333
+ return tips
334
+
335
+
336
+ def identify_hotspots(saliency_map, img_h, img_w, top_n=4):
337
+ threshold = np.percentile(saliency_map, 85)
338
+ binary = (saliency_map >= threshold).astype(np.uint8)
339
+ kernel = np.ones((25, 25), np.uint8)
340
+ binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)
341
+ num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(binary, connectivity=8)
342
+ zones = []
343
+ for i in range(1, num_labels):
344
+ area = stats[i, cv2.CC_STAT_AREA]
345
+ if area < (img_h * img_w * 0.005):
346
+ continue
347
+ cx, cy = centroids[i]
348
+ h_pos = "top" if cy < img_h * 0.33 else ("bottom" if cy > img_h * 0.66 else "middle")
349
+ w_pos = "left" if cx < img_w * 0.33 else ("right" if cx > img_w * 0.66 else "center")
350
+ avg_intensity = float(saliency_map[labels == i].mean())
351
+ zones.append({"position": f"{h_pos}-{w_pos}", "area_pct": round(float(area / (img_h * img_w)) * 100, 1),
352
+ "intensity": round(avg_intensity * 100, 1), "cx": int(cx), "cy": int(cy)})
353
+ zones.sort(key=lambda z: -z["intensity"])
354
+ top_zones = zones[:top_n]
355
+ for idx, z in enumerate(top_zones):
356
+ z["intensity_rank"] = idx + 1
357
+ top_zones.sort(key=lambda z: (z["cy"], z["cx"]))
358
+ return top_zones
359
+
360
+
361
+ def relative_luminance(rgb):
362
+ def chan(c):
363
+ c = c / 255.0
364
+ return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
365
+ r, g, b = rgb
366
+ return 0.2126 * chan(r) + 0.7152 * chan(g) + 0.0722 * chan(b)
367
+
368
+
369
+ def contrast_ratio(rgb1, rgb2):
370
+ l1 = relative_luminance(rgb1)
371
+ l2 = relative_luminance(rgb2)
372
+ lighter, darker = max(l1, l2), min(l1, l2)
373
+ return (lighter + 0.05) / (darker + 0.05)
374
+
375
+
376
+ def required_contrast_ratio(size_pct):
377
+ return 3.0 if size_pct >= 3.0 else 4.5
378
+
379
+
380
+ def contrast_severity_label(ratio, required_ratio):
381
+ deficit = (required_ratio - ratio) / required_ratio
382
+ if deficit > 0.6:
383
+ return "could be much clearer"
384
+ elif deficit > 0.35:
385
+ return "could be clearer"
386
+ elif deficit > 0.15:
387
+ return "slightly less clear than ideal"
388
+ else:
389
+ return "close to the recommended level"
390
+
391
+
392
+ def sample_text_and_bg_color(img_arr, x, y, w, h):
393
+ pad = max(2, int(h * 0.3))
394
+ y0, y1 = max(0, y - pad), min(img_arr.shape[0], y + h + pad)
395
+ x0, x1 = max(0, x - pad), min(img_arr.shape[1], x + w + pad)
396
+ region = img_arr[y0:y1, x0:x1].reshape(-1, 3).astype(np.float32)
397
+ if len(region) < 10:
398
+ return None, None
399
+ criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 10, 1.0)
400
+ try:
401
+ _, _, centers = cv2.kmeans(region, 2, None, criteria, 3, cv2.KMEANS_PP_CENTERS)
402
+ except cv2.error:
403
+ return None, None
404
+ color1 = tuple(centers[0].astype(int))
405
+ color2 = tuple(centers[1].astype(int))
406
+ return color1, color2
407
+
408
+
409
+ def analyze_readability(pil_img):
410
+ img_arr = np.array(pil_img.convert("RGB"))
411
+ img_h = img_arr.shape[0]
412
+
413
+ gray = cv2.cvtColor(img_arr, cv2.COLOR_RGB2GRAY)
414
+
415
+ clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
416
+ gray_enhanced = clahe.apply(gray)
417
+
418
+ gray_denoised = cv2.medianBlur(gray_enhanced, 3)
419
+
420
+ bw = cv2.adaptiveThreshold(gray_denoised, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 10)
421
+
422
+ cleanup_kernel = np.ones((2, 2), np.uint8)
423
+ bw = cv2.morphologyEx(bw, cv2.MORPH_OPEN, cleanup_kernel)
424
+
425
+ ocr_data = pytesseract.image_to_data(bw, output_type=pytesseract.Output.DICT)
426
+ all_words_loose = [t.strip() for t in ocr_data['text'] if t.strip()]
427
+
428
+ words_checked = 0
429
+ contrast_issues = 0
430
+ small_text_issues = 0
431
+ issue_lines = []
432
+ issue_boxes = []
433
+ text_sizes = []
434
+ for i in range(len(ocr_data['text'])):
435
+ word = ocr_data['text'][i].strip()
436
+ conf = int(ocr_data['conf'][i])
437
+ if not word or conf < 60 or len(word) < 2:
438
+ continue
439
+ x, y, w, h = ocr_data['left'][i], ocr_data['top'][i], ocr_data['width'][i], ocr_data['height'][i]
440
+ if w < 3 or h < 3:
441
+ continue
442
+ words_checked += 1
443
+ size_pct = (h / img_h) * 100
444
+ text_sizes.append(size_pct)
445
+ text_color, bg_color = sample_text_and_bg_color(img_arr, x, y, w, h)
446
+ if text_color is None:
447
+ continue
448
+ ratio = contrast_ratio(text_color, bg_color)
449
+ required_ratio = required_contrast_ratio(size_pct)
450
+ is_contrast_issue = ratio < required_ratio
451
+ is_small_issue = size_pct < 1.2
452
+
453
+ if is_contrast_issue:
454
+ contrast_issues += 1
455
+ if len(issue_lines) < 5:
456
+ severity = contrast_severity_label(ratio, required_ratio)
457
+ issue_lines.append(f"- \"{word}\" {severity} against its background")
458
+ if is_small_issue:
459
+ small_text_issues += 1
460
+ if len(issue_lines) < 5:
461
+ issue_lines.append(f"- \"{word}\" is a bit small to read comfortably")
462
+
463
+ if is_contrast_issue or is_small_issue:
464
+ combined_label = " + ".join(
465
+ l for l, flag in [("Low contrast", is_contrast_issue), ("Small text", is_small_issue)] if flag
466
+ )
467
+ issue_boxes.append({"box": (x, y, w, h), "label": combined_label})
468
+ return {"words_checked": words_checked, "contrast_issues": contrast_issues,
469
+ "small_text_issues": small_text_issues, "issue_lines": issue_lines, "issue_boxes": issue_boxes,
470
+ "text_sizes": text_sizes, "all_words": all_words_loose}
471
+
472
+
473
+ def merge_issue_boxes(issue_boxes, gap=15):
474
+ by_label = defaultdict(list)
475
+ for item in issue_boxes:
476
+ by_label[item["label"]].append(item["box"])
477
+ merged = []
478
+ for label, boxes in by_label.items():
479
+ boxes = sorted(boxes, key=lambda b: (b[1], b[0]))
480
+ used = [False] * len(boxes)
481
+ for i in range(len(boxes)):
482
+ if used[i]:
483
+ continue
484
+ x, y, w, h = boxes[i]
485
+ mx0, my0, mx1, my1 = x, y, x + w, y + h
486
+ used[i] = True
487
+ changed = True
488
+ while changed:
489
+ changed = False
490
+ for j in range(len(boxes)):
491
+ if used[j]:
492
+ continue
493
+ bx, by, bw, bh = boxes[j]
494
+ bx0, by0, bx1, by1 = bx, by, bx + bw, by + bh
495
+ vertical_overlap = min(my1, by1) - max(my0, by0)
496
+ if vertical_overlap > 0 and bx0 - mx1 <= gap and bx1 >= mx0 - gap:
497
+ mx0, my0 = min(mx0, bx0), min(my0, by0)
498
+ mx1, my1 = max(mx1, bx1), max(my1, by1)
499
+ used[j] = True
500
+ changed = True
501
+ merged.append({"box": (mx0, my0, mx1 - mx0, my1 - my0), "label": label})
502
+ return merged
503
+
504
+
505
+ def draw_issue_markers(overlay_bgr, issue_boxes, img_h, img_w):
506
+ img = overlay_bgr.copy()
507
+ scale = max(1.0, min(img_h, img_w) / 800.0)
508
+ merged_issues = merge_issue_boxes(issue_boxes)
509
+ placed_label_rects = []
510
+ pad = int(4 * scale)
511
+ box_border = max(2, int(3 * scale))
512
+ thin_border = max(1, int(scale))
513
+ font_scale = 0.5 * scale
514
+ text_thickness = max(1, int(round(scale)))
515
+ for issue in merged_issues:
516
+ x, y, w, h = issue["box"]
517
+ cv2.rectangle(img, (x - pad, y - pad), (x + w + pad, y + h + pad), (255, 255, 255), box_border)
518
+ cv2.rectangle(img, (x - pad, y - pad), (x + w + pad, y + h + pad), (0, 0, 0), thin_border)
519
+ label = issue["label"]
520
+ (tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, font_scale, text_thickness)
521
+ label_x0 = x - pad
522
+ label_x1 = label_x0 + tw + int(8 * scale)
523
+ label_y1 = max(th + int(6 * scale), y - pad)
524
+ label_y0 = label_y1 - th - int(6 * scale)
525
+
526
+ collision = True
527
+ attempts = 0
528
+ while collision and attempts < 10:
529
+ collision = False
530
+ for (px0, py0, px1, py1) in placed_label_rects:
531
+ if not (label_x1 < px0 or label_x0 > px1 or label_y1 < py0 or label_y0 > py1):
532
+ collision = True
533
+ label_y1 -= (th + int(10 * scale))
534
+ label_y0 = label_y1 - th - int(6 * scale)
535
+ break
536
+ attempts += 1
537
+
538
+ placed_label_rects.append((label_x0, label_y0, label_x1, label_y1))
539
+ cv2.rectangle(img, (label_x0, label_y0), (label_x1, label_y1), (255, 255, 255), -1)
540
+ cv2.rectangle(img, (label_x0, label_y0), (label_x1, label_y1), (0, 0, 0), thin_border)
541
+ cv2.putText(img, label, (label_x0 + int(4 * scale), label_y1 - int(4 * scale)),
542
+ cv2.FONT_HERSHEY_SIMPLEX, font_scale, (0, 0, 0), text_thickness, cv2.LINE_AA)
543
+ return img
544
+
545
+
546
+ def markdown_to_paragraphs(md_text, styles):
547
+ story = []
548
+ for raw_line in md_text.split("\n"):
549
+ line = raw_line.strip()
550
+ if not line:
551
+ story.append(Spacer(1, 6))
552
+ continue
553
+ if line.startswith("## "):
554
+ story.append(Paragraph(line[3:], styles['Heading1']))
555
+ elif line.startswith("### "):
556
+ story.append(Paragraph(line[4:], styles['Heading2']))
557
+ elif line.startswith("---"):
558
+ story.append(Spacer(1, 10))
559
+ elif line.startswith("- ") or line.startswith("* "):
560
+ text = line[2:]
561
+ text = re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', text)
562
+ text = re.sub(r'\*(.*?)\*', r'<i>\1</i>', text)
563
+ story.append(Paragraph("&bull; " + text, styles['Normal']))
564
+ elif re.match(r'^\d+\.\s', line):
565
+ num, text = line.split('.', 1)
566
+ text = re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', text.strip())
567
+ story.append(Paragraph(f"{num}. {text}", styles['Normal']))
568
+ else:
569
+ text = re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', line)
570
+ text = re.sub(r'\*(.*?)\*', r'<i>\1</i>', text)
571
+ text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<link href="\2"><u>\1</u></link>', text)
572
+ story.append(Paragraph(text, styles['Normal']))
573
+ return story
574
+
575
+
576
+ def stamp_pdf_watermark(canvas_obj, doc):
577
+ if not os.path.exists(LOGO_PATH):
578
+ return
579
+ try:
580
+ from reportlab.lib.utils import ImageReader
581
+ logo = Image.open(LOGO_PATH).convert("RGBA")
582
+ bbox = logo.getbbox()
583
+ if bbox:
584
+ logo = logo.crop(bbox)
585
+ logo_h = 0.8 * inch
586
+ logo_w = logo_h * (logo.width / logo.height)
587
+ margin = 15
588
+ canvas_obj.saveState()
589
+ canvas_obj.setFillAlpha(0.95)
590
+ canvas_obj.drawImage(ImageReader(logo), doc.pagesize[0] - logo_w - margin, margin,
591
+ width=logo_w, height=logo_h, mask='auto', preserveAspectRatio=True)
592
+ canvas_obj.restoreState()
593
+ except Exception:
594
+ pass
595
+
596
+
597
+ def export_pdf(heatmap_img, chart_img, summary_md):
598
+ if heatmap_img is None or not summary_md:
599
+ return None
600
+ if isinstance(heatmap_img, np.ndarray):
601
+ heatmap_img = Image.fromarray(heatmap_img)
602
+ if chart_img is not None and isinstance(chart_img, np.ndarray):
603
+ chart_img = Image.fromarray(chart_img)
604
+ styles = getSampleStyleSheet()
605
+ tmp_dir = tempfile.gettempdir()
606
+ pdf_path = os.path.join(tmp_dir, "design_analysis_report.pdf")
607
+ doc = SimpleDocTemplate(pdf_path, pagesize=letter, topMargin=0.6 * inch, bottomMargin=0.6 * inch,
608
+ leftMargin=0.7 * inch, rightMargin=0.7 * inch)
609
+ story = [Paragraph("Design Analyzer Report", styles['Title']), Spacer(1, 12)]
610
+
611
+ max_width = 6.1 * inch
612
+ heatmap_path = os.path.join(tmp_dir, "heatmap_export_temp.png")
613
+ heatmap_img.save(heatmap_path)
614
+ ratio = heatmap_img.height / heatmap_img.width
615
+ story.append(RLImage(heatmap_path, width=max_width, height=max_width * ratio))
616
+ story.append(Spacer(1, 14))
617
+
618
+ if chart_img is not None:
619
+ chart_path = os.path.join(tmp_dir, "chart_export_temp.png")
620
+ chart_img.save(chart_path)
621
+ chart_ratio = chart_img.height / chart_img.width
622
+ story.append(RLImage(chart_path, width=max_width, height=max_width * chart_ratio))
623
+ story.append(Spacer(1, 14))
624
+
625
+ story.extend(markdown_to_paragraphs(summary_md, styles))
626
+ doc.build(story, onFirstPage=stamp_pdf_watermark, onLaterPages=stamp_pdf_watermark)
627
+ return pdf_path
628
+
629
+
630
+ def draw_scan_path(overlay_bgr, zones, img_h, img_w):
631
+ img = overlay_bgr.copy()
632
+ scale = max(1.0, min(img_h, img_w) / 800.0)
633
+ outer_thickness = int(8 * scale)
634
+ inner_thickness = int(4 * scale)
635
+ ordered = sorted(zones, key=lambda z: z["intensity_rank"])
636
+ for i in range(len(ordered) - 1):
637
+ pt1 = (ordered[i]["cx"], ordered[i]["cy"])
638
+ pt2 = (ordered[i + 1]["cx"], ordered[i + 1]["cy"])
639
+ cv2.arrowedLine(img, pt1, pt2, (0, 0, 0), outer_thickness, tipLength=0.08, line_type=cv2.LINE_AA)
640
+ cv2.arrowedLine(img, pt1, pt2, (0, 255, 255), inner_thickness, tipLength=0.08, line_type=cv2.LINE_AA)
641
+ return img
642
+
643
+
644
+ def draw_zone_labels(overlay_bgr, zones, img_h, img_w):
645
+ img = overlay_bgr.copy()
646
+ scale = max(1.0, min(img_h, img_w) / 800.0)
647
+ radius = int(22 * scale)
648
+ font_scale = 0.9 * scale
649
+ thickness = max(2, int(3 * scale))
650
+ for i, z in enumerate(zones, 1):
651
+ cx, cy = z["cx"], z["cy"]
652
+ cv2.circle(img, (cx, cy), radius, (255, 255, 255), -1)
653
+ cv2.circle(img, (cx, cy), radius, (0, 0, 0), thickness)
654
+ text = str(i)
655
+ (tw, th), _ = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, font_scale, thickness)
656
+ cv2.putText(img, text, (cx - tw // 2, cy + th // 2), cv2.FONT_HERSHEY_SIMPLEX, font_scale, (0, 0, 0), thickness, cv2.LINE_AA)
657
+ return img
658
+
659
+
660
+ def flatten_transparency(pil_image):
661
+ if pil_image.mode in ("RGBA", "LA") or (pil_image.mode == "P" and "transparency" in pil_image.info):
662
+ rgba = pil_image.convert("RGBA")
663
+ background = Image.new("RGB", rgba.size, (255, 255, 255))
664
+ background.paste(rgba, mask=rgba.split()[-1])
665
+ return background
666
+ return pil_image.convert("RGB")
667
+
668
+
669
+ def analyze(pil_image, overlay_strength):
670
+ if pil_image is None:
671
+ return None, None, "Upload an image first."
672
+ pil_rgb = flatten_transparency(pil_image)
673
+ img_bgr = cv2.cvtColor(np.array(pil_rgb), cv2.COLOR_RGB2BGR)
674
+ h, w = img_bgr.shape[:2]
675
+
676
+ saliency_map = compute_saliency_map(pil_rgb)
677
+ overlay_bgr = render_heatmap_overlay(img_bgr, saliency_map, alpha=overlay_strength)
678
+
679
+ scores = compute_attention_scores(saliency_map)
680
+ zones = identify_hotspots(saliency_map, h, w)
681
+ read = analyze_readability(pil_rgb)
682
+ readability_score = compute_readability_score(read)
683
+ total_score, grade = compute_overall_grade(scores['focus_score'], scores['spread_score'], readability_score)
684
+ grade_reason = explain_grade(scores['focus_score'], scores['spread_score'], readability_score, read)
685
+ tips = generate_recommendations(saliency_map, scores, read, h, w)
686
+ hierarchy_result = compute_hierarchy(read.get('text_sizes', []))
687
+ balance_score = compute_balance(saliency_map)
688
+ hierarchy_score = hierarchy_result[0] if hierarchy_result else None
689
+ chart_image = create_score_chart(scores['focus_score'], scores['spread_score'], readability_score,
690
+ hierarchy_score, balance_score)
691
+
692
+ overlay_bgr = draw_scan_path(overlay_bgr, zones, h, w)
693
+ overlay_bgr = draw_zone_labels(overlay_bgr, zones, h, w)
694
+ overlay_bgr = draw_issue_markers(overlay_bgr, read.get("issue_boxes", []), h, w)
695
+ overlay_rgb = cv2.cvtColor(overlay_bgr, cv2.COLOR_BGR2RGB)
696
+
697
+ summary = f"## Grade: {grade} — {total_score}/100\n"
698
+ summary += f"*Why: {grade_reason}*\n\n"
699
+
700
+ summary += "**Where Attention Goes First:** *(numbers match the image above)*\n"
701
+ if zones:
702
+ for i, z in enumerate(zones, 1):
703
+ summary += f"{i}. {z['position'].replace('-', ' ').title()} ({z['intensity']}/100 intensity, {z['area_pct']}% of design)\n"
704
+ else:
705
+ summary += "- Attention is fairly even across the design, no strong single hotspot.\n"
706
+
707
+ summary += (
708
+ f"\n**Scores:**\n"
709
+ f"- Clear Focal Point: {scores['focus_score']}/100 (how strongly attention lands on one main spot, vs scattered everywhere)\n"
710
+ f"- Attention Coverage: {scores['spread_score']}/100 - {coverage_description(scores['spread_score'])}\n"
711
+ f"- Readability: "
712
+ )
713
+ if read['words_checked'] == 0:
714
+ summary += "couldn't be reliably read on this design (common with heavily stylized or hand-lettered fonts) - not included in the score above\n"
715
+ elif read['contrast_issues'] == 0 and read['small_text_issues'] == 0:
716
+ summary += "clean, no changes needed\n"
717
+ else:
718
+ total_issues = read['contrast_issues'] + read['small_text_issues']
719
+ summary += f"{total_issues} easy improvement(s) spotted - see below\n"
720
+
721
+ if read['issue_lines']:
722
+ summary += "\n**A few examples:**\n" + "\n".join(read['issue_lines']) + "\n"
723
+ summary += (
724
+ "\n*Note: this is automated and can occasionally flag text that's actually fine, "
725
+ "especially on busy or gradient backgrounds - if something flagged looks perfectly "
726
+ "readable to your eye, trust your eye.*\n"
727
+ )
728
+
729
+ summary += "\n**How to Improve:** *(common and usually quick to fix)*\n"
730
+ for tip in tips:
731
+ summary += f"- {tip}\n"
732
+
733
+ summary += "\n**Layout & Composition:** *(based on classic layout principles - hierarchy and balance)*\n"
734
+ if hierarchy_result:
735
+ summary += f"- Visual Hierarchy: {hierarchy_description(hierarchy_result[1])}\n"
736
+ else:
737
+ summary += "- Visual Hierarchy: not enough distinct text elements to judge\n"
738
+ summary += f"- Balance: {balance_description(balance_score)}\n"
739
+
740
+ has_price = detect_price_info(read.get('all_words', []))
741
+ has_location = detect_location_info(read.get('all_words', []))
742
+ summary += (
743
+ "\n**Content Checklist:** *(Mike Stevens' classic Who/What/Where/Why/How Much - "
744
+ "not every design needs all five)*\n"
745
+ f"- How Much (price): {'found' if has_price else 'not detected - if this design should show a price, double check it'}\n"
746
+ f"- Where (location/contact): {'found' if has_location else 'not detected - if this design should point somewhere, double check it'}\n"
747
+ "- Who / What / Why: these need a human read, not automation - ask yourself: is it "
748
+ "clear who's offering this, exactly what they're offering, and why someone should care?\n"
749
+ )
750
+
751
+ summary += (
752
+ "\n---\n"
753
+ "**Methodology:** Attention prediction combines MSI-Net, a peer-reviewed saliency model "
754
+ "(Kroner et al., *Neural Networks*, 2020, "
755
+ "[DOI: 10.1016/j.neunet.2020.05.004](https://doi.org/10.1016/j.neunet.2020.05.004)), "
756
+ "with a size-proportional boost for legible text - since eye-tracking research confirms "
757
+ "large headline text reliably draws attention, which pure bottom-up saliency models can "
758
+ "underweight relative to small high-contrast graphics. This is a data-informed prediction, "
759
+ "not a substitute for live user testing."
760
+ )
761
+
762
+ summary += (
763
+ "\n\n---\n"
764
+ "**This tool flags what's off. A designer knows what's right.** "
765
+ "Haan is a brand identity and visual designer with 12+ years turning "
766
+ "insights like these into designs that actually convert, stay true to "
767
+ "your brand, and look intentional (not just \"fixed\").\n\n"
768
+ "[Visit haanilango.com](https://www.haanilango.com) · Tel: +65 9027 2070"
769
+ )
770
+
771
+ result_image = add_watermark(Image.fromarray(overlay_rgb))
772
+ chart_image = add_watermark(chart_image, scale=0.26)
773
+
774
+ return result_image, chart_image, summary
775
+
776
+
777
+ with gr.Blocks(title="Design Analyzer") as demo:
778
+ with gr.Row():
779
+ with gr.Column():
780
+ image_input = gr.Image(type="pil", label="Upload design")
781
+ strength_slider = gr.Slider(0.1, 0.8, value=0.45, step=0.05, label="Heatmap overlay strength")
782
+ analyze_btn = gr.Button("Analyze", variant="primary")
783
+ with gr.Column():
784
+ image_output = gr.Image(label="Heatmap result", interactive=False)
785
+ chart_output = gr.Image(label="Scores at a glance", interactive=False)
786
+ with gr.Row():
787
+ text_output = gr.Markdown()
788
+ with gr.Row():
789
+ pdf_btn = gr.Button("Download PDF Report")
790
+ pdf_output = gr.File(label="PDF Report")
791
+ analyze_btn.click(fn=analyze, inputs=[image_input, strength_slider], outputs=[image_output, chart_output, text_output])
792
+ pdf_btn.click(fn=export_pdf, inputs=[image_output, chart_output, text_output], outputs=pdf_output)
793
+ gr.Markdown("---\n© 2026 Haan Ilango. All rights reserved. This tool's methodology and design are original work.")
794
+
795
+ demo.launch()