File size: 14,914 Bytes
34b8f45
 
2172a53
 
 
 
34b8f45
 
 
2172a53
ad59333
34b8f45
2172a53
9d5f7bb
2172a53
9d5f7bb
2172a53
9d5f7bb
2172a53
 
 
 
 
 
 
 
9d5f7bb
2172a53
 
 
 
 
 
4ff92c4
9d5f7bb
2172a53
 
9d5f7bb
2172a53
 
9d5f7bb
2172a53
 
 
 
 
 
 
 
 
 
 
4ff92c4
2172a53
 
9d5f7bb
2172a53
9d5f7bb
2172a53
 
 
9d5f7bb
2172a53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9d5f7bb
2172a53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0ca5147
2172a53
 
 
0ca5147
9d5f7bb
2172a53
 
 
ad59333
8c74959
 
 
 
 
 
 
 
 
 
 
34b8f45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7e70af7
2172a53
 
34b8f45
 
 
 
 
 
 
 
 
 
 
4ff92c4
34b8f45
 
 
 
 
 
 
 
4ff92c4
34b8f45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4ff92c4
34b8f45
 
 
 
4ff92c4
34b8f45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4ff92c4
34b8f45
 
 
 
 
 
4ff92c4
34b8f45
 
 
 
 
 
 
 
4ff92c4
34b8f45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4ff92c4
34b8f45
 
 
 
4ff92c4
34b8f45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4ff92c4
34b8f45
 
 
 
 
 
4ff92c4
 
 
34b8f45
4ff92c4
34b8f45
 
 
4ff92c4
34b8f45
 
 
 
4ff92c4
34b8f45
 
 
 
 
 
 
 
 
 
 
 
9d5f7bb
2172a53
34b8f45
 
 
 
 
 
 
 
 
4ff92c4
34b8f45
 
 
 
 
 
4ff92c4
34b8f45
 
4ff92c4
34b8f45
 
 
 
 
ad59333
 
9d5f7bb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
# app.py
"""
Shoplifting detection Gradio app β€” robust startup:
- finds local model file (best.pt) anywhere in repo
- avoids writing to /data if not writable (chooses a writable fallback)
- sets YOLO_CONFIG_DIR to a writable dir to silence Ultralytics permission warnings
"""
import os
import time
import logging
import gradio as gr
import pandas as pd
import tempfile

# ensure deterministic logs
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# set YOLO_CONFIG_DIR to a writable folder inside the workspace to avoid Ultralytics permission warnings
YOLO_CONFIG_DIR = os.path.join(os.getcwd(), ".ultralytics")
os.environ.setdefault("YOLO_CONFIG_DIR", YOLO_CONFIG_DIR)
try:
    os.makedirs(YOLO_CONFIG_DIR, exist_ok=True)
    logger.info(f"YOLO_CONFIG_DIR set to: {YOLO_CONFIG_DIR}")
except Exception as e:
    logger.warning(f"Failed creating YOLO_CONFIG_DIR {YOLO_CONFIG_DIR}: {e}")

# ---- model config ----
MODEL_FILENAME = "best.pt"
# common expected locations (but we'll search recursively)
COMMON_MODEL_PATHS = [
    os.path.join(os.getcwd(), MODEL_FILENAME),
    os.path.join(os.getcwd(), "models", MODEL_FILENAME),
]

# ---- utility: choose writable path (for cache and outputs) ----
def choose_writable_path(preferred_paths, fallback_name):
    """
    Return first writable path from preferred_paths (creates if needed).
    Falls back to tempfile or current working dir subfolder.
    """
    for p in preferred_paths:
        if not p:
            continue
        try:
            os.makedirs(p, exist_ok=True)
            # quick write test
            test_path = os.path.join(p, f".write_test_{int(time.time())}")
            with open(test_path, "w") as f:
                f.write("ok")
            os.remove(test_path)
            logger.info(f"Using writable path: {p}")
            return p
        except Exception as e:
            logger.warning(f"Cannot use path '{p}': {e}")

    tmp_base = os.path.join(tempfile.gettempdir(), fallback_name)
    try:
        os.makedirs(tmp_base, exist_ok=True)
        logger.info(f"Falling back to temporary path: {tmp_base}")
        return tmp_base
    except Exception as e:
        cwd_fallback = os.path.join(os.getcwd(), fallback_name)
        try:
            os.makedirs(cwd_fallback, exist_ok=True)
            logger.info(f"Falling back to CWD path: {cwd_fallback}")
            return cwd_fallback
        except Exception as e2:
            raise RuntimeError(f"Failed to create fallback dirs: {e} / {e2}")

