diff --git a/app/config.py b/app/config.py index 1c97ce2..cd1b5be 100644 --- a/app/config.py +++ b/app/config.py @@ -135,6 +135,20 @@ class Settings(BaseSettings): #: 上传医生语音 WAV 的最大字节数(默认 10MB)。 voice_upload_max_bytes: int = Field(default=10 * 1024 * 1024, ge=64, le=50 * 1024 * 1024) + # --- Demo 客户端跨源(仅用于 scripts/demo_client 联调;生产置 false) --- + #: 为 true 时挂载 CORSMiddleware,便于浏览器 demo 从另一个端口访问本服务。 + demo_cors_enabled: bool = True + #: 逗号分隔的允许来源;`*` 表示允许全部来源(demo/联调用,生产应显式指定)。 + demo_cors_origins: str = "*" + + def parsed_demo_cors_origins(self) -> list[str]: + raw = (self.demo_cors_origins or "").strip() + if not raw: + return [] + if raw == "*": + return ["*"] + return [item.strip() for item in raw.split(",") if item.strip()] + @field_validator("consumable_classifier_weights", mode="before") @classmethod def consumable_classifier_weights_default(cls, value: object) -> str: diff --git a/app/services/consumable_vision_algorithm.py b/app/services/consumable_vision_algorithm.py index 5dfc7af..42676b5 100644 --- a/app/services/consumable_vision_algorithm.py +++ b/app/services/consumable_vision_algorithm.py @@ -5,6 +5,7 @@ from __future__ import annotations +import math import os import sys from collections import Counter @@ -13,8 +14,8 @@ from pathlib import Path from threading import Lock import numpy as np -import pandas as pd from loguru import logger +from openpyxl import load_workbook from ultralytics import YOLO from app.config import Settings, settings @@ -74,14 +75,22 @@ class ClsTop3: t3_pid: str -def _find_col(df: pd.DataFrame, want: str) -> str | None: +def _find_col_idx(headers: list[object], want: str) -> int | None: want = want.strip() - for c in df.columns: - if str(c).strip() == want: - return str(c) + for i, h in enumerate(headers): + if str(h).strip() == want: + return i return None +def _cell_empty(value: object) -> bool: + if value is None: + return True + if isinstance(value, float) and math.isnan(value): + return True + return False + + def _norm_product_name(name: str) -> str: s = (name or "").strip() if s == "一次性医用垫单": @@ -91,29 +100,41 @@ def _norm_product_name(name: str) -> str: def load_name_to_product_code(xlsx: Path) -> dict[str, str]: """商品名称 -> 产品编码(白名单键为归一化后的名称)。""" - df = pd.read_excel(xlsx, sheet_name=0) - c_code = _find_col(df, "产品编码") - c_name = _find_col(df, "商品名称") - if c_code is None or c_name is None: - raise ValueError("Excel 缺少「产品编码」或「商品名称」列") - m: dict[str, str] = {} - dups: set[str] = set() - for _, row in df.iterrows(): - raw = row.get(c_name) - if raw is None or (isinstance(raw, float) and pd.isna(raw)): - continue - n = _norm_product_name(str(raw).strip()) - if not n: - continue - code = row.get(c_code) - if code is None or (isinstance(code, float) and pd.isna(code)): - continue - sc = str(code).strip() - if n in m and m[n] != sc: - dups.add(n) - continue - if n not in m: - m[n] = sc + wb = load_workbook(filename=str(xlsx), read_only=True, data_only=True) + try: + ws = wb.worksheets[0] + rows = ws.iter_rows(values_only=True) + header = next(rows, None) + if header is None: + raise ValueError("Excel 为空") + headers = list(header) + i_code = _find_col_idx(headers, "产品编码") + i_name = _find_col_idx(headers, "商品名称") + if i_code is None or i_name is None: + raise ValueError("Excel 缺少「产品编码」或「商品名称」列") + + m: dict[str, str] = {} + dups: set[str] = set() + for row in rows: + if not row: + continue + raw = row[i_name] if i_name < len(row) else None + if _cell_empty(raw): + continue + n = _norm_product_name(str(raw).strip()) + if not n: + continue + code = row[i_code] if i_code < len(row) else None + if _cell_empty(code): + continue + sc = str(code).strip() + if n in m and m[n] != sc: + dups.add(n) + continue + if n not in m: + m[n] = sc + finally: + wb.close() if dups: logger.warning( "Excel 中以下商品名称对应多组产品编码,已保留首次映射: {}", diff --git a/main.py b/main.py index 6a5a34a..0a6a5b1 100644 --- a/main.py +++ b/main.py @@ -3,9 +3,11 @@ from contextlib import asynccontextmanager import uvicorn from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware from loguru import logger from app.api import router as api_router +from app.config import settings from app.database import check_database, engine, init_db_schema from app.dependencies import camera_session_manager @@ -33,6 +35,17 @@ def create_app() -> FastAPI: title="Operation Room Monitor", lifespan=lifespan, ) + if settings.demo_cors_enabled: + origins = settings.parsed_demo_cors_origins() + if origins: + application.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=origins != ["*"], + allow_methods=["*"], + allow_headers=["*"], + ) + logger.info("CORS enabled for demo client; origins={}", origins) application.include_router(api_router) return application diff --git a/pyproject.toml b/pyproject.toml index 25d8203..5dc174a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,6 @@ dependencies = [ "fastapi>=0.136.0", "loguru>=0.7.3", "openpyxl>=3.1.5", - "pandas>=2.3.0", "pillow>=12.2.0", "pydantic-settings>=2.13.1", "python-multipart>=0.0.26", diff --git a/scripts/demo_client/README.md b/scripts/demo_client/README.md new file mode 100644 index 0000000..bb0c784 --- /dev/null +++ b/scripts/demo_client/README.md @@ -0,0 +1,64 @@ +# Demo Client + +一个浏览器里的单页 demo,用于手动触发 `app/api.py` 里的 5 个 `/client/*` 接口,覆盖开始/结束手术、查询结果、拉取待确认耗材,以及**本地麦克风录 WAV 并上传**语音确认接口。 + +## 结构 + +``` +scripts/demo_client/ + server.py # 基于 stdlib 的静态服务器;额外暴露 /labels.json + index.html # 单文件页面(原生 JS,零构建依赖) +``` + +## 运行方式 + +```bash +# 1) 启动后端(默认 38080)。CORS 中间件在 settings.demo_cors_enabled=True 时自动挂载。 +uv run python main.py + +# 2) 启动 demo 客户端静态服务(默认 127.0.0.1:38081)。 +python scripts/demo_client/server.py +# 或指定端口: +python scripts/demo_client/server.py -p 9000 --host 0.0.0.0 + +# 3) 浏览器访问: +open http://localhost:38081/ +``` + +页面顶部的「服务端 Base URL」默认是 `http://localhost:38080`;如果后端部署在其他主机/端口,直接改这里即可。 + +## 页面包含什么 + +- `GET /health` 连通性检查 +- §4.1 `POST /client/surgeries/start` — 含 `surgery_id` 校验、`camera_ids` 多值输入、`candidate_consumables` 标签编辑器(初始值从 `/labels.json` 载入,可增删) +- §4.2 `POST /client/surgeries/end` +- §4.3 `GET /client/surgeries/{id}/result` — 以表格渲染 `details` 与 `summary` +- §4.4 `GET /client/surgeries/{id}/pending-confirmation` — 支持手动拉取与 2s 自动轮询 +- §4.5 `POST .../resolve` — 本地麦克风录音 → 16 kHz 单声道 WAV → `multipart/form-data` 上传 + +右侧「响应日志」按时间倒序展示每次请求的 method/url/status/body,便于联调截图。 + +## 关于 `/labels.json` + +`server.py` 在进程启动时读 `app/resources/consumable_classifier_labels.yaml` 的 `names` 映射并返回 `{"labels": [...]}`。优先用 `PyYAML`(主项目依赖已间接引入),缺失时回退到手写的最小 YAML 解析器(仅兼容该文件的已知形状)。 + +## 关于麦克风 + +浏览器的 `getUserMedia` 仅在**安全上下文**可用,对 demo 实际意味着: + +- **可以用**:`http://localhost`、`http://127.0.0.1`、`https://...` +- **不能用**:直接 `file://` 双击打开 `index.html`,或通过非本机 `http://` 访问 —— 所以这里用了独立的静态 HTTP 服务器而不是 `file://`。 + +页面采用: + +1. `navigator.mediaDevices.getUserMedia` 拿到单声道音轨 +2. `AudioContext` + `ScriptProcessorNode` 捕获 Float32 原始 PCM(输入采样率取决于系统,如 48 kHz) +3. 前端把样本线性降采样到 16 kHz、转 Int16 并拼 RIFF/WAVE 头 +4. 产出 `Blob("audio/wav")` 后可以「下载 wav(调试)」或直接「上传并确认」 + +## 关闭 CORS(生产环境) + +`app/config.py` 新增: + +- `DEMO_CORS_ENABLED`(默认 `True`,生产请在 `.env` 里置 `false`) +- `DEMO_CORS_ORIGINS`(默认 `*`,可写 `http://my-host:38081,https://or-demo.example.com`) diff --git a/scripts/demo_client/index.html b/scripts/demo_client/index.html new file mode 100644 index 0000000..73fcee4 --- /dev/null +++ b/scripts/demo_client/index.html @@ -0,0 +1,717 @@ + + + + + + Operation Room Monitor · Demo Client + + + +
+
+
+

