sohamnk commited on
Commit
4954a1e
·
verified ·
1 Parent(s): 17bb3c0
Files changed (1) hide show
  1. app.py +109 -165
app.py CHANGED
@@ -1,18 +1,13 @@
1
  # --------------------------------------------------------------------------
2
- # UNIFIED AI SERVICE FOR LOST & FOUND V2
3
  # --------------------------------------------------------------------------
4
- # This Flask application combines two matching engines into a single service:
5
- # 1. Text Engine: Analyzes structured text fields (brand, material, etc.)
6
- # using text embeddings and specific comparison logic.
7
- # 2. Image Engine: Analyzes multiple images per item by segmenting the
8
- # object and comparing handcrafted visual features (shape, color, texture)
9
- # using a dynamic weighting system.
10
- #
11
- # Endpoints:
12
- # - /process: Extracts all text and visual features from a single item.
13
- # - /compare: Calculates a hybrid match score between a query item and
14
- # a list of candidate items.
15
  # --------------------------------------------------------------------------
 
16
  import sys
17
  sys.stdout.reconfigure(line_buffering=True)
18
  import os
@@ -21,14 +16,16 @@ import requests
21
  import cv2
22
  import traceback
23
  from io import BytesIO
24
- from skimage import feature
25
  from flask import Flask, request, jsonify
26
  from PIL import Image
 
27
 
28
  # --- Import Deep Learning Libraries ---
29
  import torch
30
- from transformers import AutoProcessor, AutoModelForZeroShotObjectDetection, AutoTokenizer, AutoModel
31
  from segment_anything import SamPredictor, sam_model_registry
 
 
32
 
33
  # ==========================================================================
34
  # --- CONFIGURATION & INITIALIZATION ---
@@ -37,14 +34,13 @@ from segment_anything import SamPredictor, sam_model_registry
37
  app = Flask(__name__)
38
 
39
  # --- Scoring and Weighting Configuration ---
40
- TEXT_FIELD_WEIGHTS = { "brand": 1.0, "material": 1.0, "markings": 1.0, "colors": 1.0, "size": 1.0 }
41
- TEXT_FIELDS_TO_EMBED = ["brand", "material", "markings"]
42
- SCORE_WEIGHTS = { "text_score": 0.5, "image_score": 0.5 }
43
- FINAL_SCORE_THRESHOLD = 0.55
44
 
45
  # --- Model Loading ---
46
  print("="*50)
47
- print("🚀 Initializing Unified AI Service...")
48
  device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
49
  print(f"🧠 Using device: {device}")
50
 
@@ -55,15 +51,22 @@ tokenizer_text = AutoTokenizer.from_pretrained(bge_model_id)
55
  model_text = AutoModel.from_pretrained(bge_model_id).to(device)
56
  print("✅ BGE model loaded.")
57
 
58
- # 2. Load Grounding DINO Model
59
- print("...Loading Grounding DINO model (IDEA-Research/grounding-dino-base)...")
 
 
 
 
 
 
 
60
  gnd_model_id = "IDEA-Research/grounding-dino-base"
61
- processor_gnd = AutoProcessor.from_pretrained(gnd_model_id)
62
  model_gnd = AutoModelForZeroShotObjectDetection.from_pretrained(gnd_model_id).to(device)
63
  print("✅ Grounding DINO model loaded.")
64
 
65
- # 3. Load Segment Anything (SAM) Model
66
- print("...Loading Segment Anything (SAM) model (vit_b)...")
67
  sam_checkpoint = "sam_vit_b_01ec64.pth"
68
  sam_model = sam_model_registry["vit_b"](checkpoint=sam_checkpoint).to(device)
69
  sam_predictor = SamPredictor(sam_model)
@@ -74,9 +77,11 @@ print("="*50)
74
  # --- HELPER FUNCTIONS ---
75
  # ==========================================================================
76
 
77
- # --- Text Processing Helpers ---
78
  def get_text_embedding(text: str) -> list:
79
  if not text or not text.strip(): return None
 
 
 
80
  instruction = "Represent this sentence for searching relevant passages: "