# Resolve HF cache & outputs using writable locations (do not assume /data exists or is writable)
PREFERRED_HF_HOME = os.getenv("HF_HOME")  # if user set in env vars
if not PREFERRED_HF_HOME:
    PREFERRED_HF_HOME = os.path.join(os.getcwd(), ".huggingface")  # default inside repo

HF_HOME_DIR = choose_writable_path([PREFERRED_HF_HOME, os.path.join(os.getcwd(), ".huggingface")], "hf_cache")
CACHE_DIR = os.path.join(HF_HOME_DIR, "hub")

PREFERRED_BASE_OUT = os.getenv("BASE_OUT") or os.path.join(os.getcwd(), "shoplift_outputs")
BASE_OUT = choose_writable_path([PREFERRED_BASE_OUT, os.path.join(os.getcwd(), "shoplift_outputs")], "shoplift_outputs")
os.makedirs(BASE_OUT, exist_ok=True)

logger.info(f"CACHE_DIR resolved to: {CACHE_DIR}")
logger.info(f"BASE_OUT resolved to: {BASE_OUT}")

# ---- prepare model path: search locally first (recommended) ----
def find_local_model():
    # 1) check common places
    for p in COMMON_MODEL_PATHS:
        try:
            if os.path.exists(p):
                size = os.path.getsize(p)
                if size > 100 * 1024:  # treat anything >100KB as the real model
                    return p
                else:
                    logger.warning(f"Found {p} but size is small ({size} bytes) β€” might be a pointer file.")
        except Exception:
            continue

    # 2) recursive search within workspace
    for root, dirs, files in os.walk(os.getcwd()):
        if MODEL_FILENAME in files:
            candidate = os.path.join(root, MODEL_FILENAME)
            try:
                size = os.path.getsize(candidate)
            except Exception:
                size = 0
            if size > 100 * 1024:
                return candidate
            else:
                # small file -> likely Git LFS pointer
                raise RuntimeError(
                    f"Found {candidate} but its size is {size} bytes β€” looks like a Git LFS pointer. "
                    "Make sure you uploaded the real model binary (use git lfs) or place the full .pt in the repo."
                )
    return None

# Try to find the model locally
local_model = find_local_model()
if local_model:
    MODEL_PATH = local_model
    logger.info(f"Using local model file at: {MODEL_PATH}")
else:
    # No local model found β€” give clear error and instructions
    raise RuntimeError(
        "No local model 'best.pt' found in the repository. Please add the model binary to the repo (recommended),\n"
        "or set HUGGINGFACE_HUB_TOKEN in Space settings to allow downloading from the Hub. "
        "Recommended step: put the full model binary at the project root or at models/best.pt and re-run."
    )

# ---- imports that rely on MODEL_PATH being set ----
from video import process_video_stream
from image import process_image

# ---- SMTP / Email settings (use env vars; fallback to defaults where appropriate) ----
SMTP_SERVER = os.getenv("SMTP_SERVER", "smtp.gmail.com")
SMTP_PORT = int(os.getenv("SMTP_PORT", os.getenv("SMTP_PORT", "587")))
EMAIL_USER = os.getenv("EMAIL_USER", "nourmohamed20230@gmail.com")
EMAIL_PASS = os.getenv("EMAIL_PASS", "rklowjzoywtbttxz")  # recommended: set this in Space Secrets / env vars (app password)

if not EMAIL_PASS:
    logger.warning(
        "EMAIL_PASS not set. Email sending will be disabled until you set EMAIL_PASS as an env var "
        "(use an app password for Gmail). Set EMAIL_USER and EMAIL_PASS in Space Settings -> Variables/Secrets."
    )

def make_smtp_cfg(email_to):
    if email_to and email_to.strip():
        return {
            "enabled": True,
            "smtp_server": SMTP_SERVER,
            "smtp_port": SMTP_PORT,
            "email_user": EMAIL_USER,
            "email_pass": EMAIL_PASS,
            "email_to": email_to.strip()
        }
    else:
        return {"enabled": False}


