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

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -815
app.py DELETED
@@ -1,815 +0,0 @@
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