feat: consumption log top1 + elapsed since recording; RTSP play once

- Add top1_name/top1_conf to TSV and show top1–3 in pending markdown
- Add 相对开录 column and pass since_recording_start from surgery start
- Track surgery_started_wall and format_elapsed_mmss_since in session registry
- Remove ffmpeg stream_loop from synthetic/demo fake RTSP (play once)
- Fix fake_rtsp_from_file poll loop indentation; update README
- Extend consumption TSV tests; add face test PNGs under tests/faces

Made-with: Cursor
This commit is contained in:
Kevin
2026-04-27 09:22:46 +08:00
parent 8a6bfe9100
commit e4c6127619
13 changed files with 130 additions and 40 deletions

View File

@@ -1,4 +1,7 @@
"""每例手术一个文本文件(制表符列):`start_surgery` 时截断并写表头,每次时间窗识别**追加**一行(仅 item_id, item_name, qty, doctor_id, timestamp。待确认行首列为 ``pending:{confirmation_id}``,语音落锤后**整行替换**为与客户端一致的最终真值,不再重复追加。终端 Markdown 时间戳为可读形式;落盘时间戳为 ISO 区间便于程序解析。
"""每例手术一个文本文件(制表符列):`start_surgery` 时截断并写表头,每次时间窗识别**追加**一行
item_id, item_name, qty, doctor_id, timestamp, top13 名与置信度)。待确认行首列为 ``pending:{confirmation_id}``、
item_name 为「待确认」top13 仍为模型输出;语音落锤后**整行替换**为与客户端一致的最终真值,不再重复追加。
终端 Markdown 时间戳为可读形式;落盘时间戳为 ISO 区间便于程序解析。
手术结束时再追加一节汇总行item_id, item_name, qty无其它列与 HTTP ``summary`` 同算法,由内存 ``details`` 经 ``build_consumption_summary`` 得到,非录制过程中按窗累计。
@@ -20,10 +23,11 @@ from app.services.consumable_vision_algorithm import ClsTop3, _norm_product_name
from app.terminal_markdown import print_markdown_stderr
# 制表符分隔;时间范围用 U+2013 连接;本窗消耗数量恒为 1。
# top2/top3 为模型原始排序未按手术候选重排item_id 只写与展示名不同的业务 idlabel_id与名称相同时留空
# top1/2/3 为模型原始排序(未按手术候选重排);确认行 item_name 与 top1_name 同为 Top1 标签
# item_id 只写与展示名不同的业务 idlabel_id与名称相同时留空。
HEADER = (
"item_id\titem_name\tqty\tdoctor_id\ttimestamp\t"
"top2_name\ttop2_conf\ttop3_name\ttop3_conf\n"
"top1_name\ttop1_conf\ttop2_name\ttop2_conf\ttop3_name\ttop3_conf\n"
)
SUMMARY_HEADER = "item_id\titem_name\tqty\n"
_RANGE_SEP = "\u2013" # en dash与样例 `00:00:00.00000:00:45.000` 一致
@@ -151,6 +155,8 @@ def build_tsv_line(
"1",
_encode_cell(doctor_id),
_encode_cell(ts),
_encode_cell(name1),
_fmt_top_conf(best.t1_conf),
_encode_cell(n2),
_fmt_top_conf(best.t2_conf),
_encode_cell(n3),
@@ -217,6 +223,7 @@ def build_consumption_markdown(
camera_id: str,
wall_start_epoch: float,
wall_end_epoch: float,
since_recording_start: str | None = None,
) -> str:
"""终端用:与落盘列一致;本窗 qty 恒为 1。"""
tsv_id, _ = resolve_consumption_ids(best.t1_name, best.t1_pid, name_to_code)
@@ -224,14 +231,16 @@ def build_consumption_markdown(
n2 = (best.t2_name or "").strip()
n3 = (best.t3_name or "").strip()
ts = format_consumption_timestamp_readable(camera_id, wall_start_epoch, wall_end_epoch)
rel = _md_cell(since_recording_start or "")
return "\n".join(
[
"| item_id | item_name | qty | doctor_id | timestamp | top2 | top3 |",
"| :--- | :--- | ---: | :--- | :--- | :--- | :--- |",
"| {} | {} | 1 | {} | {} | {} | {} |".format(
"| item_id | item_name | qty | doctor_id | 相对开录 | timestamp | top2 | top3 |",
"| :--- | :--- | ---: | :--- | :--- | :--- | :--- | :--- |",
"| {} | {} | 1 | {} | {} | {} | {} | {} |".format(
_md_cell(tsv_id),
_md_cell(n1),
_md_cell(doctor_id),
rel,
_md_cell(ts),
_md_cell(
f"{n2} ({_fmt_top_conf(best.t2_conf)})" if n2 else "",
@@ -259,6 +268,7 @@ def _build_pending_tsv_line(
) -> str:
pid = f"pending:{confirmation_id}"
ts = format_consumption_timestamp(camera_id, wall_start_epoch, wall_end_epoch)
n1 = (model_snap.t1_name or "").strip()
n2 = (model_snap.t2_name or "").strip()
n3 = (model_snap.t3_name or "").strip()
row = [
@@ -267,6 +277,8 @@ def _build_pending_tsv_line(
"1",
_encode_cell(doctor_id),
_encode_cell(ts),
_encode_cell(n1),
_fmt_top_conf(model_snap.t1_conf),
_encode_cell(n2),
_fmt_top_conf(model_snap.t2_conf),
_encode_cell(n3),
@@ -283,26 +295,31 @@ def build_pending_consumption_markdown(
camera_id: str,
wall_start_epoch: float,
wall_end_epoch: float,
since_recording_start: str | None = None,
) -> str:
pid = f"pending:{confirmation_id}"
n1 = (model_snap.t1_name or "").strip()
n2 = (model_snap.t2_name or "").strip()
n3 = (model_snap.t3_name or "").strip()
ts = format_consumption_timestamp_readable(camera_id, wall_start_epoch, wall_end_epoch)
rel = _md_cell(since_recording_start or "")
def _top_cell(name: str, conf: float) -> str:
return _md_cell(f"{name} ({_fmt_top_conf(conf)})" if name else "")
return "\n".join(
[
"| item_id | item_name | qty | doctor_id | timestamp | top2 | top3 |",
"| :--- | :--- | ---: | :--- | :--- | :--- | :--- |",
"| {} | {} | 1 | {} | {} | {} | {} |".format(
"| item_id | item_name | qty | doctor_id | 相对开录 | timestamp | top1 | top2 | top3 |",
"| :--- | :--- | ---: | :--- | :--- | :--- | :--- | :--- | :--- |",
"| {} | {} | 1 | {} | {} | {} | {} | {} | {} |".format(
_md_cell(pid),
_md_cell(PENDING_CONSUMPTION_ITEM_NAME),
_md_cell(doctor_id),
rel,
_md_cell(ts),
_md_cell(
f"{n2} ({_fmt_top_conf(model_snap.t2_conf)})" if n2 else "",
),
_md_cell(
f"{n3} ({_fmt_top_conf(model_snap.t3_conf)})" if n3 else "",
),
_top_cell(n1, model_snap.t1_conf),
_top_cell(n2, model_snap.t2_conf),
_top_cell(n3, model_snap.t3_conf),
),
"",
]
@@ -320,8 +337,9 @@ def append_consumption_pending_window(
wall_end_epoch: float,
tsv_enabled: bool | None = None,
markdown_terminal: bool | None = None,
since_recording_start: str | None = None,
) -> None:
"""需医生确认的时间窗:落盘/终端记「待确认」top2/3 保留模型提示;不更新消耗汇总。"""
"""需医生确认的时间窗:落盘/终端记「待确认」top1/2/3 保留模型提示;不更新消耗汇总。"""
en_tsv = bp.CONSUMPTION_TSV_LOG_ENABLED if tsv_enabled is None else tsv_enabled
en_md = (
bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL
@@ -349,6 +367,7 @@ def append_consumption_pending_window(
camera_id=camera_id,
wall_start_epoch=wall_start_epoch,
wall_end_epoch=wall_end_epoch,
since_recording_start=since_recording_start,
),
)
@@ -549,6 +568,7 @@ class ConsumptionTsvWriter:
camera_id: str,
wall_start_epoch: float,
wall_end_epoch: float,
since_recording_start: str | None = None,
) -> None:
if not bp.CONSUMPTION_TSV_LOG_ENABLED and not bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL:
return
@@ -571,6 +591,7 @@ class ConsumptionTsvWriter:
camera_id=camera_id,
wall_start_epoch=wall_start_epoch,
wall_end_epoch=wall_end_epoch,
since_recording_start=since_recording_start,
),
)
@@ -621,6 +642,7 @@ def append_consumption_window(
camera_id: str,
wall_start_epoch: float,
wall_end_epoch: float,
since_recording_start: str | None = None,
) -> None:
if not bp.CONSUMPTION_TSV_LOG_ENABLED and not bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL:
return
@@ -643,5 +665,6 @@ def append_consumption_window(
camera_id=camera_id,
wall_start_epoch=wall_start_epoch,
wall_end_epoch=wall_end_epoch,
since_recording_start=since_recording_start,
),
)

View File

@@ -1,4 +1,8 @@
"""Start/stop local fake RTSP streams (MediaMTX + ffmpeg) for dev orchestration."""
"""Start/stop local fake RTSP streams (MediaMTX + ffmpeg) for dev orchestration.
Each input file is published once (no ``-stream_loop``); when ffmpeg exits the
process is gone — reconnect or re-orchestrate for another playthrough.
"""
from __future__ import annotations
@@ -203,7 +207,7 @@ class SyntheticRtspManager:
url_map[s.camera_id] = dest
pub = [
"ffmpeg", "-hide_banner", "-loglevel", "warning",
"-re", "-stream_loop", "-1",
"-re",
"-i", str(s.file_path),
"-c", "copy", "-f", "rtsp", "-rtsp_transport", "tcp", dest,
]

View File

@@ -12,7 +12,7 @@
from __future__ import annotations
from loguru import logger
import time
from app.baked import pipeline as bp
from app.services.consumable_vision_algorithm import (
@@ -28,6 +28,7 @@ from app.services.video.inference_aggregator import WindowInferenceReady
from app.services.video.session_registry import (
SurgerySessionRegistry,
SurgerySessionState,
format_elapsed_mmss_since,
)
@@ -83,6 +84,10 @@ class VisionClassificationHandler:
camera_id=camera_id,
wall_start_epoch=ready.wall_lo,
wall_end_epoch=ready.wall_hi,
since_recording_start=format_elapsed_mmss_since(
state.surgery_started_wall,
at_epoch=ready.wall_hi,
),
)
async def handle(
@@ -182,11 +187,7 @@ class VisionClassificationHandler:
)
if cid is None:
return
logger.info(
"Enqueued pending consumable confirmation id={} top_key={}",
cid,
top_key,
)
at_ep = ready.wall_hi if ready is not None else time.time()
if ready is not None and surgery_id and camera_id and (
bp.CONSUMPTION_TSV_LOG_ENABLED
or bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL
@@ -201,6 +202,10 @@ class VisionClassificationHandler:
wall_end_epoch=ready.wall_hi,
tsv_enabled=bp.CONSUMPTION_TSV_LOG_ENABLED,
markdown_terminal=bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL,
since_recording_start=format_elapsed_mmss_since(
state.surgery_started_wall,
at_epoch=at_ep,
),
)
await self._registry.append_pending_consumption_detail(
state=state,

View File

@@ -30,6 +30,7 @@ from app.services.video.session_registry import (
RunningSurgery,
SurgerySessionRegistry,
SurgerySessionState,
format_elapsed_mmss_since,
)
from app.services.tear_gated_segment_consumption.product_map import (
load_tear_segment_name_to_id,
@@ -166,6 +167,7 @@ class CameraSessionManager:
state = SurgerySessionState(
candidate_consumables=list(resolved),
name_to_code=name_to_code,
surgery_started_wall=time.time(),
)
stop_event = asyncio.Event()
readies = [asyncio.Event() for _ in camera_ids]
@@ -447,9 +449,13 @@ class CameraSessionManager:
if bp.VIDEO_LOG_INFERENCE_RESULTS:
logger.info(
"Vision result surgery={} camera={} top1={}({:.3f}) top2={}({:.3f}) top3={}({:.3f})",
"Vision result surgery={} camera={} 相对开录={} top1={}({:.3f}) top2={}({:.3f}) top3={}({:.3f})",
surgery_id,
camera_id,
format_elapsed_mmss_since(
state.surgery_started_wall,
at_epoch=time.time(),
),
snap.t1_name,
snap.t1_conf,
snap.t2_name,

View File

@@ -29,6 +29,16 @@ from app.services.voice_confirm import build_prompt_text
from app.surgery_errors import SurgeryPipelineError
def format_elapsed_mmss_since(surgery_started_wall: float | None, *, at_epoch: float) -> str:
"""从 ``start_surgery`` 记录的开录时刻到 ``at_epoch`` 的流逝时间(分+秒),供终端 loguru 使用。"""
if surgery_started_wall is None:
return ""
sec = max(0.0, at_epoch - surgery_started_wall)
total = int(sec)
m, s = divmod(total, 60)
return f"{m}{s:02d}"
@dataclass
class PendingConsumableConfirmation:
"""待客户端确认的一条低置信度识别(不阻塞后续帧推理)。"""
@@ -73,6 +83,8 @@ class SurgerySessionState:
last_asr_text: str | None = None
#: 最近一次语音确认错误说明ASR/解析失败等)。
last_voice_error: str | None = None
#: ``start_surgery`` 创建会话时的 ``time.time()``,用于日志中「相对开录的流逝时间」。
surgery_started_wall: float | None = None
@dataclass