Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e870cad
Save validated camera calibration for tool and fixed camera setups
Jepson2k Sep 7, 2026
fd5ebff
Merge branch 'feat/named-device-signals' into feat/camera-calibration…
Jepson2k Sep 7, 2026
aaca06b
Merge branch 'feat/named-device-signals' into feat/camera-calibration…
Jepson2k Sep 7, 2026
537fec6
Merge branch 'feat/named-device-signals' into feat/camera-calibration…
Jepson2k Sep 7, 2026
fe56b18
Validate saved camera calibration against a newly received frame
Jepson2k Sep 8, 2026
258cc65
Merge branch 'feat/named-device-signals' into feat/camera-calibration…
Jepson2k Sep 8, 2026
a0997ca
Merge commit 'refs/gui-restack/54' into HEAD
Jepson2k Sep 10, 2026
c2ebe25
Simplify camera calibration forms and panel sizing
Jepson2k Sep 10, 2026
b238da3
Merge commit 'refs/gui-restack/54' into HEAD
Jepson2k Sep 10, 2026
1c03c93
Merge the Settings formatting correction through the feature stack
Jepson2k Sep 10, 2026
676c6df
Keep a calibration run alive across page rebuilds, dropped frames and…
Jepson2k Sep 11, 2026
3e9f186
Merge commit '095a14bb8fac1fd5d4510ce13c3e8c9b688f52d7' into feat/cam…
Jepson2k Sep 11, 2026
f20878e
Assert what a camera restart has to do, not that bytes are immutable
Jepson2k Sep 11, 2026
366bca5
Merge commit '018c744d8b320a3dae80891056e402e0119dd255' into feat/cam…
Jepson2k Sep 12, 2026
859287e
Merge branch 'feat/named-device-signals' into feat/camera-calibration…
claude Sep 17, 2026
3e93bc2
Merge branch 'feat/named-device-signals' into feat/camera-calibration…
claude Sep 17, 2026
fbb016d
Merge branch 'feat/named-device-signals' into feat/camera-calibration…
claude Sep 17, 2026
679113a
Merge branch 'feat/named-device-signals' into feat/camera-calibration…
claude Sep 17, 2026
4cdfdf6
Merge branch 'feat/named-device-signals' into feat/camera-calibration…
claude Sep 18, 2026
4e9f5db
Merge branch 'feat/named-device-signals' into feat/camera-calibration…
claude Sep 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions docs/guides/named-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,74 @@ result = wait_signal(
A fixture supplies a constant logical value. If it cannot match the requested
value, preview advances by the wait duration and takes the timeout branch. No
fixture means an unresolved preview. Live clients refuse fixtures.

## Camera calibration

The **Hand-Eye Calibration** panel supports **Camera on tool** and **Fixed
camera** placement. For a tool camera, keep the printed ChArUco board fixed in
the workspace. For a fixed camera, attach the board rigidly to the tool and
leave the camera stationary. The fixed-camera controls can list and start a
video device independently of a tool's camera assignment.

Reference the arm, hold it stationary, and capture at least four views with
rotation about multiple wrist axes; 10–15 diverse views are preferable. Capture
waits for a subsequent camera frame and reads the controller's tool/TCP binding.
Changing the camera session, tool, TCP transform or image dimensions requires
clearing the sample set. The timestamp is host receipt time, not hardware
exposure time: this acquisition workflow requires stationary observations.

**Solve** estimates pinhole intrinsics and the camera transform. A tool camera
is expressed relative to the current TCP; a fixed camera is expressed in WRF.
Both report reprojection error, rotational/translational residuals and board
position spread. Review those measurements against the accuracy your task needs.
The fixed-camera case uses inverse robot poses with OpenCV's
[hand-eye calibration solver](https://docs.opencv.org/4.8.0/d9/d0c/group__calib3d.html).

In **Saved camera data**, select the setup and camera name before pressing
**Save**. A fixed camera can be expressed in a static named frame already in
that setup; saving records the frame's WRF transform as its reference.
**Load / check** reads the saved measurement and checks it against the current
camera and controller. **Export snapshot** downloads ordinary Python containing
the setup's fixed values. Saving, loading and exporting issue no robot motion
or configuration commands.

```python
from waldo_commander.setup import load_setup

setup = load_setup("bench")
camera = setup.cameras["overhead"]
K = camera.intrinsics.camera_matrix # flat row-major 3×3, pixels
size = camera.intrinsics.image_size # width, height
quality = camera.quality
```

`CameraCalibration.validate` takes the setup plus the observed `camera_id`,
`image_size`, robot `backend`, and (for tool cameras) a current `TcpCalibration`.
`world_pose` accepts the same context and the image's WRF `tcp_pose` for tool
cameras. It returns a `Pose` after validation. Camera optical axes are +X right,
+Y down and +Z forward; translations use millimetres. A tool camera's stored
pose has frame `TCP`; use `world_pose` to resolve it before motion calculations.

Tool, variant and all six TCP components must match a tool-mounted calibration.
Fixed cameras remain usable across tool changes but reject a changed reference
frame, including changes to its parents. Changed camera source, resolution or
backend is rejected in either mode. These checks use the explicitly supplied
snapshot and observations; a running program's loaded setup remains immutable.

The camera service provides `snapshot(max_age_s=...)` and bounded
`next_snapshot(timeout_s=...)` for acquisition in the app process. Observations
include JPEG bytes, host receipt time, source identity, sequence and capture
session. Inactive, stale and missing images raise `CameraUnavailable`; display
placeholders are never returned as observations.

The built-in source identity fingerprints the configured device and requested
capture dimensions without exporting the device string. Replacing a camera at
the same device index, moving its mount, changing its lens/focus or moving the
robot base may be invisible to software. Recalibrate after those changes.

Existing per-tool measurements remain available in the panel. **Import existing
hand-eye measurement** requires confirmation of the same physical camera, lens
and mount, then checks the recorded tool, TCP and resolution. Import binds the
original intrinsics, transform, date and quality measurements to the current
source and tool variant, and saves a named setup entry. The original measurement
is preserved.
159 changes: 159 additions & 0 deletions tests/test_camera_calibration_screen.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""The camera panel displays a populated calibration without horizontal clipping.

A layout check, in a real browser, of the one state the panel is widest in: a
solved fixed-mount calibration with its residuals and its saved-data row. The
solve is the real one (board views rendered through the real detector and
`solve_hand_eye`) and the save is driven by the panel's own button through the
real measurement path, so the text being measured is text the panel produces.
The samples are handed to the panel rather than captured a pose at a time,
which is the part a browser cannot afford; the capture/solve/save workflow
itself is covered end to end by `test_handeye_panel_workflow`.
"""

import json

from nicegui import Client
import numpy as np
import pytest
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait

from tests.helpers.browser_helpers import dismiss_dialogs, run_in_app
from tests.helpers.wait import screen_wait_for_scene_ready
from tests.test_handeye_panel_integration import _FrameBackend, _jpeg
from tests.test_handeye_service import SPEC, _samples
from tests.helpers.charuco_render import render_board_view
from tests.test_handeye_service import IMAGE_SIZE, K_TRUE
from waldo_commander.services import handeye
from waldo_commander.services.camera_calibration import CaptureBinding
from waldo_commander.services.camera_service import camera_service
from waldo_commander.state import ui_state
from waldoctl.setup import TcpCalibration


@pytest.mark.browser
def test_fixed_camera_calibration_layout(screen, tmp_path, monkeypatch):
from waldo_commander.services import camera_service as module

monkeypatch.setenv("WALDO_SETUP_DIR", str(tmp_path))
monkeypatch.setattr(module, "LinuxpyBackend", _FrameBackend)
monkeypatch.setattr(module, "OpenCVBackend", _FrameBackend)
samples = _samples()
result = handeye.solve_hand_eye(
[
handeye.HandEyeSample(
np.linalg.inv(s.T_base_gripper), s.detection, s.timestamp
)
for s in samples
],
SPEC,
mount="fixed",
)
board_pose = np.eye(4)
board_pose[:3, 3] = (-75, -100, 500)
_FrameBackend.holder["jpeg"] = _jpeg(
render_board_view(SPEC, K_TRUE, board_pose, IMAGE_SIZE)
)
screen.open("/")
screen_wait_for_scene_ready(screen, timeout_s=40)
dismiss_dialogs(screen)

def marked(marker):
client = Client.instances[ui_state.active_client_id]
return next(e for e in client.elements.values() if marker in e._markers)

tab_id = run_in_app(lambda: marked("tab-handeye").id)
screen.selenium.find_element(By.ID, f"c{tab_id}").click()

def populate():
client = Client.instances[ui_state.active_client_id]
with client:
camera_service.start(0)
panel = next(p for p in ui_state.plugin_panels if p.id == "handeye")
panel._mount_select.set_value("fixed")
panel._samples = [
handeye.HandEyeSample(
np.linalg.inv(s.T_base_gripper), s.detection, s.timestamp
)
for s in samples
]
panel._refresh_samples()
panel._result = result
panel._sample_binding = CaptureBinding(
camera_service.camera_id,
camera_service._session_id,
"parol6",
TcpCalibration((0, 0, 0, 0, 0, 0), "NONE"),
)
panel._show_result(result)
panel._save_btn.set_enabled(True)
panel._data_editor.name.set_value("overhead")
panel._refresh_stage()
return panel._save_btn.id, panel._data_editor.message.id

try:
save_id, message_id = run_in_app(populate)
WebDriverWait(screen.selenium, 10).until(
lambda _: run_in_app(lambda: camera_service._snapshot is not None)
)
screen.selenium.execute_script(
"document.getElementById(arguments[0]).scrollIntoView({block: 'nearest', behavior: 'instant'});",
f"c{save_id}",
)
screen.selenium.find_element(By.ID, f"c{save_id}").click()
WebDriverWait(screen.selenium, 10).until(
lambda _: run_in_app(
lambda: "Saved bench/overhead" in marked("camera-data-message").text
)
)
screen.selenium.execute_script(
"document.getElementById(arguments[0]).scrollIntoView({block: 'nearest', behavior: 'instant'});",
f"c{message_id}",
)
WebDriverWait(screen.selenium, 10).until(
lambda driver: "Saved bench/overhead"
in driver.find_element(By.ID, f"c{message_id}").text
)

def result_visible(driver):
return driver.execute_script(
"""
const e = document.getElementById(arguments[0]);
e.scrollIntoView({block: 'nearest', behavior: 'instant'});
const a = e.getBoundingClientRect(), b = e.closest('.camera-panel-scroll').getBoundingClientRect();
return a.top >= b.top && a.bottom <= b.bottom;
""",
f"c{message_id}",
)

WebDriverWait(screen.selenium, 10).until(result_visible)
geometry = screen.selenium.execute_script(
"""
const panel = document.getElementById(arguments[0]).closest('.camera-panel-scroll');
return {width: panel.clientWidth, content: panel.scrollWidth};
""",
f"c{message_id}",
)
assert geometry["content"] <= geometry["width"] + 1, geometry
screen.selenium.save_screenshot(str(tmp_path / "camera-calibration.png"))
except BaseException:
screen.selenium.save_screenshot(
str(tmp_path / "camera-calibration-failure.png")
)
detail = screen.selenium.execute_script(
"""
let e = document.getElementById(arguments[0]);
const chain = [];
while (e) {
const r = e.getBoundingClientRect(), s = getComputedStyle(e);
chain.push({id:e.id, cls:e.className, text:e.textContent.slice(0,250), box:[r.x,r.y,r.width,r.height], display:s.display, visibility:s.visibility, overflow:s.overflow});
e = e.parentElement;
}
return chain;
""",
f"c{message_id}",
)
(tmp_path / "camera-layout.json").write_text(json.dumps(detail, indent=2))
raise
finally:
run_in_app(camera_service.stop)
53 changes: 53 additions & 0 deletions tests/test_camera_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,56 @@ def test_camera_listing_never_opens_devices():
from waldo_commander.services.camera_service import _enumerate_listing

assert _enumerate_listing() is not None


@pytest.mark.integration
async def test_snapshot_freshness_and_camera_restart(user, monkeypatch):
import asyncio
import time
from waldo_commander.camera import CameraUnavailable
from waldo_commander.services import camera_service as module
from tests.test_handeye_panel_integration import _FrameBackend, _blank_jpeg
from tests.helpers.wait import wait_for_app_ready

monkeypatch.setattr(module, "LinuxpyBackend", _FrameBackend)
monkeypatch.setattr(module, "OpenCVBackend", _FrameBackend)
_FrameBackend.holder["jpeg"] = _blank_jpeg()
await user.open("/")
await wait_for_app_ready()
service = CameraService()
try:
with pytest.raises(CameraUnavailable):
service.snapshot()
service.start(0)
first = await service.next_snapshot(timeout_s=3)
second = await service.next_snapshot(timeout_s=3)
assert second.sequence > first.sequence
assert second.received_at >= first.received_at
_FrameBackend.holder["jpeg"] = b""
deadline = time.monotonic() + 3
while True:
try:
service.snapshot(max_age_s=0.05)
except CameraUnavailable:
break
assert time.monotonic() < deadline, "Cached camera data never expired"
await asyncio.sleep(0.02)
with pytest.raises(CameraUnavailable, match="deadline"):
await service.next_snapshot(timeout_s=0.05)
service.stop()
with pytest.raises(CameraUnavailable):
service.snapshot()
_FrameBackend.holder["jpeg"] = first.jpeg
service.start(1)
changed = await service.next_snapshot(timeout_s=3)
assert changed.camera_id != first.camera_id
service.start(0)
restarted = await service.next_snapshot(timeout_s=3)
assert restarted.camera_id == first.camera_id
assert restarted.session_id != first.session_id
# The restart serves fresh frames, not the cache that expired while
# the backend was handing back nothing.
assert restarted.received_at > first.received_at
assert service.snapshot(max_age_s=1.0).session_id == restarted.session_id
finally:
service.stop()
Loading
Loading