feat: demo CORS, demo client, openpyxl catalog load

- Load consumable catalog XLSX with openpyxl and drop the pandas dependency.
- Add optional demo CORS settings and FastAPI CORSMiddleware for browser clients.
- Add scripts/demo_client static page and local server for API smoke tests.

Made-with: Cursor
This commit is contained in:
Kevin
2026-04-22 17:00:56 +08:00
parent 132702aea9
commit 42720f81cf
8 changed files with 991 additions and 84 deletions

View File

@@ -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:

View File

@@ -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 中以下商品名称对应多组产品编码,已保留首次映射: {}",

13
main.py
View File

@@ -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

View File

@@ -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",

View File

@@ -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`

View File

@@ -0,0 +1,717 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Operation Room Monitor · Demo Client</title>
<style>
:root {
--bg: #0f172a;
--panel: #111827;
--panel-2: #1f2937;
--border: #334155;
--text: #e2e8f0;
--muted: #94a3b8;
--accent: #38bdf8;
--accent-2: #22c55e;
--danger: #ef4444;
--warn: #f59e0b;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(360px, 1fr);
gap: 16px;
padding: 16px;
min-height: 100vh;
}
h1 { font-size: 18px; margin: 0 0 4px; }
h2 { font-size: 15px; margin: 0 0 10px; color: var(--accent); }
h3 { font-size: 13px; margin: 14px 0 6px; color: var(--muted); }
section.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 16px;
margin-bottom: 14px;
}
label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
input[type=text], input[type=url], input[type=number], select, textarea {
width: 100%;
background: var(--panel-2);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 7px 9px;
font-size: 13px;
font-family: inherit;
}
input:focus, textarea:focus, select:focus { outline: 1px solid var(--accent); }
button {
background: var(--accent);
color: #0b1220;
border: 0;
border-radius: 6px;
padding: 7px 14px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
button.secondary { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); }
button.danger { background: var(--danger); color: #fff; }
button.warn { background: var(--warn); color: #0b1220; }
button:disabled { opacity: .5; cursor: not-allowed; }
.row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; }
.actions { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.kv { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; color: var(--muted); }
.pill {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 999px;
padding: 3px 10px;
margin: 3px 4px 3px 0;
font-size: 12px;
}
.pill button {
background: transparent;
color: var(--muted);
padding: 0 0 0 4px;
font-size: 13px;
}
.pill button:hover { color: var(--danger); }
.tags-wrap {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px;
min-height: 42px;
max-height: 160px;
overflow-y: auto;
}
.tags-wrap input {
background: transparent;
border: 0;
padding: 3px 6px;
min-width: 140px;
width: auto;
color: var(--text);
}
.log {
background: #0b1220;
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px;
height: calc(100vh - 64px);
overflow-y: auto;
position: sticky;
top: 16px;
}
.log-item { border-bottom: 1px dashed var(--border); padding: 8px 4px; }
.log-item:last-child { border-bottom: 0; }
.log-head { display: flex; justify-content: space-between; gap: 6px; align-items: baseline; }
.log-method { font-weight: 700; color: var(--accent); font-size: 11px; letter-spacing: .5px; }
.log-status { font-size: 11px; }
.log-status.ok { color: var(--accent-2); }
.log-status.err { color: var(--danger); }
.log-url { font-size: 11px; color: var(--muted); word-break: break-all; }
.log-body {
background: var(--panel-2);
border-radius: 4px;
padding: 6px 8px;
margin-top: 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
max-height: 220px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.log-time { color: var(--muted); font-size: 11px; }
.badge {
display: inline-block;
font-size: 10px;
padding: 2px 6px;
border-radius: 3px;
margin-left: 6px;
background: var(--panel-2);
color: var(--muted);
}
.badge.on { background: #064e3b; color: #a7f3d0; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td { padding: 6px 8px; border-bottom: 1px solid var(--border); text-align: left; }
th { color: var(--muted); font-weight: 500; }
.record-controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.meter {
flex: 1;
min-width: 140px;
height: 10px;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 5px;
overflow: hidden;
}
.meter > span {
display: block;
height: 100%;
width: 0;
background: linear-gradient(90deg, #22c55e, #f59e0b, #ef4444);
transition: width 0.06s linear;
}
.muted { color: var(--muted); }
.err { color: var(--danger); }
.ok { color: var(--accent-2); }
.small { font-size: 12px; }
.grow { flex: 1; }
audio { width: 100%; margin-top: 8px; }
.pending-box { background: var(--panel-2); border-radius: 6px; padding: 10px; margin-top: 8px; }
.option-row { display: flex; justify-content: space-between; padding: 2px 0; }
@media (max-width: 960px) {
.layout { grid-template-columns: 1fr; }
.log { position: static; height: auto; max-height: 50vh; }
}
</style>
</head>
<body>
<div class="layout">
<main>
<section class="card">
<h1>Operation Room Monitor · Demo Client</h1>
<p class="muted small">手动触发 <code>/client/*</code> 5 个接口;本地麦克风录音后生成 WAV 上传语音确认接口。</p>
<div class="row" style="margin-top:10px">
<div>
<label>服务端 Base URL</label>
<input id="base-url" type="url" value="http://localhost:38080" />
</div>
<div>
<label>手术号 surgery_id6 位数字)</label>
<input id="surgery-id" type="text" inputmode="numeric" pattern="\d{6}" maxlength="6" value="123456" />
</div>
</div>
<div class="actions">
<button id="btn-health" class="secondary">GET /health</button>
<span id="health-status" class="small muted"></span>
</div>
</section>
<section class="card">
<h2>§4.1 开始手术</h2>
<div class="row">
<div>
<label>camera_ids逗号分隔至少一个</label>
<input id="camera-ids" type="text" value="or-cam-01" />
</div>
<div>
<label>candidate_consumables<span id="labels-hint" class="badge">loading…</span></label>
<div class="tags-wrap" id="tags">
<input id="tag-input" type="text" placeholder="输入后回车添加" />
</div>
</div>
</div>
<div class="actions">
<button id="btn-start">POST /client/surgeries/start</button>
<button id="btn-load-all-labels" class="secondary" type="button">载入全部标签</button>
<button id="btn-clear-labels" class="secondary" type="button">清空</button>
</div>
</section>
<section class="card">
<h2>§4.2 结束手术</h2>
<div class="actions">
<button id="btn-end" class="warn">POST /client/surgeries/end</button>
</div>
</section>
<section class="card">
<h2>§4.3 查询结果</h2>
<div class="actions">
<button id="btn-result" class="secondary">GET /client/surgeries/{id}/result</button>
</div>
<div id="result-render"></div>
</section>
<section class="card">
<h2>§4.4 待确认耗材</h2>
<div class="actions">
<button id="btn-pending" class="secondary">拉一条待确认</button>
<label class="small" style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input id="auto-poll" type="checkbox" /> 自动轮询2s
</label>
<span id="voice-status" class="small muted"></span>
</div>
<div id="pending-render" class="pending-box" hidden></div>
</section>
<section class="card">
<h2>§4.5 语音确认(录音 → WAV → 上传)</h2>
<div class="row">
<div>
<label>confirmation_id从 §4.4 自动填入,也可手动修改)</label>
<input id="confirmation-id" type="text" />
</div>
</div>
<h3>录音</h3>
<div class="record-controls">
<button id="btn-rec-start">开始录音</button>
<button id="btn-rec-stop" class="danger" disabled>停止</button>
<div class="meter"><span id="meter-bar"></span></div>
<span id="rec-info" class="muted small">就绪</span>
</div>
<audio id="audio-preview" controls hidden></audio>
<div class="actions">
<button id="btn-resolve" disabled>上传并确认</button>
<a id="btn-download" class="small muted" href="#" download="voice.wav" style="display:none">下载 WAV调试</a>
</div>
</section>
</main>
<aside>
<div class="log" id="log">
<div class="small muted" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<strong style="color:var(--text)">响应日志</strong>
<button class="secondary" id="btn-clear-log" style="padding:3px 8px;font-size:11px">清空</button>
</div>
</div>
</aside>
</div>
<script>
// ============================================================
// Utilities
// ============================================================
const $ = (id) => document.getElementById(id);
const baseUrl = () => $("base-url").value.trim().replace(/\/+$/, "");
const surgeryId = () => $("surgery-id").value.trim();
const logEl = $("log");
function addLog(method, url, status, body, { error = false } = {}) {
const item = document.createElement("div");
item.className = "log-item";
const time = new Date().toLocaleTimeString();
const statusClass = error || (typeof status === "number" && status >= 400) ? "err" : "ok";
const statusText = typeof status === "number" ? status : status || (error ? "ERROR" : "—");
item.innerHTML = `
<div class="log-head">
<span><span class="log-method">${method}</span> <span class="log-url">${url}</span></span>
<span><span class="log-status ${statusClass}">${statusText}</span> <span class="log-time">${time}</span></span>
</div>`;
const bodyEl = document.createElement("div");
bodyEl.className = "log-body";
if (body === undefined || body === null || body === "") {
bodyEl.textContent = "(empty)";
} else if (typeof body === "string") {
bodyEl.textContent = body;
} else {
try { bodyEl.textContent = JSON.stringify(body, null, 2); }
catch { bodyEl.textContent = String(body); }
}
item.appendChild(bodyEl);
logEl.insertBefore(item, logEl.children[1] ?? null);
}
$("btn-clear-log").onclick = () => {
[...logEl.querySelectorAll(".log-item")].forEach(n => n.remove());
};
async function apiJson(method, path, payload) {
const url = baseUrl() + path;
let res;
try {
res = await fetch(url, {
method,
headers: payload ? { "Content-Type": "application/json" } : undefined,
body: payload ? JSON.stringify(payload) : undefined,
});
} catch (e) {
addLog(method, url, "NETWORK", String(e), { error: true });
throw e;
}
const text = await res.text();
let parsed;
try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; }
addLog(method, url, res.status, parsed);
return { res, body: parsed };
}
// ============================================================
// Surgery ID validation
// ============================================================
function ensureSurgeryId() {
const sid = surgeryId();
if (!/^\d{6}$/.test(sid)) {
alert("surgery_id 必须是 6 位数字");
return null;
}
return sid;
}
// ============================================================
// Tags for candidate_consumables
// ============================================================
const tagsEl = $("tags");
const tagInput = $("tag-input");
let tags = [];
function renderTags() {
[...tagsEl.querySelectorAll(".pill")].forEach(n => n.remove());
tags.forEach((t, idx) => {
const pill = document.createElement("span");
pill.className = "pill";
pill.innerHTML = `<span></span><button title="移除" aria-label="移除">×</button>`;
pill.firstChild.textContent = t;
pill.querySelector("button").onclick = () => {
tags.splice(idx, 1);
renderTags();
};
tagsEl.insertBefore(pill, tagInput);
});
}
function addTag(name) {
const v = name.trim();
if (!v) return;
if (tags.includes(v)) return;
tags.push(v);
renderTags();
}
tagInput.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addTag(tagInput.value);
tagInput.value = "";
} else if (e.key === "Backspace" && !tagInput.value && tags.length) {
tags.pop();
renderTags();
}
});
tagsEl.addEventListener("click", (e) => {
if (e.target === tagsEl) tagInput.focus();
});
async function loadLabels() {
const hint = $("labels-hint");
try {
const res = await fetch("/labels.json");
if (!res.ok) throw new Error("HTTP " + res.status);
const data = await res.json();
const labels = Array.isArray(data.labels) ? data.labels : [];
tags = labels.slice(0, 5);
renderTags();
hint.textContent = `${labels.length} 个标签`;
hint.classList.add("on");
window.__ALL_LABELS__ = labels;
} catch (e) {
hint.textContent = "labels.json 加载失败";
console.warn(e);
}
}
$("btn-load-all-labels").onclick = () => {
const all = window.__ALL_LABELS__ || [];
tags = [...all];
renderTags();
};
$("btn-clear-labels").onclick = () => { tags = []; renderTags(); };
// ============================================================
// §health
// ============================================================
$("btn-health").onclick = async () => {
const { res } = await apiJson("GET", "/health");
$("health-status").textContent = `HTTP ${res.status}`;
$("health-status").className = "small " + (res.ok ? "ok" : "err");
};
// ============================================================
// §4.1 start
// ============================================================
$("btn-start").onclick = async () => {
const sid = ensureSurgeryId();
if (!sid) return;
const camera_ids = $("camera-ids").value.split(",").map(s => s.trim()).filter(Boolean);
if (camera_ids.length === 0) { alert("camera_ids 至少要 1 个"); return; }
await apiJson("POST", "/client/surgeries/start", {
surgery_id: sid,
camera_ids,
candidate_consumables: [...tags],
});
};
// ============================================================
// §4.2 end
// ============================================================
$("btn-end").onclick = async () => {
const sid = ensureSurgeryId();
if (!sid) return;
await apiJson("POST", "/client/surgeries/end", { surgery_id: sid });
};
// ============================================================
// §4.3 result
// ============================================================
$("btn-result").onclick = async () => {
const sid = ensureSurgeryId();
if (!sid) return;
const { res, body } = await apiJson("GET", `/client/surgeries/${sid}/result`);
const target = $("result-render");
target.innerHTML = "";
if (!res.ok || !body || typeof body !== "object") return;
const { details = [], summary = [] } = body;
const renderTable = (title, rows, cols) => {
const h = document.createElement("h3");
h.textContent = title;
target.appendChild(h);
const t = document.createElement("table");
const thead = document.createElement("thead");
thead.innerHTML = "<tr>" + cols.map(c => `<th>${c.label}</th>`).join("") + "</tr>";
t.appendChild(thead);
const tbody = document.createElement("tbody");
if (!rows.length) {
tbody.innerHTML = `<tr><td colspan="${cols.length}" class="muted">(空)</td></tr>`;
} else {
rows.forEach(row => {
const tr = document.createElement("tr");
tr.innerHTML = cols.map(c => `<td>${row[c.key] ?? ""}</td>`).join("");
tbody.appendChild(tr);
});
}
t.appendChild(tbody);
target.appendChild(t);
};
renderTable("明细 details[]", details, [
{ key: "timestamp", label: "time" },
{ key: "item_id", label: "item_id" },
{ key: "item_name", label: "item_name" },
{ key: "quantity", label: "qty" },
{ key: "doctor_id", label: "doctor" },
{ key: "source", label: "source" },
]);
renderTable("汇总 summary[]", summary, [
{ key: "item_id", label: "item_id" },
{ key: "item_name", label: "item_name" },
{ key: "total_quantity", label: "total" },
]);
};
// ============================================================
// §4.4 pending-confirmation
// ============================================================
let pollTimer = null;
async function fetchPendingOnce() {
const sid = surgeryId();
if (!/^\d{6}$/.test(sid)) return;
const { res, body } = await apiJson("GET", `/client/surgeries/${sid}/pending-confirmation`);
const box = $("pending-render");
if (res.status === 200 && body && body.confirmation_id) {
box.hidden = false;
$("confirmation-id").value = body.confirmation_id;
const opts = (body.options || [])
.map(o => `<div class="option-row"><span>${o.label}</span><span class="muted">${(o.confidence * 100).toFixed(1)}%</span></div>`)
.join("");
box.innerHTML = `
<div><strong>confirmation_id:</strong> <span class="kv">${body.confirmation_id}</span></div>
<div style="margin-top:4px"><strong>prompt_text:</strong> ${body.prompt_text || ""}</div>
<div style="margin-top:4px"><strong>Top1:</strong> ${body.model_top1_label} <span class="muted">(${(body.model_top1_confidence * 100).toFixed(1)}%)</span></div>
<div style="margin-top:6px"><strong>options:</strong>${opts || '<div class="muted">(无)</div>'}</div>`;
} else if (res.status === 404) {
box.hidden = false;
box.innerHTML = '<span class="muted">暂无待确认项。</span>';
} else {
box.hidden = false;
box.innerHTML = `<span class="err">HTTP ${res.status}</span>`;
}
}
$("btn-pending").onclick = fetchPendingOnce;
$("auto-poll").onchange = (e) => {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
if (e.target.checked) {
$("voice-status").textContent = "自动轮询中…";
pollTimer = setInterval(fetchPendingOnce, 2000);
fetchPendingOnce();
} else {
$("voice-status").textContent = "";
}
};
// ============================================================
// §4.5 Recording (mic → WAV 16kHz mono PCM)
// ============================================================
let audioCtx = null;
let mediaStream = null;
let sourceNode = null;
let processorNode = null;
let pcmChunks = [];
let inputSampleRate = 48000;
let recStartAt = 0;
let recordingWav = null;
function floatTo16BitPCM(view, offset, input) {
for (let i = 0; i < input.length; i++, offset += 2) {
const s = Math.max(-1, Math.min(1, input[i]));
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
}
}
function writeString(view, offset, str) {
for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
}
function downsampleTo16k(inputBuffer, inputRate) {
const target = 16000;
if (inputRate === target) return inputBuffer;
const ratio = inputRate / target;
const newLen = Math.round(inputBuffer.length / ratio);
const out = new Float32Array(newLen);
let offsetOut = 0;
let offsetIn = 0;
while (offsetOut < newLen) {
const nextIn = Math.round((offsetOut + 1) * ratio);
let accum = 0, count = 0;
for (let i = offsetIn; i < nextIn && i < inputBuffer.length; i++) {
accum += inputBuffer[i]; count++;
}
out[offsetOut] = count > 0 ? accum / count : 0;
offsetOut++;
offsetIn = nextIn;
}
return out;
}
function encodeWav(samples, sampleRate) {
const buffer = new ArrayBuffer(44 + samples.length * 2);
const view = new DataView(buffer);
writeString(view, 0, "RIFF");
view.setUint32(4, 36 + samples.length * 2, true);
writeString(view, 8, "WAVE");
writeString(view, 12, "fmt ");
view.setUint32(16, 16, true); // PCM subchunk size
view.setUint16(20, 1, true); // format = PCM
view.setUint16(22, 1, true); // channels = 1
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 2, true); // byte rate
view.setUint16(32, 2, true); // block align
view.setUint16(34, 16, true); // bits per sample
writeString(view, 36, "data");
view.setUint32(40, samples.length * 2, true);
floatTo16BitPCM(view, 44, samples);
return new Blob([view], { type: "audio/wav" });
}
function concatFloat32(chunks) {
let total = 0;
chunks.forEach(c => { total += c.length; });
const out = new Float32Array(total);
let offset = 0;
chunks.forEach(c => { out.set(c, offset); offset += c.length; });
return out;
}
async function startRecording() {
try {
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
} catch (e) {
alert("无法获取麦克风:" + e.message + "\n请确保使用 http://localhost 或 https 访问,并授权麦克风。");
return;
}
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
inputSampleRate = audioCtx.sampleRate;
sourceNode = audioCtx.createMediaStreamSource(mediaStream);
processorNode = audioCtx.createScriptProcessor(4096, 1, 1);
pcmChunks = [];
let peak = 0;
processorNode.onaudioprocess = (ev) => {
const input = ev.inputBuffer.getChannelData(0);
pcmChunks.push(new Float32Array(input));
let localPeak = 0;
for (let i = 0; i < input.length; i++) {
const v = Math.abs(input[i]);
if (v > localPeak) localPeak = v;
}
peak = Math.max(peak * 0.85, localPeak);
$("meter-bar").style.width = Math.min(100, peak * 140) + "%";
};
sourceNode.connect(processorNode);
processorNode.connect(audioCtx.destination);
recStartAt = performance.now();
$("btn-rec-start").disabled = true;
$("btn-rec-stop").disabled = false;
$("btn-resolve").disabled = true;
$("audio-preview").hidden = true;
$("btn-download").style.display = "none";
$("rec-info").textContent = `录音中 @ ${inputSampleRate} Hz…`;
$("rec-info").className = "warn small";
}
async function stopRecording() {
if (processorNode) { processorNode.disconnect(); processorNode.onaudioprocess = null; }
if (sourceNode) sourceNode.disconnect();
if (mediaStream) mediaStream.getTracks().forEach(t => t.stop());
if (audioCtx) await audioCtx.close();
const durationMs = performance.now() - recStartAt;
const samples = concatFloat32(pcmChunks);
const downsampled = downsampleTo16k(samples, inputSampleRate);
recordingWav = encodeWav(downsampled, 16000);
const url = URL.createObjectURL(recordingWav);
const audio = $("audio-preview");
audio.src = url;
audio.hidden = false;
const dl = $("btn-download");
dl.href = url;
dl.style.display = "inline-block";
$("btn-rec-start").disabled = false;
$("btn-rec-stop").disabled = true;
$("btn-resolve").disabled = false;
$("rec-info").textContent = `录音完成:${(durationMs / 1000).toFixed(1)}s · ${(recordingWav.size / 1024).toFixed(1)} KB · 16 kHz mono`;
$("rec-info").className = "ok small";
$("meter-bar").style.width = "0";
pcmChunks = [];
}
$("btn-rec-start").onclick = startRecording;
$("btn-rec-stop").onclick = stopRecording;
$("btn-resolve").onclick = async () => {
const sid = ensureSurgeryId();
if (!sid) return;
const cid = $("confirmation-id").value.trim();
if (!cid) { alert("请先获取 confirmation_id"); return; }
if (!recordingWav) { alert("请先录制音频"); return; }
const url = baseUrl() + `/client/surgeries/${sid}/pending-confirmation/${encodeURIComponent(cid)}/resolve`;
const fd = new FormData();
fd.append("audio", recordingWav, "voice.wav");
let res;
try {
res = await fetch(url, { method: "POST", body: fd });
} catch (e) {
addLog("POST", url, "NETWORK", String(e), { error: true });
return;
}
const text = await res.text();
let parsed;
try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; }
addLog("POST (multipart)", url, res.status, parsed);
};
// ============================================================
// Boot
// ============================================================
loadLabels();
</script>
</body>
</html>

View File

@@ -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: `<int>: <text>` 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()

55
uv.lock generated
View File

@@ -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"