81
  inputs = tokenizer_text(instruction + text, return_tensors='pt', padding=True, truncation=True, max_length=512).to(device)
82
  with torch.no_grad():
@@ -85,22 +90,30 @@ def get_text_embedding(text: str) -> list:
85
  embedding = torch.nn.functional.normalize(embedding, p=2, dim=1)
86
  return embedding.cpu().numpy()[0].tolist()
87
 
 
 
 
 
 
 
 
 
 
 
88
  def cosine_similarity(vec1, vec2):
89
  if vec1 is None or vec2 is None: return 0.0
90
  vec1, vec2 = np.array(vec1), np.array(vec2)
91
  return float(np.dot(vec1, vec2) / (np.linalg.norm(vec1) * np.linalg.norm(vec2)))
92
 
93
- def calculate_color_similarity(colors1: list, colors2: list) -> float:
94
- if not colors1 and not colors2: return 1.0
95
- if not colors1 or not colors2: return 0.0
96
- set1, set2 = set(c.lower() for c in colors1), set(c.lower() for c in colors2)
97
- intersection = len(set1.intersection(set2))
98
- union = len(set1.union(set2))
99
- return intersection / union if union > 0 else 0.0
100
-
101
- # --- Image Processing Helpers ---
102
- def segment_guided_object(image: Image.Image, object_label: str) -> Image.Image:
103
- prompt = f"a {object_label}."
104
  image_rgb = image.convert("RGB")
105
  image_np = np.array(image_rgb)
106
  h, w = image_np.shape[:2]
@@ -114,68 +127,24 @@ def segment_guided_object(image: Image.Image, object_label: str) -> Image.Image:
114
  )
115
 
116
  if not results or len(results[0]['boxes']) == 0:
117
- print(f" [Segment] ⚠️ Warning: Could not detect '{object_label}'. Using full image.")
118
- return image
119
 
120
  sam_predictor.set_image(image_np)
121
  box = results[0]['boxes'][0].cpu().numpy().astype(int)
122
  masks, _, _ = sam_predictor.predict(box=box, multimask_output=False)
123
 
124
  mask = masks[0]
125
- object_rgba = np.zeros((h, w, 4), dtype=np.uint8)
126
- object_rgba[:, :, :3] = image_np
127
- object_rgba[:, :, 3] = mask * 255
128
-
129
- return Image.fromarray(object_rgba, 'RGBA')
130
-
131
- def extract_visual_features(segmented_image_rgba: Image.Image) -> dict:
132
- image_np = np.array(segmented_image_rgba)
133
- bgr_image = cv2.cvtColor(image_np[:, :, :3], cv2.COLOR_RGB2BGR)
134
- mask = image_np[:, :, 3]
135
-
136
- contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
137
- shape_features = np.zeros(7)
138
- if contours:
139
- largest_contour = max(contours, key=cv2.contourArea)
140
- moments = cv2.moments(largest_contour)
141
- if moments['m00'] != 0:
142
- hu_moments = cv2.HuMoments(moments).flatten()
143
- shape_features = -np.sign(hu_moments) * np.log10(np.abs(hu_moments) + 1e-7)
144
-
145
- color_hist = cv2.calcHist([bgr_image], [0, 1, 2], mask, [8, 8, 8], [0, 256, 0, 256, 0, 256])
146
- cv2.normalize(color_hist, color_hist)
147
-
148
- gray_image = cv2.cvtColor(bgr_image, cv2.COLOR_BGR2GRAY)
149
- lbp = feature.local_binary_pattern(gray_image, P=24, R=3, method="uniform")
150
- (texture_hist, _) = np.histogram(lbp[mask > 0], bins=np.arange(0, 27), range=(0, 26))
151
- texture_hist = texture_hist.astype("float")
152
- texture_hist /= (texture_hist.sum() + 1e-6)
153
-
154
- return {
155
- "shape_features": shape_features.tolist(),
156
- "color_features": color_hist.flatten().tolist(),
157
- "texture_features": texture_hist.tolist()
158
- }
159
-
160
- def calculate_dynamic_weights(all_shape_scores, all_color_scores, stability_factor=0.4):
161
- shape_scores, color_scores = np.array(all_shape_scores), np.array(all_color_scores)
162
-
163
- def get_iqr(scores):
164
- if len(scores) < 2: return 0
165
- q3, q1 = np.percentile(scores, [75, 25])
166
- return q3 - q1
167
-
168
- shape_dispersion = get_iqr(shape_scores)
169
- color_dispersion = get_iqr(color_scores)
170
- inv_shape_disp = 1 / (shape_dispersion + stability_factor)
171
- inv_color_disp = 1 / (color_dispersion + stability_factor)
172
- total_inv_disp = inv_shape_disp + inv_color_disp
173
- remaining_weight = 0.8
174
-
175
- shape_weight = remaining_weight * (inv_shape_disp / total_inv_disp) if total_inv_disp > 0 else remaining_weight / 2
176
- color_weight = remaining_weight * (inv_color_disp / total_inv_disp) if total_inv_disp > 0 else remaining_weight / 2
177
 
