← Aung Kaung Myat/WalkthroughsRepository ↗
ROS 2 Humble · workspace moveit_pickplace_demo

Panda Pick-and-Place Pipeline

A 7-axis Franka Panda arm clears seven red balls off a table and drops them into a box. Nothing is hard-coded: a simulated camera renders the scene, an OpenCV node finds the balls in the picture, a C++ node throws away anything the arm cannot reach, and a state machine picks up only what survived that chain.

ROS 2 HumbleMoveIt 2Python 3.10 C++17OpenCVros2_control (mock) RViz2OMPL / RRTConnect
Packages
2 (Python + C++)
Nodes launched
11
Hardware needed
None — mock
Balls per run
7 of 7 placed
Source lines
≈1 600
01

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.

The one sentence that describes the architecture

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

nodeOne running program. A ROS 2 system is many small programs, each with one job.
topicA named channel. One node publishes messages onto it; any number of nodes subscribe.
MoveIt 2The motion-planning library. Given a target pose it returns a joint trajectory that avoids obstacles.
URDF / SRDFURDF lists the robot's links and joints. SRDF adds joint groups and which link pairs can never collide.
planning sceneMoveIt's model of the world: the robot plus every obstacle it has to plan around.
ros2_control (mock)The layer between a trajectory and motors. In mock mode it reports the commanded position back as if the motors had moved.
OMPL / RRTConnectThe sampling-based planner MoveIt calls. It grows two random trees, one from the start and one from the goal, until they meet.
back-projectionTurning a pixel back into a world coordinate. Possible here because the height of the table is known in advance.
TFThe transform tree — the running record of where every coordinate frame sits relative to every other.
poseA position (x, y, z) plus an orientation, stored as a quaternion.
02

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.

pythonscene_manager Owns the truth: spawns collision balls, the table and the box into MoveIt's planning scene, and publishes the ball layout once.
pythoncamera_simulator Draws a 640×480 top-down BGR image of whatever balls it was told about, at 10 Hz.
pythonvision_detector HSV red mask → contours → centroid → pinhole back-projection onto the table plane.
c++workspace_validator Rejects any detection outside the Panda's reachable cylinder. Publishes only survivors.
pythonsmart_pick_place The state machine. Picks the nearest validated target, asks MoveIt for a path, executes, repeats.
/scene_balls · PoseArray · latched /camera/image_raw · Image /camera/camera_info · CameraInfo /detected_objects · MarkerArray /validated_targets · PoseArray /scene_object_updates · String /pick_place_status · String /detection_image · Image
The loop that closes

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.

table plane, z = 0 panda_link0 camera_link · 0.40, 0.00, 0.80 m h = 0.80 m ball wₛ = cₛ + (u − cₓ)·h / fₓ wₓ = cₓ − (v − cₔ)·h / fₔ one pixel → one world point, because z is assumed to be 0
The whole depth problem, solved by an assumption. A single camera cannot measure distance — a pixel corresponds to a ray, not a point. Assuming every ball sits on the table (z = 0) turns that ray into exactly one intersection. Honest for a flat table, wrong the moment something is stacked.
03

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)

FileLinesWhat it does
simple_moveit_demo/camera_simulator.py149 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/vision_detector.py144 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/smart_pick_place.py346 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/scene_manager.py190 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/grasp_planner.py67 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/image_utils.py61 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/smart_pick_place.launch.py198 Starts all eleven nodes in the right order with the right parameters. Described in full below.
config/demo_params.yaml53 Ball layout, motion tuning, grasp geometry, box position, and the validator's reach bounds.
config/camera.yaml35 Camera intrinsics, mount pose, ball render radius, and the two HSV red windows.
test/test_image_utils.py92 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/vision_moveit.rviz Saved RViz layout so the visualiser opens with the right displays already enabled.
setup.py · package.xml36 · 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)

FileWhat it does
include/…/workspace_validator.hpp 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/workspace_validator_node.cpp 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.bash is the file you source to make the packages visible to ros2 run and ros2 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.
04

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 reasoning, stated honestly in the source

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:

