codewithRiz commited on
Commit
bfc0af1
Β·
1 Parent(s): dcba3cd

retagging images

Browse files
Files changed (1) hide show
  1. api/detection.py +222 -177
api/detection.py CHANGED
@@ -1,57 +1,6 @@
1
- # from fastapi import APIRouter, UploadFile, File, Form, HTTPException
2
- # from pathlib import Path
3
- # import cv2
4
- # import numpy as np
5
 
6
- # from .config import UPLOAD_DIR
7
- # from .utils import (
8
- # validate_form,
9
- # process_image,
10
- # save_image,
11
- # load_json,
12
- # save_json,
13
- # validate_user_and_camera,extract_metadata
14
- # )
15
-
16
- # router = APIRouter()
17
- # @router.post("/predict")
18
- # async def predict(
19
- # user_id: str = Form(...),
20
- # camera_name: str = Form(...),
21
- # images: list[UploadFile] = File(...)
22
- # ):
23
- # images = validate_form(user_id, camera_name, images)
24
- # validate_user_and_camera(user_id, camera_name)
25
- # base = Path(UPLOAD_DIR) / user_id / camera_name
26
- # base.mkdir(parents=True, exist_ok=True)
27
- # json_path = base / f"{camera_name}_detections.json"
28
- # data = load_json(json_path)
29
- # new_results = []
30
- # for file in images:
31
- # raw = await file.read()
32
- # metadata = extract_metadata(raw)
33
- # nparr = np.frombuffer(raw, np.uint8)
34
- # img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
35
- # if img is None:
36
- # raise HTTPException(400, f"Invalid image: {file.filename}")
37
- # detections = process_image(img)
38
- # url = save_image(user_id, camera_name, file.filename, raw)
39
- # record = {
40
- # "filename": file.filename,
41
- # "image_url": url,
42
- # "detections": detections,
43
- # "metadata": metadata
44
- # }
45
- # data.append(record)
46
- # new_results.append(record)
47
- # save_json(json_path, data)
48
- # return {
49
- # "message": "Images processed successfully",
50
- # "camera": camera_name,
51
- # "results": new_results
52
- # }
53
  from fastapi import APIRouter, UploadFile, File, Form, HTTPException
54
- from pydantic import BaseModel
55
  from typing import Optional, List, Literal
56
  import cv2
57
  import numpy as np
@@ -67,6 +16,8 @@ from .utils import (
67
  validate_user_and_camera,
68
  extract_metadata,
69
  _bucket_key,
 
 
70
  )
71
 
72
  router = APIRouter()