178
- return {"shape": shape_weight, "color": color_weight, "texture": 0.2}
179
 
180
  # ==========================================================================
181
  # --- FLASK ENDPOINTS ---
@@ -183,46 +152,41 @@ def calculate_dynamic_weights(all_shape_scores, all_color_scores, stability_fact
183
 
184
  @app.route('/', methods=['GET'])
185
  def health_check():
186
- return jsonify({"status": "Unified AI Service is running"}), 200
187
 
188
  @app.route('/process', methods=['POST'])
189
  def process_item():
190
  try:
191
  data = request.json
192
- print(f"\n[PROCESS] Received request for object: {data.get('objectName')}")
193
 
194
  # --- 1. Process Text Features ---
195
- print(" [PROCESS] Generating text embeddings...")
196
  response = {
197
  "canonicalLabel": data.get('objectName', '').lower().strip(),
198
  "brand_embedding": get_text_embedding(data.get('brand')),
199
  "material_embedding": get_text_embedding(data.get('material')),
200
- "markings_embedding": get_text_embedding(data.get('markings'))
 
201
  }
202
- print(" [PROCESS] ✅ Text embeddings generated.")
203
 
204
  # --- 2. Process Image Features ---
205
- visual_features_list = []
206
  if data.get('images'):
207
  print(f" [PROCESS] Processing {len(data['images'])} image(s)...")
208
- for i, image_url in enumerate(data['images']):
209
  try:
210
- print(f" - Processing image {i+1}: {image_url}")
211
  img_response = requests.get(image_url, timeout=20)
212
  img_response.raise_for_status()
213
  image = Image.open(BytesIO(img_response.content))
214
 
215
- print(" - Segmenting object...")
216
- segmented_image = segment_guided_object(image, data['objectName'])
217
- print(" - Extracting visual features...")
218
- features = extract_visual_features(segmented_image)
219
- visual_features_list.append(features)
220
- print(f" - ✅ Image {i+1} processed.")
221
  except Exception as e:
222
  print(f" - ⚠️ Could not process image {image_url}: {e}")
223
  continue
224
 
225
- response["visual_features"] = visual_features_list
226
  print(f" [PROCESS] ✅ Successfully processed all features.")
227
  return jsonify(response), 200
228
 
@@ -237,77 +201,61 @@ def compare_items():
237
  payload = request.json
238
  query_item = payload['queryItem']
239
  search_list = payload['searchList']
240
- print(f"\n[COMPARE] Comparing '{query_item.get('objectName')}' against {len(search_list)} items.")
241
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  results = []
243
  for item in search_list:
244
  item_id = item.get('_id')
245
- print(f"\n - Comparing with item: {item_id} ({item.get('objectName')})")
246
  try:
247
- # --- 1. Calculate Text Score ---
248
- total_text_score, total_text_weight = 0, 0
249
-
250
  for field in TEXT_FIELDS_TO_EMBED:
251
  q_emb = query_item.get(f"{field}_embedding")
252
  i_emb = item.get(f"{field}_embedding")
253
  if q_emb and i_emb:
254
- score = cosine_similarity(q_emb, i_emb)
255
- weight = TEXT_FIELD_WEIGHTS.get(field, 0)
256
- total_text_score += score * weight
257
- total_text_weight += weight
258
-
259
- if query_item.get('colors'):
260
- score = calculate_color_similarity(query_item['colors'], item.get('colors', []))
261
- weight = TEXT_FIELD_WEIGHTS.get('colors', 0)
262
- total_text_score += score * weight
263
- total_text_weight += weight
264
-
265
- if query_item.get('size'):
266
- score = 1.0 if query_item.get('size') == item.get('size') else 0.0
267
- weight = TEXT_FIELD_WEIGHTS.get('size', 0)
268
- total_text_score += score * weight
269
- total_text_weight += weight
270
-
271
- text_score = (total_text_score / total_text_weight) if total_text_weight > 0 else 0.0
272
- print(f" - Text Score: {text_score:.4f}")
273
 
274
- # --- 2. Calculate Image Score ---
275
  image_score = 0.0
276
- query_visuals = query_item.get('visual_features', [])
277
- item_visuals = item.get('visual_features', [])
278
-
279
- if query_visuals and item_visuals:
280
- all_shape_scores, all_color_scores, all_texture_scores = [], [], []
281
- for q_vis in query_visuals:
282
- for i_vis in item_visuals:
283
- shape_dist = cv2.matchShapes(np.array(q_vis["shape_features"], dtype="float32"), np.array(i_vis["shape_features"], dtype="float32"), cv2.CONTOURS_MATCH_I1, 0.0)
284
- all_shape_scores.append(1.0 / (1.0 + shape_dist))
285
- all_color_scores.append(cv2.compareHist(np.array(q_vis["color_features"], dtype="float32"), np.array(i_vis["color_features"], dtype="float32"), cv2.HISTCMP_CORREL))
286
- all_texture_scores.append(cv2.compareHist(np.array(q_vis["texture_features"], dtype="float32"), np.array(i_vis["texture_features"], dtype="float32"), cv2.HISTCMP_CORREL))
287
-
288
- if all_shape_scores:
289
- weights = calculate_dynamic_weights(all_shape_scores, all_color_scores)
290
- image_score = (weights["shape"] * max(all_shape_scores) +
291
- weights["color"] * max(all_color_scores) +
292
- weights["texture"] * max(all_texture_scores))
293
- print(f" - Image Score: {image_score:.4f}")
294
 
295
- # --- 3. Calculate Final Hybrid Score ---
296
- if query_visuals and item_visuals:
297
- final_score = (SCORE_WEIGHTS['text_score'] * text_score + SCORE_WEIGHTS['image_score'] * image_score)
298
- else:
299
- final_score = text_score # Default to text score if one has no image
300
 
301
- print(f" - Final Hybrid Score: {final_score:.4f}")
302
-
303
  if final_score >= FINAL_SCORE_THRESHOLD:
304
- print(f" - ✅ ACCEPTED (Score >= {FINAL_SCORE_THRESHOLD})")
305
  results.append({ "_id": str(item_id), "score": round(final_score, 4) })
306
- else:
307
- print(f" - ❌ REJECTED (Score < {FINAL_SCORE_THRESHOLD})")
308
-
309
  except Exception as e:
310
- print(f" - ⚠️ Skipping item {item_id} due to error: {e}")
311
  continue
312
 
313
  results.sort(key=lambda x: x["score"], reverse=True)
@@ -318,10 +266,6 @@ def compare_items():
318
  print(f"❌ Error in /compare: {e}")
319
  traceback.print_exc()
320
  return jsonify({"error": str(e)}), 500
321
-
322
- # ==========================================================================
323
- # --- APPLICATION RUN ---
324
- # ==========================================================================
325
 
326
  if __name__ == '__main__':
327
  app.run(host='0.0.0.0', port=7860)
 
1
  # --------------------------------------------------------------------------
2
+ # UNIFIED AI SERVICE V3 (DINOv2 Integration)
3
  # --------------------------------------------------------------------------
4
+ # This service uses DINOv2 for image embeddings and BGE for text embeddings.
5
+ # It performs intelligent filtering before scoring.
6
+ # 1. Filters by object name, date, and location hierarchy.
7
+ # 2. Extracts features using BGE (text) and DINOv2 (image).
8
+ # 3. Scores items based on a hybrid of text and image similarity.
 
 
 
 
 
 
9
  # --------------------------------------------------------------------------
10
+
11
  import sys
12
  sys.stdout.reconfigure(line_buffering=True)
13
  import os
 
16
  import cv2
17
  import traceback
18
  from io import BytesIO
 
19
  from flask import Flask, request, jsonify
20
  from PIL import Image
21
+ from datetime import datetime, timedelta
22
 
23
  # --- Import Deep Learning Libraries ---
24
  import torch
25
+ from transformers import AutoImageProcessor, AutoModel, AutoTokenizer
26
  from segment_anything import SamPredictor, sam_model_registry
27
+ # Grounding DINO is still needed for segmentation
28
+ from transformers import AutoProcessor as AutoGndProcessor, AutoModelForZeroShotObjectDetection
29
 
30
  # ==========================================================================
31
  # --- CONFIGURATION & INITIALIZATION ---
 
34
  app = Flask(__name__)
35
 
36
  # --- Scoring and Weighting Configuration ---
37
+ TEXT_FIELDS_TO_EMBED = ["brand", "material", "size", "colors"]
38
+ SCORE_WEIGHTS = { "text_score": 0.4, "image_score": 0.6 } # Give image score more weight
39
+ FINAL_SCORE_THRESHOLD = 0.5
 
40
 
41
  # --- Model Loading ---
42
  print("="*50)
43
+ print("🚀 Initializing AI Service with DINOv2...")
44
  device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
45
  print(f"🧠 Using device: {device}")
46
 
 
51
  model_text = AutoModel.from_pretrained(bge_model_id).to(device)
52
  print("✅ BGE model loaded.")
53
 
54
+ # 2. Load DINOv2 Image Model
55
+ print("...Loading DINOv2 model (facebook/dinov2-base)...")
56
+ dinov2_model_id = "facebook/dinov2-base"
57
+ processor_dinov2 = AutoImageProcessor.from_pretrained(dinov2_model_id)
58
+ model_dinov2 = AutoModel.from_pretrained(dinov2_model_id).to(device)
59
+ print("✅ DINOv2 model loaded.")
60
+
61
+ # 3. Load Grounding DINO Model (for segmentation)
62
+ print("...Loading Grounding DINO model for segmentation...")
63
  gnd_model_id = "IDEA-Research/grounding-dino-base"
64
+ processor_gnd = AutoGndProcessor.from_pretrained(gnd_model_id)
65
  model_gnd = AutoModelForZeroShotObjectDetection.from_pretrained(gnd_model_id).to(device)
66
  print("✅ Grounding DINO model loaded.")
67
 
68
+ # 4. Load Segment Anything (SAM) Model
69
+ print("...Loading SAM model...")
70
  sam_checkpoint = "sam_vit_b_01ec64.pth"
71
  sam_model = sam_model_registry["vit_b"](checkpoint=sam_checkpoint).to(device)
72
  sam_predictor = SamPredictor(sam_model)
 
77
  # --- HELPER FUNCTIONS ---
78
  # ==========================================================================
79
 
 
80
  def get_text_embedding(text: str) -> list:
81
  if not text or not text.strip(): return None
82
+ # For colors list, join them into a string
83
+ if isinstance(text, list):
84
+ text = ", ".join(text)
85
  instruction = "Represent this sentence for searching relevant passages: "
86
  inputs = tokenizer_text(instruction + text, return_tensors='pt', padding=True, truncation=True, max_length=512).to(device)
87
  with torch.no_grad():
 
90
  embedding = torch.nn.functional.normalize(embedding, p=2, dim=1)
91
  return embedding.cpu().numpy()[0].tolist()
92
 
93
+ def get_image_embedding(image: Image.Image) -> list:
94
+ """Generates a DINOv2 embedding for a given image."""
95
+ inputs = processor_dinov2(images=image, return_tensors="pt").to(device)
96
+ with torch.no_grad():
97
+ outputs = model_dinov2(**inputs)
98
+ # Use the CLS token embedding
99
+ embedding = outputs.last_hidden_state[:, 0, :]
100
+ embedding = torch.nn.functional.normalize(embedding, p=2, dim=1)
101
+ return embedding.cpu().numpy()[0].tolist()
102
+
103
  def cosine_similarity(vec1, vec2):
104
  if vec1 is None or vec2 is None: return 0.0
105
  vec1, vec2 = np.array(vec1), np.array(vec2)
106
  return float(np.dot(vec1, vec2) / (np.linalg.norm(vec1) * np.linalg.norm(vec2)))
107
 
108
+ def segment_guided_object(image: Image.Image, object_label: str, text_data: dict) -> Image.Image:
109
+ """Segments an object using a more descriptive prompt."""
110
+ # Create a richer prompt for better segmentation
111
+ desc_parts = [object_label]
112
+ if text_data.get('brand'): desc_parts.append(f"brand {text_data['brand']}")
113
+ if text_data.get('colors'): desc_parts.append(", ".join(text_data['colors']))
114
+ prompt = " ".join(desc_parts)
115
+
116
+ print(f" [Segment] Using prompt: '{prompt}'")
 
 
117
  image_rgb = image.convert("RGB")
118
  image_np = np.array(image_rgb)
119
  h, w = image_np.shape[:2]
 
127
  )
