THE githubnext FONT IS mad ugly bro

idfk how it could be that fucking chopped
This commit is contained in:
lda committed 2026-01-15 23:41:19 +07:00
1 parent d57a48eec6
commit cdbca0f370
7 files changed
+447 -278

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
"""Pipeline configuration."""
from dataclasses import dataclass
@dataclass
class PipelineConfig:
"""All pipeline thresholds and settings in one place."""
# Detection thresholds
person_nms_iou: float = 0.7
cig_nms_iou: float = 0.5
final_nms_iou: float = 0.5
# Confidence thresholds
cig_min_confidence: float = 0.2 # Minimum to consider a candidate
cig_high_confidence: float = 0.8 # Skip verifier if above this
verify_threshold: float = 0.8 # Verifier must exceed this
# Size filters (pixels)
min_crop_size: int = 10 # Minimum dimension for valid crop
min_cig_size: int = 10 # Minimum cig box dimension
# Person crop expansion
person_margin: float = 0.2 # 20% expansion around person box
# Verification crop padding
cig_crop_padding: float = 0.5 # 50% padding around cig for context
# Adaptive sampling
idle_interval: float = 0.5 # Seconds between inference when idle
active_interval: float = 0.0 # Seconds between inference when active
cooldown_frames: int = 60 # Frames to stay active after last detection
# Default config instance
DEFAULT_CONFIG = PipelineConfig()
+281 -235
View File
@@ -1,10 +1,14 @@
"""Smoking Detection Pipeline - Main orchestration."""
import concurrent.futures
import logging
import time
from collections import deque
from concurrent.futures import Future
from dataclasses import dataclass
from typing import List, Optional, Tuple
from pipeline.config import DEFAULT_CONFIG, PipelineConfig
from pipeline.ops import nms_by_class
from pipeline.protocols import Model, Sink, StreamSource, Verifier
from pipeline.types import BoundingBox, Detection, Frame, Ticket
@@ -12,7 +16,26 @@ from pipeline.types import BoundingBox, Detection, Frame, Ticket
logger = logging.getLogger(__name__)
@dataclass
class InferenceResult:
"""Result of a single inference pass."""
has_activity: bool
detections: List[Detection]
ticket: Optional[Ticket] = None
class SmokingDetectionPipeline:
"""
Multi-stage smoking detection pipeline.
Stage 1: Person Detection (broad phase)
Stage 2: Cigarette Detection (narrow phase, per-person crops)
Stage 3: Verification (classifier confirmation)
Supports early exit at Stage 2 if cigarette confidence is high enough.
"""
def __init__(
self,
person_model: Model,
@@ -20,306 +43,329 @@ class SmokingDetectionPipeline:
verifier_model: Verifier,
vlm_model: Optional[Verifier] = None,
sinks: Optional[list[Sink]] = None,
inference_interval: float = 0.5,
active_interval: float = 0.0,
cooldown_frames: int = 60,
config: Optional[PipelineConfig] = None,
):
self.person_model = person_model
self.cigarette_model = cigarette_model
self.verifier_model = verifier_model
self.vlm_model = vlm_model
self.sinks = sinks or []
self.cfg = config or DEFAULT_CONFIG
# Adaptive Sampling Settings
self.idle_interval = inference_interval
self.active_interval = active_interval
self.cooldown_frames = cooldown_frames
# State
self.current_interval = self.idle_interval
# Adaptive sampling state
self.current_interval = self.cfg.idle_interval
self.active_cooldown = 0
self.last_inference_time = 0.0
# Async Inference State
# Async inference state
self.executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
self.current_future: Optional[Future] = None
self.latest_detections: List[
Detection
] = [] # Cache for display to prevent flickering
self.latest_ticket: Optional[Ticket] = None
self.current_future: Optional[Future[InferenceResult]] = None
self.latest_result: Optional[InferenceResult] = None
# Buffer for temporal context (optional, for VLM or sequence logic)
# Frame buffer for temporal context
self.frame_buffer = deque(maxlen=30)
logger.info(
f"Pipeline initialized with {len(self.sinks)} sinks. Async Inference enabled."
"Pipeline initialized with %d sinks. Config: %s",
len(self.sinks),
self.cfg,
)
def process_stream(self, source: StreamSource):
"""Main loop processing the video stream."""
"""Main loop: read frames, schedule inference, dispatch to sinks."""
logger.info("Starting stream processing...")
count = 0
frame_count = 0
for frame in source.stream():
count += 1
if count % 100 == 0:
logger.info(f"Processed {count} frames...")
frame_count += 1
if frame_count % 100 == 0:
logger.info("Processed %d frames...", frame_count)
self.frame_buffer.append(frame)
self._poll_inference_result(frame)
self._maybe_schedule_inference(frame)
self._apply_detections_to_frame(frame)
self._dispatch_to_sinks(frame)
# 1. Check if previous inference finished
if self.current_future and self.current_future.done():
try:
# Retrieve results from the finished job
has_activity, detections, ticket = self.current_future.result()
# Update Cache
self.latest_detections = detections
self.latest_ticket = ticket
self.current_future = None
# Adaptive Logic Update
if has_activity:
if self.current_interval != self.active_interval:
logger.info(
"Frame %s: Activity detected! Switching to ACTIVE mode (0s interval).",
frame.frame_id,
)
self.current_interval = self.active_interval
self.active_cooldown = self.cooldown_frames
elif self.active_cooldown > 0:
if self.active_cooldown % 30 == 0:
logger.debug(
"Cooling down... %s frames left.", self.active_cooldown
)
self.active_cooldown -= 1
else:
if self.current_interval != self.idle_interval:
logger.info(
"Cooldown complete. Switching to IDLE mode (0.5s interval)."
)
self.current_interval = self.idle_interval
except Exception as e:
logger.error(f"Inference error: {e}")
self.current_future = None
# 2. Schedule new inference if idle and time condition met
now = frame.timestamp
time_since_last = now - self.last_inference_time
if self.current_future is None and time_since_last >= self.current_interval:
# Submit 'detect' job. We pass a COPY of data or rely on immutable usage.
# Frame.data is numpy, which is mutable but we trust models not to write to it.
# Since models might run on GPU/Thread, and we continue, it's safer to not rely on 'frame' object mutation.
# We will return the results and apply them later.
self.last_inference_time = now
self.current_future = self.executor.submit(self._run_inference, frame)
# 3. Apply cached detections to current frame for visualization
# This ensures smooth video output even if inference runs at lower fps
frame.detections = self.latest_detections.copy()
# Ticket dispatch: Only attach to ONE frame, then clear
# This prevents duplicate alerts in sinks like FileAlertStore
if self.latest_ticket:
frame.ticket = self.latest_ticket
self.latest_ticket = None # Consume the ticket
# Update sinks (Visualizers, etc)
for sink in self.sinks:
sink.handle(frame)
logger.info("Stream processing finished.")
logger.info("Stream finished. Total frames: %d", frame_count)
self.executor.shutdown(wait=False)
def _run_inference(
self, frame: Frame
) -> Tuple[bool, List[Detection], Optional[Ticket]]:
def _poll_inference_result(self, frame: Frame):
"""Check if async inference completed, update state."""
if not self.current_future or not self.current_future.done():
return
try:
self.latest_result = self.current_future.result()
self._update_adaptive_sampling(frame, self.latest_result.has_activity)
except Exception as e:
logger.error("Inference error: %s", e)
finally:
self.current_future = None
def _update_adaptive_sampling(self, frame: Frame, has_activity: bool):
"""Switch between idle/active modes based on detections."""
if has_activity:
if self.current_interval != self.cfg.active_interval:
logger.info(
"Frame %d: Activity! Switching to ACTIVE mode.", frame.frame_id
)
self.current_interval = self.cfg.active_interval
self.active_cooldown = self.cfg.cooldown_frames
elif self.active_cooldown > 0:
self.active_cooldown -= 1
else:
if self.current_interval != self.cfg.idle_interval:
logger.info("Cooldown complete. Switching to IDLE mode.")
self.current_interval = self.cfg.idle_interval
def _maybe_schedule_inference(self, frame: Frame):
"""Schedule inference if enough time has passed."""
if self.current_future is not None:
return
time_since_last = frame.timestamp - self.last_inference_time
if time_since_last >= self.current_interval:
self.last_inference_time = frame.timestamp
self.current_future = self.executor.submit(self._run_inference, frame)
def _apply_detections_to_frame(self, frame: Frame):
"""Apply cached detections to current frame for visualization."""
if self.latest_result:
frame.detections = self.latest_result.detections.copy()
# Consume ticket (only dispatch once)
if self.latest_result.ticket:
frame.ticket = self.latest_result.ticket
self.latest_result.ticket = None
def _dispatch_to_sinks(self, frame: Frame):
"""Send frame to all registered sinks."""
for sink in self.sinks:
sink.handle(frame)
# -------------------------------------------------------------------------
# Inference Chain
# -------------------------------------------------------------------------
def _run_inference(self, frame: Frame) -> InferenceResult:
"""
Runs the full detection chain.
Returns (ActivityDetected, ListOfDetections, TicketOrNone).
Does NOT modify frame in place (thread safety).
Full detection chain. Thread-safe (doesn't mutate frame).
Returns InferenceResult with detections and optional ticket.
"""
t0 = time.time()
detections = []
# --- Step 1: Broad Phase (Person Detection) ---
people = self.person_model.predict(frame.data)
people = nms_by_class(people, iou_threshold=0.7)
detections: List[Detection] = []
# Stage 1: Person Detection
people = self._detect_people(frame)
detections.extend(people)
if not people:
# logger.debug("No people found.")
return False, detections, None
return InferenceResult(has_activity=False, detections=detections)
# --- Step 2: Narrow Phase (Cigarette Candidate) ---
candidates = self._find_candidates(frame, people, detections)
# Stage 2: Cigarette Detection (with early exit for high confidence)
candidates, early_hit = self._detect_cigarettes(frame, people, detections)
if early_hit:
# High-confidence cig detection, skip verifier
ticket = self._create_ticket(frame, early_hit, stage="Detector")
detections = nms_by_class(detections, self.cfg.final_nms_iou)
self._log_inference_time(t0)
return InferenceResult(
has_activity=True, detections=detections, ticket=ticket
)
if not candidates:
return True, detections, None
return InferenceResult(has_activity=True, detections=detections)
# --- Step 3: Verification (Classifier) ---
best_hit = self._verify_candidates(frame, candidates, detections)
# Stage 3: Verification
verified_hit = self._verify_candidates(frame, candidates, detections)
ticket = None
# --- Step 4: Action ---
if best_hit:
person, cig, score = best_hit
logger.info("CONFIRMED SMOKING! Score: %.2f", score)
ticket = Ticket(
frame_id=frame.frame_id,
confidence=score,
evidence=[frame.data], # Note: this keeps ref to image data
stage_confirmed="Verifier",
)
if verified_hit:
ticket = self._create_ticket(frame, verified_hit, stage="Verifier")
# Add the verified box to detections for visualization
verified_det = Detection(
box=BoundingBox(
x1=person.box.x1
+ cig.box.x1, # Approx logic, need exact coords from find_candidates
y1=person.box.y1 + cig.box.y1,
x2=person.box.x1 + cig.box.x2,
y2=person.box.y1 + cig.box.y2,
conf=score, # Placeholder
),
score=score,
label="verified_smoking",
)
detections.append(verified_det)
detections = nms_by_class(detections, self.cfg.final_nms_iou)
self._log_inference_time(t0)
return InferenceResult(has_activity=True, detections=detections, ticket=ticket)
# Clean up overlapping boxes from model output
detections = nms_by_class(detections, iou_threshold=0.5)
def _detect_people(self, frame: Frame) -> List[Detection]:
"""Stage 1: Detect people in frame."""
people = self.person_model.predict(frame.data)
return nms_by_class(people, self.cfg.person_nms_iou)
dt = time.time() - t0
if dt > 0.1:
logger.debug("Inference took %.3fs (Slow)", dt)
def _detect_cigarettes(
self,
frame: Frame,
people: List[Detection],
detections_out: List[Detection],
) -> Tuple[List[Tuple], Optional[Tuple]]:
"""
Stage 2: Detect cigarettes in person crops.
return True, detections, ticket
def _find_candidates(
self, frame: Frame, people: List[Detection], detections_out: List[Detection]
) -> List[Tuple[Detection, List[Detection], Tuple[int, int]]]:
"""Runs cigarette detection on expanded person crops.
Args:
detections_out: List to append candidate detections to (thread-safe output)
Returns:
(candidates, early_hit)
- candidates: list of (person, cigs, offset) for verification
- early_hit: (person, cig, score, offset) if high-confidence detection, else None
"""
candidates = []
early_hit = None
h_img, w_img = frame.data.shape[:2]
margin = 0.2 # 20% expansion
cfg = self.cfg
for person in people:
# 1. Expand Box
pb = person.box
bw = pb.x2 - pb.x1
bh = pb.y2 - pb.y1
ex1 = max(0, int(pb.x1 - bw * margin))
ey1 = max(0, int(pb.y1 - bh * margin))
ex2 = min(w_img, int(pb.x2 + bw * margin))
ey2 = min(h_img, int(pb.y2 + bh * margin))
# 2. Crop
# Expand person box
crop_box = self._expand_box(person.box, cfg.person_margin, w_img, h_img)
ex1, ey1, ex2, ey2 = crop_box
person_crop = frame.data[ey1:ey2, ex1:ex2]
if (
person_crop.size == 0
or person_crop.shape[0] < 10
or person_crop.shape[1] < 10
):
if not self._is_valid_crop(person_crop):
continue
# 3. Detect
cig_candidates = self.cigarette_model.predict(person_crop)
# Detect cigarettes in crop
cigs = self.cigarette_model.predict(person_crop)
cigs = self._filter_small_boxes(cigs, cfg.min_cig_size)
cigs = nms_by_class(cigs, cfg.cig_nms_iou)
# 4. Filter Noise + NMS (model can return overlapping boxes for same cig)
cig_candidates = [
c
for c in cig_candidates
if (c.box.x2 - c.box.x1) >= 10 and (c.box.y2 - c.box.y1) >= 10
]
cig_candidates = nms_by_class(cig_candidates, iou_threshold=0.5)
for cig in cigs:
# Add to visualization
global_det = self._to_global_coords(cig, ex1, ey1, "candidate_cig")
detections_out.append(global_det)
if cig_candidates:
candidates.append((person, cig_candidates, (ex1, ey1)))
# 5. Global Coordinate Mapping (For Visualization)
for cig in cig_candidates:
global_cig = Detection(
box=BoundingBox(
x1=ex1 + cig.box.x1,
y1=ey1 + cig.box.y1,
x2=ex1 + cig.box.x2,
y2=ey1 + cig.box.y2,
conf=cig.box.conf,
),
score=cig.score,
label="candidate_cig",
metadata=cig.metadata,
# Check for early exit (high confidence)
if cig.score >= cfg.cig_high_confidence and early_hit is None:
early_hit = (person, cig, cig.score, (ex1, ey1))
logger.info(
"High-confidence cig detection (%.2f) - skipping verifier",
cig.score,
)
detections_out.append(global_cig)
return candidates
if cigs:
candidates.append((person, cigs, (ex1, ey1)))
return candidates, early_hit
def _verify_candidates(
self, frame: Frame, candidates: List[Tuple], detections_out: List[Detection]
self,
frame: Frame,
candidates: List[Tuple],
detections_out: List[Detection],
) -> Optional[Tuple]:
"""Runs verification on candidate crops.
Args:
detections_out: List to append verified detections to (thread-safe output)
"""
confirmed_hits = []
Stage 3: Run classifier on cigarette crops.
Returns best (person, cig, score, offset) if verified, else None.
"""
confirmed = []
h_img, w_img = frame.data.shape[:2]
cfg = self.cfg
for person, cigs, (ex1, ey1) in candidates:
for cig in cigs:
# 1. Derive Crop Coordinates
cig_w = cig.box.x2 - cig.box.x1
cig_h = cig.box.y2 - cig.box.y1
pad_x = int(cig_w * 0.5)
pad_y = int(cig_h * 0.5)
global_cx1 = ex1 + max(0, cig.box.x1 - pad_x)
global_cy1 = ey1 + max(0, cig.box.y1 - pad_y)
global_cx2 = ex1 + cig.box.x2 + pad_x
global_cy2 = ey1 + cig.box.y2 + pad_y
# Check bounds
h_img, w_img = frame.data.shape[:2]
final_x1 = max(0, global_cx1)
final_y1 = max(0, global_cy1)
final_x2 = min(w_img, global_cx2)
final_y2 = min(h_img, global_cy2)
# 2. Crop
obj_crop = frame.data[final_y1:final_y2, final_x1:final_x2]
if obj_crop.size == 0:
# Already caught by early exit?
if cig.score >= cfg.cig_high_confidence:
continue
# 3. Verify
score = self.verifier_model.verify(obj_crop)
# Extract padded crop for classifier
crop = self._extract_cig_crop(frame.data, cig, ex1, ey1, w_img, h_img)
if crop.size == 0:
continue
if score > 0.8: # High confidence
confirmed_hits.append((person, cig, score))
score = self.verifier_model.verify(crop)
verified_det = Detection(
box=BoundingBox(
x1=ex1 + cig.box.x1,
y1=ey1 + cig.box.y1,
x2=ex1 + cig.box.x2,
y2=ey1 + cig.box.y2,
conf=score,
),
score=score,
label="verified_smoking",
metadata={"verifier_score": score},
if score >= cfg.verify_threshold:
confirmed.append((person, cig, score, (ex1, ey1)))
# Add verified detection
verified_det = self._to_global_coords(
cig, ex1, ey1, "verified_smoking"
)
verified_det.score = score
verified_det.metadata["verifier_score"] = score
detections_out.append(verified_det)
if confirmed_hits:
return max(confirmed_hits, key=lambda x: x[2])
return None
return max(confirmed, key=lambda x: x[2]) if confirmed else None
# -------------------------------------------------------------------------
# Helpers
# -------------------------------------------------------------------------
def _expand_box(
self, box: BoundingBox, margin: float, w_max: int, h_max: int
) -> Tuple[int, int, int, int]:
"""Expand box by margin, clamped to image bounds."""
bw, bh = box.x2 - box.x1, box.y2 - box.y1
x1 = max(0, int(box.x1 - bw * margin))
y1 = max(0, int(box.y1 - bh * margin))
x2 = min(w_max, int(box.x2 + bw * margin))
y2 = min(h_max, int(box.y2 + bh * margin))
return x1, y1, x2, y2
def _is_valid_crop(self, crop) -> bool:
"""Check if crop is large enough to process."""
return (
crop.size > 0
and crop.shape[0] >= self.cfg.min_crop_size
and crop.shape[1] >= self.cfg.min_crop_size
)
def _filter_small_boxes(
self, detections: List[Detection], min_size: int
) -> List[Detection]:
"""Remove boxes smaller than min_size."""
return [
d
for d in detections
if (d.box.x2 - d.box.x1) >= min_size and (d.box.y2 - d.box.y1) >= min_size
]
def _to_global_coords(
self, det: Detection, offset_x: int, offset_y: int, label: str
) -> Detection:
"""Convert detection from crop coords to global frame coords."""
return Detection(
box=BoundingBox(
x1=offset_x + det.box.x1,
y1=offset_y + det.box.y1,
x2=offset_x + det.box.x2,
y2=offset_y + det.box.y2,
conf=det.box.conf,
),
score=det.score,
label=label,
metadata=det.metadata.copy(),
)
def _extract_cig_crop(
self, image, cig: Detection, ex1: int, ey1: int, w_max: int, h_max: int
):
"""Extract padded crop around cigarette for verification."""
pad = self.cfg.cig_crop_padding
cig_w = cig.box.x2 - cig.box.x1
cig_h = cig.box.y2 - cig.box.y1
# Global coords with padding
x1 = max(0, ex1 + cig.box.x1 - int(cig_w * pad))
y1 = max(0, ey1 + cig.box.y1 - int(cig_h * pad))
x2 = min(w_max, ex1 + cig.box.x2 + int(cig_w * pad))
y2 = min(h_max, ey1 + cig.box.y2 + int(cig_h * pad))
return image[y1:y2, x1:x2]
def _create_ticket(self, frame: Frame, hit: Tuple, stage: str) -> Ticket:
"""Create alert ticket from detection hit."""
person, cig, score, (ex1, ey1) = hit
logger.info("CONFIRMED SMOKING! Score: %.2f (Stage: %s)", score, stage)
return Ticket(
frame_id=frame.frame_id,
confidence=score,
evidence=[frame.data],
stage_confirmed=stage,
)
def _log_inference_time(self, t0: float):
"""Log if inference was slow."""
dt = time.time() - t0
if dt > 0.1:
logger.debug("Inference took %.3fs (slow)", dt)