mirror of
https://github.com/huggingface/lerobot.git
synced 2026-07-30 21:19:40 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fe58f2d3a | |||
| bd2a796217 | |||
| 4c302572c0 | |||
| 72a1858015 | |||
| 2b578e68f6 |
@@ -164,8 +164,8 @@ includes the range reported by the sensor. Requesting an unsupported control als
|
|||||||
Omitted controls leave the sensor's existing automatic or manual setting unchanged. These options
|
Omitted controls leave the sensor's existing automatic or manual setting unchanged. These options
|
||||||
require `use_rgb=True`.
|
require `use_rgb=True`.
|
||||||
|
|
||||||
On the RealSense D405, the color stream is provided by the Stereo Module, so changing manual
|
Manual color controls require a dedicated RGB module. Cameras without one, such as the RealSense
|
||||||
exposure or gain also affects the depth stream.
|
D405, do not support them and raise an error at connection time.
|
||||||
|
|
||||||
</hfoption>
|
</hfoption>
|
||||||
</hfoptions>
|
</hfoptions>
|
||||||
|
|||||||
@@ -365,11 +365,12 @@ class RealSenseCamera(Camera):
|
|||||||
return self._async_read(timeout_ms=10000, read_depth=read_depth)
|
return self._async_read(timeout_ms=10000, read_depth=read_depth)
|
||||||
|
|
||||||
def _get_color_sensor(self) -> "rs.sensor":
|
def _get_color_sensor(self) -> "rs.sensor":
|
||||||
"""Returns the sensor that controls the color stream.
|
"""Returns the dedicated "RGB Camera" sensor that controls the color stream.
|
||||||
|
|
||||||
Most RealSense cameras expose "RGB Camera" for color. The D405 has no
|
Manual color controls are only applied to a dedicated RGB module. Cameras
|
||||||
separate RGB module — its color stream comes from "Stereo Module".
|
without one (e.g. the D405, whose color stream comes from the shared
|
||||||
We try RGB Camera first, then fall back to Stereo Module.
|
"Stereo Module") are unsupported, so we never fall back to another sensor
|
||||||
|
to avoid altering the depth stream.
|
||||||
"""
|
"""
|
||||||
if self.rs_profile is None:
|
if self.rs_profile is None:
|
||||||
raise RuntimeError(f"{self}: rs_profile must be initialized before use.")
|
raise RuntimeError(f"{self}: rs_profile must be initialized before use.")
|
||||||
@@ -377,12 +378,14 @@ class RealSenseCamera(Camera):
|
|||||||
device = self.rs_profile.get_device()
|
device = self.rs_profile.get_device()
|
||||||
sensors = {s.get_info(rs.camera_info.name): s for s in device.query_sensors()}
|
sensors = {s.get_info(rs.camera_info.name): s for s in device.query_sensors()}
|
||||||
|
|
||||||
for name in ("RGB Camera", "Stereo Module"):
|
if "RGB Camera" in sensors:
|
||||||
if name in sensors:
|
return sensors["RGB Camera"]
|
||||||
return sensors[name]
|
|
||||||
|
|
||||||
available = list(sensors.keys())
|
available = list(sensors.keys())
|
||||||
raise RuntimeError(f"{self}: no color sensor found. Available sensors: {available}")
|
raise RuntimeError(
|
||||||
|
f"{self}: manual color controls require a dedicated 'RGB Camera' module, which this camera does not have. ",
|
||||||
|
f"Available sensors: {available}.",
|
||||||
|
)
|
||||||
|
|
||||||
def _set_sensor_option(self, sensor: "rs.sensor", option: "rs.option", value: float, label: str) -> None:
|
def _set_sensor_option(self, sensor: "rs.sensor", option: "rs.option", value: float, label: str) -> None:
|
||||||
"""Sets a sensor option, re-raising range errors with actionable diagnostics."""
|
"""Sets a sensor option, re-raising range errors with actionable diagnostics."""
|
||||||
|
|||||||
@@ -52,6 +52,17 @@ def get_step_checkpoint_dir(output_dir: Path, total_steps: int, step: int) -> Pa
|
|||||||
return output_dir / CHECKPOINTS_DIR / step_identifier
|
return output_dir / CHECKPOINTS_DIR / step_identifier
|
||||||
|
|
||||||
|
|
||||||
|
def should_save_checkpoint(step: int, save_freq: int, total_steps: int) -> bool:
|
||||||
|
"""Whether a checkpoint should be saved at ``step``.
|
||||||
|
|
||||||
|
A checkpoint is saved every ``save_freq`` steps and always after the final step. A
|
||||||
|
non-positive ``save_freq`` disables periodic saving (only the final checkpoint is
|
||||||
|
written), mirroring how ``log_freq``/``eval_freq`` treat non-positive values and
|
||||||
|
avoiding a ``ZeroDivisionError`` from ``step % 0``.
|
||||||
|
"""
|
||||||
|
return (save_freq > 0 and step % save_freq == 0) or step == total_steps
|
||||||
|
|
||||||
|
|
||||||
def save_training_step(
|
def save_training_step(
|
||||||
step: int, save_dir: Path, num_processes: int | None = None, batch_size: int | None = None
|
step: int, save_dir: Path, num_processes: int | None = None, batch_size: int | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ class TrainPipelineConfig(HubMixin):
|
|||||||
tolerance_s: float = 1e-4
|
tolerance_s: float = 1e-4
|
||||||
save_checkpoint: bool = True
|
save_checkpoint: bool = True
|
||||||
# Checkpoint is saved every `save_freq` training iterations and after the last training step.
|
# Checkpoint is saved every `save_freq` training iterations and after the last training step.
|
||||||
|
# A non-positive value disables periodic saving, keeping only the final checkpoint.
|
||||||
save_freq: int = 20_000
|
save_freq: int = 20_000
|
||||||
use_policy_training_preset: bool = True
|
use_policy_training_preset: bool = True
|
||||||
optimizer: OptimizerConfig | None = None
|
optimizer: OptimizerConfig | None = None
|
||||||
|
|||||||
@@ -386,6 +386,9 @@ class DatasetWriter:
|
|||||||
self._episodes_since_last_encoding = 0
|
self._episodes_since_last_encoding = 0
|
||||||
|
|
||||||
if episode_data is None:
|
if episode_data is None:
|
||||||
|
# Post-save cleanup deliberately does not go through clear_episode_buffer():
|
||||||
|
# staging frames of video cameras must survive here — the (possibly batched)
|
||||||
|
# encoder still needs them and deletes them once each video is written.
|
||||||
if len(self._meta.image_keys) > 0:
|
if len(self._meta.image_keys) > 0:
|
||||||
self._delete_camera_frame_dirs(self._meta.image_keys)
|
self._delete_camera_frame_dirs(self._meta.image_keys)
|
||||||
self.episode_buffer = self._create_episode_buffer()
|
self.episode_buffer = self._create_episode_buffer()
|
||||||
|
|||||||
@@ -23,10 +23,20 @@ from ..config import RobotConfig
|
|||||||
def lekiwi_cameras_config() -> dict[str, CameraConfig]:
|
def lekiwi_cameras_config() -> dict[str, CameraConfig]:
|
||||||
return {
|
return {
|
||||||
"front": OpenCVCameraConfig(
|
"front": OpenCVCameraConfig(
|
||||||
index_or_path="/dev/video0", fps=30, width=640, height=480, rotation=Cv2Rotation.ROTATE_180
|
index_or_path="/dev/video0",
|
||||||
|
fps=30,
|
||||||
|
width=640,
|
||||||
|
height=480,
|
||||||
|
fourcc="MJPG",
|
||||||
|
rotation=Cv2Rotation.ROTATE_180,
|
||||||
),
|
),
|
||||||
"wrist": OpenCVCameraConfig(
|
"wrist": OpenCVCameraConfig(
|
||||||
index_or_path="/dev/video2", fps=30, width=480, height=640, rotation=Cv2Rotation.ROTATE_90
|
index_or_path="/dev/video2",
|
||||||
|
fps=30,
|
||||||
|
width=480,
|
||||||
|
height=640,
|
||||||
|
fourcc="MJPG",
|
||||||
|
rotation=Cv2Rotation.ROTATE_90,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
|
|
||||||
# TODO(aliberts, Steven, Pepijn): use gRPC calls instead of zmq?
|
# TODO(aliberts, Steven, Pepijn): use gRPC calls instead of zmq?
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
@@ -134,7 +133,9 @@ class LeKiwiClient(Robot):
|
|||||||
self.zmq_observation_socket = self.zmq_context.socket(zmq.PULL)
|
self.zmq_observation_socket = self.zmq_context.socket(zmq.PULL)
|
||||||
zmq_observations_locator = f"tcp://{self.remote_ip}:{self.port_zmq_observations}"
|
zmq_observations_locator = f"tcp://{self.remote_ip}:{self.port_zmq_observations}"
|
||||||
self.zmq_observation_socket.connect(zmq_observations_locator)
|
self.zmq_observation_socket.connect(zmq_observations_locator)
|
||||||
self.zmq_observation_socket.setsockopt(zmq.CONFLATE, 1)
|
# CONFLATE does not support multipart messages; a small receive queue plus
|
||||||
|
# the existing drain-to-latest loop keeps newest-only semantics.
|
||||||
|
self.zmq_observation_socket.setsockopt(zmq.RCVHWM, 2)
|
||||||
|
|
||||||
poller = zmq.Poller()
|
poller = zmq.Poller()
|
||||||
poller.register(self.zmq_observation_socket, zmq.POLLIN)
|
poller.register(self.zmq_observation_socket, zmq.POLLIN)
|
||||||
@@ -147,8 +148,8 @@ class LeKiwiClient(Robot):
|
|||||||
def calibrate(self) -> None:
|
def calibrate(self) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _poll_and_get_latest_message(self) -> str | None:
|
def _poll_and_get_latest_message(self) -> list[bytes] | None:
|
||||||
"""Polls the ZMQ socket for a limited time and returns the latest message string."""
|
"""Polls the ZMQ socket for a limited time and returns the latest message's frames."""
|
||||||
zmq = self._zmq
|
zmq = self._zmq
|
||||||
poller = zmq.Poller()
|
poller = zmq.Poller()
|
||||||
poller.register(self.zmq_observation_socket, zmq.POLLIN)
|
poller.register(self.zmq_observation_socket, zmq.POLLIN)
|
||||||
@@ -166,7 +167,7 @@ class LeKiwiClient(Robot):
|
|||||||
last_msg = None
|
last_msg = None
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
msg = self.zmq_observation_socket.recv_string(zmq.NOBLOCK)
|
msg = self.zmq_observation_socket.recv_multipart(zmq.NOBLOCK)
|
||||||
last_msg = msg
|
last_msg = msg
|
||||||
except zmq.Again:
|
except zmq.Again:
|
||||||
break
|
break
|
||||||
@@ -176,28 +177,27 @@ class LeKiwiClient(Robot):
|
|||||||
|
|
||||||
return last_msg
|
return last_msg
|
||||||
|
|
||||||
def _parse_observation_json(self, obs_string: str) -> RobotObservation | None:
|
def _parse_observation(self, frames: list[bytes]) -> RobotObservation | None:
|
||||||
"""Parses the JSON observation string."""
|
"""Parses a multipart observation: JSON header + one raw JPEG frame per camera."""
|
||||||
try:
|
try:
|
||||||
return json.loads(obs_string)
|
header = json.loads(frames[0])
|
||||||
except json.JSONDecodeError as e:
|
cam_names = header.pop("_cams")
|
||||||
logging.error(f"Error decoding JSON observation: {e}")
|
observation: RobotObservation = header
|
||||||
|
for cam_name, jpeg in zip(cam_names, frames[1:], strict=True):
|
||||||
|
observation[cam_name] = jpeg
|
||||||
|
return observation
|
||||||
|
except (json.JSONDecodeError, KeyError, ValueError) as e:
|
||||||
|
logging.error(f"Error decoding observation: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _decode_image_from_b64(self, image_b64: str) -> np.ndarray | None:
|
def _decode_image(self, jpeg: bytes) -> np.ndarray | None:
|
||||||
"""Decodes a base64 encoded image string to an OpenCV image."""
|
"""Decodes a raw JPEG buffer to an OpenCV image."""
|
||||||
if not image_b64:
|
if not jpeg:
|
||||||
return None
|
|
||||||
try:
|
|
||||||
jpg_data = base64.b64decode(image_b64)
|
|
||||||
np_arr = np.frombuffer(jpg_data, dtype=np.uint8)
|
|
||||||
frame = cv2.imdecode(np_arr, cv2.IMREAD_COLOR)
|
|
||||||
if frame is None:
|
|
||||||
logging.warning("cv2.imdecode returned None for an image.")
|
|
||||||
return frame
|
|
||||||
except (TypeError, ValueError) as e:
|
|
||||||
logging.error(f"Error decoding base64 image data: {e}")
|
|
||||||
return None
|
return None
|
||||||
|
frame = cv2.imdecode(np.frombuffer(jpeg, dtype=np.uint8), cv2.IMREAD_COLOR)
|
||||||
|
if frame is None:
|
||||||
|
logging.warning("cv2.imdecode returned None for an image.")
|
||||||
|
return frame
|
||||||
|
|
||||||
def _remote_state_from_obs(
|
def _remote_state_from_obs(
|
||||||
self, observation: RobotObservation
|
self, observation: RobotObservation
|
||||||
@@ -212,10 +212,10 @@ class LeKiwiClient(Robot):
|
|||||||
|
|
||||||
# Decode images
|
# Decode images
|
||||||
current_frames: dict[str, np.ndarray] = {}
|
current_frames: dict[str, np.ndarray] = {}
|
||||||
for cam_name, image_b64 in observation.items():
|
for cam_name, jpeg in observation.items():
|
||||||
if cam_name not in self._cameras_ft:
|
if cam_name not in self._cameras_ft:
|
||||||
continue
|
continue
|
||||||
frame = self._decode_image_from_b64(image_b64)
|
frame = self._decode_image(jpeg)
|
||||||
if frame is not None:
|
if frame is not None:
|
||||||
current_frames[cam_name] = frame
|
current_frames[cam_name] = frame
|
||||||
|
|
||||||
@@ -230,15 +230,15 @@ class LeKiwiClient(Robot):
|
|||||||
If no new data arrives or decoding fails, returns the last known values.
|
If no new data arrives or decoding fails, returns the last known values.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# 1. Get the latest message string from the socket
|
# 1. Get the latest message's frames from the socket
|
||||||
latest_message_str = self._poll_and_get_latest_message()
|
latest_frames = self._poll_and_get_latest_message()
|
||||||
|
|
||||||
# 2. If no message, return cached data
|
# 2. If no message, return cached data
|
||||||
if latest_message_str is None:
|
if latest_frames is None:
|
||||||
return self.last_frames, self.last_remote_state
|
return self.last_frames, self.last_remote_state
|
||||||
|
|
||||||
# 3. Parse the JSON message
|
# 3. Parse the multipart message
|
||||||
observation = self._parse_observation_json(latest_message_str)
|
observation = self._parse_observation(latest_frames)
|
||||||
|
|
||||||
# 4. If JSON parsing failed, return cached data
|
# 4. If JSON parsing failed, return cached data
|
||||||
if observation is None:
|
if observation is None:
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
@@ -44,7 +43,9 @@ class LeKiwiHost:
|
|||||||
self.zmq_cmd_socket.bind(f"tcp://*:{config.port_zmq_cmd}")
|
self.zmq_cmd_socket.bind(f"tcp://*:{config.port_zmq_cmd}")
|
||||||
|
|
||||||
self.zmq_observation_socket = self.zmq_context.socket(zmq.PUSH)
|
self.zmq_observation_socket = self.zmq_context.socket(zmq.PUSH)
|
||||||
self.zmq_observation_socket.setsockopt(zmq.CONFLATE, 1)
|
# CONFLATE does not support multipart messages; a 2-deep send queue keeps
|
||||||
|
# near-latest-only semantics and sheds stale observations during stalls.
|
||||||
|
self.zmq_observation_socket.setsockopt(zmq.SNDHWM, 2)
|
||||||
self.zmq_observation_socket.bind(f"tcp://*:{config.port_zmq_observations}")
|
self.zmq_observation_socket.bind(f"tcp://*:{config.port_zmq_observations}")
|
||||||
|
|
||||||
self.connection_time_s = config.connection_time_s
|
self.connection_time_s = config.connection_time_s
|
||||||
@@ -99,19 +100,23 @@ def main(cfg: LeKiwiServerConfig):
|
|||||||
|
|
||||||
last_observation = robot.get_observation()
|
last_observation = robot.get_observation()
|
||||||
|
|
||||||
# Encode ndarrays to base64 strings
|
# Send one multipart message: a JSON header frame (state + camera
|
||||||
for cam_key, _ in robot.cameras.items():
|
# order) followed by one raw JPEG frame per camera. Raw JPEG avoids
|
||||||
ret, buffer = cv2.imencode(
|
# the 33% base64 inflation of embedding binary data in JSON.
|
||||||
".jpg", last_observation[cam_key], [int(cv2.IMWRITE_JPEG_QUALITY), 90]
|
cam_keys = list(robot.cameras.keys())
|
||||||
|
jpeg_frames = []
|
||||||
|
for cam_key in cam_keys:
|
||||||
|
ret, jpeg = cv2.imencode(
|
||||||
|
".jpg", last_observation.pop(cam_key), [int(cv2.IMWRITE_JPEG_QUALITY), 90]
|
||||||
)
|
)
|
||||||
if ret:
|
jpeg_frames.append(jpeg if ret else b"")
|
||||||
last_observation[cam_key] = base64.b64encode(buffer).decode("utf-8")
|
header = {"_cams": cam_keys, **last_observation}
|
||||||
else:
|
|
||||||
last_observation[cam_key] = ""
|
|
||||||
|
|
||||||
# Send the observation to the remote agent
|
# Send the observation to the remote agent
|
||||||
try:
|
try:
|
||||||
host.zmq_observation_socket.send_string(json.dumps(last_observation), flags=zmq.NOBLOCK)
|
host.zmq_observation_socket.send_multipart(
|
||||||
|
[json.dumps(header).encode()] + jpeg_frames, flags=zmq.NOBLOCK
|
||||||
|
)
|
||||||
except zmq.Again:
|
except zmq.Again:
|
||||||
logging.info("Dropping observation, no client connected")
|
logging.info("Dropping observation, no client connected")
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ from lerobot.common.train_utils import (
|
|||||||
load_training_state,
|
load_training_state,
|
||||||
push_checkpoint_to_hub,
|
push_checkpoint_to_hub,
|
||||||
save_checkpoint,
|
save_checkpoint,
|
||||||
|
should_save_checkpoint,
|
||||||
update_last_checkpoint,
|
update_last_checkpoint,
|
||||||
)
|
)
|
||||||
from lerobot.common.wandb_utils import WandBLogger
|
from lerobot.common.wandb_utils import WandBLogger
|
||||||
@@ -616,7 +617,7 @@ def train(cfg: TrainPipelineConfig, accelerator: "Accelerator | None" = None):
|
|||||||
progbar.update(1)
|
progbar.update(1)
|
||||||
train_tracker.step()
|
train_tracker.step()
|
||||||
is_log_step = cfg.log_freq > 0 and step % cfg.log_freq == 0
|
is_log_step = cfg.log_freq > 0 and step % cfg.log_freq == 0
|
||||||
is_saving_step = step % cfg.save_freq == 0 or step == cfg.steps
|
is_saving_step = should_save_checkpoint(step, cfg.save_freq, cfg.steps)
|
||||||
is_env_eval_step = cfg.env_eval_freq > 0 and step % cfg.env_eval_freq == 0
|
is_env_eval_step = cfg.env_eval_freq > 0 and step % cfg.env_eval_freq == 0
|
||||||
is_eval_step = cfg.eval_steps > 0 and eval_dataloader is not None and step % cfg.eval_steps == 0
|
is_eval_step = cfg.eval_steps > 0 and eval_dataloader is not None and step % cfg.eval_steps == 0
|
||||||
|
|
||||||
|
|||||||
@@ -322,15 +322,16 @@ def test_get_color_sensor_prefers_rgb_camera():
|
|||||||
assert camera._get_color_sensor() is rgb
|
assert camera._get_color_sensor() is rgb
|
||||||
|
|
||||||
|
|
||||||
def test_get_color_sensor_falls_back_to_stereo_module():
|
def test_get_color_sensor_raises_without_dedicated_rgb_module():
|
||||||
"""D405 has no separate RGB module; color comes from Stereo Module."""
|
"""D405 has no separate RGB module; we refuse to touch the shared Stereo Module."""
|
||||||
config = RealSenseCameraConfig(serial_number_or_name="042")
|
config = RealSenseCameraConfig(serial_number_or_name="042")
|
||||||
camera = RealSenseCamera(config)
|
camera = RealSenseCamera(config)
|
||||||
|
|
||||||
stereo = _make_mock_sensor("Stereo Module")
|
stereo = _make_mock_sensor("Stereo Module")
|
||||||
_attach_mock_color_sensor(camera, stereo)
|
_attach_mock_color_sensor(camera, stereo)
|
||||||
|
|
||||||
assert camera._get_color_sensor() is stereo
|
with pytest.raises(RuntimeError, match="dedicated 'RGB Camera' module"):
|
||||||
|
camera._get_color_sensor()
|
||||||
|
|
||||||
|
|
||||||
def test_get_color_sensor_raises_with_available_sensors():
|
def test_get_color_sensor_raises_with_available_sensors():
|
||||||
|
|||||||
@@ -236,6 +236,42 @@ def test_clear_removes_video_frame_staging_dir(tmp_path):
|
|||||||
assert not video_staging_dir.exists()
|
assert not video_staging_dir.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_batched_encoding_staging_survives_save(tmp_path):
|
||||||
|
"""The post-save clear must NOT delete video staging frames.
|
||||||
|
|
||||||
|
With ``batch_encoding_size > 1`` the frames of already-saved episodes stay
|
||||||
|
on disk until the batch encode runs; the encoder deletes them afterwards.
|
||||||
|
A blanket switch of the post-save cleanup to ``camera_keys`` (as done in the
|
||||||
|
discard path) would silently break batched encoding.
|
||||||
|
"""
|
||||||
|
video_key = "observation.images.cam"
|
||||||
|
features = {
|
||||||
|
video_key: {
|
||||||
|
"dtype": "video",
|
||||||
|
"shape": (64, 96, 3),
|
||||||
|
"names": ["height", "width", "channels"],
|
||||||
|
},
|
||||||
|
"action": {"dtype": "float32", "shape": (2,), "names": None},
|
||||||
|
}
|
||||||
|
dataset = LeRobotDataset.create(
|
||||||
|
repo_id=DUMMY_REPO_ID,
|
||||||
|
fps=DEFAULT_FPS,
|
||||||
|
features=features,
|
||||||
|
root=tmp_path / "ds",
|
||||||
|
use_videos=True,
|
||||||
|
batch_encoding_size=2,
|
||||||
|
)
|
||||||
|
for _ in range(3):
|
||||||
|
dataset.add_frame(_make_frame(features))
|
||||||
|
|
||||||
|
staging_dir = dataset.writer._get_image_file_dir(0, video_key)
|
||||||
|
assert staging_dir.is_dir()
|
||||||
|
|
||||||
|
dataset.save_episode() # first of a batch of 2: no encoding yet
|
||||||
|
|
||||||
|
assert staging_dir.is_dir() and any(staging_dir.iterdir())
|
||||||
|
|
||||||
|
|
||||||
def test_finalize_is_idempotent(tmp_path):
|
def test_finalize_is_idempotent(tmp_path):
|
||||||
"""Calling finalize() twice does not raise."""
|
"""Calling finalize() twice does not raise."""
|
||||||
dataset = LeRobotDataset.create(
|
dataset = LeRobotDataset.create(
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from lerobot.common.train_utils import (
|
|||||||
save_checkpoint,
|
save_checkpoint,
|
||||||
save_training_state,
|
save_training_state,
|
||||||
save_training_step,
|
save_training_step,
|
||||||
|
should_save_checkpoint,
|
||||||
update_last_checkpoint,
|
update_last_checkpoint,
|
||||||
)
|
)
|
||||||
from lerobot.utils.constants import (
|
from lerobot.utils.constants import (
|
||||||
@@ -50,6 +51,18 @@ def test_get_step_identifier():
|
|||||||
assert get_step_identifier(456789, 1_000_000) == "0456789"
|
assert get_step_identifier(456789, 1_000_000) == "0456789"
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_save_checkpoint():
|
||||||
|
# Periodic checkpoints land on multiples of save_freq.
|
||||||
|
assert should_save_checkpoint(10, save_freq=10, total_steps=100) is True
|
||||||
|
assert should_save_checkpoint(5, save_freq=10, total_steps=100) is False
|
||||||
|
# The final step always saves, even when it is not a multiple of save_freq.
|
||||||
|
assert should_save_checkpoint(100, save_freq=30, total_steps=100) is True
|
||||||
|
# save_freq <= 0 disables periodic saving without raising ZeroDivisionError.
|
||||||
|
assert should_save_checkpoint(1, save_freq=0, total_steps=100) is False
|
||||||
|
assert should_save_checkpoint(100, save_freq=0, total_steps=100) is True
|
||||||
|
assert should_save_checkpoint(1, save_freq=-1, total_steps=100) is False
|
||||||
|
|
||||||
|
|
||||||
def test_get_step_checkpoint_dir():
|
def test_get_step_checkpoint_dir():
|
||||||
output_dir = Path("/checkpoints")
|
output_dir = Path("/checkpoints")
|
||||||
step_dir = get_step_checkpoint_dir(output_dir, 1000, 5)
|
step_dir = get_step_checkpoint_dir(output_dir, 1000, 5)
|
||||||
|
|||||||
Reference in New Issue
Block a user