HaanIlango commited on
Commit
ceeff38
·
verified ·
1 Parent(s): 6399907

Delete app.py

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