feat: add video detection and robot alerts

This commit is contained in:
2026-08-12 17:15:11 +08:00
parent 67d5b40736
commit bb395e3d9a
22 changed files with 1623 additions and 319 deletions

16
.env.example Normal file
View File

@@ -0,0 +1,16 @@
YOLO_WEIGHTS=runs/detect/smoke_fire_yolo11s_v1-4/weights/best.pt
YOLO_DEVICE=0
YOLO_IMGSZ=768
YOLO_CONF=0.40
YOLO_IOU=0.45
# 企业微信群机器人,可留空。
WECHAT_WEBHOOK_URL=
# 飞书群自定义机器人,可留空。开启签名校验时再填写密钥。
FEISHU_WEBHOOK_URL=
FEISHU_SECRET=
# 连续命中帧数与每个类别的告警冷却时间。
ALERT_CONFIRM_FRAMES=3
ALERT_COOLDOWN_SECONDS=60

1
.gitignore vendored
View File

@@ -9,6 +9,7 @@ wheels/
# Virtual environments and caches # Virtual environments and caches
.venv/ .venv/
.ruff_cache/ .ruff_cache/
.env
# IDEs # IDEs
.idea/ .idea/

View File

@@ -0,0 +1,83 @@
# Smoke and Fire YOLO
Ultralytics YOLO training, validation, prediction, and export pipeline for the Smoke-Fire-Detection-YOLO dataset.
## Project layout
```text
configs/datasets/smoke_fire.yaml Dataset configuration
models/pretrained/yolo11s.pt Default pretrained model
src/yolo/cli.py Command-line entry point
src/yolo/config.py Training configuration
src/yolo/defaults.py Project defaults
src/yolo/engine.py Train, validate, predict, and export
src/yolo/checkpoints.py Checkpoint discovery and resume support
src/yolo/reporting.py Training result reporting
```
## Setup
```bash
uv sync
```
## Commands
Fine-tune from the current best YOLO11s checkpoint:
```bash
uv run fire-yolo train
```
Resume from the newest checkpoint:
```bash
uv run fire-yolo train --resume
```
Validate a trained model:
```bash
uv run fire-yolo val --weights runs/detect/<run-name>/weights/best.pt
```
Run inference:
```bash
uv run fire-yolo predict --weights runs/detect/<run-name>/weights/best.pt --source path/to/image-or-video
```
Export a model:
```bash
uv run fire-yolo export --weights runs/detect/<run-name>/weights/best.pt --format onnx
```
## Default training settings
- Model: `runs/detect/smoke_fire_yolo11s_v1-4/weights/best.pt`
- Dataset: `configs/datasets/smoke_fire.yaml`
- Epochs: 80
- Image size: 768
- Batch: 24
- Workers: 0
- Optimizer: AdamW
- Initial learning rate: 0.0002
- Mosaic/MixUp: disabled
- Prediction confidence: 0.40
- Checkpoint interval: every 5 epochs
- Output: `runs/detect`
Training writes `last.pt`, `best.pt`, periodic checkpoints, and `best_point.json` to the run directory.
## Web Detection Service
After training finishes, install the web dependencies and run the integrated frontend and inference API:
```bash
uv sync
uv run uvicorn backend.main:app --host 127.0.0.1 --port 8000
```
Open `http://127.0.0.1:8000`, select a local video, and start continuous detection. The browser plays the video locally and sends sequential JPEG frames to `POST /api/detect`; requests do not overlap.
The local `.env` file contains optional robot settings. Set `WECHAT_WEBHOOK_URL` for an Enterprise WeChat group robot, `FEISHU_WEBHOOK_URL` for a Feishu custom group robot, or both. If Feishu signature verification is enabled, also set `FEISHU_SECRET`. Alerts require three consecutive positive frames by default and use separate 60-second cooldowns for fire and smoke. `ALERT_CONFIRM_FRAMES` and `ALERT_COOLDOWN_SECONDS` override these settings. Without a webhook, video detection still works and the UI reports that notifications are disabled.

