Nisha56 commited on
Commit
fe44f24
Β·
1 Parent(s): 28470ee

Week 4: Layer Proposal Engine, logo decomposer, image classifier, component architecture, undo/redo, selection modes, properties panel

Browse files
grounding.py CHANGED
@@ -20,10 +20,6 @@ print(f"[GDINO] Using device: {DEVICE}")
20
 
21
 
22
  def load_grounding_model():
23
- """
24
- Load GroundingDINO model into memory.
25
- Call once at startup β€” takes ~5 seconds.
26
- """
27
  model = load_model(GROUNDING_CONFIG, GROUNDING_WEIGHTS)
28
  model = model.to(DEVICE)
29
  print("[GDINO] Model loaded successfully")
@@ -44,9 +40,12 @@ def detect_objects(image_path: str, model, text_prompt: str = None) -> list:
44
  All coordinates are absolute pixels.
45
  """
46
  if text_prompt is None:
 
 
 
 
47
  text_prompt = (
48
- "product . text . logo . background . "
49
- "image . graphic . illustration . icon . button"
50
  )
51
 
52
  # load_image returns (PIL image, transformed tensor) β€” both needed
 
20
 
21
 
22
  def load_grounding_model():
 
 
 
 
23
  model = load_model(GROUNDING_CONFIG, GROUNDING_WEIGHTS)
24
  model = model.to(DEVICE)
25
  print("[GDINO] Model loaded successfully")
 
40
  All coordinates are absolute pixels.
41
  """
42
  if text_prompt is None:
43
+ # text_prompt = (
44
+ # "product . text . logo . background . "
45
+ # "image . graphic . illustration . icon . button"
46
+ # )
47
  text_prompt = (
48
+ "product . logo . person . icon . illustration . graphic"
 
49
  )
50
 
51
  # load_image returns (PIL image, transformed tensor) β€” both needed
layer_extractor.py CHANGED
@@ -1,5 +1,4 @@
1
  import os
2
- import sys
3
  import json
4
  import base64
5
  import io
@@ -10,106 +9,198 @@ from PIL import Image
10
 
11
  from grounding import load_grounding_model, detect_objects
12
  from ocr import extract_text
 
 
13
 
14
  BACKEND_DIR = os.path.dirname(os.path.abspath(__file__))
15
- SKIP_LABELS = {"background", "text", "button"}
16
 
 
17
 
18
  def pil_to_base64(img: Image.Image, fmt: str = "PNG") -> str:
19
- """Convert PIL image to base64 string."""
20
  buffer = io.BytesIO()
21
  img.save(buffer, format=fmt)
22
  return base64.b64encode(buffer.getvalue()).decode("utf-8")
23
 
24
 
25
- def crop_text_layer(image_path: str, x: int, y: int, w: int, h: int) -> str:
26
- """
27
- Crop text region as transparent PNG.
28
- Text layers use simple rectangular crop with white pixels made transparent.
29
- """
30
  img = Image.open(image_path).convert("RGBA")
31
  crop = img.crop((x, y, x + w, y + h))
32
  return pil_to_base64(crop, "PNG")
33
 
34
 
35
- def build_layers(image_path: str) -> list:
36
- """
37
- Full layer extraction pipeline:
38
- 1. GroundingDINO finds named objects with bounding boxes
39
- 2. SAM2 refines each box into a precise pixel mask
40
- 3. PaddleOCR finds all text blocks
41
- 4. Each layer gets a transparent PNG crop
42
 
43
- Returns flat list of layers ready for Fabric.js canvas.
44
- """
45
- layers = []
46
- layer_id = 1
 
 
 
 
 
 
 
 
 
 
 
 
47
 
48
- # --- load image ---
49
  image_bgr = cv2.imread(image_path)
50
  image_rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
51
  img_h, img_w = image_rgb.shape[:2]
52
 
53
- # --- step 1: GroundingDINO object detection ---
54
  print("[LAYERS] Running GroundingDINO...")
55
  gdino_model = load_grounding_model()
56
  detections = detect_objects(image_path, gdino_model)
57
 
58
- # filter background/text labels
59
- obj_detections = [d for d in detections if d["label"].lower() not in SKIP_LABELS]
60
- print(f"[LAYERS] {len(obj_detections)} objects to segment with SAM2")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
- # --- step 2: SAM2 precise masks for each object ---
63
- if obj_detections:
64
  from segment import load_sam2_model, get_mask_for_box, mask_to_transparent_png
65
  predictor = load_sam2_model()
66
 
