# python .\src\app.py # ------------------------------ # Recycle Material Classifier App # ------------------------------ # This script: # 1. Loads a trained ResNet-18 model # 2. Lets user upload an image or use a live IP camera # 3. Classifies the item (paper/plastic/metal) # 4. Shows Grad-CAM heatmaps for explainability # 5. Displays classification history # ------------------------------ import json, torch from pathlib import Path from PIL import Image from torchvision import transforms import gradio as gr from model import build_model import cv2 import threading import time from explain import generate_gradcam # ---- GLOBAL FLAG (used to stop live feed thread) --- stop_flag = False # ---- MODEL FILE PATHS ---- WEIGHTS = Path("models/resnet18_best.pt") LABELS = Path("models/labels.json") # ---- SELECT DEVICE (GPU if available) ---- device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # ---- LOAD LABELS ---- with open(LABELS) as f: idx2name = {int(k): v for k, v in json.load(f).items()} class_names = [idx2name[i] for i in sorted(idx2name.keys())] # ---- LOAD MODEL ---- model = build_model(num_classes=len(class_names), freeze_backbone=False, device=device) state = torch.load(WEIGHTS, map_location=device) model.load_state_dict(state) model.eval() # ---- IMAGE TRANSFORMATIONS ---- # Resize -> Tensor -> Normalize (same as training) tfm = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406],[0.229, 0.224, 0.225]), ]) # ---- PREDICTION FUNCTION ---- def predict(img: Image.Image): # Generate Grad-CAM heatmaps (explainable visualization) overlay, heatmap, pred_label, conf = generate_gradcam(img, model, device, class_names) # Compute probability scores for all classes with torch.no_grad(): x = tfm(img.convert("RGB")).unsqueeze(0).to(device) probs = torch.softmax(model(x), dim=1).squeeze(0).cpu().tolist() scores = {cls: float(probs[i]) for i, cls in enumerate(class_names)} top = max(scores, key=scores.get) return [img, overlay, heatmap], pred_label, conf, scores # ---- HISTORY SETTINGS ---- MAX_HISTORY = 12 # show up to 12 previous uploads def classify_and_update(img, history_state): if img is None: return [], "N/A", "N/A", {}, history_state # Run classification gallery_imgs, pred_label, conf, all_scores = predict(img) # Update history (keep last 12 images) history_state.append(img) history_state = history_state[-MAX_HISTORY:] # Pad empty slots padded = history_state + [None]*(MAX_HISTORY - len(history_state)) return gallery_imgs, pred_label, f"{round(conf*100)}%", all_scores, *padded, history_state # ---- HISTORY CLICK EVENT ---- def on_history_select(evt: gr.SelectData, history_state): return history_state[evt.index] # ---- history click ---- def on_history_click(idx, history_state): if idx < len(history_state): return history_state[idx] return None # ---- IP CAMERA SETUP ---- # Replace the IP with your phone’s IP Webcam URL # ip_url = "http://10.132.39.1:8080/video" # replace with your phone's IP ip_url = "http://192.168.1.4:8080/video" # ip_url = "http://10.132.39.1:8080/video" # Variables for motion detection # cap = None # prev_gray = None # motion_active = False # recent_preds = [] def start_live_feed(): global stop_flag stop_flag = False def run(): while not stop_flag: outputs = live_ipcam_generator() # Returns (json_dict, label_dict) json_out_live.update(outputs[0]) label_out_live.update(outputs[1]) time.sleep(0.1) threading.Thread(target=run, daemon=True).start() def stop_live_feed(): global stop_flag stop_flag = True cap = None prev_gray = None motion_active = False recent_preds = [] #ip_url = "http://10.132.39.1:8080/video" #ip_url = "http://192.168.1.6:8080/video" def live_ipcam_generator(): """ Generator that yields only frames with motion detected. Skips all frames without meaningful motion. """ global cap, prev_gray motion_threshold = 100 # How sensitive to motion cooldown_sec = 0.5 # Avoid multiple detections per second last_trigger_time = 0 while True: # Initialize camera if not already if cap is None or not cap.isOpened(): try: cap = cv2.VideoCapture(ip_url) time.sleep(1) ret, prev = cap.read() if not ret or prev is None: prev_gray = None raise ValueError("No frame received") prev_gray = cv2.cvtColor(prev, cv2.COLOR_BGR2GRAY) except Exception: # If camera fails, send a blank image + "offline" message dummy_img = Image.new("RGB", (224, 224), (0, 0, 0)) yield {"label": "Camera offline", "conf": 0}, {}, [dummy_img], {"motion_level": 0} time.sleep(1) continue # Read frame ret, frame = cap.read() if not ret or frame is None: cap.release() cap = None dummy_img = Image.new("RGB", (224, 224), (0, 0, 0)) yield {"label": "Camera disconnected", "conf": 0}, {}, [dummy_img], {"motion_level": 0} time.sleep(1) continue # Convert to grayscale for motion detection gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) if prev_gray is not None: diff = cv2.absdiff(prev_gray, gray) motion_level = cv2.countNonZero(cv2.threshold(diff, 25, 255, cv2.THRESH_BINARY)[1]) else: motion_level = 0 prev_gray = gray # Only process frames with motion above threshold if motion_level > motion_threshold: current_time = time.time() if current_time - last_trigger_time >= cooldown_sec: img = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) img = img.resize((840, 480)) pred_images, pred_label, conf, scores = predict(img) pred_json = {"label": pred_label, "conf": round(conf * 100, 2)} motion_info = {"motion_level": motion_level} last_trigger_time = current_time yield pred_json, scores, pred_images, motion_info else: # Skip frame due to cooldown continue else: # Skip frames without motion continue # tiny sleep to avoid hogging CPU time.sleep(0.01) # ---- SIMPLE CSS (hide Gradio footer) ---- css = """ footer, #footer, .footer, [data-testid="branding"] {display:none !important;} a[href*="gradio.app"] {display:none !important;} """ # ---- GRADIO APP LAYOUT ---- with gr.Blocks(theme=gr.themes.Soft(), css=css) as demo: gr.Markdown("