codewithRiz commited on
Commit
5f084a6
Β·
1 Parent(s): 0d15c78

retagging images

Browse files
Files changed (1) hide show
  1. api/detection.py +113 -146
api/detection.py CHANGED
@@ -68,84 +68,57 @@ async def predict(
68
  "results": new_results
69
  }
70
  # ─────────────────
71
- # Request Models
72
  # ─────────────────
73
 
74
-
75
- # ================================================================
76
- # VALID LABELS β€” must exactly match what process_images_batch()
77
- # produces from the 3-stage YOLO pipeline:
78
- # Stage 1: deer detected
79
- # Stage 2: Buck β†’ Stage 3: Whitetail | Mule
80
- # Stage 2: Doe
81
- # ================================================================
82
  VALID_LABELS = {
83
- "Deer | Doe", # trailing space matches pipeline output
84
- "Deer | Buck | White Tail Bucks",
85
- "Deer | Buck | Mule Bucks",
86
- }
87
-
88
- VALID_LABELS_DISPLAY = [ # clean version shown in error messages
89
  "Deer | Doe",
90
  "Deer | Buck | White Tail Bucks",
91
  "Deer | Buck | Mule Bucks",
92
-
 
 
 
 
 
 
93
 
94
- ]
95
-
96
-
97
- def _normalise_label(label: str) -> str:
98
- """Strip and normalise label so 'Deer | Doe' == 'Deer | Doe '."""
99
  return label.strip()
100
-
101
-
102
- def _validate_label(label: str) -> str:
103
- """Raise a clear error if the label is not from the detection pipeline."""
104
- normalised = _normalise_label(label)
105
- # Match against normalised versions of VALID_LABELS
106
- if normalised not in {l.strip() for l in VALID_LABELS}:
107
  raise HTTPException(
108
  status_code=422,
109
- detail=(
110
- f"Invalid label '{label}'. "
111
- f"Must be one of: {VALID_LABELS_DISPLAY}"
112
- )
113
  )
114
- # Return the canonical pipeline form (with trailing space for Doe)
115
- for valid in VALID_LABELS:
116
- if valid.strip() == normalised:
117
- return valid
118
- return label
119
-
120
-
121
  # ─────────────────
122
  # Request Models
123
  # ─────────────────
124
-
125
  class DetectionOperation(BaseModel):
126
  action: Literal["add", "update", "delete"]
127
  detection_index: Optional[int] = None
128
  label: Optional[str] = None
129
- bbox: Optional[List[float]] = None # [x1, y1, x2, y2]
130
-
131
  @validator("label")
132
- def label_must_be_valid(cls, v):
133
  if v is None:
134
  return v
135
- normalised = v.strip()
136
- valid_normalised = {l.strip() for l in VALID_LABELS}
137
- if normalised not in valid_normalised:
138
- raise ValueError(
139
- f"Invalid label '{v}'. Must be one of: {VALID_LABELS_DISPLAY}"
140
- )
141
- # Return canonical form
142
- for valid in VALID_LABELS:
143
- if valid.strip() == normalised:
144
- return valid
145
- return v
146
-
147
  @validator("bbox")
148
- def bbox_must_be_four_values(cls, v):
149
  if v is None:
150
  return v
151
  if len(v) != 4:
@@ -154,127 +127,122 @@ class DetectionOperation(BaseModel):
154
  if x2 <= x1 or y2 <= y1:
155
  raise ValueError("bbox must satisfy x2 > x1 and y2 > y1")
156
  return v
157
-
158
  @validator("detection_index")
159
- def index_must_be_non_negative(cls, v):
160
  if v is not None and v < 0:
161
  raise ValueError("detection_index must be >= 0")
162
  return v
163
-
164
-
165
  class MultiUpdateRequest(BaseModel):
166
  user_id: str
167
  camera_name: str
168
  image_url: str
169
  operations: List[DetectionOperation]
170
-
171
  @validator("operations")
172
- def operations_must_not_be_empty(cls, v):
173
- if not v:
174
  raise ValueError("operations list cannot be empty")
175
- return v
176
-
177
- @validator("operations", each_item=True)
178
- def validate_operation_fields(cls, op):
179
- if op.action == "add":
180
- if op.label is None:
181
- raise ValueError("'add' operation requires a label")
182
- if op.bbox is None:
183
- raise ValueError("'add' operation requires a bbox")
184
- elif op.action == "update":
185
- if op.detection_index is None:
186
- raise ValueError("'update' operation requires detection_index")
187
- if op.label is None and op.bbox is None:
188
- raise ValueError("'update' operation requires at least label or bbox")
189
- elif op.action == "delete":
190
- if op.detection_index is None:
191
- raise ValueError("'delete' operation requires detection_index")
192
- return op
193
-
194
-
195
  # ─────────────────
196
  # Endpoint
197
  # ─────────────────
198
-
199
  @router.post("/modify_detections")
200
  async def modify_detections(req: MultiUpdateRequest):