67
- for det in obj_detections:
68
- box = [det["x1"], det["y1"], det["x2"], det["y2"]]
69
-
 
 
 
 
70
  try:
71
  mask = get_mask_for_box(predictor, image_rgb, box)
72
-
73
- # get tight bounding box from mask
74
- rows = np.where(mask.any(axis=1))[0]
75
- cols = np.where(mask.any(axis=0))[0]
76
- if len(rows) == 0: continue
77
 
78
  y1, y2 = int(rows.min()), int(rows.max())
79
  x1, x2 = int(cols.min()), int(cols.max())
80
-
81
- # create transparent PNG
82
  png_img = mask_to_transparent_png(image_rgb, mask)
83
  b64 = pil_to_base64(png_img, "PNG")
84
 
85
  layers.append({
86
  "id": layer_id,
87
  "type": "object",
88
- "label": det["label"],
89
  "x": x1,
90
  "y": y1,
91
  "w": x2 - x1,
92
  "h": y2 - y1,
93
- "confidence": det["confidence"],
94
  "base64": b64,
95
  "format": "png",
96
  })
97
  layer_id += 1
98
 
99
  except Exception as e:
100
- print(f"[LAYERS] SAM2 failed for {det['label']}: {e}")
101
  continue
102
 
103
- # free SAM2 VRAM before OCR
104
  del predictor
105
- torch.cuda.empty_cache() if __import__('torch').cuda.is_available() else None
 
106
  print("[LAYERS] SAM2 VRAM freed")
107
 
108
- # --- step 3: OCR text layers ---
109
- print("[LAYERS] Running OCR...")
110
- text_blocks = extract_text(image_path)
 
 
111
 
112
- for block in text_blocks:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  b64 = crop_text_layer(
114
  image_path,
115
  block["x"], block["y"],
@@ -119,6 +210,7 @@ def build_layers(image_path: str) -> list:
119
  "id": layer_id,
120
  "type": "text",
121
  "text": block["text"],
 
122
  "x": block["x"],
123
  "y": block["y"],
124
  "w": block["w"],
@@ -129,43 +221,7 @@ def build_layers(image_path: str) -> list:
129
  })
130
  layer_id += 1
131
 
132
- print(f"[LAYERS] Built {len(layers)} layers ({len(obj_detections)} objects + {len(text_blocks)} text)")
133
- return layers
134
-
135
-
136
- def save_layers(layers: list, output_dir: str = None) -> str:
137
- if output_dir is None:
138
- output_dir = os.path.join(BACKEND_DIR, "outputs")
139
- os.makedirs(output_dir, exist_ok=True)
140
-
141
- output_path = os.path.join(output_dir, "layers.json")
142
- with open(output_path, "w", encoding="utf-8") as f:
143
- json.dump(layers, f, indent=2, ensure_ascii=False)
144
-
145
- print(f"[LAYERS] Saved β†’ {output_path}")
146
- return output_path
147
-
148
-
149
- if __name__ == "__main__":
150
- TEST_IMAGE = os.path.join(BACKEND_DIR, "test_images", "sale_img.jpg")
151
-
152
- if not os.path.exists(TEST_IMAGE):
153
- print("ERROR: Test image missing")
154
- exit(1)
155
-
156
- print("=" * 50)
157
- print("Building Transparent Layers")
158
- print("=" * 50)
159
-
160
- layers = build_layers(TEST_IMAGE)
161
-
162
- print(f"\nGenerated {len(layers)} layers:")
163
- for layer in layers:
164
- fmt = layer.get("format", "jpg")
165
- if layer["type"] == "text":
166
- print(f" [{layer['id']}] TEXT '{layer['text']}' at ({layer['x']},{layer['y']}) [{fmt}]")
167
- else:
168
- print(f" [{layer['id']}] OBJECT '{layer['label']}' at ({layer['x']},{layer['y']}) [{fmt}]")
169
 
170
- save_layers(layers)
171
- print("=" * 50)
 
1
  import os
 
2
  import json
3
  import base64
4
  import io
 
9
 
10
  from grounding import load_grounding_model, detect_objects
11
  from ocr import extract_text
12
+ from proposal_engine import run_proposal_engine, compute_groups, reject_text_inside_objects
13
+ from logo_decomposer import decompose_logo, get_text_inside_logo
14
 
15
  BACKEND_DIR = os.path.dirname(os.path.abspath(__file__))
 
16
 
17
+ LOGO_AREA_THRESHOLD = 0.20
18
 
19
  def pil_to_base64(img: Image.Image, fmt: str = "PNG") -> str:
 
20
  buffer = io.BytesIO()
21
  img.save(buffer, format=fmt)