def make_openrouter_cfg():
    OPENROUTER_API_KEY = os.getenv("OPENROUTER_API_KEY", "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")
    OPENROUTER_BASEURL = os.getenv("OPENROUTER_BASEURL", "https://openrouter.ai/api/v1")
    OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "google/gemma-3-12b-it:free")
    if OPENROUTER_API_KEY and OPENROUTER_API_KEY.strip():
        return {
            "api_key": OPENROUTER_API_KEY.strip(),
            "base_url": OPENROUTER_BASEURL,
            "model_name": OPENROUTER_MODEL
        }
    else:
        return None


def run_video_pipeline(uploaded_video_file, email_to, conf_thresh, confirm_conf_thresh):
    """Wrapper generator for video processing to normalize outputs for Gradio."""
    if uploaded_video_file is None:
        yield "Please upload a video.", None, None, [], pd.DataFrame(), None
        return

    ts = int(time.time())
    run_dir = os.path.join(BASE_OUT, f"run_{ts}")
    os.makedirs(run_dir, exist_ok=True)

    # save uploaded video (uploaded_video_file is a gr.File -> has .name path)
    video_local = os.path.join(run_dir, "input_video.mp4")
    try:
        with open(video_local, "wb") as out_f, open(uploaded_video_file.name, "rb") as in_f:
            out_f.write(in_f.read())
    except Exception as e:
        yield f"Error saving uploaded video: {e}", None, None, [], pd.DataFrame(), None
        return

    smtp_cfg = make_smtp_cfg(email_to)
    openrouter_cfg = make_openrouter_cfg()

    gen = process_video_stream(
        video_path=video_local,
        model_path=MODEL_PATH,
        out_root=run_dir,
        openrouter_cfg=openrouter_cfg,
        smtp_cfg=smtp_cfg,
        conf_thresh=float(conf_thresh),
        confirm_conf_thresh=float(confirm_conf_thresh),
        send_interval=4.0,
        confirmed_block_seconds=1000.0,
        progress_interval_frames=30
    )

    last_csv_df = pd.DataFrame()
    last_gallery = []
    last_annotated_video = None
    last_live = None

    for update in gen:
        status = update.get("status", "")
        live_frame = update.get("live_frame", "")   # path to last suspicious frame
        suspicious_list = update.get("suspicious_list", []) or []
        csv_path = update.get("csv_path", "")
        annotated_video = update.get("annotated_video", None)

        # load CSV into dataframe if exists
        if csv_path and os.path.exists(csv_path):
            try:
                df = pd.read_csv(csv_path)
            except Exception:
                df = last_csv_df
        else:
            df = last_csv_df

        gallery_list = suspicious_list
        live_img = live_frame if live_frame and os.path.exists(live_frame) else last_live

        last_csv_df = df
        last_gallery = gallery_list
        if annotated_video:
            last_annotated_video = annotated_video
        last_live = live_img

        # For video mode: annotated_vid filled, annotated_img None
        yield status, (last_annotated_video if last_annotated_video else None), None, gallery_list, df, (live_img if live_img else None)

    return


def run_image_pipeline(uploaded_image_file, email_to, conf_thresh, confirm_conf_thresh):
    """Wrapper generator for image processing to normalize outputs for Gradio."""
    if uploaded_image_file is None:
        yield "Please upload an image.", None, None, [], pd.DataFrame(), None
        return

    ts = int(time.time())
    run_dir = os.path.join(BASE_OUT, f"run_{ts}")
    os.makedirs(run_dir, exist_ok=True)

    # save uploaded image (uploaded_image_file is gr.File -> has .name path)
    image_local = os.path.join(run_dir, os.path.basename(uploaded_image_file.name))
    try:
        with open(image_local, "wb") as out_f, open(uploaded_image_file.name, "rb") as in_f:
            out_f.write(in_f.read())
    except Exception as e:
        yield f"Error saving uploaded image: {e}", None, None, [], pd.DataFrame(), None
        return

    smtp_cfg = make_smtp_cfg(email_to)
    openrouter_cfg = make_openrouter_cfg()

    gen = process_image(
        image_path=image_local,
        model_path=MODEL_PATH,
        out_root=run_dir,
        openrouter_cfg=openrouter_cfg,
        smtp_cfg=smtp_cfg,
        conf_thresh=float(conf_thresh),
        confirm_conf_thresh=float(confirm_conf_thresh)
    )

    last_csv_df = pd.DataFrame()
    last_gallery = []
    last_annotated_image = None
    last_live = None

    for update in gen:
        status = update.get("status", "")
        live_frame = update.get("live_frame", "")   # path to last suspicious frame
        suspicious_list = update.get("suspicious_list", []) or []
        csv_path = update.get("csv_path", "")
        annotated_image = update.get("annotated_image", None)

        # load CSV into dataframe if exists
        if csv_path and os.path.exists(csv_path):
            try:
                df = pd.read_csv(csv_path)
            except Exception:
                df = last_csv_df
        else:
            df = last_csv_df

        gallery_list = suspicious_list
        live_img = live_frame if live_frame and os.path.exists(live_frame) else last_live

        last_csv_df = df
        last_gallery = gallery_list
        if annotated_image:
            last_annotated_image = annotated_image
        last_live = live_img

        # For image mode: annotated_vid None, annotated_img filled
        yield status, None, (last_annotated_image if last_annotated_image else None), gallery_list, df, (live_img if live_img else None)

    return