128
 
129
  if not results or len(results[0]['boxes']) == 0:
130
+ print(f" [Segment] ⚠️ Warning: Could not detect object. Using full image.")
131
+ return image_rgb # Return the RGB image for DINOv2
132
 
133
  sam_predictor.set_image(image_np)
134
  box = results[0]['boxes'][0].cpu().numpy().astype(int)
135
  masks, _, _ = sam_predictor.predict(box=box, multimask_output=False)
136
 
137
  mask = masks[0]
138
+ # Create a white background
139
+ background = np.ones_like(image_np, dtype=np.uint8) * 255
140
+ # Apply mask to original image
141
+ foreground = cv2.bitwise_and(image_np, image_np, mask=mask.astype(np.uint8))
142
+ # Apply inverse mask to background
143
+ background = cv2.bitwise_and(background, background, mask=~mask.astype(np.uint8))
144
+ # Combine foreground and background
145
+ segmented_np = cv2.add(foreground, background)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
 
147
+ return Image.fromarray(segmented_np, 'RGB')
148
 
149
  # ==========================================================================
150
  # --- FLASK ENDPOINTS ---
 
152
 
153
  @app.route('/', methods=['GET'])
154
  def health_check():
155
+ return jsonify({"status": "Unified AI Service (DINOv2) is running"}), 200
156
 