201
- """
202
- Add, update, and delete detections (tags) for a given image.
203
- Supports multiple operations in a single request.
204
- Labels must match the detection pipeline format exactly.
205
- """
206
-
207
- # ── Validate user & camera ────────────────────────────────────
208
  validate_user_and_camera(req.user_id, req.camera_name)
209
-
210
- # ── Validate detections JSON exists in bucket ─────────────────
211
- json_key = _bucket_key(req.user_id, req.camera_name, f"{req.camera_name}_detections.json")
 
 
 
 
212
  if not _key_exists(json_key):
213
  raise HTTPException(status_code=404, detail="Detections file not found")
214
-
215
- # ── Load data from bucket ─────────────────────────────────────
216
  data = load_json(json_key)
217
-
218
- # ── Find image record by filename ─────────────────────────────
219
- target_filename = req.image_url.split("/")[-1].split("?")[0]
220
-
221
- record = None
222
- for item in data:
223
- stored = item.get("image_url", item.get("filename", ""))
224
- stored_filename = stored.split("/")[-1].split("?")[0]
225
- if stored_filename == target_filename:
226
- record = item
227
- break
228
-
229
  if record is None:
230
  raise HTTPException(status_code=404, detail="Image not found")
231
-
232
- # ── Ensure detections list exists ─────────────────────────────
233
- if "detections" not in record or not isinstance(record["detections"], list):
234
- record["detections"] = []
235
-
236
- dets = record["detections"]
237
-
238
- # ── Apply operations ──────────────────────────────────────────
239
- # Deletes run in reverse index order to avoid index shifting
240
  delete_ops = [op for op in req.operations if op.action == "delete"]
241
- other_ops = [op for op in req.operations if op.action != "delete"]
242
-
243
- # DELETE (reverse order to avoid index shifting)
244
- for op in sorted(delete_ops, key=lambda x: x.detection_index or -1, reverse=True):
245
- if op.detection_index >= len(dets):
 
246
  raise HTTPException(
247
  status_code=400,
248
- detail=f"Invalid delete index {op.detection_index} β€” only {len(dets)} detection(s) exist"
249
  )
250
- dets.pop(op.detection_index)
251
-
252
- # ADD + UPDATE
253
  for op in other_ops:
254
-
255
  if op.action == "add":
256
  dets.append({
257
- "label": op.label, # already validated & canonicalised by validator
258
- "confidence": 1.0,
259
- "bbox": op.bbox,
260
  "manually_edited": True
261
  })
262
-
263
  elif op.action == "update":
264
- if op.detection_index >= len(dets):
 
265
  raise HTTPException(
266
  status_code=400,
267
- detail=f"Invalid update index {op.detection_index} β€” only {len(dets)} detection(s) exist"
268
  )
 
269
  if op.label is not None:
270
- dets[op.detection_index]["label"] = op.label
 
271
  if op.bbox is not None:
272
- dets[op.detection_index]["bbox"] = op.bbox
273
- dets[op.detection_index]["manually_edited"] = True
274
-
275
- # ── Save back to bucket ───────────────────────────────────────
276
  save_json(json_key, data)
277
-
278
  logger.info(
279
  "Detections modified | user=%s camera=%s file=%s ops=%d final_count=%d",
280
  req.user_id,
@@ -283,12 +251,11 @@ async def modify_detections(req: MultiUpdateRequest):
283
  len(req.operations),
284
  len(dets)
285
  )
286
-
287
  return {
288
- "success": True,
289
- "message": "Detections modified successfully",
290
- "filename": target_filename,
291
  "total_detections": len(dets),
292
- "detections": dets
293
- }
294
-
 
68
  "results": new_results
69
  }
70
  # ─────────────────
71
+ # VALID LABELS
72
  # ─────────────────
73
 
 
 
 
 
 
 
 
 
74
  VALID_LABELS = {
 
 
 
 
 
 
75
  "Deer | Doe",
76
  "Deer | Buck | White Tail Bucks",
77
  "Deer | Buck | Mule Bucks",
78
+ }
79
+
80
+ VALID_LABELS_DISPLAY = list(VALID_LABELS)
81
+
82
+ # Precompute normalized β†’ canonical mapping (FAST lookup)
83
+ NORMALIZED_LABEL_MAP = {l.strip(): l for l in VALID_LABELS}
84
+
85
 
86
+ def normalize_label(label: str) -> str:
 
 
 
 
87
  return label.strip()
88
+
89
+
90
+ def validate_label(label: str) -> str:
91
+ norm = normalize_label(label)
92
+ if norm not in NORMALIZED_LABEL_MAP:
 
 
93
  raise HTTPException(
94
  status_code=422,
95
+ detail=f"Invalid label '{label}'. Must be one of: {VALID_LABELS_DISPLAY}"
 
 
 
96
  )
97
+ return NORMALIZED_LABEL_MAP[norm]
98
+
99
+
100
+ def extract_filename(url: str) -> str:
101
+ return url.split("/")[-1].split("?")[0]
102
+
103
+
104
  # ─────────────────
105
  # Request Models
106
  # ─────────────────
107
+
108
  class DetectionOperation(BaseModel):
