creek
The AI Image Editor of 2030git clone https://github.com/maydayv7/creek
android/app/src/main/python/analyze_layout.py (17839B)
1import cv2
2import numpy as np
3import os
4import json
5from math import hypot
6
7
8# Custom JSON encoder for numpy types
9class NumpyEncoder(json.JSONEncoder):
10 def default(self, obj):
11 if isinstance(obj, (np.integer, int)):
12 return int(obj)
13 elif isinstance(obj, (np.floating, float)):
14 return float(obj)
15 elif isinstance(obj, np.ndarray):
16 return obj.tolist()
17 elif isinstance(obj, np.bool_):
18 return bool(obj)
19 return super(NumpyEncoder, self).default(obj)
20
21
22# ==========================================
23# 1. HELPER FUNCTIONS
24# ==========================================
25
26
27def resize_for_fast_processing(img, max_side=640):
28 h, w = img.shape[:2]
29 scale = max_side / max(h, w) if max(h, w) > max_side else 1.0
30 if scale != 1.0:
31 img = cv2.resize(
32 img, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA
33 )
34 return img, scale
35
36
37def compute_saliency_gray(img):
38 sal = None
39 try:
40 salience = cv2.saliency.StaticSaliencySpectralResidual_create()
41 success, sal = salience.computeSaliency(img)
42 if not success:
43 sal = None
44 except Exception:
45 sal = None
46 if sal is None:
47 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
48 blur1 = cv2.GaussianBlur(gray, (3, 3), 0)
49 blur2 = cv2.GaussianBlur(gray, (21, 21), 0)
50 sal = cv2.absdiff(blur1, blur2)
51 sal = sal.astype(np.float32) / (sal.max() + 1e-9)
52 else:
53 sal = sal.astype(np.float32)
54 if sal.max() > 0:
55 sal /= sal.max()
56 return sal
57
58
59def saliency_centroid(sal):
60 h, w = sal.shape
61 M = sal.sum()
62 if M <= 1e-6:
63 return (w / 2, h / 2)
64 ys, xs = np.indices(sal.shape)
65 cx = (xs * sal).sum() / M
66 cy = (ys * sal).sum() / M
67 return (float(cx), float(cy))
68
69
70def detect_lines(gray, canny_thresh1=50, canny_thresh2=150, hough_thresh=50):
71 edges = cv2.Canny(gray, canny_thresh1, canny_thresh2)
72 lines = cv2.HoughLinesP(
73 edges,
74 rho=1,
75 theta=np.pi / 180,
76 threshold=hough_thresh,
77 minLineLength=min(gray.shape) // 10,
78 maxLineGap=10,
79 )
80 if lines is None:
81 return edges, []
82 lines = [tuple(l[0]) for l in lines]
83 return edges, lines
84
85
86def project_sal_along_axis(sal, axis=0, smooth_k=15):
87 proj = sal.sum(axis=1 - axis) if axis == 0 else sal.sum(axis=0)
88 k = max(3, smooth_k)
89 kernel = np.ones(k) / k
90 proj_s = np.convolve(proj, kernel, mode="same")
91 if proj_s.max() > 0:
92 proj_s = proj_s / proj_s.max()
93 return proj_s
94
95
96def count_saliency_peaks_along_y(sal, min_sep_fraction=0.1, threshold=0.2):
97 h, w = sal.shape
98 proj = project_sal_along_axis(sal, axis=0, smooth_k=max(5, int(h * 0.03)))
99 peaks = []
100 for i in range(1, len(proj) - 1):
101 if proj[i] > proj[i - 1] and proj[i] > proj[i + 1] and proj[i] > threshold:
102 peaks.append(i)
103 min_sep = int(min_sep_fraction * h)
104 filtered = []
105 for p in peaks:
106 if not filtered or p - filtered[-1] >= min_sep:
107 filtered.append(p)
108 return len(filtered), filtered
109
110
111def band_saliency_fractions(sal, bands=3):
112 h, w = sal.shape
113 sums = []
114 total = sal.sum() + 1e-9
115 for i in range(bands):
116 band = sal[:, i * w // bands : (i + 1) * w // bands]
117 sums.append(band.sum() / total)
118 return sums
119
120
121def sal_bbox_and_margins(sal, thresh=0.3):
122 mask = sal > thresh
123 if mask.sum() == 0:
124 return (0, 0, 0, 0), None
125 ys, xs = np.where(mask)
126 minx, maxx = int(xs.min()), int(xs.max())
127 miny, maxy = int(ys.min()), int(ys.max())
128 return (minx, miny, maxx, maxy), mask
129
130
131def component_bboxes_from_mask(mask):
132 mask_u8 = mask.astype(np.uint8) * 255
133 contours, _ = cv2.findContours(mask_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
134 bboxes = []
135 for c in contours:
136 x, y, w, h = cv2.boundingRect(c)
137 bboxes.append((x, y, x + w - 1, y + h - 1))
138 return bboxes
139
140
141def bbox_laplacian_variance(gray, bbox):
142 x1, y1, x2, y2 = bbox
143 roi = gray[y1 : y2 + 1, x1 : x2 + 1]
144 if roi.size == 0:
145 return 0.0
146 lap = cv2.Laplacian(roi, cv2.CV_64F)
147 return float(np.var(lap))
148
149
150# ==========================================
151# 2. ROBUST CONTINUOUS LAYOUT SCORERS
152# ==========================================
153
154
155def _proj_and_peaks(sal, axis=0, smooth_k=15):
156 proj = sal.sum(axis=1 - axis) if axis == 0 else sal.sum(axis=0)
157 k = max(3, smooth_k)
158 kernel = np.ones(k) / k
159 proj_s = np.convolve(proj, kernel, mode="same")
160 if proj_s.max() > 0:
161 proj_s = proj_s / proj_s.max()
162 return proj_s
163
164
165def vertical_stack_score(sal, lines, min_peaks=2):
166 h, w = sal.shape
167 proj = _proj_and_peaks(sal, axis=0, smooth_k=max(5, int(h * 0.03)))
168 peaks = [
169 i
170 for i in range(1, len(proj) - 1)
171 if proj[i] > proj[i - 1] and proj[i] > proj[i + 1] and proj[i] > 0.18
172 ]
173 if len(peaks) == 0:
174 return 0.0, {"peaks": 0}
175 avg_peak = float(np.mean([proj[p] for p in peaks]))
176 peak_count_score = min(1.0, len(peaks) / max(1.0, min_peaks))
177 peak_prom_score = avg_peak
178 h_lines = 0
179 for x1, y1, x2, y2 in lines:
180 dy = abs(y2 - y1)
181 dx = abs(x2 - x1) + 1e-9
182 slope = dy / dx
183 if slope < 0.3 and abs(x2 - x1) > 0.6 * w:
184 h_lines += 1
185 sep_score = min(1.0, h_lines / 2.0)
186 span_ok = 0
187 for p in peaks:
188 row = sal[max(0, p - 2) : min(h, p + 3), :]
189 if row.sum() <= 1e-9:
190 continue
191 col_sum = row.sum(axis=0)
192 frac_nonzero = (col_sum > (col_sum.max() * 0.05)).sum() / float(w)
193 if frac_nonzero > 0.65:
194 span_ok += 1
195 span_score = min(1.0, span_ok / max(1.0, len(peaks)))
196 score = (
197 0.45 * peak_count_score
198 + 0.25 * peak_prom_score
199 + 0.15 * sep_score
200 + 0.15 * span_score
201 )
202 return float(np.clip(score, 0.0, 1.0)), {
203 "peaks": len(peaks),
204 "avg_peak": avg_peak,
205 "h_lines": h_lines,
206 "span_ok": span_ok,
207 }
208
209
210def triptych_score(sal, lines):
211 h, w = sal.shape
212 total = sal.sum() + 1e-9
213 bands = [sal[:, i * w // 3 : (i + 1) * w // 3].sum() / total for i in range(3)]
214 mn, mx = min(bands), max(bands)
215 balance = 1.0 - (mx - mn)
216 balance = np.clip(balance, 0.0, 1.0)
217 v_hits = 0
218 for x1, y1, x2, y2 in lines:
219 dx = abs(x2 - x1) + 1e-9
220 dy = abs(y2 - y1)
221 slope = dy / dx if dx > 1 else 1e9
222 if slope > 3 and dy > 0.6 * h:
223 cx = (x1 + x2) / 2
224 if abs(cx - w / 3) < 0.08 * w or abs(cx - 2 * w / 3) < 0.08 * w:
225 v_hits += 1
226 line_score = np.tanh(v_hits / 2.0)
227 band_floor = min(1.0, mn / 0.15)
228 score = 0.55 * balance + 0.25 * line_score + 0.20 * band_floor
229 return float(np.clip(score, 0.0, 1.0)), {"band_fracs": bands, "v_line_hits": v_hits}
230
231
232def full_bleed_score(
233 sal,
234 img,
235 edge_margin_frac=0.03,
236 edge_sal_thresh=0.25,
237 sal_bbox_thresh=0.2,
238 white_thresh=245,
239 white_frac_thresh=0.80,
240 edge_sal_frac_thresh=0.50,
241 bbox_touch_required=0.75,
242):
243 """
244 Binary full-bleed detector (returns 0 or 1, plus info).
245 - Rejects as full-bleed if a white margin/border is detected.
246 - Uses saliency near edges and whether saliency bbox touches edges.
247 """
248 h, w = sal.shape
249 m = max(1, int(min(h, w) * edge_margin_frac))
250
251 # 1) fraction of edge band pixels that are salient (sal > edge_sal_thresh)
252 mask_edge = np.zeros_like(sal, dtype=bool)
253 mask_edge[:m, :] = True
254 mask_edge[-m:, :] = True
255 mask_edge[:, :m] = True
256 mask_edge[:, -m:] = True
257 edge_sal_frac = float((sal[mask_edge] > edge_sal_thresh).sum()) / (
258 mask_edge.sum() + 1e-9
259 )
260
261 # 2) compute saliency bbox touch (based on sal > sal_bbox_thresh)
262 mask2 = sal > sal_bbox_thresh
263 if mask2.sum() == 0:
264 bbox_touch = 0.0
265 else:
266 ys, xs = np.where(mask2)
267 minx, maxx = int(xs.min()), int(xs.max())
268 miny, maxy = int(ys.min()), int(ys.max())
269 touches = (
270 int(minx <= 1) + int(maxx >= w - 2) + int(miny <= 1) + int(maxy >= h - 2)
271 )
272 bbox_touch = touches / 4.0
273
274 # 3) check for white margin/border
275 # sample the same border band from the color image and compute fraction of nearly-white pixels
276 # ensure img is uint8 BGR
277 band_top = img[:m, :, :] if m > 0 else np.zeros((0, w, 3), dtype=img.dtype)
278 band_bottom = img[-m:, :, :] if m > 0 else np.zeros((0, w, 3), dtype=img.dtype)
279 band_left = img[:, :m, :] if m > 0 else np.zeros((h, 0, 3), dtype=img.dtype)
280 band_right = img[:, -m:, :] if m > 0 else np.zeros((h, 0, 3), dtype=img.dtype)
281
282 # stack all border samples into one array (may contain duplicates at corners but that's fine)
283 border_pixels = np.concatenate(
284 [
285 band_top.reshape(-1, 3),
286 band_bottom.reshape(-1, 3),
287 band_left.reshape(-1, 3),
288 band_right.reshape(-1, 3),
289 ],
290 axis=0,
291 )
292 if border_pixels.size == 0:
293 white_border_frac = 0.0
294 else:
295 # white if all channels >= white_thresh
296 white_mask = np.all(border_pixels >= white_thresh, axis=1)
297 white_border_frac = float(white_mask.sum()) / float(border_pixels.shape[0])
298
299 # Also detect a uniform border (low std) which sometimes signals a margin even if not white
300 border_std = float(np.std(border_pixels)) if border_pixels.size > 0 else 0.0
301 border_uniform = border_std < 6.0 # small std => uniform border
302
303 # 4) Decide binary score
304 # Conditions favoring full-bleed:
305 cond_edge_sal = edge_sal_frac >= edge_sal_frac_thresh
306 cond_bbox_touch = (
307 bbox_touch >= bbox_touch_required
308 ) # e.g. >=0.75 means touching 3 or 4 edges
309
310 # Final logic: require (edge saliency OR bbox touch) AND NOT white border
311 is_full_bleed = (
312 (cond_edge_sal or cond_bbox_touch)
313 and (white_border_frac < white_frac_thresh)
314 and (not (border_uniform and white_border_frac > 0.25))
315 )
316
317 score = 1 if is_full_bleed else 0
318
319 info = {
320 "edge_margin_px": m,
321 "edge_sal_frac": edge_sal_frac,
322 "edge_sal_thresh": edge_sal_thresh,
323 "cond_edge_sal": cond_edge_sal,
324 "bbox_touch": bbox_touch,
325 "bbox_touch_required": bbox_touch_required,
326 "white_border_frac": white_border_frac,
327 "white_thresh": white_thresh,
328 "white_frac_thresh": white_frac_thresh,
329 "border_std": border_std,
330 "border_uniform": border_uniform,
331 "decision_full_bleed": bool(is_full_bleed),
332 }
333 return score, info
334
335
336def tight_crop_score(sal, gray, sal_thresh=0.1):
337 h, w = sal.shape
338 mask = sal > sal_thresh
339 if mask.sum() == 0:
340 return 0.0, {"area_frac": 0.0, "tightness": 1.0}
341 ys, xs = np.where(mask)
342 minx, maxx = int(xs.min()), int(xs.max())
343 miny, maxy = int(ys.min()), int(ys.max())
344 bbox_area = (maxx - minx + 1) * (maxy - miny + 1)
345 area_frac = bbox_area / float(w * h)
346 left = minx
347 right = w - 1 - maxx
348 top = miny
349 bottom = h - 1 - maxy
350 tightness = min(left, right, top, bottom) / float(min(w, h) + 1e-9)
351 area_score = np.clip((area_frac - 0.15) / (0.6 - 0.15), 0.0, 1.0)
352 tight_score = 1.0 - np.clip(tightness / 0.08, 0.0, 1.0)
353 score = 0.6 * area_score + 0.4 * tight_score
354 return float(np.clip(score, 0.0, 1.0)), {
355 "area_frac": area_frac,
356 "tightness": tightness,
357 "margins": (left, right, top, bottom),
358 }
359
360
361def layered_foreground_score(sal, gray):
362 mask = (sal > 0.25).astype(np.uint8)
363 kernel = np.ones((5, 5), np.uint8)
364 mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
365 mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
366 contours, _ = cv2.findContours(
367 (mask * 255).astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
368 )
369 bboxes = []
370 sharpness = []
371 for c in contours:
372 x, y, w, h = cv2.boundingRect(c)
373 if w * h < 0.005 * sal.size:
374 continue
375 bboxes.append((x, y, x + w - 1, y + h - 1))
376 roi = gray[y : y + h, x : x + w]
377 lap = cv2.Laplacian(roi, cv2.CV_64F)
378 sharpness.append(float(np.var(lap)))
379 n = len(bboxes)
380 if n < 2:
381 return 0.0, {"num_components": n}
382 s = np.array(sharpness) + 1e-9
383 s_norm = (s - s.mean()) / (s.std() + 1e-9)
384 sharp_std = float(np.std(s_norm))
385 overlaps = 0
386 for i in range(len(bboxes)):
387 for j in range(i + 1, len(bboxes)):
388 a = bboxes[i]
389 b = bboxes[j]
390 ix1 = max(a[0], b[0])
391 iy1 = max(a[1], b[1])
392 ix2 = min(a[2], b[2])
393 iy2 = min(a[3], b[3])
394 if ix2 >= ix1 and iy2 >= iy1:
395 inter = (ix2 - ix1 + 1) * (iy2 - iy1 + 1)
396 area_a = (a[2] - a[0] + 1) * (a[3] - a[1] + 1)
397 if inter > 0.05 * area_a:
398 overlaps += 1
399 comp_score = np.clip((n - 1) / 4.0, 0, 1)
400 sharp_score = np.tanh(sharp_std)
401 overlap_score = np.tanh(overlaps / 2.0)
402 score = 0.45 * comp_score + 0.35 * sharp_score + 0.20 * overlap_score
403 return float(np.clip(score, 0.0, 1.0)), {
404 "num_components": n,
405 "sharp_std": sharp_std,
406 "overlaps": overlaps,
407 }
408
409
410def balance_score(img_bgr, sal):
411 img_lab = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2LAB).astype(np.float32)
412 L = img_lab[:, :, 0] / 255.0
413 img_hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV).astype(np.float32)
414 S = img_hsv[:, :, 1] / 255.0
415 weight = sal * (L + 0.5 * S)
416 M = weight.sum()
417 h, w = sal.shape
418 if M <= 1e-6:
419 return 0.0
420 ys, xs = np.indices(sal.shape)
421 cx = (xs * weight).sum() / M
422 cy = (ys * weight).sum() / M
423 dx = abs(cx - w / 2) / (w / 2)
424 dy = abs(cy - h / 2) / (h / 2)
425 dist = np.sqrt(dx * dx + dy * dy) / np.sqrt(2)
426 return float(max(0.0, 1.0 - dist * 1.4))
427
428
429def symmetry_score(img_gray):
430 h, w = img_gray.shape
431 left = img_gray[:, : w // 2]
432 right = img_gray[:, w - w // 2 :]
433 right_flipped = cv2.flip(right, 1)
434 if left.shape != right_flipped.shape:
435 right_flipped = cv2.resize(right_flipped, (left.shape[1], left.shape[0]))
436 leftf = left.astype(np.float32)
437 rightf = right_flipped.astype(np.float32)
438 if leftf.std() < 1e-3 or rightf.std() < 1e-3:
439 return 0.0
440 corr = np.corrcoef(leftf.flatten(), rightf.flatten())[0, 1]
441 return float(max(0.0, min(1.0, (corr + 1) / 2)))
442
443
444def thirds_score(sal, centroid):
445 h, w = sal.shape
446 cx, cy = centroid
447 thirds_pts = [
448 (w / 3, h / 3),
449 (w / 3, 2 * h / 3),
450 (2 * w / 3, h / 3),
451 (2 * w / 3, 2 * h / 3),
452 ]
453 dists = [hypot(cx - x, cy - y) for (x, y) in thirds_pts]
454 maxd = hypot(w, h)
455 score = 1.0 - min(dists) / maxd * 1.2
456 vline_dist = min(abs(cx - w / 3), abs(cx - 2 * w / 3)) / w
457 hline_dist = min(abs(cy - h / 3), abs(cy - 2 * h / 3)) / h
458 line_score = 1.0 - min(vline_dist, hline_dist)
459 return float(max(0.0, min(1.0, 0.6 * score + 0.4 * line_score)))
460
461
462def golden_ratio_score(sal, centroid):
463 h, w = sal.shape
464 gx = [0.382, 0.618]
465 gy = [0.382, 0.618]
466 cx, cy = centroid
467 pts = [(g * w, g2 * h) for g in gx for g2 in gy]
468 dists = [hypot(cx - x, cy - y) for x, y in pts]
469 maxd = hypot(w, h)
470 return float(max(0.0, min(1.0, 1.0 - min(dists) / (maxd * 0.9))))
471
472
473def negative_space_score(sal):
474 low = (sal < 0.15).sum() / sal.size
475 return float(min(1.0, low))
476
477
478def center_score(sal, centroid):
479 h, w = sal.shape
480 cx, cy = centroid
481 dx = abs(cx - w / 2) / (w / 2)
482 dy = abs(cy - h / 2) / (h / 2)
483 dist = np.sqrt(dx * dx + dy * dy) / np.sqrt(2)
484 return float(max(0.0, 1.0 - dist * 1.2))
485
486
487def analyze_single_image(img_path):
488 if not os.path.exists(img_path):
489 return json.dumps(
490 {"scores": {}, "error": f"File not found at {img_path}"}, cls=NumpyEncoder
491 )
492
493 img_full = cv2.imread(img_path)
494 if img_full is None:
495 return json.dumps(
496 {"scores": {}, "error": f"Could not load image. Check format."},
497 cls=NumpyEncoder,
498 )
499
500 img, scale = resize_for_fast_processing(img_full, max_side=640)
501 h, w = img.shape[:2]
502 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
503
504 sal = compute_saliency_gray(img)
505 if sal.shape != gray.shape:
506 sal = cv2.resize(sal, (w, h), interpolation=cv2.INTER_LINEAR)
507 centroid = saliency_centroid(sal)
508
509 edges, lines = detect_lines(gray)
510
511 # ======= compute composition scores (existing + new layout scores) =======
512 composition_scores = {
513 "Rule of Thirds": thirds_score(sal, centroid),
514 "Golden Ratio": golden_ratio_score(sal, centroid),
515 "Symmetry": symmetry_score(gray),
516 "Negative Space": negative_space_score(sal),
517 "Center Composition": center_score(sal, centroid),
518 "Balance": balance_score(img, sal),
519 }
520
521 v_score, v_info = vertical_stack_score(sal, lines)
522 t_score, t_info = triptych_score(sal, lines)
523 fb_score, fb_info = full_bleed_score(sal, img)
524 tc_score, tc_info = tight_crop_score(sal, gray)
525 lf_score, lf_info = layered_foreground_score(sal, gray)
526
527 composition_scores["Vertical Stack"] = v_score
528 composition_scores["Triptych"] = t_score
529 composition_scores["Full Bleed"] = fb_score
530 composition_scores["Tight Crop"] = tc_score
531 composition_scores["Layered Foreground"] = lf_score
532
533 diagnostics = {
534 "Vertical Stack_info": v_info,
535 "Triptych_info": t_info,
536 "Full Bleed_info": fb_info,
537 "Tight Crop_info": tc_info,
538 "Layered Foreground_info": lf_info,
539 }
540 result = {"scores": composition_scores, "diagnostics": diagnostics}
541 return json.dumps(result, cls=NumpyEncoder)