157
  @app.route('/process', methods=['POST'])
158
  def process_item():
159
  try:
160
  data = request.json
161
+ print(f"\n[PROCESS] Received request for: {data.get('objectName')}")
162
 
163
  # --- 1. Process Text Features ---
 
164
  response = {
165
  "canonicalLabel": data.get('objectName', '').lower().strip(),
166
  "brand_embedding": get_text_embedding(data.get('brand')),
167
  "material_embedding": get_text_embedding(data.get('material')),
168
+ "size_embedding": get_text_embedding(data.get('size')),
169
+ "colors_embedding": get_text_embedding(data.get('colors')),
170
  }
 
171
 
172
  # --- 2. Process Image Features ---
173
+ image_embeddings = []
174
  if data.get('images'):
175
  print(f" [PROCESS] Processing {len(data['images'])} image(s)...")
176
+ for image_url in data['images']:
177
  try:
 
178
  img_response = requests.get(image_url, timeout=20)
179
  img_response.raise_for_status()
180
  image = Image.open(BytesIO(img_response.content))
181
 
182
+ segmented_image = segment_guided_object(image, data['objectName'], data)
183
+ embedding = get_image_embedding(segmented_image)
184
+ image_embeddings.append(embedding)
 
 
 
185
  except Exception as e:
