LogicGoInfotechSpaces commited on
Commit
54dc5e2
·
verified ·
1 Parent(s): 770313f

Update api/media_clicks_changes.py

Browse files
Files changed (1) hide show
  1. api/media_clicks_changes.py +1185 -0
api/media_clicks_changes.py CHANGED
@@ -0,0 +1,1185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import uuid
3
+ import shutil
4
+ import re
5
+ from datetime import datetime, timedelta, date
6
+ from io import BytesIO
7
+ from typing import Dict, List, Optional,Any
8
+ import numpy as np
9
+ from fastapi import (
10
+ FastAPI,
11
+ UploadFile,
12
+ File,
13
+ HTTPException,
14
+ Depends,
15
+ Header,
16
+ Request,
17
+ Form,
18
+ )
19
+ from fastapi.responses import FileResponse, JSONResponse
20
+ from pydantic import BaseModel
21
+ from PIL import Image, UnidentifiedImageError
22
+ import cv2
23
+ import logging
24
+ from gridfs import GridFS
25
+ from gridfs.errors import NoFile
26
+
27
+ from bson import ObjectId
28
+ from pymongo import MongoClient
29
+ import time
30
+
31
+ # Load environment variables from .env if present
32
+ try:
33
+ from dotenv import load_dotenv
34
+
35
+ load_dotenv()
36
+ except Exception:
37
+ pass
38
+
39
+ logging.basicConfig(level=logging.INFO)
40
+ log = logging.getLogger("api")
41
+
42
+ from src.core import process_inpaint
43
+
44
+ # Directories (use writable space on HF Spaces)
45
+ BASE_DIR = os.environ.get("DATA_DIR", "/data")
46
+ if not os.path.isdir(BASE_DIR):
47
+ # Fallback to /tmp if /data not available
48
+ BASE_DIR = "/tmp"
49
+
50
+ UPLOAD_DIR = os.path.join(BASE_DIR, "uploads")
51
+ OUTPUT_DIR = os.path.join(BASE_DIR, "outputs")
52
+
53
+ os.makedirs(UPLOAD_DIR, exist_ok=True)
54
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
55
+
56
+ # Optional Bearer token: set env API_TOKEN to require auth; if not set, endpoints are open
57
+ ENV_TOKEN = os.environ.get("API_TOKEN")
58
+
59
+ app = FastAPI(title="Photo Object Removal API", version="1.0.0")
60
+
61
+ # In-memory stores
62
+ file_store: Dict[str, Dict[str, str]] = {}
63
+ logs: List[Dict[str, str]] = []
64
+
65
+ MONGO_URI = os.environ.get("MONGO_URI") or os.environ.get("MONGODB_URI")
66
+ mongo_client = None
67
+ mongo_db = None
68
+ mongo_logs = None
69
+ grid_fs = None
70
+
71
+ if MONGO_URI:
72
+ try:
73
+ mongo_client = MongoClient(MONGO_URI)
74
+ # Try to get database from connection string first
75
+ try:
76
+ mongo_db = mongo_client.get_default_database()
77
+ log.info("Using database from connection string: %s", mongo_db.name)
78
+ except Exception as db_err:
79
+ mongo_db = None
80
+ log.warning("Could not extract database from connection string: %s", db_err)
81
+
82
+ # Fallback to 'object_remover' if no database in connection string
83
+ if mongo_db is None:
84
+ mongo_db = mongo_client["object_remover"]
85
+ log.info("Using default database: object_remover")
86
+
87
+ mongo_logs = mongo_db["api_logs"]
88
+ grid_fs = GridFS(mongo_db)
89
+ log.info("MongoDB connection initialized successfully - Database: %s, Collection: %s", mongo_db.name, mongo_logs.name)
90
+ except Exception as err:
91
+ log.error("Failed to initialize MongoDB connection: %s", err, exc_info=True)
92
+ log.warning("GridFS operations will be disabled. Set MONGO_URI or MONGODB_URI environment variable.")
93
+ else:
94
+ log.warning("MONGO_URI not set. GridFS operations will be disabled. Upload endpoints will not work.")
95
+
96
+ API_LOGS_MONGO_URI = os.environ.get("API_LOGS_MONGODB_URL")
97
+
98
+ api_logs_client = None
99
+ api_logs_db = None
100
+ api_logs_collection = None
101
+
102
+ if API_LOGS_MONGO_URI:
103
+ try:
104
+ api_logs_client = MongoClient(API_LOGS_MONGO_URI)
105
+ api_logs_db = api_logs_client["logs"] # 🔥 logs database
106
+ api_logs_collection = api_logs_db["objectRemover"] # 🔥 objectRemover collection
107
+ log.info("API Logs Mongo initialized → logs/objectRemover")
108
+ except Exception as e:
109
+ log.error("Failed to initialize API Logs MongoDB: %s", e)
110
+ api_logs_collection = None
111
+ else:
112
+ log.warning("API_LOGS_MONGODB_URL not set. API logging disabled.")
113
+
114
+ ADMIN_MONGO_URI = os.environ.get("MONGODB_ADMIN")
115
+ DEFAULT_CATEGORY_ID = "69368f722e46bd68ae188984"
116
+
117
+ # Collage-maker MongoDB configuration
118
+ COLLAGE_MAKER_MONGO_URI = os.environ.get("MONGODB_COLLAGE_MAKER")
119
+ COLLAGE_MAKER_DB_NAME = os.environ.get("MONGODB_COLLAGE_MAKER_DB_NAME", "collage-maker")
120
+ COLLAGE_MAKER_ADMIN_DB_NAME = os.environ.get("MONGODB_COLLAGE_MAKER_ADMIN_DB_NAME", "adminPanel")
121
+ collage_maker_client = None
122
+ collage_maker_db = None
123
+ collage_maker_admin_db = None
124
+ collage_maker_categories = None
125
+
126
+ # AI-Enhancer MongoDB configuration
127
+ AI_ENHANCER_MONGO_URI = os.environ.get("MONGODB_AI_ENHANCER")
128
+ AI_ENHANCER_DB_NAME = os.environ.get("MONGODB_AI_ENHANCER_DB_NAME", "ai-enhancer")
129
+ AI_ENHANCER_ADMIN_DB_NAME = os.environ.get("MONGODB_AI_ENHANCER_ADMIN_DB_NAME", "test")
130
+ ai_enhancer_client = None
131
+ ai_enhancer_db = None
132
+ ai_enhancer_admin_db = None
133
+
134
+
135
+ def get_collage_maker_client() -> Optional[MongoClient]:
136
+ """Get collage-maker MongoDB client."""
137
+ global collage_maker_client
138
+ if collage_maker_client is None and COLLAGE_MAKER_MONGO_URI:
139
+ try:
140
+ collage_maker_client = MongoClient(COLLAGE_MAKER_MONGO_URI)
141
+ log.info("Collage-maker MongoDB client initialized")
142
+ except Exception as err:
143
+ log.error("Failed to initialize collage-maker MongoDB client: %s", err)
144
+ collage_maker_client = None
145
+ return collage_maker_client
146
+
147
+
148
+ def get_collage_maker_database() -> Optional[Any]:
149
+ """Get collage-maker database instance."""
150
+ global collage_maker_db
151
+ client = get_collage_maker_client()
152
+ if client is None:
153
+ return None
154
+ if collage_maker_db is None:
155
+ try:
156
+ collage_maker_db = client[COLLAGE_MAKER_DB_NAME]
157
+ log.info("Collage-maker database initialized: %s", COLLAGE_MAKER_DB_NAME)
158
+ except Exception as err:
159
+ log.error("Failed to get collage-maker database: %s", err)
160
+ collage_maker_db = None
161
+ return collage_maker_db
162
+
163
+
164
+ def _init_collage_maker_mongo() -> None:
165
+ """Initialize collage-maker MongoDB connections."""
166
+ global collage_maker_admin_db, collage_maker_categories
167
+ client = get_collage_maker_client()
168
+ if client is None:
169
+ log.info("Collage-maker Mongo URI not provided; collage-maker features disabled")
170
+ return
171
+ try:
172
+ collage_maker_admin_db = client[COLLAGE_MAKER_ADMIN_DB_NAME]
173
+ collage_maker_categories = collage_maker_admin_db["categories"]
174
+ log.info(
175
+ "Collage-maker admin initialized: db=%s, categories=%s",
176
+ COLLAGE_MAKER_ADMIN_DB_NAME,
177
+ collage_maker_categories.name,
178
+ )
179
+ except Exception as err:
180
+ log.error("Failed to init collage-maker admin Mongo: %s", err)
181
+ collage_maker_admin_db = None
182
+ collage_maker_categories = None
183
+
184
+
185
+ _init_collage_maker_mongo()
186
+
187
+
188
+ def get_ai_enhancer_client() -> Optional[MongoClient]:
189
+ """Get AI-Enhancer MongoDB client."""
190
+ global ai_enhancer_client
191
+ if ai_enhancer_client is None and AI_ENHANCER_MONGO_URI:
192
+ try:
193
+ ai_enhancer_client = MongoClient(AI_ENHANCER_MONGO_URI)
194
+ log.info("AI-Enhancer MongoDB client initialized")
195
+ except Exception as err:
196
+ log.error("Failed to initialize AI-Enhancer MongoDB client: %s", err)
197
+ ai_enhancer_client = None
198
+ return ai_enhancer_client
199
+
200
+
201
+ def get_ai_enhancer_database() -> Optional[Any]:
202
+ """Get AI-Enhancer database instance."""
203
+ global ai_enhancer_db
204
+ client = get_ai_enhancer_client()
205
+ if client is None:
206
+ return None
207
+ if ai_enhancer_db is None:
208
+ try:
209
+ ai_enhancer_db = client[AI_ENHANCER_DB_NAME]
210
+ log.info("AI-Enhancer database initialized: %s", AI_ENHANCER_DB_NAME)
211
+ except Exception as err:
212
+ log.error("Failed to get AI-Enhancer database: %s", err)
213
+ ai_enhancer_db = None
214
+ return ai_enhancer_db
215
+
216
+
217
+ def _init_ai_enhancer_mongo() -> None:
218
+ """Initialize AI-Enhancer MongoDB connections."""
219
+ global ai_enhancer_admin_db
220
+ client = get_ai_enhancer_client()
221
+ if client is None:
222
+ log.info("AI-Enhancer Mongo URI not provided; AI-Enhancer features disabled")
223
+ return
224
+ try:
225
+ ai_enhancer_admin_db = client[AI_ENHANCER_ADMIN_DB_NAME]
226
+ log.info(
227
+ "AI-Enhancer admin initialized: db=%s",
228
+ AI_ENHANCER_ADMIN_DB_NAME,
229
+ )
230
+ except Exception as err:
231
+ log.error("Failed to init AI-Enhancer admin Mongo: %s", err)
232
+ ai_enhancer_admin_db = None
233
+
234
+
235
+ _init_ai_enhancer_mongo()
236
+
237
+
238
+ def get_category_id_from_collage_maker() -> Optional[str]:
239
+ """Query category ID from collage-maker categories collection."""
240
+ if collage_maker_categories is None:
241
+ log.warning("Collage-maker categories collection not initialized")
242
+ return None
243
+ try:
244
+ # Query the categories collection - you may need to adjust the query based on your schema
245
+ # This assumes there's a default category or we get the first one
246
+ category_doc = collage_maker_categories.find_one()
247
+ if category_doc:
248
+ category_id = str(category_doc.get("_id", ""))
249
+ log.info("Found category ID from collage-maker: %s", category_id)
250
+ return category_id
251
+ else:
252
+ log.warning("No categories found in collage-maker collection")
253
+ return None
254
+ except Exception as err:
255
+ log.error("Failed to query collage-maker categories: %s", err)
256
+ return None
257
+
258
+
259
+ def _init_admin_mongo() -> None:
260
+ # Admin MongoDB initialization removed - media_clicks logging disabled
261
+ pass
262
+
263
+
264
+ _init_admin_mongo()
265
+
266
+
267
+ def _admin_logging_status() -> Dict[str, object]:
268
+ return {
269
+ "enabled": False,
270
+ "db": None,
271
+ "collection": None,
272
+ }
273
+
274
+
275
+ def _save_upload_to_gridfs(upload: UploadFile, file_type: str) -> str:
276
+ """Store an uploaded file into GridFS and return its ObjectId string."""
277
+ if grid_fs is None:
278
+ raise HTTPException(
279
+ status_code=503,
280
+ detail="MongoDB/GridFS not configured. Set MONGO_URI or MONGODB_URI environment variable."
281
+ )
282
+ data = upload.file.read()
283
+ if not data:
284
+ raise HTTPException(status_code=400, detail=f"{file_type} file is empty")
285
+ oid = grid_fs.put(
286
+ data,
287
+ filename=upload.filename or f"{file_type}.bin",
288
+ contentType=upload.content_type,
289
+ metadata={"type": file_type},
290
+ )
291
+ return str(oid)
292
+
293
+
294
+ def _read_gridfs_bytes(file_id: str, expected_type: str) -> bytes:
295
+ """Fetch raw bytes from GridFS and validate the stored type metadata."""
296
+ if grid_fs is None:
297
+ raise HTTPException(
298
+ status_code=503,
299
+ detail="MongoDB/GridFS not configured. Set MONGO_URI or MONGODB_URI environment variable."
300
+ )
301
+ try:
302
+ oid = ObjectId(file_id)
303
+ except Exception:
304
+ raise HTTPException(status_code=404, detail=f"{expected_type}_id invalid")
305
+
306
+ try:
307
+ grid_out = grid_fs.get(oid)
308
+ except NoFile:
309
+ raise HTTPException(status_code=404, detail=f"{expected_type}_id not found")
310
+
311
+ meta = grid_out.metadata or {}
312
+ stored_type = meta.get("type")
313
+ if stored_type and stored_type != expected_type:
314
+ raise HTTPException(status_code=404, detail=f"{expected_type}_id not found")
315
+
316
+ return grid_out.read()
317
+
318
+
319
+ def _load_rgba_image_from_gridfs(file_id: str, expected_type: str) -> Image.Image:
320
+ """Load an image from GridFS and convert to RGBA."""
321
+ data = _read_gridfs_bytes(file_id, expected_type)
322
+ try:
323
+ img = Image.open(BytesIO(data))
324
+ except UnidentifiedImageError:
325
+ raise HTTPException(status_code=422, detail=f"{expected_type} is not a valid image")
326
+ return img.convert("RGBA")
327
+
328
+
329
+ def _build_ai_edit_daily_count(
330
+ existing: Optional[List[Dict[str, object]]],
331
+ today: date,
332
+ ) -> List[Dict[str, object]]:
333
+ """
334
+ Build / extend the ai_edit_daily_count array with the following rules:
335
+
336
+ - Case A (no existing data): return [{date: today, count: 1}]
337
+ - Case B (today already recorded): return list unchanged
338
+ - Case C (gap in days): fill missing days with count=0 and append today with count=1
339
+
340
+ Additionally, the returned list is capped to the most recent 32 entries.
341
+
342
+ The stored "date" value is a midnight UTC (naive UTC) datetime for the given day.
343
+ """
344
+
345
+ def _to_date_only(value: object) -> date:
346
+ if isinstance(value, datetime):
347
+ return value.date()
348
+ if isinstance(value, date):
349
+ return value
350
+ # Fallback: try parsing ISO string "YYYY-MM-DD" or full datetime
351
+ try:
352
+ text = str(value)
353
+ if len(text) == 10:
354
+ return datetime.strptime(text, "%Y-%m-%d").date()
355
+ return datetime.fromisoformat(text).date()
356
+ except Exception:
357
+ # If parsing fails, just treat as today to avoid crashing
358
+ return today
359
+
360
+ # Case A: first ever use (no array yet)
361
+ if not existing:
362
+ return [
363
+ {
364
+ "date": datetime(today.year, today.month, today.day),
365
+ "count": 1,
366
+ }
367
+ ]
368
+
369
+ # Work on a shallow copy so we don't mutate original in-place
370
+ result: List[Dict[str, object]] = list(existing)
371
+
372
+ last_entry = result[-1] if result else None
373
+ if not last_entry or "date" not in last_entry:
374
+ # If structure is unexpected, re-initialize safely
375
+ return [
376
+ {
377
+ "date": datetime(today.year, today.month, today.day),
378
+ "count": 1,
379
+ }
380
+ ]
381
+
382
+ last_date = _to_date_only(last_entry["date"])
383
+
384
+ # If somehow the last stored date is in the future, do nothing to avoid corrupting history
385
+ if last_date > today:
386
+ return result
387
+
388
+ # Case B: today's date already present as the last entry → unchanged
389
+ if last_date == today:
390
+ return result
391
+
392
+ # Case C: there is a gap, fill missing days with count=0 and append today with count=1
393
+ cursor = last_date + timedelta(days=1)
394
+ while cursor < today:
395
+ result.append(
396
+ {
397
+ "date": datetime(cursor.year, cursor.month, cursor.day),
398
+ "count": 0,
399
+ }
400
+ )
401
+ cursor += timedelta(days=1)
402
+
403
+ # Finally add today's presence indicator
404
+ result.append(
405
+ {
406
+ "date": datetime(today.year, today.month, today.day),
407
+ "count": 1,
408
+ }
409
+ )
410
+
411
+
412
+ # [oldest, ..., newest]
413
+ try:
414
+ result.sort(key=lambda entry: _to_date_only(entry.get("date")))
415
+ except Exception:
416
+ # If anything goes wrong during sort, fall back to current ordering
417
+ pass
418
+
419
+ # Enforce 32-entry limit (keep the most recent 32 days)
420
+ if len(result) > 32:
421
+ result = result[-32:]
422
+
423
+ return result
424
+
425
+ def bearer_auth(authorization: Optional[str] = Header(default=None)) -> None:
426
+ if not ENV_TOKEN:
427
+ return
428
+ if authorization is None or not authorization.lower().startswith("bearer "):
429
+ raise HTTPException(status_code=401, detail="Unauthorized")
430
+ token = authorization.split(" ", 1)[1]
431
+ if token != ENV_TOKEN:
432
+ raise HTTPException(status_code=403, detail="Forbidden")
433
+
434
+
435
+ class InpaintRequest(BaseModel):
436
+ image_id: str
437
+ mask_id: str
438
+ invert_mask: bool = True # True => selected/painted area is removed
439
+ passthrough: bool = False # If True, return the original image unchanged
440
+ prompt: Optional[str] = None # Optional: describe what to remove
441
+ user_id: Optional[str] = None
442
+ category_id: Optional[str] = None
443
+ appname: Optional[str] = None # Optional: app name (e.g., "collage-maker")
444
+
445
+
446
+ class SimpleRemoveRequest(BaseModel):
447
+ image_id: str # Image with pink/magenta segments to remove
448
+
449
+
450
+ def _coerce_object_id(value: Optional[str]) -> ObjectId:
451
+ if value is None:
452
+ return ObjectId()
453
+ value_str = str(value).strip()
454
+ if re.fullmatch(r"[0-9a-fA-F]{24}", value_str):
455
+ return ObjectId(value_str)
456
+ if value_str.isdigit():
457
+ hex_str = format(int(value_str), "x")
458
+ if len(hex_str) > 24:
459
+ hex_str = hex_str[-24:]
460
+ hex_str = hex_str.rjust(24, "0")
461
+ return ObjectId(hex_str)
462
+ return ObjectId()
463
+
464
+
465
+ def _coerce_category_id(category_id: Optional[str]) -> ObjectId:
466
+ raw = category_id or DEFAULT_CATEGORY_ID
467
+ raw_str = str(raw).strip()
468
+ if re.fullmatch(r"[0-9a-fA-F]{24}", raw_str):
469
+ return ObjectId(raw_str)
470
+ return _coerce_object_id(raw_str)
471
+
472
+
473
+ def log_media_click(user_id: Optional[str], category_id: Optional[str], appname: Optional[str] = None) -> None:
474
+ """Media clicks logging disabled - no-op function."""
475
+ pass
476
+
477
+
478
+ @app.get("/")
479
+ def root() -> Dict[str, Any]:
480
+ return {
481
+ "success": True,
482
+ "message": "Object Remover API",
483
+ "data": {
484
+ "version": "1.0.0",
485
+ "product_name": "Beauty Camera - GlowCam AI Studio",
486
+ "released_by": "LogicGo Infotech"
487
+ }
488
+ }
489
+
490
+
491
+
492
+ @app.get("/health")
493
+ def health() -> Dict[str, str]:
494
+ return {"status": "healthy"}
495
+
496
+
497
+ @app.get("/logging-status")
498
+ def logging_status(_: None = Depends(bearer_auth)) -> Dict[str, object]:
499
+ """Helper endpoint to verify admin media logging wiring (no secrets exposed)."""
500
+ return _admin_logging_status()
501
+
502
+
503
+ @app.get("/mongo-status")
504
+ def mongo_status(_: None = Depends(bearer_auth)) -> Dict[str, object]:
505
+ """Check MongoDB connection status and verify data storage."""
506
+ status = {
507
+ "mongo_configured": MONGO_URI is not None,
508
+ "mongo_connected": mongo_client is not None,
509
+ "database": mongo_db.name if mongo_db else None,
510
+ "collection": mongo_logs.name if mongo_logs else None,
511
+ "admin_logging": _admin_logging_status(),
512
+ }
513
+
514
+ # Try to count documents in api_logs collection
515
+ if mongo_logs is not None:
516
+ try:
517
+ count = mongo_logs.count_documents({})
518
+ status["api_logs_count"] = count
519
+ # Get latest 5 documents
520
+ latest_docs = list(mongo_logs.find().sort("timestamp", -1).limit(5))
521
+ status["recent_logs"] = []
522
+ for doc in latest_docs:
523
+ doc_dict = {
524
+ "_id": str(doc.get("_id")),
525
+ "output_id": doc.get("output_id"),
526
+ "status": doc.get("status"),
527
+ "timestamp": doc.get("timestamp").isoformat() if isinstance(doc.get("timestamp"), datetime) else str(doc.get("timestamp")),
528
+ }
529
+ if "input_image_id" in doc:
530
+ doc_dict["input_image_id"] = doc.get("input_image_id")
531
+ if "input_mask_id" in doc:
532
+ doc_dict["input_mask_id"] = doc.get("input_mask_id")
533
+ if "error" in doc:
534
+ doc_dict["error"] = doc.get("error")
535
+ status["recent_logs"].append(doc_dict)
536
+
537
+ # Get latest document for backward compatibility
538
+ if latest_docs:
539
+ latest = latest_docs[0]
540
+ status["latest_log"] = {
541
+ "_id": str(latest.get("_id")),
542
+ "output_id": latest.get("output_id"),
543
+ "status": latest.get("status"),
544
+ "timestamp": latest.get("timestamp").isoformat() if isinstance(latest.get("timestamp"), datetime) else str(latest.get("timestamp")),
545
+ }
546
+ except Exception as err:
547
+ status["api_logs_error"] = str(err)
548
+ log.error("Error querying MongoDB: %s", err, exc_info=True)
549
+
550
+ return status
551
+
552
+
553
+ @app.post("/upload-image")
554
+ def upload_image(image: UploadFile = File(...), _: None = Depends(bearer_auth)) -> Dict[str, str]:
555
+ file_id = _save_upload_to_gridfs(image, "image")
556
+ logs.append({"id": file_id, "filename": image.filename, "type": "image", "timestamp": datetime.utcnow().isoformat()})
557
+ return {"id": file_id, "filename": image.filename}
558
+
559
+
560
+ @app.post("/upload-mask")
561
+ def upload_mask(mask: UploadFile = File(...), _: None = Depends(bearer_auth)) -> Dict[str, str]:
562
+ file_id = _save_upload_to_gridfs(mask, "mask")
563
+ logs.append({"id": file_id, "filename": mask.filename, "type": "mask", "timestamp": datetime.utcnow().isoformat()})
564
+ return {"id": file_id, "filename": mask.filename}
565
+
566
+
567
+ def _compress_image(image_path: str, output_path: str, quality: int = 85) -> None:
568
+ """
569
+ Compress an image to reduce file size.
570
+ Converts to JPEG format with specified quality to achieve smaller file size.
571
+ """
572
+ img = Image.open(image_path)
573
+ # Convert RGBA to RGB if needed (JPEG doesn't support alpha)
574
+ if img.mode == "RGBA":
575
+ rgb_img = Image.new("RGB", img.size, (255, 255, 255))
576
+ rgb_img.paste(img, mask=img.split()[3]) # Use alpha channel as mask
577
+ img = rgb_img
578
+ elif img.mode != "RGB":
579
+ img = img.convert("RGB")
580
+
581
+ # Save as JPEG with quality setting for compression
582
+ img.save(output_path, "JPEG", quality=quality, optimize=True)
583
+
584
+
585
+ def _load_rgba_mask_from_image(img: Image.Image) -> np.ndarray:
586
+ """
587
+ Convert mask image to RGBA format (black/white mask).
588
+ Standard convention: white (255) = area to remove, black (0) = area to keep
589
+ Returns RGBA with white in RGB channels where removal is needed, alpha=255
590
+ """
591
+ if img.mode != "RGBA":
592
+ # For RGB/Grayscale masks: white (value>128) = remove, black (value<=128) = keep
593
+ gray = img.convert("L")
594
+ arr = np.array(gray)
595
+ # Create proper black/white mask: white pixels (>128) = remove, black (<=128) = keep
596
+ mask_bw = np.where(arr > 128, 255, 0).astype(np.uint8)
597
+
598
+ rgba = np.zeros((img.height, img.width, 4), dtype=np.uint8)
599
+ rgba[:, :, 0] = mask_bw # R
600
+ rgba[:, :, 1] = mask_bw # G
601
+ rgba[:, :, 2] = mask_bw # B
602
+ rgba[:, :, 3] = 255 # Fully opaque
603
+ log.info(f"Loaded {img.mode} mask: {int((mask_bw > 0).sum())} white pixels (to remove)")
604
+ return rgba
605
+
606
+ # For RGBA: check if alpha channel is meaningful
607
+ arr = np.array(img)
608
+ alpha = arr[:, :, 3]
609
+ rgb = arr[:, :, :3]
610
+
611
+ # If alpha is mostly opaque everywhere (mean > 200), treat RGB channels as mask values
612
+ if alpha.mean() > 200:
613
+ # Use RGB to determine mask: white/bright in RGB = remove
614
+ gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
615
+ # Also detect magenta specifically
616
+ magenta = np.all(rgb == [255, 0, 255], axis=2).astype(np.uint8) * 255
617
+ mask_bw = np.maximum(np.where(gray > 128, 255, 0).astype(np.uint8), magenta)
618
+
619
+ rgba = arr.copy()
620
+ rgba[:, :, 0] = mask_bw # R
621
+ rgba[:, :, 1] = mask_bw # G
622
+ rgba[:, :, 2] = mask_bw # B
623
+ rgba[:, :, 3] = 255 # Fully opaque
624
+ log.info(f"Loaded RGBA mask (RGB-based): {int((mask_bw > 0).sum())} white pixels (to remove)")
625
+ return rgba
626
+
627
+ # Alpha channel encodes the mask - convert to RGB-based
628
+ # Transparent areas (alpha < 128) = remove, Opaque areas = keep
629
+ mask_bw = np.where(alpha < 128, 255, 0).astype(np.uint8)
630
+ rgba = arr.copy()
631
+ rgba[:, :, 0] = mask_bw
632
+ rgba[:, :, 1] = mask_bw
633
+ rgba[:, :, 2] = mask_bw
634
+ rgba[:, :, 3] = 255
635
+ log.info(f"Loaded RGBA mask (alpha-based): {int((mask_bw > 0).sum())} white pixels (to remove)")
636
+ return rgba
637
+
638
+ @app.post("/inpaint")
639
+ def inpaint(req: InpaintRequest, request: Request, _: None = Depends(bearer_auth)) -> Dict[str, str]:
640
+ start_time = time.time()
641
+ status = "success"
642
+ error_msg = None
643
+ output_name = None
644
+ compressed_url = None
645
+
646
+ try:
647
+ # Handle appname="collage-maker": get category_id from collage-maker if not provided
648
+ category_id = req.category_id
649
+ if req.appname == "collage-maker" and not category_id:
650
+ category_id = get_category_id_from_collage_maker()
651
+ if category_id:
652
+ log.info("Using category_id from collage-maker: %s", category_id)
653
+
654
+ img_rgba = _load_rgba_image_from_gridfs(req.image_id, "image")
655
+ mask_img = _load_rgba_image_from_gridfs(req.mask_id, "mask")
656
+ mask_rgba = _load_rgba_mask_from_image(mask_img)
657
+
658
+ if req.passthrough:
659
+ result = np.array(img_rgba.convert("RGB"))
660
+ else:
661
+ result = process_inpaint(
662
+ np.array(img_rgba),
663
+ mask_rgba,
664
+ invert_mask=req.invert_mask,
665
+ prompt=req.prompt,
666
+ )
667
+
668
+ output_name = f"output_{uuid.uuid4().hex}.png"
669
+ output_path = os.path.join(OUTPUT_DIR, output_name)
670
+
671
+ Image.fromarray(result).save(
672
+ output_path, "PNG", optimize=False, compress_level=1
673
+ )
674
+
675
+ # Create compressed version
676
+ compressed_name = f"compressed_{output_name.replace('.png', '.jpg')}"
677
+ compressed_path = os.path.join(OUTPUT_DIR, compressed_name)
678
+ try:
679
+ _compress_image(output_path, compressed_path, quality=85)
680
+ compressed_url = str(request.url_for("download_file", filename=compressed_name))
681
+ except Exception as compress_err:
682
+ log.warning("Failed to create compressed image: %s", compress_err)
683
+ compressed_url = None
684
+
685
+ response = {"result": output_name}
686
+ if compressed_url:
687
+ response["Compressed_Image_URL"] = compressed_url
688
+ return response
689
+
690
+ except Exception as e:
691
+ status = "fail"
692
+ error_msg = str(e)
693
+ raise
694
+
695
+ finally:
696
+ end_time = time.time()
697
+ response_time_ms = (end_time - start_time) * 1000
698
+
699
+ log_doc = {
700
+ "endpoint": "/inpaint",
701
+ "status": status,
702
+ "response_time_ms": float(response_time_ms),
703
+ "timestamp": datetime.utcnow(),
704
+ "appname": req.appname if req.appname else "None",
705
+ "error": error_msg
706
+ }
707
+
708
+ # Store appname in api_logs if provided
709
+ if req.appname:
710
+ log_doc["appname"] = req.appname
711
+
712
+ if error_msg:
713
+ log_doc["error"] = error_msg
714
+ if api_logs_collection is not None:
715
+ try:
716
+ api_logs_collection.insert_one(log_doc)
717
+ log.info("API log inserted into logs/objectRemover")
718
+ except Exception as e:
719
+ log.error("Failed to insert API log: %s", e)
720
+
721
+
722
+ @app.post("/inpaint-url")
723
+ def inpaint_url(req: InpaintRequest, request: Request, _: None = Depends(bearer_auth)) -> Dict[str, str]:
724
+ """Same as /inpaint but returns a JSON with a public download URL instead of image bytes."""
725
+ start_time = time.time()
726
+ status = "success"
727
+ error_msg = None
728
+ result_name = None
729
+
730
+ try:
731
+ # Handle appname="collage-maker": get category_id from collage-maker if not provided
732
+ category_id = req.category_id
733
+ if req.appname == "collage-maker" and not category_id:
734
+ category_id = get_category_id_from_collage_maker()
735
+ if category_id:
736
+ log.info("Using category_id from collage-maker: %s", category_id)
737
+
738
+ img_rgba = _load_rgba_image_from_gridfs(req.image_id, "image")
739
+ mask_img = _load_rgba_image_from_gridfs(req.mask_id, "mask") # may be RGB/gray/RGBA
740
+ mask_rgba = _load_rgba_mask_from_image(mask_img)
741
+
742
+ if req.passthrough:
743
+ result = np.array(img_rgba.convert("RGB"))
744
+ else:
745
+ result = process_inpaint(
746
+ np.array(img_rgba),
747
+ mask_rgba,
748
+ invert_mask=req.invert_mask,
749
+ prompt=req.prompt,
750
+ )
751
+ result_name = f"output_{uuid.uuid4().hex}.png"
752
+ result_path = os.path.join(OUTPUT_DIR, result_name)
753
+ Image.fromarray(result).save(result_path, "PNG", optimize=False, compress_level=1)
754
+
755
+ url = str(request.url_for("download_file", filename=result_name))
756
+ logs.append({"result": result_name, "url": url, "timestamp": datetime.utcnow().isoformat()})
757
+ return {"result": result_name, "url": url}
758
+ except Exception as e:
759
+ status = "fail"
760
+ error_msg = str(e)
761
+ raise
762
+ finally:
763
+ # Always log to regular MongoDB (mandatory)
764
+ end_time = time.time()
765
+ response_time_ms = (end_time - start_time) * 1000
766
+ log_doc = {
767
+ "input_image_id": req.image_id,
768
+ "input_mask_id": req.mask_id,
769
+ "output_id": result_name,
770
+ "status": status,
771
+ "timestamp": datetime.utcnow(),
772
+ "ts": int(time.time()),
773
+ "response_time_ms": response_time_ms,
774
+ }
775
+ # Store appname in api_logs if provided
776
+ if req.appname:
777
+ log_doc["appname"] = req.appname
778
+ if error_msg:
779
+ log_doc["error"] = error_msg
780
+ if mongo_logs is not None:
781
+ try:
782
+ log.info("Inserting log to MongoDB - Database: %s, Collection: %s", mongo_logs.database.name, mongo_logs.name)
783
+ result = mongo_logs.insert_one(log_doc)
784
+ log.info("Mongo log inserted successfully: _id=%s, output_id=%s, status=%s, db=%s, collection=%s",
785
+ result.inserted_id, output_name, status, mongo_logs.database.name, mongo_logs.name)
786
+
787
+ # Verify the insert by reading it back
788
+ try:
789
+ verify_doc = mongo_logs.find_one({"_id": result.inserted_id})
790
+ if verify_doc:
791
+ log.info("Verified: Document exists in MongoDB after insert")
792
+ else:
793
+ log.error("WARNING: Document not found after insert! _id=%s", result.inserted_id)
794
+ except Exception as verify_err:
795
+ log.warning("Could not verify insert: %s", verify_err)
796
+ except Exception as mongo_err:
797
+ log.error("Mongo log insert failed: %s, log_doc=%s", mongo_err, log_doc, exc_info=True)
798
+ else:
799
+ log.warning("MongoDB not configured, skipping log insert")
800
+
801
+
802
+ @app.post("/inpaint-multipart")
803
+ def inpaint_multipart(
804
+ image: UploadFile = File(...),
805
+ mask: UploadFile = File(...),
806
+ request: Request = None,
807
+ invert_mask: bool = True,
808
+ mask_is_painted: bool = False, # if True, mask file is the painted-on image (e.g., black strokes on original)
809
+ passthrough: bool = False,
810
+ prompt: Optional[str] = Form(None),
811
+ user_id: Optional[str] = Form(None),
812
+ category_id: Optional[str] = Form(None),
813
+ appname: Optional[str] = Form(None),
814
+ _: None = Depends(bearer_auth),
815
+ ) -> Dict[str, str]:
816
+ start_time = time.time()
817
+ status = "success"
818
+ error_msg = None
819
+ result_name = None
820
+
821
+ try:
822
+ # Handle appname="collage-maker": get category_id from collage-maker if not provided
823
+ final_category_id = category_id
824
+ if appname == "collage-maker" and not final_category_id:
825
+ final_category_id = get_category_id_from_collage_maker()
826
+ if final_category_id:
827
+ log.info("Using category_id from collage-maker: %s", final_category_id)
828
+
829
+ # Load in-memory
830
+ img = Image.open(image.file).convert("RGBA")
831
+ m = Image.open(mask.file).convert("RGBA")
832
+
833
+ if passthrough:
834
+ # Just echo the input image, ignore mask
835
+ result = np.array(img.convert("RGB"))
836
+ result_name = f"output_{uuid.uuid4().hex}.png"
837
+ result_path = os.path.join(OUTPUT_DIR, result_name)
838
+ Image.fromarray(result).save(result_path, "PNG", optimize=False, compress_level=1)
839
+
840
+ url: Optional[str] = None
841
+ try:
842
+ if request is not None:
843
+ url = str(request.url_for("download_file", filename=result_name))
844
+ except Exception:
845
+ url = None
846
+
847
+ entry: Dict[str, str] = {"result": result_name, "timestamp": datetime.utcnow().isoformat()}
848
+ if url:
849
+ entry["url"] = url
850
+ logs.append(entry)
851
+ resp: Dict[str, str] = {"result": result_name}
852
+ if url:
853
+ resp["url"] = url
854
+ return resp
855
+
856
+ if mask_is_painted:
857
+ # Auto-detect pink/magenta paint and convert to black/white mask
858
+ # White pixels = areas to remove, Black pixels = areas to keep
859
+ log.info("Auto-detecting pink/magenta paint from uploaded image...")
860
+
861
+ m_rgb = cv2.cvtColor(np.array(m), cv2.COLOR_RGBA2RGB)
862
+
863
+ # Detect pink/magenta using fixed RGB bounds (same as /remove-pink)
864
+ lower = np.array([150, 0, 100], dtype=np.uint8)
865
+ upper = np.array([255, 120, 255], dtype=np.uint8)
866
+ magenta_detected = (
867
+ (m_rgb[:, :, 0] >= lower[0]) & (m_rgb[:, :, 0] <= upper[0]) &
868
+ (m_rgb[:, :, 1] >= lower[1]) & (m_rgb[:, :, 1] <= upper[1]) &
869
+ (m_rgb[:, :, 2] >= lower[2]) & (m_rgb[:, :, 2] <= upper[2])
870
+ ).astype(np.uint8) * 255
871
+
872
+ # Method 2: Also check if original image was provided to find differences
873
+ if img is not None:
874
+ img_rgb = cv2.cvtColor(np.array(img), cv2.COLOR_RGBA2RGB)
875
+ if img_rgb.shape == m_rgb.shape:
876
+ diff = cv2.absdiff(img_rgb, m_rgb)
877
+ gray_diff = cv2.cvtColor(diff, cv2.COLOR_RGB2GRAY)
878
+ # Any significant difference (>50) could be paint
879
+ diff_mask = (gray_diff > 50).astype(np.uint8) * 255
880
+ # Combine with magenta detection
881
+ binmask = cv2.bitwise_or(magenta_detected, diff_mask)
882
+ else:
883
+ binmask = magenta_detected
884
+ else:
885
+ # No original image provided, use magenta detection only
886
+ binmask = magenta_detected
887
+
888
+ # Clean up the mask: remove noise and fill small holes
889
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
890
+ # Close small gaps in the mask
891
+ binmask = cv2.morphologyEx(binmask, cv2.MORPH_CLOSE, kernel, iterations=2)
892
+ # Remove small noise
893
+ binmask = cv2.morphologyEx(binmask, cv2.MORPH_OPEN, kernel, iterations=1)
894
+
895
+ nonzero = int((binmask > 0).sum())
896
+ log.info("Pink/magenta paint detected: %d pixels marked for removal (white)", nonzero)
897
+
898
+ # If very few pixels detected, assume the user may already be providing a BW mask
899
+ # and proceed without forcing strict detection
900
+
901
+ if nonzero < 50:
902
+ log.error("CRITICAL: Could not detect pink/magenta paint! Returning original image.")
903
+ result = np.array(img.convert("RGB")) if img else np.array(m.convert("RGB"))
904
+ result_name = f"output_{uuid.uuid4().hex}.png"
905
+ result_path = os.path.join(OUTPUT_DIR, result_name)
906
+ Image.fromarray(result).save(result_path, "PNG", optimize=False, compress_level=1)
907
+ return {"result": result_name, "error": "pink/magenta paint detection failed - very few pixels detected"}
908
+
909
+ # Create binary mask: Pink pixels → white (255), Everything else → black (0)
910
+ # Encode in RGBA format for process_inpaint
911
+ # process_inpaint does: mask = 255 - mask[:,:,3]
912
+ # So: alpha=0 (transparent/pink) → becomes 255 (white/remove)
913
+ # alpha=255 (opaque/keep) → becomes 0 (black/keep)
914
+ mask_rgba = np.zeros((binmask.shape[0], binmask.shape[1], 4), dtype=np.uint8)
915
+ mask_rgba[:, :, 0] = binmask # R: white where pink (for visualization)
916
+ mask_rgba[:, :, 1] = binmask # G: white where pink
917
+ mask_rgba[:, :, 2] = binmask # B: white where pink
918
+ # Alpha: invert so pink areas get alpha=0 → will become white after 255-alpha
919
+ mask_rgba[:, :, 3] = 255 - binmask
920
+
921
+ log.info("Successfully created binary mask: %d pink pixels → white (255), %d pixels → black (0)",
922
+ nonzero, binmask.shape[0] * binmask.shape[1] - nonzero)
923
+ else:
924
+ mask_rgba = _load_rgba_mask_from_image(m)
925
+
926
+ # When mask_is_painted=true, we encode pink as alpha=0, so process_inpaint's default invert_mask=True works correctly
927
+ actual_invert = invert_mask # Use default True for painted masks
928
+ log.info("Using invert_mask=%s (mask_is_painted=%s)", actual_invert, mask_is_painted)
929
+
930
+ result = process_inpaint(
931
+ np.array(img),
932
+ mask_rgba,
933
+ invert_mask=actual_invert,
934
+ prompt=prompt,
935
+ )
936
+ result_name = f"output_{uuid.uuid4().hex}.png"
937
+ result_path = os.path.join(OUTPUT_DIR, result_name)
938
+ Image.fromarray(result).save(result_path, "PNG", optimize=False, compress_level=1)
939
+
940
+ url: Optional[str] = None
941
+ try:
942
+ if request is not None:
943
+ url = str(request.url_for("download_file", filename=result_name))
944
+ except Exception:
945
+ url = None
946
+
947
+ entry: Dict[str, str] = {"result": result_name, "timestamp": datetime.utcnow().isoformat()}
948
+ if url:
949
+ entry["url"] = url
950
+ logs.append(entry)
951
+ resp: Dict[str, str] = {"result": result_name}
952
+ if url:
953
+ resp["url"] = url
954
+ return resp
955
+ except Exception as e:
956
+ status = "fail"
957
+ error_msg = str(e)
958
+ raise
959
+ finally:
960
+ # Always log to regular MongoDB (mandatory)
961
+ end_time = time.time()
962
+ response_time_ms = (end_time - start_time) * 1000
963
+ log_doc = {
964
+ "endpoint": "inpaint-multipart",
965
+ "output_id": result_name,
966
+ "status": status,
967
+ "timestamp": datetime.utcnow(),
968
+ "ts": int(time.time()),
969
+ "response_time_ms": response_time_ms,
970
+ }
971
+ # Store appname in api_logs if provided
972
+ if appname:
973
+ log_doc["appname"] = appname
974
+ if error_msg:
975
+ log_doc["error"] = error_msg
976
+ if mongo_logs is not None:
977
+ try:
978
+ log.info("Inserting log to MongoDB - Database: %s, Collection: %s", mongo_logs.database.name, mongo_logs.name)
979
+ result = mongo_logs.insert_one(log_doc)
980
+ log.info("Mongo log inserted successfully: _id=%s, output_id=%s, status=%s, db=%s, collection=%s",
981
+ result.inserted_id, output_name, status, mongo_logs.database.name, mongo_logs.name)
982
+
983
+ # Verify the insert by reading it back
984
+ try:
985
+ verify_doc = mongo_logs.find_one({"_id": result.inserted_id})
986
+ if verify_doc:
987
+ log.info("Verified: Document exists in MongoDB after insert")
988
+ else:
989
+ log.error("WARNING: Document not found after insert! _id=%s", result.inserted_id)
990
+ except Exception as verify_err:
991
+ log.warning("Could not verify insert: %s", verify_err)
992
+ except Exception as mongo_err:
993
+ log.error("Mongo log insert failed: %s, log_doc=%s", mongo_err, log_doc, exc_info=True)
994
+ else:
995
+ log.warning("MongoDB not configured, skipping log insert")
996
+
997
+
998
+ @app.post("/remove-pink")
999
+ def remove_pink_segments(
1000
+ image: UploadFile = File(...),
1001
+ request: Request = None,
1002
+ user_id: Optional[str] = Form(None),
1003
+ category_id: Optional[str] = Form(None),
1004
+ appname: Optional[str] = Form(None),
1005
+ _: None = Depends(bearer_auth),
1006
+ ) -> Dict[str, str]:
1007
+ """
1008
+ Simple endpoint: upload an image with pink/magenta segments to remove.
1009
+ - Pink/Magenta segments → automatically removed (white in mask)
1010
+ - Everything else → automatically kept (black in mask)
1011
+ Just paint pink/magenta on areas you want to remove, upload the image, and it works!
1012
+ """
1013
+ start_time = time.time()
1014
+ status = "success"
1015
+ error_msg = None
1016
+ result_name = None
1017
+
1018
+ try:
1019
+ # Handle appname="collage-maker": get category_id from collage-maker if not provided
1020
+ final_category_id = category_id
1021
+ if appname == "collage-maker" and not final_category_id:
1022
+ final_category_id = get_category_id_from_collage_maker()
1023
+ if final_category_id:
1024
+ log.info("Using category_id from collage-maker: %s", final_category_id)
1025
+
1026
+ log.info(f"Simple remove-pink: processing image {image.filename}")
1027
+
1028
+ # Load the image (with pink paint on it)
1029
+ img = Image.open(image.file).convert("RGBA")
1030
+ img_rgb = cv2.cvtColor(np.array(img), cv2.COLOR_RGBA2RGB)
1031
+
1032
+ # Auto-detect pink/magenta segments to remove
1033
+ # Pink/Magenta → white in mask (remove)
1034
+ # Everything else (natural image colors, including dark areas) → black in mask (keep)
1035
+
1036
+ # Detect pink/magenta using fixed RGB bounds per requested logic
1037
+ lower = np.array([150, 0, 100], dtype=np.uint8)
1038
+ upper = np.array([255, 120, 255], dtype=np.uint8)
1039
+ binmask = (
1040
+ (img_rgb[:, :, 0] >= lower[0]) & (img_rgb[:, :, 0] <= upper[0]) &
1041
+ (img_rgb[:, :, 1] >= lower[1]) & (img_rgb[:, :, 1] <= upper[1]) &
1042
+ (img_rgb[:, :, 2] >= lower[2]) & (img_rgb[:, :, 2] <= upper[2])
1043
+ ).astype(np.uint8) * 255
1044
+
1045
+ # Clean up the pink mask
1046
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
1047
+ binmask = cv2.morphologyEx(binmask, cv2.MORPH_CLOSE, kernel, iterations=2)
1048
+ binmask = cv2.morphologyEx(binmask, cv2.MORPH_OPEN, kernel, iterations=1)
1049
+
1050
+ nonzero = int((binmask > 0).sum())
1051
+ total_pixels = binmask.shape[0] * binmask.shape[1]
1052
+ log.info(f"Detected {nonzero} pink pixels ({100*nonzero/total_pixels:.2f}% of image) to remove")
1053
+
1054
+ # Debug: log bounds used
1055
+ log.info("Pink detection bounds used: lower=[150,0,100], upper=[255,120,255]")
1056
+
1057
+ if nonzero < 50:
1058
+ log.error("No pink segments detected! Returning original image.")
1059
+ result = np.array(img.convert("RGB"))
1060
+ result_name = f"output_{uuid.uuid4().hex}.png"
1061
+ result_path = os.path.join(OUTPUT_DIR, result_name)
1062
+ Image.fromarray(result).save(result_path, "PNG", optimize=False, compress_level=1)
1063
+ return {
1064
+ "result": result_name,
1065
+ "error": "No pink/magenta segments detected. Please paint areas to remove with magenta/pink color (RGB 255,0,255)."
1066
+ }
1067
+
1068
+ # Create binary mask: Pink pixels → white (255), Everything else → black (0)
1069
+ # Encode in RGBA format that process_inpaint expects
1070
+ # process_inpaint does: mask = 255 - mask[:,:,3]
1071
+ # So: alpha=0 (transparent/pink) → becomes 255 (white/remove)
1072
+ # alpha=255 (opaque/keep) → becomes 0 (black/keep)
1073
+ mask_rgba = np.zeros((binmask.shape[0], binmask.shape[1], 4), dtype=np.uint8)
1074
+ # RGB channels don't matter for process_inpaint, but set them to white where pink for visualization
1075
+ mask_rgba[:, :, 0] = binmask # R: white where pink
1076
+ mask_rgba[:, :, 1] = binmask # G: white where pink
1077
+ mask_rgba[:, :, 2] = binmask # B: white where pink
1078
+ # Alpha: 0 (transparent) where pink → will become white after 255-alpha
1079
+ # 255 (opaque) everywhere else → will become black after 255-alpha
1080
+ mask_rgba[:, :, 3] = 255 - binmask # Invert: pink areas get alpha=0, rest get alpha=255
1081
+
1082
+ # Verify mask encoding
1083
+ alpha_zero_count = int((mask_rgba[:,:,3] == 0).sum())
1084
+ alpha_255_count = int((mask_rgba[:,:,3] == 255).sum())
1085
+ total_pixels = binmask.shape[0] * binmask.shape[1]
1086
+ log.info(f"Mask encoding: {alpha_zero_count} pixels with alpha=0 (pink), {alpha_255_count} pixels with alpha=255 (keep)")
1087
+ log.info(f"After 255-alpha conversion: {alpha_zero_count} will become white (255/remove), {alpha_255_count} will become black (0/keep)")
1088
+
1089
+ # IMPORTANT: We need to use the ORIGINAL image WITHOUT pink paint for inpainting!
1090
+ # Remove pink from the original image before processing
1091
+ # Create a clean version: where pink was detected, keep original image colors
1092
+ img_clean = np.array(img.convert("RGBA"))
1093
+ # Where pink is detected, we want to inpaint, so we can leave it (or blend it out)
1094
+ # Actually, the model will inpaint over those areas, so we can pass the original
1095
+ # But for better results, we might want to remove the pink overlay first
1096
+
1097
+ # Process with invert_mask=True (default) because process_inpaint expects alpha=0 for removal
1098
+ log.info(f"Starting inpainting process...")
1099
+ result = process_inpaint(img_clean, mask_rgba, invert_mask=True)
1100
+ log.info(f"Inpainting complete, result shape: {result.shape}")
1101
+ result_name = f"output_{uuid.uuid4().hex}.png"
1102
+ result_path = os.path.join(OUTPUT_DIR, result_name)
1103
+ Image.fromarray(result).save(result_path, "PNG", optimize=False, compress_level=1)
1104
+
1105
+ url: Optional[str] = None
1106
+ try:
1107
+ if request is not None:
1108
+ url = str(request.url_for("download_file", filename=result_name))
1109
+ except Exception:
1110
+ url = None
1111
+
1112
+ logs.append({
1113
+ "result": result_name,
1114
+ "filename": image.filename,
1115
+ "pink_pixels": nonzero,
1116
+ "timestamp": datetime.utcnow().isoformat()
1117
+ })
1118
+
1119
+ resp: Dict[str, str] = {"result": result_name, "pink_segments_detected": str(nonzero)}
1120
+ if url:
1121
+ resp["url"] = url
1122
+ return resp
1123
+ except Exception as e:
1124
+ status = "fail"
1125
+ error_msg = str(e)
1126
+ raise
1127
+ finally:
1128
+ # Always log to regular MongoDB (mandatory)
1129
+ end_time = time.time()
1130
+ response_time_ms = (end_time - start_time) * 1000
1131
+ log_doc = {
1132
+ "endpoint": "remove-pink",
1133
+ "output_id": result_name,
1134
+ "status": status,
1135
+ "timestamp": datetime.utcnow(),
1136
+ "ts": int(time.time()),
1137
+ "response_time_ms": response_time_ms,
1138
+ }
1139
+ # Store appname in api_logs if provided
1140
+ if appname:
1141
+ log_doc["appname"] = appname
1142
+ if error_msg:
1143
+ log_doc["error"] = error_msg
1144
+ if mongo_logs is not None:
1145
+ try:
1146
+ log.info("Inserting log to MongoDB - Database: %s, Collection: %s", mongo_logs.database.name, mongo_logs.name)
1147
+ result = mongo_logs.insert_one(log_doc)
1148
+ log.info("Mongo log inserted successfully: _id=%s, output_id=%s, status=%s, db=%s, collection=%s",
1149
+ result.inserted_id, output_name, status, mongo_logs.database.name, mongo_logs.name)
1150
+
1151
+ # Verify the insert by reading it back
1152
+ try:
1153
+ verify_doc = mongo_logs.find_one({"_id": result.inserted_id})
1154
+ if verify_doc:
1155
+ log.info("Verified: Document exists in MongoDB after insert")
1156
+ else:
1157
+ log.error("WARNING: Document not found after insert! _id=%s", result.inserted_id)
1158
+ except Exception as verify_err:
1159
+ log.warning("Could not verify insert: %s", verify_err)
1160
+ except Exception as mongo_err:
1161
+ log.error("Mongo log insert failed: %s, log_doc=%s", mongo_err, log_doc, exc_info=True)
1162
+ else:
1163
+ log.warning("MongoDB not configured, skipping log insert")
1164
+
1165
+
1166
+ @app.get("/download/{filename}")
1167
+ def download_file(filename: str):
1168
+ path = os.path.join(OUTPUT_DIR, filename)
1169
+ if not os.path.isfile(path):
1170
+ raise HTTPException(status_code=404, detail="file not found")
1171
+ return FileResponse(path)
1172
+
1173
+
1174
+ @app.get("/result/{filename}")
1175
+ def view_result(filename: str):
1176
+ """View result image directly in browser (same as download but with proper content-type for viewing)"""
1177
+ path = os.path.join(OUTPUT_DIR, filename)
1178
+ if not os.path.isfile(path):
1179
+ raise HTTPException(status_code=404, detail="file not found")
1180
+ return FileResponse(path, media_type="image/png")
1181
+
1182
+
1183
+ @app.get("/logs")
1184
+ def get_logs(_: None = Depends(bearer_auth)) -> JSONResponse:
1185
+ return JSONResponse(content=logs)