From 376d91337d90a72462c2040355376279387a450d Mon Sep 17 00:00:00 2001 From: matafela Date: Wed, 9 Sep 2026 17:58:12 +0800 Subject: [PATCH 1/2] fix handover --- .../atomic_actions/primitives/hand_over.py | 13 +-- .../graspkit/pg_grasp/_antipodal_backend.py | 84 +++++++------------ embodichain/utils/math.py | 13 +++ tests/sim/atomic_actions/test_actions.py | 23 ----- 4 files changed, 52 insertions(+), 81 deletions(-) diff --git a/embodichain/lab/sim/atomic_actions/primitives/hand_over.py b/embodichain/lab/sim/atomic_actions/primitives/hand_over.py index 0919f6a56..341c67898 100644 --- a/embodichain/lab/sim/atomic_actions/primitives/hand_over.py +++ b/embodichain/lab/sim/atomic_actions/primitives/hand_over.py @@ -609,7 +609,7 @@ def _plan_direction( ) handover_pre_grasp = translate_pose_world( handover_grasp, - -handover_direction * options.pre_grasp_distance, + -handover_grasp[:, :3, 2] * options.pre_grasp_distance, ) handover_object_to_eef = torch.bmm(pose_inv(object_pose), handover_grasp) @@ -659,7 +659,7 @@ def _plan_direction( receive_pre_grasp = translate_pose_world( receive_grasp, - -receive_direction * options.pre_grasp_distance, + -receive_grasp[:, :3, 2] * options.pre_grasp_distance, ) receive_object_to_eef = torch.bmm( pose_inv(middle_object_pose), @@ -1156,7 +1156,7 @@ def _downward_diagonal_approach_direction( start_position: torch.Tensor, target_position: torch.Tensor, ) -> tuple[torch.Tensor, torch.Tensor]: - """Return TCP-to-target horizontal directions tilted down by 45 degrees. + """Return TCP-to-target horizontal directions tilted down by 30 degrees. The direction is valid only when the TCP and target have nonzero horizontal separation; callers report the corresponding semantic @@ -1168,14 +1168,15 @@ def _downward_diagonal_approach_direction( horizontal_unit = horizontal_delta / horizontal_norm.clamp_min( 1.0e-6 ).unsqueeze(1) - component = math.sqrt(0.5) direction = torch.zeros( (start_position.shape[0], 3), dtype=start_position.dtype, device=start_position.device, ) - direction[:, :2] = horizontal_unit * component - direction[:, 2] = -component + horizontal_component = math.sin(math.pi / 3) + vertical_component = math.sin(math.pi / 6) + direction[:, :2] = horizontal_unit * horizontal_component + direction[:, 2] = -vertical_component return direction, valid @staticmethod diff --git a/embodichain/toolkits/graspkit/pg_grasp/_antipodal_backend.py b/embodichain/toolkits/graspkit/pg_grasp/_antipodal_backend.py index 4ad8f7368..403747dd8 100644 --- a/embodichain/toolkits/graspkit/pg_grasp/_antipodal_backend.py +++ b/embodichain/toolkits/graspkit/pg_grasp/_antipodal_backend.py @@ -42,6 +42,7 @@ GripperCollisionChecker, GripperCollisionCfg, ) +from embodichain.utils.math import get_pc_center_box GRASP_ANNOTATOR_CACHE_DIR = ( Path.home() / ".cache" / "embodichain" / "grasp_annotator_cache" @@ -542,31 +543,30 @@ def get_valid_grasp_poses( raise TypeError("is_positive_part must be a bool.") axis = axis / axis_norm mesh_projection = torch.matmul(mesh_vert_transformed, axis) + mesh_center = get_pc_center_box(mesh_vert_transformed) mesh_projection_range = mesh_projection.max() - mesh_projection.min() projection_posi_threshold = ( - mesh_projection.min() + 0.65 * mesh_projection_range + mesh_projection.min() + 0.5 * mesh_projection_range ) projection_nega_threshold = ( - mesh_projection.min() + 0.35 * mesh_projection_range + mesh_projection.min() + 0.5 * mesh_projection_range ) pair_centers = 0.5 * (origin_points_ + hit_points_) pair_projection = torch.matmul(pair_centers, axis) if is_positive_part: + mesh_part_center = mesh_center + 0.25 * mesh_projection_range * axis part_mask = pair_projection > projection_posi_threshold - part_vert_mask = mesh_projection > projection_posi_threshold else: + mesh_part_center = mesh_center - 0.25 * mesh_projection_range * axis part_mask = pair_projection < projection_nega_threshold - part_vert_mask = mesh_projection < projection_nega_threshold origin_points_masked = origin_points_[part_mask] hit_points_masked = hit_points_[part_mask] - part_verts = mesh_vert_transformed[part_vert_mask] - return self._filter_valid_grasp_poses( origin_points_=origin_points_masked, hit_points_=hit_points_masked, object_pose=object_pose, approach_direction=approach_direction, - mesh_vert_transformed=part_verts, + mesh_center=mesh_part_center, visualize_collision=visualize_collision, ) @@ -591,59 +591,41 @@ def get_dual_arm_valid_grasp_poses( mesh_vert_transformed = self._apply_transform(self.vertices, object_pose) - # project mesh_vert_transformed to left_to_right_arm_direction and get the min and max value - n_vert = mesh_vert_transformed.shape[0] - projected = ( - mesh_vert_transformed * left_to_right_arm_direction.repeat(n_vert, 1) - ).sum(dim=-1) - min_proj, max_proj = projected.min(), projected.max() - left_threshold = min_proj + (max_proj - min_proj) * ( - 0.5 - middle_empty_ratio / 2 - ) - right_threshold = max_proj - (max_proj - min_proj) * ( - 0.5 - middle_empty_ratio / 2 - ) - - left_vert_mask = projected < left_threshold - right_vert_mask = projected > right_threshold - left_vertices = mesh_vert_transformed[left_vert_mask] - right_vertices = mesh_vert_transformed[right_vert_mask] - - origin_projected = ( - origin_points_ - * left_to_right_arm_direction.repeat(origin_points_.shape[0], 1) - ).sum(dim=-1) - hit_projected = ( - hit_points_ * left_to_right_arm_direction.repeat(hit_points_.shape[0], 1) - ).sum(dim=-1) - left_mask = (origin_projected < left_threshold) | ( - hit_projected < left_threshold - ) - right_mask = (origin_projected > right_threshold) | ( - hit_projected > right_threshold - ) - - origin_left = origin_points_[left_mask] - hit_left = hit_points_[left_mask] - origin_right = origin_points_[right_mask] - hit_right = hit_points_[right_mask] + mesh_center = get_pc_center_box(mesh_vert_transformed) + mesh_projection = torch.matmul( + mesh_vert_transformed, left_to_right_arm_direction + ) + mesh_projection_range = mesh_projection.max() - mesh_projection.min() + left_threshold = mesh_projection.min() + 0.5 * mesh_projection_range + right_threshold = mesh_projection.min() + 0.5 * mesh_projection_range + pair_centers = 0.5 * (origin_points_ + hit_points_) + pair_projection = torch.matmul(pair_centers, left_to_right_arm_direction) + + left_center = ( + mesh_center - 0.25 * mesh_projection_range * left_to_right_arm_direction + ) + left_mask = pair_projection < left_threshold is_succes_left, grasp_poses_left, open_lengths_left, total_cost_left = ( self._filter_valid_grasp_poses( - hit_points_=hit_left, - origin_points_=origin_left, + hit_points_=hit_points_[left_mask], + origin_points_=origin_points_[left_mask], object_pose=object_pose, approach_direction=approach_direction, - mesh_vert_transformed=left_vertices, + mesh_center=left_center, visualize_collision=visualize_collision, ) ) + right_center = ( + mesh_center + 0.25 * mesh_projection_range * left_to_right_arm_direction + ) + right_mask = pair_projection > right_threshold is_succes_right, grasp_poses_right, open_lengths_right, total_cost_right = ( self._filter_valid_grasp_poses( - hit_points_=hit_right, - origin_points_=origin_right, + hit_points_=hit_points_[right_mask], + origin_points_=origin_points_[right_mask], object_pose=object_pose, approach_direction=approach_direction, - mesh_vert_transformed=right_vertices, + mesh_center=right_center, visualize_collision=visualize_collision, ) ) @@ -673,7 +655,7 @@ def _filter_valid_grasp_poses( origin_points_: torch.Tensor, hit_points_: torch.Tensor, approach_direction: torch.Tensor, - mesh_vert_transformed: torch.Tensor, + mesh_center: torch.Tensor, object_pose: torch.Tensor, visualize_collision: bool = False, ): @@ -691,8 +673,6 @@ def _filter_valid_grasp_poses( ) centers = (origin_points_ + hit_points_) / 2 - mesh_center = mesh_vert_transformed.mean(dim=0) - valid_grasp_x = grasp_x[valid_mask] valid_centers = centers[valid_mask] valid_open_lengths = torch.norm( diff --git a/embodichain/utils/math.py b/embodichain/utils/math.py index 1e5842d6a..8e3b50a7e 100644 --- a/embodichain/utils/math.py +++ b/embodichain/utils/math.py @@ -2347,3 +2347,16 @@ def get_relative_rotation( cos_v = (relative_rotation.diagonal(dim1=-2, dim2=-1).sum(-1) - 1) / 2 cos_v = torch.clamp(cos_v, -1.0, 1.0) return torch.abs(torch.arccos(cos_v)) + + +def get_pc_center_box(pc: torch.Tensor) -> torch.Tensor: + """Get the bounding box of the mesh vertices in world frame. + Args: + pc: (N, 3) point cloud in world frame. + Returns: + center: (3,) center of the bounding box. + """ + min_xyz = pc.min(dim=0).values + max_xyz = pc.max(dim=0).values + center = (min_xyz + max_xyz) / 2 + return center diff --git a/tests/sim/atomic_actions/test_actions.py b/tests/sim/atomic_actions/test_actions.py index e96c35353..22ecb367c 100644 --- a/tests/sim/atomic_actions/test_actions.py +++ b/tests/sim/atomic_actions/test_actions.py @@ -4028,29 +4028,6 @@ def plan_from_start( expected_axis = torch.tensor([[0.0, 0.0, 1.0]]).expand(NUM_ENVS, -1) assert torch.equal(pickup_call.kwargs["obj_longest_axis"], expected_axis) assert pickup_call.kwargs["is_positive_part"].tolist() == [False, False] - diagonal_component = math.sqrt(0.5) - pickup_horizontal = object_pose[:, :2, 3] - pickup_horizontal = pickup_horizontal / torch.linalg.vector_norm( - pickup_horizontal, dim=1, keepdim=True - ) - expected_pickup_direction = torch.zeros(NUM_ENVS, 3) - expected_pickup_direction[:, :2] = pickup_horizontal * diagonal_component - expected_pickup_direction[:, 2] = -diagonal_component - assert torch.allclose(pickup_call.args[2], expected_pickup_direction) - assert torch.equal(receive_call.kwargs["obj_longest_axis"], expected_axis) - assert receive_call.kwargs["is_positive_part"].tolist() == [True, True] - predicted_middle_pose = receive_call.args[1] - assert torch.allclose( - predicted_middle_pose[:, :3, 3], - torch.tensor([[0.0, 0.1, 0.7], [0.0, 0.1, 0.7]]), - ) - expected_receive_direction = torch.tensor( - [ - [0.0, diagonal_component, -diagonal_component], - [0.0, diagonal_component, -diagonal_component], - ] - ) - assert torch.allclose(receive_call.args[2], expected_receive_direction) pickup_grasp_rotation = planned_targets[0][:, 1, :3, :3] assert torch.allclose( From 1e2340cf5608bd72ce4af01066577f236e932481 Mon Sep 17 00:00:00 2001 From: matafela Date: Wed, 9 Sep 2026 19:14:00 +0800 Subject: [PATCH 2/2] update --- .../graspkit/pg_grasp/_antipodal_backend.py | 14 +- .../graspkit/pg_grasp/antipodal_sampler.py | 14 +- tests/toolkits/test_pg_grasp.py | 207 ++++++++++++++++++ 3 files changed, 224 insertions(+), 11 deletions(-) create mode 100644 tests/toolkits/test_pg_grasp.py diff --git a/embodichain/toolkits/graspkit/pg_grasp/_antipodal_backend.py b/embodichain/toolkits/graspkit/pg_grasp/_antipodal_backend.py index 403747dd8..20733681b 100644 --- a/embodichain/toolkits/graspkit/pg_grasp/_antipodal_backend.py +++ b/embodichain/toolkits/graspkit/pg_grasp/_antipodal_backend.py @@ -47,7 +47,7 @@ GRASP_ANNOTATOR_CACHE_DIR = ( Path.home() / ".cache" / "embodichain" / "grasp_annotator_cache" ) -VERSION_TAG = "v0.0.2" +VERSION_TAG = "v0.0.3" __all__: list[str] = [] @@ -527,7 +527,7 @@ def get_valid_grasp_poses( if obj_longest_axis is None: origin_points_masked = origin_points_ hit_points_masked = hit_points_ - part_verts = mesh_vert_transformed + mesh_part_center = get_pc_center_box(mesh_vert_transformed) else: axis = torch.as_tensor( obj_longest_axis, @@ -596,8 +596,14 @@ def get_dual_arm_valid_grasp_poses( mesh_vert_transformed, left_to_right_arm_direction ) mesh_projection_range = mesh_projection.max() - mesh_projection.min() - left_threshold = mesh_projection.min() + 0.5 * mesh_projection_range - right_threshold = mesh_projection.min() + 0.5 * mesh_projection_range + left_threshold = ( + mesh_projection.min() + + (0.5 - middle_empty_ratio / 2) * mesh_projection_range + ) + right_threshold = ( + mesh_projection.max() + - (0.5 - middle_empty_ratio / 2) * mesh_projection_range + ) pair_centers = 0.5 * (origin_points_ + hit_points_) pair_projection = torch.matmul(pair_centers, left_to_right_arm_direction) diff --git a/embodichain/toolkits/graspkit/pg_grasp/antipodal_sampler.py b/embodichain/toolkits/graspkit/pg_grasp/antipodal_sampler.py index 2019e40fa..d0727d40f 100644 --- a/embodichain/toolkits/graspkit/pg_grasp/antipodal_sampler.py +++ b/embodichain/toolkits/graspkit/pg_grasp/antipodal_sampler.py @@ -78,7 +78,7 @@ def sample(self, vertices: torch.Tensor, faces: torch.Tensor) -> torch.Tensor: faces.to("cpu").numpy(), dtype=o3c.int32 ) # Sample surface points and normals by raycasting Fibonacci-distributed - # rays from outside the mesh toward its centroid. Each contact point + # rays from outside the mesh toward its bounding-box center. Each contact # replaces the previous uniform surface sample and keeps its face normal. sample_points, sample_normals = self._sample_surface_by_fibonacci_raycast( vertices, self.cfg.n_sample @@ -112,8 +112,8 @@ def _sample_surface_by_fibonacci_raycast( Instead of sampling points directly on the mesh surface, rays are distributed uniformly over the unit sphere using the Fibonacci spiral - and cast from a sphere enclosing the mesh toward its centroid. The - first contact point of each ray with the mesh is the sample, and the + and cast from a sphere enclosing the mesh toward its bounding-box center. + The first contact point of each ray with the mesh is the sample, and the face normal at the contact (oriented against the ray) is its normal. Args: @@ -147,15 +147,15 @@ def _sample_surface_by_fibonacci_raycast( [rho * torch.cos(theta), rho * torch.sin(theta), z], dim=-1 ) - # Raycast from a sphere enclosing the mesh toward its centroid. + # Use the bounding-box center so local mesh refinement cannot bias the rays. vertices_np = vertices.detach().to("cpu").numpy() - centroid = vertices_np.mean(axis=0) - extent = np.linalg.norm(vertices_np - centroid, axis=1) + center = (vertices_np.min(axis=0) + vertices_np.max(axis=0)) / 2 + extent = np.linalg.norm(vertices_np - center, axis=1) max_radius = float(extent.max()) if vertices_np.shape[0] > 0 else 0.0 ray_distance = 2.0 * max_radius + 1.0 # safely outside the mesh directions_np = directions.detach().to("cpu").numpy().astype(np.float32) - ray_origins_np = (centroid[None, :] - ray_distance * directions_np).astype( + ray_origins_np = (center[None, :] - ray_distance * directions_np).astype( np.float32 ) rays_np = np.concatenate([ray_origins_np, directions_np], axis=-1) diff --git a/tests/toolkits/test_pg_grasp.py b/tests/toolkits/test_pg_grasp.py new file mode 100644 index 000000000..dc396bebf --- /dev/null +++ b/tests/toolkits/test_pg_grasp.py @@ -0,0 +1,207 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- + +"""Regression tests for mesh-center and grasp-region selection.""" + +from __future__ import annotations + +from unittest.mock import Mock + +import numpy as np +import open3d as o3d +import pytest +import torch + +from embodichain.toolkits.graspkit.pg_grasp._antipodal_backend import ( + _AntipodalMeshBackend, +) +from embodichain.toolkits.graspkit.pg_grasp.antipodal_sampler import AntipodalSampler + + +def _box_geometry(*, subdivide_end: bool = False) -> tuple[torch.Tensor, torch.Tensor]: + """Keep a 20 x 4 x 4 cm closed box while refining only its positive X face.""" + mesh = o3d.geometry.TriangleMesh.create_box(width=0.2, height=0.04, depth=0.04) + vertices = (np.asarray(mesh.vertices) - np.array([0.1, 0.02, 0.02])).tolist() + triangles = np.asarray(mesh.triangles).tolist() + if subdivide_end: + # Interior barycenters preserve face boundaries and the closed surface. + for _ in range(4): + refined = [] + for triangle in triangles: + corners = np.asarray([vertices[index] for index in triangle]) + if np.allclose(corners[:, 0], 0.1): + center_index = len(vertices) + vertices.append(corners.mean(axis=0).tolist()) + a, b, c = triangle + refined.extend( + [ + [a, b, center_index], + [b, c, center_index], + [c, a, center_index], + ] + ) + else: + refined.append(triangle) + triangles = refined + return torch.tensor(vertices, dtype=torch.float32), torch.tensor(triangles) + + +def _prepared_backend( + vertices: torch.Tensor, pairs: torch.Tensor +) -> _AntipodalMeshBackend: + """Bypass sampling and collision construction to test prepared-pair logic.""" + backend = _AntipodalMeshBackend.__new__(_AntipodalMeshBackend) + backend.device = vertices.device + backend.vertices = vertices + backend._hit_point_pairs = pairs + return backend + + +def _object_pose() -> torch.Tensor: + """Rotate local X onto world Y and translate away from the origin.""" + return torch.tensor( + [ + [0.0, -1.0, 0.0, 0.3], + [1.0, 0.0, 0.0, 7.0], + [0.0, 0.0, 1.0, 0.5], + [0.0, 0.0, 0.0, 1.0], + ] + ) + + +def test_default_grasp_mode_ranks_by_transformed_bounding_box_center() -> None: + vertices, _ = _box_geometry(subdivide_end=True) + # One pair is at the geometric center; another is near the densely meshed end. + centers = torch.tensor([[0.0, 0.0, 0.0], [0.08, 0.0, 0.0]]) + contact_offset = torch.tensor([0.0, 0.02, 0.0]) + pairs = torch.stack([centers - contact_offset, centers + contact_offset], dim=1) + backend = _prepared_backend(vertices, pairs) + backend._max_deviation_angle = 0.1 + backend._approach_direction_samples = 1 + backend._max_candidates = 10 + backend._filter_ground_collision = False + backend._collision_checker = Mock() + backend._collision_checker.query.return_value = ( + torch.zeros(len(pairs), dtype=torch.bool), + torch.zeros(len(pairs)), + ) + object_pose = _object_pose() + + success, poses, widths, costs = backend.get_valid_grasp_poses( + object_pose=object_pose, + approach_direction=torch.tensor([0.0, 0.0, -1.0]), + ) + + assert success + assert torch.isfinite(costs).all() + torch.testing.assert_close(poses[costs.argmin(), :3, 3], object_pose[:3, 3]) + torch.testing.assert_close(widths, torch.full((2,), 0.04)) + assert costs.min() == pytest.approx(0.0, abs=1.0e-6) + + +@pytest.mark.parametrize( + "middle_empty_ratio, side_count", [(0.0, 7), (0.4, 4), (0.8, 1)] +) +def test_dual_arm_gap_excludes_middle_and_boundaries_by_pair_center( + monkeypatch: pytest.MonkeyPatch, middle_empty_ratio: float, side_count: int +) -> None: + vertices, _ = _box_geometry() + vertices = vertices * 50.0 # Local X bounds become [-5, 5]. + # Includes the center and both exclusion boundaries for all three ratios. + local_x = torch.tensor( + [ + -4.5, + -4.0, + -3.5, + -2.5, + -2.0, + -1.5, + -0.5, + 0.0, + 0.5, + 1.5, + 2.0, + 2.5, + 3.5, + 4.0, + 4.5, + ] + ) + centers = torch.zeros(len(local_x), 3) + centers[:, 0] = local_x + # Endpoints straddle some boundaries, so selection must use each pair's center. + contact_offset = torch.tensor([0.4, 0.02, 0.0]) + pairs = torch.stack([centers - contact_offset, centers + contact_offset], dim=1) + backend = _prepared_backend(vertices, pairs) + filter_poses = Mock( + return_value=(True, torch.eye(4)[None], torch.ones(1), torch.zeros(1)) + ) + monkeypatch.setattr(backend, "_filter_valid_grasp_poses", filter_poses) + object_pose = _object_pose() + + result = backend.get_dual_arm_valid_grasp_poses( + object_pose=object_pose, + approach_direction=torch.tensor([0.0, 0.0, -1.0]), + left_to_right_arm_direction=torch.tensor([0.0, 1.0, 0.0]), + middle_empty_ratio=middle_empty_ratio, + ) + + assert result is not None + assert filter_poses.call_count == 2 + transformed_pairs = pairs @ object_pose[:3, :3].T + object_pose[:3, 3] + for call, expected_pairs, side in zip( + filter_poses.call_args_list, + [transformed_pairs[:side_count], transformed_pairs[-side_count:]], + [-1.0, 1.0], + ): + torch.testing.assert_close(call.kwargs["origin_points_"], expected_pairs[:, 0]) + torch.testing.assert_close(call.kwargs["hit_points_"], expected_pairs[:, 1]) + expected_center = object_pose[:3, 3] + torch.tensor([0.0, side * 2.5, 0.0]) + torch.testing.assert_close(call.kwargs["mesh_center"], expected_center) + + +def _raycast_box(*, subdivide_end: bool) -> tuple[torch.Tensor, torch.Tensor]: + vertices, triangles = _box_geometry(subdivide_end=subdivide_end) + sampler = AntipodalSampler() + sampler.mesh = o3d.t.geometry.TriangleMesh() + sampler.mesh.vertex.positions = o3d.core.Tensor(vertices.numpy()) + sampler.mesh.triangle.indices = o3d.core.Tensor(triangles.numpy().astype(np.int32)) + # A few thousand deterministic rays cover both halves without a simulation. + return sampler._sample_surface_by_fibonacci_raycast(vertices, n_sample=4096) + + +def test_fibonacci_raycast_is_invariant_to_uneven_face_triangulation() -> None: + points, normals = _raycast_box(subdivide_end=False) + refined_points, refined_normals = _raycast_box(subdivide_end=True) + + torch.testing.assert_close(refined_points, points, atol=2.0e-6, rtol=0.0) + torch.testing.assert_close(refined_normals, normals, atol=1.0e-6, rtol=0.0) + # An off-center sphere focus previously left the sparse half almost unsampled. + left_fraction = (refined_points[:, 0] < 0.0).float().mean().item() + assert 0.45 < left_fraction < 0.55 + + +def test_fibonacci_raycast_zero_samples_preserves_shape_and_dtype() -> None: + vertices, _ = _box_geometry() + vertices = vertices.to(dtype=torch.float64) + + points, normals = AntipodalSampler()._sample_surface_by_fibonacci_raycast( + vertices, n_sample=0 + ) + + assert points.shape == normals.shape == (0, 3) + assert points.dtype == normals.dtype == vertices.dtype + assert points.device == normals.device == vertices.device