pestdetectionai commited on
Commit
4e06c68
·
verified ·
1 Parent(s): 3cdae2d

Sync from GitHub via hub-sync

Browse files
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pestdetectionai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,10 +1,2 @@
1
- ---
2
- title: Web
3
- emoji: 🏢
4
- colorFrom: green
5
- colorTo: blue
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
+ # web
2
+ web
 
 
 
 
 
 
 
 
download_model.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ from huggingface_hub import hf_hub_download
3
+
4
+ MODEL_REPO = "underdogquality/yolo11s-pest-detection"
5
+ MODEL_FILE = "best.pt"
6
+
7
+ BASE_DIR = Path(__file__).resolve().parent
8
+ MODEL_DIR = BASE_DIR / "models"
9
+ MODEL_DIR.mkdir(parents=True, exist_ok=True)
10
+
11
+ print("Downloading pest detection model...")
12
+ print(f"Repo: {MODEL_REPO}")
13
+ print(f"File: {MODEL_FILE}")
14
+
15
+ downloaded_path = hf_hub_download(
16
+ repo_id=MODEL_REPO,
17
+ filename=MODEL_FILE,
18
+ local_dir=str(MODEL_DIR),
19
+ local_dir_use_symlinks=False
20
+ )
21
+
22
+ print("Model downloaded successfully:")
23
+ print(downloaded_path)
filestructure.txt ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ |-- .env
2
+ |-- .github
3
+ |-- workflows
4
+ |-- sync-to-huggingface.yml
5
+ |-- .gitignore
6
+ |-- debug
7
+ |-- mask_20260506_044403_b6b1f10e7c884d7fabad5522bfa5d17f.jpg
8
+ |-- download_model.py
9
+ |-- LICENSE
10
+ |-- main.py
11
+ |-- models
12
+ |-- best.pt
13
+ |-- README.md
14
+ |-- requirements.txt
15
+ |-- results
16
+ |-- result_20260506_044403_b6b1f10e7c884d7fabad5522bfa5d17f.jpg
17
+ |-- uploads
18
+ |-- 20260506_044403_b6b1f10e7c884d7fabad5522bfa5d17f.jpeg
19
+ |-- web
20
+ |-- access.html
21
+ |-- css
22
+ |-- app.css
23
+ |-- detail.html
24
+ |-- index.html
25
+ |-- js
26
+ |-- api.js
27
+ |-- dashboard.js
28
+ |-- detail.js
29
+ |-- logs.js
30
+ |-- logs.html
31
+ |-- __pycache__
32
+ |-- main.cpython-310.pyc
main.py ADDED
@@ -0,0 +1,1802 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, UploadFile, File, HTTPException, Request, Query
2
+ from fastapi.responses import JSONResponse, FileResponse, RedirectResponse
3
+ from fastapi.staticfiles import StaticFiles
4
+ from ultralytics import YOLO
5
+ from huggingface_hub import hf_hub_download
6
+ from pathlib import Path
7
+ from datetime import datetime, timedelta
8
+ from collections import Counter, defaultdict
9
+ from dotenv import load_dotenv
10
+ import uuid
11
+ import cv2
12
+ import numpy as np
13
+ import os
14
+ import base64
15
+ import json
16
+ import firebase_admin
17
+ from firebase_admin import credentials, db
18
+ import cloudinary
19
+ import cloudinary.uploader
20
+
21
+
22
+ # =========================================================
23
+ # ENV
24
+ # =========================================================
25
+
26
+ load_dotenv()
27
+
28
+
29
+ # =========================================================
30
+ # CONFIG
31
+ # =========================================================
32
+
33
+ BASE_DIR = Path(__file__).resolve().parent
34
+
35
+ MODEL_DIR = BASE_DIR / "models"
36
+
37
+ MODEL_FILENAME = os.getenv("MODEL_FILENAME", "best.pt").strip()
38
+ MODEL_PATH = Path(os.getenv("MODEL_PATH", str(MODEL_DIR / MODEL_FILENAME))).resolve()
39
+
40
+ HF_MODEL_REPO = os.getenv(
41
+ "HF_MODEL_REPO",
42
+ "underdogquality/yolo11s-pest-detection"
43
+ ).strip()
44
+
45
+ HF_MODEL_FILE = os.getenv(
46
+ "HF_MODEL_FILE",
47
+ MODEL_FILENAME
48
+ ).strip()
49
+
50
+ HF_TOKEN = os.getenv("HF_TOKEN", "").strip() or None
51
+
52
+ AUTO_DOWNLOAD_MODEL = os.getenv(
53
+ "AUTO_DOWNLOAD_MODEL",
54
+ "true"
55
+ ).strip().lower() in {"1", "true", "yes", "on"}
56
+
57
+ UPLOAD_DIR = BASE_DIR / "uploads"
58
+ RESULT_DIR = BASE_DIR / "results"
59
+ DEBUG_DIR = BASE_DIR / "debug"
60
+ WEB_DIR = BASE_DIR / "web"
61
+
62
+ MODEL_PATH.parent.mkdir(parents=True, exist_ok=True)
63
+ UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
64
+ RESULT_DIR.mkdir(parents=True, exist_ok=True)
65
+ DEBUG_DIR.mkdir(parents=True, exist_ok=True)
66
+ WEB_DIR.mkdir(parents=True, exist_ok=True)
67
+
68
+ ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
69
+
70
+ APP_PUBLIC_BASE_URL = os.getenv("APP_PUBLIC_BASE_URL", "").strip()
71
+
72
+ FIREBASE_DATABASE_URL = os.getenv("FIREBASE_DATABASE_URL", "").strip()
73
+ FIREBASE_SERVICE_ACCOUNT_PATH = os.getenv("FIREBASE_SERVICE_ACCOUNT_PATH", "").strip()
74
+ FIREBASE_SERVICE_ACCOUNT_JSON_B64 = os.getenv("FIREBASE_SERVICE_ACCOUNT_JSON_B64", "").strip()
75
+
76
+ FIREBASE_LOGS_PATH = "/api/analyze/logs"
77
+
78
+ CLOUDINARY_CLOUD_NAME = os.getenv("CLOUDINARY_CLOUD_NAME", "").strip()
79
+ CLOUDINARY_API_KEY = os.getenv("CLOUDINARY_API_KEY", "").strip()
80
+ CLOUDINARY_API_SECRET = os.getenv("CLOUDINARY_API_SECRET", "").strip()
81
+ CLOUDINARY_FOLDER = os.getenv("CLOUDINARY_FOLDER", "smart-pest-detection").strip()
82
+
83
+ # YOLO settings
84
+ YOLO_CONFIDENCE = 0.08
85
+ YOLO_IOU = 0.40
86
+ YOLO_IMAGE_SIZE = 1280
87
+
88
+ # YOLO filter
89
+ MAX_YOLO_BOX_AREA_RATIO = 0.10
90
+ LOW_CONF_LARGE_BOX_CONF = 0.20
91
+ LOW_CONF_LARGE_BOX_AREA_RATIO = 0.040
92
+
93
+ # CV proposal settings
94
+ PROPOSAL_MIN_AREA = 10
95
+ PROPOSAL_MAX_AREA_RATIO = 0.030
96
+ PROPOSAL_MIN_WIDTH = 4
97
+ PROPOSAL_MIN_HEIGHT = 4
98
+ PROPOSAL_MAX_WIDTH_RATIO = 0.35
99
+ PROPOSAL_MAX_HEIGHT_RATIO = 0.35
100
+
101
+ GOOD_YOLO_CONFIDENCE = 0.24
102
+ UNKNOWN_OVERLAP_WITH_GOOD_YOLO = 0.12
103
+
104
+ FINAL_NMS_IOU = 0.14
105
+
106
+ GREEN = (0, 255, 0)
107
+ ORANGE = (0, 165, 255)
108
+ BLACK = (0, 0, 0)
109
+
110
+
111
+ # =========================================================
112
+ # APP INIT
113
+ # =========================================================
114
+
115
+ app = FastAPI(
116
+ title="Smart Pest Trap Detection API",
117
+ description="YOLO pest identification + Cloudinary storage + Firebase logs + static website.",
118
+ version="9.1.0"
119
+ )
120
+
121
+ app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads")
122
+ app.mount("/results", StaticFiles(directory=str(RESULT_DIR)), name="results")
123
+ app.mount("/debug", StaticFiles(directory=str(DEBUG_DIR)), name="debug")
124
+ app.mount("/web", StaticFiles(directory=str(WEB_DIR)), name="web")
125
+
126
+
127
+ # =========================================================
128
+ # GLOBALS
129
+ # =========================================================
130
+
131
+ model = None
132
+ firebase_ready = False
133
+ cloudinary_ready = False
134
+
135
+
136
+ # =========================================================
137
+ # STARTUP
138
+ # =========================================================
139
+
140
+ @app.on_event("startup")
141
+ def startup():
142
+ load_model()
143
+ init_firebase()
144
+ init_cloudinary()
145
+
146
+
147
+ def ensure_model_available():
148
+ """
149
+ Downloads the YOLO model on boot if models/best.pt is missing.
150
+ First boot needs internet. After download, it uses the local file.
151
+ """
152
+
153
+ MODEL_PATH.parent.mkdir(parents=True, exist_ok=True)
154
+
155
+ if MODEL_PATH.exists() and MODEL_PATH.stat().st_size > 0:
156
+ print("====================================")
157
+ print("[MODEL] Local model found")
158
+ print(f"[MODEL] Path: {MODEL_PATH}")
159
+ print("====================================")
160
+ return
161
+
162
+ if not AUTO_DOWNLOAD_MODEL:
163
+ raise RuntimeError(
164
+ f"Model not found: {MODEL_PATH}\n"
165
+ "AUTO_DOWNLOAD_MODEL=false, so boot download is disabled."
166
+ )
167
+
168
+ print("====================================")
169
+ print("[MODEL] Local model not found")
170
+ print("[MODEL] Downloading model from Hugging Face...")
171
+ print(f"[MODEL] Repo: {HF_MODEL_REPO}")
172
+ print(f"[MODEL] File: {HF_MODEL_FILE}")
173
+ print(f"[MODEL] Save to: {MODEL_PATH.parent}")
174
+ print("====================================")
175
+
176
+ try:
177
+ downloaded_path = hf_hub_download(
178
+ repo_id=HF_MODEL_REPO,
179
+ filename=HF_MODEL_FILE,
180
+ local_dir=str(MODEL_PATH.parent),
181
+ local_dir_use_symlinks=False,
182
+ token=HF_TOKEN
183
+ )
184
+
185
+ downloaded_path = Path(downloaded_path).resolve()
186
+
187
+ if downloaded_path != MODEL_PATH:
188
+ if downloaded_path.exists():
189
+ MODEL_PATH.write_bytes(downloaded_path.read_bytes())
190
+
191
+ if not MODEL_PATH.exists() or MODEL_PATH.stat().st_size <= 0:
192
+ raise RuntimeError(f"Downloaded model is missing or empty: {MODEL_PATH}")
193
+
194
+ print("====================================")
195
+ print("[MODEL] Download complete")
196
+ print(f"[MODEL] Path: {MODEL_PATH}")
197
+ print("====================================")
198
+
199
+ except Exception as e:
200
+ raise RuntimeError(
201
+ f"Model download failed.\n"
202
+ f"Repo: {HF_MODEL_REPO}\n"
203
+ f"File: {HF_MODEL_FILE}\n"
204
+ f"Target: {MODEL_PATH}\n"
205
+ f"Error: {e}"
206
+ )
207
+
208
+
209
+ def load_model():
210
+ global model
211
+
212
+ ensure_model_available()
213
+
214
+ print("====================================")
215
+ print("[MODEL] Loading pest detection model")
216
+ print(f"[MODEL] Path: {MODEL_PATH}")
217
+ print("====================================")
218
+
219
+ model = YOLO(str(MODEL_PATH))
220
+
221
+ print("[MODEL] Loaded successfully")
222
+
223
+
224
+ def init_firebase():
225
+ global firebase_ready
226
+
227
+ if not FIREBASE_DATABASE_URL:
228
+ print("[FIREBASE] Disabled: FIREBASE_DATABASE_URL is missing")
229
+ firebase_ready = False
230
+ return
231
+
232
+ try:
233
+ if firebase_admin._apps:
234
+ firebase_ready = True
235
+ print("[FIREBASE] Already initialized")
236
+ return
237
+
238
+ if FIREBASE_SERVICE_ACCOUNT_JSON_B64:
239
+ decoded = base64.b64decode(FIREBASE_SERVICE_ACCOUNT_JSON_B64).decode("utf-8")
240
+ service_account_info = json.loads(decoded)
241
+ cred = credentials.Certificate(service_account_info)
242
+ print("[FIREBASE] Using FIREBASE_SERVICE_ACCOUNT_JSON_B64")
243
+
244
+ elif FIREBASE_SERVICE_ACCOUNT_PATH:
245
+ service_account_path = Path(FIREBASE_SERVICE_ACCOUNT_PATH)
246
+
247
+ if not service_account_path.is_absolute():
248
+ service_account_path = BASE_DIR / service_account_path
249
+
250
+ if not service_account_path.exists():
251
+ print(f"[FIREBASE] Service account file not found: {service_account_path}")
252
+ firebase_ready = False
253
+ return
254
+
255
+ cred = credentials.Certificate(str(service_account_path))
256
+ print(f"[FIREBASE] Using service account file: {service_account_path}")
257
+
258
+ else:
259
+ print("[FIREBASE] Disabled: service account is missing")
260
+ firebase_ready = False
261
+ return
262
+
263
+ firebase_admin.initialize_app(
264
+ cred,
265
+ {
266
+ "databaseURL": FIREBASE_DATABASE_URL
267
+ }
268
+ )
269
+
270
+ firebase_ready = True
271
+ print("[FIREBASE] Initialized successfully")
272
+
273
+ except Exception as e:
274
+ firebase_ready = False
275
+ print(f"[FIREBASE] Init failed: {e}")
276
+
277
+
278
+ def init_cloudinary():
279
+ global cloudinary_ready
280
+
281
+ if not CLOUDINARY_CLOUD_NAME or not CLOUDINARY_API_KEY or not CLOUDINARY_API_SECRET:
282
+ cloudinary_ready = False
283
+ print("[CLOUDINARY] Disabled: missing CLOUDINARY_CLOUD_NAME/API_KEY/API_SECRET")
284
+ return
285
+
286
+ try:
287
+ cloudinary.config(
288
+ cloud_name=CLOUDINARY_CLOUD_NAME,
289
+ api_key=CLOUDINARY_API_KEY,
290
+ api_secret=CLOUDINARY_API_SECRET,
291
+ secure=True
292
+ )
293
+
294
+ cloudinary_ready = True
295
+ print("[CLOUDINARY] Initialized successfully")
296
+
297
+ except Exception as e:
298
+ cloudinary_ready = False
299
+ print(f"[CLOUDINARY] Init failed: {e}")
300
+
301
+
302
+ # =========================================================
303
+ # BASIC HELPERS
304
+ # =========================================================
305
+
306
+ def now_dt():
307
+ return datetime.now()
308
+
309
+
310
+ def now_string():
311
+ return now_dt().strftime("%Y-%m-%d %H:%M")
312
+
313
+
314
+ def now_iso():
315
+ return now_dt().isoformat(timespec="seconds")
316
+
317
+
318
+ def now_timestamp_ms():
319
+ return int(now_dt().timestamp() * 1000)
320
+
321
+
322
+ def get_base_url(request: Request):
323
+ if APP_PUBLIC_BASE_URL:
324
+ return APP_PUBLIC_BASE_URL.rstrip("/")
325
+ return str(request.base_url).rstrip("/")
326
+
327
+
328
+ def validate_image_file(file: UploadFile):
329
+ filename = file.filename or ""
330
+ ext = Path(filename).suffix.lower()
331
+
332
+ if ext not in ALLOWED_EXTENSIONS:
333
+ raise HTTPException(
334
+ status_code=400,
335
+ detail=f"Invalid image type. Allowed: {', '.join(sorted(ALLOWED_EXTENSIONS))}"
336
+ )
337
+
338
+ return ext
339
+
340
+
341
+ async def save_upload(file: UploadFile, ext: str) -> Path:
342
+ UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
343
+
344
+ unique_name = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex}{ext}"
345
+ save_path = UPLOAD_DIR / unique_name
346
+
347
+ content = await file.read()
348
+
349
+ if not content:
350
+ raise HTTPException(status_code=400, detail="Uploaded image is empty.")
351
+
352
+ with open(save_path, "wb") as buffer:
353
+ buffer.write(content)
354
+
355
+ if not save_path.exists():
356
+ raise HTTPException(
357
+ status_code=500,
358
+ detail=f"Upload save failed. File was not created: {save_path}"
359
+ )
360
+
361
+ if save_path.stat().st_size <= 0:
362
+ raise HTTPException(
363
+ status_code=500,
364
+ detail=f"Upload save failed. File is empty: {save_path}"
365
+ )
366
+
367
+ return save_path
368
+
369
+
370
+ def safe_label(label: str):
371
+ return label.replace("_", " ").strip()
372
+
373
+
374
+ def get_box(det):
375
+ b = det["box"]
376
+ return [float(b["x1"]), float(b["y1"]), float(b["x2"]), float(b["y2"])]
377
+
378
+
379
+ def box_area(box):
380
+ x1, y1, x2, y2 = box
381
+ return max(0.0, x2 - x1) * max(0.0, y2 - y1)
382
+
383
+
384
+ def clamp_box(box, width, height):
385
+ x1, y1, x2, y2 = box
386
+
387
+ x1 = max(0, min(float(x1), width - 1))
388
+ y1 = max(0, min(float(y1), height - 1))
389
+ x2 = max(0, min(float(x2), width - 1))
390
+ y2 = max(0, min(float(y2), height - 1))
391
+
392
+ if x2 < x1:
393
+ x1, x2 = x2, x1
394
+
395
+ if y2 < y1:
396
+ y1, y2 = y2, y1
397
+
398
+ return [x1, y1, x2, y2]
399
+
400
+
401
+ def expand_box(box, pad, width, height):
402
+ x1, y1, x2, y2 = box
403
+ return clamp_box(
404
+ [x1 - pad, y1 - pad, x2 + pad, y2 + pad],
405
+ width,
406
+ height
407
+ )
408
+
409
+
410
+ def iou(box_a, box_b):
411
+ ax1, ay1, ax2, ay2 = box_a
412
+ bx1, by1, bx2, by2 = box_b
413
+
414
+ ix1 = max(ax1, bx1)
415
+ iy1 = max(ay1, by1)
416
+ ix2 = min(ax2, bx2)
417
+ iy2 = min(ay2, by2)
418
+
419
+ iw = max(0.0, ix2 - ix1)
420
+ ih = max(0.0, iy2 - iy1)
421
+
422
+ inter = iw * ih
423
+ union = box_area(box_a) + box_area(box_b) - inter
424
+
425
+ if union <= 0:
426
+ return 0.0
427
+
428
+ return inter / union
429
+
430
+
431
+ def intersection_ratio_small(box_a, box_b):
432
+ ax1, ay1, ax2, ay2 = box_a
433
+ bx1, by1, bx2, by2 = box_b
434
+
435
+ ix1 = max(ax1, bx1)
436
+ iy1 = max(ay1, by1)
437
+ ix2 = min(ax2, bx2)
438
+ iy2 = min(ay2, by2)
439
+
440
+ iw = max(0.0, ix2 - ix1)
441
+ ih = max(0.0, iy2 - iy1)
442
+
443
+ inter = iw * ih
444
+ smaller = min(box_area(box_a), box_area(box_b))
445
+
446
+ if smaller <= 0:
447
+ return 0.0
448
+
449
+ return inter / smaller
450
+
451
+
452
+ def nms_detections(detections, iou_threshold=0.14, class_aware=False):
453
+ if not detections:
454
+ return []
455
+
456
+ detections = sorted(detections, key=lambda d: d["confidence"], reverse=True)
457
+ kept = []
458
+
459
+ while detections:
460
+ best = detections.pop(0)
461
+ kept.append(best)
462
+
463
+ remaining = []
464
+
465
+ for det in detections:
466
+ overlap = iou(get_box(best), get_box(det))
467
+
468
+ if class_aware:
469
+ if best["type"] == det["type"] and overlap > iou_threshold:
470
+ continue
471
+ else:
472
+ if overlap > iou_threshold:
473
+ continue
474
+
475
+ remaining.append(det)
476
+
477
+ detections = remaining
478
+
479
+ return kept
480
+
481
+
482
+ # =========================================================
483
+ # CLOUDINARY HELPERS
484
+ # =========================================================
485
+
486
+ def upload_image_to_cloudinary(file_path: Path, folder_name: str, public_id_prefix: str):
487
+ if not cloudinary_ready:
488
+ print("[CLOUDINARY] Skipped: cloudinary_ready=False")
489
+ return None
490
+
491
+ if not file_path:
492
+ print("[CLOUDINARY] Skipped: file_path is None")
493
+ return None
494
+
495
+ file_path = Path(file_path)
496
+
497
+ if not file_path.exists():
498
+ print(f"[CLOUDINARY] Skipped: file does not exist: {file_path}")
499
+ return None
500
+
501
+ if file_path.stat().st_size <= 0:
502
+ print(f"[CLOUDINARY] Skipped: file is empty: {file_path}")
503
+ return None
504
+
505
+ try:
506
+ public_id = f"{public_id_prefix}_{file_path.stem}"
507
+
508
+ result = cloudinary.uploader.upload(
509
+ str(file_path.resolve()),
510
+ folder=f"{CLOUDINARY_FOLDER}/{folder_name}",
511
+ public_id=public_id,
512
+ resource_type="image",
513
+ overwrite=True
514
+ )
515
+
516
+ return {
517
+ "secure_url": result.get("secure_url"),
518
+ "url": result.get("url"),
519
+ "public_id": result.get("public_id"),
520
+ "asset_id": result.get("asset_id"),
521
+ "format": result.get("format"),
522
+ "bytes": result.get("bytes"),
523
+ "width": result.get("width"),
524
+ "height": result.get("height")
525
+ }
526
+
527
+ except Exception as e:
528
+ print(f"[CLOUDINARY] Upload failed for {file_path}: {e}")
529
+ return None
530
+
531
+
532
+ def upload_analysis_images_to_cloudinary(uploaded_path: Path, result_image_path: Path, debug_mask_path: Path | None):
533
+ timestamp_folder = datetime.now().strftime("%Y/%m/%d")
534
+
535
+ original_cloud = upload_image_to_cloudinary(
536
+ uploaded_path,
537
+ f"{timestamp_folder}/original",
538
+ "original"
539
+ )
540
+
541
+ annotated_cloud = upload_image_to_cloudinary(
542
+ result_image_path,
543
+ f"{timestamp_folder}/annotated",
544
+ "annotated"
545
+ )
546
+
547
+ debug_cloud = None
548
+
549
+ if debug_mask_path:
550
+ debug_cloud = upload_image_to_cloudinary(
551
+ debug_mask_path,
552
+ f"{timestamp_folder}/debug",
553
+ "debug"
554
+ )
555
+
556
+ return original_cloud, annotated_cloud, debug_cloud
557
+
558
+
559
+ # =========================================================
560
+ # IMAGE PREPROCESSING
561
+ # =========================================================
562
+
563
+ def remove_red_markup_if_present(image):
564
+ hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
565
+
566
+ lower_red1 = np.array([0, 80, 80])
567
+ upper_red1 = np.array([12, 255, 255])
568
+
569
+ lower_red2 = np.array([170, 80, 80])
570
+ upper_red2 = np.array([180, 255, 255])
571
+
572
+ mask1 = cv2.inRange(hsv, lower_red1, upper_red1)
573
+ mask2 = cv2.inRange(hsv, lower_red2, upper_red2)
574
+
575
+ red_mask = cv2.bitwise_or(mask1, mask2)
576
+
577
+ if cv2.countNonZero(red_mask) < 50:
578
+ return image
579
+
580
+ return cv2.inpaint(image, red_mask, 5, cv2.INPAINT_TELEA)
581
+
582
+
583
+ def enhance_image(image):
584
+ lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB)
585
+ l, a, b = cv2.split(lab)
586
+
587
+ clahe = cv2.createCLAHE(
588
+ clipLimit=3.5,
589
+ tileGridSize=(8, 8)
590
+ )
591
+
592
+ l2 = clahe.apply(l)
593
+ lab2 = cv2.merge((l2, a, b))
594
+ enhanced = cv2.cvtColor(lab2, cv2.COLOR_LAB2BGR)
595
+
596
+ blur = cv2.GaussianBlur(enhanced, (0, 0), 1.0)
597
+ sharp = cv2.addWeighted(enhanced, 1.8, blur, -0.8, 0)
598
+
599
+ return sharp
600
+
601
+
602
+ def find_trap_floor_crop(image):
603
+ h, w = image.shape[:2]
604
+
605
+ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
606
+ blur = cv2.GaussianBlur(gray, (7, 7), 0)
607
+
608
+ _, thresh = cv2.threshold(
609
+ blur,
610
+ 0,
611
+ 255,
612
+ cv2.THRESH_BINARY + cv2.THRESH_OTSU
613
+ )
614
+
615
+ contours, _ = cv2.findContours(
616
+ thresh,
617
+ cv2.RETR_EXTERNAL,
618
+ cv2.CHAIN_APPROX_SIMPLE
619
+ )
620
+
621
+ if contours:
622
+ contours = sorted(contours, key=cv2.contourArea, reverse=True)
623
+
624
+ for cnt in contours[:10]:
625
+ x, y, cw, ch = cv2.boundingRect(cnt)
626
+ area = cw * ch
627
+ image_area = w * h
628
+
629
+ if area > image_area * 0.16 and cw > w * 0.25 and ch > h * 0.25:
630
+ pad = 2
631
+ x1 = max(0, x - pad)
632
+ y1 = max(0, y - pad)
633
+ x2 = min(w, x + cw + pad)
634
+ y2 = min(h, y + ch + pad)
635
+ return image[y1:y2, x1:x2].copy(), x1, y1
636
+
637
+ x1 = int(w * 0.16)
638
+ y1 = int(h * 0.24)
639
+ x2 = int(w * 0.82)
640
+ y2 = int(h * 0.82)
641
+
642
+ return image[y1:y2, x1:x2].copy(), x1, y1
643
+
644
+
645
+ # =========================================================
646
+ # YOLO DETECTION
647
+ # =========================================================
648
+
649
+ def yolo_predict(image, offset_x=0, offset_y=0, source_name="image"):
650
+ results = model.predict(
651
+ source=image,
652
+ imgsz=YOLO_IMAGE_SIZE,
653
+ conf=YOLO_CONFIDENCE,
654
+ iou=YOLO_IOU,
655
+ verbose=False
656
+ )
657
+
658
+ detections = []
659
+
660
+ if not results:
661
+ return detections
662
+
663
+ result = results[0]
664
+
665
+ if result.boxes is None or len(result.boxes) == 0:
666
+ return detections
667
+
668
+ names = result.names
669
+
670
+ for box in result.boxes:
671
+ cls_id = int(box.cls[0].item())
672
+ confidence = float(box.conf[0].item())
673
+ label = safe_label(names.get(cls_id, str(cls_id)))
674
+
675
+ xyxy = box.xyxy[0].cpu().numpy().astype(float)
676
+ x1, y1, x2, y2 = xyxy.tolist()
677
+
678
+ detections.append({
679
+ "type": label,
680
+ "confidence": round(confidence, 4),
681
+ "source": source_name,
682
+ "box": {
683
+ "x1": round(x1 + offset_x, 2),
684
+ "y1": round(y1 + offset_y, 2),
685
+ "x2": round(x2 + offset_x, 2),
686
+ "y2": round(y2 + offset_y, 2)
687
+ }
688
+ })
689
+
690
+ return detections
691
+
692
+
693
+ def yolo_tiled(image, offset_x=0, offset_y=0):
694
+ detections = []
695
+
696
+ h, w = image.shape[:2]
697
+
698
+ tile_size = 448
699
+ overlap = 220
700
+ step = tile_size - overlap
701
+
702
+ y_positions = list(range(0, max(1, h - tile_size + 1), step))
703
+ x_positions = list(range(0, max(1, w - tile_size + 1), step))
704
+
705
+ if not y_positions:
706
+ y_positions = [0]
707
+
708
+ if not x_positions:
709
+ x_positions = [0]
710
+
711
+ last_y = max(0, h - tile_size)
712
+ last_x = max(0, w - tile_size)
713
+
714
+ if y_positions[-1] != last_y:
715
+ y_positions.append(last_y)
716
+
717
+ if x_positions[-1] != last_x:
718
+ x_positions.append(last_x)
719
+
720
+ for y in y_positions:
721
+ for x in x_positions:
722
+ tile = image[y:y + tile_size, x:x + tile_size].copy()
723
+
724
+ tile_detections = yolo_predict(
725
+ tile,
726
+ offset_x=offset_x + x,
727
+ offset_y=offset_y + y,
728
+ source_name="tile"
729
+ )
730
+
731
+ detections.extend(tile_detections)
732
+
733
+ return detections
734
+
735
+
736
+ def filter_bad_yolo_boxes(detections, image_width, image_height):
737
+ filtered = []
738
+ image_area = image_width * image_height
739
+
740
+ for det in detections:
741
+ box = get_box(det)
742
+ area_ratio = box_area(box) / max(image_area, 1)
743
+ conf = det["confidence"]
744
+
745
+ bw = box[2] - box[0]
746
+ bh = box[3] - box[1]
747
+
748
+ if bw < 4 or bh < 4:
749
+ continue
750
+
751
+ if area_ratio > MAX_YOLO_BOX_AREA_RATIO:
752
+ continue
753
+
754
+ if conf < LOW_CONF_LARGE_BOX_CONF and area_ratio > LOW_CONF_LARGE_BOX_AREA_RATIO:
755
+ continue
756
+
757
+ filtered.append(det)
758
+
759
+ return filtered
760
+
761
+
762
+ # =========================================================
763
+ # COMPUTER VISION PROPOSAL DETECTOR
764
+ # =========================================================
765
+
766
+ def create_floor_mask(floor_crop):
767
+ enhanced = enhance_image(floor_crop)
768
+
769
+ gray = cv2.cvtColor(enhanced, cv2.COLOR_BGR2GRAY)
770
+ gray = cv2.GaussianBlur(gray, (3, 3), 0)
771
+
772
+ local_bg = cv2.GaussianBlur(gray, (0, 0), 19)
773
+ dark_diff = cv2.subtract(local_bg, gray)
774
+ _, dark_mask = cv2.threshold(dark_diff, 5, 255, cv2.THRESH_BINARY)
775
+
776
+ adaptive = cv2.adaptiveThreshold(
777
+ gray,
778
+ 255,
779
+ cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
780
+ cv2.THRESH_BINARY_INV,
781
+ 31,
782
+ 3
783
+ )
784
+
785
+ edges = cv2.Canny(gray, 18, 75)
786
+ edges = cv2.dilate(edges, np.ones((2, 2), np.uint8), iterations=1)
787
+
788
+ blackhat_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (19, 19))
789
+ blackhat = cv2.morphologyEx(gray, cv2.MORPH_BLACKHAT, blackhat_kernel)
790
+ _, blackhat_mask = cv2.threshold(blackhat, 4, 255, cv2.THRESH_BINARY)
791
+
792
+ lab = cv2.cvtColor(enhanced, cv2.COLOR_BGR2LAB)
793
+ l, a, b = cv2.split(lab)
794
+
795
+ bg_l = cv2.GaussianBlur(l, (0, 0), 21)
796
+ bg_a = cv2.GaussianBlur(a, (0, 0), 21)
797
+ bg_b = cv2.GaussianBlur(b, (0, 0), 21)
798
+
799
+ diff_l = cv2.absdiff(l, bg_l)
800
+ diff_a = cv2.absdiff(a, bg_a)
801
+ diff_b = cv2.absdiff(b, bg_b)
802
+
803
+ color_diff = cv2.addWeighted(diff_l, 0.45, diff_a, 0.30, 0)
804
+ color_diff = cv2.addWeighted(color_diff, 1.0, diff_b, 0.25, 0)
805
+
806
+ _, color_mask = cv2.threshold(color_diff, 7, 255, cv2.THRESH_BINARY)
807
+
808
+ combined = cv2.bitwise_or(dark_mask, adaptive)
809
+ combined = cv2.bitwise_or(combined, edges)
810
+ combined = cv2.bitwise_or(combined, blackhat_mask)
811
+ combined = cv2.bitwise_or(combined, color_mask)
812
+
813
+ k2 = np.ones((2, 2), np.uint8)
814
+ k3 = np.ones((3, 3), np.uint8)
815
+
816
+ combined = cv2.morphologyEx(combined, cv2.MORPH_OPEN, k2, iterations=1)
817
+ combined = cv2.morphologyEx(combined, cv2.MORPH_CLOSE, k3, iterations=1)
818
+ combined = cv2.dilate(combined, k3, iterations=1)
819
+
820
+ return combined
821
+
822
+
823
+ def score_candidate(floor_crop, x, y, w, h):
824
+ crop_h, crop_w = floor_crop.shape[:2]
825
+
826
+ x1 = max(0, x)
827
+ y1 = max(0, y)
828
+ x2 = min(crop_w, x + w)
829
+ y2 = min(crop_h, y + h)
830
+
831
+ roi = floor_crop[y1:y2, x1:x2]
832
+
833
+ if roi.size == 0:
834
+ return 0.0
835
+
836
+ gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
837
+
838
+ contrast = float(np.std(gray))
839
+ mean_darkness = 255.0 - float(np.mean(gray))
840
+
841
+ edges = cv2.Canny(gray, 20, 80)
842
+ edge_density = cv2.countNonZero(edges) / max(1, edges.shape[0] * edges.shape[1])
843
+
844
+ area = w * h
845
+ aspect = w / max(h, 1)
846
+
847
+ score = 0.0
848
+
849
+ if contrast >= 5:
850
+ score += 0.25
851
+
852
+ if contrast >= 10:
853
+ score += 0.25
854
+
855
+ if mean_darkness >= 35:
856
+ score += 0.20
857
+
858
+ if edge_density >= 0.015:
859
+ score += 0.20
860
+
861
+ if 0.12 <= aspect <= 8.5:
862
+ score += 0.10
863
+
864
+ if area >= 20:
865
+ score += 0.10
866
+
867
+ return min(score, 0.95)
868
+
869
+
870
+ def proposal_detections_from_floor(original, floor_crop, floor_x, floor_y):
871
+ detections = []
872
+
873
+ mask = create_floor_mask(floor_crop)
874
+
875
+ crop_h, crop_w = floor_crop.shape[:2]
876
+ original_h, original_w = original.shape[:2]
877
+ crop_area = crop_w * crop_h
878
+
879
+ contours, _ = cv2.findContours(
880
+ mask,
881
+ cv2.RETR_EXTERNAL,
882
+ cv2.CHAIN_APPROX_SIMPLE
883
+ )
884
+
885
+ max_area = crop_area * PROPOSAL_MAX_AREA_RATIO
886
+ max_w = crop_w * PROPOSAL_MAX_WIDTH_RATIO
887
+ max_h = crop_h * PROPOSAL_MAX_HEIGHT_RATIO
888
+
889
+ for cnt in contours:
890
+ x, y, bw, bh = cv2.boundingRect(cnt)
891
+ area = cv2.contourArea(cnt)
892
+ box_area_local = bw * bh
893
+
894
+ if area < PROPOSAL_MIN_AREA:
895
+ continue
896
+
897
+ if box_area_local > max_area:
898
+ continue
899
+
900
+ if bw < PROPOSAL_MIN_WIDTH or bh < PROPOSAL_MIN_HEIGHT:
901
+ continue
902
+
903
+ if bw > max_w or bh > max_h:
904
+ continue
905
+
906
+ aspect = bw / max(bh, 1)
907
+
908
+ if aspect < 0.08 or aspect > 11.0:
909
+ continue
910
+
911
+ score = score_candidate(floor_crop, x, y, bw, bh)
912
+
913
+ if score < 0.22:
914
+ continue
915
+
916
+ x1 = floor_x + x
917
+ y1 = floor_y + y
918
+ x2 = floor_x + x + bw
919
+ y2 = floor_y + y + bh
920
+
921
+ x1, y1, x2, y2 = expand_box(
922
+ [x1, y1, x2, y2],
923
+ pad=4,
924
+ width=original_w,
925
+ height=original_h
926
+ )
927
+
928
+ detections.append({
929
+ "type": "unknown_pest",
930
+ "confidence": round(max(0.18, min(score, 0.60)), 4),
931
+ "source": "cv_proposal_counter",
932
+ "box": {
933
+ "x1": round(x1, 2),
934
+ "y1": round(y1, 2),
935
+ "x2": round(x2, 2),
936
+ "y2": round(y2, 2)
937
+ }
938
+ })
939
+
940
+ detections = nms_detections(
941
+ detections,
942
+ iou_threshold=0.10,
943
+ class_aware=False
944
+ )
945
+
946
+ return detections, mask
947
+
948
+
949
+ def remove_unknown_duplicates(yolo_detections, unknown_detections):
950
+ good_yolo = [
951
+ d for d in yolo_detections
952
+ if d["confidence"] >= GOOD_YOLO_CONFIDENCE
953
+ ]
954
+
955
+ cleaned = []
956
+
957
+ for unknown in unknown_detections:
958
+ ub = get_box(unknown)
959
+
960
+ duplicate = False
961
+
962
+ for known in good_yolo:
963
+ kb = get_box(known)
964
+
965
+ overlap = intersection_ratio_small(ub, kb)
966
+
967
+ if overlap >= UNKNOWN_OVERLAP_WITH_GOOD_YOLO:
968
+ duplicate = True
969
+ break
970
+
971
+ if not duplicate:
972
+ cleaned.append(unknown)
973
+
974
+ return cleaned
975
+
976
+
977
+ # =========================================================
978
+ # FULL DETECTION PIPELINE
979
+ # =========================================================
980
+
981
+ def run_detection_pipeline(image_path: Path):
982
+ image_path = Path(image_path)
983
+
984
+ if not image_path.exists():
985
+ raise HTTPException(
986
+ status_code=500,
987
+ detail=f"Uploaded image file does not exist before processing: {image_path}"
988
+ )
989
+
990
+ original = cv2.imread(str(image_path))
991
+
992
+ if original is None:
993
+ raise HTTPException(status_code=400, detail="Unable to read uploaded image.")
994
+
995
+ original = remove_red_markup_if_present(original)
996
+
997
+ h, w = original.shape[:2]
998
+
999
+ floor_crop, floor_x, floor_y = find_trap_floor_crop(original)
1000
+ enhanced_floor = enhance_image(floor_crop)
1001
+
1002
+ yolo_detections = []
1003
+
1004
+ yolo_detections.extend(
1005
+ yolo_predict(
1006
+ original,
1007
+ offset_x=0,
1008
+ offset_y=0,
1009
+ source_name="original"
1010
+ )
1011
+ )
1012
+
1013
+ yolo_detections.extend(
1014
+ yolo_predict(
1015
+ floor_crop,
1016
+ offset_x=floor_x,
1017
+ offset_y=floor_y,
1018
+ source_name="trap_floor"
1019
+ )
1020
+ )
1021
+
1022
+ yolo_detections.extend(
1023
+ yolo_predict(
1024
+ enhanced_floor,
1025
+ offset_x=floor_x,
1026
+ offset_y=floor_y,
1027
+ source_name="enhanced_floor"
1028
+ )
1029
+ )
1030
+
1031
+ yolo_detections.extend(
1032
+ yolo_tiled(
1033
+ enhanced_floor,
1034
+ offset_x=floor_x,
1035
+ offset_y=floor_y
1036
+ )
1037
+ )
1038
+
1039
+ fixed_yolo = []
1040
+
1041
+ for det in yolo_detections:
1042
+ b = det["box"]
1043
+
1044
+ x1, y1, x2, y2 = clamp_box(
1045
+ [b["x1"], b["y1"], b["x2"], b["y2"]],
1046
+ width=w,
1047
+ height=h
1048
+ )
1049
+
1050
+ det["box"] = {
1051
+ "x1": round(x1, 2),
1052
+ "y1": round(y1, 2),
1053
+ "x2": round(x2, 2),
1054
+ "y2": round(y2, 2)
1055
+ }
1056
+
1057
+ fixed_yolo.append(det)
1058
+
1059
+ yolo_detections = filter_bad_yolo_boxes(
1060
+ fixed_yolo,
1061
+ image_width=w,
1062
+ image_height=h
1063
+ )
1064
+
1065
+ yolo_detections = nms_detections(
1066
+ yolo_detections,
1067
+ iou_threshold=0.20,
1068
+ class_aware=True
1069
+ )
1070
+
1071
+ unknown_detections, proposal_mask = proposal_detections_from_floor(
1072
+ original,
1073
+ floor_crop,
1074
+ floor_x,
1075
+ floor_y
1076
+ )
1077
+
1078
+ unknown_detections = remove_unknown_duplicates(
1079
+ yolo_detections,
1080
+ unknown_detections
1081
+ )
1082
+
1083
+ final_detections = []
1084
+ final_detections.extend(yolo_detections)
1085
+ final_detections.extend(unknown_detections)
1086
+
1087
+ final_detections = nms_detections(
1088
+ final_detections,
1089
+ iou_threshold=FINAL_NMS_IOU,
1090
+ class_aware=False
1091
+ )
1092
+
1093
+ return final_detections, original, proposal_mask
1094
+
1095
+
1096
+ # =========================================================
1097
+ # DRAWING AND RESPONSE HELPERS
1098
+ # =========================================================
1099
+
1100
+ def draw_annotated_image(original_image, image_path: Path, detections):
1101
+ RESULT_DIR.mkdir(parents=True, exist_ok=True)
1102
+
1103
+ image = original_image.copy()
1104
+
1105
+ for det in detections:
1106
+ box = det["box"]
1107
+ label = det["type"]
1108
+ confidence = det["confidence"]
1109
+
1110
+ x1 = int(box["x1"])
1111
+ y1 = int(box["y1"])
1112
+ x2 = int(box["x2"])
1113
+ y2 = int(box["y2"])
1114
+
1115
+ color = ORANGE if label == "unknown_pest" else GREEN
1116
+
1117
+ text = f"{label} {confidence:.2f}"
1118
+
1119
+ cv2.rectangle(image, (x1, y1), (x2, y2), color, 2)
1120
+
1121
+ font = cv2.FONT_HERSHEY_SIMPLEX
1122
+ font_scale = 0.55
1123
+ thickness = 2
1124
+
1125
+ text_size, _ = cv2.getTextSize(text, font, font_scale, thickness)
1126
+ text_w, text_h = text_size
1127
+
1128
+ label_y1 = max(y1 - text_h - 10, 0)
1129
+ label_y2 = max(y1, text_h + 12)
1130
+
1131
+ cv2.rectangle(
1132
+ image,
1133
+ (x1, label_y1),
1134
+ (min(x1 + text_w + 8, image.shape[1] - 1), label_y2),
1135
+ color,
1136
+ -1
1137
+ )
1138
+
1139
+ cv2.putText(
1140
+ image,
1141
+ text,
1142
+ (x1 + 4, max(y1 - 6, text_h + 4)),
1143
+ font,
1144
+ font_scale,
1145
+ BLACK,
1146
+ thickness,
1147
+ cv2.LINE_AA
1148
+ )
1149
+
1150
+ output_name = f"result_{image_path.stem}.jpg"
1151
+ output_path = RESULT_DIR / output_name
1152
+
1153
+ success = cv2.imwrite(str(output_path), image)
1154
+
1155
+ if not success or not output_path.exists():
1156
+ raise HTTPException(
1157
+ status_code=500,
1158
+ detail=f"Failed to save annotated image: {output_path}"
1159
+ )
1160
+
1161
+ return output_path
1162
+
1163
+
1164
+ def save_debug_mask(image_path: Path, mask):
1165
+ if mask is None:
1166
+ return None
1167
+
1168
+ DEBUG_DIR.mkdir(parents=True, exist_ok=True)
1169
+
1170
+ output_name = f"mask_{image_path.stem}.jpg"
1171
+ output_path = DEBUG_DIR / output_name
1172
+
1173
+ success = cv2.imwrite(str(output_path), mask)
1174
+
1175
+ if not success or not output_path.exists():
1176
+ print(f"[DEBUG] Failed to save debug mask: {output_path}")
1177
+ return None
1178
+
1179
+ return output_path
1180
+
1181
+
1182
+ def build_summary(detections):
1183
+ counts = Counter(det["type"] for det in detections)
1184
+
1185
+ return [
1186
+ {
1187
+ "type": pest_type,
1188
+ "count": count
1189
+ }
1190
+ for pest_type, count in sorted(counts.items())
1191
+ ]
1192
+
1193
+
1194
+ def get_local_image_urls(request: Request, uploaded_path: Path, result_image_path: Path, debug_mask_path: Path | None):
1195
+ base_url = get_base_url(request)
1196
+
1197
+ original_image_url = f"{base_url}/uploads/{uploaded_path.name}"
1198
+ annotated_image_url = f"{base_url}/results/{result_image_path.name}"
1199
+
1200
+ debug_mask_url = None
1201
+ if debug_mask_path is not None:
1202
+ debug_mask_url = f"{base_url}/debug/{debug_mask_path.name}"
1203
+
1204
+ return original_image_url, annotated_image_url, debug_mask_url
1205
+
1206
+
1207
+ # =========================================================
1208
+ # FIREBASE LOG FUNCTIONS
1209
+ # =========================================================
1210
+
1211
+ def firebase_logs_ref():
1212
+ return db.reference(FIREBASE_LOGS_PATH)
1213
+
1214
+
1215
+ def save_analysis_log_to_firebase(log_payload: dict):
1216
+ if not firebase_ready:
1217
+ return None
1218
+
1219
+ ref = firebase_logs_ref().push()
1220
+ log_id = ref.key
1221
+
1222
+ log_payload["id"] = log_id
1223
+ log_payload["firebase_saved"] = True
1224
+ log_payload["firebase_path"] = f"{FIREBASE_LOGS_PATH}/{log_id}"
1225
+
1226
+ ref.set(log_payload)
1227
+
1228
+ return log_id
1229
+
1230
+
1231
+ def get_all_logs_from_firebase():
1232
+ if not firebase_ready:
1233
+ raise HTTPException(
1234
+ status_code=503,
1235
+ detail="Firebase is not initialized. Check FIREBASE_DATABASE_URL and service account."
1236
+ )
1237
+
1238
+ raw = firebase_logs_ref().get()
1239
+
1240
+ if not raw:
1241
+ return []
1242
+
1243
+ logs = []
1244
+
1245
+ for key, value in raw.items():
1246
+ if not isinstance(value, dict):
1247
+ continue
1248
+
1249
+ item = value
1250
+ item["id"] = value.get("id", key)
1251
+ logs.append(item)
1252
+
1253
+ return logs
1254
+
1255
+
1256
+ def get_log_from_firebase(log_id: str):
1257
+ if not firebase_ready:
1258
+ raise HTTPException(
1259
+ status_code=503,
1260
+ detail="Firebase is not initialized. Check FIREBASE_DATABASE_URL and service account."
1261
+ )
1262
+
1263
+ item = firebase_logs_ref().child(log_id).get()
1264
+
1265
+ if not item:
1266
+ raise HTTPException(status_code=404, detail="Log not found")
1267
+
1268
+ item["id"] = item.get("id", log_id)
1269
+ return item
1270
+
1271
+
1272
+ def parse_date_filter(value: str | None, end_of_day=False):
1273
+ if not value:
1274
+ return None
1275
+
1276
+ try:
1277
+ if len(value) == 10:
1278
+ parsed = datetime.strptime(value, "%Y-%m-%d")
1279
+ if end_of_day:
1280
+ parsed = parsed.replace(hour=23, minute=59, second=59, microsecond=999000)
1281
+ return parsed
1282
+
1283
+ return datetime.fromisoformat(value)
1284
+
1285
+ except Exception:
1286
+ raise HTTPException(
1287
+ status_code=400,
1288
+ detail=f"Invalid date format: {value}. Use YYYY-MM-DD or ISO datetime."
1289
+ )
1290
+
1291
+
1292
+ def filter_logs(
1293
+ logs,
1294
+ pest_type=None,
1295
+ date_from=None,
1296
+ date_to=None,
1297
+ min_total=None,
1298
+ max_total=None,
1299
+ search=None
1300
+ ):
1301
+ date_from_dt = parse_date_filter(date_from, end_of_day=False)
1302
+ date_to_dt = parse_date_filter(date_to, end_of_day=True)
1303
+
1304
+ filtered = []
1305
+
1306
+ for item in logs:
1307
+ total = int(item.get("total", 0) or 0)
1308
+
1309
+ if min_total is not None and total < min_total:
1310
+ continue
1311
+
1312
+ if max_total is not None and total > max_total:
1313
+ continue
1314
+
1315
+ timestamp_ms = item.get("timestamp_ms")
1316
+
1317
+ if timestamp_ms:
1318
+ item_dt = datetime.fromtimestamp(int(timestamp_ms) / 1000)
1319
+ else:
1320
+ item_dt = None
1321
+
1322
+ if date_from_dt and item_dt and item_dt < date_from_dt:
1323
+ continue
1324
+
1325
+ if date_to_dt and item_dt and item_dt > date_to_dt:
1326
+ continue
1327
+
1328
+ data = item.get("data", [])
1329
+ detections = item.get("detections", [])
1330
+
1331
+ if pest_type:
1332
+ wanted = pest_type.lower().strip()
1333
+ found_type = False
1334
+
1335
+ for row in data:
1336
+ if str(row.get("type", "")).lower().strip() == wanted:
1337
+ found_type = True
1338
+ break
1339
+
1340
+ for det in detections:
1341
+ if str(det.get("type", "")).lower().strip() == wanted:
1342
+ found_type = True
1343
+ break
1344
+
1345
+ if not found_type:
1346
+ continue
1347
+
1348
+ if search:
1349
+ s = search.lower().strip()
1350
+ haystack = json.dumps(item, ensure_ascii=False).lower()
1351
+ if s not in haystack:
1352
+ continue
1353
+
1354
+ filtered.append(item)
1355
+
1356
+ return filtered
1357
+
1358
+
1359
+ def paginate_items(items, page, page_size):
1360
+ if page <= 0:
1361
+ page = 1
1362
+
1363
+ if page_size <= 0:
1364
+ page_size = 10
1365
+
1366
+ if page_size > 100:
1367
+ page_size = 100
1368
+
1369
+ total_items = len(items)
1370
+ total_pages = max(1, (total_items + page_size - 1) // page_size)
1371
+
1372
+ if page > total_pages:
1373
+ page_items = []
1374
+ else:
1375
+ start = (page - 1) * page_size
1376
+ end = start + page_size
1377
+ page_items = items[start:end]
1378
+
1379
+ return {
1380
+ "page": page,
1381
+ "page_size": page_size,
1382
+ "total_items": total_items,
1383
+ "total_pages": total_pages,
1384
+ "has_next": page < total_pages,
1385
+ "has_prev": page > 1,
1386
+ "items": page_items
1387
+ }
1388
+
1389
+
1390
+ def compact_log_item(item):
1391
+ return {
1392
+ "id": item.get("id"),
1393
+ "datatime": item.get("datatime"),
1394
+ "timestamp_ms": item.get("timestamp_ms"),
1395
+ "total": item.get("total", 0),
1396
+ "data": item.get("data", []),
1397
+ "annotated_image": item.get("annotated_image"),
1398
+ "original_image": item.get("original_image"),
1399
+ "debug_mask": item.get("debug_mask"),
1400
+ "cloudinary": item.get("cloudinary", {})
1401
+ }
1402
+
1403
+
1404
+ # =========================================================
1405
+ # DASHBOARD HELPERS
1406
+ # =========================================================
1407
+
1408
+ def build_dashboard_data(logs):
1409
+ logs = sorted(logs, key=lambda x: int(x.get("timestamp_ms", 0) or 0), reverse=True)
1410
+
1411
+ today = datetime.now().date()
1412
+ seven_days_ago = datetime.now() - timedelta(days=6)
1413
+
1414
+ total_logs = len(logs)
1415
+ total_pests = sum(int(item.get("total", 0) or 0) for item in logs)
1416
+
1417
+ today_logs = []
1418
+ last_7_days_logs = []
1419
+
1420
+ pest_counter = Counter()
1421
+ daily_counter = defaultdict(int)
1422
+ hourly_today_counter = defaultdict(int)
1423
+
1424
+ for item in logs:
1425
+ timestamp_ms = item.get("timestamp_ms")
1426
+
1427
+ if timestamp_ms:
1428
+ item_dt = datetime.fromtimestamp(int(timestamp_ms) / 1000)
1429
+ else:
1430
+ item_dt = None
1431
+
1432
+ item_total = int(item.get("total", 0) or 0)
1433
+
1434
+ for row in item.get("data", []):
1435
+ pest_counter[row.get("type", "unknown")] += int(row.get("count", 0) or 0)
1436
+
1437
+ if item_dt:
1438
+ day_key = item_dt.strftime("%Y-%m-%d")
1439
+ daily_counter[day_key] += item_total
1440
+
1441
+ if item_dt.date() == today:
1442
+ today_logs.append(item)
1443
+ hour_key = item_dt.strftime("%H:00")
1444
+ hourly_today_counter[hour_key] += item_total
1445
+
1446
+ if item_dt >= seven_days_ago:
1447
+ last_7_days_logs.append(item)
1448
+
1449
+ today_pests = sum(int(item.get("total", 0) or 0) for item in today_logs)
1450
+
1451
+ top_pests = [
1452
+ {
1453
+ "type": pest_type,
1454
+ "count": count
1455
+ }
1456
+ for pest_type, count in pest_counter.most_common(10)
1457
+ ]
1458
+
1459
+ daily_chart = []
1460
+
1461
+ for i in range(6, -1, -1):
1462
+ day = datetime.now() - timedelta(days=i)
1463
+ key = day.strftime("%Y-%m-%d")
1464
+ daily_chart.append(
1465
+ {
1466
+ "date": key,
1467
+ "total": daily_counter.get(key, 0)
1468
+ }
1469
+ )
1470
+
1471
+ hourly_chart = []
1472
+
1473
+ for hour in range(24):
1474
+ key = f"{hour:02d}:00"
1475
+ hourly_chart.append(
1476
+ {
1477
+ "hour": key,
1478
+ "total": hourly_today_counter.get(key, 0)
1479
+ }
1480
+ )
1481
+
1482
+ latest_log = logs[0] if logs else None
1483
+
1484
+ recent_logs = [compact_log_item(item) for item in logs[:10]]
1485
+
1486
+ return {
1487
+ "summary": {
1488
+ "total_logs": total_logs,
1489
+ "total_pests": total_pests,
1490
+ "today_logs": len(today_logs),
1491
+ "today_pests": today_pests,
1492
+ "last_7_days_logs": len(last_7_days_logs),
1493
+ "last_7_days_pests": sum(int(item.get("total", 0) or 0) for item in last_7_days_logs),
1494
+ "top_pests": top_pests
1495
+ },
1496
+ "chart": {
1497
+ "daily_last_7_days": daily_chart,
1498
+ "hourly_today": hourly_chart
1499
+ },
1500
+ "live_camera_stream": {
1501
+ "latest": compact_log_item(latest_log) if latest_log else None,
1502
+ "polling_route": "/api/live/latest",
1503
+ "note": "Use latest.annotated_image as the latest processed camera frame. Frontend can poll every 1 to 3 seconds."
1504
+ },
1505
+ "logs": recent_logs
1506
+ }
1507
+
1508
+
1509
+ # =========================================================
1510
+ # WEB UI ROUTES
1511
+ # =========================================================
1512
+
1513
+ @app.get("/", include_in_schema=False)
1514
+ def web_root():
1515
+ return RedirectResponse(url="/ui")
1516
+
1517
+
1518
+ @app.get("/ui", include_in_schema=False)
1519
+ def ui_dashboard():
1520
+ index_path = WEB_DIR / "index.html"
1521
+
1522
+ if not index_path.exists():
1523
+ raise HTTPException(
1524
+ status_code=404,
1525
+ detail=f"Missing web file: {index_path}"
1526
+ )
1527
+
1528
+ return FileResponse(index_path)
1529
+
1530
+
1531
+ @app.get("/ui/logs", include_in_schema=False)
1532
+ def ui_logs():
1533
+ logs_path = WEB_DIR / "logs.html"
1534
+
1535
+ if not logs_path.exists():
1536
+ raise HTTPException(
1537
+ status_code=404,
1538
+ detail=f"Missing web file: {logs_path}"
1539
+ )
1540
+
1541
+ return FileResponse(logs_path)
1542
+
1543
+
1544
+ @app.get("/ui/logs/{log_id}", include_in_schema=False)
1545
+ def ui_log_detail(log_id: str):
1546
+ detail_path = WEB_DIR / "detail.html"
1547
+
1548
+ if not detail_path.exists():
1549
+ raise HTTPException(
1550
+ status_code=404,
1551
+ detail=f"Missing web file: {detail_path}"
1552
+ )
1553
+
1554
+ return FileResponse(detail_path)
1555
+
1556
+
1557
+ @app.get("/ui/access", include_in_schema=False)
1558
+ def ui_access():
1559
+ access_path = WEB_DIR / "access.html"
1560
+
1561
+ if not access_path.exists():
1562
+ raise HTTPException(
1563
+ status_code=404,
1564
+ detail=f"Missing web file: {access_path}"
1565
+ )
1566
+
1567
+ return FileResponse(access_path)
1568
+
1569
+
1570
+ # =========================================================
1571
+ # API ROUTES
1572
+ # =========================================================
1573
+
1574
+ @app.get("/api/status")
1575
+ def api_status():
1576
+ return {
1577
+ "message": "Smart Pest Trap Detection API is running",
1578
+ "ui_route": "/ui",
1579
+ "analyze_route": "/api/analyze",
1580
+ "logs_route": "/api/logs",
1581
+ "log_detail_route": "/api/logs/{id}",
1582
+ "dashboard_route": "/api/dashboard",
1583
+ "live_latest_route": "/api/live/latest",
1584
+ "firebase_ready": firebase_ready,
1585
+ "cloudinary_ready": cloudinary_ready,
1586
+ "model_path": str(MODEL_PATH),
1587
+ "model_exists": MODEL_PATH.exists(),
1588
+ "auto_download_model": AUTO_DOWNLOAD_MODEL,
1589
+ "hf_model_repo": HF_MODEL_REPO,
1590
+ "hf_model_file": HF_MODEL_FILE,
1591
+ "firebase_logs_path": FIREBASE_LOGS_PATH,
1592
+ "cloudinary_folder": CLOUDINARY_FOLDER,
1593
+ "field_name": "image"
1594
+ }
1595
+
1596
+
1597
+ @app.post("/api/analyze")
1598
+ async def analyze_pest(request: Request, image: UploadFile = File(...)):
1599
+ try:
1600
+ ext = validate_image_file(image)
1601
+ uploaded_path = await save_upload(image, ext)
1602
+
1603
+ detections, processed_original, proposal_mask = run_detection_pipeline(uploaded_path)
1604
+
1605
+ result_image_path = draw_annotated_image(
1606
+ processed_original,
1607
+ uploaded_path,
1608
+ detections
1609
+ )
1610
+
1611
+ debug_mask_path = save_debug_mask(
1612
+ uploaded_path,
1613
+ proposal_mask
1614
+ )
1615
+
1616
+ data = build_summary(detections)
1617
+ total = len(detections)
1618
+
1619
+ local_original_url, local_annotated_url, local_debug_url = get_local_image_urls(
1620
+ request,
1621
+ uploaded_path,
1622
+ result_image_path,
1623
+ debug_mask_path
1624
+ )
1625
+
1626
+ original_cloud, annotated_cloud, debug_cloud = upload_analysis_images_to_cloudinary(
1627
+ uploaded_path,
1628
+ result_image_path,
1629
+ debug_mask_path
1630
+ )
1631
+
1632
+ original_image_url = original_cloud.get("secure_url") if original_cloud else local_original_url
1633
+ annotated_image_url = annotated_cloud.get("secure_url") if annotated_cloud else local_annotated_url
1634
+ debug_mask_url = debug_cloud.get("secure_url") if debug_cloud else local_debug_url
1635
+
1636
+ cloudinary_saved = bool(original_cloud and annotated_cloud)
1637
+
1638
+ log_payload = {
1639
+ "id": None,
1640
+ "datatime": now_string(),
1641
+ "created_at": now_iso(),
1642
+ "timestamp_ms": now_timestamp_ms(),
1643
+ "data": data,
1644
+ "total": total,
1645
+ "detections": detections,
1646
+
1647
+ "original_image": original_image_url,
1648
+ "annotated_image": annotated_image_url,
1649
+ "debug_mask": debug_mask_url,
1650
+
1651
+ "local_images": {
1652
+ "original_image": local_original_url,
1653
+ "annotated_image": local_annotated_url,
1654
+ "debug_mask": local_debug_url
1655
+ },
1656
+
1657
+ "image_files": {
1658
+ "original_filename": uploaded_path.name,
1659
+ "annotated_filename": result_image_path.name,
1660
+ "debug_mask_filename": debug_mask_path.name if debug_mask_path else None
1661
+ },
1662
+
1663
+ "cloudinary_saved": cloudinary_saved,
1664
+ "cloudinary": {
1665
+ "original": original_cloud,
1666
+ "annotated": annotated_cloud,
1667
+ "debug_mask": debug_cloud
1668
+ },
1669
+
1670
+ "firebase_saved": False,
1671
+ "firebase_path": None,
1672
+
1673
+ "note": "Green boxes are YOLO identified pests. Orange boxes are counted pest-like objects that the model could not identify."
1674
+ }
1675
+
1676
+ log_id = save_analysis_log_to_firebase(log_payload)
1677
+
1678
+ response = {
1679
+ "datatime": log_payload["datatime"],
1680
+ "id": log_id,
1681
+ "data": data,
1682
+ "total": total,
1683
+ "detections": detections,
1684
+ "original_image": original_image_url,
1685
+ "annotated_image": annotated_image_url,
1686
+ "debug_mask": debug_mask_url,
1687
+ "cloudinary_saved": cloudinary_saved,
1688
+ "firebase_saved": bool(log_id),
1689
+ "firebase_path": f"{FIREBASE_LOGS_PATH}/{log_id}" if log_id else None,
1690
+ "cloudinary": {
1691
+ "original": original_cloud,
1692
+ "annotated": annotated_cloud,
1693
+ "debug_mask": debug_cloud
1694
+ }
1695
+ }
1696
+
1697
+ return JSONResponse(content=response)
1698
+
1699
+ except HTTPException:
1700
+ raise
1701
+
1702
+ except Exception as e:
1703
+ raise HTTPException(
1704
+ status_code=500,
1705
+ detail=f"Analysis failed: {str(e)}"
1706
+ )
1707
+
1708
+
1709
+ @app.get("/api/logs")
1710
+ def list_logs(
1711
+ page: int = Query(1, description="Page number. If page=0, it becomes page=1."),
1712
+ page_size: int = Query(10, description="Items per page. Max 100."),
1713
+ pest_type: str | None = Query(None, description="Filter by pest type, example: unknown_pest"),
1714
+ date_from: str | None = Query(None, description="YYYY-MM-DD or ISO datetime"),
1715
+ date_to: str | None = Query(None, description="YYYY-MM-DD or ISO datetime"),
1716
+ min_total: int | None = Query(None),
1717
+ max_total: int | None = Query(None),
1718
+ search: str | None = Query(None),
1719
+ sort: str = Query("desc", description="desc or asc")
1720
+ ):
1721
+ logs = get_all_logs_from_firebase()
1722
+
1723
+ logs = filter_logs(
1724
+ logs,
1725
+ pest_type=pest_type,
1726
+ date_from=date_from,
1727
+ date_to=date_to,
1728
+ min_total=min_total,
1729
+ max_total=max_total,
1730
+ search=search
1731
+ )
1732
+
1733
+ reverse = sort.lower() != "asc"
1734
+
1735
+ logs = sorted(
1736
+ logs,
1737
+ key=lambda x: int(x.get("timestamp_ms", 0) or 0),
1738
+ reverse=reverse
1739
+ )
1740
+
1741
+ logs = [compact_log_item(item) for item in logs]
1742
+
1743
+ result = paginate_items(logs, page, page_size)
1744
+
1745
+ return {
1746
+ "success": True,
1747
+ "filters": {
1748
+ "pest_type": pest_type,
1749
+ "date_from": date_from,
1750
+ "date_to": date_to,
1751
+ "min_total": min_total,
1752
+ "max_total": max_total,
1753
+ "search": search,
1754
+ "sort": sort
1755
+ },
1756
+ **result
1757
+ }
1758
+
1759
+
1760
+ @app.get("/api/logs/{log_id}")
1761
+ def get_log_detail(log_id: str):
1762
+ item = get_log_from_firebase(log_id)
1763
+
1764
+ return {
1765
+ "success": True,
1766
+ "data": item
1767
+ }
1768
+
1769
+
1770
+ @app.get("/api/dashboard")
1771
+ @app.get("/api/dashboard/")
1772
+ def dashboard():
1773
+ logs = get_all_logs_from_firebase()
1774
+ dashboard_data = build_dashboard_data(logs)
1775
+
1776
+ return {
1777
+ "success": True,
1778
+ "datatime": now_string(),
1779
+ **dashboard_data
1780
+ }
1781
+
1782
+
1783
+ @app.get("/api/live/latest")
1784
+ def live_latest():
1785
+ logs = get_all_logs_from_firebase()
1786
+
1787
+ if not logs:
1788
+ return {
1789
+ "success": True,
1790
+ "latest": None
1791
+ }
1792
+
1793
+ logs = sorted(
1794
+ logs,
1795
+ key=lambda x: int(x.get("timestamp_ms", 0) or 0),
1796
+ reverse=True
1797
+ )
1798
+
1799
+ return {
1800
+ "success": True,
1801
+ "latest": compact_log_item(logs[0])
1802
+ }
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn[standard]
3
+ python-multipart
4
+ ultralytics
5
+ opencv-python
6
+ numpy
7
+ pillow
8
+ huggingface_hub
9
+ firebase-admin
10
+ python-dotenv
11
+ cloudinary
web/access.html ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="utf-8" />
6
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
7
+ <title>Access · Pest Detection AI</title>
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@600;700&display=swap"
11
+ rel="stylesheet">
12
+ <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" rel="stylesheet">
13
+ <link rel="stylesheet" href="/web/css/app.css">
14
+ </head>
15
+
16
+ <body>
17
+ <header class="topbar">
18
+ <div style="display:flex;align-items:center;gap:28px"><a class="brand" href="/ui">Pest Detection AI</a>
19
+ <nav class="nav"><a href="/ui">Dashboard</a><a href="/ui/logs">Logs</a><a class="active"
20
+ href="/ui/access">Access</a></nav>
21
+ </div>
22
+ <div class="avatar">AI</div>
23
+ </header>
24
+ <main class="shell">
25
+ <div class="label">Admin Portal</div>
26
+ <h1 style="font-size:36px;margin:4px 0 28px;color:var(--primary)">Access Management</h1>
27
+ <section class="card">
28
+ <div style="padding:0 24px;border-bottom:1px solid #dfe4e1;display:flex;gap:24px"><button class="btn"
29
+ style="border:0;border-bottom:2px solid var(--primary);border-radius:0">Access
30
+ Request</button><button class="btn" style="border:0;border-radius:0;color:#60716a">Users</button>
31
+ </div>
32
+ <div style="padding:24px">
33
+ <div class="toolbar" style="justify-content:space-between;margin-bottom:20px"><input class="input"
34
+ style="min-width:360px" placeholder="Search by name or email..."><button class="btn"><span
35
+ class="material-symbols-outlined">download</span> Export CSV</button></div>
36
+ <table class="table">
37
+ <thead>
38
+ <tr>
39
+ <th>Name</th>
40
+ <th>Email</th>
41
+ <th>Requested Role</th>
42
+ <th style="text-align:right">Action</th>
43
+ </tr>
44
+ </thead>
45
+ <tbody id="requests"></tbody>
46
+ </table>
47
+ </div>
48
+ </section>
49
+ <div class="grid stats" style="margin-top:24px">
50
+ <div class="card pad">
51
+ <div class="label">Total Users</div>
52
+ <div class="stat">1,248</div>
53
+ <div class="good">↗ +12% this month</div>
54
+ </div>
55
+ <div class="card pad">
56
+ <div class="label">Pending Invites</div>
57
+ <div class="stat">42</div>
58
+ <div class="sub">Expires in 24 hours</div>
59
+ </div>
60
+ <div class="card pad">
61
+ <div class="label">System Health</div>
62
+ <div class="stat">99.9%</div>
63
+ <div class="sub">Authentication uptime</div>
64
+ </div>
65
+ </div>
66
+ </main>
67
+ <script>const data = [['JD', 'Julian D\'Arby', 'j.darby@sentinel-ops.io', 'Site Supervisor'], ['MA', 'Marcus Aurel', 'm.aurel@agri-tech.com', 'Data Analyst'], ['SL', 'Sarah Lane', 'sarah.lane@orchardview.net', 'Viewer']]; document.getElementById('requests').innerHTML = data.map(r => `<tr><td><div style="display:flex;align-items:center;gap:12px"><div class="avatar">${r[0]}</div><b>${r[1]}</b></div></td><td>${r[2]}</td><td>${r[3]}</td><td style="text-align:right"><button class="btn primary">Approve</button> <button class="btn danger">Reject</button></td></tr>`).join('')</script>
68
+ </body>
69
+
70
+ </html>
web/css/app.css ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ :root{
2
+ --bg:#f8f9fa;--surface:#ffffff;--line:#dfe4e1;--muted:#4e616a;--muted2:#6b7680;--primary:#012d1d;--primary2:#06452e;--soft:#ecf8f1;--soft2:#f3f6f5;--danger:#ba1a1a;--orange:#f59e0b;--blue:#3b82f6;--purple:#a855f7;
3
+ }
4
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:#17201c;font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif}.mono,.label{font-family:"Space Grotesk",Inter,sans-serif}.material-symbols-outlined{font-variation-settings:'FILL' 0,'wght' 420,'GRAD' 0,'opsz' 24}.topbar{height:64px;background:#fff;border-bottom:1px solid #e7ece9;display:flex;align-items:center;justify-content:space-between;padding:0 28px;position:sticky;top:0;z-index:50}.brand{font-weight:800;color:var(--primary);font-size:20px;letter-spacing:-.02em}.nav{display:flex;gap:20px;align-items:center}.nav a{color:#64736c;text-decoration:none;font-weight:650;font-size:14px;padding:22px 0 18px;border-bottom:2px solid transparent}.nav a.active,.nav a:hover{color:var(--primary);border-color:var(--primary)}.avatar{width:34px;height:34px;border-radius:999px;border:2px solid #d9e2dd;background:linear-gradient(135deg,#d8efe1,#f0f5f2);display:grid;place-items:center;color:var(--primary);font-weight:800}.shell{max-width:1440px;margin:0 auto;padding:32px}.grid{display:grid;gap:24px}.stats{grid-template-columns:repeat(3,minmax(0,1fr))}.card{background:var(--surface);border:1px solid var(--line);border-radius:12px;box-shadow:0 2px 6px rgba(1,45,29,.035)}.card.pad{padding:22px}.label{text-transform:uppercase;font-size:12px;letter-spacing:.08em;font-weight:800;color:var(--muted)}.stat{font-family:"Space Grotesk";font-size:44px;line-height:1;color:var(--primary);font-weight:700}.sub{color:var(--muted2);font-size:14px}.good{color:#057347}.muted{color:var(--muted2)}.main-grid{grid-template-columns:7fr 3fr}.live-frame{position:relative;overflow:hidden;background:#07110d;border-radius:12px}.live-frame img{display:block;width:100%;height:100%;aspect-ratio:16/9;object-fit:cover}.badge{display:inline-flex;align-items:center;gap:8px;background:rgba(0,0,0,.55);backdrop-filter:blur(8px);color:#fff;border:1px solid rgba(255,255,255,.15);border-radius:999px;padding:8px 12px;font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.04em}.live-badge{position:absolute;top:16px;left:16px}.dot{width:8px;height:8px;border-radius:50%;background:#ef4444;display:inline-block}.pulse{animation:pulse 1.5s infinite}@keyframes pulse{50%{opacity:.35}}.side-list{padding:22px}.det-row{display:flex;align-items:center;justify-content:space-between;padding:14px 0}.det-left{display:flex;align-items:center;gap:12px}.det-dot{width:8px;height:8px;border-radius:50%;background:#10b981}.det-count{font-family:"Space Grotesk";font-size:24px;font-weight:700}.chart{height:260px;display:flex;align-items:end;gap:8px;padding:24px 18px 8px;background-image:radial-gradient(#dfe4e1 1px,transparent 1px);background-size:24px 24px}.bar{flex:1;background:#b8efd2;border-radius:4px 4px 0 0;min-height:4px;transition:.2s}.bar.hot{background:#059669}.section-head{display:flex;align-items:end;justify-content:space-between;margin-bottom:16px}.toolbar{display:flex;gap:10px;align-items:center}.input{border:1px solid #d8e0db;background:#fff;border-radius:10px;padding:12px 14px;font:inherit;outline:none}.input:focus{border-color:#0a6b45;box-shadow:0 0 0 3px rgba(5,115,71,.08)}.btn{border:1px solid #cfd8d3;border-radius:10px;background:#fff;color:#24352e;padding:10px 14px;font-weight:750;cursor:pointer;text-decoration:none;display:inline-flex;gap:8px;align-items:center}.btn:hover{background:#f2f7f4}.btn.primary{background:var(--primary);color:#fff;border-color:var(--primary)}.btn.danger{border-color:#f0b7b7;color:#a40000}.table{width:100%;border-collapse:collapse}.table th{text-align:left;background:#f1f4f3;color:#51625b;font-family:"Space Grotesk";font-size:12px;letter-spacing:.08em;text-transform:uppercase;padding:14px 18px}.table td{padding:16px 18px;border-top:1px solid #ecf0ee;vertical-align:middle}.thumb{width:64px;height:64px;border-radius:8px;object-fit:cover;background:#e8efeb;border:1px solid #dce5e0}.thumb.sm{width:52px;height:52px}.log-row{cursor:pointer}.log-row:hover{background:#f6fbf8}.confbar{width:90px;height:7px;background:#dfe8e3;border-radius:99px;overflow:hidden}.confbar span{display:block;height:100%;background:#057347}.pager{display:flex;justify-content:space-between;align-items:center;padding:16px 20px;background:#f3f6f5;border-top:1px solid #dfe4e1}.detail-grid{grid-template-columns:2fr 1fr}.hero-img{width:100%;aspect-ratio:16/10;object-fit:contain;background:#07110d;border-radius:12px}.metric-grid{grid-template-columns:repeat(2,1fr)}.metric{display:flex;gap:14px;align-items:center;padding:18px}.iconbox{width:48px;height:48px;border-radius:999px;background:#eaf8f0;display:grid;place-items:center;color:#057347}.break-row{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;border-top:1px solid #edf1ef}.dropzone{border:1px dashed #aab8b0;background:#fbfdfc;border-radius:14px;padding:18px;display:flex;flex-direction:column;gap:12px}.notice{background:#fff8e8;border:1px solid #f5d08a;color:#7a4d00;border-radius:10px;padding:12px 14px;font-size:13px}.loading{opacity:.6;pointer-events:none}.hide{display:none!important}.empty{padding:28px;text-align:center;color:#6d7a74}.mobile-only{display:none}@media(max-width:900px){.nav{display:none}.shell{padding:18px 14px 86px}.stats,.main-grid,.detail-grid,.metric-grid{grid-template-columns:1fr}.toolbar{flex-direction:column;align-items:stretch}.topbar{padding:0 16px}.table{min-width:760px}.mobile-only{display:block}.desktop-only{display:none}}
web/detail.html ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="utf-8" />
6
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
7
+ <title>Detection Detail · Pest Detection AI</title>
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@600;700&display=swap"
11
+ rel="stylesheet">
12
+ <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" rel="stylesheet">
13
+ <link rel="stylesheet" href="/web/css/app.css">
14
+ </head>
15
+
16
+ <body>
17
+ <header class="topbar">
18
+ <div style="display:flex;align-items:center;gap:14px"><a class="btn" href="/ui/logs"><span
19
+ class="material-symbols-outlined">arrow_back</span></a><a class="brand" href="/ui">Pest Detection
20
+ AI</a></div>
21
+ <div class="avatar">AI</div>
22
+ </header>
23
+ <main class="shell">
24
+ <div class="section-head">
25
+ <div>
26
+ <div class="label">Logs / <span id="eventId">...</span></div>
27
+ <h1 style="font-size:36px;margin:4px 0;color:var(--primary)">Detection Detail</h1>
28
+ </div>
29
+ <div class="toolbar"><a id="downloadBtn" class="btn" target="_blank"><span
30
+ class="material-symbols-outlined">download</span> Export Frame</a><button
31
+ class="btn primary"><span class="material-symbols-outlined">verified</span> Validate
32
+ Results</button></div>
33
+ </div>
34
+ <div class="grid detail-grid">
35
+ <section>
36
+ <div class="card pad"><img id="detailImage" class="hero-img" onerror="imgFallback(event)"></div>
37
+ <div class="grid metric-grid" style="margin-top:20px">
38
+ <div class="card metric">
39
+ <div class="iconbox"><span class="material-symbols-outlined">radar</span></div>
40
+ <div>
41
+ <div class="label">Detection Count</div>
42
+ <div id="detCount" class="stat" style="font-size:32px">0</div>
43
+ </div>
44
+ </div>
45
+ <div class="card metric">
46
+ <div class="iconbox"><span class="material-symbols-outlined">query_stats</span></div>
47
+ <div>
48
+ <div class="label">Avg Confidence</div>
49
+ <div id="avgConf" class="stat" style="font-size:32px">0%</div>
50
+ </div>
51
+ </div>
52
+ </div>
53
+ </section>
54
+ <aside class="grid">
55
+ <section class="card">
56
+ <div style="padding:16px 18px;border-bottom:1px solid #e5ebe7;background:#f3f6f5">
57
+ <div class="label">Event Metadata</div>
58
+ </div>
59
+ <div style="padding:20px">
60
+ <div class="label">Timestamp</div>
61
+ <p id="timestamp" style="font-weight:750"></p>
62
+ <hr style="border:0;border-top:1px solid #edf1ef;margin:18px 0">
63
+ <div class="label">Storage</div>
64
+ <p id="storage" class="sub"></p>
65
+ <p><a id="originalLink" target="_blank">Original image</a> · <a id="debugLink"
66
+ target="_blank">Debug mask</a></p>
67
+ </div>
68
+ </section>
69
+ <section class="card">
70
+ <div
71
+ style="padding:16px 18px;border-bottom:1px solid #e5ebe7;background:#f3f6f5;display:flex;justify-content:space-between">
72
+ <div class="label">Pest Breakdown</div><b id="breakTotal">0 total</b>
73
+ </div>
74
+ <div id="breakdown"></div>
75
+ </section>
76
+ <section class="card pad"><label class="label">Analyst Notes</label><textarea class="input"
77
+ style="width:100%;min-height:110px;margin-top:10px"
78
+ placeholder="Add manual observation note..."></textarea><button class="btn"
79
+ style="width:100%;justify-content:center;margin-top:12px">Save Analysis</button></section>
80
+ </aside>
81
+ </div>
82
+ </main>
83
+ <script src="/web/js/api.js"></script>
84
+ <script src="/web/js/detail.js"></script>
85
+ </body>
86
+
87
+ </html>
web/index.html ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="utf-8" />
6
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
7
+ <title>Pest Detection AI</title>
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@600;700&display=swap"
11
+ rel="stylesheet">
12
+ <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" rel="stylesheet">
13
+ <link rel="stylesheet" href="/web/css/app.css">
14
+ </head>
15
+
16
+ <body>
17
+ <header class="topbar">
18
+ <div style="display:flex;align-items:center;gap:28px"><a class="brand" href="/ui">Pest Detection AI</a>
19
+ <nav class="nav"><a class="active" href="/ui">Dashboard</a><a href="/ui/logs">Logs</a><a
20
+ href="/ui/access">Access</a></nav>
21
+ </div>
22
+ <div style="display:flex;align-items:center;gap:12px"><span
23
+ class="material-symbols-outlined muted">notifications</span>
24
+ <div class="avatar">AI</div>
25
+ </div>
26
+ </header>
27
+ <main class="shell grid">
28
+ <section class="grid stats">
29
+ <div class="card pad">
30
+ <div class="label">Detections</div>
31
+ <div><span id="todayPests" class="stat">0</span> <span class="sub">today</span></div>
32
+ <div id="topToday" class="good">Loading...</div>
33
+ </div>
34
+ <div class="card pad">
35
+ <div class="label">Biodiversity</div>
36
+ <div><span id="biodiversity" class="stat">0</span></div>
37
+ <div class="sub">Type of pest today</div>
38
+ </div>
39
+ <div class="card pad">
40
+ <div class="label">Trend Analysis</div>
41
+ <div><span id="dailyAvg" class="stat">0</span> <span class="sub">daily average</span></div>
42
+ <div id="monthInfo" class="sub">Last 7 days analysis</div>
43
+ </div>
44
+ </section>
45
+ <section class="grid main-grid">
46
+ <div class="live-frame card">
47
+ <div class="badge live-badge"><span class="dot pulse"></span> Live feed: latest analysis</div><img
48
+ id="liveImage" onerror="imgFallback(event)" alt="Latest AI pest detection">
49
+ <div style="position:absolute;right:16px;bottom:16px;display:flex;gap:8px"><a id="openLatest" href="#"
50
+ class="btn" style="background:rgba(255,255,255,.12);color:#fff;border-color:rgba(255,255,255,.18)"><span
51
+ class="material-symbols-outlined">open_in_new</span></a></div>
52
+ </div>
53
+ <aside class="card side-list">
54
+ <div class="section-head">
55
+ <div class="label">Live Detections</div>
56
+ <div class="sub" id="updateAge">--</div>
57
+ </div>
58
+ <div id="liveList"></div>
59
+ <div style="border-top:1px solid #edf1ef;margin-top:22px;padding-top:18px">
60
+ <div class="sub" style="display:flex;justify-content:space-between;font-weight:800"><span>Total
61
+ Load</span><span id="loadPct">0%</span></div>
62
+ <div style="height:7px;background:#e0e8e3;border-radius:99px;margin-top:8px;overflow:hidden">
63
+ <div id="loadBar" style="height:100%;width:0;background:#059669"></div>
64
+ </div>
65
+ </div>
66
+ </aside>
67
+ </section>
68
+ <section class="card pad">
69
+ <div class="section-head">
70
+ <div>
71
+ <div class="label">24-hour trend</div>
72
+ <div class="sub">Detection frequency from Firebase logs</div>
73
+ </div>
74
+ <div class="toolbar"><button class="btn primary">24H</button><a class="btn" href="/ui/logs">View Logs</a></div>
75
+ </div>
76
+ <div id="hourlyChart" class="chart"></div>
77
+ <div
78
+ style="display:flex;justify-content:space-between;padding:0 18px 10px;color:#7c8b84;font-size:12px;font-weight:700">
79
+ <span>00:00</span><span>04:00</span><span>08:00</span><span>12:00</span><span>16:00</span><span>20:00</span><span>23:59</span>
80
+ </div>
81
+ </section>
82
+ <section class="card">
83
+ <div
84
+ style="padding:20px 24px;border-bottom:1px solid #edf1ef;display:flex;justify-content:space-between;gap:14px;align-items:center">
85
+ <h2 style="margin:0;color:var(--primary);font-size:22px">Detection Logs</h2><input id="searchBox" class="input"
86
+ placeholder="Search logs, species, timestamps...">
87
+ </div>
88
+ <div id="recentLogs"></div>
89
+ <div class="pager"><span id="logCount" class="sub">Loading...</span><a class="btn" href="/ui/logs">Load More
90
+ Entries</a></div>
91
+ </section>
92
+ <section class="card pad">
93
+ <div class="section-head">
94
+ <div>
95
+ <div class="label">Manual Upload Test</div>
96
+ <div class="sub">Upload an image to /api/analyze, then save image to Cloudinary and log to Firebase.</div>
97
+ </div>
98
+ </div>
99
+ <div class="dropzone"><input id="uploadInput" type="file" accept="image/*" class="input"><button id="uploadBtn"
100
+ class="btn primary"><span class="material-symbols-outlined">upload</span> Analyze Image</button>
101
+ <div id="uploadStatus" class="sub"></div>
102
+ </div>
103
+ </section>
104
+ </main>
105
+ <script src="/web/js/api.js"></script>
106
+ <script src="/web/js/dashboard.js"></script>
107
+ </body>
108
+
109
+ </html>
web/js/api.js ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const API = {
2
+ async get(path){
3
+ const res = await fetch(path, {headers:{'Accept':'application/json'}});
4
+ if(!res.ok) throw new Error(await res.text());
5
+ return res.json();
6
+ },
7
+ async postImage(path, file){
8
+ const fd = new FormData();
9
+ fd.append('image', file);
10
+ const res = await fetch(path, {method:'POST', body:fd});
11
+ if(!res.ok) throw new Error(await res.text());
12
+ return res.json();
13
+ }
14
+ };
15
+ function fmtNum(n){ return Number(n||0).toLocaleString(); }
16
+ function pad2(n){ return String(n||0).padStart(2,'0'); }
17
+ function fmtDate(ms, fallback=''){
18
+ if(!ms) return fallback || '-';
19
+ const d = new Date(Number(ms));
20
+ return d.toLocaleString([], {year:'numeric',month:'short',day:'2-digit',hour:'2-digit',minute:'2-digit'});
21
+ }
22
+ function fmtTime(ms){ if(!ms) return '-'; return new Date(Number(ms)).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}); }
23
+ function primaryType(item){
24
+ const data = item?.data || [];
25
+ if(!data.length) return 'No pest';
26
+ return [...data].sort((a,b)=>(b.count||0)-(a.count||0))[0].type || 'unknown_pest';
27
+ }
28
+ function avgConfidence(item){
29
+ const dets = item?.detections || [];
30
+ if(!dets.length) return 0;
31
+ return dets.reduce((s,d)=>s + Number(d.confidence||0),0) / dets.length;
32
+ }
33
+ function confidenceText(v){ return `${Math.round(Number(v||0)*100)}%`; }
34
+ function imgFallback(e){
35
+ e.target.src='data:image/svg+xml;charset=UTF-8,'+encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="600" height="400"><rect width="100%" height="100%" fill="#e9efec"/><text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="#52655d" font-family="Arial" font-size="22">No image</text></svg>`);
36
+ }
37
+ function setText(id, value){ const el=document.getElementById(id); if(el) el.textContent=value; }
38
+ function setImg(id, src){ const el=document.getElementById(id); if(el){ el.src=src || ''; el.onerror=imgFallback; } }
39
+ function escapeHtml(s){ return String(s??'').replace(/[&<>"]/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
web/js/dashboard.js ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ let dashboardCache = null;
2
+ async function loadDashboard(){
3
+ const dash = await API.get('/api/dashboard'); dashboardCache = dash;
4
+ const s = dash.summary || {}; const chart = dash.chart || {};
5
+ setText('todayPests', fmtNum(s.today_pests));
6
+ setText('biodiversity', fmtNum((s.top_pests||[]).length));
7
+ const avg = (s.last_7_days_pests || 0) / 7; setText('dailyAvg', Math.round(avg));
8
+ const top = (s.top_pests||[])[0]; setText('topToday', top ? `${fmtNum(top.count)} ${top.type} total` : 'No detections yet');
9
+ setText('monthInfo', `${fmtNum(s.last_7_days_pests)} pests in last 7 days`);
10
+ renderLive(dash.live_camera_stream?.latest); renderChart(chart.hourly_today||[]); renderRecent(dash.logs||[]);
11
+ }
12
+ async function loadLatest(){ try{ const r=await API.get('/api/live/latest'); renderLive(r.latest); }catch(e){ console.warn(e); } }
13
+ function renderLive(item){
14
+ const list = document.getElementById('liveList');
15
+ if(!item){ setImg('liveImage',''); list.innerHTML='<div class="empty">No live detection yet</div>'; return; }
16
+ setImg('liveImage', item.annotated_image || item.original_image); document.getElementById('openLatest').href = `/ui/logs/${item.id}`;
17
+ const data = item.data || []; const total = item.total || 0; setText('updateAge', fmtTime(item.timestamp_ms));
18
+ list.innerHTML = data.length ? data.map((d,i)=>`<div class="det-row"><div class="det-left"><span class="det-dot" style="background:${['#10b981','#f59e0b','#3b82f6','#a855f7','#ef4444'][i%5]}"></span><span>${escapeHtml(d.type)}</span></div><span class="det-count">${pad2(d.count)}</span></div>`).join('') : '<div class="empty">No pest in latest frame</div>';
19
+ const pct = Math.min(100, Math.round((total/25)*100)); setText('loadPct', pct+'%'); document.getElementById('loadBar').style.width=pct+'%';
20
+ }
21
+ function renderChart(rows){
22
+ const el=document.getElementById('hourlyChart'); const max=Math.max(1,...rows.map(r=>r.total||0));
23
+ el.innerHTML = rows.map((r,i)=>`<div title="${r.hour}: ${r.total}" class="bar ${r.total===max?'hot':''}" style="height:${Math.max(4, (r.total/max)*92)}%"></div>`).join('');
24
+ }
25
+ function renderRecent(logs){
26
+ const root=document.getElementById('recentLogs'); setText('logCount', `Showing ${logs.length} latest entries`);
27
+ if(!logs.length){ root.innerHTML='<div class="empty">No logs found</div>'; return; }
28
+ root.innerHTML=logs.map(item=>{ const type=primaryType(item); const conf=avgConfidence(item); return `<div class="log-row" onclick="location.href='/ui/logs/${item.id}'" style="display:flex;align-items:center;padding:16px 20px;border-bottom:1px solid #f0f3f1"><img class="thumb" src="${item.annotated_image||item.original_image||''}" onerror="imgFallback(event)"><div style="margin-left:16px;flex:1"><b style="color:var(--primary)">${escapeHtml(type)}</b><div class="sub">Confidence ${confidenceText(conf)} · Total ${item.total||0}</div></div><div class="desktop-only sub" style="min-width:180px">${fmtDate(item.timestamp_ms,item.datatime)}</div><span class="material-symbols-outlined muted">chevron_right</span></div>`; }).join('');
29
+ }
30
+ document.getElementById('searchBox').addEventListener('input', async e=>{
31
+ const q=e.target.value.trim(); if(!q){ renderRecent(dashboardCache?.logs||[]); return; }
32
+ const r=await API.get(`/api/logs?page=1&page_size=10&search=${encodeURIComponent(q)}`); renderRecent(r.items||[]);
33
+ });
34
+ document.getElementById('uploadBtn').addEventListener('click', async ()=>{
35
+ const file=document.getElementById('uploadInput').files[0]; if(!file){ setText('uploadStatus','Choose image first.'); return; }
36
+ document.body.classList.add('loading'); setText('uploadStatus','Analyzing image...');
37
+ try{ const r=await API.postImage('/api/analyze', file); setText('uploadStatus',`Saved. Total detected: ${r.total}. Opening detail...`); setTimeout(()=>location.href=`/ui/logs/${r.id}`,700); }
38
+ catch(e){ setText('uploadStatus','Failed: '+e.message); }
39
+ finally{ document.body.classList.remove('loading'); }
40
+ });
41
+ loadDashboard().catch(e=>{ console.error(e); document.body.insertAdjacentHTML('afterbegin',`<div class="notice">Dashboard error: ${escapeHtml(e.message)}</div>`); }); setInterval(loadLatest, 5000);
web/js/detail.js ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ function getId(){ return location.pathname.split('/').filter(Boolean).pop(); }
2
+ async function loadDetail(){
3
+ const id=getId(); setText('eventId', id); const r=await API.get(`/api/logs/${id}`); const item=r.data;
4
+ setImg('detailImage', item.annotated_image||item.original_image); document.getElementById('downloadBtn').href=item.annotated_image||item.original_image||'#'; document.getElementById('originalLink').href=item.original_image||'#'; document.getElementById('debugLink').href=item.debug_mask||'#';
5
+ setText('detCount', pad2(item.total||0)); const conf=avgConfidence(item); setText('avgConf', confidenceText(conf)); setText('timestamp', fmtDate(item.timestamp_ms,item.datatime)); setText('storage', item.cloudinary_saved ? 'Cloudinary permanent storage' : 'Local temporary storage'); setText('breakTotal', `${item.total||0} total`);
6
+ const rows=item.data||[]; document.getElementById('breakdown').innerHTML = rows.length ? rows.map(row=>`<div class="break-row"><div style="display:flex;align-items:center;gap:12px"><div class="iconbox" style="width:34px;height:34px"><span class="material-symbols-outlined" style="font-size:18px">bug_report</span></div><b>${escapeHtml(row.type)}</b></div><span class="det-count">${row.count}</span></div>`).join('') : '<div class="empty">No pest breakdown</div>';
7
+ }
8
+ loadDetail().catch(e=>alert(e.message));
web/js/logs.js ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ let page=1,pageSize=10,last=null;
2
+ async function loadLogs(){
3
+ const q=document.getElementById('search').value.trim(); const p=document.getElementById('pestType').value.trim();
4
+ const url=`/api/logs?page=${page}&page_size=${pageSize}${q?`&search=${encodeURIComponent(q)}`:''}${p?`&pest_type=${encodeURIComponent(p)}`:''}`;
5
+ const r=await API.get(url); last=r; renderLogs(r.items||[]); setText('pageInfo',`Showing page ${r.page} of ${r.total_pages} · ${r.total_items} records`); document.getElementById('prevBtn').disabled=!r.has_prev; document.getElementById('nextBtn').disabled=!r.has_next;
6
+ }
7
+ function renderLogs(items){
8
+ const tb=document.getElementById('logTable');
9
+ if(!items.length){ tb.innerHTML='<tr><td colspan="6"><div class="empty">No logs found</div></td></tr>'; return; }
10
+ tb.innerHTML=items.map(item=>{ const type=primaryType(item); const conf=avgConfidence(item); return `<tr class="log-row" onclick="location.href='/ui/logs/${item.id}'"><td><img class="thumb sm" src="${item.annotated_image||item.original_image||''}" onerror="imgFallback(event)"></td><td><b style="color:var(--primary)">${escapeHtml(type)}</b><div class="sub">${escapeHtml(item.id||'')}</div></td><td><div style="display:flex;align-items:center;gap:10px"><div class="confbar"><span style="width:${Math.round(conf*100)}%"></span></div><b>${confidenceText(conf)}</b></div></td><td>${item.total||0}</td><td>${fmtDate(item.timestamp_ms,item.datatime)}</td><td style="text-align:right"><span class="material-symbols-outlined muted">open_in_new</span></td></tr>`; }).join('');
11
+ }
12
+ document.getElementById('filterBtn').onclick=()=>{page=1;loadLogs().catch(alert)}; document.getElementById('search').addEventListener('keydown',e=>{if(e.key==='Enter'){page=1;loadLogs().catch(alert)}}); document.getElementById('prevBtn').onclick=()=>{if(last?.has_prev){page--;loadLogs().catch(alert)}}; document.getElementById('nextBtn').onclick=()=>{if(last?.has_next){page++;loadLogs().catch(alert)}}; loadLogs().catch(e=>alert(e.message));
web/logs.html ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="utf-8" />
6
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
7
+ <title>Detection Logs · Pest Detection AI</title>
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Space+Grotesk:wght@600;700&display=swap"
11
+ rel="stylesheet">
12
+ <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined" rel="stylesheet">
13
+ <link rel="stylesheet" href="/web/css/app.css">
14
+ </head>
15
+
16
+ <body>
17
+ <header class="topbar">
18
+ <div style="display:flex;align-items:center;gap:28px"><a class="brand" href="/ui">Pest Detection AI</a>
19
+ <nav class="nav"><a href="/ui">Dashboard</a><a class="active" href="/ui/logs">Logs</a><a
20
+ href="/ui/access">Access</a></nav>
21
+ </div>
22
+ <div class="avatar">AI</div>
23
+ </header>
24
+ <main class="shell">
25
+ <div class="section-head">
26
+ <div>
27
+ <div class="label">Platform archive</div>
28
+ <h1 style="font-size:36px;margin:4px 0;color:var(--primary)">Detection Logs</h1>
29
+ <div class="sub">Cloudinary thumbnails from Firebase Realtime Database logs.</div>
30
+ </div>
31
+ </div>
32
+ <div class="card pad" style="margin-bottom:20px">
33
+ <div class="toolbar"><input id="search" class="input" style="flex:1"
34
+ placeholder="Filter by species, timestamp, or log id..."><input id="pestType" class="input"
35
+ placeholder="Pest type"><button id="filterBtn" class="btn primary"><span
36
+ class="material-symbols-outlined">filter_list</span> Filter</button></div>
37
+ </div>
38
+ <div class="card" style="overflow:hidden">
39
+ <div style="overflow-x:auto">
40
+ <table class="table">
41
+ <thead>
42
+ <tr>
43
+ <th>Entity</th>
44
+ <th>Identification</th>
45
+ <th>Confidence</th>
46
+ <th>Total</th>
47
+ <th>Timestamp</th>
48
+ <th style="text-align:right">Action</th>
49
+ </tr>
50
+ </thead>
51
+ <tbody id="logTable"></tbody>
52
+ </table>
53
+ </div>
54
+ <div class="pager"><span id="pageInfo" class="sub">Loading...</span>
55
+ <div class="toolbar"><button id="prevBtn" class="btn">Previous</button><button id="nextBtn"
56
+ class="btn">Next</button></div>
57
+ </div>
58
+ </div>
59
+ </main>
60
+ <script src="/web/js/api.js"></script>
61
+ <script src="/web/js/logs.js"></script>
62
+ </body>
63
+
64
+ </html>