@@ -120,128 +71,222 @@ async def predict(
120
  # Request Models
121
  # ─────────────────
122
 
123
- # class DetectionOperation(BaseModel):
124
- # action: Literal["add", "update", "delete"]
125
- # detection_index: Optional[int] = None
126
- # label: Optional[str] = None
127
- # bbox: Optional[List[float]] = None # [x1, y1, x2, y2]
128
-
129
-
130
- # class MultiUpdateRequest(BaseModel):
131
- # user_id: str
132
- # camera_name: str
133
- # image_url: str
134
- # operations: List[DetectionOperation]
135
-
136
-
137
- # # ─────────────────
138
- # # Endpoint
139
- # # ─────────────────
140
-
141
- # @router.post("/modify_detections")
142
- # async def modify_detections(req: MultiUpdateRequest):
143
- # """
144
- # Add, update, and delete detections (tags) for a given image.
145
- # Supports multiple operations in a single request.
146
- # """
147
-
148
- # # Validate user
149
- # user_path = Path(UPLOAD_DIR) / req.user_id
150
- # if not user_path.exists() or not user_path.is_dir():
151
- # raise HTTPException(status_code=404, detail="user not found")
152
-
153
- # # Validate camera
154
- # camera_path = user_path / req.camera_name
155
- # if not camera_path.exists() or not camera_path.is_dir():
156
- # raise HTTPException(status_code=404, detail="camera not found")
157
-
158
- # # Validate JSON file
159
- # json_path = camera_path / f"{req.camera_name}_detections.json"
160
- # if not json_path.exists():
161
- # raise HTTPException(status_code=404, detail="detections file not found")
162
-
163
- # # Load data
164
- # data = load_json(json_path)
165
-
166
- # # Find image record
167
- # target_filename = req.image_url.split("/")[-1].split("?")[0]
168
-
169
- # record = None
170
- # for item in data:
171
- # stored = item.get("image_url", item.get("filename", ""))
172
- # stored_filename = stored.split("/")[-1].split("?")[0]
173
- # if stored_filename == target_filename:
174
- # record = item
175
- # break
176
-
177
- # if record is None:
178
- # raise HTTPException(status_code=404, detail="image not found")
179
-
180
- # # ── 6. Ensure detections list exists
181
- # if "detections" not in record or not isinstance(record["detections"], list):
182
- # record["detections"] = []
183
-
184
- # dets = record["detections"]
185
-
186
- # # ── 7. Apply operations safely ──────
187
- # # NOTE: Reverse delete operations to avoid index shifting issues
188
- # delete_ops = [op for op in req.operations if op.action == "delete"]
189
- # other_ops = [op for op in req.operations if op.action != "delete"]
190
-
191
- # # Handle DELETE (reverse order)
192
- # for op in sorted(delete_ops, key=lambda x: x.detection_index or -1, reverse=True):
193
- # if op.detection_index is None or op.detection_index >= len(dets):
194
- # raise HTTPException(
195
- # status_code=400,
196
- # detail=f"Invalid delete index {op.detection_index}"
197
- # )
198
- # dets.pop(op.detection_index)
199
-
200
- # # Handle ADD + UPDATE
201
- # for op in other_ops:
202
-
203
- # # ADD
204
- # if op.action == "add":
205
- # dets.append({
206
- # "label": op.label or "Unknown",
207
- # "confidence": 1.0,
208
- # "bbox": op.bbox or [],
209
- # "manually_edited": True
210
- # })
211
-
212
- # # UPDATE
213
- # elif op.action == "update":
214
- # if op.detection_index is None or op.detection_index >= len(dets):
215
- # raise HTTPException(
216
- # status_code=400,
217
- # detail=f"Invalid update index {op.detection_index}"
218
- # )
219
-
220
- # if op.label is not None:
221
- # dets[op.detection_index]["label"] = op.label
222
-
223
- # if op.bbox is not None:
224
- # dets[op.detection_index]["bbox"] = op.bbox
225
-
226
- # dets[op.detection_index]["manually_edited"] = True
227
-
228
- # # ── 8. Save back ────────────────────
229
- # save_json(json_path, data)
230
-
231
- # logger.info(
232
- # "Detections modified | user=%s camera=%s file=%s ops=%d final_count=%d",
233
- # req.user_id,
234
- # req.camera_name,
235
- # target_filename,
236
- # len(req.operations),
237
- # len(dets)
238
- # )
239
-
240
- # # ── 9. Response ─────────────────────
241
- # return {
242
- # "success": True,
243
- # "message": "Detections modified successfully",
244
- # "filename": target_filename,
245
- # "total_detections": len(dets),
246
- # "detections": dets
247
- # }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  from fastapi import APIRouter, UploadFile, File, Form, HTTPException
3
+ from pydantic import BaseModel,validator
4
  from typing import Optional, List, Literal
5
  import cv2
6
  import numpy as np
 
16
  validate_user_and_camera,
17
  extract_metadata,
18
  _bucket_key,
19
+ _key_exists,
20
+
21
  )
22
 
