HaanIlango commited on
Commit
4a61546
·
verified ·
1 Parent(s): 676bb3a

Delete app.py

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