Operation Room Monitor · Demo Client

+

手动触发 /client/* 5 个接口;本地麦克风录音后生成 WAV 上传语音确认接口。

+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+

§4.1 开始手术

+
+
+ + +
+
+ +
+ +
+
+
+
+ + + +
+
+ +
+

§4.2 结束手术

+
+ +
+
+ +
+

§4.3 查询结果

+
+ +
+
+
+ +
+

§4.4 待确认耗材

+
+ + + +
+ +
+ +
+

§4.5 语音确认(录音 → WAV → 上传)

+
+
+ + +
+
+ +

录音

+
+ + +
+ 就绪 +
+ + +
+ + +
+
+
+ + +
+ + + + diff --git a/scripts/demo_client/server.py b/scripts/demo_client/server.py new file mode 100644 index 0000000..5c171c7 --- /dev/null +++ b/scripts/demo_client/server.py @@ -0,0 +1,134 @@ +"""Tiny stdlib HTTP server for the demo client page. + +- Serves `index.html` (and other sibling files) from this directory. +- Exposes `GET /labels.json`, which parses the repo's + `app/resources/consumable_classifier_labels.yaml` and returns its label + list so the page can prefill the candidate-consumables input. + +Run: + python scripts/demo_client/server.py # 127.0.0.1:38081 + python scripts/demo_client/server.py -p 9000 # custom port +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from http import HTTPStatus +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +SCRIPT_DIR = Path(__file__).resolve().parent +REPO_ROOT = SCRIPT_DIR.parents[1] +LABELS_YAML = REPO_ROOT / "app" / "resources" / "consumable_classifier_labels.yaml" + + +def _load_labels_with_pyyaml(path: Path) -> list[str] | None: + try: + import yaml # type: ignore[import-untyped] + except ImportError: + return None + try: + data: Any = yaml.safe_load(path.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError): + return None + if not isinstance(data, dict): + return None + names = data.get("names") + if isinstance(names, dict): + try: + items = sorted(names.items(), key=lambda kv: int(kv[0])) + except (TypeError, ValueError): + items = list(names.items()) + return [str(v) for _, v in items] + if isinstance(names, list): + return [str(v) for v in names] + return None + + +def _load_labels_fallback(path: Path) -> list[str]: + """Minimal parser for the known labels.yaml shape: `: ` under `names:`.""" + labels: list[tuple[int, str]] = [] + in_names = False + pattern = re.compile(r"^\s+(\d+)\s*:\s*(.+?)\s*$") + try: + text = path.read_text(encoding="utf-8") + except OSError: + return [] + for line in text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if stripped.startswith("names:"): + in_names = True + continue + if in_names and not line.startswith((" ", "\t")): + in_names = False + if not in_names: + continue + match = pattern.match(line) + if match: + labels.append((int(match.group(1)), match.group(2))) + labels.sort(key=lambda kv: kv[0]) + return [name for _, name in labels] + + +def load_labels() -> list[str]: + if not LABELS_YAML.is_file(): + return [] + labels = _load_labels_with_pyyaml(LABELS_YAML) + if labels is None: + labels = _load_labels_fallback(LABELS_YAML) + return labels + + +class DemoHandler(SimpleHTTPRequestHandler): + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, directory=str(SCRIPT_DIR), **kwargs) + + def do_GET(self) -> None: # noqa: N802 (stdlib override) + if self.path.split("?", 1)[0] == "/labels.json": + self._send_labels() + return + super().do_GET() + + def _send_labels(self) -> None: + body = json.dumps({"labels": load_labels()}, ensure_ascii=False).encode("utf-8") + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: Any) -> None: # noqa: A002 + sys.stderr.write( + "[demo-client] %s - %s\n" % (self.address_string(), format % args) + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Operation room demo client server") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("-p", "--port", type=int, default=38081) + args = parser.parse_args() + + server = ThreadingHTTPServer((args.host, args.port), DemoHandler) + url = f"http://{args.host}:{args.port}/" + print(f"Demo client serving at {url}") + print(f" static dir : {SCRIPT_DIR}") + print(f" labels yaml: {LABELS_YAML}") + print("Press Ctrl+C to stop.") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nShutting down.") + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock index d9652df..033a8e7 100644 --- a/uv.lock +++ b/uv.lock @@ -833,7 +833,6 @@ dependencies = [ { name = "loguru" }, { name = "minio" }, { name = "openpyxl" }, - { name = "pandas" }, { name = "pillow" }, { name = "pydantic-settings" }, { name = "python-multipart" }, @@ -860,7 +859,6 @@ requires-dist = [ { name = "loguru", specifier = ">=0.7.3" }, { name = "minio", specifier = ">=7.2.15" }, { name = "openpyxl", specifier = ">=3.1.5" }, - { name = "pandas", specifier = ">=2.3.0" }, { name = "pillow", specifier = ">=12.2.0" }, { name = "pydantic-settings", specifier = ">=2.13.1" }, { name = "python-multipart", specifier = ">=0.0.26" }, @@ -886,50 +884,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, ] -[[package]] -name = "pandas" -version = "3.0.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "python-dateutil" }, - { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/da/99/b342345300f13440fe9fe385c3c481e2d9a595ee3bab4d3219247ac94e9a/pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", size = 4645855, upload-time = "2026-03-31T06:48:30.816Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/ca/3e639a1ea6fcd0617ca4e8ca45f62a74de33a56ae6cd552735470b22c8d3/pandas-3.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5918ba197c951dec132b0c5929a00c0bf05d5942f590d3c10a807f6e15a57d3", size = 10321105, upload-time = "2026-03-31T06:46:57.327Z" }, - { url = "https://files.pythonhosted.org/packages/0b/77/dbc82ff2fb0e63c6564356682bf201edff0ba16c98630d21a1fb312a8182/pandas-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d606a041c89c0a474a4702d532ab7e73a14fe35c8d427b972a625c8e46373668", size = 9864088, upload-time = "2026-03-31T06:46:59.935Z" }, - { url = "https://files.pythonhosted.org/packages/5c/2b/341f1b04bbca2e17e13cd3f08c215b70ef2c60c5356ef1e8c6857449edc7/pandas-3.0.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:710246ba0616e86891b58ab95f2495143bb2bc83ab6b06747c74216f583a6ac9", size = 10369066, upload-time = "2026-03-31T06:47:02.792Z" }, - { url = "https://files.pythonhosted.org/packages/12/c5/cbb1ffefb20a93d3f0e1fdcda699fb84976210d411b008f97f48bf6ce27e/pandas-3.0.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d3cfe227c725b1f3dff4278b43d8c784656a42a9325b63af6b1492a8232209e", size = 10876780, upload-time = "2026-03-31T06:47:06.205Z" }, - { url = "https://files.pythonhosted.org/packages/98/fe/2249ae5e0a69bd0ddf17353d0a5d26611d70970111f5b3600cdc8be883e7/pandas-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c3b723df9087a9a9a840e263ebd9f88b64a12075d1bf2ea401a5a42f254f084d", size = 11375181, upload-time = "2026-03-31T06:47:09.383Z" }, - { url = "https://files.pythonhosted.org/packages/de/64/77a38b09e70b6464883b8d7584ab543e748e42c1b5d337a2ee088e0df741/pandas-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3096110bf9eac0070b7208465f2740e2d8a670d5cb6530b5bb884eca495fd39", size = 11928899, upload-time = "2026-03-31T06:47:12.686Z" }, - { url = "https://files.pythonhosted.org/packages/5e/52/42855bf626868413f761addd574acc6195880ae247a5346477a4361c3acb/pandas-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:07a10f5c36512eead51bc578eb3354ad17578b22c013d89a796ab5eee90cd991", size = 9746574, upload-time = "2026-03-31T06:47:15.64Z" }, - { url = "https://files.pythonhosted.org/packages/88/39/21304ae06a25e8bf9fc820d69b29b2c495b2ae580d1e143146c309941760/pandas-3.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:5fdbfa05931071aba28b408e59226186b01eb5e92bea2ab78b65863ca3228d84", size = 9047156, upload-time = "2026-03-31T06:47:18.595Z" }, - { url = "https://files.pythonhosted.org/packages/72/20/7defa8b27d4f330a903bb68eea33be07d839c5ea6bdda54174efcec0e1d2/pandas-3.0.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:dbc20dea3b9e27d0e66d74c42b2d0c1bed9c2ffe92adea33633e3bedeb5ac235", size = 10756238, upload-time = "2026-03-31T06:47:22.012Z" }, - { url = "https://files.pythonhosted.org/packages/e9/95/49433c14862c636afc0e9b2db83ff16b3ad92959364e52b2955e44c8e94c/pandas-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b75c347eff42497452116ce05ef461822d97ce5b9ff8df6edacb8076092c855d", size = 10408520, upload-time = "2026-03-31T06:47:25.197Z" }, - { url = "https://files.pythonhosted.org/packages/3b/f8/462ad2b5881d6b8ec8e5f7ed2ea1893faa02290d13870a1600fe72ad8efc/pandas-3.0.2-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1478075142e83a5571782ad007fb201ed074bdeac7ebcc8890c71442e96adf7", size = 10324154, upload-time = "2026-03-31T06:47:28.097Z" }, - { url = "https://files.pythonhosted.org/packages/0a/65/d1e69b649cbcddda23ad6e4c40ef935340f6f652a006e5cbc3555ac8adb3/pandas-3.0.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5880314e69e763d4c8b27937090de570f1fb8d027059a7ada3f7f8e98bdcb677", size = 10714449, upload-time = "2026-03-31T06:47:30.85Z" }, - { url = "https://files.pythonhosted.org/packages/47/a4/85b59bc65b8190ea3689882db6cdf32a5003c0ccd5a586c30fdcc3ffc4fc/pandas-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b5329e26898896f06035241a626d7c335daa479b9bbc82be7c2742d048e41172", size = 11338475, upload-time = "2026-03-31T06:47:34.026Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c4/bc6966c6e38e5d9478b935272d124d80a589511ed1612a5d21d36f664c68/pandas-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:81526c4afd31971f8b62671442a4b2b51e0aa9acc3819c9f0f12a28b6fcf85f1", size = 11786568, upload-time = "2026-03-31T06:47:36.941Z" }, - { url = "https://files.pythonhosted.org/packages/e8/74/09298ca9740beed1d3504e073d67e128aa07e5ca5ca2824b0c674c0b8676/pandas-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:7cadd7e9a44ec13b621aec60f9150e744cfc7a3dd32924a7e2f45edff31823b0", size = 10488652, upload-time = "2026-03-31T06:47:40.612Z" }, - { url = "https://files.pythonhosted.org/packages/bb/40/c6ea527147c73b24fc15c891c3fcffe9c019793119c5742b8784a062c7db/pandas-3.0.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db0dbfd2a6cdf3770aa60464d50333d8f3d9165b2f2671bcc299b72de5a6677b", size = 10326084, upload-time = "2026-03-31T06:47:43.834Z" }, - { url = "https://files.pythonhosted.org/packages/95/25/bdb9326c3b5455f8d4d3549fce7abcf967259de146fe2cf7a82368141948/pandas-3.0.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0555c5882688a39317179ab4a0ed41d3ebc8812ab14c69364bbee8fb7a3f6288", size = 9914146, upload-time = "2026-03-31T06:47:46.67Z" }, - { url = "https://files.pythonhosted.org/packages/8d/77/3a227ff3337aa376c60d288e1d61c5d097131d0ac71f954d90a8f369e422/pandas-3.0.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01f31a546acd5574ef77fe199bc90b55527c225c20ccda6601cf6b0fd5ed597c", size = 10444081, upload-time = "2026-03-31T06:47:49.681Z" }, - { url = "https://files.pythonhosted.org/packages/15/88/3cdd54fa279341afa10acf8d2b503556b1375245dccc9315659f795dd2e9/pandas-3.0.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:deeca1b5a931fdf0c2212c8a659ade6d3b1edc21f0914ce71ef24456ca7a6535", size = 10897535, upload-time = "2026-03-31T06:47:53.033Z" }, - { url = "https://files.pythonhosted.org/packages/06/9d/98cc7a7624f7932e40f434299260e2917b090a579d75937cb8a57b9d2de3/pandas-3.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f48afd9bb13300ffb5a3316973324c787054ba6665cda0da3fbd67f451995db", size = 11446992, upload-time = "2026-03-31T06:47:56.193Z" }, - { url = "https://files.pythonhosted.org/packages/9a/cd/19ff605cc3760e80602e6826ddef2824d8e7050ed80f2e11c4b079741dc3/pandas-3.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c4d8458b97a35717b62469a4ea0e85abd5ed8687277f5ccfc67f8a5126f8c53", size = 11968257, upload-time = "2026-03-31T06:47:59.137Z" }, - { url = "https://files.pythonhosted.org/packages/db/60/aba6a38de456e7341285102bede27514795c1eaa353bc0e7638b6b785356/pandas-3.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:b35d14bb5d8285d9494fe93815a9e9307c0876e10f1e8e89ac5b88f728ec8dcf", size = 9865893, upload-time = "2026-03-31T06:48:02.038Z" }, - { url = "https://files.pythonhosted.org/packages/08/71/e5ec979dd2e8a093dacb8864598c0ff59a0cee0bbcdc0bfec16a51684d4f/pandas-3.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:63d141b56ef686f7f0d714cfb8de4e320475b86bf4b620aa0b7da89af8cbdbbb", size = 9188644, upload-time = "2026-03-31T06:48:05.045Z" }, - { url = "https://files.pythonhosted.org/packages/f1/6c/7b45d85db19cae1eb524f2418ceaa9d85965dcf7b764ed151386b7c540f0/pandas-3.0.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:140f0cffb1fa2524e874dde5b477d9defe10780d8e9e220d259b2c0874c89d9d", size = 10776246, upload-time = "2026-03-31T06:48:07.789Z" }, - { url = "https://files.pythonhosted.org/packages/a8/3e/7b00648b086c106e81766f25322b48aa8dfa95b55e621dbdf2fdd413a117/pandas-3.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae37e833ff4fed0ba352f6bdd8b73ba3ab3256a85e54edfd1ab51ae40cca0af8", size = 10424801, upload-time = "2026-03-31T06:48:10.897Z" }, - { url = "https://files.pythonhosted.org/packages/da/6e/558dd09a71b53b4008e7fc8a98ec6d447e9bfb63cdaeea10e5eb9b2dabe8/pandas-3.0.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d888a5c678a419a5bb41a2a93818e8ed9fd3172246555c0b37b7cc27027effd", size = 10345643, upload-time = "2026-03-31T06:48:13.7Z" }, - { url = "https://files.pythonhosted.org/packages/be/e3/921c93b4d9a280409451dc8d07b062b503bbec0531d2627e73a756e99a82/pandas-3.0.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b444dc64c079e84df91baa8bf613d58405645461cabca929d9178f2cd392398d", size = 10743641, upload-time = "2026-03-31T06:48:16.659Z" }, - { url = "https://files.pythonhosted.org/packages/56/ca/fd17286f24fa3b4d067965d8d5d7e14fe557dd4f979a0b068ac0deaf8228/pandas-3.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4544c7a54920de8eeacaa1466a6b7268ecfbc9bc64ab4dbb89c6bbe94d5e0660", size = 11361993, upload-time = "2026-03-31T06:48:19.475Z" }, - { url = "https://files.pythonhosted.org/packages/e4/a5/2f6ed612056819de445a433ca1f2821ac3dab7f150d569a59e9cc105de1d/pandas-3.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:734be7551687c00fbd760dc0522ed974f82ad230d4a10f54bf51b80d44a08702", size = 11815274, upload-time = "2026-03-31T06:48:22.695Z" }, - { url = "https://files.pythonhosted.org/packages/00/2f/b622683e99ec3ce00b0854bac9e80868592c5b051733f2cf3a868e5fea26/pandas-3.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:57a07209bebcbcf768d2d13c9b78b852f9a15978dac41b9e6421a81ad4cdd276", size = 10888530, upload-time = "2026-03-31T06:48:25.806Z" }, - { url = "https://files.pythonhosted.org/packages/cb/2b/f8434233fab2bd66a02ec014febe4e5adced20e2693e0e90a07d118ed30e/pandas-3.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482", size = 9455341, upload-time = "2026-03-31T06:48:28.418Z" }, -] - [[package]] name = "pillow" version = "12.2.0" @@ -1568,15 +1522,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] -[[package]] -name = "tzdata" -version = "2026.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" }, -] - [[package]] name = "ultralytics" version = "8.4.40"