186
  print(f" - ⚠️ Could not process image {image_url}: {e}")
187
  continue
188
 
189
+ response["image_embeddings"] = image_embeddings
190
  print(f" [PROCESS] ✅ Successfully processed all features.")
191
  return jsonify(response), 200
192
 
 
201
  payload = request.json
202
  query_item = payload['queryItem']
203
  search_list = payload['searchList']
204
+ print(f"\n[COMPARE] Received {len(search_list)} candidates for '{query_item.get('objectName')}'.")
205
 
206
+ # --- HIERARCHICAL FILTERING ---
207
+ # 1. Object Name
208
+ query_label = query_item.get('canonicalLabel')
209
+ if query_label:
210
+ search_list = [item for item in search_list if item.get('canonicalLabel') == query_label]
211
+ print(f" [FILTER] After object name: {len(search_list)} candidates remain.")
212
+
213
+ # 2. Date
214
+ query_date_str = query_item.get('dateLost') or query_item.get('dateFound')
215
+ query_date = datetime.fromisoformat(query_date_str.replace('Z', '+00:00'))
216
+ one_week = timedelta(days=7)
217
+ search_list = [item for item in search_list if abs(query_date - datetime.fromisoformat((item.get('dateFound') or item.get('dateLost')).replace('Z', '+00:00'))) <= one_week]
218
+ print(f" [FILTER] After date: {len(search_list)} candidates remain.")
219
+
220
+ # 3. Location
221
+ query_location = query_item.get('locationLost') or query_item.get('locationFound')
222
+ if query_location and query_location != "Campus":
223
+ search_list = [item for item in search_list if (item.get('locationFound') or item.get('locationLost')) in [query_location, "Campus"]]
224
+ print(f" [FILTER] After location: {len(search_list)} candidates for scoring.")
225
+
226
+ # --- SCORING ---
227
  results = []
