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
This commit is contained in:
Kevin
2026-04-23 16:09:20 +08:00
parent 0c05463617
commit 69980d8073
20 changed files with 994 additions and 610 deletions

View File

@@ -1,4 +1,6 @@
"""每例手术一个文本文件(制表符列):`start_surgery` 时截断并写表头,每次时间窗识别**追加**一行。终端 Markdown 时间戳为可读形式;落盘行内仍为 ISO 便于程序解析。
"""每例手术一个文本文件(制表符列):`start_surgery` 时截断并写表头,每次时间窗识别**追加**一行(仅 item_id, item_name, qty, doctor_id, timestamp。终端 Markdown 时间戳为可读形式;落盘时间戳为 ISO 区间便于程序解析。
手术结束时再追加一节汇总行item_id, item_name, qty无其它列
时间戳:在拉流起点记录 `time.time()`,与 `time.monotonic()` 时间窗对齐。直播 RTSP 经 OpenCV 一般无可靠绝对时码,以本机接收时刻为准。
"""
@@ -14,11 +16,12 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from loguru import logger
from app.config import settings
from app.services.consumable_vision_algorithm import ClsTop3
from app.services.consumable_vision_algorithm import ClsTop3, _norm_product_name
from app.terminal_markdown import print_markdown_stderr
# 制表符分隔;时间范围用 U+2013 连接;Top2/3 仅名称;本窗消耗数量恒为 1
HEADER = "物品id\t物品名称\tTop2物品名称\tTop3物品名称\t消耗数量\t医生id\t时间戳\n"
# 制表符分隔;时间范围用 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()
@@ -83,13 +86,20 @@ def _encode_cell(value: str) -> str:
return s
def _item_id_for_row(name: str, pid: str, name_to_code: dict[str, str]) -> str:
p = (pid or "").strip()
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
n = (name or "").strip()
if n in name_to_code:
return (name_to_code.get(n) or n).strip()
return n
@@ -102,17 +112,12 @@ def build_tsv_line(
wall_start_epoch: float,
wall_end_epoch: float,
) -> str:
id1 = _item_id_for_row(best.t1_name, best.t1_pid, name_to_code)
# 与历史样例Top1 为「名称 置信度」四位小数
name1 = f"{(best.t1_name or '').strip()} {best.t1_conf:.4f}".strip()
n2 = (best.t2_name or "").strip()
n3 = (best.t3_name or "").strip()
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),
_encode_cell(n2),
_encode_cell(n3),
"1",
_encode_cell(doctor_id),
_encode_cell(ts),
@@ -179,25 +184,17 @@ def build_consumption_markdown(
wall_start_epoch: float,
wall_end_epoch: float,
) -> str:
"""终端用:Top1 含 id/名称/置信度Top2/3 仅名称;消耗数量恒为 1。"""
id1 = _item_id_for_row(best.t1_name, best.t1_pid, name_to_code)
"""终端用:与落盘列一致;本窗 qty 恒为 1。"""
id1 = resolve_consumption_item_id(best.t1_name, best.t1_pid, name_to_code)
n1 = (best.t1_name or "").strip()
has2 = bool((best.t2_name or "").strip())
has3 = bool((best.t3_name or "").strip())
n2 = (best.t2_name or "").strip() if has2 else ""
n3 = (best.t3_name or "").strip() if has3 else ""
dash = ""
ts = format_consumption_timestamp_readable(camera_id, wall_start_epoch, wall_end_epoch)
return "\n".join(
[
"| Top1 物品id | Top1 物品名称 | Top1 置信度 | Top2 物品名称 | Top3 物品名称 | 消耗数量 | 医生id | 时间戳 |",
"| :--- | :--- | ---: | :--- | :--- | ---: | :--- | :--- |",
"| {} | {} | {:.4f} | {} | {} | 1 | {} | {} |".format(
"| item_id | item_name | qty | doctor_id | timestamp |",
"| :--- | :--- | ---: | :--- | :--- |",
"| {} | {} | 1 | {} | {} |".format(
_md_cell(id1),
_md_cell(n1),
best.t1_conf,
_md_cell(n2) if has2 else dash,
_md_cell(n3) if has3 else dash,
_md_cell(doctor_id),
_md_cell(ts),
),
@@ -206,6 +203,47 @@ def build_consumption_markdown(
)
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,
@@ -215,9 +253,17 @@ def append_consumption_window(
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,