HaanIlango commited on
Commit
0df9d12
·
verified ·
1 Parent(s): 99ef18c

Upload app.py

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