feat: add video detection and robot alerts
This commit is contained in:
16
.env.example
Normal file
16
.env.example
Normal 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
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,6 +9,7 @@ wheels/
|
||||
# Virtual environments and caches
|
||||
.venv/
|
||||
.ruff_cache/
|
||||
.env
|
||||
|
||||
# IDEs
|
||||
.idea/
|
||||
@@ -22,4 +23,4 @@ runs/
|
||||
*.pt
|
||||
*.onnx
|
||||
*.engine
|
||||
*.torchscript
|
||||
*.torchscript
|
||||
|
||||
83
README.md
83
README.md
@@ -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
1
backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""HTTP inference backend for the smoke and fire detector."""
|
||||
323
backend/alerting.py
Normal file
323
backend/alerting.py
Normal 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
154
backend/main.py
Normal 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
243
frontend/app.js
Normal 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
90
frontend/index.html
Normal 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
75
frontend/styles.css
Normal 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%; }
|
||||
}
|
||||
11
models/pretrained/README.md
Normal file
11
models/pretrained/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Pretrained Models
|
||||
|
||||
本目录保存本地基础模型权重,默认训练权重为 `yolov8n.pt`。
|
||||
|
||||
当前本地权重:
|
||||
|
||||
- `yolov8n.pt`
|
||||
- `yolov8s.pt`
|
||||
- `yolo26n.pt`
|
||||
|
||||
权重文件通过 `.gitignore` 排除,不提交到 Git。
|
||||
@@ -15,6 +15,10 @@ dependencies = [
|
||||
"torch>=2.13.0",
|
||||
"torchvision>=0.28.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]
|
||||
@@ -31,4 +35,4 @@ fire-yolo = "yolo.cli:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.12.0,<0.13.0"]
|
||||
build-backend = "uv_build"
|
||||
build-backend = "uv_build"
|
||||
|
||||
25
src/yolo/checkpoints.py
Normal file
25
src/yolo/checkpoints.py
Normal 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
|
||||
141
src/yolo/cli.py
141
src/yolo/cli.py
@@ -1,41 +1,77 @@
|
||||
import argparse
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from .data import convert_dataset
|
||||
from .config import TrainConfig
|
||||
from .defaults import (
|
||||
DEFAULT_DATA_CONFIG,
|
||||
DEFAULT_DATASET_ROOT,
|
||||
DEFAULT_DETECT_RUNS_DIR,
|
||||
DEFAULT_PRETRAINED_MODEL,
|
||||
)
|
||||
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:
|
||||
parser = argparse.ArgumentParser(
|
||||
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)
|
||||
|
||||
train_parser = subparsers.add_parser("train", help="Train a fire detection model")
|
||||
train_parser.add_argument("--data", default=str(DEFAULT_DATA_CONFIG))
|
||||
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)",
|
||||
)
|
||||
train_parser = subparsers.add_parser("train", help="Train a detection model")
|
||||
_add_train_arguments(train_parser)
|
||||
|
||||
val_parser = subparsers.add_parser("val", help="Evaluate model weights")
|
||||
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.add_argument("--weights", 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("--imgsz", type=int, default=640)
|
||||
predict_parser.add_argument("--device")
|
||||
@@ -64,34 +100,45 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
export_parser.add_argument("--imgsz", type=int, default=640)
|
||||
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
|
||||
|
||||
|
||||
def _train_config_from_args(args: argparse.Namespace) -> TrainConfig:
|
||||
return TrainConfig(
|
||||
data_yaml=args.data,
|
||||
model_weights=args.model,
|
||||
epochs=args.epochs,
|
||||
imgsz=args.imgsz,
|
||||
batch=args.batch,
|
||||
workers=args.workers,
|
||||
device=args.device,
|
||||
project=args.project,
|
||||
name=args.name,
|
||||
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(
|
||||
data_yaml=args.data,
|
||||
model_weights=args.model,
|
||||
epochs=args.epochs,
|
||||
imgsz=args.imgsz,
|
||||
batch=args.batch,
|
||||
workers=args.workers,
|
||||
device=args.device,
|
||||
project=args.project,
|
||||
name=args.name,
|
||||
cache=args.cache,
|
||||
patience=args.patience,
|
||||
)
|
||||
train(_train_config_from_args(args))
|
||||
elif args.command == "val":
|
||||
validate(
|
||||
weights=args.weights,
|
||||
@@ -122,14 +169,6 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
imgsz=args.imgsz,
|
||||
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
|
||||
|
||||
|
||||
47
src/yolo/config.py
Normal file
47
src/yolo/config.py
Normal 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)
|
||||
@@ -1,3 +0,0 @@
|
||||
from .convert import convert_dataset, voc_to_yolo
|
||||
|
||||
__all__ = ["convert_dataset", "voc_to_yolo"]
|
||||
@@ -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)
|
||||
@@ -6,7 +6,8 @@ DATA_DIR = PROJECT_ROOT / "data"
|
||||
MODELS_DIR = PROJECT_ROOT / "models"
|
||||
RUNS_DIR = PROJECT_ROOT / "runs"
|
||||
|
||||
DEFAULT_DATA_CONFIG = CONFIG_DIR / "datasets" / "fire.yaml"
|
||||
DEFAULT_DATASET_ROOT = DATA_DIR / "fire-dataset"
|
||||
DEFAULT_PRETRAINED_MODEL = MODELS_DIR / "pretrained" / "yolov8n.pt"
|
||||
DEFAULT_DATA_CONFIG = CONFIG_DIR / "datasets" / "smoke_fire.yaml"
|
||||
DEFAULT_DATASET_ROOT = DATA_DIR / "Smoke-Fire-Detection-YOLO"
|
||||
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"
|
||||
@@ -1,56 +1,14 @@
|
||||
from datetime import datetime
|
||||
import csv
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from ultralytics import YOLO, settings
|
||||
|
||||
from .defaults import (
|
||||
DEFAULT_DATA_CONFIG,
|
||||
DEFAULT_DETECT_RUNS_DIR,
|
||||
DEFAULT_PRETRAINED_MODEL,
|
||||
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",
|
||||
)
|
||||
from .checkpoints import resolve_checkpoint
|
||||
from .config import PathLike, TrainConfig
|
||||
from .defaults import DEFAULT_DATA_CONFIG, DEFAULT_DETECT_RUNS_DIR, PROJECT_ROOT
|
||||
from .reporting import write_best_point
|
||||
|
||||
|
||||
def resolve_device(device: str | None = None) -> str:
|
||||
@@ -70,39 +28,70 @@ def _configure_ultralytics() -> None:
|
||||
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(
|
||||
data_yaml: PathLike = DEFAULT_DATA_CONFIG,
|
||||
model_weights: PathLike = DEFAULT_PRETRAINED_MODEL,
|
||||
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,
|
||||
config: TrainConfig | None = None,
|
||||
**overrides: Any,
|
||||
) -> Any:
|
||||
data_path = _existing_file(data_yaml, "Dataset config")
|
||||
weights_path = _existing_file(model_weights, "Model weights")
|
||||
active_config, ultralytics_overrides = _resolve_train_config(config, overrides)
|
||||
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()
|
||||
|
||||
run_name = name or f"fire_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
run_dir = Path(project) / run_name
|
||||
run_name = active_config.name or (
|
||||
f"smoke_fire_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
)
|
||||
model = YOLO(str(weights_path))
|
||||
results = model.train(
|
||||
data=str(data_path),
|
||||
epochs=epochs,
|
||||
imgsz=imgsz,
|
||||
batch=batch,
|
||||
workers=workers,
|
||||
device=resolve_device(device),
|
||||
project=str(Path(project)),
|
||||
epochs=active_config.epochs,
|
||||
imgsz=active_config.imgsz,
|
||||
batch=active_config.batch,
|
||||
workers=active_config.workers,
|
||||
device=resolve_device(active_config.device),
|
||||
project=str(Path(active_config.project)),
|
||||
name=run_name,
|
||||
patience=patience,
|
||||
**kwargs,
|
||||
patience=active_config.patience,
|
||||
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
|
||||
|
||||
|
||||
@@ -177,4 +166,4 @@ def export_model(
|
||||
device=resolve_device(device),
|
||||
**kwargs,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -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
39
src/yolo/reporting.py
Normal 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",
|
||||
)
|
||||
@@ -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
343
uv.lock
generated
@@ -10,6 +10,36 @@ resolution-markers = [
|
||||
"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]]
|
||||
name = "certifi"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "contourpy"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "filelock"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "idna"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pyparsing"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pyyaml"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "sympy"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "tzdata"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "yolo"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "matplotlib" },
|
||||
{ name = "numpy" },
|
||||
{ name = "opencv-python" },
|
||||
{ name = "pandas" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "torch" },
|
||||
{ name = "torchvision" },
|
||||
{ name = "ultralytics" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.115.0" },
|
||||
{ name = "matplotlib", specifier = ">=3.11.1" },
|
||||
{ name = "numpy", specifier = ">=2.5.1" },
|
||||
{ name = "opencv-python", specifier = ">=5.0.0.93" },
|
||||
{ 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 = "torchvision", specifier = ">=0.28.0", index = "https://download.pytorch.org/whl/cu132" },
|
||||
{ name = "ultralytics", specifier = ">=8.3.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user