amalsp commited on
Commit
37b8651
·
0 Parent(s):

chore: publish PPE Sentinel Space

Browse files
.dockerignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .venv
3
+ __pycache__
4
+ .pytest_cache
5
+ .hf-cache
6
+ artifacts
7
+ dist
8
+ tests
9
+ docs
10
+ deploy
.env.example ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ OPENAI_API_KEY=
2
+ OPENAI_MODEL=gpt-5.2
3
+ DETECTION_THRESHOLD=0.26
4
+ DEFAULT_REQUIRED_ITEMS=helmet,vest
5
+ MAX_UPLOAD_SIZE_MB=60
6
+ MAX_VIDEO_SAMPLES=10
7
+ APP_HOST=0.0.0.0
8
+ APP_PORT=7860
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ HF_HOME=/app/.hf-cache
6
+
7
+ WORKDIR /app
8
+
9
+ RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/*
10
+
11
+ COPY requirements.txt .
12
+ RUN pip install --no-cache-dir -r requirements.txt
13
+
14
+ COPY . .
15
+
16
+ EXPOSE 7860
17
+
18
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: PPE Sentinel
3
+ emoji: 🦺
4
+ colorFrom: amber
5
+ colorTo: slate
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # PPE Sentinel
12
+
13
+ Upload construction photos or short videos to detect workers, helmets, vests, masks, and gloves.
14
+
15
+ ## Space secrets
16
+
17
+ - `OPENAI_API_KEY`
18
+
19
+ ## Notes
20
+
21
+ - The first run downloads the zero-shot detection model from Hugging Face.
22
+ - For production usage, keep videos short and enable persistent storage if you want artifact history.
app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """PPE Sentinel application package."""
app/api/routes.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile
6
+ from fastapi.responses import HTMLResponse, JSONResponse
7
+ from fastapi.templating import Jinja2Templates
8
+
9
+ from app.core.config import settings
10
+ from app.models.schemas import AnalyzeResponse
11
+ from app.services.detection import analyze_image_bytes, analyze_video_bytes
12
+ from app.services.reporting import build_openai_report, build_rule_based_report
13
+
14
+
15
+ router = APIRouter()
16
+ templates = Jinja2Templates(directory=str(settings.templates_dir))
17
+
18
+ IMAGE_TYPES = {".jpg", ".jpeg", ".png", ".webp"}
19
+ VIDEO_TYPES = {".mp4", ".mov", ".avi", ".mkv", ".webm"}
20
+
21
+
22
+ def parse_required_items(value: str | None) -> list[str]:
23
+ if not value:
24
+ return list(settings.required_items)
25
+ items = [item.strip().lower() for item in value.split(",") if item.strip()]
26
+ return items or list(settings.required_items)
27
+
28
+
29
+ @router.get("/", response_class=HTMLResponse)
30
+ async def index(request: Request) -> HTMLResponse:
31
+ return templates.TemplateResponse(
32
+ request=request,
33
+ name="index.html",
34
+ context={
35
+ "app_name": settings.app_name,
36
+ "tagline": settings.app_tagline,
37
+ "default_required_items": list(settings.required_items),
38
+ "openai_enabled": bool(settings.openai_api_key),
39
+ },
40
+ )
41
+
42
+
43
+ @router.get("/health")
44
+ async def health() -> JSONResponse:
45
+ return JSONResponse(
46
+ {"status": "ok", "model": settings.grounding_model_id, "openai_enabled": bool(settings.openai_api_key)}
47
+ )
48
+
49
+
50
+ @router.post("/api/analyze", response_model=AnalyzeResponse)
51
+ async def analyze(
52
+ file: UploadFile = File(...),
53
+ required_items: str = Form(""),
54
+ generate_ai_summary: bool = Form(True),
55
+ ) -> AnalyzeResponse:
56
+ suffix = Path(file.filename or "upload.bin").suffix.lower()
57
+ if suffix not in IMAGE_TYPES | VIDEO_TYPES:
58
+ raise HTTPException(status_code=400, detail="Unsupported file type. Upload an image or a short video.")
59
+
60
+ payload = await file.read()
61
+ if len(payload) > settings.max_upload_size_mb * 1024 * 1024:
62
+ raise HTTPException(status_code=400, detail=f"File exceeds {settings.max_upload_size_mb} MB upload limit.")
63
+
64
+ chosen_required_items = parse_required_items(required_items)
65
+ filename = file.filename or "upload"
66
+
67
+ try:
68
+ if suffix in IMAGE_TYPES:
69
+ detections, workers, site_summary, annotated_asset = analyze_image_bytes(payload, chosen_required_items)
70
+ media_type = "image"
71
+ frames = []
72
+ else:
73
+ detections, workers, site_summary, annotated_asset, frames = analyze_video_bytes(payload, chosen_required_items)
74
+ media_type = "video"
75
+ except ValueError as exc:
76
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
77
+ except Exception as exc:
78
+ raise HTTPException(status_code=500, detail=f"Analysis failed: {exc}") from exc
79
+
80
+ report = (
81
+ build_openai_report(filename, media_type, site_summary, workers, chosen_required_items)
82
+ if generate_ai_summary
83
+ else build_rule_based_report(filename, media_type, site_summary, workers, chosen_required_items)
84
+ )
85
+
86
+ return AnalyzeResponse(
87
+ media_type=media_type,
88
+ filename=filename,
89
+ required_items=chosen_required_items,
90
+ site_summary=site_summary,
91
+ detections=detections,
92
+ workers=workers,
93
+ report=report,
94
+ annotated_asset=annotated_asset,
95
+ frames=frames,
96
+ )
app/core/config.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+ from dotenv import load_dotenv
8
+
9
+
10
+ BASE_DIR = Path(__file__).resolve().parents[2]
11
+ load_dotenv(BASE_DIR / ".env")
12
+
13
+
14
+ @dataclass(slots=True)
15
+ class Settings:
16
+ app_name: str = "PPE Sentinel"
17
+ app_tagline: str = "Industrial-grade PPE compliance intelligence"
18
+ environment: str = os.getenv("APP_ENV", "development")
19
+ host: str = os.getenv("APP_HOST", "0.0.0.0")
20
+ port: int = int(os.getenv("APP_PORT", "7860"))
21
+ detection_threshold: float = float(os.getenv("DETECTION_THRESHOLD", "0.26"))
22
+ max_upload_size_mb: int = int(os.getenv("MAX_UPLOAD_SIZE_MB", "60"))
23
+ max_video_samples: int = int(os.getenv("MAX_VIDEO_SAMPLES", "10"))
24
+ openai_api_key: str = os.getenv("OPENAI_API_KEY", "")
25
+ openai_model: str = os.getenv("OPENAI_MODEL", "gpt-5.2")
26
+ grounding_model_id: str = os.getenv("GROUNDING_MODEL_ID", "IDEA-Research/grounding-dino-tiny")
27
+ hf_cache_dir: str = os.getenv("HF_HOME", str(BASE_DIR / ".hf-cache"))
28
+ required_items: tuple[str, ...] = field(
29
+ default_factory=lambda: tuple(
30
+ item.strip()
31
+ for item in os.getenv("DEFAULT_REQUIRED_ITEMS", "helmet,vest").split(",")
32
+ if item.strip()
33
+ )
34
+ )
35
+ artifacts_dir: Path = field(default_factory=lambda: BASE_DIR / "artifacts")
36
+ static_dir: Path = field(default_factory=lambda: BASE_DIR / "app" / "static")
37
+ templates_dir: Path = field(default_factory=lambda: BASE_DIR / "app" / "templates")
38
+
39
+ def ensure_directories(self) -> None:
40
+ self.artifacts_dir.mkdir(parents=True, exist_ok=True)
41
+ Path(self.hf_cache_dir).mkdir(parents=True, exist_ok=True)
42
+
43
+
44
+ settings = Settings()
45
+ settings.ensure_directories()
app/main.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from fastapi import FastAPI
4
+ from fastapi.staticfiles import StaticFiles
5
+
6
+ from app.api.routes import router
7
+ from app.core.config import settings
8
+
9
+
10
+ app = FastAPI(title=settings.app_name)
11
+ app.mount("/static", StaticFiles(directory=str(settings.static_dir)), name="static")
12
+ app.mount("/artifacts", StaticFiles(directory=str(settings.artifacts_dir)), name="artifacts")
13
+ app.include_router(router)
app/models/schemas.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class BoundingBox(BaseModel):
9
+ xmin: float
10
+ ymin: float
11
+ xmax: float
12
+ ymax: float
13
+
14
+ @property
15
+ def width(self) -> float:
16
+ return max(0.0, self.xmax - self.xmin)
17
+
18
+ @property
19
+ def height(self) -> float:
20
+ return max(0.0, self.ymax - self.ymin)
21
+
22
+
23
+ class Detection(BaseModel):
24
+ label: str
25
+ score: float
26
+ box: BoundingBox
27
+
28
+
29
+ class WorkerCompliance(BaseModel):
30
+ worker_id: str
31
+ status: Literal["compliant", "non-compliant"]
32
+ score: float
33
+ required_items: list[str]
34
+ present_items: list[str]
35
+ missing_items: list[str]
36
+ box: BoundingBox
37
+
38
+
39
+ class ExecutiveReport(BaseModel):
40
+ source: Literal["openai", "rules"]
41
+ title: str
42
+ summary: str
43
+ actions: list[str] = Field(default_factory=list)
44
+
45
+
46
+ class MediaAsset(BaseModel):
47
+ kind: Literal["image", "video_storyboard"]
48
+ url: str
49
+ width: int | None = None
50
+ height: int | None = None
51
+
52
+
53
+ class FrameAnalysis(BaseModel):
54
+ timestamp_seconds: float
55
+ total_workers: int
56
+ compliant_workers: int
57
+ non_compliant_workers: int
58
+ risk_level: Literal["low", "medium", "high"]
59
+ thumbnail_url: str
60
+
61
+
62
+ class SiteSummary(BaseModel):
63
+ total_workers: int
64
+ compliant_workers: int
65
+ non_compliant_workers: int
66
+ compliance_rate: float
67
+ detected_items: dict[str, int]
68
+ missing_items: dict[str, int]
69
+ status: Literal["clear", "attention"]
70
+
71
+
72
+ class AnalyzeResponse(BaseModel):
73
+ media_type: Literal["image", "video"]
74
+ filename: str
75
+ required_items: list[str]
76
+ site_summary: SiteSummary
77
+ detections: list[Detection]
78
+ workers: list[WorkerCompliance]
79
+ report: ExecutiveReport
80
+ annotated_asset: MediaAsset
81
+ frames: list[FrameAnalysis] = Field(default_factory=list)
app/services/compliance.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections import Counter
5
+
6
+ from app.models.schemas import BoundingBox, Detection, SiteSummary, WorkerCompliance
7
+
8
+
9
+ LABEL_ALIASES = {
10
+ "person": "person",
11
+ "worker": "person",
12
+ "helmet": "helmet",
13
+ "hard hat": "helmet",
14
+ "safety helmet": "helmet",
15
+ "hardhat": "helmet",
16
+ "vest": "vest",
17
+ "safety vest": "vest",
18
+ "reflective vest": "vest",
19
+ "high visibility vest": "vest",
20
+ "hi vis vest": "vest",
21
+ "hi-vis vest": "vest",
22
+ "mask": "mask",
23
+ "face mask": "mask",
24
+ "respirator mask": "mask",
25
+ "glove": "gloves",
26
+ "gloves": "gloves",
27
+ "safety gloves": "gloves",
28
+ }
29
+
30
+
31
+ def normalize_label(label: str) -> str:
32
+ cleaned = re.sub(r"\s+", " ", label.strip().lower())
33
+ return LABEL_ALIASES.get(cleaned, cleaned)
34
+
35
+
36
+ def center_of(box: BoundingBox) -> tuple[float, float]:
37
+ return ((box.xmin + box.xmax) / 2, (box.ymin + box.ymax) / 2)
38
+
39
+
40
+ def intersection_area(box_a: BoundingBox, box_b: BoundingBox) -> float:
41
+ x_left = max(box_a.xmin, box_b.xmin)
42
+ y_top = max(box_a.ymin, box_b.ymin)
43
+ x_right = min(box_a.xmax, box_b.xmax)
44
+ y_bottom = min(box_a.ymax, box_b.ymax)
45
+ if x_right <= x_left or y_bottom <= y_top:
46
+ return 0.0
47
+ return (x_right - x_left) * (y_bottom - y_top)
48
+
49
+
50
+ def iou(box_a: BoundingBox, box_b: BoundingBox) -> float:
51
+ inter = intersection_area(box_a, box_b)
52
+ union = box_a.width * box_a.height + box_b.width * box_b.height - inter
53
+ if union <= 0:
54
+ return 0.0
55
+ return inter / union
56
+
57
+
58
+ def item_matches_person(item: Detection, person: Detection) -> bool:
59
+ ix, iy = center_of(item.box)
60
+ inside_box = person.box.xmin <= ix <= person.box.xmax and person.box.ymin <= iy <= person.box.ymax
61
+ if not inside_box and intersection_area(item.box, person.box) <= 0:
62
+ return False
63
+
64
+ height = max(person.box.height, 1.0)
65
+ rel_y = (iy - person.box.ymin) / height
66
+
67
+ if item.label == "helmet":
68
+ return rel_y <= 0.42
69
+ if item.label == "mask":
70
+ return rel_y <= 0.5
71
+ if item.label == "vest":
72
+ return 0.18 <= rel_y <= 0.88
73
+ if item.label == "gloves":
74
+ return 0.2 <= rel_y <= 1.02
75
+ return True
76
+
77
+
78
+ def class_aware_nms(detections: list[Detection], threshold: float = 0.45) -> list[Detection]:
79
+ grouped: dict[str, list[Detection]] = {}
80
+ for detection in detections:
81
+ grouped.setdefault(detection.label, []).append(detection)
82
+
83
+ final: list[Detection] = []
84
+ for group in grouped.values():
85
+ kept: list[Detection] = []
86
+ for detection in sorted(group, key=lambda item: item.score, reverse=True):
87
+ if any(iou(detection.box, existing.box) >= threshold for existing in kept):
88
+ continue
89
+ kept.append(detection)
90
+ final.extend(kept)
91
+ return final
92
+
93
+
94
+ def evaluate_site(detections: list[Detection], required_items: list[str]) -> tuple[list[WorkerCompliance], SiteSummary]:
95
+ canonical_required = [normalize_label(item) for item in required_items]
96
+ people = [item for item in detections if item.label == "person"]
97
+ workers: list[WorkerCompliance] = []
98
+ detected_counter: Counter[str] = Counter()
99
+ missing_counter: Counter[str] = Counter()
100
+
101
+ for item in detections:
102
+ if item.label != "person":
103
+ detected_counter[item.label] += 1
104
+
105
+ for index, person in enumerate(sorted(people, key=lambda item: item.box.xmin), start=1):
106
+ attached_items = {
107
+ item.label
108
+ for item in detections
109
+ if item.label != "person" and item_matches_person(item, person)
110
+ }
111
+ missing_items = [item for item in canonical_required if item not in attached_items]
112
+ for missing in missing_items:
113
+ missing_counter[missing] += 1
114
+
115
+ present_items = [item for item in canonical_required if item in attached_items]
116
+ score = 100.0 if not canonical_required else round((len(present_items) / len(canonical_required)) * 100, 1)
117
+ workers.append(
118
+ WorkerCompliance(
119
+ worker_id=f"W-{index:02d}",
120
+ status="compliant" if not missing_items else "non-compliant",
121
+ score=score,
122
+ required_items=canonical_required,
123
+ present_items=present_items,
124
+ missing_items=missing_items,
125
+ box=person.box,
126
+ )
127
+ )
128
+
129
+ compliant_workers = sum(1 for worker in workers if worker.status == "compliant")
130
+ total_workers = len(workers)
131
+ compliance_rate = round((compliant_workers / total_workers) * 100, 1) if total_workers else 0.0
132
+ summary = SiteSummary(
133
+ total_workers=total_workers,
134
+ compliant_workers=compliant_workers,
135
+ non_compliant_workers=max(total_workers - compliant_workers, 0),
136
+ compliance_rate=compliance_rate,
137
+ detected_items=dict(sorted(detected_counter.items())),
138
+ missing_items=dict(sorted(missing_counter.items())),
139
+ status="clear" if total_workers and compliant_workers == total_workers else "attention",
140
+ )
141
+ return workers, summary
142
+
143
+
144
+ def frame_risk_level(summary: SiteSummary) -> str:
145
+ if summary.non_compliant_workers == 0:
146
+ return "low"
147
+ if summary.compliance_rate >= 60:
148
+ return "medium"
149
+ return "high"
app/services/detection.py ADDED
@@ -0,0 +1,196 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import math
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ import cv2
10
+ import torch
11
+ from PIL import Image, ImageDraw, ImageOps
12
+ from transformers import AutoModelForZeroShotObjectDetection, AutoProcessor
13
+
14
+ from app.core.config import settings
15
+ from app.models.schemas import BoundingBox, Detection, FrameAnalysis, MediaAsset, SiteSummary, WorkerCompliance
16
+ from app.services.compliance import class_aware_nms, evaluate_site, frame_risk_level, normalize_label
17
+ from app.services.storage import save_image
18
+
19
+
20
+ TEXT_QUERIES = [
21
+ "person",
22
+ "helmet",
23
+ "hard hat",
24
+ "safety helmet",
25
+ "safety vest",
26
+ "reflective vest",
27
+ "high visibility vest",
28
+ "face mask",
29
+ "gloves",
30
+ ]
31
+
32
+ COLOR_MAP = {
33
+ "person": "#8B5CF6",
34
+ "helmet": "#22C55E",
35
+ "vest": "#F59E0B",
36
+ "mask": "#06B6D4",
37
+ "gloves": "#EF4444",
38
+ }
39
+
40
+
41
+ class PpeDetector:
42
+ def __init__(self) -> None:
43
+ self._processor = None
44
+ self._model = None
45
+ self._device = "cuda" if torch.cuda.is_available() else "cpu"
46
+
47
+ def _ensure_model(self) -> None:
48
+ if self._processor is not None and self._model is not None:
49
+ return
50
+ os.environ.setdefault("HF_HOME", settings.hf_cache_dir)
51
+ self._processor = AutoProcessor.from_pretrained(settings.grounding_model_id)
52
+ self._model = AutoModelForZeroShotObjectDetection.from_pretrained(settings.grounding_model_id).to(self._device)
53
+ self._model.eval()
54
+
55
+ def detect_image(self, image: Image.Image) -> list[Detection]:
56
+ self._ensure_model()
57
+ prepared = image.convert("RGB")
58
+ inputs = self._processor(images=prepared, text=[TEXT_QUERIES], return_tensors="pt")
59
+ inputs = {key: value.to(self._device) if hasattr(value, "to") else value for key, value in inputs.items()}
60
+
61
+ with torch.inference_mode():
62
+ outputs = self._model(**inputs)
63
+
64
+ results = self._processor.post_process_grounded_object_detection(
65
+ outputs,
66
+ inputs["input_ids"],
67
+ box_threshold=settings.detection_threshold,
68
+ text_threshold=0.2,
69
+ target_sizes=[prepared.size[::-1]],
70
+ )
71
+
72
+ detections: list[Detection] = []
73
+ for result in results:
74
+ for score, label, box in zip(result["scores"], result["labels"], result["boxes"]):
75
+ canonical_label = normalize_label(str(label))
76
+ if canonical_label not in COLOR_MAP:
77
+ continue
78
+ xmin, ymin, xmax, ymax = [float(value) for value in box.tolist()]
79
+ detections.append(
80
+ Detection(
81
+ label=canonical_label,
82
+ score=round(float(score), 4),
83
+ box=BoundingBox(xmin=xmin, ymin=ymin, xmax=xmax, ymax=ymax),
84
+ )
85
+ )
86
+ return class_aware_nms(detections)
87
+
88
+
89
+ detector = PpeDetector()
90
+
91
+
92
+ def annotate_image(image: Image.Image, detections: list[Detection], workers: list[WorkerCompliance]) -> Image.Image:
93
+ canvas = image.convert("RGB").copy()
94
+ draw = ImageDraw.Draw(canvas)
95
+
96
+ for detection in detections:
97
+ color = COLOR_MAP.get(detection.label, "#FFFFFF")
98
+ box = detection.box
99
+ draw.rounded_rectangle((box.xmin, box.ymin, box.xmax, box.ymax), outline=color, width=4, radius=12)
100
+ label = f"{detection.label.upper()} {int(detection.score * 100)}%"
101
+ draw.text((box.xmin + 8, max(box.ymin - 22, 8)), label, fill=color)
102
+
103
+ for worker in workers:
104
+ color = "#22C55E" if worker.status == "compliant" else "#EF4444"
105
+ label = f"{worker.worker_id} {worker.status.replace('-', ' ').upper()}"
106
+ draw.text((worker.box.xmin + 8, worker.box.ymax + 8), label, fill=color)
107
+
108
+ return canvas
109
+
110
+
111
+ def build_storyboard(frames: list[Image.Image]) -> Image.Image:
112
+ thumb_width = 380
113
+ thumb_height = 220
114
+ cols = 2 if len(frames) > 1 else 1
115
+ rows = math.ceil(len(frames) / cols)
116
+ sheet = Image.new("RGB", (cols * thumb_width + 48, rows * thumb_height + 48), "#09101A")
117
+
118
+ for index, frame in enumerate(frames):
119
+ thumb = ImageOps.fit(frame, (thumb_width, thumb_height))
120
+ x = 24 + (index % cols) * thumb_width
121
+ y = 24 + (index // cols) * thumb_height
122
+ sheet.paste(thumb, (x, y))
123
+ return sheet
124
+
125
+
126
+ def analyze_image_bytes(payload: bytes, required_items: list[str]) -> tuple[list[Detection], list[WorkerCompliance], SiteSummary, MediaAsset]:
127
+ image = Image.open(io.BytesIO(payload)).convert("RGB")
128
+ detections = detector.detect_image(image)
129
+ workers, summary = evaluate_site(detections, required_items)
130
+ annotated = annotate_image(image, detections, workers)
131
+ url = save_image(annotated, ".png")
132
+ asset = MediaAsset(kind="image", url=url, width=annotated.width, height=annotated.height)
133
+ return detections, workers, summary, asset
134
+
135
+
136
+ def analyze_video_bytes(
137
+ payload: bytes, required_items: list[str]
138
+ ) -> tuple[list[Detection], list[WorkerCompliance], SiteSummary, MediaAsset, list[FrameAnalysis]]:
139
+ with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as temp_file:
140
+ temp_file.write(payload)
141
+ temp_path = Path(temp_file.name)
142
+
143
+ capture = cv2.VideoCapture(str(temp_path))
144
+ total_frames = int(capture.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
145
+ fps = float(capture.get(cv2.CAP_PROP_FPS) or 1.0)
146
+ step = max(1, math.ceil(total_frames / max(settings.max_video_samples, 1)))
147
+
148
+ sampled_frames: list[Image.Image] = []
149
+ analyses: list[FrameAnalysis] = []
150
+ all_detections: list[Detection] = []
151
+ last_workers: list[WorkerCompliance] = []
152
+ last_summary: SiteSummary | None = None
153
+
154
+ frame_index = 0
155
+ while capture.isOpened():
156
+ success, frame = capture.read()
157
+ if not success:
158
+ break
159
+ if frame_index % step != 0:
160
+ frame_index += 1
161
+ continue
162
+
163
+ rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
164
+ image = Image.fromarray(rgb)
165
+ detections = detector.detect_image(image)
166
+ workers, summary = evaluate_site(detections, required_items)
167
+ annotated = annotate_image(image, detections, workers)
168
+ thumb_url = save_image(ImageOps.fit(annotated, (720, 420)), ".png")
169
+ analyses.append(
170
+ FrameAnalysis(
171
+ timestamp_seconds=round(frame_index / max(fps, 1.0), 2),
172
+ total_workers=summary.total_workers,
173
+ compliant_workers=summary.compliant_workers,
174
+ non_compliant_workers=summary.non_compliant_workers,
175
+ risk_level=frame_risk_level(summary),
176
+ thumbnail_url=thumb_url,
177
+ )
178
+ )
179
+ sampled_frames.append(annotated)
180
+ all_detections.extend(detections)
181
+ last_workers = workers
182
+ last_summary = summary
183
+ frame_index += 1
184
+ if len(sampled_frames) >= settings.max_video_samples:
185
+ break
186
+
187
+ capture.release()
188
+ temp_path.unlink(missing_ok=True)
189
+
190
+ if not sampled_frames or last_summary is None:
191
+ raise ValueError("No analyzable frames were found in the uploaded video.")
192
+
193
+ storyboard = build_storyboard(sampled_frames)
194
+ asset = MediaAsset(kind="video_storyboard", url=save_image(storyboard, ".png"), width=storyboard.width, height=storyboard.height)
195
+ deduped_detections = class_aware_nms(all_detections, threshold=0.35)
196
+ return deduped_detections, last_workers, last_summary, asset, analyses
app/services/reporting.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ from openai import OpenAI
6
+
7
+ from app.core.config import settings
8
+ from app.models.schemas import ExecutiveReport, SiteSummary, WorkerCompliance
9
+
10
+
11
+ def build_rule_based_report(
12
+ filename: str,
13
+ media_type: str,
14
+ site_summary: SiteSummary,
15
+ workers: list[WorkerCompliance],
16
+ required_items: list[str],
17
+ ) -> ExecutiveReport:
18
+ if site_summary.non_compliant_workers == 0 and site_summary.total_workers > 0:
19
+ summary = (
20
+ f"{filename} was analyzed as a {media_type}. All {site_summary.total_workers} detected workers met "
21
+ f"the required PPE policy for {', '.join(required_items)}."
22
+ )
23
+ actions = [
24
+ "Keep this upload as a positive reference sample for shift leads.",
25
+ "Continue periodic spot checks to make sure compliance stays above 95%.",
26
+ ]
27
+ else:
28
+ missing_categories = ", ".join(site_summary.missing_items) or "multiple categories"
29
+ summary = (
30
+ f"{filename} shows {site_summary.non_compliant_workers} non-compliant workers out of "
31
+ f"{site_summary.total_workers} detected. Missing PPE is concentrated in {missing_categories}."
32
+ )
33
+ actions = [
34
+ "Escalate the flagged zone to the floor supervisor for an immediate PPE correction round.",
35
+ "Capture a follow-up scan after the correction to verify the compliance rate has improved.",
36
+ "Review induction signage near site entry points if the same missing items recur across uploads.",
37
+ ]
38
+ return ExecutiveReport(source="rules", title="Shift Safety Snapshot", summary=summary, actions=actions)
39
+
40
+
41
+ def build_openai_report(
42
+ filename: str,
43
+ media_type: str,
44
+ site_summary: SiteSummary,
45
+ workers: list[WorkerCompliance],
46
+ required_items: list[str],
47
+ ) -> ExecutiveReport:
48
+ if not settings.openai_api_key:
49
+ return build_rule_based_report(filename, media_type, site_summary, workers, required_items)
50
+
51
+ try:
52
+ client = OpenAI(api_key=settings.openai_api_key)
53
+ payload = {
54
+ "filename": filename,
55
+ "media_type": media_type,
56
+ "required_items": required_items,
57
+ "site_summary": site_summary.model_dump(),
58
+ "workers": [worker.model_dump() for worker in workers],
59
+ }
60
+ response = client.responses.create(
61
+ model=settings.openai_model,
62
+ max_output_tokens=500,
63
+ input=[
64
+ {
65
+ "role": "system",
66
+ "content": [
67
+ {
68
+ "type": "input_text",
69
+ "text": (
70
+ "You are an industrial safety analyst. Write a concise executive summary plus 3 "
71
+ "action items. Be specific, operational, and suitable for an enterprise dashboard."
72
+ ),
73
+ }
74
+ ],
75
+ },
76
+ {
77
+ "role": "user",
78
+ "content": [{"type": "input_text", "text": json.dumps(payload, indent=2)}],
79
+ },
80
+ ],
81
+ )
82
+ text = (response.output_text or "").strip()
83
+ except Exception:
84
+ return build_rule_based_report(filename, media_type, site_summary, workers, required_items)
85
+
86
+ if not text:
87
+ return build_rule_based_report(filename, media_type, site_summary, workers, required_items)
88
+
89
+ lines = [line.strip("- ").strip() for line in text.splitlines() if line.strip()]
90
+ title = lines[0][:80] if lines else "AI Safety Summary"
91
+ summary = lines[1] if len(lines) > 1 else text
92
+ actions = [line for line in lines[2:5]]
93
+ if not actions:
94
+ actions = [
95
+ "Review the flagged non-compliant workers and trigger a spot correction.",
96
+ "Re-scan the affected zone after corrective action.",
97
+ "Track repeat missing-item patterns for training improvements.",
98
+ ]
99
+ return ExecutiveReport(source="openai", title=title, summary=summary, actions=actions)
app/services/storage.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from uuid import uuid4
5
+
6
+ from PIL import Image
7
+
8
+ from app.core.config import settings
9
+
10
+
11
+ def save_bytes(content: bytes, suffix: str) -> str:
12
+ filename = f"{uuid4().hex}{suffix}"
13
+ target = settings.artifacts_dir / filename
14
+ target.write_bytes(content)
15
+ return f"/artifacts/{filename}"
16
+
17
+
18
+ def save_image(image: Image.Image, suffix: str = ".png") -> str:
19
+ filename = f"{uuid4().hex}{suffix}"
20
+ target = settings.artifacts_dir / filename
21
+ image.save(target)
22
+ return f"/artifacts/{filename}"
23
+
24
+
25
+ def save_text(text: str, suffix: str = ".md") -> str:
26
+ filename = f"{uuid4().hex}{suffix}"
27
+ target = settings.artifacts_dir / filename
28
+ target.write_text(text, encoding="utf-8")
29
+ return f"/artifacts/{filename}"
30
+
31
+
32
+ def absolute_artifact_path(relative_url: str) -> Path:
33
+ return settings.artifacts_dir / relative_url.replace("/artifacts/", "", 1)
app/static/css/styles.css ADDED
@@ -0,0 +1,487 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #08111f;
3
+ --panel: rgba(8, 19, 34, 0.8);
4
+ --panel-strong: #0e1c31;
5
+ --text: #e9eef9;
6
+ --muted: #98a6bf;
7
+ --lime: #8ce06e;
8
+ --amber: #ffb74d;
9
+ --red: #ff6b6b;
10
+ --cyan: #42d1ff;
11
+ --purple: #9f8cff;
12
+ --border: rgba(255, 255, 255, 0.09);
13
+ --shadow: 0 24px 60px rgba(0, 0, 0, 0.3);
14
+ }
15
+
16
+ * {
17
+ box-sizing: border-box;
18
+ }
19
+
20
+ body {
21
+ margin: 0;
22
+ min-height: 100vh;
23
+ font-family: "DM Sans", sans-serif;
24
+ color: var(--text);
25
+ background:
26
+ radial-gradient(circle at top left, rgba(66, 209, 255, 0.18), transparent 30%),
27
+ radial-gradient(circle at top right, rgba(255, 183, 77, 0.16), transparent 26%),
28
+ linear-gradient(135deg, #07101d 0%, #0f1b31 45%, #08111f 100%);
29
+ }
30
+
31
+ .noise {
32
+ position: fixed;
33
+ inset: 0;
34
+ pointer-events: none;
35
+ background-image: linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px),
36
+ linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
37
+ background-size: 28px 28px;
38
+ mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.7), transparent);
39
+ }
40
+
41
+ .topbar,
42
+ .page-shell {
43
+ width: min(1240px, calc(100vw - 32px));
44
+ margin: 0 auto;
45
+ }
46
+
47
+ .topbar {
48
+ display: flex;
49
+ justify-content: space-between;
50
+ align-items: center;
51
+ padding: 22px 0 10px;
52
+ }
53
+
54
+ .brand {
55
+ display: flex;
56
+ align-items: center;
57
+ gap: 14px;
58
+ }
59
+
60
+ .brand-mark {
61
+ display: grid;
62
+ place-items: center;
63
+ width: 52px;
64
+ height: 52px;
65
+ border-radius: 16px;
66
+ background: linear-gradient(135deg, var(--amber), #f97316);
67
+ color: #07101d;
68
+ font-family: "Space Grotesk", sans-serif;
69
+ font-weight: 700;
70
+ }
71
+
72
+ .brand h1,
73
+ .hero h2,
74
+ .section-head h3,
75
+ .report-header h4,
76
+ .section-head.minor h4,
77
+ .empty-state h4 {
78
+ margin: 0;
79
+ font-family: "Space Grotesk", sans-serif;
80
+ }
81
+
82
+ .eyebrow {
83
+ margin: 0 0 4px;
84
+ text-transform: uppercase;
85
+ letter-spacing: 0.16em;
86
+ font-size: 0.72rem;
87
+ color: var(--muted);
88
+ }
89
+
90
+ .status-chip,
91
+ .hero-pills span,
92
+ .drop-icon,
93
+ .ghost-button,
94
+ .primary-button,
95
+ .worker-pill,
96
+ .risk-pill {
97
+ border-radius: 999px;
98
+ }
99
+
100
+ .status-chip {
101
+ padding: 10px 16px;
102
+ border: 1px solid var(--border);
103
+ background: rgba(255, 255, 255, 0.04);
104
+ }
105
+
106
+ .status-chip.enabled {
107
+ color: #09111b;
108
+ background: linear-gradient(135deg, #7ee787, #9be15d);
109
+ }
110
+
111
+ .status-chip.muted {
112
+ color: var(--muted);
113
+ }
114
+
115
+ .page-shell {
116
+ padding: 12px 0 42px;
117
+ }
118
+
119
+ .workspace {
120
+ display: grid;
121
+ grid-template-columns: minmax(320px, 390px) minmax(0, 1fr);
122
+ gap: 22px;
123
+ }
124
+
125
+ .card {
126
+ position: relative;
127
+ border: 1px solid var(--border);
128
+ background: var(--panel);
129
+ backdrop-filter: blur(18px);
130
+ border-radius: 28px;
131
+ box-shadow: var(--shadow);
132
+ }
133
+
134
+ .hero {
135
+ display: grid;
136
+ grid-template-columns: 1.7fr 1fr;
137
+ gap: 18px;
138
+ padding: 28px;
139
+ margin-bottom: 22px;
140
+ }
141
+
142
+ .lead {
143
+ color: var(--muted);
144
+ line-height: 1.7;
145
+ max-width: 60ch;
146
+ }
147
+
148
+ .hero-pills {
149
+ display: flex;
150
+ flex-wrap: wrap;
151
+ gap: 10px;
152
+ margin-top: 18px;
153
+ }
154
+
155
+ .hero-pills span,
156
+ .worker-pill,
157
+ .risk-pill {
158
+ padding: 10px 14px;
159
+ border: 1px solid rgba(255, 255, 255, 0.08);
160
+ background: rgba(255, 255, 255, 0.05);
161
+ font-size: 0.9rem;
162
+ }
163
+
164
+ .hero-metrics {
165
+ display: grid;
166
+ gap: 12px;
167
+ }
168
+
169
+ .hero-metrics div,
170
+ .summary-card,
171
+ .report-panel,
172
+ .timeline-card,
173
+ .worker-card {
174
+ background: var(--panel-strong);
175
+ border: 1px solid var(--border);
176
+ border-radius: 22px;
177
+ }
178
+
179
+ .hero-metrics div {
180
+ padding: 18px;
181
+ }
182
+
183
+ .hero-metrics span {
184
+ display: block;
185
+ color: var(--muted);
186
+ margin-bottom: 8px;
187
+ }
188
+
189
+ .uploader,
190
+ .results {
191
+ padding: 24px;
192
+ }
193
+
194
+ .section-head {
195
+ display: flex;
196
+ justify-content: space-between;
197
+ gap: 14px;
198
+ align-items: flex-start;
199
+ margin-bottom: 20px;
200
+ }
201
+
202
+ .section-head.minor {
203
+ margin: 10px 0 14px;
204
+ }
205
+
206
+ .upload-form {
207
+ display: grid;
208
+ gap: 16px;
209
+ }
210
+
211
+ .dropzone {
212
+ border: 1.5px dashed rgba(255, 255, 255, 0.2);
213
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02));
214
+ border-radius: 24px;
215
+ padding: 34px 22px;
216
+ text-align: center;
217
+ cursor: pointer;
218
+ transition: transform 180ms ease, border-color 180ms ease, background 180ms ease;
219
+ }
220
+
221
+ .dropzone:hover,
222
+ .dropzone.dragging {
223
+ transform: translateY(-2px);
224
+ border-color: rgba(255, 183, 77, 0.8);
225
+ background: linear-gradient(180deg, rgba(255, 183, 77, 0.12), rgba(255, 255, 255, 0.04));
226
+ }
227
+
228
+ .dropzone input {
229
+ display: none;
230
+ }
231
+
232
+ .drop-icon {
233
+ display: inline-block;
234
+ padding: 8px 14px;
235
+ margin-bottom: 14px;
236
+ background: linear-gradient(135deg, var(--purple), var(--cyan));
237
+ color: #07101d;
238
+ font-weight: 700;
239
+ }
240
+
241
+ .form-grid {
242
+ display: grid;
243
+ gap: 16px;
244
+ }
245
+
246
+ label span {
247
+ display: block;
248
+ margin-bottom: 10px;
249
+ color: var(--muted);
250
+ font-size: 0.92rem;
251
+ }
252
+
253
+ input[type="text"] {
254
+ width: 100%;
255
+ padding: 14px 16px;
256
+ border-radius: 16px;
257
+ border: 1px solid var(--border);
258
+ background: rgba(255, 255, 255, 0.04);
259
+ color: var(--text);
260
+ font: inherit;
261
+ }
262
+
263
+ .toggle-row {
264
+ display: flex;
265
+ justify-content: space-between;
266
+ align-items: center;
267
+ padding: 14px 16px;
268
+ border-radius: 16px;
269
+ border: 1px solid var(--border);
270
+ background: rgba(255, 255, 255, 0.04);
271
+ }
272
+
273
+ input[type="checkbox"] {
274
+ width: 22px;
275
+ height: 22px;
276
+ accent-color: var(--amber);
277
+ }
278
+
279
+ .ghost-button,
280
+ .primary-button {
281
+ padding: 12px 16px;
282
+ border: 1px solid var(--border);
283
+ font: inherit;
284
+ cursor: pointer;
285
+ transition: transform 180ms ease, opacity 180ms ease;
286
+ }
287
+
288
+ .ghost-button {
289
+ background: rgba(255, 255, 255, 0.03);
290
+ color: var(--text);
291
+ }
292
+
293
+ .primary-button {
294
+ background: linear-gradient(135deg, var(--amber), #f97316);
295
+ color: #09111b;
296
+ font-weight: 700;
297
+ border: 0;
298
+ }
299
+
300
+ .ghost-button:hover,
301
+ .primary-button:hover {
302
+ transform: translateY(-1px);
303
+ }
304
+
305
+ .file-preview,
306
+ .loading-state,
307
+ .empty-state,
308
+ .report-summary,
309
+ .action-list,
310
+ .worker-meta,
311
+ .timeline-meta {
312
+ color: var(--muted);
313
+ }
314
+
315
+ .file-preview,
316
+ .loading-state,
317
+ .empty-state {
318
+ padding: 16px;
319
+ border-radius: 18px;
320
+ background: rgba(255, 255, 255, 0.04);
321
+ border: 1px solid var(--border);
322
+ }
323
+
324
+ .loading-state {
325
+ min-width: 150px;
326
+ text-align: center;
327
+ }
328
+
329
+ .summary-grid,
330
+ .worker-list,
331
+ .timeline-grid {
332
+ display: grid;
333
+ gap: 14px;
334
+ }
335
+
336
+ .summary-grid {
337
+ grid-template-columns: repeat(4, minmax(0, 1fr));
338
+ margin-bottom: 20px;
339
+ }
340
+
341
+ .summary-card {
342
+ padding: 18px;
343
+ }
344
+
345
+ .summary-card span {
346
+ display: block;
347
+ color: var(--muted);
348
+ margin-bottom: 10px;
349
+ }
350
+
351
+ .summary-card strong {
352
+ font-size: 2rem;
353
+ font-family: "Space Grotesk", sans-serif;
354
+ }
355
+
356
+ .media-panel img {
357
+ width: 100%;
358
+ border-radius: 22px;
359
+ border: 1px solid var(--border);
360
+ margin-bottom: 18px;
361
+ background: #06101d;
362
+ }
363
+
364
+ .report-panel {
365
+ padding: 20px;
366
+ margin-bottom: 18px;
367
+ }
368
+
369
+ .report-header {
370
+ display: flex;
371
+ justify-content: space-between;
372
+ gap: 14px;
373
+ align-items: flex-start;
374
+ margin-bottom: 12px;
375
+ }
376
+
377
+ .action-list {
378
+ display: grid;
379
+ gap: 10px;
380
+ }
381
+
382
+ .action-item,
383
+ .worker-card,
384
+ .timeline-card {
385
+ padding: 16px;
386
+ }
387
+
388
+ .action-item {
389
+ border-radius: 16px;
390
+ border: 1px solid var(--border);
391
+ background: rgba(255, 255, 255, 0.03);
392
+ }
393
+
394
+ .worker-list {
395
+ grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
396
+ }
397
+
398
+ .worker-card,
399
+ .timeline-card {
400
+ display: grid;
401
+ gap: 12px;
402
+ }
403
+
404
+ .worker-header,
405
+ .timeline-header {
406
+ display: flex;
407
+ justify-content: space-between;
408
+ align-items: center;
409
+ gap: 12px;
410
+ }
411
+
412
+ .worker-card h5,
413
+ .timeline-card h5 {
414
+ margin: 0;
415
+ font-family: "Space Grotesk", sans-serif;
416
+ font-size: 1rem;
417
+ }
418
+
419
+ .worker-pill.ok,
420
+ .risk-pill.low {
421
+ color: #0d1b13;
422
+ background: linear-gradient(135deg, #92f2a4, #7ee787);
423
+ }
424
+
425
+ .worker-pill.bad,
426
+ .risk-pill.high {
427
+ color: #2d0909;
428
+ background: linear-gradient(135deg, #ff9c9c, #ff6b6b);
429
+ }
430
+
431
+ .risk-pill.medium {
432
+ color: #2a1804;
433
+ background: linear-gradient(135deg, #ffd38a, #ffb74d);
434
+ }
435
+
436
+ .worker-meta strong {
437
+ color: var(--text);
438
+ }
439
+
440
+ .timeline-grid {
441
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
442
+ margin-top: 10px;
443
+ }
444
+
445
+ .timeline-card img {
446
+ width: 100%;
447
+ border-radius: 16px;
448
+ border: 1px solid var(--border);
449
+ }
450
+
451
+ .hidden {
452
+ display: none;
453
+ }
454
+
455
+ @media (max-width: 960px) {
456
+ .hero,
457
+ .workspace {
458
+ grid-template-columns: 1fr;
459
+ }
460
+
461
+ .summary-grid {
462
+ grid-template-columns: repeat(2, minmax(0, 1fr));
463
+ }
464
+ }
465
+
466
+ @media (max-width: 640px) {
467
+ .topbar {
468
+ flex-direction: column;
469
+ align-items: flex-start;
470
+ gap: 12px;
471
+ }
472
+
473
+ .summary-grid {
474
+ grid-template-columns: 1fr;
475
+ }
476
+
477
+ .uploader,
478
+ .results,
479
+ .hero {
480
+ padding: 18px;
481
+ }
482
+
483
+ .report-header,
484
+ .section-head {
485
+ flex-direction: column;
486
+ }
487
+ }
app/static/js/app.js ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const form = document.getElementById("analyzeForm");
2
+ const fileInput = document.getElementById("fileInput");
3
+ const requiredItems = document.getElementById("requiredItems");
4
+ const generateAiSummary = document.getElementById("generateAiSummary");
5
+ const submitButton = document.getElementById("submitButton");
6
+ const filePreview = document.getElementById("filePreview");
7
+ const loadingState = document.getElementById("loadingState");
8
+ const emptyState = document.getElementById("emptyState");
9
+ const resultsContent = document.getElementById("resultsContent");
10
+ const summaryGrid = document.getElementById("summaryGrid");
11
+ const annotatedImage = document.getElementById("annotatedImage");
12
+ const reportTitle = document.getElementById("reportTitle");
13
+ const reportSummary = document.getElementById("reportSummary");
14
+ const reportActions = document.getElementById("reportActions");
15
+ const workerList = document.getElementById("workerList");
16
+ const timelineSection = document.getElementById("timelineSection");
17
+ const timelineGrid = document.getElementById("timelineGrid");
18
+ const dropzone = document.getElementById("dropzone");
19
+ const demoPolicyButton = document.getElementById("demoPolicyButton");
20
+ const downloadJsonButton = document.getElementById("downloadJsonButton");
21
+
22
+ let latestResponse = null;
23
+
24
+ function setLoading(message, busy = false) {
25
+ loadingState.textContent = message;
26
+ submitButton.disabled = busy;
27
+ submitButton.style.opacity = busy ? "0.7" : "1";
28
+ }
29
+
30
+ function updateFilePreview() {
31
+ const file = fileInput.files?.[0];
32
+ filePreview.textContent = file
33
+ ? `${file.name} • ${(file.size / (1024 * 1024)).toFixed(2)} MB`
34
+ : "No file selected yet.";
35
+ }
36
+
37
+ function createSummaryCard(label, value, tone = "") {
38
+ const card = document.createElement("article");
39
+ card.className = `summary-card ${tone}`.trim();
40
+ card.innerHTML = `<span>${label}</span><strong>${value}</strong>`;
41
+ return card;
42
+ }
43
+
44
+ function renderWorkers(workers) {
45
+ workerList.innerHTML = "";
46
+ workers.forEach((worker) => {
47
+ const card = document.createElement("article");
48
+ card.className = "worker-card";
49
+ const statusClass = worker.status === "compliant" ? "ok" : "bad";
50
+ card.innerHTML = `
51
+ <div class="worker-header">
52
+ <h5>${worker.worker_id}</h5>
53
+ <span class="worker-pill ${statusClass}">${worker.status}</span>
54
+ </div>
55
+ <div class="worker-meta"><strong>Score:</strong> ${worker.score}%</div>
56
+ <div class="worker-meta"><strong>Present:</strong> ${worker.present_items.join(", ") || "None"}</div>
57
+ <div class="worker-meta"><strong>Missing:</strong> ${worker.missing_items.join(", ") || "None"}</div>
58
+ `;
59
+ workerList.appendChild(card);
60
+ });
61
+ }
62
+
63
+ function renderActions(actions) {
64
+ reportActions.innerHTML = "";
65
+ actions.forEach((action) => {
66
+ const item = document.createElement("div");
67
+ item.className = "action-item";
68
+ item.textContent = action;
69
+ reportActions.appendChild(item);
70
+ });
71
+ }
72
+
73
+ function renderTimeline(frames) {
74
+ timelineGrid.innerHTML = "";
75
+ if (!frames.length) {
76
+ timelineSection.classList.add("hidden");
77
+ return;
78
+ }
79
+ timelineSection.classList.remove("hidden");
80
+ frames.forEach((frame) => {
81
+ const card = document.createElement("article");
82
+ card.className = "timeline-card";
83
+ card.innerHTML = `
84
+ <div class="timeline-header">
85
+ <h5>${frame.timestamp_seconds}s</h5>
86
+ <span class="risk-pill ${frame.risk_level}">${frame.risk_level} risk</span>
87
+ </div>
88
+ <img src="${frame.thumbnail_url}" alt="Frame at ${frame.timestamp_seconds} seconds" />
89
+ <div class="timeline-meta">${frame.compliant_workers}/${frame.total_workers} workers compliant</div>
90
+ `;
91
+ timelineGrid.appendChild(card);
92
+ });
93
+ }
94
+
95
+ function renderResults(payload) {
96
+ latestResponse = payload;
97
+ emptyState.classList.add("hidden");
98
+ resultsContent.classList.remove("hidden");
99
+
100
+ summaryGrid.innerHTML = "";
101
+ summaryGrid.appendChild(createSummaryCard("Workers", payload.site_summary.total_workers));
102
+ summaryGrid.appendChild(createSummaryCard("Compliance", `${payload.site_summary.compliance_rate}%`));
103
+ summaryGrid.appendChild(createSummaryCard("Non-Compliant", payload.site_summary.non_compliant_workers));
104
+ summaryGrid.appendChild(createSummaryCard("Policy", payload.required_items.join(", ")));
105
+
106
+ annotatedImage.src = payload.annotated_asset.url;
107
+ reportTitle.textContent = payload.report.title;
108
+ reportSummary.textContent = payload.report.summary;
109
+ renderActions(payload.report.actions);
110
+ renderWorkers(payload.workers);
111
+ renderTimeline(payload.frames || []);
112
+ }
113
+
114
+ async function handleSubmit(event) {
115
+ event.preventDefault();
116
+ const file = fileInput.files?.[0];
117
+ if (!file) {
118
+ setLoading("Choose an image or video first");
119
+ return;
120
+ }
121
+
122
+ const body = new FormData();
123
+ body.append("file", file);
124
+ body.append("required_items", requiredItems.value);
125
+ body.append("generate_ai_summary", generateAiSummary.checked ? "true" : "false");
126
+
127
+ setLoading("Analyzing PPE compliance...", true);
128
+
129
+ try {
130
+ const response = await fetch("/api/analyze", {
131
+ method: "POST",
132
+ body,
133
+ });
134
+ const data = await response.json();
135
+ if (!response.ok) {
136
+ throw new Error(data.detail || "Analysis failed");
137
+ }
138
+ renderResults(data);
139
+ setLoading("Analysis complete");
140
+ } catch (error) {
141
+ setLoading(error.message || "Analysis failed");
142
+ } finally {
143
+ submitButton.disabled = false;
144
+ submitButton.style.opacity = "1";
145
+ }
146
+ }
147
+
148
+ function enableDragAndDrop() {
149
+ ["dragenter", "dragover"].forEach((eventName) => {
150
+ dropzone.addEventListener(eventName, (event) => {
151
+ event.preventDefault();
152
+ dropzone.classList.add("dragging");
153
+ });
154
+ });
155
+
156
+ ["dragleave", "drop"].forEach((eventName) => {
157
+ dropzone.addEventListener(eventName, (event) => {
158
+ event.preventDefault();
159
+ dropzone.classList.remove("dragging");
160
+ });
161
+ });
162
+
163
+ dropzone.addEventListener("drop", (event) => {
164
+ const file = event.dataTransfer?.files?.[0];
165
+ if (!file) {
166
+ return;
167
+ }
168
+ fileInput.files = event.dataTransfer.files;
169
+ updateFilePreview();
170
+ });
171
+ }
172
+
173
+ demoPolicyButton.addEventListener("click", () => {
174
+ requiredItems.value = "helmet,vest";
175
+ });
176
+
177
+ downloadJsonButton.addEventListener("click", () => {
178
+ if (!latestResponse) {
179
+ return;
180
+ }
181
+ const blob = new Blob([JSON.stringify(latestResponse, null, 2)], { type: "application/json" });
182
+ const url = URL.createObjectURL(blob);
183
+ const anchor = document.createElement("a");
184
+ anchor.href = url;
185
+ anchor.download = "ppe-analysis.json";
186
+ anchor.click();
187
+ URL.revokeObjectURL(url);
188
+ });
189
+
190
+ fileInput.addEventListener("change", updateFilePreview);
191
+ form.addEventListener("submit", handleSubmit);
192
+ enableDragAndDrop();
app/templates/index.html ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>{{ app_name }}</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&family=Space+Grotesk:wght@500;700&display=swap"
11
+ rel="stylesheet"
12
+ />
13
+ <link rel="stylesheet" href="/static/css/styles.css" />
14
+ </head>
15
+ <body>
16
+ <div class="noise"></div>
17
+ <header class="topbar">
18
+ <div class="brand">
19
+ <span class="brand-mark">PS</span>
20
+ <div>
21
+ <p class="eyebrow">Safety Intelligence</p>
22
+ <h1>{{ app_name }}</h1>
23
+ </div>
24
+ </div>
25
+ <div class="status-chip {{ 'enabled' if openai_enabled else 'muted' }}">
26
+ {{ 'OpenAI Reports Ready' if openai_enabled else 'Rule-Based Reports' }}
27
+ </div>
28
+ </header>
29
+
30
+ <main class="page-shell">
31
+ <section class="hero card">
32
+ <div class="hero-copy">
33
+ <p class="eyebrow">Enterprise PPE Monitoring</p>
34
+ <h2>{{ tagline }}</h2>
35
+ <p class="lead">
36
+ Upload photos or short videos from phones, CCTV exports, or site audits. The app detects workers,
37
+ scores PPE compliance, and generates a management-ready summary.
38
+ </p>
39
+ <div class="hero-pills">
40
+ <span>Desktop + mobile ready</span>
41
+ <span>Images + short videos</span>
42
+ <span>Helmet, vest, mask, gloves</span>
43
+ </div>
44
+ </div>
45
+ <div class="hero-metrics">
46
+ <div>
47
+ <span>Detector</span>
48
+ <strong>Zero-Shot Grounding DINO</strong>
49
+ </div>
50
+ <div>
51
+ <span>Default Policy</span>
52
+ <strong>{{ default_required_items | join(', ') }}</strong>
53
+ </div>
54
+ <div>
55
+ <span>Designed For</span>
56
+ <strong>Construction and industrial teams</strong>
57
+ </div>
58
+ </div>
59
+ </section>
60
+
61
+ <section class="workspace">
62
+ <section class="uploader card">
63
+ <div class="section-head">
64
+ <div>
65
+ <p class="eyebrow">Upload</p>
66
+ <h3>Scan a site image or video</h3>
67
+ </div>
68
+ <button id="demoPolicyButton" class="ghost-button" type="button">Use Standard Policy</button>
69
+ </div>
70
+
71
+ <form id="analyzeForm" class="upload-form">
72
+ <label class="dropzone" for="fileInput" id="dropzone">
73
+ <input id="fileInput" name="file" type="file" accept="image/*,video/*" required />
74
+ <span class="drop-icon">Inspect</span>
75
+ <strong>Drop media here or tap to browse</strong>
76
+ <small>Supports JPG, PNG, WEBP, MP4, MOV, AVI, MKV, WEBM</small>
77
+ </label>
78
+
79
+ <div class="form-grid">
80
+ <label>
81
+ <span>Required PPE policy</span>
82
+ <input
83
+ id="requiredItems"
84
+ name="required_items"
85
+ type="text"
86
+ value="{{ default_required_items | join(',') }}"
87
+ placeholder="helmet,vest"
88
+ />
89
+ </label>
90
+ <label class="toggle-row">
91
+ <span>Generate executive summary</span>
92
+ <input id="generateAiSummary" name="generate_ai_summary" type="checkbox" checked />
93
+ </label>
94
+ </div>
95
+
96
+ <div class="file-preview" id="filePreview">No file selected yet.</div>
97
+ <button class="primary-button" id="submitButton" type="submit">Analyze PPE Compliance</button>
98
+ </form>
99
+ </section>
100
+
101
+ <section class="results card">
102
+ <div class="section-head">
103
+ <div>
104
+ <p class="eyebrow">Results</p>
105
+ <h3>Operational command view</h3>
106
+ </div>
107
+ <div id="loadingState" class="loading-state">Waiting for upload</div>
108
+ </div>
109
+
110
+ <div id="emptyState" class="empty-state">
111
+ <h4>Ready for the first scan</h4>
112
+ <p>The dashboard will populate with worker cards, compliance metrics, evidence snapshots, and the report.</p>
113
+ </div>
114
+
115
+ <div id="resultsContent" class="results-content hidden">
116
+ <div class="summary-grid" id="summaryGrid"></div>
117
+ <div class="media-panel">
118
+ <img id="annotatedImage" alt="Annotated result" />
119
+ </div>
120
+ <div class="report-panel">
121
+ <div class="report-header">
122
+ <div>
123
+ <p class="eyebrow">Executive Summary</p>
124
+ <h4 id="reportTitle"></h4>
125
+ </div>
126
+ <button id="downloadJsonButton" class="ghost-button" type="button">Download JSON</button>
127
+ </div>
128
+ <p id="reportSummary" class="report-summary"></p>
129
+ <div id="reportActions" class="action-list"></div>
130
+ </div>
131
+ <div>
132
+ <div class="section-head minor">
133
+ <div>
134
+ <p class="eyebrow">Workers</p>
135
+ <h4>Compliance roster</h4>
136
+ </div>
137
+ </div>
138
+ <div id="workerList" class="worker-list"></div>
139
+ </div>
140
+ <div id="timelineSection" class="timeline-section hidden">
141
+ <div class="section-head minor">
142
+ <div>
143
+ <p class="eyebrow">Video Timeline</p>
144
+ <h4>Sampled compliance frames</h4>
145
+ </div>
146
+ </div>
147
+ <div id="timelineGrid" class="timeline-grid"></div>
148
+ </div>
149
+ </div>
150
+ </section>
151
+ </section>
152
+ </main>
153
+
154
+ <script src="/static/js/app.js"></script>
155
+ </body>
156
+ </html>
artifacts/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.135.1
2
+ jinja2==3.1.6
3
+ numpy==2.3.3
4
+ openai==2.29.0
5
+ opencv-python-headless==4.13.0.92
6
+ pillow==12.1.1
7
+ pydantic==2.12.0
8
+ python-dotenv==1.2.2
9
+ python-multipart==0.0.20
10
+ torch==2.10.0
11
+ transformers==4.57.6
12
+ uvicorn[standard]==0.38.0