1
backend/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""HTTP inference backend for the smoke and fire detector."""

323
backend/alerting.py Normal file
View File

@@ -0,0 +1,323 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import logging
import os
import threading
import time
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from io import BytesIO
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from PIL import Image, ImageDraw, ImageFont
LOGGER = logging.getLogger(__name__)
ALERT_CLASSES = ("fire", "smoke")
@dataclass(slots=True)
class SessionState:
consecutive: dict[str, int] = field(
default_factory=lambda: defaultdict(int)
)
last_alert_at: dict[str, float] = field(
default_factory=lambda: defaultdict(float)
)
last_seen_at: float = field(default_factory=time.monotonic)
class AlertManager:
def __init__(
self,
webhook_url: str | None = None,
wechat_webhook_url: str | None = None,
feishu_webhook_url: str | None = None,
feishu_secret: str | None = None,
confirm_frames: int = 3,
cooldown_seconds: float = 60.0,
session_ttl_seconds: float = 3600.0,
) -> None:
self.wechat_webhook_url = (
wechat_webhook_url
or webhook_url
or os.getenv("WECHAT_WEBHOOK_URL")
)
self.feishu_webhook_url = (
feishu_webhook_url or os.getenv("FEISHU_WEBHOOK_URL")
)
self.feishu_secret = feishu_secret or os.getenv("FEISHU_SECRET")
self.confirm_frames = max(1, confirm_frames)
self.cooldown_seconds = max(0.0, cooldown_seconds)
self.session_ttl_seconds = max(60.0, session_ttl_seconds)
self._states: dict[str, SessionState] = {}
self._lock = threading.Lock()
self._executor = ThreadPoolExecutor(
max_workers=4,
thread_name_prefix="alert-dispatch",
)
@property
def channels(self) -> dict[str, bool]:
return {
"wechat": bool(self.wechat_webhook_url),
"feishu": bool(self.feishu_webhook_url),
}
@property
def enabled(self) -> bool:
return any(self.channels.values())
def evaluate(
self,
session_id: str,
detections: list[dict[str, Any]],
image: Image.Image,
) -> dict[str, Any]:
now = time.monotonic()
present = {
detection["class"]
for detection in detections
if detection.get("class") in ALERT_CLASSES
}
triggered: list[str] = []
with self._lock:
self._prune_sessions(now)
state = self._states.setdefault(session_id, SessionState())
state.last_seen_at = now
for class_name in ALERT_CLASSES:
state.consecutive[class_name] = (
state.consecutive[class_name] + 1
if class_name in present
else 0
)
ready = state.consecutive[class_name] >= self.confirm_frames
cooldown_elapsed = (
now - state.last_alert_at[class_name]
>= self.cooldown_seconds
)
if ready and cooldown_elapsed:
state.last_alert_at[class_name] = now
triggered.append(class_name)
consecutive = dict(state.consecutive)
if triggered and self.enabled:
self._executor.submit(
self._send_alerts,
annotate_image(image, detections),
triggered,
detections,
)
return {
"triggered": bool(triggered),
"classes": triggered,
"confirmed_frames": self.confirm_frames,
"consecutive": consecutive,
"cooldown_seconds": self.cooldown_seconds,
"notification_enabled": self.enabled,
"notification_channels": self.channels,
}
def reset(self, session_id: str) -> None:
with self._lock:
self._states.pop(session_id, None)
def _prune_sessions(self, now: float) -> None:
expired = [
session_id
for session_id, state in self._states.items()
if now - state.last_seen_at > self.session_ttl_seconds
]
for session_id in expired:
del self._states[session_id]
def _send_alerts(
self,
image: Image.Image,
triggered: list[str],
detections: list[dict[str, Any]],
) -> None:
if self.wechat_webhook_url:
try:
self._send_wechat_alert(image, triggered, detections)
except (HTTPError, URLError, TimeoutError, ValueError) as error:
LOGGER.error("WeChat alert failed: %s", error)
if self.feishu_webhook_url:
try:
self._send_feishu_alert(triggered, detections)
except (HTTPError, URLError, TimeoutError, ValueError) as error:
LOGGER.error("Feishu alert failed: %s", error)
def _send_wechat_alert(
self,
image: Image.Image,
triggered: list[str],
detections: list[dict[str, Any]],
) -> None:
if not self.wechat_webhook_url:
return
target_text, max_confidence = alert_summary(triggered, detections)
message = (
f"🔥 烟火检测告警\n"
f"> 检测目标:{target_text}\n"
f"> 最高置信度:{max_confidence:.1%}\n"
f"> 请及时查看现场视频。"
)
post_json(
self.wechat_webhook_url,
{"msgtype": "markdown", "markdown": {"content": message}},
)
image_bytes = encode_jpeg(image)
post_json(
self.wechat_webhook_url,
{
"msgtype": "image",
"image": {
"base64": base64.b64encode(image_bytes).decode("ascii"),
"md5": hashlib.md5(
image_bytes,
usedforsecurity=False,
).hexdigest(),
},
},
)
def _send_feishu_alert(
self,
triggered: list[str],
detections: list[dict[str, Any]],
) -> None:
if not self.feishu_webhook_url:
return
target_text, max_confidence = alert_summary(triggered, detections)
payload: dict[str, Any] = {
"msg_type": "interactive",
"card": {
"config": {"wide_screen_mode": True},
"header": {
"template": "red",
"title": {
"tag": "plain_text",
"content": "烟火检测告警",
},
},
"elements": [
{
"tag": "markdown",
"content": (
f"**检测目标:** {target_text}\n"
f"**最高置信度:** {max_confidence:.1%}\n"
f"**告警时间:** "
f"{time.strftime('%Y-%m-%d %H:%M:%S')}\n"
f"请及时查看现场视频。"
),
}
],
},
}
if self.feishu_secret:
timestamp = str(int(time.time()))
payload["timestamp"] = timestamp
payload["sign"] = feishu_signature(
timestamp,
self.feishu_secret,
)
post_json(self.feishu_webhook_url, payload)
def alert_summary(
triggered: list[str],
detections: list[dict[str, Any]],
) -> tuple[str, float]:
labels = {"fire": "火焰", "smoke": "烟雾"}
target_text = "".join(labels[name] for name in triggered)
max_confidence = max(
(
float(detection.get("confidence", 0.0))
for detection in detections
if detection.get("class") in triggered
),
default=0.0,
)
return target_text, max_confidence
def feishu_signature(timestamp: str, secret: str) -> str:
string_to_sign = f"{timestamp}\n{secret}".encode("utf-8")
digest = hmac.new(string_to_sign, digestmod=hashlib.sha256).digest()
return base64.b64encode(digest).decode("ascii")
def post_json(url: str, payload: dict[str, Any]) -> dict[str, Any]:
request = Request(
url,
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urlopen(request, timeout=10) as response:
result = json.loads(response.read().decode("utf-8"))
error_code = result.get(
"errcode",
result.get("code", result.get("StatusCode", 0)),
)
if error_code != 0:
raise ValueError(
result.get("errmsg")
or result.get("msg")
or result.get("StatusMessage")
or "Unknown robot webhook error"
)
return result
def annotate_image(
image: Image.Image,
detections: list[dict[str, Any]],
) -> Image.Image:
annotated = image.copy()
draw = ImageDraw.Draw(annotated)
font = ImageFont.load_default()
colors = {"fire": "#ff3b30", "smoke": "#00a89b"}
for detection in detections:
box = detection.get("box")
if not box or len(box) != 4:
continue
class_name = str(detection.get("class", "target"))
confidence = float(detection.get("confidence", 0.0))
color = colors.get(class_name, "#ffd166")
coordinates = tuple(int(round(value)) for value in box)
draw.rectangle(coordinates, outline=color, width=4)
draw.text(
(coordinates[0] + 4, max(0, coordinates[1] - 16)),
f"{class_name} {confidence:.0%}",
fill=color,
font=font,
)
return annotated
def encode_jpeg(image: Image.Image, max_bytes: int = 1_900_000) -> bytes:
working = image.convert("RGB")
quality = 88
while True:
output = BytesIO()
working.save(output, format="JPEG", quality=quality, optimize=True)
payload = output.getvalue()
if len(payload) <= max_bytes:
return payload
if quality > 55:
quality -= 10
continue
width, height = working.size
working = working.resize(
(max(1, int(width * 0.8)), max(1, int(height * 0.8)))
)
quality = 75

154
backend/main.py Normal file
View File

@@ -0,0 +1,154 @@
from __future__ import annotations
import io
import os
import time
from functools import lru_cache
from pathlib import Path
from typing import Any
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from PIL import Image, UnidentifiedImageError
from dotenv import load_dotenv
from ultralytics import YOLO
from .alerting import AlertManager
PROJECT_ROOT = Path(__file__).resolve().parents[1]
FRONTEND_DIR = PROJECT_ROOT / "frontend"
load_dotenv(PROJECT_ROOT / ".env")
DEFAULT_WEIGHTS = PROJECT_ROOT / "runs" / "detect" / "smoke_fire_yolo11s_v1-4" / "weights" / "best.pt"
WEIGHTS_PATH = Path(os.getenv("YOLO_WEIGHTS", str(DEFAULT_WEIGHTS))).expanduser().resolve()
DEVICE = os.getenv("YOLO_DEVICE") or None
IMAGE_SIZE = int(os.getenv("YOLO_IMGSZ", "768"))
CONFIDENCE = float(os.getenv("YOLO_CONF", "0.40"))
IOU = float(os.getenv("YOLO_IOU", "0.45"))
MAX_UPLOAD_BYTES = 15 * 1024 * 1024
MAX_IMAGE_PIXELS = int(os.getenv("YOLO_MAX_IMAGE_PIXELS", "25000000"))
CLASS_NAMES = {0: "smoke", 1: "fire"}
ALERT_MANAGER = AlertManager(
confirm_frames=int(os.getenv("ALERT_CONFIRM_FRAMES", "3")),
cooldown_seconds=float(os.getenv("ALERT_COOLDOWN_SECONDS", "60")),
)
app = FastAPI(title="Smoke Fire Detector API", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET", "POST", "DELETE"],
allow_headers=["*"],
)
@lru_cache(maxsize=1)
def get_model() -> YOLO:
if not WEIGHTS_PATH.is_file():
raise FileNotFoundError(f"YOLO weights not found: {WEIGHTS_PATH}")
return YOLO(str(WEIGHTS_PATH))
def validate_image(payload: bytes) -> Image.Image:
if not payload:
raise HTTPException(status_code=400, detail="Uploaded file is empty")
if len(payload) > MAX_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="Uploaded file exceeds 15 MB")
try:
image = Image.open(io.BytesIO(payload))
if image.width * image.height > MAX_IMAGE_PIXELS:
raise HTTPException(
status_code=413,
detail="Image dimensions are too large",
)
image.load()
return image.convert("RGB")
except (OSError, UnidentifiedImageError) as error:
raise HTTPException(status_code=415, detail="Only valid image files are supported") from error
def predict_image(image: Image.Image) -> dict[str, Any]:
started_at = time.perf_counter()
try:
result = get_model().predict(
source=image,
conf=CONFIDENCE,
iou=IOU,
imgsz=IMAGE_SIZE,
device=DEVICE,
verbose=False,
)[0]
except FileNotFoundError as error:
raise HTTPException(status_code=503, detail=str(error)) from error
except Exception as error:
raise HTTPException(status_code=500, detail=f"Inference failed: {error}") from error
detections = []
for box in result.boxes:
class_id = int(box.cls.item())
detections.append({
"class": CLASS_NAMES.get(class_id, str(class_id)),
"class_id": class_id,
"confidence": round(float(box.conf.item()), 6),
"box": [round(float(value), 2) for value in box.xyxy[0].tolist()],
})
return {
"detections": detections,
"image": {"width": image.width, "height": image.height},
"inference_ms": round((time.perf_counter() - started_at) * 1000, 1),
}
@app.get("/api/health")
def health() -> dict[str, Any]:
return {
"status": "ok",
"weights": str(WEIGHTS_PATH),
"weights_available": WEIGHTS_PATH.is_file(),
"wechat_alerts_enabled": ALERT_MANAGER.channels["wechat"],
"feishu_alerts_enabled": ALERT_MANAGER.channels["feishu"],
"alert_channels": ALERT_MANAGER.channels,
}
@app.post("/api/detect")
async def detect(
file: UploadFile = File(...),
session_id: str | None = None,
) -> dict[str, Any]:
payload = await file.read(MAX_UPLOAD_BYTES + 1)
image = validate_image(payload)
result = predict_image(image)
result["alert"] = (
ALERT_MANAGER.evaluate(
session_id,
result["detections"],
image,
)
if session_id
else {
"triggered": False,
"classes": [],
"notification_enabled": ALERT_MANAGER.enabled,
"notification_channels": ALERT_MANAGER.channels,
}
)
return result
@app.delete("/api/sessions/{session_id}")
def reset_detection_session(session_id: str) -> dict[str, str]:
ALERT_MANAGER.reset(session_id)
return {"status": "reset"}
@app.get("/")
def frontend() -> FileResponse:
return FileResponse(FRONTEND_DIR / "index.html")
@app.get("/{asset_path:path}")
def frontend_asset(asset_path: str) -> FileResponse:
requested = (FRONTEND_DIR / asset_path).resolve()
if FRONTEND_DIR not in requested.parents or not requested.is_file():
raise HTTPException(status_code=404, detail="Asset not found")
return FileResponse(requested)

243
frontend/app.js Normal file
View File

@@ -0,0 +1,243 @@
const API_ENDPOINT = "/api/detect";
const elements = {
alertStatus: document.querySelector("#alertStatus"),
clearButton: document.querySelector("#clearButton"),
connectionStatus: document.querySelector("#connectionStatus"),
detectionList: document.querySelector("#detectionList"),
emptyState: document.querySelector("#emptyState"),
fireCount: document.querySelector("#fireCount"),
intervalSelect: document.querySelector("#intervalSelect"),
lastUpdated: document.querySelector("#lastUpdated"),
loadingState: document.querySelector("#loadingState"),
maxConfidence: document.querySelector("#maxConfidence"),
overlayCanvas: document.querySelector("#overlayCanvas"),
riskStatus: document.querySelector("#riskStatus"),
runDetectionButton: document.querySelector("#runDetectionButton"),
smokeCount: document.querySelector("#smokeCount"),
sourceLabel: document.querySelector("#sourceLabel"),
videoInput: document.querySelector("#videoInput"),
videoPreview: document.querySelector("#videoPreview"),
};
const frameCanvas = document.createElement("canvas");
let videoUrl = null;
let sessionId = null;
let detectionActive = false;
let requestInFlight = false;
let lastDetectionAt = 0;
function setConnectionStatus(label, state = "idle") {
elements.connectionStatus.textContent = label;
elements.connectionStatus.className = `status-pill status-${state}`;
}
function enabledChannelNames(channels = {}) {
const names = [];
if (channels.wechat) names.push("企业微信");
if (channels.feishu) names.push("飞书");
return names;
}
function clearResults() {
elements.smokeCount.textContent = "0";
elements.fireCount.textContent = "0";
elements.maxConfidence.textContent = "--";
elements.riskStatus.textContent = "待机";
elements.detectionList.innerHTML = '<p class="muted">暂无检测结果</p>';
const context = elements.overlayCanvas.getContext("2d");
context.clearRect(0, 0, elements.overlayCanvas.width, elements.overlayCanvas.height);
}
function drawDetections(detections = []) {
const video = elements.videoPreview;
if (!video.videoWidth || !video.videoHeight) return;
const canvas = elements.overlayCanvas;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const context = canvas.getContext("2d");
context.clearRect(0, 0, canvas.width, canvas.height);
detections.forEach((detection) => {
const [x1, y1, x2, y2] = detection.box || [];
const color = detection.class === "fire" ? "#ff786b" : "#55d5c2";
context.strokeStyle = color;
context.lineWidth = Math.max(2, canvas.width / 320);
context.strokeRect(x1, y1, x2 - x1, y2 - y1);
context.fillStyle = color;
context.font = `${Math.max(13, canvas.width / 60)}px sans-serif`;
context.fillText(`${detection.class} ${Math.round(detection.confidence * 100)}%`, x1 + 4, Math.max(18, y1 - 6));
});
}
function updateResults(result) {
const detections = result.detections || [];
const smoke = detections.filter((item) => item.class === "smoke").length;
const fire = detections.filter((item) => item.class === "fire").length;
const max = detections.reduce((highest, item) => Math.max(highest, item.confidence || 0), 0);
elements.smokeCount.textContent = String(smoke);
elements.fireCount.textContent = String(fire);
elements.maxConfidence.textContent = max ? `${Math.round(max * 100)}%` : "--";
elements.riskStatus.textContent = fire ? "火焰告警" : smoke ? "烟雾告警" : "正常";
elements.detectionList.innerHTML = detections.length
? detections.map((item) => `<div class="detection-row"><span>${item.class === "fire" ? "火焰" : "烟雾"}</span><strong>${Math.round(item.confidence * 100)}%</strong></div>`).join("")
: '<p class="muted">未发现目标</p>';
const alert = result.alert || {};
if (alert.triggered) {
const labels = alert.classes.map((name) => name === "fire" ? "火焰" : "烟雾").join("、");
const channelNames = enabledChannelNames(alert.notification_channels);
elements.alertStatus.textContent = channelNames.length
? `已触发 ${channelNames.join(" + ")} 告警:${labels}`
: `已满足告警条件:${labels}(未配置机器人 Webhook`;
elements.alertStatus.className = "alert-status alert-triggered";
} else {
const fireFrames = alert.consecutive?.fire || 0;
const smokeFrames = alert.consecutive?.smoke || 0;
const channelNames = enabledChannelNames(alert.notification_channels);
elements.alertStatus.textContent = channelNames.length
? `${channelNames.join(" + ")} 告警已启用 · 连续帧 火焰 ${fireFrames} / 烟雾 ${smokeFrames}`
: "机器人告警未配置,检测功能正常";
elements.alertStatus.className = "alert-status";
}
drawDetections(detections);
elements.lastUpdated.textContent = `视频 ${formatTime(elements.videoPreview.currentTime)} · 推理 ${result.inference_ms ?? "--"} ms`;
}
function formatTime(seconds) {
const minutes = Math.floor(seconds / 60);
const remaining = Math.floor(seconds % 60);
return `${String(minutes).padStart(2, "0")}:${String(remaining).padStart(2, "0")}`;
}
function captureFrame() {
const video = elements.videoPreview;
frameCanvas.width = video.videoWidth;
frameCanvas.height = video.videoHeight;
frameCanvas.getContext("2d").drawImage(video, 0, 0);
return new Promise((resolve) => frameCanvas.toBlob(resolve, "image/jpeg", 0.88));
}
async function detectCurrentFrame(timestamp) {
if (
!detectionActive
|| requestInFlight
|| !sessionId
|| elements.videoPreview.paused
|| elements.videoPreview.ended
) return;
const interval = Number(elements.intervalSelect.value);
if (timestamp - lastDetectionAt < interval) return;
lastDetectionAt = timestamp;
requestInFlight = true;
elements.loadingState.hidden = false;
try {
const frame = await captureFrame();
if (!frame) throw new Error("无法截取视频帧");
const form = new FormData();
form.append("file", frame, "video-frame.jpg");
const response = await fetch(`${API_ENDPOINT}?session_id=${encodeURIComponent(sessionId)}`, { method: "POST", body: form });
if (!response.ok) throw new Error(`API ${response.status}`);
updateResults(await response.json());
setConnectionStatus("检测服务已连接", "ready");
} catch (error) {
setConnectionStatus("检测服务异常", "alert");
elements.detectionList.innerHTML = `<p class="muted">${error.message}</p>`;
} finally {
requestInFlight = false;
elements.loadingState.hidden = true;
}
}
function scheduleDetection(timestamp) {
detectCurrentFrame(timestamp);
if (detectionActive) requestAnimationFrame(scheduleDetection);
}
async function startDetection() {
if (!elements.videoPreview.src) return;
detectionActive = true;
lastDetectionAt = -Infinity;
elements.runDetectionButton.textContent = "停止检测";
elements.intervalSelect.disabled = true;
try {
await elements.videoPreview.play();
} catch {
setConnectionStatus("请允许视频播放", "alert");
}
requestAnimationFrame(scheduleDetection);
}
function stopDetection() {
detectionActive = false;
elements.runDetectionButton.textContent = "开始连续检测";
elements.intervalSelect.disabled = false;
}
async function resetSession() {
const previousSession = sessionId;
sessionId = crypto.randomUUID();
if (previousSession) {
fetch(`/api/sessions/${encodeURIComponent(previousSession)}`, { method: "DELETE" }).catch(() => {});
}
}
async function checkHealth() {
try {
const response = await fetch("/api/health");
if (!response.ok) throw new Error();
const health = await response.json();
setConnectionStatus("检测服务已连接", "ready");
const channelNames = enabledChannelNames(health.alert_channels);
elements.alertStatus.textContent = channelNames.length
? `${channelNames.join(" + ")} 告警已启用`
: "机器人告警未配置,检测功能正常";
} catch {
setConnectionStatus("检测服务未连接", "alert");
elements.alertStatus.textContent = "无法读取机器人告警状态";
}
}
elements.videoInput.addEventListener("change", async () => {
const [file] = elements.videoInput.files;
if (!file) return;
stopDetection();
elements.videoPreview.pause();
if (videoUrl) URL.revokeObjectURL(videoUrl);
videoUrl = URL.createObjectURL(file);
elements.videoPreview.src = videoUrl;
elements.videoPreview.hidden = false;
elements.emptyState.hidden = true;
elements.sourceLabel.textContent = file.name;
elements.runDetectionButton.disabled = false;
await resetSession();
clearResults();
});
elements.runDetectionButton.addEventListener("click", () => {
if (detectionActive) stopDetection(); else startDetection();
});
elements.videoPreview.addEventListener("ended", stopDetection);
elements.videoPreview.addEventListener("seeked", async () => {
drawDetections([]);
await resetSession();
});
elements.videoPreview.addEventListener("resize", () => drawDetections([]));
elements.clearButton.addEventListener("click", async () => {
stopDetection();
elements.videoPreview.pause();
elements.videoPreview.removeAttribute("src");
elements.videoPreview.load();
elements.videoPreview.hidden = true;
if (videoUrl) URL.revokeObjectURL(videoUrl);
videoUrl = null;
elements.videoInput.value = "";
elements.sourceLabel.textContent = "等待选择视频";
elements.emptyState.hidden = false;
elements.runDetectionButton.disabled = true;
await resetSession();
clearResults();
});
resetSession();
checkHealth();

90
frontend/index.html Normal file
View File

@@ -0,0 +1,90 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>烟火视频检测</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<main class="app-shell">
<header class="topbar">
<div>
<p class="eyebrow">YOLO11S / VIDEO DETECTION</p>
<h1>烟火视频检测</h1>
</div>
<span id="connectionStatus" class="status-pill status-idle">正在检查服务</span>
</header>
<section class="workspace">
<div class="stage-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">VIDEO VIEW</p>
<h2>检测画面</h2>
</div>
<span id="sourceLabel" class="muted">等待选择视频</span>
</div>
<div class="media-stage">
<video id="videoPreview" controls muted playsinline hidden></video>
<div id="emptyState" class="empty-state">
<div class="empty-icon">+</div>
<strong>选择本地视频开始检测</strong>
<span>视频仅在浏览器本地播放,发送的是抽取帧</span>
</div>
<canvas id="overlayCanvas" aria-hidden="true"></canvas>
<div id="loadingState" class="loading-state" hidden>正在分析视频帧...</div>
</div>
<div class="stage-footer">
<span id="lastUpdated">尚未检测</span>
<button id="runDetectionButton" class="button button-primary" type="button" disabled>开始连续检测</button>
</div>
</div>
<aside class="control-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">INPUT</p>
<h2>视频源</h2>
</div>
</div>
<div class="control-stack">
<label class="upload-control">
<span class="button button-secondary">选择视频</span>
<input id="videoInput" type="file" accept="video/*" />
<small>支持浏览器可播放的 MP4、WebM 等格式</small>
</label>
<label class="setting-row">
<span>检测间隔</span>
<select id="intervalSelect">
<option value="500">0.5 秒</option>
<option value="1000" selected>1 秒</option>
<option value="2000">2 秒</option>
</select>
</label>
<button id="clearButton" class="button button-quiet" type="button">清除视频</button>
</div>
<div class="divider"></div>
<div class="panel-heading compact-heading">
<div>
<p class="eyebrow">RESULTS</p>
<h2>检测摘要</h2>
</div>
</div>
<div class="metrics-grid">
<div class="metric-card"><span>烟雾</span><strong id="smokeCount">0</strong></div>
<div class="metric-card"><span>火焰</span><strong id="fireCount">0</strong></div>
<div class="metric-card"><span>最高置信度</span><strong id="maxConfidence">--</strong></div>
<div class="metric-card"><span>状态</span><strong id="riskStatus">待机</strong></div>
</div>
<div id="alertStatus" class="alert-status">机器人告警状态:检查中</div>
<div id="detectionList" class="detection-list">
<p class="muted">暂无检测结果</p>
</div>
</aside>
</section>
</main>
<script src="./app.js" type="module"></script>
</body>
</html>

75
frontend/styles.css Normal file
View File

@@ -0,0 +1,75 @@
:root {
color-scheme: dark;
--bg: #101417;
--surface: #171d21;
--surface-raised: #1e272c;
--line: #2c373d;
--text: #edf3f2;
--muted: #93a2a5;
--cyan: #55d5c2;
--cyan-deep: #183d3b;
--orange: #ffb454;
--red: #ff786b;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; background: var(--bg); color: var(--text); }
button, input, select { font: inherit; }
button { cursor: pointer; }
.app-shell { width: min(1380px, calc(100% - 40px)); margin: 0 auto; padding: 28px 0 40px; }
.topbar, .panel-heading, .stage-footer { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.topbar { border-bottom: 1px solid var(--line); padding-bottom: 24px; }
.eyebrow { margin: 0 0 7px; color: var(--cyan); font-size: 11px; font-weight: 800; letter-spacing: 0.12em; }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: 0; font-size: clamp(24px, 4vw, 38px); letter-spacing: 0; }
h2 { margin-bottom: 0; font-size: 17px; }
.status-pill { border: 1px solid var(--line); border-radius: 999px; padding: 8px 12px; color: var(--muted); font-size: 12px; white-space: nowrap; }
.status-ready { border-color: #286f67; background: var(--cyan-deep); color: var(--cyan); }
.status-alert { border-color: #81473f; background: #392321; color: var(--red); }
.workspace { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 18px; margin-top: 22px; }
.stage-panel, .control-panel { border: 1px solid var(--line); background: var(--surface); }
.stage-panel { min-width: 0; padding: 20px; }
.control-panel { padding: 20px; }
.muted { color: var(--muted); font-size: 13px; }
.media-stage { position: relative; display: grid; place-items: center; min-height: min(66vh, 680px); margin: 20px 0 16px; overflow: hidden; background: #0a0d0e; border: 1px solid var(--line); }
.media-stage img, .media-stage video { display: block; width: 100%; height: 100%; max-height: min(66vh, 680px); object-fit: contain; }
.media-stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.empty-state { display: grid; justify-items: center; gap: 8px; color: var(--muted); text-align: center; }
.empty-icon { display: grid; place-items: center; width: 44px; height: 44px; border: 1px solid var(--line); border-radius: 50%; color: var(--cyan); font-size: 26px; }
.loading-state { position: absolute; inset: auto 16px 16px auto; padding: 10px 12px; background: #0e1718e8; border: 1px solid #286f67; color: var(--cyan); font-size: 12px; }
.stage-footer { color: var(--muted); font-size: 12px; }
.control-stack { display: grid; gap: 10px; margin-top: 22px; }
.button { display: inline-flex; min-height: 42px; align-items: center; justify-content: center; border: 1px solid transparent; border-radius: 6px; padding: 0 15px; font-weight: 700; }
.button:disabled { cursor: not-allowed; opacity: 0.45; }
.button-primary { background: var(--cyan); color: #0b1918; }
.button-secondary { border-color: #3b5658; background: var(--surface-raised); color: var(--text); }
.button-secondary:hover, .button-quiet:hover { border-color: var(--cyan); color: var(--cyan); }
.button-quiet { border-color: var(--line); background: transparent; color: var(--muted); }
.upload-control { display: grid; gap: 8px; }
.upload-control input { position: absolute; width: 1px; height: 1px; opacity: 0; }
.upload-control small { color: var(--muted); font-size: 11px; }
.setting-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: var(--muted); font-size: 13px; }
.setting-row select { min-height: 38px; border: 1px solid var(--line); border-radius: 6px; padding: 0 10px; background: var(--surface-raised); color: var(--text); }
.alert-status { margin-top: 14px; border: 1px solid var(--line); padding: 10px 12px; color: var(--muted); font-size: 12px; line-height: 1.5; }
.alert-triggered { border-color: #81473f; background: #392321; color: var(--red); }
.divider { height: 1px; margin: 24px 0; background: var(--line); }
.compact-heading { margin-bottom: 14px; }
.metrics-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.metric-card { display: grid; gap: 8px; min-height: 78px; padding: 12px; border: 1px solid var(--line); background: var(--surface-raised); }
.metric-card span { color: var(--muted); font-size: 12px; }
.metric-card strong { font-size: 20px; }
.detection-list { display: grid; gap: 8px; margin-top: 14px; }
.detection-row { display: flex; justify-content: space-between; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line); font-size: 13px; }
.detection-row strong { color: var(--orange); }
@media (max-width: 860px) {
.app-shell { width: min(100% - 24px, 680px); padding-top: 18px; }
.workspace { grid-template-columns: 1fr; }
.media-stage { min-height: 48vh; }
}
@media (max-width: 480px) {
.topbar { align-items: flex-start; flex-direction: column; }
.stage-panel, .control-panel { padding: 14px; }
.stage-footer { align-items: flex-end; flex-direction: column; }
.stage-footer .button { width: 100%; }
}

View File

@@ -0,0 +1,11 @@
# Pretrained Models
本目录保存本地基础模型权重,默认训练权重为 `yolov8n.pt`
当前本地权重:
- `yolov8n.pt`
- `yolov8s.pt`
- `yolo26n.pt`
权重文件通过 `.gitignore` 排除,不提交到 Git。

View File

@@ -15,6 +15,10 @@ dependencies = [
"torch>=2.13.0", "torch>=2.13.0",
"torchvision>=0.28.0", "torchvision>=0.28.0",
"ultralytics>=8.3.0", "ultralytics>=8.3.0",
"fastapi>=0.115.0",
"python-multipart>=0.0.9",
"python-dotenv>=1.0.0",
"uvicorn[standard]>=0.30.0",
] ]
[tool.uv.sources] [tool.uv.sources]

25
src/yolo/checkpoints.py Normal file
View File

@@ -0,0 +1,25 @@
from pathlib import Path
from .config import PathLike
LATEST_CHECKPOINT = "latest"
def find_latest_checkpoint(project: PathLike) -> Path:
project_path = Path(project).expanduser().resolve()
checkpoints = list(project_path.rglob("last.pt"))
if not checkpoints:
raise FileNotFoundError(f"No last.pt checkpoint found under: {project_path}")
return max(checkpoints, key=lambda path: path.stat().st_mtime)
def resolve_checkpoint(resume: PathLike | None, project: PathLike) -> Path | None:
if resume is None:
return None
if str(resume).lower() == LATEST_CHECKPOINT:
return find_latest_checkpoint(project)
checkpoint = Path(resume).expanduser().resolve()
if not checkpoint.is_file():
raise FileNotFoundError(f"Checkpoint does not exist: {checkpoint}")
return checkpoint

View File

@@ -1,41 +1,77 @@
import argparse import argparse
from collections.abc import Sequence from collections.abc import Sequence
from pathlib import Path
from .data import convert_dataset from .config import TrainConfig
from .defaults import ( from .defaults import (
DEFAULT_DATA_CONFIG, DEFAULT_DATA_CONFIG,
DEFAULT_DATASET_ROOT,
DEFAULT_DETECT_RUNS_DIR, DEFAULT_DETECT_RUNS_DIR,
DEFAULT_PRETRAINED_MODEL,
) )
from .engine import export_model, predict, train, validate from .engine import export_model, predict, train, validate
def _add_train_arguments(parser: argparse.ArgumentParser) -> None:
defaults = TrainConfig()
parser.add_argument("--data", default=str(defaults.data_yaml))
parser.add_argument("--model", default=str(defaults.model_weights))
parser.add_argument("--epochs", type=int, default=defaults.epochs)
parser.add_argument("--imgsz", type=int, default=defaults.imgsz)
parser.add_argument("--batch", type=int, default=defaults.batch)
parser.add_argument("--workers", type=int, default=defaults.workers)
parser.add_argument("--device", default=defaults.device)
parser.add_argument("--project", default=str(defaults.project))
parser.add_argument("--name", default=defaults.name)
parser.add_argument("--cache", action="store_true", default=defaults.cache)
parser.add_argument(
"--save-period",
type=int,
default=defaults.save_period,
help="Save an epoch checkpoint every N epochs (-1 disables periodic saves)",
)
parser.add_argument(
"--resume",
nargs="?",
const="latest",
default=defaults.resume,
metavar="CHECKPOINT",
help="Resume from CHECKPOINT, or from the newest last.pt when omitted",
)
parser.add_argument(
"--patience",
type=int,
default=defaults.patience,
help="Stop after this many epochs without improvement (0 disables early stopping)",
)
parser.add_argument("--optimizer", default=defaults.optimizer)
parser.add_argument("--lr0", type=float, default=defaults.lr0)
parser.add_argument("--lrf", type=float, default=defaults.lrf)
parser.add_argument(
"--cos-lr",
action=argparse.BooleanOptionalAction,
default=defaults.cos_lr,
)
parser.add_argument(
"--warmup-epochs",
type=float,
default=defaults.warmup_epochs,
)
parser.add_argument("--close-mosaic", type=int, default=defaults.close_mosaic)
parser.add_argument("--mosaic", type=float, default=defaults.mosaic)
parser.add_argument("--mixup", type=float, default=defaults.mixup)
parser.add_argument("--degrees", type=float, default=defaults.degrees)
parser.add_argument("--translate", type=float, default=defaults.translate)
parser.add_argument("--scale", type=float, default=defaults.scale)
parser.add_argument("--fliplr", type=float, default=defaults.fliplr)
def build_parser() -> argparse.ArgumentParser: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="fire-yolo", prog="fire-yolo",
description="Fire detection training, validation, prediction, and data utilities.", description="Smoke and fire detection training and data utilities.",
) )
subparsers = parser.add_subparsers(dest="command", required=True) subparsers = parser.add_subparsers(dest="command", required=True)
train_parser = subparsers.add_parser("train", help="Train a fire detection model") train_parser = subparsers.add_parser("train", help="Train a detection model")
train_parser.add_argument("--data", default=str(DEFAULT_DATA_CONFIG)) _add_train_arguments(train_parser)
train_parser.add_argument("--model", default=str(DEFAULT_PRETRAINED_MODEL))
train_parser.add_argument("--epochs", type=int, default=150)
train_parser.add_argument("--imgsz", type=int, default=640)
train_parser.add_argument("--batch", type=int, default=32)
train_parser.add_argument("--workers", type=int, default=8)
train_parser.add_argument("--device")
train_parser.add_argument("--project", default=str(DEFAULT_DETECT_RUNS_DIR))
train_parser.add_argument("--name")
train_parser.add_argument("--cache", action="store_true")
train_parser.add_argument(
"--patience",
type=int,
default=20,
help="Stop after this many epochs without a fitness improvement (0 disables early stopping)",
)
val_parser = subparsers.add_parser("val", help="Evaluate model weights") val_parser = subparsers.add_parser("val", help="Evaluate model weights")
val_parser.add_argument("--weights", required=True) val_parser.add_argument("--weights", required=True)
@@ -50,7 +86,7 @@ def build_parser() -> argparse.ArgumentParser:
predict_parser = subparsers.add_parser("predict", help="Run prediction") predict_parser = subparsers.add_parser("predict", help="Run prediction")
predict_parser.add_argument("--weights", required=True) predict_parser.add_argument("--weights", required=True)
predict_parser.add_argument("--source", required=True) predict_parser.add_argument("--source", required=True)
predict_parser.add_argument("--conf", type=float, default=0.25) predict_parser.add_argument("--conf", type=float, default=0.40)
predict_parser.add_argument("--iou", type=float, default=0.45) predict_parser.add_argument("--iou", type=float, default=0.45)
predict_parser.add_argument("--imgsz", type=int, default=640) predict_parser.add_argument("--imgsz", type=int, default=640)
predict_parser.add_argument("--device") predict_parser.add_argument("--device")
@@ -64,22 +100,12 @@ def build_parser() -> argparse.ArgumentParser:
export_parser.add_argument("--imgsz", type=int, default=640) export_parser.add_argument("--imgsz", type=int, default=640)
export_parser.add_argument("--device") export_parser.add_argument("--device")
convert_parser = subparsers.add_parser("convert", help="Convert VOC XML to YOLO labels")
convert_parser.add_argument("--data-root", default=str(DEFAULT_DATASET_ROOT))
convert_parser.add_argument(
"--splits",
nargs="+",
default=["train", "validation"],
)
return parser return parser
def main(argv: Sequence[str] | None = None) -> int: def _train_config_from_args(args: argparse.Namespace) -> TrainConfig:
args = build_parser().parse_args(argv) return TrainConfig(
if args.command == "train":
train(
data_yaml=args.data, data_yaml=args.data,
model_weights=args.model, model_weights=args.model,
epochs=args.epochs, epochs=args.epochs,
@@ -89,9 +115,30 @@ def main(argv: Sequence[str] | None = None) -> int:
device=args.device, device=args.device,
project=args.project, project=args.project,
name=args.name, name=args.name,
cache=args.cache,
patience=args.patience, patience=args.patience,
cache=args.cache,
save_period=args.save_period,
resume=args.resume,
optimizer=args.optimizer,
lr0=args.lr0,
lrf=args.lrf,
cos_lr=args.cos_lr,
warmup_epochs=args.warmup_epochs,
close_mosaic=args.close_mosaic,
mosaic=args.mosaic,
mixup=args.mixup,
degrees=args.degrees,
translate=args.translate,
scale=args.scale,
fliplr=args.fliplr,
) )
def main(argv: Sequence[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if args.command == "train":
train(_train_config_from_args(args))
elif args.command == "val": elif args.command == "val":
validate( validate(
weights=args.weights, weights=args.weights,
@@ -122,14 +169,6 @@ def main(argv: Sequence[str] | None = None) -> int:
imgsz=args.imgsz, imgsz=args.imgsz,
device=args.device, device=args.device,
) )
elif args.command == "convert":
data_root = Path(args.data_root).expanduser().resolve()
for split in args.splits:
converted = convert_dataset(
data_root / split / "annotations",
data_root / split / "labels",
)
print(f"{split}: converted {converted} XML files")
return 0 return 0

47
src/yolo/config.py Normal file
View File

@@ -0,0 +1,47 @@
from dataclasses import dataclass, fields, replace
from pathlib import Path
from typing import Any
from .defaults import (
DEFAULT_DATA_CONFIG,
DEFAULT_DETECT_RUNS_DIR,
DEFAULT_FINETUNE_MODEL,
)
PathLike = str | Path
@dataclass(frozen=True, slots=True)
class TrainConfig:
data_yaml: PathLike = DEFAULT_DATA_CONFIG
model_weights: PathLike = DEFAULT_FINETUNE_MODEL
epochs: int = 80
imgsz: int = 768
batch: int = 24
workers: int = 0
device: str | None = None
project: PathLike = DEFAULT_DETECT_RUNS_DIR
name: str | None = "smoke_fire_yolo11s_hard_negative_ft_v1"
patience: int = 20
cache: bool = False
save_period: int = 5
resume: PathLike | None = None
optimizer: str = "AdamW"
lr0: float = 0.0002
lrf: float = 0.1
cos_lr: bool = True
warmup_epochs: float = 2.0
close_mosaic: int = 0
mosaic: float = 0.0
mixup: float = 0.0
degrees: float = 0.0
translate: float = 0.02
scale: float = 0.1
fliplr: float = 0.5
@classmethod
def field_names(cls) -> set[str]:
return {field.name for field in fields(cls)}
def with_overrides(self, **overrides: Any) -> TrainConfig:
return replace(self, **overrides)

View File

@@ -1,3 +0,0 @@
from .convert import convert_dataset, voc_to_yolo
__all__ = ["convert_dataset", "voc_to_yolo"]

View File

@@ -1,83 +0,0 @@
import xml.etree.ElementTree as ET
from collections.abc import Mapping
from pathlib import Path
YoloBox = tuple[int, float, float, float, float]
def voc_to_yolo(
xml_path: str | Path,
class_map: Mapping[str, int] | None = None,
) -> list[YoloBox]:
classes = class_map or {"fire": 0}
source = Path(xml_path)
root = ET.parse(source).getroot()
size = root.find("size")
if size is None:
raise ValueError(f"Missing <size> in {source}")
image_width = float(size.findtext("width") or 0)
image_height = float(size.findtext("height") or 0)
if image_width <= 0 or image_height <= 0:
raise ValueError(
f"Invalid image size in {source}: {image_width}x{image_height}"
)
labels: list[YoloBox] = []
for obj in root.findall("object"):
name = obj.findtext("name")
if name not in classes:
continue
box = obj.find("bndbox")
if box is None:
raise ValueError(f"Missing <bndbox> in {source}")
xmin = float(box.findtext("xmin") or 0)
ymin = float(box.findtext("ymin") or 0)
xmax = float(box.findtext("xmax") or 0)
ymax = float(box.findtext("ymax") or 0)
if xmin < 0 or ymin < 0 or xmax <= xmin or ymax <= ymin:
raise ValueError(
f"Invalid bounding box in {source}: {xmin}, {ymin}, {xmax}, {ymax}"
)
if xmax > image_width or ymax > image_height:
raise ValueError(f"Bounding box exceeds image bounds in {source}")
labels.append(
(
classes[name],
((xmin + xmax) / 2) / image_width,
((ymin + ymax) / 2) / image_height,
(xmax - xmin) / image_width,
(ymax - ymin) / image_height,
)
)
return labels
def convert_dataset(
xml_dir: str | Path,
output_dir: str | Path,
class_map: Mapping[str, int] | None = None,
) -> int:
source_dir = Path(xml_dir)
destination_dir = Path(output_dir)
if not source_dir.is_dir():
raise FileNotFoundError(f"Annotation directory does not exist: {source_dir}")
destination_dir.mkdir(parents=True, exist_ok=True)
xml_files = sorted(source_dir.glob("*.xml"))
for xml_file in xml_files:
labels = voc_to_yolo(xml_file, class_map)
output_file = destination_dir / f"{xml_file.stem}.txt"
output_file.write_text(
"".join(
f"{class_id} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}\n"
for class_id, x_center, y_center, width, height in labels
),
encoding="utf-8",
)
return len(xml_files)

View File

@@ -6,7 +6,8 @@ DATA_DIR = PROJECT_ROOT / "data"
MODELS_DIR = PROJECT_ROOT / "models" MODELS_DIR = PROJECT_ROOT / "models"
RUNS_DIR = PROJECT_ROOT / "runs" RUNS_DIR = PROJECT_ROOT / "runs"
DEFAULT_DATA_CONFIG = CONFIG_DIR / "datasets" / "fire.yaml" DEFAULT_DATA_CONFIG = CONFIG_DIR / "datasets" / "smoke_fire.yaml"
DEFAULT_DATASET_ROOT = DATA_DIR / "fire-dataset" DEFAULT_DATASET_ROOT = DATA_DIR / "Smoke-Fire-Detection-YOLO"
DEFAULT_PRETRAINED_MODEL = MODELS_DIR / "pretrained" / "yolov8n.pt" DEFAULT_PRETRAINED_MODEL = MODELS_DIR / "pretrained" / "yolo11s.pt"
DEFAULT_FINETUNE_MODEL = RUNS_DIR / "detect" / "smoke_fire_yolo11s_v1-4" / "weights" / "best.pt"
DEFAULT_DETECT_RUNS_DIR = RUNS_DIR / "detect" DEFAULT_DETECT_RUNS_DIR = RUNS_DIR / "detect"

View File

@@ -1,56 +1,14 @@
from datetime import datetime from datetime import datetime
import csv
import json
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import torch import torch
from ultralytics import YOLO, settings from ultralytics import YOLO, settings
from .defaults import ( from .checkpoints import resolve_checkpoint
DEFAULT_DATA_CONFIG, from .config import PathLike, TrainConfig
DEFAULT_DETECT_RUNS_DIR, from .defaults import DEFAULT_DATA_CONFIG, DEFAULT_DETECT_RUNS_DIR, PROJECT_ROOT
DEFAULT_PRETRAINED_MODEL, from .reporting import write_best_point
PROJECT_ROOT,
)
PathLike = str | Path
def _write_best_point(run_dir: Path) -> None:
results_path = run_dir / "results.csv"
if not results_path.is_file():
return
with results_path.open(encoding="utf-8-sig", newline="") as results_file:
rows = list(csv.DictReader(results_file))
if not rows:
return
metric_keys = ("metrics/mAP50(B)", "metrics/mAP50-95(B)")
if any(metric_key not in rows[0] for metric_key in metric_keys):
return
best_points = {}
for metric_key in metric_keys:
best_row = max(rows, key=lambda row: float(row[metric_key]))
best_points[metric_key] = {
"epoch": int(float(best_row["epoch"])),
"value": float(best_row[metric_key]),
}
summary = {
"selection_metric": "metrics/mAP50-95(B)",
"best_point": best_points["metrics/mAP50-95(B)"],
"best_map50": best_points["metrics/mAP50(B)"],
"best_weights": str(run_dir / "weights" / "best.pt"),
"last_weights": str(run_dir / "weights" / "last.pt"),
"epochs_completed": len(rows),
}
(run_dir / "best_point.json").write_text(
json.dumps(summary, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def resolve_device(device: str | None = None) -> str: def resolve_device(device: str | None = None) -> str:
@@ -70,39 +28,70 @@ def _configure_ultralytics() -> None:
settings.update({"datasets_dir": str(PROJECT_ROOT)}) settings.update({"datasets_dir": str(PROJECT_ROOT)})
def _resolve_train_config(
config: TrainConfig | None,
overrides: dict[str, Any],
) -> tuple[TrainConfig, dict[str, Any]]:
active_config = config or TrainConfig()
config_fields = TrainConfig.field_names()
config_overrides = {
key: value for key, value in overrides.items() if key in config_fields
}
ultralytics_overrides = {
key: value for key, value in overrides.items() if key not in config_fields
}
return active_config.with_overrides(**config_overrides), ultralytics_overrides
def train( def train(
data_yaml: PathLike = DEFAULT_DATA_CONFIG, config: TrainConfig | None = None,
model_weights: PathLike = DEFAULT_PRETRAINED_MODEL, **overrides: Any,
epochs: int = 150,
imgsz: int = 640,
batch: int = 32,
workers: int = 8,
device: str | None = None,
project: PathLike = DEFAULT_DETECT_RUNS_DIR,
name: str | None = None,
patience: int = 20,
**kwargs: Any,
) -> Any: ) -> Any:
data_path = _existing_file(data_yaml, "Dataset config") active_config, ultralytics_overrides = _resolve_train_config(config, overrides)
weights_path = _existing_file(model_weights, "Model weights") data_path = _existing_file(active_config.data_yaml, "Dataset config")
checkpoint_path = resolve_checkpoint(active_config.resume, active_config.project)
weights_path = checkpoint_path or _existing_file(
active_config.model_weights,
"Model weights",
)
_configure_ultralytics() _configure_ultralytics()
run_name = name or f"fire_{datetime.now().strftime('%Y%m%d_%H%M%S')}" run_name = active_config.name or (
run_dir = Path(project) / run_name f"smoke_fire_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
)
model = YOLO(str(weights_path)) model = YOLO(str(weights_path))
results = model.train( results = model.train(
data=str(data_path), data=str(data_path),
epochs=epochs, epochs=active_config.epochs,
imgsz=imgsz, imgsz=active_config.imgsz,
batch=batch, batch=active_config.batch,
workers=workers, workers=active_config.workers,
device=resolve_device(device), device=resolve_device(active_config.device),
project=str(Path(project)), project=str(Path(active_config.project)),
name=run_name, name=run_name,
patience=patience, patience=active_config.patience,
**kwargs, cache=active_config.cache,
save_period=active_config.save_period,
resume=checkpoint_path is not None,
optimizer=active_config.optimizer,
lr0=active_config.lr0,
lrf=active_config.lrf,
cos_lr=active_config.cos_lr,
warmup_epochs=active_config.warmup_epochs,
close_mosaic=active_config.close_mosaic,
mosaic=active_config.mosaic,
mixup=active_config.mixup,
degrees=active_config.degrees,
translate=active_config.translate,
scale=active_config.scale,
fliplr=active_config.fliplr,
**ultralytics_overrides,
) )
_write_best_point(run_dir)
trainer = getattr(model, "trainer", None)
save_dir = getattr(trainer, "save_dir", None)
if save_dir is not None:
write_best_point(Path(save_dir))
return results return results

View File

@@ -0,0 +1,12 @@
"""Backward-compatible imports for the public model API."""
from .config import TrainConfig
from .engine import export_model, predict, train, validate
__all__ = ["TrainConfig", "export_model", "predict", "train", "validate"]
if __name__ == "__main__":
from .cli import main
raise SystemExit(main())

39
src/yolo/reporting.py Normal file
View File

@@ -0,0 +1,39 @@
import csv
import json
from pathlib import Path
def write_best_point(run_dir: Path) -> None:
results_path = run_dir / "results.csv"
if not results_path.is_file():
return
with results_path.open(encoding="utf-8-sig", newline="") as results_file:
rows = list(csv.DictReader(results_file))
if not rows:
return
metric_keys = ("metrics/mAP50(B)", "metrics/mAP50-95(B)")
if any(metric_key not in rows[0] for metric_key in metric_keys):
return
best_points = {}
for metric_key in metric_keys:
best_row = max(rows, key=lambda row: float(row[metric_key]))
best_points[metric_key] = {
"epoch": int(float(best_row["epoch"])),
"value": float(best_row[metric_key]),
}
summary = {
"selection_metric": "metrics/mAP50-95(B)",
"best_point": best_points["metrics/mAP50-95(B)"],
"best_map50": best_points["metrics/mAP50(B)"],
"best_weights": str(run_dir / "weights" / "best.pt"),
"last_weights": str(run_dir / "weights" / "last.pt"),
"epochs_completed": len(rows),
}
(run_dir / "best_point.json").write_text(
json.dumps(summary, ensure_ascii=False, indent=2),
encoding="utf-8",
)

View File

@@ -1,106 +0,0 @@
from collections.abc import Sequence
from pathlib import Path
import cv2
import numpy as np
PixelBox = tuple[int, int, int, int]
def draw_boxes(
image: np.ndarray,
boxes: Sequence[PixelBox],
labels: Sequence[str] | None = None,
confidences: Sequence[float] | None = None,
color: tuple[int, int, int] = (0, 0, 255),
thickness: int = 2,
) -> np.ndarray:
output = image.copy()
for index, (x1, y1, x2, y2) in enumerate(boxes):
cv2.rectangle(output, (x1, y1), (x2, y2), color, thickness)
text_parts: list[str] = []
if labels and index < len(labels):
text_parts.append(labels[index])
if confidences and index < len(confidences):
text_parts.append(f"{confidences[index]:.2f}")
text = " ".join(text_parts)
if not text:
continue
(text_width, text_height), _ = cv2.getTextSize(
text,
cv2.FONT_HERSHEY_SIMPLEX,
0.5,
1,
)
cv2.rectangle(
output,
(x1, y1 - text_height - 4),
(x1 + text_width + 4, y1),
color,
-1,
)
cv2.putText(
output,
text,
(x1 + 2, y1 - 3),
cv2.FONT_HERSHEY_SIMPLEX,
0.5,
(255, 255, 255),
1,
)
return output
def compare(
image_path: str | Path,
boxes: Sequence[PixelBox],
labels: Sequence[str] | None = None,
confidences: Sequence[float] | None = None,
save_path: str | Path | None = None,
) -> np.ndarray:
image = cv2.imread(str(image_path))
if image is None:
raise FileNotFoundError(f"Cannot read image: {image_path}")
annotated = draw_boxes(image, boxes, labels, confidences)
height, width = image.shape[:2]
padding = 20
comparison = np.full(
(height + padding, width * 2 + padding * 3, 3),
255,
dtype=np.uint8,
)
cv2.putText(
comparison,
"Original",
(padding + width // 2 - 30, 15),
cv2.FONT_HERSHEY_SIMPLEX,
0.5,
(0, 0, 0),
1,
)
cv2.putText(
comparison,
"Detected",
(padding * 2 + width + width // 2 - 30, 15),
cv2.FONT_HERSHEY_SIMPLEX,
0.5,
(0, 0, 0),
1,
)
comparison[padding : padding + height, padding : padding + width] = image
comparison[
padding : padding + height,
padding * 2 + width : padding * 2 + width * 2,
] = annotated
if save_path is not None:
destination = Path(save_path)
destination.parent.mkdir(parents=True, exist_ok=True)
if not cv2.imwrite(str(destination), comparison):
raise OSError(f"Failed to write image: {destination}")
return comparison

343
uv.lock generated
View File

@@ -10,6 +10,36 @@ resolution-markers = [
"python_full_version < '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version < '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'",
] ]
[[package]]
name = "annotated-doc"
version = "0.0.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
]
[[package]]
name = "annotated-types"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
]
[[package]]
name = "anyio"
version = "4.14.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.7.22" version = "2026.7.22"
@@ -54,6 +84,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" },
] ]
[[package]]
name = "click"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]] [[package]]
name = "contourpy" name = "contourpy"
version = "1.3.3" version = "1.3.3"
@@ -167,6 +218,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" },
] ]
[[package]]
name = "fastapi"
version = "0.141.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" },
]
[[package]] [[package]]
name = "filelock" name = "filelock"
version = "3.32.2" version = "3.32.2"
@@ -210,6 +277,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279", size = 206583, upload-time = "2026-07-28T16:34:49.538Z" }, { url = "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279", size = 206583, upload-time = "2026-07-28T16:34:49.538Z" },
] ]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httptools"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" },
{ url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" },
{ url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" },
{ url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" },
{ url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" },
{ url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" },
{ url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" },
{ url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" },
{ url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" },
{ url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" },
{ url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" },
{ url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" },
{ url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" },
{ url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" },
]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.18" version = "3.18"
@@ -697,6 +795,62 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
] ]
[[package]]
name = "pydantic"
version = "2.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
]
[[package]]
name = "pydantic-core"
version = "2.46.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
]
[[package]] [[package]]
name = "pyparsing" name = "pyparsing"
version = "3.3.2" version = "3.3.2"
@@ -718,6 +872,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
] ]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.32"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]
[[package]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0.3" version = "6.0.3"
@@ -777,6 +949,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
] ]
[[package]]
name = "starlette"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" },
]
[[package]] [[package]]
name = "sympy" name = "sympy"
version = "1.14.0" version = "1.14.0"
@@ -860,6 +1044,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
] ]
[[package]]
name = "typing-inspection"
version = "0.4.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6d/bc/4eae18cd40c65798a16267572ba346c11f599d44b01603dbd843342042bc/typing_inspection-0.4.3.tar.gz", hash = "sha256:c5f9ec1530b5c1e2c9bc34a84d9a3466ed1b2f3f2fa9f901368d9c5596210e4d", size = 76711, upload-time = "2026-08-10T09:39:18.063Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/42/f7/7a3935abdebd5cf18705a5f0335dd6a3a18bef3baa7cb9edc3b6b9922cc8/typing_inspection-0.4.3-py3-none-any.whl", hash = "sha256:5f42b23858a91e0b4ef521f5418f03a0da3c9216fd2995ef5e73463100e676cd", size = 14693, upload-time = "2026-08-10T09:39:16.693Z" },
]
[[package]] [[package]]
name = "tzdata" name = "tzdata"
version = "2026.3" version = "2026.3"
@@ -915,27 +1111,174 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
] ]
[[package]]
name = "uvicorn"
version = "0.52.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/03/18/ccce41535dee1be77735592bd19965f3972c82e07ee703d324709496b716/uvicorn-0.52.1.tar.gz", hash = "sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd", size = 100571, upload-time = "2026-08-01T18:19:30.732Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/d5/68e6e9bca63c0badf67002890a46d3784c958de45b65e1275ec583ca1f06/uvicorn-0.52.1-py3-none-any.whl", hash = "sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a", size = 79859, upload-time = "2026-08-01T18:19:29.294Z" },
]
[package.optional-dependencies]
standard = [
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
]
[[package]]
name = "watchfiles"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" },
{ url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" },
{ url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" },
{ url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" },
{ url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" },
{ url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" },
{ url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" },
{ url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" },
{ url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" },
{ url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" },
{ url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" },
{ url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" },
{ url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" },
{ url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" },
{ url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" },
{ url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" },
{ url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" },
{ url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" },
{ url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" },
{ url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" },
{ url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" },
{ url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" },
{ url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" },
{ url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" },
{ url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" },
{ url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" },
{ url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" },
{ url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" },
{ url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" },
{ url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" },
{ url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" },
{ url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" },
{ url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" },
{ url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" },
]
[[package]]
name = "websockets"
version = "17.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f7/96/e01084f83a64bcb3a27994bd0cb0db68ff29d9c6707fae37ec19b18ba990/websockets-17.0.1.tar.gz", hash = "sha256:5baa9bc0dfbae8c507e51c8cf1b6d4628086f7a87bbd3a9952bd5f035451f1cc", size = 183298, upload-time = "2026-07-31T11:31:27.665Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/dc/cadab608924ac605647031472fb1f8792d7d4ea07565ba1899ec42028e0d/websockets-17.0.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:53b90c00bc6201ab6695c7ff51a04d0e425514c37515e9eeecd2c1b978ac6c0e", size = 212640, upload-time = "2026-07-31T11:30:22.436Z" },
{ url = "https://files.pythonhosted.org/packages/16/7a/b034d13ca181211bbd58bb50835cb196a7784cd505b5a2079d4d03374f9f/websockets-17.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5f33a649bfcb8312524173cc4bbafa7dbb236e18eee9aa31a1d324ca0ddda28c", size = 210332, upload-time = "2026-07-31T11:30:23.608Z" },
{ url = "https://files.pythonhosted.org/packages/2f/4d/943ede39b53744768edf1ed84a3f9401527388228a3d6c1249c02c3d6bd7/websockets-17.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cddc675ec31bca65473321f9a9794e488b43b3b8de5d02c8ef4810c5d5792163", size = 210546, upload-time = "2026-07-31T11:30:24.932Z" },
{ url = "https://files.pythonhosted.org/packages/cf/e3/88dc159d2ae66743c669443246243f28d873b0c5e58271b8cc1ca0440334/websockets-17.0.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b3ff0ad440ad52dda64138f16895f66403f40192365e39b1010e889f289746b0", size = 219928, upload-time = "2026-07-31T11:30:26.221Z" },
{ url = "https://files.pythonhosted.org/packages/fe/f2/ff27eaefa15851a5cf7f004ab827a022bf2d6632cb520f89cb100db7e84b/websockets-17.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:72d7f2a5aeb4e82daa4ee18f125b4277f427033359be5c745ad709608446cc2c", size = 220279, upload-time = "2026-07-31T11:30:27.49Z" },
{ url = "https://files.pythonhosted.org/packages/19/2e/a5166149f363d2449c1cb2dde6486a245521979509d53b87a09f3e79662b/websockets-17.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6fd88365da261c53d3e943fb37e0d0721b9cde119f6b2e3fc84369b6ab234d63", size = 221525, upload-time = "2026-07-31T11:30:28.872Z" },
{ url = "https://files.pythonhosted.org/packages/9b/b5/f46931269b3ff3bde65d27c65ddb22f9bb8ce92ac2c6c4df0910128f6219/websockets-17.0.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ab9f962a5b64a5c3c845d556b7dc4e6fb683f7b67179f8205e814bb2e0213ffe", size = 222897, upload-time = "2026-07-31T11:30:30.164Z" },
{ url = "https://files.pythonhosted.org/packages/42/f4/deccf3439f35df953ec35e13fe07986821c5f1ab5785d69614283bdb9034/websockets-17.0.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c07f145d0b9e90cbd96035f31fb79199aef4da1872854e36ebeb258e3d57594", size = 222129, upload-time = "2026-07-31T11:30:31.489Z" },
{ url = "https://files.pythonhosted.org/packages/35/a5/e1b57a59da92ade37fd021567a17b518ea8267b28e5530075844cdb525fe/websockets-17.0.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9f7747d3daa41a11f25f7cca5dc988fc51da97b311bed4c9d843860f79779283", size = 220875, upload-time = "2026-07-31T11:30:32.805Z" },
{ url = "https://files.pythonhosted.org/packages/f0/30/e7d0889c790a854156de424575fd67af79ddbaed9ff3157ae863dfd1c1dc/websockets-17.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2abb1ba0a5133b7d2ef3c1c9f4b0c1e8a101012dce0b594ab2b2888d9a64820e", size = 218160, upload-time = "2026-07-31T11:30:34.512Z" },
{ url = "https://files.pythonhosted.org/packages/09/2e/43db785d6ed9ae7594fae7b62bbc9cb4dfee2b015e06a1005f1e5ce283b6/websockets-17.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f3fd9a1f87f8f0f3f8e9f9bd0195f7516562d13f5b178db8c5784d1f60b60bed", size = 220951, upload-time = "2026-07-31T11:30:35.806Z" },
{ url = "https://files.pythonhosted.org/packages/eb/f5/4ac3cab3d5e8a830657a822f64a8910e3803229c6783e59c3fd9a3487427/websockets-17.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2bc14b481e05e331811108daa1aeb41a5e237a5564ef2f02ec5a356a0f102f78", size = 219460, upload-time = "2026-07-31T11:30:37.273Z" },
{ url = "https://files.pythonhosted.org/packages/03/0e/c3a4020673ffc17c82cf1a467835038a196a555d3b4f2a50f0f063cf8ccc/websockets-17.0.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57d2ee9b24b404ce75f3814f92073c0ed88106c950148d2427fe8d25ca254d1f", size = 220248, upload-time = "2026-07-31T11:30:38.527Z" },
{ url = "https://files.pythonhosted.org/packages/7d/87/e47a6a278cc1dfade38444c893ce18322943c25d4b780a74450d9d164be1/websockets-17.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1b363bfd72a52c0658a3154a4cff219f15a474b35a235057d38853bf151acce7", size = 221421, upload-time = "2026-07-31T11:30:39.879Z" },
{ url = "https://files.pythonhosted.org/packages/da/8f/473d5fc4e3836e375b0233c6ef26777e6e5e3f7bfc84ccd524eae4090ed5/websockets-17.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:10b1587c599fa0f2c89154587c80e0fda98ade6c9fa8c0260a2823fb1800b685", size = 218975, upload-time = "2026-07-31T11:30:41.192Z" },
{ url = "https://files.pythonhosted.org/packages/d4/b9/819ec2dcdf69031d7e9cab11247f3a6ff9bbc8c7c53ada1dbcb9055b227b/websockets-17.0.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d7d72843691f50b91127c50688df10cb72ec6f4c4b1d7e2c11ab33b16acf8e51", size = 219925, upload-time = "2026-07-31T11:30:42.524Z" },
{ url = "https://files.pythonhosted.org/packages/85/b9/6c0da301f6118502e079cf92f4e864adf28e56b3f8c0f6085076ced7b876/websockets-17.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90973a3a00f23afdfd1c9b06fb84289bf0220f247ef8a62501a1967c7af54f7b", size = 220218, upload-time = "2026-07-31T11:30:44.04Z" },
{ url = "https://files.pythonhosted.org/packages/55/f9/cba32dc9dd856565263d6272f594255bd0e2781deb8cd982c026a54760ad/websockets-17.0.1-cp314-cp314-win32.whl", hash = "sha256:599b03beb77633bffc095334338fad79cafc2b01fbd58953838130a9ae967d7b", size = 212626, upload-time = "2026-07-31T11:30:45.579Z" },
{ url = "https://files.pythonhosted.org/packages/fa/95/91cdd8c192287d7ea741f37cf7d64fdc1a14410f06f73805e428a1a590af/websockets-17.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:81ce19c6046ace11da7001781be7317bb1dc389f399af4b2ed962190f76f9add", size = 212969, upload-time = "2026-07-31T11:30:46.983Z" },
{ url = "https://files.pythonhosted.org/packages/8e/fd/8c98a1e431960661c5769ab1a4dd66494e87ab02d791cc79e51e0d9a289f/websockets-17.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:efe0ae052a8d023b87198921e8a7ce1dc7768816bcd2fbc20df171ac73a04891", size = 212850, upload-time = "2026-07-31T11:30:48.304Z" },
{ url = "https://files.pythonhosted.org/packages/13/c1/142f5186ee7dc3beee0426b998a79e223e067b7689afcaa95890d64aa800/websockets-17.0.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ab56439c9f74c52770690c7b2f616b3bf775cb3920453ee355ac765c032d8bbf", size = 212967, upload-time = "2026-07-31T11:30:49.688Z" },
{ url = "https://files.pythonhosted.org/packages/02/de/4b03ed316c9dee180365286c298219809ff247be39beeaaf9958b21167ab/websockets-17.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:20a92f78ac8250984ed459faa9ca48c285adbfc0038ddc3fdac6046990a9c9ed", size = 210504, upload-time = "2026-07-31T11:30:50.987Z" },
{ url = "https://files.pythonhosted.org/packages/cd/d8/ad2b3e8f867e1e8cac3077e2f33ffb60b71bd763d6cfc71bd916f113c3bf/websockets-17.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6a434e59962a4fb9016bea327e1d14d6cd67670ecfb8942b4f4a0c24036634ce", size = 210702, upload-time = "2026-07-31T11:30:52.261Z" },
{ url = "https://files.pythonhosted.org/packages/48/18/7a77a82ce9d6f831c07b176da3942f7e71acd0f115f3ecdb1d00a040eb01/websockets-17.0.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2503c7e2a5049a12d5dac917a46d5d52591283a766165b8176bb167560421b38", size = 220290, upload-time = "2026-07-31T11:30:53.582Z" },
{ url = "https://files.pythonhosted.org/packages/f5/af/43c3e3c3ea7ba4693c2181743f3221957df28bededadcbd9fc8a0661bde0/websockets-17.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:28012a54510fe8301bb893ef143cec30a2780a2d3bc20b7bbdf4379d7a63945d", size = 220573, upload-time = "2026-07-31T11:30:54.966Z" },
{ url = "https://files.pythonhosted.org/packages/1f/bd/ed48eca15725743ee7e2dc172e15c61de29e85ec98dace7b14257f366836/websockets-17.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22bd00f8bae2bccdb5dbe41e20f58ba44ca9fff0b4b561aaf39099c35da762ed", size = 221747, upload-time = "2026-07-31T11:30:56.762Z" },
{ url = "https://files.pythonhosted.org/packages/99/50/838deb7937a8225c4925dd4a977eafea473fabf444178a99de0bc7e92bb0/websockets-17.0.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e98ec9ec61cce5bc4b8b218322ad090b0994eb060bb04da704c62ef0a3d864e6", size = 223891, upload-time = "2026-07-31T11:30:58.127Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e9/657fb70c6eb6bcd01adfa5d2b06496e9911e1c1a8813d353b8c00f7591cd/websockets-17.0.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e387adb0c692c6b5571bdeafc8ac9d1901ea30f10309134780b16ecd35e6605", size = 222317, upload-time = "2026-07-31T11:30:59.416Z" },
{ url = "https://files.pythonhosted.org/packages/07/4c/82cb722afa5428fed981331210c4c07600570db01bb1620579f655b5adaf/websockets-17.0.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd1470d2c53fe53269bf5619da7725d30dd9b9693f1689f7a85eab8dea734442", size = 221047, upload-time = "2026-07-31T11:31:00.757Z" },
{ url = "https://files.pythonhosted.org/packages/90/84/bd6d67d6bc65f0de0cb50de55dab42f256a9876a351c4736522eb168fda0/websockets-17.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:884af729b8ab50486acd94d9768c2b60914bf39b579ebba0a5cb73bfdfd61fd2", size = 218626, upload-time = "2026-07-31T11:31:02.48Z" },
{ url = "https://files.pythonhosted.org/packages/96/cb/6a372c8553976f0d8f97f5115826ed47e34b3be6b8bf0d0249af249a7416/websockets-17.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a60fa1a25cca1bcc2bf87b8d6be37a741f0a3239fb5e9cfb7a37173b68ffcf87", size = 221299, upload-time = "2026-07-31T11:31:03.795Z" },
{ url = "https://files.pythonhosted.org/packages/bc/31/f966e8472337974f74d788b3ef6c6f3b8b9a5f201efd16a843c91d269fa5/websockets-17.0.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e8208f2729cba030ff872a92064c97584eeb9502f53d32a05a0f05d5a17ca6c6", size = 219789, upload-time = "2026-07-31T11:31:05.08Z" },
{ url = "https://files.pythonhosted.org/packages/57/34/404e83a6cc7b0efcac810b7041bffd72ff76900e6fd0aa45a26c92fb2ffe/websockets-17.0.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:54cdcaa56f5d3eafd57058f0fa4a3de93a310b43a3c4699f06efc4c0bd054a5a", size = 220678, upload-time = "2026-07-31T11:31:06.635Z" },
{ url = "https://files.pythonhosted.org/packages/e5/70/8946188c2a68d67251859b589a3634918cf7867bf0b891347a5ecaa43d30/websockets-17.0.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4d41c0a1d47a478bc432b3b9068097bee1ce0c5b19327ea6f75c2ab34ab1f2fb", size = 221697, upload-time = "2026-07-31T11:31:08.023Z" },
{ url = "https://files.pythonhosted.org/packages/04/16/ee73fc2083a2938ac6209f4ec804960496835b20a0068dbcfe8424957c04/websockets-17.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f991247276797d0c61ab7770bc9791eadc16f683b4d83517f624932adc1a8bab", size = 219390, upload-time = "2026-07-31T11:31:09.378Z" },
{ url = "https://files.pythonhosted.org/packages/94/6a/d5f88033c69932af6cdaa72da62516ade47c257e3bf69f4c0ba5f40e12a2/websockets-17.0.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:733e3cc7171fa1b899edbe725ef9382d0e960657dc1fd933f3281ae910c01dab", size = 220161, upload-time = "2026-07-31T11:31:10.915Z" },
{ url = "https://files.pythonhosted.org/packages/e1/2e/6183dd2c0370287ecf4afe0bb33aca364208e5e7b0e1a286adcaecc0c78b/websockets-17.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:810cb3fb5fa6e447216f4e82d9a85cb8aed0929ae3538153ddfe8a6e3121a58d", size = 220591, upload-time = "2026-07-31T11:31:12.289Z" },
{ url = "https://files.pythonhosted.org/packages/26/93/70f6516d85b9744f7eac224c4b1b9ef4e84133f80b53be02080cb1c3e663/websockets-17.0.1-cp314-cp314t-win32.whl", hash = "sha256:17ac37716c0244e82c9e384c41653c090b1864c6610224ca3857e7f7b58fce10", size = 212755, upload-time = "2026-07-31T11:31:13.883Z" },
{ url = "https://files.pythonhosted.org/packages/f1/2c/9d9c1da5a7ea9af307b386d25f64d1dead4729644198d2b92e36db5dfd41/websockets-17.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bb31f42ea095ea826463c770829aa188a86c9a5c976b1467cbbf583c811de833", size = 213094, upload-time = "2026-07-31T11:31:15.367Z" },
{ url = "https://files.pythonhosted.org/packages/5b/24/a585e7573e128070605d003b5544729bcd58d9756c7e99d550818ca4b916/websockets-17.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dbfae8e75b342e31fc6fd1a8bbb393b7cbb91d6cfd581650300a94381e7b7e2b", size = 213009, upload-time = "2026-07-31T11:31:16.776Z" },
{ url = "https://files.pythonhosted.org/packages/09/ce/3929538b2b9918f5eee623fbf3346893973191f6df93f19bbda097bd7bb7/websockets-17.0.1-py3-none-any.whl", hash = "sha256:c6be9cba65c65cc76dfa3d4619e359ff02a4476c74e179b215236c11a0b32345", size = 206718, upload-time = "2026-07-31T11:31:26.037Z" },
]
[[package]] [[package]]
name = "yolo" name = "yolo"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "fastapi" },
{ name = "matplotlib" }, { name = "matplotlib" },
{ name = "numpy" }, { name = "numpy" },
{ name = "opencv-python" }, { name = "opencv-python" },
{ name = "pandas" }, { name = "pandas" },
{ name = "python-dotenv" },
{ name = "python-multipart" },
{ name = "torch" }, { name = "torch" },
{ name = "torchvision" }, { name = "torchvision" },
{ name = "ultralytics" }, { name = "ultralytics" },
{ name = "uvicorn", extra = ["standard"] },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "matplotlib", specifier = ">=3.11.1" }, { name = "matplotlib", specifier = ">=3.11.1" },
{ name = "numpy", specifier = ">=2.5.1" }, { name = "numpy", specifier = ">=2.5.1" },
{ name = "opencv-python", specifier = ">=5.0.0.93" }, { name = "opencv-python", specifier = ">=5.0.0.93" },
{ name = "pandas", specifier = ">=3.0.5" }, { name = "pandas", specifier = ">=3.0.5" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "python-multipart", specifier = ">=0.0.9" },
{ name = "torch", specifier = ">=2.13.0", index = "https://download.pytorch.org/whl/cu132" }, { name = "torch", specifier = ">=2.13.0", index = "https://download.pytorch.org/whl/cu132" },
{ name = "torchvision", specifier = ">=0.28.0", index = "https://download.pytorch.org/whl/cu132" }, { name = "torchvision", specifier = ">=0.28.0", index = "https://download.pytorch.org/whl/cu132" },
{ name = "ultralytics", specifier = ">=8.3.0" }, { name = "ultralytics", specifier = ">=8.3.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
] ]