23
  router = APIRouter()
 
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 | Whitetail",
85
+ "Deer | Buck | Mule",
86
+ }
87
+
88
+ VALID_LABELS_DISPLAY = [ # clean version shown in error messages
89
+ "Deer | Doe",
90
+ "Deer | Buck | Whitetail",
91
+ "Deer | Buck | Mule",
92
+ ]
93
+
94
+
95
+ def _normalise_label(label: str) -> str:
96
+ """Strip and normalise label so 'Deer | Doe' == 'Deer | Doe '."""
97
+ return label.strip()
98
+
99
+
100
+ def _validate_label(label: str) -> str:
101
+ """Raise a clear error if the label is not from the detection pipeline."""
102
+ normalised = _normalise_label(label)
103
+ # Match against normalised versions of VALID_LABELS
104
+ if normalised not in {l.strip() for l in VALID_LABELS}:
105
+ raise HTTPException(
106
+ status_code=422,
107
+ detail=(
108
+ f"Invalid label '{label}'. "
109
+ f"Must be one of: {VALID_LABELS_DISPLAY}"
110
+ )
111
+ )
112
+ # Return the canonical pipeline form (with trailing space for Doe)
113
+ for valid in VALID_LABELS:
114
+ if valid.strip() == normalised:
115
+ return valid
116
+ return label
117
+
118
+
119
+ # ─────────────────
120
+ # Request Models
121
+ # ─────────────────
122
+
123
+ class DetectionOperation(BaseModel):
124
+ action: Literal["add", "update", "delete"]
125
+ detection_index: Optional[int] = None
126
+ label: Optional[str] = None
127
+ bbox: Optional[List[float]] = None # [x1, y1, x2, y2]
128
+
129
+ @validator("label")
130
+ def label_must_be_valid(cls, v):
131
+ if v is None:
132
+ return v
133
+ normalised = v.strip()
134
+ valid_normalised = {l.strip() for l in VALID_LABELS}
135
+ if normalised not in valid_normalised:
136
+ raise ValueError(
137
+ f"Invalid label '{v}'. Must be one of: {VALID_LABELS_DISPLAY}"
138
+ )
139
+ # Return canonical form
140
+ for valid in VALID_LABELS:
141
+ if valid.strip() == normalised:
142
+ return valid
143
+ return v
144
+
145
+ @validator("bbox")
146
+ def bbox_must_be_four_values(cls, v):
147
+ if v is None:
148
+ return v
149
+ if len(v) != 4:
150
+ raise ValueError("bbox must have exactly 4 values: [x1, y1, x2, y2]")
151
+ x1, y1, x2, y2 = v
152
+ if x2 <= x1 or y2 <= y1:
153
+ raise ValueError("bbox must satisfy x2 > x1 and y2 > y1")
154
+ return v
155
+
156
+ @validator("detection_index")
157
+ def index_must_be_non_negative(cls, v):
158
+ if v is not None and v < 0:
159
+ raise ValueError("detection_index must be >= 0")
160
+ return v
161
+
162
+
163
+ class MultiUpdateRequest(BaseModel):
164
+ user_id: str
165
+ camera_name: str
166
+ image_url: str
167
+ operations: List[DetectionOperation]
168
+
169
+ @validator("operations")
170
+ def operations_must_not_be_empty(cls, v):
171
+ if not v:
172
+ raise ValueError("operations list cannot be empty")
173
+ return v
174
+
175
+ @validator("operations", each_item=True)
176
+ def validate_operation_fields(cls, op):
177
+ if op.action == "add":
178
+ if op.label is None:
179
+ raise ValueError("'add' operation requires a label")
180
+ if op.bbox is None:
181
+ raise ValueError("'add' operation requires a bbox")
182
+ elif op.action == "update":
183
+ if op.detection_index is None:
184
+ raise ValueError("'update' operation requires detection_index")
185
+ if op.label is None and op.bbox is None:
186
+ raise ValueError("'update' operation requires at least label or bbox")
187
+ elif op.action == "delete":
188
+ if op.detection_index is None:
189
+ raise ValueError("'delete' operation requires detection_index")
190
+ return op
191
+
192
+
193
+ # ─────────────────
194
+ # Endpoint
195
+ # ─────────────────
196
+
197
+ @router.post("/modify_detections")
198
+ async def modify_detections(req: MultiUpdateRequest):
199
+ """
200
+ Add, update, and delete detections (tags) for a given image.
201
+ Supports multiple operations in a single request.
202
+ Labels must match the detection pipeline format exactly.
203
+ """
204
+
205
+ # ── Validate user & camera ────────────────────────────────────
206
+ validate_user_and_camera(req.user_id, req.camera_name)
207
+
208
+ # ── Validate detections JSON exists in bucket ─────────────────
209
+ json_key = _bucket_key(req.user_id, req.camera_name, f"{req.camera_name}_detections.json")
210
+ if not _key_exists(json_key):
211
+ raise HTTPException(status_code=404, detail="Detections file not found")
212
+
213
+ # ── Load data from bucket ─────────────────────────────────────
214
+ data = load_json(json_key)
215
+
216
+ # ── Find image record by filename ─────────────────────────────
217
+ target_filename = req.image_url.split("/")[-1].split("?")[0]
218
+
219
+ record = None
220
+ for item in data:
221
+ stored = item.get("image_url", item.get("filename", ""))
222
+ stored_filename = stored.split("/")[-1].split("?")[0]
223
+ if stored_filename == target_filename:
224
+ record = item
225
+ break
226
+
227
+ if record is None:
228
+ raise HTTPException(status_code=404, detail="Image not found")
229
+
230
+ # ── Ensure detections list exists ─────────────────────────────
231
+ if "detections" not in record or not isinstance(record["detections"], list):
232
+ record["detections"] = []
233
+
234
+ dets = record["detections"]
235
+
236
+ # ── Apply operations ──────────────────────────────────────────
237
+ # Deletes run in reverse index order to avoid index shifting
238
+ delete_ops = [op for op in req.operations if op.action == "delete"]
239
+ other_ops = [op for op in req.operations if op.action != "delete"]
240
+
241
+ # DELETE (reverse order to avoid index shifting)
242
+ for op in sorted(delete_ops, key=lambda x: x.detection_index or -1, reverse=True):
243
+ if op.detection_index >= len(dets):
244
+ raise HTTPException(
245
+ status_code=400,
246
+ detail=f"Invalid delete index {op.detection_index} β€” only {len(dets)} detection(s) exist"
247
+ )
248
+ dets.pop(op.detection_index)
249
+
250
+ # ADD + UPDATE
251
+ for op in other_ops:
252
+
253
+ if op.action == "add":
254
+ dets.append({
255
+ "label": op.label, # already validated & canonicalised by validator
256
+ "confidence": 1.0,
257
+ "bbox": op.bbox,
258
+ "manually_edited": True
259
+ })
260
+
261
+ elif op.action == "update":
262
+ if op.detection_index >= len(dets):
263
+ raise HTTPException(
264
+ status_code=400,
265
+ detail=f"Invalid update index {op.detection_index} β€” only {len(dets)} detection(s) exist"
266
+ )
267
+ if op.label is not None:
268
+ dets[op.detection_index]["label"] = op.label
269
+ if op.bbox is not None:
270
+ dets[op.detection_index]["bbox"] = op.bbox
271
+ dets[op.detection_index]["manually_edited"] = True
272
+
273
+ # ── Save back to bucket ───────────────────────────────────────
274
+ save_json(json_key, data)
275
+
276
+ logger.info(
277
+ "Detections modified | user=%s camera=%s file=%s ops=%d final_count=%d",
278
+ req.user_id,
279
+ req.camera_name,
280
+ target_filename,
281
+ len(req.operations),
282
+ len(dets)
283
+ )
284
+
285
+ return {
286
+ "success": True,
287
+ "message": "Detections modified successfully",
288
+ "filename": target_filename,
289
+ "total_detections": len(dets),
290
+ "detections": dets
291
+ }
292
+