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:
@@ -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`` 得到,非录制过程中按窗累计。
|
手术结束时再追加一节汇总行: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
|
from app.terminal_markdown import print_markdown_stderr
|
||||||
|
|
||||||
# 制表符分隔;时间范围用 U+2013 连接;本窗消耗数量恒为 1。
|
# 制表符分隔;时间范围用 U+2013 连接;本窗消耗数量恒为 1。
|
||||||
# top2/top3 为模型原始排序(未按手术候选重排);item_id 只写与展示名不同的业务 id(label_id),与名称相同时留空。
|
# top1/2/3 为模型原始排序(未按手术候选重排);确认行 item_name 与 top1_name 同为 Top1 标签。
|
||||||
|
# item_id 只写与展示名不同的业务 id(label_id),与名称相同时留空。
|
||||||
HEADER = (
|
HEADER = (
|
||||||
"item_id\titem_name\tqty\tdoctor_id\ttimestamp\t"
|
"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"
|
SUMMARY_HEADER = "item_id\titem_name\tqty\n"
|
||||||
_RANGE_SEP = "\u2013" # en dash,与样例 `00:00:00.000–00:00:45.000` 一致
|
_RANGE_SEP = "\u2013" # en dash,与样例 `00:00:00.000–00:00:45.000` 一致
|
||||||
@@ -151,6 +155,8 @@ def build_tsv_line(
|
|||||||
"1",
|
"1",
|
||||||
_encode_cell(doctor_id),
|
_encode_cell(doctor_id),
|
||||||
_encode_cell(ts),
|
_encode_cell(ts),
|
||||||
|
_encode_cell(name1),
|
||||||
|
_fmt_top_conf(best.t1_conf),
|
||||||
_encode_cell(n2),
|
_encode_cell(n2),
|
||||||
_fmt_top_conf(best.t2_conf),
|
_fmt_top_conf(best.t2_conf),
|
||||||
_encode_cell(n3),
|
_encode_cell(n3),
|
||||||
@@ -217,6 +223,7 @@ def build_consumption_markdown(
|
|||||||
camera_id: str,
|
camera_id: str,
|
||||||
wall_start_epoch: float,
|
wall_start_epoch: float,
|
||||||
wall_end_epoch: float,
|
wall_end_epoch: float,
|
||||||
|
since_recording_start: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""终端用:与落盘列一致;本窗 qty 恒为 1。"""
|
"""终端用:与落盘列一致;本窗 qty 恒为 1。"""
|
||||||
tsv_id, _ = resolve_consumption_ids(best.t1_name, best.t1_pid, name_to_code)
|
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()
|
n2 = (best.t2_name or "").strip()
|
||||||
n3 = (best.t3_name or "").strip()
|
n3 = (best.t3_name or "").strip()
|
||||||
ts = format_consumption_timestamp_readable(camera_id, wall_start_epoch, wall_end_epoch)
|
ts = format_consumption_timestamp_readable(camera_id, wall_start_epoch, wall_end_epoch)
|
||||||
|
rel = _md_cell(since_recording_start or "—")
|
||||||
return "\n".join(
|
return "\n".join(
|
||||||
[
|
[
|
||||||
"| item_id | item_name | qty | doctor_id | timestamp | top2 | top3 |",
|
"| item_id | item_name | qty | doctor_id | 相对开录 | timestamp | top2 | top3 |",
|
||||||
"| :--- | :--- | ---: | :--- | :--- | :--- | :--- |",
|
"| :--- | :--- | ---: | :--- | :--- | :--- | :--- | :--- |",
|
||||||
"| {} | {} | 1 | {} | {} | {} | {} |".format(
|
"| {} | {} | 1 | {} | {} | {} | {} | {} |".format(
|
||||||
_md_cell(tsv_id),
|
_md_cell(tsv_id),
|
||||||
_md_cell(n1),
|
_md_cell(n1),
|
||||||
_md_cell(doctor_id),
|
_md_cell(doctor_id),
|
||||||
|
rel,
|
||||||
_md_cell(ts),
|
_md_cell(ts),
|
||||||
_md_cell(
|
_md_cell(
|
||||||
f"{n2} ({_fmt_top_conf(best.t2_conf)})" if n2 else "—",
|
f"{n2} ({_fmt_top_conf(best.t2_conf)})" if n2 else "—",
|
||||||
@@ -259,6 +268,7 @@ def _build_pending_tsv_line(
|
|||||||
) -> str:
|
) -> str:
|
||||||
pid = f"pending:{confirmation_id}"
|
pid = f"pending:{confirmation_id}"
|
||||||
ts = format_consumption_timestamp(camera_id, wall_start_epoch, wall_end_epoch)
|
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()
|
n2 = (model_snap.t2_name or "").strip()
|
||||||
n3 = (model_snap.t3_name or "").strip()
|
n3 = (model_snap.t3_name or "").strip()
|
||||||
row = [
|
row = [
|
||||||
@@ -267,6 +277,8 @@ def _build_pending_tsv_line(
|
|||||||
"1",
|
"1",
|
||||||
_encode_cell(doctor_id),
|
_encode_cell(doctor_id),
|
||||||
_encode_cell(ts),
|
_encode_cell(ts),
|
||||||
|
_encode_cell(n1),
|
||||||
|
_fmt_top_conf(model_snap.t1_conf),
|
||||||
_encode_cell(n2),
|
_encode_cell(n2),
|
||||||
_fmt_top_conf(model_snap.t2_conf),
|
_fmt_top_conf(model_snap.t2_conf),
|
||||||
_encode_cell(n3),
|
_encode_cell(n3),
|
||||||
@@ -283,26 +295,31 @@ def build_pending_consumption_markdown(
|
|||||||
camera_id: str,
|
camera_id: str,
|
||||||
wall_start_epoch: float,
|
wall_start_epoch: float,
|
||||||
wall_end_epoch: float,
|
wall_end_epoch: float,
|
||||||
|
since_recording_start: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
pid = f"pending:{confirmation_id}"
|
pid = f"pending:{confirmation_id}"
|
||||||
|
n1 = (model_snap.t1_name or "").strip()
|
||||||
n2 = (model_snap.t2_name or "").strip()
|
n2 = (model_snap.t2_name or "").strip()
|
||||||
n3 = (model_snap.t3_name or "").strip()
|
n3 = (model_snap.t3_name or "").strip()
|
||||||
ts = format_consumption_timestamp_readable(camera_id, wall_start_epoch, wall_end_epoch)
|
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(
|
return "\n".join(
|
||||||
[
|
[
|
||||||
"| item_id | item_name | qty | doctor_id | timestamp | top2 | top3 |",
|
"| item_id | item_name | qty | doctor_id | 相对开录 | timestamp | top1 | top2 | top3 |",
|
||||||
"| :--- | :--- | ---: | :--- | :--- | :--- | :--- |",
|
"| :--- | :--- | ---: | :--- | :--- | :--- | :--- | :--- | :--- |",
|
||||||
"| {} | {} | 1 | {} | {} | {} | {} |".format(
|
"| {} | {} | 1 | {} | {} | {} | {} | {} | {} |".format(
|
||||||
_md_cell(pid),
|
_md_cell(pid),
|
||||||
_md_cell(PENDING_CONSUMPTION_ITEM_NAME),
|
_md_cell(PENDING_CONSUMPTION_ITEM_NAME),
|
||||||
_md_cell(doctor_id),
|
_md_cell(doctor_id),
|
||||||
|
rel,
|
||||||
_md_cell(ts),
|
_md_cell(ts),
|
||||||
_md_cell(
|
_top_cell(n1, model_snap.t1_conf),
|
||||||
f"{n2} ({_fmt_top_conf(model_snap.t2_conf)})" if n2 else "—",
|
_top_cell(n2, model_snap.t2_conf),
|
||||||
),
|
_top_cell(n3, model_snap.t3_conf),
|
||||||
_md_cell(
|
|
||||||
f"{n3} ({_fmt_top_conf(model_snap.t3_conf)})" if n3 else "—",
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
@@ -320,8 +337,9 @@ def append_consumption_pending_window(
|
|||||||
wall_end_epoch: float,
|
wall_end_epoch: float,
|
||||||
tsv_enabled: bool | None = None,
|
tsv_enabled: bool | None = None,
|
||||||
markdown_terminal: bool | None = None,
|
markdown_terminal: bool | None = None,
|
||||||
|
since_recording_start: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""需医生确认的时间窗:落盘/终端记「待确认」,top2/3 仍保留模型提示;不更新消耗汇总。"""
|
"""需医生确认的时间窗:落盘/终端记「待确认」,top1/2/3 保留模型提示;不更新消耗汇总。"""
|
||||||
en_tsv = bp.CONSUMPTION_TSV_LOG_ENABLED if tsv_enabled is None else tsv_enabled
|
en_tsv = bp.CONSUMPTION_TSV_LOG_ENABLED if tsv_enabled is None else tsv_enabled
|
||||||
en_md = (
|
en_md = (
|
||||||
bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL
|
bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL
|
||||||
@@ -349,6 +367,7 @@ def append_consumption_pending_window(
|
|||||||
camera_id=camera_id,
|
camera_id=camera_id,
|
||||||
wall_start_epoch=wall_start_epoch,
|
wall_start_epoch=wall_start_epoch,
|
||||||
wall_end_epoch=wall_end_epoch,
|
wall_end_epoch=wall_end_epoch,
|
||||||
|
since_recording_start=since_recording_start,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -549,6 +568,7 @@ class ConsumptionTsvWriter:
|
|||||||
camera_id: str,
|
camera_id: str,
|
||||||
wall_start_epoch: float,
|
wall_start_epoch: float,
|
||||||
wall_end_epoch: float,
|
wall_end_epoch: float,
|
||||||
|
since_recording_start: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not bp.CONSUMPTION_TSV_LOG_ENABLED and not bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL:
|
if not bp.CONSUMPTION_TSV_LOG_ENABLED and not bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL:
|
||||||
return
|
return
|
||||||
@@ -571,6 +591,7 @@ class ConsumptionTsvWriter:
|
|||||||
camera_id=camera_id,
|
camera_id=camera_id,
|
||||||
wall_start_epoch=wall_start_epoch,
|
wall_start_epoch=wall_start_epoch,
|
||||||
wall_end_epoch=wall_end_epoch,
|
wall_end_epoch=wall_end_epoch,
|
||||||
|
since_recording_start=since_recording_start,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -621,6 +642,7 @@ def append_consumption_window(
|
|||||||
camera_id: str,
|
camera_id: str,
|
||||||
wall_start_epoch: float,
|
wall_start_epoch: float,
|
||||||
wall_end_epoch: float,
|
wall_end_epoch: float,
|
||||||
|
since_recording_start: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not bp.CONSUMPTION_TSV_LOG_ENABLED and not bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL:
|
if not bp.CONSUMPTION_TSV_LOG_ENABLED and not bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL:
|
||||||
return
|
return
|
||||||
@@ -643,5 +665,6 @@ def append_consumption_window(
|
|||||||
camera_id=camera_id,
|
camera_id=camera_id,
|
||||||
wall_start_epoch=wall_start_epoch,
|
wall_start_epoch=wall_start_epoch,
|
||||||
wall_end_epoch=wall_end_epoch,
|
wall_end_epoch=wall_end_epoch,
|
||||||
|
since_recording_start=since_recording_start,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -203,7 +207,7 @@ class SyntheticRtspManager:
|
|||||||
url_map[s.camera_id] = dest
|
url_map[s.camera_id] = dest
|
||||||
pub = [
|
pub = [
|
||||||
"ffmpeg", "-hide_banner", "-loglevel", "warning",
|
"ffmpeg", "-hide_banner", "-loglevel", "warning",
|
||||||
"-re", "-stream_loop", "-1",
|
"-re",
|
||||||
"-i", str(s.file_path),
|
"-i", str(s.file_path),
|
||||||
"-c", "copy", "-f", "rtsp", "-rtsp_transport", "tcp", dest,
|
"-c", "copy", "-f", "rtsp", "-rtsp_transport", "tcp", dest,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from loguru import logger
|
import time
|
||||||
|
|
||||||
from app.baked import pipeline as bp
|
from app.baked import pipeline as bp
|
||||||
from app.services.consumable_vision_algorithm import (
|
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 (
|
from app.services.video.session_registry import (
|
||||||
SurgerySessionRegistry,
|
SurgerySessionRegistry,
|
||||||
SurgerySessionState,
|
SurgerySessionState,
|
||||||
|
format_elapsed_mmss_since,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -83,6 +84,10 @@ class VisionClassificationHandler:
|
|||||||
camera_id=camera_id,
|
camera_id=camera_id,
|
||||||
wall_start_epoch=ready.wall_lo,
|
wall_start_epoch=ready.wall_lo,
|
||||||
wall_end_epoch=ready.wall_hi,
|
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(
|
async def handle(
|
||||||
@@ -182,11 +187,7 @@ class VisionClassificationHandler:
|
|||||||
)
|
)
|
||||||
if cid is None:
|
if cid is None:
|
||||||
return
|
return
|
||||||
logger.info(
|
at_ep = ready.wall_hi if ready is not None else time.time()
|
||||||
"Enqueued pending consumable confirmation id={} top_key={}",
|
|
||||||
cid,
|
|
||||||
top_key,
|
|
||||||
)
|
|
||||||
if ready is not None and surgery_id and camera_id and (
|
if ready is not None and surgery_id and camera_id and (
|
||||||
bp.CONSUMPTION_TSV_LOG_ENABLED
|
bp.CONSUMPTION_TSV_LOG_ENABLED
|
||||||
or bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL
|
or bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL
|
||||||
@@ -201,6 +202,10 @@ class VisionClassificationHandler:
|
|||||||
wall_end_epoch=ready.wall_hi,
|
wall_end_epoch=ready.wall_hi,
|
||||||
tsv_enabled=bp.CONSUMPTION_TSV_LOG_ENABLED,
|
tsv_enabled=bp.CONSUMPTION_TSV_LOG_ENABLED,
|
||||||
markdown_terminal=bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL,
|
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(
|
await self._registry.append_pending_consumption_detail(
|
||||||
state=state,
|
state=state,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from app.services.video.session_registry import (
|
|||||||
RunningSurgery,
|
RunningSurgery,
|
||||||
SurgerySessionRegistry,
|
SurgerySessionRegistry,
|
||||||
SurgerySessionState,
|
SurgerySessionState,
|
||||||
|
format_elapsed_mmss_since,
|
||||||
)
|
)
|
||||||
from app.services.tear_gated_segment_consumption.product_map import (
|
from app.services.tear_gated_segment_consumption.product_map import (
|
||||||
load_tear_segment_name_to_id,
|
load_tear_segment_name_to_id,
|
||||||
@@ -166,6 +167,7 @@ class CameraSessionManager:
|
|||||||
state = SurgerySessionState(
|
state = SurgerySessionState(
|
||||||
candidate_consumables=list(resolved),
|
candidate_consumables=list(resolved),
|
||||||
name_to_code=name_to_code,
|
name_to_code=name_to_code,
|
||||||
|
surgery_started_wall=time.time(),
|
||||||
)
|
)
|
||||||
stop_event = asyncio.Event()
|
stop_event = asyncio.Event()
|
||||||
readies = [asyncio.Event() for _ in camera_ids]
|
readies = [asyncio.Event() for _ in camera_ids]
|
||||||
@@ -447,9 +449,13 @@ class CameraSessionManager:
|
|||||||
|
|
||||||
if bp.VIDEO_LOG_INFERENCE_RESULTS:
|
if bp.VIDEO_LOG_INFERENCE_RESULTS:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Vision result surgery={} camera={} top1={}({:.3f}) top2={}({:.3f}) top3={}({:.3f})",
|
"Vision result surgery={} camera={} 相对开录={} top1={}({:.3f}) top2={}({:.3f}) top3={}({:.3f})",
|
||||||
surgery_id,
|
surgery_id,
|
||||||
camera_id,
|
camera_id,
|
||||||
|
format_elapsed_mmss_since(
|
||||||
|
state.surgery_started_wall,
|
||||||
|
at_epoch=time.time(),
|
||||||
|
),
|
||||||
snap.t1_name,
|
snap.t1_name,
|
||||||
snap.t1_conf,
|
snap.t1_conf,
|
||||||
snap.t2_name,
|
snap.t2_name,
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ from app.services.voice_confirm import build_prompt_text
|
|||||||
from app.surgery_errors import SurgeryPipelineError
|
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
|
@dataclass
|
||||||
class PendingConsumableConfirmation:
|
class PendingConsumableConfirmation:
|
||||||
"""待客户端确认的一条低置信度识别(不阻塞后续帧推理)。"""
|
"""待客户端确认的一条低置信度识别(不阻塞后续帧推理)。"""
|
||||||
@@ -73,6 +83,8 @@ class SurgerySessionState:
|
|||||||
last_asr_text: str | None = None
|
last_asr_text: str | None = None
|
||||||
#: 最近一次语音确认错误说明(ASR/解析失败等)。
|
#: 最近一次语音确认错误说明(ASR/解析失败等)。
|
||||||
last_voice_error: str | None = None
|
last_voice_error: str | None = None
|
||||||
|
#: ``start_surgery`` 创建会话时的 ``time.time()``,用于日志中「相对开录的流逝时间」。
|
||||||
|
surgery_started_wall: float | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
scripts/demo_client/
|
scripts/demo_client/
|
||||||
server.py # 基于 stdlib 的静态服务器;额外暴露 /labels.json
|
server.py # 基于 stdlib 的静态服务器;额外暴露 /labels.json
|
||||||
index.html # 单文件页面(原生 JS,零构建依赖)
|
index.html # 单文件页面(原生 JS,零构建依赖)
|
||||||
fake_rtsp_from_file.py # 无真摄像头时:把本地视频循环发布为 RTSP(ffmpeg + Docker MediaMTX)
|
fake_rtsp_from_file.py # 无真摄像头时:把本地视频按文件时长推一次到 RTSP(ffmpeg + Docker MediaMTX)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 调试:无真实摄像头,用录好的视频模拟 RTSP
|
## 调试:无真实摄像头,用录好的视频模拟 RTSP
|
||||||
@@ -53,10 +53,10 @@ python3 scripts/demo_client/fake_rtsp_from_file.py --port 18554 \
|
|||||||
发布失败时,可尝试把输入转码后再推流(示例,需自行调整):
|
发布失败时,可尝试把输入转码后再推流(示例,需自行调整):
|
||||||
|
|
||||||
```bash
|
```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=...` 方式自行在终端完成。
|
Demo 页面「调试:两路视频」中可用 **选择视频** / **拖放** 为路1/路2 指定文件,并配合下面 **一键开录** 上传,无需在页面里手抄 `python3` / `export` 命令。若必须完全手跑 `fake_rtsp_from_file.py`,请在上文命令示例与 `export VIDEO_RTSP_URLS_JSON=...` 方式自行在终端完成。
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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.
|
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
|
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:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(
|
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(
|
parser.add_argument(
|
||||||
"video",
|
"video",
|
||||||
@@ -198,7 +198,6 @@ def main() -> int:
|
|||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-hide_banner", "-loglevel", "info",
|
"-hide_banner", "-loglevel", "info",
|
||||||
"-re",
|
"-re",
|
||||||
"-stream_loop", "-1",
|
|
||||||
"-i", str(fp),
|
"-i", str(fp),
|
||||||
"-c", "copy",
|
"-c", "copy",
|
||||||
"-f", "rtsp",
|
"-f", "rtsp",
|
||||||
@@ -225,7 +224,11 @@ def main() -> int:
|
|||||||
h = u.replace("127.0.0.1", "host.docker.internal", 1)
|
h = u.replace("127.0.0.1", "host.docker.internal", 1)
|
||||||
print(f" {cam}: {h}", file=sys.stderr)
|
print(f" {cam}: {h}", file=sys.stderr)
|
||||||
print("---", 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:
|
def on_sigint(_sig: int, _frame) -> None:
|
||||||
for p in procs:
|
for p in procs:
|
||||||
@@ -240,8 +243,8 @@ def main() -> int:
|
|||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
for p in procs:
|
for p in procs:
|
||||||
if p.poll() is not None:
|
if p.poll() is not None:
|
||||||
print(
|
print(
|
||||||
f"[fake-rtsp] ffmpeg ended (code {p.returncode}), stopping all.",
|
f"[fake-rtsp] ffmpeg ended (code {p.returncode}), stopping all.",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
|
|||||||
BIN
tests/faces/图片_20260424165952_58_52.png
Normal file
BIN
tests/faces/图片_20260424165952_58_52.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 388 KiB |
BIN
tests/faces/图片_20260424170014_59_52.png
Normal file
BIN
tests/faces/图片_20260424170014_59_52.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 454 KiB |
BIN
tests/faces/图片_20260424170052_60_52.png
Normal file
BIN
tests/faces/图片_20260424170052_60_52.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 296 KiB |
BIN
tests/faces/图片_20260424170106_61_52.png
Normal file
BIN
tests/faces/图片_20260424170106_61_52.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 283 KiB |
BIN
tests/faces/图片_20260424170114_62_52.png
Normal file
BIN
tests/faces/图片_20260424170114_62_52.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 524 KiB |
@@ -11,6 +11,7 @@ from app.services.consumption_tsv_log import (
|
|||||||
append_consumption_log_summary,
|
append_consumption_log_summary,
|
||||||
append_consumption_tsv_line,
|
append_consumption_tsv_line,
|
||||||
build_consumption_markdown,
|
build_consumption_markdown,
|
||||||
|
build_pending_consumption_markdown,
|
||||||
build_tsv_line,
|
build_tsv_line,
|
||||||
init_consumption_log_file,
|
init_consumption_log_file,
|
||||||
replace_pending_line_with_voice_resolution,
|
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,
|
wall_end_epoch=w0 + 45.0,
|
||||||
)
|
)
|
||||||
parts = line.rstrip("\n").split("\t")
|
parts = line.rstrip("\n").split("\t")
|
||||||
assert len(parts) == 9
|
assert len(parts) == 11
|
||||||
assert parts[0] == "2237844"
|
assert parts[0] == "2237844"
|
||||||
assert parts[1] == "一次性医用灭菌棉签"
|
assert parts[1] == "一次性医用灭菌棉签"
|
||||||
assert parts[2] == "1"
|
assert parts[2] == "1"
|
||||||
@@ -59,10 +60,12 @@ def test_build_tsv_line_matches_sample_shape(monkeypatch: pytest.MonkeyPatch) ->
|
|||||||
+ _RANGE_SEP
|
+ _RANGE_SEP
|
||||||
+ "2024-01-01T00:00:45.000+00:00"
|
+ "2024-01-01T00:00:45.000+00:00"
|
||||||
)
|
)
|
||||||
assert parts[5] == "cls2"
|
assert parts[5] == "一次性医用灭菌棉签"
|
||||||
assert parts[6] == "0.0003"
|
assert parts[6] == "0.9997"
|
||||||
assert parts[7] == "cls3"
|
assert parts[7] == "cls2"
|
||||||
assert parts[8] == "0.0002"
|
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:
|
def test_resolve_consumption_item_id_uses_normalized_catalog_key() -> None:
|
||||||
@@ -78,6 +81,8 @@ def test_header_columns() -> None:
|
|||||||
"qty",
|
"qty",
|
||||||
"doctor_id",
|
"doctor_id",
|
||||||
"timestamp",
|
"timestamp",
|
||||||
|
"top1_name",
|
||||||
|
"top1_conf",
|
||||||
"top2_name",
|
"top2_name",
|
||||||
"top2_conf",
|
"top2_conf",
|
||||||
"top3_name",
|
"top3_name",
|
||||||
@@ -101,7 +106,8 @@ def test_replace_pending_line_with_voice_resolution_rewrites_one_row(
|
|||||||
pending = (
|
pending = (
|
||||||
"pending:abc-123\t待确认\t1\tvision\t"
|
"pending:abc-123\t待确认\t1\tvision\t"
|
||||||
"cam01@2024-01-01T00:00:00.000+00:00"
|
"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)
|
append_consumption_tsv_line("SURG01", pending)
|
||||||
replace_pending_line_with_voice_resolution(
|
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,
|
wall_end_epoch=w0 + 45.0,
|
||||||
)
|
)
|
||||||
assert "| item_id |" in md and "| item_name |" in md and "| qty |" in md
|
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 "| top2 |" in md and "| top3 |" in md
|
||||||
assert "2237844" in md
|
assert "2237844" in md
|
||||||
assert "一次性医用灭菌棉签" 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 "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" in md and " · " in md and _RANGE_SEP in md
|
||||||
assert "cam01@2024-01" not 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
|
||||||
|
|||||||
Reference in New Issue
Block a user