feat: per-phase timings of the frame path in the log

One log line per phase every PHASE_LOG_EVERY samples (default 100, 0 = off):
mean and max of wait_frames, align, convert, each JPEG/PNG encoding,
colormap, depth_stats, base64, json and ws_send. Meant for the measurement
on PC2 via `docker logs` (optimize-streams R01): where a capture spends its
~130 ms of CPU is not known yet.

capture() moves its numpy conversion into _frame_from() unchanged, only so
the block can be timed; _depth_colormap() likewise wraps _jet_colormap().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
OliviaCandClaude Opus 5.5 committed 2026-09-29 12:57:25 +02:00
1 parent 441e0bd917
commit 9aa63e4e02
6 files changed
+166 -22

No files matched your search

+30 -16
View File
@@ -12,6 +12,7 @@ from src.backend.api.dependencies import verify_api_key
from src.backend.models.api import ok, fail
from src.backend.services.camera import RealSenseD435, CameraFrame
from src.backend.services.image_saver import ImageSaver
from src.backend.services.phase_timing import timer
logger = logging.getLogger(__name__)
@@ -37,16 +38,23 @@ def set_image_saver(saver: ImageSaver) -> None:
# ---------------------------------------------------------------------------
def _encode_rgb_jpeg(rgb: np.ndarray, quality: int = 85) -> bytes:
_, buf = cv2.imencode(".jpg", rgb, [cv2.IMWRITE_JPEG_QUALITY, quality])
with timer.phase(f"jpeg_q{quality}"):
_, buf = cv2.imencode(".jpg", rgb, [cv2.IMWRITE_JPEG_QUALITY, quality])
return buf.tobytes()
def _encode_depth_png(depth: np.ndarray) -> bytes:
_, buf = cv2.imencode(".png", depth)
with timer.phase("png_depth"):
_, buf = cv2.imencode(".png", depth)
return buf.tobytes()
def _depth_colormap(depth: np.ndarray) -> np.ndarray:
with timer.phase("colormap"):
return _jet_colormap(depth)
def _jet_colormap(depth: np.ndarray) -> np.ndarray:
valid = depth[depth > 0]
if len(valid) == 0:
return np.zeros((*depth.shape, 3), dtype=np.uint8)
@@ -63,21 +71,25 @@ def _frame_to_dict(frame: CameraFrame, include_images: bool = True) -> dict:
global _frame_count
_frame_count += 1
result = {
"frame_id": _frame_count,
"timestamp": frame.timestamp,
"rgb_shape": list(frame.rgb.shape),
"depth_shape": list(frame.depth.shape),
"depth_min_mm": int(frame.depth[frame.depth > 0].min()) if (frame.depth > 0).any() else 0,
"depth_max_mm": int(frame.depth.max()),
"depth_valid_pixels": int((frame.depth > 0).sum()),
}
with timer.phase("depth_stats"):
result = {
"frame_id": _frame_count,
"timestamp": frame.timestamp,
"rgb_shape": list(frame.rgb.shape),
"depth_shape": list(frame.depth.shape),
"depth_min_mm": int(frame.depth[frame.depth > 0].min()) if (frame.depth > 0).any() else 0,
"depth_max_mm": int(frame.depth.max()),
"depth_valid_pixels": int((frame.depth > 0).sum()),
}
if include_images:
result["rgb_jpeg_b64"] = base64.b64encode(_encode_rgb_jpeg(frame.rgb)).decode()
result["depth_png_b64"] = base64.b64encode(_encode_depth_png(frame.depth)).decode()
colormap = _depth_colormap(frame.depth)
result["depth_color_b64"] = base64.b64encode(_encode_rgb_jpeg(colormap, 90)).decode()
rgb_jpeg = _encode_rgb_jpeg(frame.rgb)
depth_png = _encode_depth_png(frame.depth)
color_jpeg = _encode_rgb_jpeg(_depth_colormap(frame.depth), 90)
with timer.phase("base64"):
result["rgb_jpeg_b64"] = base64.b64encode(rgb_jpeg).decode()
result["depth_png_b64"] = base64.b64encode(depth_png).decode()
result["depth_color_b64"] = base64.b64encode(color_jpeg).decode()
return result
@@ -221,7 +233,9 @@ async def capture():
if frame is None:
return fail("CAPTURE_TIMEOUT", "Frame capture timeout")
return JSONResponse(ok(_frame_to_dict(frame)))
data = _frame_to_dict(frame)
with timer.phase("json"):
return JSONResponse(ok(data))
@router.get("/rgb")