diff --git a/app/services/consumption_tsv_log.py b/app/services/consumption_tsv_log.py index 06fa6f5..55ea633 100644 --- a/app/services/consumption_tsv_log.py +++ b/app/services/consumption_tsv_log.py @@ -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, top1–3 名与置信度)。待确认行首列为 ``pending:{confirmation_id}``、 +item_name 为「待确认」,top1–3 仍为模型输出;语音落锤后**整行替换**为与客户端一致的最终真值,不再重复追加。 +终端 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 只写与展示名不同的业务 id(label_id),与名称相同时留空。 +# top1/2/3 为模型原始排序(未按手术候选重排);确认行 item_name 与 top1_name 同为 Top1 标签。 +# item_id 只写与展示名不同的业务 id(label_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.000–00: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, ), ) diff --git a/app/services/synthetic_rtsp.py b/app/services/synthetic_rtsp.py index d87ca45..c34476a 100644 --- a/app/services/synthetic_rtsp.py +++ b/app/services/synthetic_rtsp.py @@ -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, ] diff --git a/app/services/video/classification_handler.py b/app/services/video/classification_handler.py index da4cb4d..012efb2 100644 --- a/app/services/video/classification_handler.py +++ b/app/services/video/classification_handler.py @@ -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, diff --git a/app/services/video/session_manager.py b/app/services/video/session_manager.py index 1838f0b..8ebf7a4 100644 --- a/app/services/video/session_manager.py +++ b/app/services/video/session_manager.py @@ -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, diff --git a/app/services/video/session_registry.py b/app/services/video/session_registry.py index 9b43bcb..1e0784a 100644 --- a/app/services/video/session_registry.py +++ b/app/services/video/session_registry.py @@ -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 diff --git a/scripts/demo_client/README.md b/scripts/demo_client/README.md index 0810ad5..1926230 100644 --- a/scripts/demo_client/README.md +++ b/scripts/demo_client/README.md @@ -8,7 +8,7 @@ scripts/demo_client/ server.py # 基于 stdlib 的静态服务器;额外暴露 /labels.json index.html # 单文件页面(原生 JS,零构建依赖) - fake_rtsp_from_file.py # 无真摄像头时:把本地视频循环发布为 RTSP(ffmpeg + Docker MediaMTX) + fake_rtsp_from_file.py # 无真摄像头时:把本地视频按文件时长推一次到 RTSP(ffmpeg + Docker MediaMTX) ``` ## 调试:无真实摄像头,用录好的视频模拟 RTSP @@ -53,10 +53,10 @@ python3 scripts/demo_client/fake_rtsp_from_file.py --port 18554 \ 发布失败时,可尝试把输入转码后再推流(示例,需自行调整): ```bash -ffmpeg -re -stream_loop -1 -i recording.mp4 -c:v libx264 -pix_fmt yuv420p -f rtsp -rtsp_transport tcp rtsp://127.0.0.1:18554/demo +ffmpeg -re -i recording.mp4 -c:v libx264 -pix_fmt yuv420p -f rtsp -rtsp_transport tcp rtsp://127.0.0.1:18554/demo ``` -(仍须先自行启动 MediaMTX 或等价 RTSP 服务端。) +(仍须先自行启动 MediaMTX 或等价 RTSP 服务端;上例为**播完即止**,若要循环请加 `-stream_loop -1`。) Demo 页面「调试:两路视频」中可用 **选择视频** / **拖放** 为路1/路2 指定文件,并配合下面 **一键开录** 上传,无需在页面里手抄 `python3` / `export` 命令。若必须完全手跑 `fake_rtsp_from_file.py`,请在上文命令示例与 `export VIDEO_RTSP_URLS_JSON=...` 方式自行在终端完成。 diff --git a/scripts/demo_client/fake_rtsp_from_file.py b/scripts/demo_client/fake_rtsp_from_file.py index 213f58a..8fe665e 100644 --- a/scripts/demo_client/fake_rtsp_from_file.py +++ b/scripts/demo_client/fake_rtsp_from_file.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Publish local video file(s) as looping RTSP stream(s) (fake camera) for local dev. +"""Publish local video file(s) to RTSP once per file (fake camera) for local dev. The Operation Room server only opens RTSP URLs (OpenCV); there is no video-upload API. This script does NOT change the application backend: it runs ffmpeg + a small @@ -103,7 +103,7 @@ def _parse_stream_arg(spec: str) -> tuple[str, Path, str]: def main() -> int: parser = argparse.ArgumentParser( - description="Loop video file(s) to RTSP URL(s) (dev fake camera; no backend code change).", + description="Play each video file once to an RTSP URL (dev fake camera; no backend code change).", ) parser.add_argument( "video", @@ -198,7 +198,6 @@ def main() -> int: "ffmpeg", "-hide_banner", "-loglevel", "info", "-re", - "-stream_loop", "-1", "-i", str(fp), "-c", "copy", "-f", "rtsp", @@ -225,7 +224,11 @@ def main() -> int: h = u.replace("127.0.0.1", "host.docker.internal", 1) print(f" {cam}: {h}", file=sys.stderr) print("---", file=sys.stderr) - print("Fake RTSP running (Ctrl+C to stop; MediaMTX container removed on exit).", file=sys.stderr) + print( + "Fake RTSP running: each file plays once; script exits when ffmpeg ends " + "(Ctrl+C to stop early; MediaMTX container removed on exit).", + file=sys.stderr, + ) def on_sigint(_sig: int, _frame) -> None: for p in procs: @@ -240,8 +243,8 @@ def main() -> int: try: while True: time.sleep(0.5) - for p in procs: - if p.poll() is not None: + for p in procs: + if p.poll() is not None: print( f"[fake-rtsp] ffmpeg ended (code {p.returncode}), stopping all.", file=sys.stderr, diff --git a/tests/faces/图片_20260424165952_58_52.png b/tests/faces/图片_20260424165952_58_52.png new file mode 100644 index 0000000..5baa80a Binary files /dev/null and b/tests/faces/图片_20260424165952_58_52.png differ diff --git a/tests/faces/图片_20260424170014_59_52.png b/tests/faces/图片_20260424170014_59_52.png new file mode 100644 index 0000000..fa612a2 Binary files /dev/null and b/tests/faces/图片_20260424170014_59_52.png differ diff --git a/tests/faces/图片_20260424170052_60_52.png b/tests/faces/图片_20260424170052_60_52.png new file mode 100644 index 0000000..a10c06f Binary files /dev/null and b/tests/faces/图片_20260424170052_60_52.png differ diff --git a/tests/faces/图片_20260424170106_61_52.png b/tests/faces/图片_20260424170106_61_52.png new file mode 100644 index 0000000..33e7f59 Binary files /dev/null and b/tests/faces/图片_20260424170106_61_52.png differ diff --git a/tests/faces/图片_20260424170114_62_52.png b/tests/faces/图片_20260424170114_62_52.png new file mode 100644 index 0000000..6a5af4c Binary files /dev/null and b/tests/faces/图片_20260424170114_62_52.png differ diff --git a/tests/test_consumption_tsv_log.py b/tests/test_consumption_tsv_log.py index 9661bb8..dcb710d 100644 --- a/tests/test_consumption_tsv_log.py +++ b/tests/test_consumption_tsv_log.py @@ -11,6 +11,7 @@ from app.services.consumption_tsv_log import ( append_consumption_log_summary, append_consumption_tsv_line, build_consumption_markdown, + build_pending_consumption_markdown, build_tsv_line, init_consumption_log_file, replace_pending_line_with_voice_resolution, @@ -48,7 +49,7 @@ def test_build_tsv_line_matches_sample_shape(monkeypatch: pytest.MonkeyPatch) -> wall_end_epoch=w0 + 45.0, ) parts = line.rstrip("\n").split("\t") - assert len(parts) == 9 + assert len(parts) == 11 assert parts[0] == "2237844" assert parts[1] == "一次性医用灭菌棉签" assert parts[2] == "1" @@ -59,10 +60,12 @@ def test_build_tsv_line_matches_sample_shape(monkeypatch: pytest.MonkeyPatch) -> + _RANGE_SEP + "2024-01-01T00:00:45.000+00:00" ) - assert parts[5] == "cls2" - assert parts[6] == "0.0003" - assert parts[7] == "cls3" - assert parts[8] == "0.0002" + assert parts[5] == "一次性医用灭菌棉签" + assert parts[6] == "0.9997" + assert parts[7] == "cls2" + assert parts[8] == "0.0003" + assert parts[9] == "cls3" + assert parts[10] == "0.0002" def test_resolve_consumption_item_id_uses_normalized_catalog_key() -> None: @@ -78,6 +81,8 @@ def test_header_columns() -> None: "qty", "doctor_id", "timestamp", + "top1_name", + "top1_conf", "top2_name", "top2_conf", "top3_name", @@ -101,7 +106,8 @@ def test_replace_pending_line_with_voice_resolution_rewrites_one_row( pending = ( "pending:abc-123\t待确认\t1\tvision\t" "cam01@2024-01-01T00:00:00.000+00:00" - f"{_RANGE_SEP}2024-01-01T00:00:45.000+00:00\tx\t0.1\ty\t0.2\n" + f"{_RANGE_SEP}2024-01-01T00:00:45.000+00:00\t" + "一次性针头\t0.5000\tx\t0.1\ty\t0.2\n" ) append_consumption_tsv_line("SURG01", pending) replace_pending_line_with_voice_resolution( @@ -189,6 +195,7 @@ def test_build_consumption_markdown_top123_columns(monkeypatch: pytest.MonkeyPat wall_end_epoch=w0 + 45.0, ) assert "| item_id |" in md and "| item_name |" in md and "| qty |" in md + assert "| 相对开录 |" in md assert "| top2 |" in md and "| top3 |" in md assert "2237844" in md assert "一次性医用灭菌棉签" in md @@ -199,3 +206,33 @@ def test_build_consumption_markdown_top123_columns(monkeypatch: pytest.MonkeyPat assert "2024-01-01 00:00:00.000" in md and "2024-01-01 00:00:45.000" in md assert "cam01" in md and " · " in md and _RANGE_SEP in md assert "cam01@2024-01" not in md + assert "| DOCTOR_PLACEHOLDER | — |" in md + + +def test_build_pending_consumption_markdown_top123(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(bp, "CONSUMPTION_LOG_TIMEZONE", "UTC") + snap = ClsTop3( + t1_name="输液器A", + t1_conf=0.88, + t2_name="候选二", + t2_conf=0.07, + t3_name="候选三", + t3_conf=0.02, + t1_pid="", + t2_pid="", + t3_pid="", + ) + w0 = 1704067200.0 + md = build_pending_consumption_markdown( + confirmation_id="cid-1", + model_snap=snap, + doctor_id="vision", + camera_id="or-cam-01", + wall_start_epoch=w0, + wall_end_epoch=w0 + 15.0, + since_recording_start="0分15秒", + ) + assert "| top1 | top2 | top3 |" in md + assert "输液器A (0.8800)" in md + assert "候选二 (0.0700)" in md + assert "候选三 (0.0200)" in md