creek

The AI Image Editor of 2030

models/composition.py (16861B)


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