In plain words
What the system does, and the one design choice that makes it more than a scripted animation.
Imagine a robot arm bolted to a table. Seven red balls are scattered in front of it and there is an orange box off to one side. The job is: pick up every ball and put it in the box, without a human telling the arm where anything is.
The naive way to build this is to write the seven ball positions into the code and command the arm to those coordinates. That would work, and it would prove nothing — the arm would perform the same dance whether the balls were there or not. This project deliberately does not do that.
Instead the positions travel a full loop. A fake camera draws a picture of the table. A separate program looks at that picture, finds the red blobs, and converts pixel coordinates back into metres. A third program (written in C++) checks whether the arm's joints can physically reach each of those metre coordinates and drops the ones that fail. Only what comes out of the far end of that chain is ever grasped. The arm literally does not know where the balls "really" are.
Perception drives motion — the ground-truth ball positions exist only to draw the picture, and the state machine subscribes to the validator's output, never to the truth.
Why a "fake" camera counts
The camera node is not a real driver, it is an OpenCV drawing routine: grey background, grid lines, an
orange rectangle for the box, red filled circles for the balls. But it publishes on the exact same ROS 2
topic type a real camera driver uses (sensor_msgs/Image) with real intrinsics on
/camera/camera_info. So every node downstream of it is the code you would ship. Replacing
the simulator with a RealSense driver means changing one line in the launch file and nothing else.
What this does not give you is robustness. There is no sensor noise, no motion blur, no lighting variation, no shadow. Detection here is HSV thresholding on a synthetic frame — it works perfectly because the image is perfect. That limitation is stated in the repository's own README rather than glossed over, and it is worth repeating here.
Terms used on this page
How data moves through the system
The five nodes, the topics between them, and the feedback loop that closes when a ball is removed.
Six programs run at once and talk over ROS 2 topics. Read the strip left to right — each box is one node, and the labels underneath are the topics carrying data between them.
When a ball is grasped, the state machine publishes remove:ball_3 on
/scene_object_updates. The camera simulator receives that and stops drawing ball 3. On the
next frame the detector sees six balls instead of seven, the validator forwards six, and the state
machine has one fewer target. That is what makes it a loop rather than a list — the picture changes
because the arm acted on it.
Every file, and what it is for
Every source file in both packages, how big it is, and the single job it owns.
A ROS 2 workspace has a fixed shape: source packages under src/, and
build/, install/, log/ generated by the build tool
(colcon). Only src/ is written by hand.
Package 1 — simple_moveit_demo (Python, ament_python)
| File | Lines | What it does |
|---|---|---|
| simple_moveit_demo/ | 149 | The synthetic camera. Subscribes to /scene_balls (a latched topic, so it gets the
layout even if it started late) and to removal messages. Every 100 ms it composes a 640×480 image:
grey base, 40 px grid, the orange box outline, then one filled red circle per visible ball, and
publishes it together with a CameraInfo carrying fx=fy=520,
cx=320, cy=240. A threading.Lock guards the ball list because
subscription callbacks and the timer run on different threads. |
| simple_moveit_demo/ | 144 | The perception node. Converts the image to HSV, builds a red mask from two hue windows
(red wraps around the hue axis at 0/180, so one window cannot cover it), morphologically opens then
closes to remove speckle and fill holes, finds external contours, drops anything under 400 px²,
takes the image moments to get a sub-pixel centroid, and back-projects that centroid onto the table
plane. Publishes a MarkerArray of spheres plus an annotated debug image. |
| simple_moveit_demo/ | 346 | The controller and the biggest file. Declares all 28 parameters, builds the grasp planner and scene manager, waits for MoveIt's action server, then runs the pick loop on a background thread. Contains both motion strategies (Cartesian and OMPL), the trajectory retimer, and a generic action-goal helper that turns ROS 2's callback-based action API into a blocking call. |
| simple_moveit_demo/ | 190 | Not a node — a plain class owned by the controller. Publishes CollisionObject
messages so MoveIt knows the table, the seven ball spheres and the five box panels are solid and
must be planned around. Also colours them via PlanningScene diffs, maps a detected
(x, y) back to a collision-object id by nearest neighbour within 8 cm, and re-adds a ball inside
the box after placement using a 2×2 slot grid that stacks into a second layer past four balls. |
| simple_moveit_demo/ | 67 | Pure geometry, no ROS. Given an object at (x, y), returns three stamped poses: pre-grasp 20 cm above, grasp at 4 cm above (floored at 16 cm so the wrist never dives into the table), and lift at 28 cm. Every pose carries roll = 180° so the gripper points straight down. Includes a hand-written Euler-to-quaternion conversion rather than pulling in a transform library for four lines of algebra. |
| simple_moveit_demo/ | 61 | A 40-line replacement for cv_bridge, the standard image conversion library.
Converts a NumPy array to sensor_msgs/Image and back for the single encoding this
package uses (bgr8). Handles the row-stride subtlety — publishers may pad rows, and
some leave step at 0 — and raises loudly on any other encoding. |
| launch/ | 198 | Starts all eleven nodes in the right order with the right parameters. Described in full below. |
| config/ | 53 | Ball layout, motion tuning, grasp geometry, box position, and the validator's reach bounds. |
| config/ | 35 | Camera intrinsics, mount pose, ball render radius, and the two HSV red windows. |
| test/ | 92 | Fifteen contract tests for the one file that reimplements a standard library: exact round-trip, padded stride, zero stride, truncated buffer, wrong encoding. No node, no camera, pure NumPy. |
| rviz/ | — | Saved RViz layout so the visualiser opens with the right displays already enabled. |
| setup.py · package.xml | 36 · 48 | Build metadata. setup.py declares the three console entry points that become
ros2 run executables and installs launch/config/rviz files into the share directory;
package.xml declares the dependency list that rosdep resolves. |
Package 2 — moveit_grasp_utils (C++, ament_cmake)
| File | What it does |
|---|---|
| include/…/ |
A header-only class with no ROS dependency at all — just <cmath> and
<string>. Holds four bounds in a Params struct and exposes
is_reachable(x, y, z) (marked noexcept) plus
rejection_reason(...) which returns a human-readable string for the log. Separating
the geometry from the node means the rule can be unit-tested or reused without spinning ROS. |
| src/ |
The ROS wrapper. Declares the four bounds as parameters, subscribes to
/detected_objects, filters, publishes /validated_targets, and on a 2-second
timer draws two translucent cylinders in RViz so you can see the reachable shell and the excluded
inner column. |
| CMakeLists.txt · package.xml | C++17, -Wall -Wextra -Wpedantic, one executable, install rules for the binary and the
public header, and the ament linters wired into BUILD_TESTING. |
Workspace root
- README.md
- The accurate, current description of the system — architecture diagram, decision table, parameters, and the honest-simulation note.
- CONTEXT.md
- A running work log: current status, verified run, and a newest-first list of solved problems. Useful because it records what was removed, not just what was added.
- build/ install/ log/
- Generated by
colcon.install/setup.bashis the file you source to make the packages visible toros2 runandros2 launch. - src/simple_moveit_demo/README.md
- Stale. It documents an older, larger version of this package — YOLOv8, ArUco markers, a depth estimator, colour-sorted bins, a seven-state machine. All of that was deliberately deleted (see the log in
CONTEXT.md). Read the root README instead.
The vision half, step by step
From a drawn image to a metre coordinate — HSV masking, contour filtering, and the pinhole back-projection.
Why red is detected with two hue ranges, not one
HSV separates colour (hue) from brightness and vividness, which is why it is used for colour
thresholding instead of raw RGB — a red ball in shadow and the same ball in light have very different
RGB values but nearly the same hue. OpenCV stores hue as 0–179. Red sits at the seam: pure red is hue 0,
and slightly-purple red is hue 179. A single window [0, 10] would miss half of it. So the
node builds two masks and ORs them together:
hsv_red_lo1: [0, 120, 80] hsv_red_hi1: [10, 255, 255]
hsv_red_lo2: [170, 120, 80] hsv_red_hi2: [180, 255, 255]
The second and third numbers matter as much as the hue: saturation ≥ 120 excludes washed-out greys that happen to lean red, and value ≥ 80 excludes near-black pixels where hue is meaningless noise.
Open, then close
The raw mask is noisy at the edges. Two morphological operations clean it with a 7×7 elliptical kernel:
- MORPH_OPEN
- Erode then dilate. Removes isolated specks smaller than the kernel without shrinking the real blobs.
- MORPH_CLOSE
- Dilate then erode. Fills small holes inside a blob — such as the white outline stroke drawn around each rendered ball.
Order matters. Closing first would fuse two nearby specks into one fake object before opening had a chance to delete them.
Centroid from image moments, not from the bounding box
Once a contour survives the 400 px² area filter, the centre is computed from moments:
u = m10/m00, v = m01/m00. That is the area-weighted average pixel position —
a sub-pixel value, and it is robust to a ragged edge in a way that the centre of a bounding rectangle
is not. For a circular ball the difference is small; for a partly-clipped blob it is not.
Back-projection, and where the intrinsics come from
The two lines that turn pixels into metres are, in the code:
wy = cam_y + (u - cx) * cam_h / fx
wx = cam_x - (v - cy) * cam_h / fy
wz = 0.0 # DETECT_Z — the table plane assumption
Note the sign flip and the axis swap. Image u grows to the right and maps to the robot's
y; image v grows downward and maps to the robot's x with a minus
sign. Getting this wrong produces a system that runs cleanly and reaches for the mirror image of every
ball, which is exactly the class of bug that never raises an exception.
The detector starts with hard-coded defaults for fx, fy, cx, cy but overwrites them the
moment a CameraInfo message arrives on /camera/camera_info. That is how a real
driver publishes calibration, so the node reads it the way it would read a real one. Only the
extrinsics — where the camera is mounted — come from the YAML, and they are shared with the
simulator so both ends agree by construction.
Why cv_bridge was replaced by 40 lines
cv_bridge is the standard ROS library for converting between OpenCV arrays and
sensor_msgs/Image. It exists to handle roughly twenty pixel encodings from C++, and it ships
as a compiled extension linked against a specific NumPy major version. In this package both ends of the
image path are internal and both are bgr8, so the conversion is a reshape and a copy.
The tricky part is step, the row stride in bytes. A publisher may pad rows, so
step is not always width × 3, and some publishers leave it at 0. The code
treats 0 as unpadded rather than dividing by it — dividing would reshape to a zero-width image and fail
much later with an error about something else entirely. It then validates that the stride is a whole
number of pixels and that the buffer is long enough, slices off the padding, and copies so OpenCV
receives a writable contiguous array.
The trigger was a NumPy 2 incompatibility, but that error will not appear on a stock Humble install,
whose system NumPy is 1.x — so it is not the justification. The justification is that the workspace no
longer carries a compiled dependency, or a workspace-wide version pin, that it does not need. The file's
own docstring says cv_bridge should come back the moment a real camera does, because most
drivers publish rgb8 and RealSense depth is 16UC1.
The C++ reachability filter
A Franka Panda cannot reach everywhere. The validator approximates its workspace as a cylinder and applies four bounds plus one extra rule:
| Test | Bound | Reason |
|---|---|---|
| height | −0.05 … 0.98 m | Below the table surface or above the arm's vertical span. |
| horizontal radius | 0.18 … 0.82 m | √(x² + y²) from the base axis. Too close is folded onto itself; too far is simply out of reach. |
| singularity column | r < 0.08 m | Directly above the base the wrist axes line up and inverse kinematics becomes ill-conditioned — many joint solutions map to nearly the same pose, and the planner produces wild motions. |
Rejections are logged with a reason string rather than silently dropped, and the surviving points are
republished as a PoseArray with identity orientation — the validator has an opinion about
where, not about how. The two RViz cylinders it draws every two seconds are the same
bounds made visible, so an out-of-reach ball is obvious on screen before you read a log line.
This node exists in C++ rather than Python for a stated reason: it runs on every camera frame at 10 Hz and must never become the reason the controller waits. It is also the natural place to demonstrate a multi-language workspace, which is what real ROS 2 systems are.
The motion half, step by step
How a validated coordinate becomes a grasp: pose construction, Cartesian planning, the fallback, and the state machine.
The state machine
Five real states plus a fatal one. The loop lives in a while inside
_run(), not in recursion — a recursive state machine that runs for seven balls and several
retries would grow a stack frame per transition for no reason.
Each cycle: wait for a validated target that maps to a ball still on the table (with an eight-second grace period, after which any remaining unreachable balls are reported and the run ends cleanly), build the three grasp poses, show a "ghost" marker, remove the collision ball, move, place, repeat.
The ghost-ball trick
There is a genuine conflict here. MoveIt refuses to plan a path that ends inside a collision object —
and the grasp pose is, by definition, at the ball. Attaching the ball to the gripper
(AttachedCollisionObject, the textbook answer) was tried and caused self-collision failures
against panda_link7 plus a green sphere artefact stuck to the gripper.
The solution splits the ball into two things. The collision ball is removed just before the
approach so the planner has a clear goal. A visual ball — an RViz Marker, which has
no physics and no collision meaning — is shown in the same place so the operator still sees it during
the approach, then deleted the instant the gripper arrives. After the place motion succeeds, a fresh
collision sphere is added inside the box. The result on screen is what a viewer expects, and the planner
never fights an obstacle that is about to be picked up.
Cartesian first, OMPL second
Two ways to get the gripper from A to B:
- Cartesian path
- Service
/compute_cartesian_path. Interpolates the end-effector along a straight line in 1 cm steps and solves inverse kinematics at each one. Predictable, human-legible motion — glide over the ball, straight down; straight up, across to the box. Returns afraction: how much of the requested path it actually managed. Below 0.95 the result is rejected. - OMPL RRTConnect
- Action
/move_action. A sampling-based planner that finds some collision-free path through joint space. It will always find one if it exists, but the path can include large, alarming wrist excursions because nothing in the objective asks for a tidy route. Used only as a fallback.
The controller tries Cartesian, and on failure falls back with the same target expressed as a constrained OMPL goal. That ordering is why the demo looks deliberate rather than chaotic.
Retiming the trajectory afterwards
The OMPL path lets you request max_velocity_scaling_factor. Humble's
GetCartesianPath service has no such field — it returns a trajectory timed at full speed.
So the code stretches the timing after planning:
t_new = t_old / scale
velocities ×= scale
accelerations ×= scale²
With cartesian_speed_scale = 0.5 everything takes twice as long. The acceleration term is
squared because acceleration has units of distance per time squared — scaling only the velocities
would hand the controller a trajectory whose numbers contradict each other, and a well-behaved controller
will reject it.
The goal constraints
An OMPL goal is not a pose, it is a set of constraints. The controller builds two:
- Position — a box-shaped region 4 cm on a side for a grasp, 6 cm for a place,
centred on the target, attached to link
panda_link8. A tolerance region rather than a point, because demanding an exact pose from a 7-axis arm makes the IK solver work far harder for no benefit. - Orientation — tilt tolerance 0.25 rad on the x and y axes so the gripper stays pointing down, but 3.14 rad on z, which is to say free. A sphere looks identical from every angle of wrist rotation, so constraining that rotation only removes solutions the planner could have used.
All grasps use yaw = 0° so the wrist keeps the same orientation from grasp to place to the next grasp, which gives OMPL simpler joint-space paths to find.
Making an asynchronous action look synchronous
ROS 2 actions are two-stage and callback-driven: send a goal, get a handle telling you whether it was
accepted, then register another callback for the result. Written literally, a five-step pick sequence
becomes five levels of nested callbacks. _send_goal() collapses that using a
threading.Event and a one-element list to smuggle the result out of the closure, then blocks
with a 60-second timeout. The state machine reads as straight-line code.
This only works because the node spins on a MultiThreadedExecutor with the action clients
in a ReentrantCallbackGroup, and because the pick loop runs on its own thread. On a
single-threaded executor the blocking wait would prevent the very callback it is waiting for from ever
running — a classic ROS 2 deadlock.
The launch file, line by line
What the launch file starts, in what order, and why each delay exists.
One command starts eleven processes. A ROS 2 launch file is Python that returns a
LaunchDescription — a list of things to start, in order, with parameters.
1 — Domain isolation, first
domain_id_arg = DeclareLaunchArgument('domain_id', default_value='42')
set_domain_id = SetEnvironmentVariable('ROS_DOMAIN_ID', LaunchConfiguration('domain_id'))
ROS 2 nodes discover each other over the network automatically. A simulation left on the default domain
will find — and be found by — a real robot on the same network. Domain 42 walls this off. It must come
before every Node action in the returned list, or nodes launch before the variable is set.
To inspect topics from another terminal you need the same domain:
ROS_DOMAIN_ID=42 ros2 topic list.
2 — Building the MoveIt configuration
MoveItConfigsBuilder("moveit_resources_panda",
package_name="moveit_resources_panda_moveit_config")
.robot_description(file_path="config/panda.urdf.xacro",
mappings={"ros2_control_hardware_type": "mock_components"})
.robot_description_semantic(file_path="config/panda.srdf")
.robot_description_kinematics()
.trajectory_execution(file_path="config/moveit_controllers.yaml")
.planning_pipelines(pipelines=["ompl"])
.joint_limits()
.to_moveit_configs()
MoveIt needs roughly a dozen separate parameter sets. MoveItConfigsBuilder assembles them
from the community Panda configuration package. Two pieces are worth naming:
- URDF
- The physical robot: links, joints, limits, meshes.
- SRDF
- The semantic layer: which joints form the group named
panda_arm, which link pairs can never collide, what named poses exist. Planning needs both. - mock_components
- A
ros2_controlhardware plugin that reports back whatever position it was commanded, instantly. The controller stack, the action interfaces and the state publishing are all real; only the physics is absent. Swapping this string for a Gazebo or a real hardware interface changes nothing above it.
3 — The eleven nodes
| Node | Delay | Role |
|---|---|---|
move_group | 0 s | The MoveIt planning server. Holds the planning scene, runs OMPL, serves the Cartesian-path service, executes trajectories. The heaviest process — takes 5–10 s to load. |
robot_state_publisher | 0 s | Reads /joint_states and publishes the resulting TF transform tree so every node agrees where each link is. |
static_transform_publisher | 0 s | Ties world to panda_link0 at the identity transform. |
camera_tf | 0 s | A second static transform placing camera_link at (0.40, 0, 0.80) with roll = π, i.e. looking straight down. Matches camera.yaml exactly. |
ros2_control_node | 0 s | The controller manager. Loads the mock hardware and hosts the controllers. |
joint_state_broadcaster | 2 s | Publishes /joint_states from the hardware interface. |
panda_arm_controller | 3 s | The joint-trajectory controller that MoveIt sends executions to. |
rviz2 | 0 s | Visualisation, loaded with the saved layout. |
camera_simulator | 0 s | Parameters from camera.yaml. |
vision_detector | 0 s | Parameters from camera.yaml — same file as the simulator, so intrinsics and mount cannot drift apart. |
workspace_validator | 0 s | Parameters from demo_params.yaml (the workspace_validator: block). |
smart_pick_place | 4 s | Parameters from demo_params.yaml. Delayed so MoveIt and the controllers have a head start; it also polls for the action server every second regardless, so the delay is an optimisation, not the safety mechanism. |
The 2 / 3 / 4 second sequence is not arbitrary — a spawner that runs before its controller manager exists fails permanently rather than retrying, and both spawners must be up before the controller can accept a trajectory. The repository's own notes flag this ordering as tested and not to be adjusted casually.
Every tunable number
The two YAML files that hold every parameter, with what each one changes if you touch it.
Nothing numeric is hard-coded in the nodes. Every value below is declared as a ROS 2 parameter with a default and loaded from YAML at launch, which means it can also be overridden on the command line without a rebuild.
Motion — demo_params.yaml
| Parameter | Value | Meaning |
|---|---|---|
planning_attempts | 20 | OMPL is randomised; 20 independent tries, best one kept. |
planning_time_s | 10.0 | Per-segment budget. |
velocity_scaling / acceleration_scaling | 0.2 / 0.2 | 20 % of the arm's limits on OMPL paths. |
cartesian_eef_step | 0.01 m | Interpolation step along the straight line. |
cartesian_min_fraction | 0.95 | Below this the Cartesian result is discarded and OMPL takes over. |
cartesian_speed_scale | 0.5 | Post-planning time stretch (see retiming, above). |
grasp_pos_tol / place_pos_tol | 0.04 / 0.06 m | Side length of the goal tolerance box. |
orient_tol_rad | 0.25 | Tilt tolerance ≈ 14°, keeps the gripper down. |
orient_yaw_tol_rad | 3.14 | Wrist spin left completely free. |
Geometry — demo_params.yaml
| Parameter | Value | Meaning |
|---|---|---|
ball_x / ball_y | 7 pairs | Ground-truth layout, all inside reach, inside the camera view, clear of the box. |
ball_radius / ball_z | 0.03 / 0.025 m | Sphere size and resting height. |
approach_clearance | 0.20 m | Height of the pre-grasp hover. |
grasp_clearance / min_grasp_z | 0.04 / 0.16 m | Gripper stops 4 cm above the object, never below 16 cm absolute. |
lift_height | 0.28 m | Straight-up retreat before crossing to the box. |
box_x / box_y / box_drop_z | 0.55 / 0.25 / 0.30 m | Container position and release height. |
home_x/y/z | 0.3 / 0.0 / 0.6 m | Recovery pose, used only after a planning failure. |
Camera — camera.yaml
| Parameter | Value | Meaning |
|---|---|---|
publish_rate | 10 Hz | Frame rate of the synthetic camera. |
image_width / image_height | 640 × 480 | Frame size. |
fx / fy | 520 / 520 | Focal length in pixels. Equal, so pixels are square. |
cx / cy | 320 / 240 | Principal point — exactly the image centre, no lens offset. |
cam_x / cam_y / cam_h | 0.40 / 0.00 / 0.80 m | Mount pose in the robot base frame. Shared by simulator and detector. |
min_contour_area_px | 400 | Anything smaller is noise, not a ball. |
Decisions worth defending
Places where a different choice was available, and the reason this one was taken.
| Decision | Reasoning |
|---|---|
| Reachability filter in C++, everything else Python | It runs per frame and sits between perception and the controller, so it must never be the thing that stalls. Python is the right language for orchestration and for OpenCV work where the heavy lifting is already in C. |
The state machine subscribes to /validated_targets |
An earlier version published that topic and never read it — the arm used ground truth while a validator ran decoratively alongside. Closing that loop is the difference between a system and a demonstration of one. |
Ghost marker instead of AttachedCollisionObject |
The textbook approach produced panda_link7 self-collision failures and a visual artefact. Separating "what the planner must avoid" from "what the operator sees" solved both. |
| Cartesian first, OMPL as fallback | Straight-line end-effector motion is predictable and inspectable. RRTConnect is a safety net, not the default. |
| Yaw unconstrained at the goal | A sphere has no observable orientation. Constraining wrist spin removes valid IK solutions and buys nothing. |
| Retiming after planning | Works around a genuine gap in Humble's Cartesian-path service rather than accepting full-speed motion. |
| All values in YAML | Retuning the demo is a text edit and a relaunch, not a rebuild. It also makes the tuning legible in one place. |
ROS_DOMAIN_ID=42 in the launch file |
A simulation on a shared network must not discover, or be discovered by, real hardware. |
Fifteen tests on image_utils.py only |
It is the one file that reimplements a standard library, so it is the one file that has to prove it round-trips and fails loudly on anything unsupported. |
What this does not demonstrate
The limits of a mock-hardware, synthetic-image system, stated plainly.
Stating the boundary is part of the work. The following are true and are not hidden in the repository either.
- No sensor realism. The images are drawn, not captured. No noise, no blur, no exposure variation, no shadows, no lens distortion. HSV thresholding succeeds here because the problem was made easy on purpose; on a real camera it would be the first thing to break.
- No depth. Positions come from a plane assumption, not from measurement. Stack two balls and the upper one is reported at the wrong place with full confidence.
- No physics.
mock_componentsreports back whatever it is commanded. There is no gravity, no contact, no gripper force — the "grasp" is a pose the arm reaches, and the ball is teleported into the box by the scene manager. - No learned component. Everything is classical: thresholding, contours, moments, pinhole geometry, sampling-based planning. That is the correct engineering choice for detecting red spheres on a plain table, and it means this project does not itself demonstrate a trained model.
- The reachability model is a cylinder, not the Panda's actual workspace, which is a complicated shape with orientation-dependent boundaries. It is a conservative approximation and is labelled as one.
- The inner package README is out of date and describes a deleted version of the system. Only the root README tracks what is actually built.
Swap mock_components for Gazebo Ignition with a Panda plugin — the perception and planning
stack needs no changes. Then add a learned component where the problem is genuinely hard — grasp pose
and orientation prediction for non-spherical objects — and export it to ONNX and TensorRT for a Jetson.
Detection stays HSV; a neural network for red spheres would be the wrong tool.
Building and running it
Dependencies, the build command, and what a correct run looks like on screen.
System dependencies
sudo apt install ros-humble-moveit ros-humble-ros2-control \
ros-humble-ros2-controllers ros-humble-moveit-ros-move-group \
ros-humble-moveit-planners-ompl
Build
mkdir -p ~/moveit_project_ws/src
cd ~/moveit_project_ws/src
git clone https://github.com/AungKaung1928/moveit_pickplace_demo.git .
cd ~/moveit_project_ws
rosdep install --from-paths src --ignore-src -r -y
colcon build --symlink-install
source install/setup.bash
Run
ros2 launch simple_moveit_demo smart_pick_place.launch.py
--symlink-install links Python sources into the install space instead of copying them,
so editing a node does not require a rebuild. rosdep reads every
package.xml and installs the declared dependencies.
What you should see
- RViz opens immediately; the arm starts moving after 5–10 seconds of MoveIt warm-up.
- Seven red balls on a brown table, one orange box.
- The ball stays visible while the arm approaches, and disappears the moment the gripper arrives.
- Straight-line motion: glide over the ball → straight down; straight up → across to the box. No return to a home pose in between.
- The ball reappears inside the box in a 2×2 grid, stacking into a second layer for balls 5 to 7.
- Two translucent cylinders showing the reachable workspace, refreshed every two seconds.
Inspecting it while it runs
ROS_DOMAIN_ID=42 ros2 topic echo /pick_place_status # IDLE / GRASPING / PLACING / DONE
ROS_DOMAIN_ID=42 ros2 topic echo /validated_targets # what the FSM is allowed to pick
ROS_DOMAIN_ID=42 ros2 topic hz /camera/image_raw # should read ~10 Hz
ROS_DOMAIN_ID=42 ros2 node list
Known harmless shutdown noise
| Message | Cause |
|---|---|
rviz2 exit code -11 | A known MoveIt 2 Humble segfault in a destructor. Shutdown only. |
move_group SIGKILL after 10 s | A known Humble shutdown race. Shutdown only. |
| Arm never leaves IDLE | Nothing is arriving on /validated_targets — check that the camera and detector started cleanly, and that the domain id matches. |
The short version
The whole project compressed into one paragraph.
A two-package ROS 2 Humble workspace in which a Franka Panda clears seven balls into a box using only what a camera saw. A Python node renders a synthetic 640×480 top-down frame; a second Python node finds the balls by HSV thresholding and recovers their table-plane positions by exact pinhole back-projection; a C++ node filters those positions against the arm's reachable cylinder; a Python state machine picks up only what survived, preferring straight-line Cartesian motion and falling back to OMPL, retiming the Cartesian trajectory by hand because Humble's service does not expose velocity scaling. Every tunable number lives in two YAML files. The one file that reimplements a standard library carries fifteen contract tests. The simulation boundary — drawn images, a plane assumption, mock hardware — is documented rather than implied.