Spaces:
Sleeping
Sleeping
| # app.py | |
| # Live Video Insight — real-time video understanding (English UI) | |
| # Defaults set to your requested live URLs: | |
| # 1) VDOT – Fairfax NO0451 (HLS) | |
| # 2) Red Bull Bike – RBMN (HLS) | |
| # Notes: | |
| # • Start/Stop buttons pinned at top; robust lifecycle. | |
| # • Preview fits card; no deprecated Streamlit args (uses width="stretch"). | |
| # • Optional credentials only for Custom URLs. | |
| # • Natural preview pacing; default sampling=10 FPS. | |
| # • Qwen2-VL chat template under the hood; AMP deprecation fixed. | |
| import os | |
| import sys | |
| import time | |
| import queue | |
| import threading | |
| from collections import deque | |
| from typing import List, Optional | |
| from urllib.parse import urlparse, urlunparse | |
| import io, base64 # (media store bypass için) | |
| # Windows/Tornado event loop stability for Streamlit websockets | |
| try: | |
| if sys.platform.startswith("win"): | |
| import asyncio | |
| asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) | |
| except Exception: | |
| pass | |
| import av | |
| import torch | |
| import streamlit as st | |
| from PIL import Image | |
| import gc # RAM temizliği için | |
| APP_TITLE = "Live Video Insight" | |
| APP_TAGLINE = "Real-time video understanding." | |
| MODEL_ID = os.getenv("VISION_ENGINE_ID", "Qwen/Qwen2-VL-2B-Instruct") | |
| # Optional: attach Streamlit ScriptRunContext to threads | |
| try: | |
| from streamlit.runtime.scriptrunner import add_script_run_ctx | |
| except Exception: | |
| add_script_run_ctx = None | |
| st.set_page_config(page_title=APP_TITLE, page_icon="🎥", layout="wide") | |
| # ==================== Theme & layout ==================== | |
| st.markdown(""" | |
| <style> | |
| :root { --bg:#0e0f12; --fg:#e8eaed; --muted:#cfd3dc; --brand:#ff7a1a; --panel:#171a1f; --border:#232730; --ghost:#2a2f3a; } | |
| html, body, .stApp, [data-testid="stAppViewContainer"], [data-testid="stHeader"], [data-testid="stToolbar"]{ | |
| background-color:var(--bg) !important; color:var(--fg) !important; | |
| } | |
| a, a:visited { color:var(--brand) !important; } | |
| [data-testid="stSidebar"] { background:var(--bg) !important; border-right:1px solid var(--border) !important; } | |
| /* FORCE all sidebar text visible */ | |
| section[data-testid="stSidebar"] * { color: var(--fg) !important; } | |
| /* Inputs */ | |
| section[data-testid="stSidebar"] input[type="text"], section[data-testid="stSidebar"] input[type="password"]{ | |
| color: var(--fg) !important; background: var(--panel) !important; | |
| border:1px solid var(--border) !important; border-radius:10px !important; | |
| } | |
| section[data-testid="stSidebar"] input::placeholder { color:#9aa3b2 !important; } | |
| /* Select */ | |
| section[data-testid="stSidebar"] [data-baseweb="select"] > div { | |
| color: var(--fg) !important; background: var(--panel) !important; | |
| border:1px solid var(--border) !important; border-radius:10px !important; | |
| } | |
| /* Sliders */ | |
| section[data-testid="stSidebar"] [data-testid="stThumbValue"]{ | |
| color: var(--bg) !important; background: var(--brand) !important; border-radius:8px !important; | |
| } | |
| section[data-testid="stSidebar"] .stSlider [role="slider"]{ | |
| background: var(--brand) !important; border: 2px solid var(--brand) !important; | |
| } | |
| section[data-testid="stSidebar"] .stSlider [data-baseweb="slider"] > div > div{ | |
| background: linear-gradient(to right, var(--brand), var(--brand)) !important; | |
| } | |
| /* Buttons: full width without deprecated flags */ | |
| section[data-testid="stSidebar"] .stButton>button { | |
| width: 100% !important; border-radius:12px; font-weight:700; background:var(--brand); color:var(--bg); border:0; | |
| } | |
| section[data-testid="stSidebar"] .stButton>button:hover { filter:brightness(0.95); } | |
| /* Cards / KPIs */ | |
| .card { background:var(--panel); border:1px solid var(--border); border-radius:16px; padding:14px; } | |
| .kpi { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:14px; } | |
| .badge{ display:inline-block; padding:2px 8px; border-radius:999px; border:1px solid var(--ghost); color:#dbe0ea; font-size:12px; } | |
| h1,h2,h3 { color:var(--brand); } | |
| hr.soft { border:0; border-top:1px solid var(--border); margin:12px 0 8px; } | |
| /* Fixed-height, scrollable, latest-first */ | |
| .scrollbox { | |
| max-height: 320px; overflow-y: auto; padding: 8px 10px; | |
| background:var(--panel); border:1px solid var(--border); border-radius:12px; | |
| display: flex; flex-direction: column; gap: 10px; | |
| } | |
| .scrollline { margin:0; line-height:1.25rem; color: var(--muted); } | |
| /* Preview image fits the card */ | |
| .video-frame img { | |
| width: 100% !important; | |
| height: auto !important; | |
| object-fit: contain !important; | |
| border-radius: 12px; | |
| } | |
| /* Tiny spinner (no white Streamlit status box) */ | |
| .tiny-spinner { color: var(--muted); font-size: 0.9rem; } | |
| .sidebar-title { font-weight:700; margin-top:6px; } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # ==================== State ==================== | |
| if "running" not in st.session_state: | |
| st.session_state.running = False | |
| if "stop_event" not in st.session_state: | |
| st.session_state.stop_event = threading.Event() | |
| # ==================== Default demo URLs (your picks) ==================== | |
| DEMO_OPTIONS = { | |
| "VDOT – Fairfax NO0451": "https://media-sfs1.vdotcameras.com:443/rtplive/NO0451/playlist.m3u8", | |
| "Red Bull Bike – RBMN": "https://rbmn-live.akamaized.net/hls/live/590964/BoRB-AT/master_3360.m3u8", | |
| } | |
| # ==================== Sidebar (Start/Stop on top) ==================== | |
| st.sidebar.markdown('<div class="sidebar-title">Controls</div>', unsafe_allow_html=True) | |
| btn_cols = st.sidebar.columns(2) | |
| start_clicked = btn_cols[0].button("Start ▶️") | |
| stop_clicked = btn_cols[1].button("Stop ⏹") | |
| # Map button clicks to state transitions (idempotent, robust) | |
| if start_clicked and not st.session_state.running: | |
| # yeni run öncesi hafif temizlik | |
| try: | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| except Exception: | |
| pass | |
| gc.collect() | |
| st.session_state.running = True | |
| st.session_state.stop_event.clear() | |
| if stop_clicked and st.session_state.running: | |
| st.session_state.stop_event.set() | |
| # Source mode: Demo vs Custom | |
| st.sidebar.markdown('<div class="sidebar-title">Source</div>', unsafe_allow_html=True) | |
| source_mode = st.sidebar.radio("Mode", ["Demo URLs", "Custom URL"], index=0, horizontal=True) | |
| if source_mode == "Demo URLs": | |
| demo_label = st.sidebar.selectbox("Pick a demo", list(DEMO_OPTIONS.keys()), index=0) | |
| source_final = DEMO_OPTIONS[demo_label] | |
| st.sidebar.caption(f"Selected demo URL:\n{source_final}") | |
| auth_enabled = False | |
| user = pwd = "" | |
| else: | |
| # Custom URL + optional credentials | |
| source_type = st.sidebar.selectbox("Type", ["Video URL (HTTP/HTTPS)", "IP Camera (RTSP)", "Local File"], index=0) | |
| default_hint = { | |
| "Video URL (HTTP/HTTPS)": list(DEMO_OPTIONS.values())[0], | |
| "IP Camera (RTSP)" : "rtsp://ip:port/stream", | |
| "Local File" : "C:\\videos\\video.mp4 or /path/video.mp4" | |
| }[source_type] | |
| source_input = st.sidebar.text_input("Address or path", value=default_hint, placeholder=default_hint, key="custom_source") | |
| cred_disabled = st.session_state.running | |
| auth_enabled = False; user = pwd = "" | |
| if source_type in ("Video URL (HTTP/HTTPS)", "IP Camera (RTSP)"): | |
| auth_enabled = st.sidebar.toggle("Use credentials (optional)", value=False, | |
| help="Enable if the URL requires username/password.", | |
| disabled=cred_disabled) | |
| if auth_enabled: | |
| user = st.sidebar.text_input("Username", value="", placeholder="user", disabled=cred_disabled) | |
| pwd = st.sidebar.text_input("Password", value="", placeholder="password", type="password", disabled=cred_disabled) | |
| source_final = source_input | |
| # Advanced groups | |
| with st.sidebar.expander("Analysis (advanced)", expanded=False): | |
| fps_sample = st.slider("Frame sampling (FPS)", 1, 20, 10, | |
| help="Frames per second sampled for analysis.", | |
| disabled=st.session_state.running) | |
| segment_seconds = st.slider("Segment length (sec)", 1, 8, 2, disabled=st.session_state.running) | |
| resp_len = st.slider("Response length (tokens)", 16, 128, 64, disabled=st.session_state.running) | |
| style = st.selectbox("Commentary style", | |
| ["Play-by-play (concise)", "Tactical analysis (succinct)", "Highlights only (major moments)"], | |
| disabled=st.session_state.running) | |
| with st.sidebar.expander("Performance (advanced)", expanded=False): | |
| preview_fps = st.slider("Preview FPS cap", 10, 30, 25, | |
| help="25–30 looks natural.", disabled=st.session_state.running) | |
| gpu_mode = st.toggle("GPU mode (if available)", value=True, | |
| help="Takes effect on next run.", disabled=st.session_state.running) | |
| eff_mode = st.toggle("Efficiency mode", value=True, | |
| help="Downscale frames to 512px; keep responses short.", | |
| disabled=st.session_state.running) | |
| fast_processor = st.toggle("Fast processor", value=True, | |
| help="Takes effect on next run.", disabled=st.session_state.running) | |
| with st.sidebar.expander("History (advanced)", expanded=False): | |
| console_max = st.slider("Live Console lines", 5, 300, 60, disabled=st.session_state.running) | |
| summary_max = st.slider("Segment Summary items", 5, 300, 60, disabled=st.session_state.running) | |
| # ==================== Header ==================== | |
| st.title(APP_TITLE) | |
| st.write(APP_TAGLINE) | |
| # ==================== Helpers ==================== | |
| def is_rtsp(url: str) -> bool: | |
| return url.strip().lower().startswith("rtsp") | |
| def apply_credentials(url: str, username: str, password: str) -> str: | |
| if not url or not username: | |
| return url | |
| p = urlparse(url) | |
| if p.scheme not in ("http", "https", "rtsp"): | |
| return url | |
| hostpart = p.netloc.split("@", 1)[-1] | |
| auth = f"{username}:{password}" if password else username | |
| return urlunparse(p._replace(netloc=f"{auth}@{hostpart}")) | |
| def safe_ts(frame, vstream) -> Optional[float]: | |
| if getattr(frame, "time", None) is not None: | |
| return float(frame.time) | |
| if frame.pts is not None and vstream and vstream.time_base: | |
| return float(frame.pts * vstream.time_base) | |
| return None | |
| def downscale(img: Image.Image, max_side: int = 512) -> Image.Image: | |
| w, h = img.size | |
| m = max(w, h) | |
| if m <= max_side: return img | |
| s = max_side / float(m) | |
| return img.resize((int(w*s), int(h*s)), Image.BILINEAR) | |
| # --- Media store bypass: st.image yerine data-URI --- | |
| def img_to_data_uri(img: Image.Image, quality: int = 80) -> str: | |
| buf = io.BytesIO() | |
| img.convert("RGB").save(buf, format="JPEG", quality=quality, optimize=True) | |
| b64 = base64.b64encode(buf.getvalue()).decode("ascii") | |
| return f"data:image/jpeg;base64,{b64}" | |
| def safe_image_data_uri(placeholder, img: Image.Image, ts: Optional[float] = None, quality: int = 80): | |
| try: | |
| uri = img_to_data_uri(img, quality=quality) | |
| cap = f"t = {ts:.2f}s" if ts is not None else "" | |
| html = f''' | |
| <figure style="margin:0"> | |
| <img src="{uri}" alt="frame" style="width:100%;height:auto;object-fit:contain;border-radius:12px;" /> | |
| <figcaption style="color:#9aa3b2;font-size:0.85rem;margin-top:4px">{cap}</figcaption> | |
| </figure> | |
| ''' | |
| placeholder.markdown(html, unsafe_allow_html=True) | |
| return True | |
| except Exception: | |
| return False | |
| # --- Hafıza/CPU temizliği yardımcıları --- | |
| def purge_queue(q: "queue.Queue"): | |
| try: | |
| while True: | |
| q.get_nowait() | |
| except queue.Empty: | |
| pass | |
| def finalize_and_free(decoder_obj, worker_obj, buffers: List, deques: List, queues: List): | |
| # Threadleri durdur/kat | |
| try: | |
| if worker_obj is not None: | |
| worker_obj.stop() | |
| except Exception: | |
| pass | |
| try: | |
| if decoder_obj is not None and hasattr(decoder_obj, "stop_event"): | |
| pass # decoder loop stop_event ile çıkıyor | |
| except Exception: | |
| pass | |
| try: | |
| if decoder_obj is not None: | |
| decoder_obj.join(timeout=1.0) | |
| except Exception: | |
| pass | |
| try: | |
| if worker_obj is not None: | |
| worker_obj.join(timeout=1.0) | |
| except Exception: | |
| pass | |
| # Buffer/queue temizle | |
| for b in buffers: | |
| try: | |
| if hasattr(b, "clear"): | |
| b.clear() | |
| except Exception: | |
| pass | |
| for d in deques: | |
| try: | |
| d.clear() | |
| except Exception: | |
| pass | |
| for q in queues: | |
| try: | |
| purge_queue(q) | |
| except Exception: | |
| pass | |
| # Torch/CUDA bellek | |
| try: | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| except Exception: | |
| pass | |
| gc.collect() | |
| # ==================== Engine Loader (Qwen2-VL under the hood) ==================== | |
| # suppress default status box | |
| def load_engine(model_id: str, use_gpu: bool, use_fast_processor: bool): | |
| from transformers import AutoProcessor, Qwen2VLForConditionalGeneration | |
| from qwen_vl_utils import process_vision_info # noqa | |
| cuda_built = getattr(torch.backends, "cuda", None) and torch.backends.cuda.is_built() | |
| cuda_ok = bool(use_gpu and torch.cuda.is_available() and cuda_built) | |
| device = torch.device("cuda") if cuda_ok else torch.device("cpu") | |
| amp_dtype = torch.float16 if device.type == "cuda" else torch.float32 | |
| processor = AutoProcessor.from_pretrained( | |
| model_id, trust_remote_code=True, use_fast=bool(use_fast_processor) | |
| ) | |
| model = Qwen2VLForConditionalGeneration.from_pretrained( | |
| model_id, trust_remote_code=True, dtype=amp_dtype | |
| ).to(device).eval() | |
| # Deterministic defaults | |
| gcfg = getattr(model, "generation_config", None) | |
| if gcfg is not None: | |
| for k in ("temperature", "top_p", "top_k"): | |
| if hasattr(gcfg, k): | |
| setattr(gcfg, k, None) | |
| tokenizer = getattr(processor, "tokenizer", None) | |
| if tokenizer is None: | |
| raise RuntimeError("Tokenizer not available from processor; please update transformers.") | |
| device_label = "CPU" | |
| if device.type == "cuda": | |
| try: | |
| device_label = f"GPU ({torch.cuda.get_device_name(0)})" | |
| except Exception: | |
| device_label = "GPU" | |
| if device.type == "cpu": | |
| try: | |
| torch.set_num_threads(max(1, os.cpu_count() // 2)) | |
| except Exception: | |
| pass | |
| cuda_hint = None | |
| if use_gpu and device.type == "cpu": | |
| cuda_ver = getattr(torch.version, "cuda", None) | |
| cuda_hint = ( | |
| f"CUDA not available to PyTorch. torch.version.cuda={cuda_ver}, " | |
| f"torch.backends.cuda.is_built={cuda_built}, torch.cuda.is_available={torch.cuda.is_available()}." | |
| " Install a CUDA-enabled PyTorch build from the official selector." | |
| ) | |
| return (tokenizer, processor, model, device), device_label, cuda_hint | |
| # ==================== Prompting ==================== | |
| SYSTEM_PROMPT = ( | |
| "You are a real-time video analyst. Respond ONLY in English. " | |
| "Use short, factual sentences grounded in the provided frames. " | |
| "Do not speculate about unseen causes or off-screen events. " | |
| "When nothing occurs, nothing say." | |
| ) | |
| def build_analysis_instruction(kind: str, t0: float, t1: float) -> str: | |
| if kind == "Tactical analysis (succinct)": | |
| return ( | |
| f"Segment {t0:.1f}–{t1:.1f}s. Provide 1–2 sentences on formations, movements, and spatial patterns. " | |
| "Be objective. No questions. No speculation." | |
| ) | |
| if kind == "Highlights only (major moments)": | |
| return ( | |
| f"Segment {t0:.1f}–{t1:.1f}s. Report only decisive highlights (e.g., goals/shots on target/turnovers/aces). " | |
| "One short sentence." | |
| ) | |
| return ( | |
| f"Segment {t0:.1f}–{t1:.1f}s. Describe visible actions in present tense. " | |
| "Use short factual sentences; avoid guesses." | |
| ) | |
| def pick_k(frames: List[Image.Image], k: int = 4) -> List[Image.Image]: | |
| if len(frames) <= k: return frames | |
| idxs = [round(i*(len(frames)-1)/(k-1)) for i in range(k)] | |
| return [frames[i] for i in idxs] | |
| def build_inputs_qwen(tokenizer, processor, images: List[Image.Image], instruction: str, device: torch.device): | |
| # Qwen2-VL chat template: system + user([{image...}, {text...}]) | |
| from qwen_vl_utils import process_vision_info | |
| messages = [ | |
| {"role": "system", "content": [{"type": "text", "text": SYSTEM_PROMPT}]}, | |
| {"role": "user", | |
| "content": ([{"type": "image", "image": im} for im in images] + | |
| [{"type": "text", "text": instruction}])} | |
| ] | |
| text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) | |
| image_inputs, video_inputs = process_vision_info(messages) | |
| inputs = processor(text=[text], images=image_inputs, videos=video_inputs, | |
| padding=True, return_tensors="pt") | |
| for k, v in list(inputs.items()): | |
| if torch.is_tensor(v): | |
| inputs[k] = v.to(device) | |
| return inputs | |
| # ==================== Decoder (Producer) ==================== | |
| class Decoder(threading.Thread): | |
| """pyAV decoder. Real-time pacing for HTTP/Local; RTSP flows as-is.""" | |
| def __init__(self, src: str, fps_target: int, buf: deque, stop_event: threading.Event, is_rtsp_src: bool): | |
| super().__init__(daemon=True) | |
| self.src = src; self.fps_target = fps_target; self.buf = buf | |
| self.stop_event = stop_event; self.is_rtsp_src = is_rtsp_src | |
| self.err: Optional[Exception] = None | |
| self._container = None; self._vstream = None | |
| def run(self): | |
| try: | |
| options = {"rtsp_transport": "tcp", "stimeout": "5000000"} if self.is_rtsp_src else {} | |
| self._container = av.open(self.src, options=options) | |
| self._vstream = next((s for s in self._container.streams if s.type == "video"), None) | |
| if self._vstream is None: | |
| raise RuntimeError("No video stream detected.") | |
| step = 1.0 / float(self.fps_target) | |
| next_t = 0.0 | |
| wall0 = None; ts0 = None | |
| for packet in self._container.demux(self._vstream): | |
| if self.stop_event.is_set(): break | |
| for f in packet.decode(): | |
| t = safe_ts(f, self._vstream) | |
| if t is None: continue | |
| if t + 1e-6 < next_t: continue | |
| if not self.is_rtsp_src: | |
| if wall0 is None: | |
| wall0 = time.time(); ts0 = t | |
| target_wall = wall0 + (t - ts0) | |
| now = time.time() | |
| if target_wall > now: | |
| dt = target_wall - now | |
| while dt > 0 and (not self.stop_event.is_set()): | |
| time.sleep(min(dt, 0.03)) | |
| now = time.time() | |
| dt = target_wall - now | |
| try: | |
| self.buf.append((f.to_image(), t)) | |
| except Exception: | |
| continue | |
| next_t += step | |
| except Exception as e: | |
| self.err = e | |
| finally: | |
| try: | |
| if self._container is not None: | |
| self._container.close() | |
| except Exception: | |
| pass | |
| # ==================== Inference Worker ==================== | |
| class InferenceWorker(threading.Thread): | |
| def __init__(self, engine, job_q: "queue.Queue", out_q: "queue.Queue", max_tokens: int): | |
| super().__init__(daemon=True) | |
| self.engine = engine; self.job_q = job_q; self.out_q = out_q | |
| self.max_tokens = int(max_tokens); self.alive = True | |
| def run(self): | |
| tokenizer, processor, model, device = self.engine | |
| while self.alive: | |
| try: | |
| frames, t0, t1, instruction = self.job_q.get(timeout=0.1) | |
| except queue.Empty: | |
| continue | |
| try: | |
| picks = pick_k(frames, 4) | |
| inputs = build_inputs_qwen(tokenizer, processor, picks, instruction, device) | |
| start = time.time() | |
| with torch.inference_mode(): | |
| if device.type == "cuda": | |
| with torch.amp.autocast("cuda", dtype=torch.float16): | |
| outputs = model.generate(**inputs, max_new_tokens=self.max_tokens, | |
| do_sample=False, use_cache=True) | |
| else: | |
| outputs = model.generate(**inputs, max_new_tokens=self.max_tokens, | |
| do_sample=False, use_cache=True) | |
| latency = time.time() - start | |
| input_len = inputs["input_ids"].shape[1] | |
| new_ids = outputs[0][input_len:] | |
| text = tokenizer.decode(new_ids, skip_special_tokens=True).strip() | |
| self.out_q.put((t0, t1, text, latency)) | |
| except Exception as e: | |
| self.out_q.put((t0, t1, f"[analysis error] {e}", None)) | |
| def stop(self): self.alive = False | |
| # ==================== Page layout ==================== | |
| left, right = st.columns([1.05, 1.0]) | |
| with left: | |
| st.subheader("Video") | |
| st.markdown('<div class="card">', unsafe_allow_html=True) | |
| # The .video-frame wrapper ensures fit-to-card visuals | |
| st.markdown('<div class="video-frame">', unsafe_allow_html=True) | |
| video_img = st.empty() | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| meta_row = st.columns([1,1,1]) | |
| fps_badge = meta_row[0].markdown('<span class="badge">Sampling: -- fps</span>', unsafe_allow_html=True) | |
| time_badge = meta_row[1].markdown('<span class="badge">Time: 0.0s</span>', unsafe_allow_html=True) | |
| seg_badge = meta_row[2].markdown('<span class="badge">Segment: --</span>', unsafe_allow_html=True) | |
| st.markdown('<hr class="soft"/>', unsafe_allow_html=True) | |
| st.caption("If the URL requires credentials, switch to Custom mode and enable 'Use credentials'. RTSP over TCP is preferred.") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| with right: | |
| st.subheader("Live Console") | |
| console_box = st.empty() | |
| st.subheader("Segment Summary") | |
| summary_box = st.empty() | |
| st.markdown('<div class="card"></div>', unsafe_allow_html=True) | |
| # ==================== Misc UI helpers ==================== | |
| def render_scrollbox(lines: deque[str], dom_id: str) -> str: | |
| items = "".join(f'<p class="scrollline">{l}</p>' for l in reversed(lines)) | |
| return f''' | |
| <div id="{dom_id}" class="scrollbox">{items}</div> | |
| <script> | |
| const el = window.parent.document.getElementById('{dom_id}'); | |
| if (el) el.scrollTop = 0; | |
| </script> | |
| ''' | |
| def safe_markdown(placeholder, html: str): | |
| try: placeholder.markdown(html, unsafe_allow_html=True); return True | |
| except Exception: return False | |
| def safe_image(placeholder, img, **kwargs): | |
| # Kept for compatibility; artık data-URI kullanılıyor. | |
| try: | |
| placeholder.image(img, width="stretch", **kwargs) | |
| return True | |
| except Exception: | |
| return False | |
| # ==================== RUN ==================== | |
| if st.session_state.running: | |
| # Pre-run validation | |
| if source_mode == "Custom URL": | |
| if not source_final.strip(): | |
| st.warning("Please provide a valid source.") | |
| st.session_state.running = False | |
| st.stop() | |
| if source_final.lower().startswith("rtsp"): | |
| pass | |
| elif source_final.lower().startswith(("http://", "https://")): | |
| pass | |
| elif source_final and os.path.isfile(source_final): | |
| pass | |
| else: | |
| st.error("Unsupported source.") | |
| st.session_state.running = False | |
| st.stop() | |
| else: | |
| pass | |
| final_source = source_final | |
| if source_mode == "Custom URL" and auth_enabled and source_final.lower().startswith(("http://", "https://", "rtsp")): | |
| final_source = apply_credentials(source_final, user.strip(), pwd) | |
| seg_len_frames = max(1, int(segment_seconds * fps_sample)) | |
| frames_buf: List[Image.Image] = [] | |
| times_buf: List[float] = [] | |
| console_buf: deque[str] = deque(maxlen=60 if 'console_max' not in locals() else console_max) | |
| summary_buf: deque[str] = deque(maxlen=60 if 'summary_max' not in locals() else summary_max) | |
| ema_latency: Optional[float] = None | |
| # Tiny spinner text (no white box) | |
| st.markdown('<div class="tiny-spinner">Loading AI…</div>', unsafe_allow_html=True) | |
| (tokenizer, processor, model, device), device_label, cuda_hint = load_engine(MODEL_ID, gpu_mode, fast_processor) | |
| # KPIs | |
| k1, k2, k3, k4, k5 = st.columns(5) | |
| k1.markdown(f'<div class="kpi"><b>Sampling FPS</b><br/><big>{fps_sample}</big></div>', unsafe_allow_html=True) | |
| k2.markdown(f'<div class="kpi"><b>Segment (sec)</b><br/><big>{segment_seconds}</big></div>', unsafe_allow_html=True) | |
| k3.markdown(f'<div class="kpi"><b>Response length</b><br/><big>{resp_len}</big></div>', unsafe_allow_html=True) | |
| k4.markdown(f'<div class="kpi"><b>Device</b><br/><big>{device_label}</big></div>', unsafe_allow_html=True) | |
| lat_slot = k5.empty() | |
| lat_slot.markdown(f'<div class="kpi"><b>Avg latency</b><br/><big>--</big></div>', unsafe_allow_html=True) | |
| if cuda_hint: | |
| st.warning(cuda_hint, icon="⚠️") | |
| # Queues & worker | |
| job_q: queue.Queue = queue.Queue(maxsize=1) | |
| out_q: queue.Queue = queue.Queue() | |
| worker = InferenceWorker((tokenizer, processor, model, device), job_q, out_q, resp_len) | |
| if add_script_run_ctx: add_script_run_ctx(worker) | |
| worker.start() | |
| # Decoder | |
| ring = deque(maxlen=300) # ~10–12s @25–30fps | |
| decoder = Decoder(final_source, fps_sample, ring, st.session_state.stop_event, is_rtsp(final_source)) | |
| if add_script_run_ctx: add_script_run_ctx(decoder) | |
| decoder.start() | |
| preview_period = 1.0 / float(preview_fps) | |
| last_preview = 0.0 | |
| try: | |
| while True: | |
| if st.session_state.stop_event.is_set(): | |
| break | |
| if decoder.err: | |
| raise decoder.err | |
| now = time.time() | |
| if ring and (now - last_preview) >= preview_period: | |
| img, ts = ring[-1] | |
| img_disp = downscale(img, 512 if eff_mode else 1024) | |
| # data-URI render (media store hatalarını önler) | |
| if not safe_image_data_uri(video_img, img_disp, ts, quality=80): break | |
| last_preview = now | |
| if not safe_markdown(fps_badge, f'<span class="badge">Sampling: {fps_sample} fps</span>'): break | |
| if not safe_markdown(time_badge, f'<span class="badge">Time: {ts:.2f}s</span>'): break | |
| frames_buf.append(img_disp); times_buf.append(ts) | |
| if not safe_markdown(seg_badge, f'<span class="badge">Segment: {len(frames_buf)}/{seg_len_frames}</span>'): break | |
| # Dispatch a job each filled segment | |
| if len(frames_buf) >= seg_len_frames: | |
| t0, t1 = times_buf[0], times_buf[-1] | |
| instruction = build_analysis_instruction(style, t0, t1) | |
| try: | |
| if job_q.full(): | |
| _ = job_q.get_nowait() | |
| job_q.put_nowait((frames_buf.copy(), t0, t1, instruction)) | |
| except queue.Full: | |
| pass | |
| frames_buf.clear(); times_buf.clear() | |
| # Drain model outputs | |
| try: | |
| while True: | |
| t0o, t1o, txt, lat = out_q.get_nowait() | |
| console_buf.append(f"<b>[{t0o:.1f}–{t1o:.1f}s]</b> {txt}") | |
| summary_buf.append(f"<b>{t0o:.1f}–{t1o:.1f}s</b>: {txt}") | |
| if lat is not None: | |
| ema_latency = lat if ema_latency is None else (0.7*ema_latency + 0.3*lat) | |
| lat_slot.markdown(f'<div class="kpi"><b>Avg latency</b><br/><big>{ema_latency:.1f}s</big></div>', unsafe_allow_html=True) | |
| except queue.Empty: | |
| pass | |
| # One-shot render | |
| if not safe_markdown(console_box, render_scrollbox(console_buf, "console_box")): break | |
| if not safe_markdown(summary_box, render_scrollbox(summary_buf, "summary_box")): break | |
| time.sleep(0.002) | |
| except Exception as e: | |
| st.error(f"Could not process source: {e}") | |
| finally: | |
| # Signal threads to stop, then join | |
| st.session_state.stop_event.set() | |
| try: | |
| finalize_and_free( | |
| decoder_obj=decoder, | |
| worker_obj=worker, | |
| buffers=[frames_buf, times_buf], | |
| deques=[ring, console_buf, summary_buf], | |
| queues=[job_q, out_q], | |
| ) | |
| except Exception: | |
| pass | |
| st.session_state.running = False | |
| try: | |
| st.success("Stopped.") | |
| except Exception: | |
| pass | |
| # Son kez bellek temizliği | |
| try: | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| except Exception: | |
| pass | |
| gc.collect() | |
| else: | |
| # Idle screen (refresh/stop'tan sonra da hafif temizlik yap) | |
| try: | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| except Exception: | |
| pass | |
| gc.collect() | |
| right_col = st.container() | |
| right_col.info("Pick a demo on the left or switch to Custom URL, then press Start ▶️.") | |
| st.caption("Defaults: Frame sampling=10, Preview FPS=25. Use credentials only if your custom URL is protected.") | |