TestBoundReason
height−0.05 … 0.98 mBelow the table surface or above the arm's vertical span.
horizontal radius0.18 … 0.82 m√(x² + y²) from the base axis. Too close is folded onto itself; too far is simply out of reach.
singularity columnr < 0.08 mDirectly 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.

05

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

IDLE GRASPING PLACING next ball… DONE on failure → HOMING · ERROR

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 a fraction: 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.

06

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_control hardware 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

NodeDelayRole
move_group0 sThe 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_publisher0 sReads /joint_states and publishes the resulting TF transform tree so every node agrees where each link is.
static_transform_publisher0 sTies world to panda_link0 at the identity transform.
camera_tf0 sA 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_node0 sThe controller manager. Loads the mock hardware and hosts the controllers.
joint_state_broadcaster2 sPublishes /joint_states from the hardware interface.
panda_arm_controller3 sThe joint-trajectory controller that MoveIt sends executions to.
rviz20 sVisualisation, loaded with the saved layout.
camera_simulator0 sParameters from camera.yaml.
vision_detector0 sParameters from camera.yaml — same file as the simulator, so intrinsics and mount cannot drift apart.
workspace_validator0 sParameters from demo_params.yaml (the workspace_validator: block).
smart_pick_place4 sParameters 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.
Why the delays are marked "do not touch"

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.

07

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

ParameterValueMeaning
planning_attempts20OMPL is randomised; 20 independent tries, best one kept.
planning_time_s10.0Per-segment budget.
velocity_scaling / acceleration_scaling0.2 / 0.220 % of the arm's limits on OMPL paths.
cartesian_eef_step0.01 mInterpolation step along the straight line.
cartesian_min_fraction0.95Below this the Cartesian result is discarded and OMPL takes over.
cartesian_speed_scale0.5Post-planning time stretch (see retiming, above).
grasp_pos_tol / place_pos_tol0.04 / 0.06 mSide length of the goal tolerance box.
orient_tol_rad0.25Tilt tolerance ≈ 14°, keeps the gripper down.
orient_yaw_tol_rad3.14Wrist spin left completely free.

Geometry — demo_params.yaml

ParameterValueMeaning
ball_x / ball_y7 pairsGround-truth layout, all inside reach, inside the camera view, clear of the box.
ball_radius / ball_z0.03 / 0.025 mSphere size and resting height.
approach_clearance0.20 mHeight of the pre-grasp hover.
grasp_clearance / min_grasp_z0.04 / 0.16 mGripper stops 4 cm above the object, never below 16 cm absolute.
lift_height0.28 mStraight-up retreat before crossing to the box.
box_x / box_y / box_drop_z0.55 / 0.25 / 0.30 mContainer position and release height.
home_x/y/z0.3 / 0.0 / 0.6 mRecovery pose, used only after a planning failure.

Camera — camera.yaml

ParameterValueMeaning
publish_rate10 HzFrame rate of the synthetic camera.
image_width / image_height640 × 480Frame size.
fx / fy520 / 520Focal length in pixels. Equal, so pixels are square.
cx / cy320 / 240Principal point — exactly the image centre, no lens offset.
cam_x / cam_y / cam_h0.40 / 0.00 / 0.80 mMount pose in the robot base frame. Shared by simulator and detector.
min_contour_area_px400Anything smaller is noise, not a ball.
08

Decisions worth defending

Places where a different choice was available, and the reason this one was taken.

DecisionReasoning
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.
09

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_components reports 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.
The stated next steps

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.

10

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

MessageCause
rviz2 exit code -11A known MoveIt 2 Humble segfault in a destructor. Shutdown only.
move_group SIGKILL after 10 sA known Humble shutdown race. Shutdown only.
Arm never leaves IDLENothing is arriving on /validated_targets — check that the camera and detector started cleanly, and that the domain id matches.
11

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.