Files
operating-room-monitor-server/app/services/consumption_tsv_log.py
Kevin 69980d8073 feat: align surgery API with schemas and extend client tooling
- Refactor app API and schemas; adjust surgery pipeline, repository, and session manager.

- Improve consumption TSV logging and consumable vision integration; trim voice resolution.

- Add Baidu Face 1:N search script, .env.example entries, and client API integration doc.

- Update demo client, staging checklist, surgery interface doc, and related tests; add sample face image.

Made-with: Cursor
2026-04-23 16:09:20 +08:00

288 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""每例手术一个文本文件(制表符列):`start_surgery` 时截断并写表头,每次时间窗识别**追加**一行(仅 item_id, item_name, qty, doctor_id, timestamp。终端 Markdown 时间戳为可读形式;落盘时间戳为 ISO 区间便于程序解析。
手术结束时再追加一节汇总行item_id, item_name, qty无其它列
时间戳:在拉流起点记录 `time.time()`,与 `time.monotonic()` 时间窗对齐。直播 RTSP 经 OpenCV 一般无可靠绝对时码,以本机接收时刻为准。
"""
from __future__ import annotations
import re
import threading
from datetime import datetime, timezone
from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from loguru import logger
from app.config import settings
from app.services.consumable_vision_algorithm import ClsTop3, _norm_product_name
from app.terminal_markdown import print_markdown_stderr
# 制表符分隔;时间范围用 U+2013 连接;本窗消耗数量恒为 1
HEADER = "item_id\titem_name\tqty\tdoctor_id\ttimestamp\n"
SUMMARY_HEADER = "item_id\titem_name\tqty\n"
_RANGE_SEP = "\u2013" # en dash与样例 `00:00:00.00000:00:45.000` 一致
_lock = threading.Lock()
def _consumption_tzinfo():
raw = (settings.consumption_log_timezone or "").strip()
if not raw:
lt = datetime.now().astimezone().tzinfo
return lt if lt is not None else timezone.utc
try:
return ZoneInfo(raw)
except ZoneInfoNotFoundError:
logger.warning("无效的 consumption_log_timezone={!r},回退为 UTC", raw)
return timezone.utc
def format_consumption_timestamp(
camera_id: str,
wall_start_epoch: float,
wall_end_epoch: float,
) -> str:
"""落盘用:墙钟 + 配置时区 → `camXX@ISO8601ISO8601`。"""
tz = _consumption_tzinfo()
a = datetime.fromtimestamp(wall_start_epoch, tz=tz)
b = datetime.fromtimestamp(wall_end_epoch, tz=tz)
cam = short_camera_label(camera_id)
return f"{cam}@{a.isoformat(timespec='milliseconds')}{_RANGE_SEP}{b.isoformat(timespec='milliseconds')}"
def format_consumption_timestamp_readable(
camera_id: str,
wall_start_epoch: float,
wall_end_epoch: float,
) -> str:
"""仅终端 Rich不含 `T` 的本地可读区间 + 摄像头简名,便于人眼对时。"""
tz = _consumption_tzinfo()
a = datetime.fromtimestamp(wall_start_epoch, tz=tz)
b = datetime.fromtimestamp(wall_end_epoch, tz=tz)
cam = short_camera_label(camera_id)
def _fmt(d: datetime) -> str:
return d.strftime("%Y-%m-%d %H:%M:%S") + f".{d.microsecond // 1000:03d}"
return f"{_fmt(a)} {_RANGE_SEP} {_fmt(b)} · {cam}"
def short_camera_label(camera_id: str) -> str:
s = (camera_id or "").strip()
m = re.match(r"^or-cam-(\d+)$", s, re.IGNORECASE)
if m:
return f"cam{int(m.group(1)):02d}"
m2 = re.match(r"^cam-?0*(\d+)$", s, re.IGNORECASE)
if m2:
return f"cam{int(m2.group(1)):02d}"
alnum = re.sub(r"[^\w-]", "", s)[:12]
return alnum or "cam"
def _encode_cell(value: str) -> str:
s = (value or "").replace("\r", " ").replace("\n", " ").replace("\t", " ")
return s
def resolve_consumption_item_id(
t1_name: str,
t1_pid: str,
name_to_code: dict[str, str],
) -> str:
"""业务物品 id`name_to_code` 的键为归一化名称,须与分类输出一同参与查找。"""
n = (t1_name or "").strip()
norm = _norm_product_name(n)
code = (name_to_code.get(norm) or name_to_code.get(n) or "").strip()
if code:
return code
p = (t1_pid or "").strip()
if p:
return p
return n
def build_tsv_line(
*,
name_to_code: dict[str, str],
best: ClsTop3,
doctor_id: str,
camera_id: str,
wall_start_epoch: float,
wall_end_epoch: float,
) -> str:
id1 = resolve_consumption_item_id(best.t1_name, best.t1_pid, name_to_code)
name1 = (best.t1_name or "").strip()
ts = format_consumption_timestamp(camera_id, wall_start_epoch, wall_end_epoch)
row = [
_encode_cell(id1),
_encode_cell(name1),
"1",
_encode_cell(doctor_id),
_encode_cell(ts),
]
return "\t".join(row) + "\n"
def _safe_surgery_path_segment(surgery_id: str) -> str:
s = (surgery_id or "unknown").strip() or "unknown"
s = re.sub(r"[^\w\-.@]", "_", s)
return s[:200] if len(s) > 200 else s
def resolved_consumption_log_path(surgery_id: str) -> Path:
raw = (settings.consumption_tsv_log_path or "logs/consumption_{surgery_id}.txt").strip()
safe = _safe_surgery_path_segment(surgery_id)
if "{surgery_id}" in raw:
raw = raw.replace("{surgery_id}", safe)
else:
p0 = Path(raw)
if p0.suffix:
raw = str(p0.with_name(f"{p0.stem}_{safe}{p0.suffix}"))
else:
raw = f"{raw.rstrip('/')}_{safe}.txt"
p = Path(raw).expanduser()
if not p.is_absolute():
p = Path.cwd() / p
return p
def init_consumption_log_file(surgery_id: str) -> None:
"""新手术开始:截断该手术对应文件并写入表头(一次)。"""
if not settings.consumption_tsv_log_enabled:
return
path = resolved_consumption_log_path(surgery_id)
path.parent.mkdir(parents=True, exist_ok=True)
with _lock:
with path.open("w", encoding="utf-8") as f:
f.write(HEADER)
def append_consumption_tsv_line(surgery_id: str, line: str) -> None:
if not settings.consumption_tsv_log_enabled:
return
path = resolved_consumption_log_path(surgery_id)
path.parent.mkdir(parents=True, exist_ok=True)
with _lock:
with path.open("a", encoding="utf-8") as f:
f.write(line)
def _md_cell(value: str) -> str:
"""避免破坏 Markdown 表格的 | 与换行。"""
s = (value or "").replace("\r", " ").replace("\n", " ").replace("|", "")
return s
def build_consumption_markdown(
*,
name_to_code: dict[str, str],
best: ClsTop3,
doctor_id: str,
camera_id: str,
wall_start_epoch: float,
wall_end_epoch: float,
) -> str:
"""终端用:与落盘列一致;本窗 qty 恒为 1。"""
id1 = resolve_consumption_item_id(best.t1_name, best.t1_pid, name_to_code)
n1 = (best.t1_name or "").strip()
ts = format_consumption_timestamp_readable(camera_id, wall_start_epoch, wall_end_epoch)
return "\n".join(
[
"| item_id | item_name | qty | doctor_id | timestamp |",
"| :--- | :--- | ---: | :--- | :--- |",
"| {} | {} | 1 | {} | {} |".format(
_md_cell(id1),
_md_cell(n1),
_md_cell(doctor_id),
_md_cell(ts),
),
"",
]
)
def append_consumption_log_summary(
surgery_id: str,
totals: dict[str, tuple[str, int]],
) -> None:
"""在明细行之后追加汇总块(表头 + 每物品一行)。"""
if not settings.consumption_tsv_log_enabled or not totals:
return
path = resolved_consumption_log_path(surgery_id)
if not path.is_file():
return
body = "".join(
["\n", SUMMARY_HEADER]
+ [
"\t".join([_encode_cell(iid), _encode_cell(name), str(qty)]) + "\n"
for iid, (name, qty) in sorted(totals.items(), key=lambda x: x[0])
]
)
with _lock:
with path.open("a", encoding="utf-8") as f:
f.write(body)
def print_consumption_summary_markdown(
totals: dict[str, tuple[str, int]],
) -> None:
if not settings.consumption_log_markdown_terminal or not totals:
return
lines = [
"## 消耗汇总",
"",
"| item_id | item_name | qty |",
"| :--- | :--- | ---: |",
]
for iid, (name, qty) in sorted(totals.items(), key=lambda x: x[0]):
lines.append(
"| {} | {} | {} |".format(_md_cell(iid), _md_cell(name), qty)
)
lines.append("")
print_markdown_stderr("\n".join(lines))
def append_consumption_window(
*,
surgery_id: str,
name_to_code: dict[str, str],
best: ClsTop3,
doctor_id: str,
camera_id: str,
wall_start_epoch: float,
wall_end_epoch: float,
running_totals: dict[str, tuple[str, int]] | None = None,
) -> None:
if not settings.consumption_tsv_log_enabled and not settings.consumption_log_markdown_terminal:
return
iid = resolve_consumption_item_id(best.t1_name, best.t1_pid, name_to_code)
iname = (best.t1_name or "").strip()
if running_totals is not None:
if iid not in running_totals:
running_totals[iid] = (iname, 0)
prev_name, q = running_totals[iid]
running_totals[iid] = (prev_name, q + 1)
if settings.consumption_tsv_log_enabled:
line = build_tsv_line(
name_to_code=name_to_code,
best=best,
doctor_id=doctor_id,
camera_id=camera_id,
wall_start_epoch=wall_start_epoch,
wall_end_epoch=wall_end_epoch,
)
append_consumption_tsv_line(surgery_id, line)
if settings.consumption_log_markdown_terminal:
print_markdown_stderr(
build_consumption_markdown(
name_to_code=name_to_code,
best=best,
doctor_id=doctor_id,
camera_id=camera_id,
wall_start_epoch=wall_start_epoch,
wall_end_epoch=wall_end_epoch,
),
)