22
  return base64.b64encode(buffer.getvalue()).decode("utf-8")
23
 
24
 
25
+ def crop_text_layer(image_path, x, y, w, h):
 
 
 
 
26
  img = Image.open(image_path).convert("RGBA")
27
  crop = img.crop((x, y, x + w, y + h))
28
  return pil_to_base64(crop, "PNG")
29
 
30
 
31
+ def classify_image_type(detections, img_w, img_h):
32
+ image_area = img_w * img_h
 
 
 
 
 
33
 
34
+ best_det = None
35
+ best_fraction = 0.0
36
+
37
+ for d in detections:
38
+ label = d["label"].strip().lower()
39
+ fraction = (d["x2"] - d["x1"]) * (d["y2"] - d["y1"]) / image_area
40
+ if label in {"logo", "logo icon"} and fraction > LOGO_AREA_THRESHOLD and fraction > best_fraction:
41
+ best_fraction = fraction
42
+ best_det = d
43
+
44
+ if best_det:
45
+ print(f"[LAYERS] Image classified as: logo (logo covers {best_fraction:.1%})")
46
+ return "logo", best_det
47
+
48
+ print("[LAYERS] Image classified as: not-logo")
49
+ return "not-logo", None
50
 
51
+ def build_layers(image_path: str) -> tuple:
52
  image_bgr = cv2.imread(image_path)
53
  image_rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
54
  img_h, img_w = image_rgb.shape[:2]
55
 
56
+ # --- GroundingDINO ---
57
  print("[LAYERS] Running GroundingDINO...")
58
  gdino_model = load_grounding_model()
59
  detections = detect_objects(image_path, gdino_model)
60
 
61
+ object_proposals = [
62
+ {
63
+ "type": "object",
64
+ "label": d["label"],
65
+ "x": d["x1"],
66
+ "y": d["y1"],
67
+ "w": d["x2"] - d["x1"],
68
+ "h": d["y2"] - d["y1"],
69
+ "confidence": d["confidence"],
70
+ }
71
+ for d in detections
72
+ ]
73
+
74
+ # --- OCR ---
75
+ print("[LAYERS] Running OCR...")
76
+ text_blocks = extract_text(image_path)
77
+
78
+ # --- classify and route ---
79
+ image_type, logo_detection = classify_image_type(detections, img_w, img_h)
80
+
81
+ if image_type == "logo":
82
+ from segment import load_sam2_model
83
+ predictor = load_sam2_model()
84
+ layers, groups = decompose_logo(
85
+ image_path, image_rgb, logo_detection, text_blocks, predictor
86
+ )
87
+ del predictor
88
+ if torch.cuda.is_available():
89
+ torch.cuda.empty_cache()
90
+ print("[LAYERS] SAM2 VRAM freed")
91
+
92
+ logo_box_dict = {
93
+ "x": logo_detection["x1"],
94
+ "y": logo_detection["y1"],
95
+ "w": logo_detection["x2"] - logo_detection["x1"],
96
+ "h": logo_detection["y2"] - logo_detection["y1"],
97
+ }
98
+ text_inside_ids = {id(b) for b in get_text_inside_logo(text_blocks, logo_box_dict)}
99
+ next_id = max((l["id"] for l in layers), default=0) + 1
100
+
101
+ for block in text_blocks:
102
+ if id(block) in text_inside_ids:
103
+ continue
104
+ b64 = crop_text_layer(image_path, block["x"], block["y"], block["w"], block["h"])
105
+ layers.append({
106
+ "id": next_id,
107
+ "type": "text",
108
+ "text": block["text"],
109
+ "label": block["text"],
110
+ "x": block["x"],
111
+ "y": block["y"],
112
+ "w": block["w"],
113
+ "h": block["h"],
114
+ "confidence": block["confidence"],
115
+ "base64": b64,
116
+ "format": "png",
117
+ "group_id": None,
118
+ "group_role": None,
119
+ })
120
+ next_id += 1
121
+
122
+ print(f"[LAYERS] Built {len(layers)} layers, {len(groups)} groups")
123
+ return layers, groups
124
+
125
+ # --- not-logo path ---
126
+ clean_objects = run_proposal_engine(object_proposals, img_w, img_h)
127
+ print(f"[LAYERS] {len(clean_objects)} objects passed proposal engine")
128
+
129
+ layers = []
130
+ layer_id = 1
131
 
132
+ if clean_objects:
133
+ print(f"[LAYERS] Running SAM2 on {len(clean_objects)} objects...")
134
  from segment import load_sam2_model, get_mask_for_box, mask_to_transparent_png