def run_handler(mode, video_file, image_file, email_to, conf_thresh, confirm_conf_thresh):
    """Main dispatching function called by Gradio. It yields tuples matching outputs:
    (status_txt, annotated_vid, annotated_img, gallery, csv_table (df), live_frame)
    """
    if mode == "Video":
        # delegate to video pipeline generator
        for out in run_video_pipeline(video_file, email_to, conf_thresh, confirm_conf_thresh):
            yield out
    else:
        # Image mode
        for out in run_image_pipeline(image_file, email_to, conf_thresh, confirm_conf_thresh):
            yield out


# Build Gradio UI
with gr.Blocks() as demo:
    gr.Markdown("# Shoplifting Detection β€” Video or Image")

    with gr.Row():
        with gr.Column(scale=2):
            mode = gr.Radio(["Video", "Image"], label="Mode", value="Video")
            video_file = gr.File(label="Upload video (mp4...)", file_types=["video"], visible=True)
            image_file = gr.File(label="Upload image (jpg/png...)", file_types=["image"], visible=False)
            email_to = gr.Textbox(label="Recipient email (to) β€” leave empty to disable email")
            conf_thresh = gr.Slider(label="Confidence threshold", minimum=0.01, maximum=1.0, value=0.5, step=0.01)
            confirm_conf = gr.Slider(label="Confirmation threshold", minimum=0.01, maximum=1.0, value=0.7, step=0.01)
            start_btn = gr.Button("Start")
            gr.Markdown(f"**Using MODEL_PATH (static):** `{MODEL_PATH}`")
            gr.Markdown("**Note:** SMTP / OpenRouter API key are read from env vars if set.")

        with gr.Column(scale=2):
            status_txt = gr.Textbox(label="Status", lines=3)
            annotated_vid = gr.Video(label="Annotated video (final)", visible=True)
            annotated_img = gr.Image(label="Annotated image (final)", visible=False)
            gallery = gr.Gallery(label="Suspicious frames (click to preview)", columns=4, height="auto")
            csv_table = gr.Dataframe(label="CSV Log")
            live_frame = gr.Image(label="Live detected frame (real-time)")

    # Toggle visibility of inputs/outputs when mode changes
    def toggle_mode(m):
        if m == "Video":
            return gr.update(visible=True), gr.update(visible=False), gr.update(visible=True), gr.update(visible=False)
        else:
            return gr.update(visible=False), gr.update(visible=True), gr.update(visible=False), gr.update(visible=True)

    # Connect mode change: updates (video_file, image_file, annotated_vid, annotated_img)
    mode.change(toggle_mode, inputs=[mode], outputs=[video_file, image_file, annotated_vid, annotated_img])

    # Start button triggers the dispatcher. Outputs: status_txt, annotated_vid, annotated_img, gallery, csv_table, live_frame
    start_btn.click(
        fn=run_handler,
        inputs=[mode, video_file, image_file, email_to, conf_thresh, confirm_conf],
        outputs=[status_txt, annotated_vid, annotated_img, gallery, csv_table, live_frame]
    )

if __name__ == "__main__":
    demo.launch(share=True)