228
  for item in search_list:
229
  item_id = item.get('_id')
 
230
  try:
231
+ # 1. Calculate Text Score
232
+ total_text_score = 0
 
233
  for field in TEXT_FIELDS_TO_EMBED:
234
  q_emb = query_item.get(f"{field}_embedding")
235
  i_emb = item.get(f"{field}_embedding")
236
  if q_emb and i_emb:
237
+ total_text_score += cosine_similarity(q_emb, i_emb)
238
+ text_score = total_text_score / len(TEXT_FIELDS_TO_EMBED) if TEXT_FIELDS_TO_EMBED else 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
 
240
+ # 2. Calculate Image Score
241
  image_score = 0.0
242
+ query_img_embs = query_item.get('image_embeddings', [])
243
+ item_img_embs = item.get('image_embeddings', [])
244
+ if query_img_embs and item_img_embs:
245
+ all_img_scores = []
246
+ for q_emb in query_img_embs:
247
+ for i_emb in item_img_embs:
248
+ all_img_scores.append(cosine_similarity(q_emb, i_emb))
249
+ if all_img_scores:
250
+ image_score = max(all_img_scores)
 
 
 
 
 
 
 
 
 
251
 
252
+ # 3. Calculate Final Score
253
+ final_score = (SCORE_WEIGHTS['text_score'] * text_score + SCORE_WEIGHTS['image_score'] * image_score)
 
 
 
254
 
 
 
255
  if final_score >= FINAL_SCORE_THRESHOLD:
 
256
  results.append({ "_id": str(item_id), "score": round(final_score, 4) })
 
 
 
257
  except Exception as e:
258
+ print(f" - ⚠️ Skipping item {item_id} due to scoring error: {e}")
259
  continue
260
 
261
  results.sort(key=lambda x: x["score"], reverse=True)
 
266
  print(f"❌ Error in /compare: {e}")
267
  traceback.print_exc()
268
  return jsonify({"error": str(e)}), 500
 
 
 
 
269
 
270
  if __name__ == '__main__':
271
  app.run(host='0.0.0.0', port=7860)