135
  predictor = load_sam2_model()
136
 
137
+ for proposal in clean_objects:
138
+ box = [
139
+ proposal["x"],
140
+ proposal["y"],
141
+ proposal["x"] + proposal["w"],
142
+ proposal["y"] + proposal["h"],
143
+ ]
144
  try:
145
  mask = get_mask_for_box(predictor, image_rgb, box)
146
+ rows = np.where(mask.any(axis=1))[0]
147
+ cols = np.where(mask.any(axis=0))[0]
148
+ if len(rows) == 0:
149
+ continue
 
150
 
151
  y1, y2 = int(rows.min()), int(rows.max())
152
  x1, x2 = int(cols.min()), int(cols.max())
 
 
153
  png_img = mask_to_transparent_png(image_rgb, mask)
154
  b64 = pil_to_base64(png_img, "PNG")
155
 
156
  layers.append({
157
  "id": layer_id,
158
  "type": "object",
159
+ "label": proposal["label"],
160
  "x": x1,
161
  "y": y1,
162
  "w": x2 - x1,
163
  "h": y2 - y1,
164
+ "confidence": proposal["confidence"],
165
  "base64": b64,
166
  "format": "png",
167
  })
168
  layer_id += 1
169
 
170
  except Exception as e:
171
+ print(f"[LAYERS] SAM2 failed for '{proposal['label']}': {e}")
172
  continue
173
 
 
174
  del predictor
175
+ if torch.cuda.is_available():
176
+ torch.cuda.empty_cache()
177
  print("[LAYERS] SAM2 VRAM freed")
178
 
179
+ # --- text layers: reject any that sit inside an accepted object box ---
180
+ object_boxes = [
181
+ {"x": l["x"], "y": l["y"], "w": l["w"], "h": l["h"]}
182
+ for l in layers
183
+ ]
184
 
185
+ text_proposals = [
186
+ {
187
+ "type": "text",
188
+ "text": b["text"],
189
+ "label": b["text"],
190
+ "x": b["x"],
191
+ "y": b["y"],
192
+ "w": b["w"],
193
+ "h": b["h"],
194
+ "confidence": b["confidence"],
195
+ }
196
+ for b in text_blocks
197
+ ]
198
+
199
+ clean_text, rejected_text = reject_text_inside_objects(text_proposals, object_boxes)
200
+ if rejected_text:
201
+ print(f"[LAYERS] Rejected {len(rejected_text)} text blocks inside object layers")
202
+
203
+ for block in clean_text:
204
  b64 = crop_text_layer(
205
  image_path,
206
  block["x"], block["y"],
 
210
  "id": layer_id,
211
  "type": "text",
212
  "text": block["text"],
213
+ "label": block["text"],
214
  "x": block["x"],
215
  "y": block["y"],
216
  "w": block["w"],
 
221
  })
222
  layer_id += 1
223
 
224
+ annotated, groups = compute_groups(layers)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
 
