creek

The AI Image Editor of 2030

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)