Spaces:
Sleeping
Sleeping
Commit ·
37b8651
0
Parent(s):
chore: publish PPE Sentinel Space
Browse files- .dockerignore +10 -0
- .env.example +8 -0
- Dockerfile +18 -0
- README.md +22 -0
- app/__init__.py +1 -0
- app/api/routes.py +96 -0
- app/core/config.py +45 -0
- app/main.py +13 -0
- app/models/schemas.py +81 -0
- app/services/compliance.py +149 -0
- app/services/detection.py +196 -0
- app/services/reporting.py +99 -0
- app/services/storage.py +33 -0
- app/static/css/styles.css +487 -0
- app/static/js/app.js +192 -0
- app/templates/index.html +156 -0
- artifacts/.gitkeep +1 -0
- requirements.txt +12 -0
.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
|