226
+ print(f"[LAYERS] Built {len(annotated)} layers ({len(clean_objects)} objects + {len(clean_text)} text), {len(groups)} groups")
227
+ return annotated, groups
logo_decomposer.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ from PIL import Image
3
+ import io
4
+ import base64
5
+
6
+
7
+ def pil_to_base64(img: Image.Image, fmt: str = "PNG") -> str:
8
+ buffer = io.BytesIO()
9
+ img.save(buffer, format=fmt)
10
+ return base64.b64encode(buffer.getvalue()).decode("utf-8")
11
+
12
+
13
+ def crop_text_layer(image_path, x, y, w, h):
14
+ img = Image.open(image_path).convert("RGBA")
15
+ crop = img.crop((x, y, x + w, y + h))
16
+ return pil_to_base64(crop, "PNG")
17
+
18
+
19
+ def get_text_inside_logo(text_blocks, logo_box, tolerance=10):
20
+ lx2 = logo_box["x"] + logo_box["w"]
21
+ ly2 = logo_box["y"] + logo_box["h"]
22
+ return [
23
+ b for b in text_blocks
24
+ if (b["x"] >= logo_box["x"] - tolerance and
25
+ b["y"] >= logo_box["y"] - tolerance and
26
+ b["x"] + b["w"] <= lx2 + tolerance and
27
+ b["y"] + b["h"] <= ly2 + tolerance)
28
+ ]
29
+
30
+
31
+ def subtract_text_from_mask(mask, text_blocks, logo_box, tolerance=10):
32
+ """
33
+ Zeroes out OCR text regions from the SAM2 mask.
34
+ What remains is the graphic-only pixels β€” no text baked in.
35
+ Works on any image regardless of content.
36
+ """
37
+ result = mask.copy()
38
+ lx1 = logo_box["x"] - tolerance
39
+ ly1 = logo_box["y"] - tolerance
40
+ lx2 = logo_box["x"] + logo_box["w"] + tolerance
41
+ ly2 = logo_box["y"] + logo_box["h"] + tolerance
42
+
43
+ for block in text_blocks:
44
+ # only subtract text that sits inside the logo region
45
+ bx1 = max(0, block["x"] - tolerance)
46
+ by1 = max(0, block["y"] - tolerance)
47
+ bx2 = block["x"] + block["w"] + tolerance
48
+ by2 = block["y"] + block["h"] + tolerance
49
+
50
+ if (block["x"] >= lx1 and block["y"] >= ly1 and
51
+ block["x"] + block["w"] <= lx2 and
52
+ block["y"] + block["h"] <= ly2):
53
+ result[by1:by2, bx1:bx2] = False
54
+
55
+ return result
56
+
57
+
58
+ def decompose_logo(image_path, image_rgb, logo_detection, text_blocks, predictor):
59
+ """
60
+ v1: logo graphic (SAM2 mask minus text regions) + individual text layers.
61
+ v2 can add SAM2 auto-segmentation for internal shapes by extending only
62
+ this function β€” nothing outside this file changes.
63
+ """
64
+ from segment import get_mask_for_box, mask_to_transparent_png
65
+
66
+ layers = []
67
+ layer_id = 1
68
+
69
+ logo_box_list = [
70
+ logo_detection["x1"],
71
+ logo_detection["y1"],
72
+ logo_detection["x2"],
73
+ logo_detection["y2"],
74
+ ]
75
+
76
+ logo_box_dict = {
77
+ "x": logo_detection["x1"],
78
+ "y": logo_detection["y1"],
79
+ "w": logo_detection["x2"] - logo_detection["x1"],
80
+ "h": logo_detection["y2"] - logo_detection["y1"],
81
+ }
82
+
83
+ text_inside = get_text_inside_logo(text_blocks, logo_box_dict)
84
+
85
+ graphic_created = False
86
+ try:
87
+ mask = get_mask_for_box(predictor, image_rgb, logo_box_list)
88
+
89
+ # remove text pixel regions from graphic mask
90
+ if text_inside:
91
+ mask = subtract_text_from_mask(mask, text_inside, logo_box_dict)
92
+
93
+ rows = np.where(mask.any(axis=1))[0]
94
+ cols = np.where(mask.any(axis=0))[0]
95
+
96
+ if len(rows) > 0:
97
+ y1, y2 = int(rows.min()), int(rows.max())
98
+ x1, x2 = int(cols.min()), int(cols.max())
99
+ png_img = mask_to_transparent_png(image_rgb, mask)
100
+ b64 = pil_to_base64(png_img, "PNG")
101
+
102
+ layers.append({
103
+ "id": layer_id,
104
+ "type": "object",
105
+ "label": "logo",
106
+ "x": x1,
107
+ "y": y1,
108
+ "w": x2 - x1,
109
+ "h": y2 - y1,
110
+ "confidence": logo_detection["confidence"],
111
+ "base64": b64,
112
+ "format": "png",
113
+ "group_id": "group_0",
114
+ "group_role": "root",
115
+ })
116
+ layer_id += 1
117
+ graphic_created = True
118
+
119
+ except Exception as e:
120
+ print(f"[LOGO] SAM2 failed for logo graphic: {e}")
121
+
122
+ for block in text_inside:
123
+ b64 = crop_text_layer(
124
+ image_path,
125
+ block["x"], block["y"],
126
+ block["w"], block["h"]
127
+ )
128
+ layers.append({
129
+ "id": layer_id,
130
+ "type": "text",
131
+ "text": block["text"],
132
+ "label": block["text"],
133
+ "x": block["x"],
134
+ "y": block["y"],
135
+ "w": block["w"],
136
+ "h": block["h"],
137
+ "confidence": block["confidence"],
138
+ "base64": b64,
139
+ "format": "png",
140
+ "group_id": "group_0" if graphic_created else None,
141
+ "group_role": "child" if graphic_created else None,
142
+ })
143
+ layer_id += 1
144
+
145
+ groups = [{"id": "group_0", "label": "Logo", "type": "group"}] if graphic_created else []
146
+
147
+ print(f"[LOGO] Decomposed: 1 graphic + {len(text_inside)} text = {len(layers)} layers")
148
+ return layers, groups
pipeline.py CHANGED
@@ -24,15 +24,9 @@ def pil_to_base64(img: Image.Image, fmt: str = "PNG") -> str:
24
 
