creek

The AI Image Editor of 2030
commit 7145c8c0003d72ebfb4e78f347050d614af343a7
parent 2e1dd4c208e31daaedb7f20e3a66f835dd40ba27
Author: aditya-samal <samaladitya2004@gmail.com>
Date:   Sat, 29 Nov 2025 12:25:45 +0530

Changed Layout Model

Diffstat:
Mandroid/app/build.gradle.kts | 2+-
Mandroid/app/proguard-rules.pro | 12++++++++++++
Mandroid/app/src/main/python/analyze_layout.py | 438+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mandroid/gradle/wrapper/gradle-wrapper.properties | 2+-
Mandroid/settings.gradle.kts | 2+-
5 files changed, 333 insertions(+), 123 deletions(-)

diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts @@ -5,7 +5,7 @@ buildscript { maven { url = uri("https://chaquo.com/maven") } } dependencies { - classpath("com.android.tools.build:gradle:8.7.0") + classpath("com.android.tools.build:gradle:8.9.1") classpath("com.chaquo.python:gradle:15.0.1") } } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro @@ -1 +1,12 @@ -keep class ai.onnxruntime.** { *; } + +# Google ML Kit Text Recognition - Keep all text recognizer options +-keep class com.google.mlkit.vision.text.** { *; } +-dontwarn com.google.mlkit.vision.text.chinese.ChineseTextRecognizerOptions$Builder +-dontwarn com.google.mlkit.vision.text.chinese.ChineseTextRecognizerOptions +-dontwarn com.google.mlkit.vision.text.devanagari.DevanagariTextRecognizerOptions$Builder +-dontwarn com.google.mlkit.vision.text.devanagari.DevanagariTextRecognizerOptions +-dontwarn com.google.mlkit.vision.text.japanese.JapaneseTextRecognizerOptions$Builder +-dontwarn com.google.mlkit.vision.text.japanese.JapaneseTextRecognizerOptions +-dontwarn com.google.mlkit.vision.text.korean.KoreanTextRecognizerOptions$Builder +-dontwarn com.google.mlkit.vision.text.korean.KoreanTextRecognizerOptions +\ No newline at end of file diff --git a/android/app/src/main/python/analyze_layout.py b/android/app/src/main/python/analyze_layout.py @@ -4,6 +4,19 @@ import os import json from math import hypot +# Custom JSON encoder for numpy types +class NumpyEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, (np.integer, int)): + return int(obj) + elif isinstance(obj, (np.floating, float)): + return float(obj) + elif isinstance(obj, np.ndarray): + return obj.tolist() + elif isinstance(obj, np.bool_): + return bool(obj) + return super(NumpyEncoder, self).default(obj) + # ========================================== # 1. HELPER FUNCTIONS # ========================================== @@ -24,7 +37,6 @@ def compute_saliency_gray(img): except Exception: sal = None if sal is None: - # Fallback: Simple Gaussian Difference gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blur1 = cv2.GaussianBlur(gray, (3, 3), 0) blur2 = cv2.GaussianBlur(gray, (21, 21), 0) @@ -52,14 +64,262 @@ def detect_lines(gray, canny_thresh1=50, canny_thresh2=150, hough_thresh=50): lines = [tuple(l[0]) for l in lines] return edges, lines +def project_sal_along_axis(sal, axis=0, smooth_k=15): + proj = sal.sum(axis=1-axis) if axis == 0 else sal.sum(axis=0) + k = max(3, smooth_k) + kernel = np.ones(k) / k + proj_s = np.convolve(proj, kernel, mode='same') + if proj_s.max() > 0: + proj_s = proj_s / proj_s.max() + return proj_s + +def count_saliency_peaks_along_y(sal, min_sep_fraction=0.1, threshold=0.2): + h, w = sal.shape + proj = project_sal_along_axis(sal, axis=0, smooth_k=max(5, int(h*0.03))) + peaks = [] + for i in range(1, len(proj)-1): + if proj[i] > proj[i-1] and proj[i] > proj[i+1] and proj[i] > threshold: + peaks.append(i) + min_sep = int(min_sep_fraction * h) + filtered = [] + for p in peaks: + if not filtered or p - filtered[-1] >= min_sep: + filtered.append(p) + return len(filtered), filtered + +def band_saliency_fractions(sal, bands=3): + h, w = sal.shape + sums = [] + total = sal.sum() + 1e-9 + for i in range(bands): + band = sal[:, i*w//bands:(i+1)*w//bands] + sums.append(band.sum() / total) + return sums + +def sal_bbox_and_margins(sal, thresh=0.3): + mask = sal > thresh + if mask.sum() == 0: + return (0,0,0,0), None + ys, xs = np.where(mask) + minx, maxx = int(xs.min()), int(xs.max()) + miny, maxy = int(ys.min()), int(ys.max()) + return (minx, miny, maxx, maxy), mask + +def component_bboxes_from_mask(mask): + mask_u8 = (mask.astype(np.uint8) * 255) + contours, _ = cv2.findContours(mask_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + bboxes = [] + for c in contours: + x,y,w,h = cv2.boundingRect(c) + bboxes.append((x,y,x+w-1,y+h-1)) + return bboxes + +def bbox_laplacian_variance(gray, bbox): + x1,y1,x2,y2 = bbox + roi = gray[y1:y2+1, x1:x2+1] + if roi.size == 0: return 0.0 + lap = cv2.Laplacian(roi, cv2.CV_64F) + return float(np.var(lap)) + # ========================================== -# 2. SCORING FUNCTIONS +# 2. ROBUST CONTINUOUS LAYOUT SCORERS # ========================================== -def saturation_score(img_bgr): - hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV).astype(np.float32) - sat = hsv[:, :, 1] / 255.0 - return float(np.mean(sat)) +def _proj_and_peaks(sal, axis=0, smooth_k=15): + proj = sal.sum(axis=1-axis) if axis==0 else sal.sum(axis=0) + k = max(3, smooth_k) + kernel = np.ones(k) / k + proj_s = np.convolve(proj, kernel, mode='same') + if proj_s.max() > 0: proj_s = proj_s / proj_s.max() + return proj_s + +def vertical_stack_score(sal, lines, min_peaks=2): + h,w = sal.shape + proj = _proj_and_peaks(sal, axis=0, smooth_k=max(5, int(h*0.03))) + peaks = [i for i in range(1,len(proj)-1) if proj[i]>proj[i-1] and proj[i]>proj[i+1] and proj[i]>0.18] + if len(peaks)==0: + return 0.0, {'peaks':0} + avg_peak = float(np.mean([proj[p] for p in peaks])) + peak_count_score = min(1.0, len(peaks) / max(1.0, min_peaks)) + peak_prom_score = avg_peak + h_lines = 0 + for (x1,y1,x2,y2) in lines: + dy = abs(y2-y1); dx = abs(x2-x1)+1e-9 + slope = dy/dx + if slope < 0.3 and abs(x2-x1) > 0.6*w: + h_lines += 1 + sep_score = min(1.0, h_lines / 2.0) + span_ok = 0 + for p in peaks: + row = sal[max(0,p-2):min(h,p+3), :] + if row.sum()<=1e-9: continue + col_sum = row.sum(axis=0) + frac_nonzero = (col_sum > (col_sum.max()*0.05)).sum() / float(w) + if frac_nonzero > 0.65: + span_ok += 1 + span_score = min(1.0, span_ok / max(1.0, len(peaks))) + score = 0.45*peak_count_score + 0.25*peak_prom_score + 0.15*sep_score + 0.15*span_score + return float(np.clip(score,0.0,1.0)), {'peaks': len(peaks), 'avg_peak': avg_peak, 'h_lines': h_lines, 'span_ok': span_ok} + +def triptych_score(sal, lines): + h,w = sal.shape + total = sal.sum() + 1e-9 + bands = [sal[:, i*w//3:(i+1)*w//3].sum() / total for i in range(3)] + mn, mx = min(bands), max(bands) + balance = 1.0 - (mx - mn) + balance = np.clip(balance, 0.0, 1.0) + v_hits = 0 + for (x1,y1,x2,y2) in lines: + dx = abs(x2-x1)+1e-9; dy = abs(y2-y1) + slope = dy / dx if dx>1 else 1e9 + if slope > 3 and dy > 0.6*h: + cx = (x1+x2)/2 + if abs(cx - w/3) < 0.08*w or abs(cx - 2*w/3) < 0.08*w: + v_hits += 1 + line_score = np.tanh(v_hits/2.0) + band_floor = min(1.0, mn / 0.15) + score = 0.55*balance + 0.25*line_score + 0.20*band_floor + return float(np.clip(score,0.0,1.0)), {'band_fracs': bands, 'v_line_hits': v_hits} + +def full_bleed_score(sal, img, edge_margin_frac=0.03, + edge_sal_thresh=0.25, + sal_bbox_thresh=0.2, + white_thresh=245, + white_frac_thresh=0.80, + edge_sal_frac_thresh=0.50, + bbox_touch_required=0.75): + """ + Binary full-bleed detector (returns 0 or 1, plus info). + - Rejects as full-bleed if a white margin/border is detected. + - Uses saliency near edges and whether saliency bbox touches edges. + """ + h, w = sal.shape + m = max(1, int(min(h, w) * edge_margin_frac)) + + # 1) fraction of edge band pixels that are salient (sal > edge_sal_thresh) + mask_edge = np.zeros_like(sal, dtype=bool) + mask_edge[:m, :] = True + mask_edge[-m:, :] = True + mask_edge[:, :m] = True + mask_edge[:, -m:] = True + edge_sal_frac = float((sal[mask_edge] > edge_sal_thresh).sum()) / (mask_edge.sum() + 1e-9) + + # 2) compute saliency bbox touch (based on sal > sal_bbox_thresh) + mask2 = sal > sal_bbox_thresh + if mask2.sum() == 0: + bbox_touch = 0.0 + else: + ys, xs = np.where(mask2) + minx, maxx = int(xs.min()), int(xs.max()) + miny, maxy = int(ys.min()), int(ys.max()) + touches = int(minx <= 1) + int(maxx >= w - 2) + int(miny <= 1) + int(maxy >= h - 2) + bbox_touch = touches / 4.0 + + # 3) check for white margin/border + # sample the same border band from the color image and compute fraction of nearly-white pixels + # ensure img is uint8 BGR + band_top = img[:m, :, :] if m > 0 else np.zeros((0, w, 3), dtype=img.dtype) + band_bottom = img[-m:, :, :] if m > 0 else np.zeros((0, w, 3), dtype=img.dtype) + band_left = img[:, :m, :] if m > 0 else np.zeros((h, 0, 3), dtype=img.dtype) + band_right = img[:, -m:, :] if m > 0 else np.zeros((h, 0, 3), dtype=img.dtype) + + # stack all border samples into one array (may contain duplicates at corners but that's fine) + border_pixels = np.concatenate([band_top.reshape(-1, 3), + band_bottom.reshape(-1, 3), + band_left.reshape(-1, 3), + band_right.reshape(-1, 3)], axis=0) + if border_pixels.size == 0: + white_border_frac = 0.0 + else: + # white if all channels >= white_thresh + white_mask = np.all(border_pixels >= white_thresh, axis=1) + white_border_frac = float(white_mask.sum()) / float(border_pixels.shape[0]) + + # Also detect a uniform border (low std) which sometimes signals a margin even if not white + border_std = float(np.std(border_pixels)) if border_pixels.size > 0 else 0.0 + border_uniform = border_std < 6.0 # small std => uniform border + + # 4) Decide binary score + # Conditions favoring full-bleed: + cond_edge_sal = edge_sal_frac >= edge_sal_frac_thresh + cond_bbox_touch = bbox_touch >= bbox_touch_required # e.g. >=0.75 means touching 3 or 4 edges + + # Final logic: require (edge saliency OR bbox touch) AND NOT white border + is_full_bleed = (cond_edge_sal or cond_bbox_touch) and (white_border_frac < white_frac_thresh) and (not (border_uniform and white_border_frac > 0.25)) + + score = 1 if is_full_bleed else 0 + + info = { + 'edge_margin_px': m, + 'edge_sal_frac': edge_sal_frac, + 'edge_sal_thresh': edge_sal_thresh, + 'cond_edge_sal': cond_edge_sal, + 'bbox_touch': bbox_touch, + 'bbox_touch_required': bbox_touch_required, + 'white_border_frac': white_border_frac, + 'white_thresh': white_thresh, + 'white_frac_thresh': white_frac_thresh, + 'border_std': border_std, + 'border_uniform': border_uniform, + 'decision_full_bleed': bool(is_full_bleed) + } + return score, info + + +def tight_crop_score(sal, gray, sal_thresh=0.1): + h,w = sal.shape + mask = sal > sal_thresh + if mask.sum()==0: + return 0.0, {'area_frac':0.0, 'tightness':1.0} + ys,xs = np.where(mask) + minx,maxx = int(xs.min()), int(xs.max()) + miny,maxy = int(ys.min()), int(ys.max()) + bbox_area = (maxx-minx+1)*(maxy-miny+1) + area_frac = bbox_area / float(w*h) + left = minx; right = w-1-maxx; top = miny; bottom = h-1-maxy + tightness = min(left,right,top,bottom) / float(min(w,h)+1e-9) + area_score = np.clip((area_frac - 0.15) / (0.6 - 0.15), 0.0, 1.0) + tight_score = 1.0 - np.clip(tightness / 0.08, 0.0, 1.0) + score = 0.6*area_score + 0.4*tight_score + return float(np.clip(score,0.0,1.0)), {'area_frac': area_frac, 'tightness': tightness, 'margins': (left,right,top,bottom)} + +def layered_foreground_score(sal, gray): + mask = (sal > 0.25).astype(np.uint8) + kernel = np.ones((5,5), np.uint8) + mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) + mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) + contours, _ = cv2.findContours((mask*255).astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + bboxes=[]; sharpness=[] + for c in contours: + x,y,w,h = cv2.boundingRect(c) + if w*h < 0.005*sal.size: continue + bboxes.append((x,y,x+w-1,y+h-1)) + roi = gray[y:y+h, x:x+w] + lap = cv2.Laplacian(roi, cv2.CV_64F) + sharpness.append(float(np.var(lap))) + n = len(bboxes) + if n < 2: + return 0.0, {'num_components': n} + s = np.array(sharpness) + 1e-9 + s_norm = (s - s.mean()) / (s.std()+1e-9) + sharp_std = float(np.std(s_norm)) + overlaps = 0 + for i in range(len(bboxes)): + for j in range(i+1, len(bboxes)): + a=bboxes[i]; b=bboxes[j] + ix1 = max(a[0], b[0]); iy1 = max(a[1], b[1]) + ix2 = min(a[2], b[2]); iy2 = min(a[3], b[3]) + if ix2 >= ix1 and iy2 >= iy1: + inter = (ix2-ix1+1)*(iy2-iy1+1) + area_a = (a[2]-a[0]+1)*(a[3]-a[1]+1) + if inter > 0.05*area_a: + overlaps += 1 + comp_score = np.clip((n-1)/4.0, 0, 1) + sharp_score = np.tanh(sharp_std) + overlap_score = np.tanh(overlaps/2.0) + score = 0.45*comp_score + 0.35*sharp_score + 0.20*overlap_score + return float(np.clip(score,0.0,1.0)), {'num_components': n, 'sharp_std': sharp_std, 'overlaps': overlaps} + def balance_score(img_bgr, sal): img_lab = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2LAB).astype(np.float32) @@ -78,50 +338,6 @@ def balance_score(img_bgr, sal): dist = np.sqrt(dx*dx + dy*dy) / np.sqrt(2) return float(max(0.0, 1.0 - dist*1.4)) -def depth_score(img_gray, sal): - mask = sal > 0.3 - if mask.sum() == 0: return 0.0 - ys,xs = np.where(mask) - minx, maxx = xs.min(), xs.max() - miny, maxy = ys.min(), ys.max() - lap = cv2.Laplacian(img_gray, cv2.CV_64F) - inside = lap[miny:maxy+1, minx:maxx+1] - outside_mask = np.ones_like(img_gray, dtype=bool) - outside_mask[miny:maxy+1, minx:maxx+1] = False - outside = lap[outside_mask] - var_in = float(np.var(inside)) if inside.size>0 else 0.0 - var_out = float(np.var(outside)) if outside.size>0 else 0.0 - diff = var_in - var_out - score = (diff / (abs(var_out) + 1e-6)) if var_out>1e-6 else (1.0 if diff>0 else 0.0) - score = np.tanh(score) - return float(max(0.0, min(1.0, (score+1)/2))) - -def diagonals_triangles_score(lines, contours, centroid, img_shape): - if not lines: - diag_strength = 0.0 - else: - angles = [] - for (x1,y1,x2,y2) in lines: - dx = x2 - x1; dy = y2 - y1 - ang = abs(np.arctan2(dy, dx)) - ang = min(ang, np.pi - ang) - angles.append(1.0 - abs(ang - np.pi/4) / (np.pi/4)) - diag_strength = float(np.mean(angles)) if angles else 0.0 - h,w = img_shape[0], img_shape[1] - cx,cy = int(centroid[0]), int(centroid[1]) - tri_score = 0.0 - for c in contours: - area = cv2.contourArea(c) - if area < 0.01*w*h: continue - peri = cv2.arcLength(c, True) - approx = cv2.approxPolyDP(c, 0.04 * peri, True) - if len(approx) == 3: - inside = cv2.pointPolygonTest(c, (cx,cy), False) - dist_to_centroid = 0 if inside>=0 else min([np.linalg.norm(np.array(pt[0]) - np.array([cx,cy])) for pt in approx]) - tri_score = max(tri_score, max(0.0, 1.0 - dist_to_centroid / max(w,h))) - combined = 0.6*diag_strength + 0.4*tri_score - return float(max(0.0, min(1.0, combined))) - def symmetry_score(img_gray): h,w = img_gray.shape left = img_gray[:, :w//2] @@ -134,19 +350,6 @@ def symmetry_score(img_gray): corr = np.corrcoef(leftf.flatten(), rightf.flatten())[0,1] return float(max(0.0, min(1.0, (corr + 1)/2))) -def fill_frame_score(sal, threshold=0.5): - mask = sal > threshold - if mask.sum() == 0: return 0.0 - ys, xs = np.where(mask) - h,w = sal.shape - box_area = (xs.max()-xs.min()+1)*(ys.max()-ys.min()+1) - frac = box_area / (w*h) - return float(min(1.0, frac*2.0)) - -def negative_space_score(sal): - low = (sal < 0.15).sum() / sal.size - return float(min(1.0, low)) - def thirds_score(sal, centroid): h,w = sal.shape cx, cy = centroid @@ -168,6 +371,10 @@ def golden_ratio_score(sal, centroid): maxd = hypot(w, h) return float(max(0.0, min(1.0, 1.0 - min(dists)/ (maxd*0.9)))) +def negative_space_score(sal): + low = (sal < 0.15).sum() / sal.size + return float(min(1.0, low)) + def center_score(sal, centroid): h,w = sal.shape cx, cy = centroid @@ -177,65 +384,56 @@ def center_score(sal, centroid): return float(max(0.0, 1.0 - dist*1.2)) -# ========================================== -# 3. SINGLE IMAGE ANALYZER -# ========================================== - def analyze_single_image(img_path): if not os.path.exists(img_path): - return json.dumps({ - "error": f"File not found at {img_path}", - "success": False - }) + return json.dumps({'scores': {}, 'error': f"File not found at {img_path}"}, cls=NumpyEncoder) - # Load Image img_full = cv2.imread(img_path) if img_full is None: - return json.dumps({ - "error": "Could not load image. Check format.", - "success": False - }) + return json.dumps({'scores': {}, 'error': f"Could not load image. Check format."}, cls=NumpyEncoder) - try: - # 1. Preprocessing - img, scale = resize_for_fast_processing(img_full, max_side=640) - h, w = img.shape[:2] - gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) - - # 2. Saliency Map - sal = compute_saliency_gray(img) - if sal.shape != gray.shape: - sal = cv2.resize(sal, (w, h), interpolation=cv2.INTER_LINEAR) - centroid = saliency_centroid(sal) - - # 3. Geometric Features (Lines & Contours) - edges, lines = detect_lines(gray) - _, thr = cv2.threshold((gray).astype(np.uint8), 0, 255, cv2.THRESH_OTSU + cv2.THRESH_BINARY_INV) - contours, _ = cv2.findContours(thr, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) - - # 4. Calculate Scores - scores = { - 'Rule of Thirds': thirds_score(sal, centroid), - 'Golden Ratio': golden_ratio_score(sal, centroid), - 'Symmetry': symmetry_score(gray), - 'Fill Frame': fill_frame_score(sal, threshold=0.45), - 'Negative Space': negative_space_score(sal), - 'Center Composition': center_score(sal, centroid), - 'Visual Balance': balance_score(img, sal), - 'Depth': depth_score(gray, sal), - 'Saturation': saturation_score(img), - 'Diagonals & Triangles': diagonals_triangles_score(lines, contours, centroid, img.shape) - } - - return json.dumps({ - "success": True, - "scores": scores, - "error": None - }) - - except Exception as e: - return json.dumps({ - "success": False, - "scores": {}, - "error": str(e) - }) -\ No newline at end of file + img, scale = resize_for_fast_processing(img_full, max_side=640) + h, w = img.shape[:2] + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + + sal = compute_saliency_gray(img) + if sal.shape != gray.shape: + sal = cv2.resize(sal, (w, h), interpolation=cv2.INTER_LINEAR) + centroid = saliency_centroid(sal) + + edges, lines = detect_lines(gray) + + # ======= compute composition scores (existing + new layout scores) ======= + composition_scores = { + 'Rule of Thirds': thirds_score(sal, centroid), + 'Golden Ratio': golden_ratio_score(sal, centroid), + 'Symmetry': symmetry_score(gray), + 'Negative Space': negative_space_score(sal), + 'Center Composition': center_score(sal, centroid), + 'Balance': balance_score(img, sal) + } + + v_score, v_info = vertical_stack_score(sal, lines) + t_score, t_info = triptych_score(sal, lines) + fb_score, fb_info = full_bleed_score(sal, img) + tc_score, tc_info = tight_crop_score(sal, gray) + lf_score, lf_info = layered_foreground_score(sal, gray) + + composition_scores['Vertical Stack'] = v_score + composition_scores['Triptych'] = t_score + composition_scores['Full Bleed'] = fb_score + composition_scores['Tight Crop'] = tc_score + composition_scores['Layered Foreground'] = lf_score + + diagnostics = { + 'Vertical Stack_info': v_info, + 'Triptych_info': t_info, + 'Full Bleed_info': fb_info, + 'Tight Crop_info': tc_info, + 'Layered Foreground_info': lf_info + } + result = { + 'scores': composition_scores, + 'diagnostics': diagnostics + } + return json.dumps(result, cls=NumpyEncoder) +\ No newline at end of file diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts @@ -19,7 +19,7 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.7.0" apply false + id("com.android.application") version "8.9.1" apply false id("org.jetbrains.kotlin.android") version "2.2.21" apply false id("com.chaquo.python") version "15.0.1" apply false }