109
  action: Literal["add", "update", "delete"]
110
  detection_index: Optional[int] = None
111
  label: Optional[str] = None
112
+ bbox: Optional[List[float]] = None # [x1, y1, x2, y2]
113
+
114
  @validator("label")
115
+ def validate_label_field(cls, v):
116
  if v is None:
117
  return v
118
+ return validate_label(v)
119
+
 
 
 
 
 
 
 
 
 
 
120
  @validator("bbox")
121
+ def validate_bbox(cls, v):
122
  if v is None:
123
  return v
124
  if len(v) != 4:
 
127
  if x2 <= x1 or y2 <= y1:
128
  raise ValueError("bbox must satisfy x2 > x1 and y2 > y1")
129
  return v
130
+
131
  @validator("detection_index")
132
+ def validate_index(cls, v):
133
  if v is not None and v < 0:
134
  raise ValueError("detection_index must be >= 0")
135
  return v
136
+
137
+
138
  class MultiUpdateRequest(BaseModel):
139
  user_id: str
140
  camera_name: str
141
  image_url: str
142
  operations: List[DetectionOperation]
143
+
144
  @validator("operations")
145
+ def validate_operations(cls, ops):
146
+ if not ops:
147
  raise ValueError("operations list cannot be empty")
148
+
149
+ for op in ops:
150
+ if op.action == "add":
151
+ if op.label is None or op.bbox is None:
152
+ raise ValueError("'add' requires both label and bbox")
153
+
154
+ elif op.action == "update":
155
+ if op.detection_index is None:
156
+ raise ValueError("'update' requires detection_index")
157
+ if op.label is None and op.bbox is None:
158
+ raise ValueError("'update' requires label or bbox")
159
+
160
+ elif op.action == "delete":
161
+ if op.detection_index is None:
162
+ raise ValueError("'delete' requires detection_index")
163
+
164
+ return ops
165
+
166
+
 
167
  # ─────────────────
168
  # Endpoint
169
  # ─────────────────
170
+
171
  @router.post("/modify_detections")
172
  async def modify_detections(req: MultiUpdateRequest):
173
+
 
 
 
 
 
 
174
  validate_user_and_camera(req.user_id, req.camera_name)
175
+
176
+ json_key = _bucket_key(
177
+ req.user_id,
178
+ req.camera_name,
179
+ f"{req.camera_name}_detections.json"
180
+ )
181
+
182
  if not _key_exists(json_key):
183
  raise HTTPException(status_code=404, detail="Detections file not found")
184
+
 
185
  data = load_json(json_key)
186
+
187
+ target_filename = extract_filename(req.image_url)
188
+
189
+ # Find record (optimized)
190
+ record = next(
191
+ (
192
+ item for item in data
193
+ if extract_filename(item.get("image_url", item.get("filename", ""))) == target_filename
194
+ ),
195
+ None
196
+ )
197
+
198
  if record is None:
199
  raise HTTPException(status_code=404, detail="Image not found")
200
+
201
+ dets = record.setdefault("detections", [])
202
+
203
+ # ── Split operations ─────────────────
 
 
 
 
 
204
  delete_ops = [op for op in req.operations if op.action == "delete"]
205
+ other_ops = [op for op in req.operations if op.action != "delete"]
206
+
207
+ # ── DELETE (reverse order) ───────────
208
+ for op in sorted(delete_ops, key=lambda x: x.detection_index, reverse=True):
209
+ idx = op.detection_index
210
+ if idx is None or idx >= len(dets):
211
  raise HTTPException(
212
  status_code=400,
213
+ detail=f"Invalid delete index {idx} β€” only {len(dets)} detection(s) exist"
214
  )
215
+ dets.pop(idx)
216
+
217
+ # ── ADD + UPDATE ────────────────────
218
  for op in other_ops:
219
+
220
  if op.action == "add":
221
  dets.append({
222
+ "label": op.label,
223
+ "confidence": 1.0,
224
+ "bbox": op.bbox,
225
  "manually_edited": True
226
  })
227
+
228
  elif op.action == "update":
229
+ idx = op.detection_index
230
+ if idx is None or idx >= len(dets):
231
  raise HTTPException(
232
  status_code=400,
233
+ detail=f"Invalid update index {idx} β€” only {len(dets)} detection(s) exist"
234
  )
235
+
236
  if op.label is not None:
237
+ dets[idx]["label"] = op.label
238
+
239
  if op.bbox is not None:
240
+ dets[idx]["bbox"] = op.bbox
241
+
242
+ dets[idx]["manually_edited"] = True
243
+
244
  save_json(json_key, data)
245
+
246
  logger.info(
247
  "Detections modified | user=%s camera=%s file=%s ops=%d final_count=%d",
248
  req.user_id,
 
251
  len(req.operations),
252
  len(dets)
253
  )
254
+
255
  return {
256
+ "success": True,
257
+ "message": "Detections modified successfully",
258
+ "filename": target_filename,
259
  "total_detections": len(dets),
260
+ "detections": dets
261
+ }