25
 
26
  def reconstruct_background(image_path: str, layers: list) -> str:
27
- """
28
- Build ONE combined mask covering all detected layers.
29
- Run a single OpenCV TELEA inpaint to reconstruct the clean background.
30
- Returns base64 JPEG of the clean background.
31
- """
32
  image_bgr = cv2.imread(image_path)
33
  h, w = image_bgr.shape[:2]
34
 
35
- # combined mask β€” white where any layer exists
36
  combined_mask = np.zeros((h, w), dtype=np.uint8)
37
 
38
  for layer in layers:
@@ -42,7 +36,6 @@ def reconstruct_background(image_path: str, layers: list) -> str:
42
  y2 = min(h, layer["y"] + layer["h"])
43
  combined_mask[y:y2, x:x2] = 255
44
 
45
- # single TELEA inpaint β€” fast, works for MVP, replaceable later
46
  clean_bg = cv2.inpaint(
47
  image_bgr,
48
  combined_mask,
@@ -50,49 +43,35 @@ def reconstruct_background(image_path: str, layers: list) -> str:
50
  flags=cv2.INPAINT_TELEA
51
  )
52
 
53
- # encode to base64
54
  _, buffer = cv2.imencode('.jpg', clean_bg, [cv2.IMWRITE_JPEG_QUALITY, 95])
55
  return base64.b64encode(buffer).decode("utf-8")
56
 
57
 
58
  def run_pipeline(image_path: str) -> dict:
59
- """
60
- Full pipeline:
61
- 1. Extract all layers as transparent PNGs
62
- 2. Build combined mask of all layer regions
63
- 3. Reconstruct clean background in ONE inpaint pass
64
- 4. Return clean background + independent layers
65
-
66
- Editor renders: clean background + layer PNGs
67
- Every element exists exactly once β€” no duplicates possible.
68
- Editing is instant β€” no AI calls needed during editing.
69
- """
70
  start = time.time()
71
  print(f"\n[PIPELINE] Starting: {os.path.basename(image_path)}")
72
 
73
- img = Image.open(image_path)
74
  img_w, img_h = img.size
75
  print(f"[PIPELINE] Image: {img_w}x{img_h}")
76
 
77
- # step 1 β€” extract all layers
78
- layers = build_layers(image_path)
79
- print(f"[PIPELINE] Extracted {len(layers)} layers")
80
 
81
- # step 2 β€” reconstruct background once using combined mask
82
- print("[PIPELINE] Reconstructing clean background...")
83
  bg_base64 = reconstruct_background(image_path, layers)
84
- print("[PIPELINE] Background reconstructed")
85
 
86
  elapsed = round(time.time() - start, 2)
87
  print(f"[PIPELINE] Done in {elapsed}s")
88
 
89
  return {
90
- "image_w": img_w,
91
- "image_h": img_h,
92
- "background_base64": bg_base64, # clean background, no elements
93
- "original_base64": image_to_base64(image_path), # for inpainting reference
94
- "layers": layers,
95
- "processing_time_s": elapsed,
 
96
  }
97
 
98
 
@@ -106,6 +85,8 @@ def save_pipeline_output(result: dict, output_dir: str = None) -> str:
106
  "image_h": result["image_h"],
107
  "processing_time_s": result["processing_time_s"],
108
  "layer_count": len(result["layers"]),
 
 
109
  "layers": [
110
  {k: v for k, v in layer.items() if k != "base64"}
111
  for layer in result["layers"]
@@ -117,25 +98,4 @@ def save_pipeline_output(result: dict, output_dir: str = None) -> str:
117
  json.dump(result_slim, f, indent=2, ensure_ascii=False)
118
 
119
  print(f"[PIPELINE] Saved β†’ {out_path}")
120
- return out_path
121
-
122
-
123
- if __name__ == "__main__":
124
- TEST_IMAGE = os.path.join(BACKEND_DIR, "test_images", "sale_img.jpg")
125
-
126
- if not os.path.exists(TEST_IMAGE):
127
- print("ERROR: Test image missing")
128
- exit(1)
129
-
130
- print("=" * 50)
131
- print("Running Editify Pipeline")
132
- print("=" * 50)
133
-
134
- result = run_pipeline(TEST_IMAGE)
135
- save_pipeline_output(result)
136
-
137
- print("=" * 50)
138
- print(f"Layers: {len(result['layers'])}")
139
- print(f"Image: {result['image_w']}x{result['image_h']}")
140
- print(f"Time: {result['processing_time_s']}s")
141
- print("=" * 50)
 
24
 
25
 
26
  def reconstruct_background(image_path: str, layers: list) -> str:
 
 
 
 
 
27
  image_bgr = cv2.imread(image_path)
28
  h, w = image_bgr.shape[:2]
29
 
 
30
  combined_mask = np.zeros((h, w), dtype=np.uint8)
31
 
32
  for layer in layers:
 
36
  y2 = min(h, layer["y"] + layer["h"])
37
  combined_mask[y:y2, x:x2] = 255
38
 
 
39
  clean_bg = cv2.inpaint(
40
  image_bgr,
41
  combined_mask,
 
43
  flags=cv2.INPAINT_TELEA
44
  )
45
 
 
46
  _, buffer = cv2.imencode('.jpg', clean_bg, [cv2.IMWRITE_JPEG_QUALITY, 95])
47
  return base64.b64encode(buffer).decode("utf-8")
48
 
49
 
50
  def run_pipeline(image_path: str) -> dict:
 
 
 
 
 
 
 
 
 
 
 
51
  start = time.time()
52
  print(f"\n[PIPELINE] Starting: {os.path.basename(image_path)}")
53
 
54
+ img = Image.open(image_path)
55
  img_w, img_h = img.size
56
  print(f"[PIPELINE] Image: {img_w}x{img_h}")
57
 
58
+ layers, groups = build_layers(image_path)
59
+ print(f"[PIPELINE] {len(layers)} layers, {len(groups)} groups")
 
60
 
61
+ print("[PIPELINE] Reconstructing background...")
 
62
  bg_base64 = reconstruct_background(image_path, layers)
 
63
 
64
  elapsed = round(time.time() - start, 2)
65
  print(f"[PIPELINE] Done in {elapsed}s")
66
 
67
  return {
68
+ "image_w": img_w,
69
+ "image_h": img_h,
70
+ "background_base64": bg_base64,
71
+ "original_base64": image_to_base64(image_path),
72
+ "layers": layers,
73
+ "groups": groups,
74
+ "processing_time_s": elapsed,
75
  }
76
 
77
 
 
85
  "image_h": result["image_h"],
86
  "processing_time_s": result["processing_time_s"],
87
  "layer_count": len(result["layers"]),
88
+ "group_count": len(result["groups"]),
89
+ "groups": result["groups"],
90
  "layers": [
91
  {k: v for k, v in layer.items() if k != "base64"}
92
  for layer in result["layers"]
 
98
  json.dump(result_slim, f, indent=2, ensure_ascii=False)
99
 
100
  print(f"[PIPELINE] Saved β†’ {out_path}")
101
+ return out_path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
proposal_engine.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+
3
+ SEMANTIC_REJECT = {
4
+ "background", "wall", "sky", "floor", "ground",
5
+ "ceiling", "road", "pavement", "surface"
6
+ }
7
+
8
+ MIN_AREA_FRACTION = 0.001
9
+ MAX_AREA_FRACTION = 0.80
10
+ DUPLICATE_IOU_THRESHOLD = 0.70
11
+
12
+
13
+ def compute_iou(a, b):
14
+ ax2 = a["x"] + a["w"]
15
+ ay2 = a["y"] + a["h"]
16
+ bx2 = b["x"] + b["w"]
17
+ by2 = b["y"] + b["h"]
18
+
19
+ ix1 = max(a["x"], b["x"])
20
+ iy1 = max(a["y"], b["y"])
21
+ ix2 = min(ax2, bx2)
22
+ iy2 = min(ay2, by2)
23
+
24
+ if ix2 <= ix1 or iy2 <= iy1:
25
+ return 0.0
26
+
27
+ intersection = (ix2 - ix1) * (iy2 - iy1)
28
+ union = a["w"] * a["h"] + b["w"] * b["h"] - intersection
29
+ return intersection / union if union > 0 else 0.0
30
+
31
+
32
+ def is_fully_inside(inner, outer):
33
+ return (
34
+ inner["x"] >= outer["x"] and
35
+ inner["y"] >= outer["y"] and
36
+ inner["x"] + inner["w"] <= outer["x"] + outer["w"] and
37
+ inner["y"] + inner["h"] <= outer["y"] + outer["h"]
38
+ )
39
+
40
+
41
+ def semantic_filter(proposals):
42
+ accepted, rejected = [], []
43
+ for p in proposals:
44
+ label = p.get("label", "").lower().strip()
45
+ if label in SEMANTIC_REJECT:
46
+ rejected.append((p, "semantic"))
47
+ else:
48
+ accepted.append(p)
49
+ return accepted, rejected
50
+
51
+
52
+ def area_filter(proposals, img_w, img_h):
53
+ image_area = img_w * img_h
54
+ accepted, rejected = [], []
55
+ for p in proposals:
56
+ fraction = (p["w"] * p["h"]) / image_area
57
+ if fraction < MIN_AREA_FRACTION:
58
+ rejected.append((p, "too small"))
59
+ elif fraction > MAX_AREA_FRACTION:
60
+ rejected.append((p, "too large"))
61
+ else:
62
+ accepted.append(p)
63
+ return accepted, rejected
64
+
65
+
66
+ def deduplicate(proposals):
67
+ kept, rejected = [], []
68
+ for candidate in proposals:
69
+ duplicate = False
70
+ for accepted in kept:
71
+ if compute_iou(candidate, accepted) > DUPLICATE_IOU_THRESHOLD:
72
+ duplicate = True
73
+ break
74
+ if duplicate:
75
+ rejected.append((candidate, "duplicate"))
76
+ else:
77
+ kept.append(candidate)
78
+ return kept, rejected
79
+
80
+
81
+ def container_filter(proposals):
82
+ accepted, rejected = [], []
83
+ for i, candidate in enumerate(proposals):
84
+ others = [p for j, p in enumerate(proposals) if j != i]
85
+ children_inside = sum(1 for o in others if is_fully_inside(o, candidate))
86
+ if children_inside >= 2:
87
+ rejected.append((candidate, "container"))
88
+ else:
89
+ accepted.append(candidate)
90
+ return accepted, rejected
91
+
92
+
93
+ def reject_text_inside_objects(text_proposals, accepted_objects):
94
+ kept, rejected = [], []
95
+ for t in text_proposals:
96
+ inside = any(is_fully_inside(t, obj) for obj in accepted_objects)
97
+ if inside:
98
+ rejected.append((t, "inside object"))
99
+ else:
100
+ kept.append(t)
101
+ return kept, rejected
102
+
103
+
104
+ def compute_groups(layers):
105
+ groups = []
106
+ group_counter = 0
107
+ annotated = [dict(l, group_id=None, group_role=None) for l in layers]
108
+
109
+ for i, root in enumerate(annotated):
110
+ children = []
111
+ for j, other in enumerate(annotated):
112
+ if i == j:
113
+ continue
114
+ if is_fully_inside(other, root):
115
+ children.append(j)
116
+
117
+ if not children:
118
+ continue
119
+
120
+ free = [j for j in children if annotated[j]["group_id"] is None]
121
+ if not free:
122
+ continue
123
+
124
+ group_id = f"group_{group_counter}"
125
+ group_label = root.get("label") or root.get("text") or f"Group {group_counter}"
126
+ group_counter += 1
127
+
128
+ annotated[i]["group_id"] = group_id
129
+ annotated[i]["group_role"] = "root"
130
+ for j in free:
131
+ annotated[j]["group_id"] = group_id
132
+ annotated[j]["group_role"] = "child"
133
+
134
+ groups.append({
135
+ "id": group_id,
136
+ "label": group_label,
137
+ "type": "group",
138
+ })
139
+
140
+ return annotated, groups
141
+
142
+
143
+ def print_proposal_report(accepted, all_rejected):
144
+ print("\n[PROPOSAL] ── Proposal Report ──────────────────")
145
+ for p in accepted:
146
+ label = p.get("label") or p.get("text", "")[:20]
147
+ print(f"[PROPOSAL] {label:<25} Accepted")
148
+ for p, reason in all_rejected:
149
+ label = p.get("label") or p.get("text", "")[:20]
150
+ print(f"[PROPOSAL] {label:<25} Rejected ({reason})")
151
+ print("[PROPOSAL] ─────────────────────────────────────\n")
152
+
153
+
154
+ def run_proposal_engine(object_proposals, img_w, img_h):
155
+ all_rejected = []
156
+
157
+ after_semantic, rej = semantic_filter(object_proposals)
158
+ all_rejected.extend(rej)
159
+
160
+ after_area, rej = area_filter(after_semantic, img_w, img_h)
161
+ all_rejected.extend(rej)
162
+
163
+ after_dedup, rej = deduplicate(after_area)
164
+ all_rejected.extend(rej)
165
+
166
+ after_container, rej = container_filter(after_dedup)
167
+ all_rejected.extend(rej)
168
+
169
+ print_proposal_report(after_container, all_rejected)
170
+
171
+ return after_container
test_images/logo.jpg ADDED
test_images/logo_company.jpg ADDED