Arm Camera¶
This sample world includes a ZYY arm with an attached camera and visual targets. This model is a testbed for exploring vision-guided behaviors.
The control script uses the OpenCV Python module for image processing. This can
be installed in your Python system using pip3 install opencv-python
.
The world file is arm-camera.wbt
, which can be found within the Webots.zip archive.
World File¶
The world file specifies all the elements of the scene: camera position, background scenery, lighting, fixed objects, the robot arm, and a kinematics-only Cartesian robot to position a vision target.
The robot arm is defined within the world file as a modified form of the ZYY Robot Arm Model. The modified model adds a camera and display and removes a distance sensor.
1#VRML_SIM R2023b utf8
2
3EXTERNPROTO "../protos/HL-A11-room.proto"
4EXTERNPROTO "../protos/cartesian-target.proto"
5
6WorldInfo {
7}
8Viewpoint {
9 orientation 0.0967171354651438 0.028945079794029917 -0.9948909377731483 2.6640220350656656
10 position 6.685541706721584 3.239781663991078 2.4623641250844526
11 followType "None"
12}
13Background {
14 skyColor [
15 0.1 0.1 0.1
16 ]
17}
18SpotLight {
19 attenuation 0 0 1
20 beamWidth 0.3
21 cutOffAngle 0.4
22 direction -0.5 -2 -1.2
23 intensity 5
24 location 1.5 2 2
25}
26SpotLight {
27 attenuation 0 0 1
28 beamWidth 0.3
29 cutOffAngle 0.4
30 direction -0.5 2 -1.2
31 intensity 5
32 location 1.5 -2 2
33}
34HL-A11-room {
35}
36Robot {
37 rotation 0 1 0 0
38 children [
39 Receiver {
40 }
41 Emitter {
42 }
43 DEF fixedBaseShape Pose {
44 translation 0 0 0.05
45 children [
46 Shape {
47 appearance DEF blueAppearance PBRAppearance {
48 baseColor 0.21529 0.543008 0.99855
49 roughness 0.5
50 metalness 0.5
51 }
52 geometry Cylinder {
53 height 0.1
54 radius 0.2
55 }
56 }
57 ]
58 }
59 HingeJoint {
60 jointParameters HingeJointParameters {
61 axis 0 0 1
62 minStop -2.0943951023931953
63 maxStop 2.0943951023931953
64 }
65 device [
66 PositionSensor {
67 name "joint1"
68 }
69 RotationalMotor {
70 name "motor1"
71 acceleration 2
72 controlPID 100 0 25
73 maxVelocity 3.14
74 maxTorque 20
75 }
76 ]
77 endPoint Solid {
78 translation 0 0 0.12
79 children [
80 DEF rotatingBaseShape Pose {
81 translation 0 0 0.05
82 children [
83 Shape {
84 appearance USE blueAppearance
85 geometry Cylinder {
86 height 0.1
87 radius 0.2
88 subdivision 6
89 }
90 }
91 ]
92 }
93 HingeJoint {
94 jointParameters HingeJointParameters {
95 axis 0 1 0
96 anchor 0 0 0.17
97 minStop -0.08726646259971647
98 maxStop 1.5707963267948966
99 }
100 device [
101 PositionSensor {
102 name "joint2"
103 }
104 RotationalMotor {
105 name "motor2"
106 acceleration 2
107 controlPID 50 0 15
108 maxVelocity 3.14
109 maxTorque 20
110 }
111 ]
112 endPoint Solid {
113 translation 0 0 0.42
114 rotation 0 1 0 0
115 children [
116 HingeJoint {
117 jointParameters HingeJointParameters {
118 axis 0 1 0
119 anchor 0 0 0.25
120 minStop -0.08726646259971647
121 maxStop 2.0943951023931953
122 dampingConstant 0.1
123 }
124 device [
125 PositionSensor {
126 name "joint3"
127 }
128 RotationalMotor {
129 name "motor3"
130 acceleration 2
131 controlPID 50 0 15
132 maxVelocity 6.28
133 maxTorque 15
134 }
135 ]
136 endPoint Solid {
137 translation 0 0 0.5
138 rotation 0 1 0 0
139 children [
140 DEF link2Shape Pose {
141 children [
142 Shape {
143 appearance DEF greenAppearance PBRAppearance {
144 baseColor 0.413001 1 0.33489
145 roughness 0.5
146 metalness 0.5
147 }
148 geometry Cylinder {
149 height 0.5
150 radius 0.05
151 }
152 }
153 ]
154 }
155 Pose {
156 translation 0 0 -0.25
157 rotation 1 0 0 1.5708
158 children [
159 Shape {
160 appearance USE greenAppearance
161 geometry Cylinder {
162 height 0.1
163 radius 0.05
164 }
165 }
166 ]
167 }
168 Camera {
169 translation 0 0 0.25
170 rotation 0 1 0 -1.5708
171 children [
172 Pose {
173 rotation 0 1 0 -1.5707953071795862
174 children [
175 Shape {
176 appearance PBRAppearance {
177 baseColor 1 0.99028 0.0584421
178 roughness 0.5
179 metalness 0.5
180 emissiveColor 1 0.99028 0.0584421
181 emissiveIntensity 0.2
182 }
183 geometry Cone {
184 bottomRadius 0.025
185 height 0.05
186 }
187 }
188 ]
189 }
190 ]
191 fieldOfView 0.75
192 far 5
193 }
194 ]
195 name "link2"
196 boundingObject USE link2Shape
197 physics Physics {
198 density 211.7
199 }
200 }
201 }
202 DEF link1Shape Pose {
203 children [
204 Shape {
205 appearance DEF redAppearance PBRAppearance {
206 baseColor 0.990494 0.516915 0.468254
207 roughness 0.5
208 metalness 0.5
209 }
210 geometry Cylinder {
211 height 0.5
212 radius 0.05
213 }
214 }
215 ]
216 }
217 Pose {
218 translation 0 0 -0.25
219 rotation 1 0 0 1.5708
220 children [
221 Shape {
222 appearance USE redAppearance
223 geometry Cylinder {
224 height 0.1
225 radius 0.05
226 }
227 }
228 ]
229 }
230 ]
231 name "link1"
232 boundingObject USE link1Shape
233 physics Physics {
234 density 211.7
235 }
236 }
237 }
238 ]
239 name "base"
240 boundingObject USE rotatingBaseShape
241 physics Physics {
242 density -1
243 mass 2
244 }
245 }
246 }
247 Display {
248 }
249 ]
250 name "camera-on-arm"
251 boundingObject USE fixedBaseShape
252 controller "arm_camera"
253}
254Solid {
255 translation 1 1.5 0.01
256 rotation 0.7071072811860408 1.223050486354381e-06 0.7071062811856431 3.14159
257 children [
258 Shape {
259 appearance PBRAppearance {
260 baseColorMap ImageTexture {
261 url [
262 "textures/stripes.png"
263 ]
264 filtering 1
265 }
266 metalness 0
267 }
268 geometry IndexedFaceSet {
269 coord Coordinate {
270 point [
271 0 -0.3 -0.3
272 0 0.3 -0.3
273 0 0.3 0.3
274 0 -0.3 0.3
275 ]
276 }
277 texCoord TextureCoordinate {
278 point [
279 0 0
280 1 0
281 1 1
282 0 1
283 ]
284 }
285 coordIndex [
286 0, 1, 2, 3
287 ]
288 texCoordIndex [
289 0, 1, 2, 3
290 ]
291 }
292 }
293 ]
294 name "right target"
295}
296Solid {
297 translation 1.25 0 0.01
298 rotation -0.5773509358554485 0.5773489358556708 -0.5773509358554485 -2.094395307179586
299 children [
300 Shape {
301 appearance PBRAppearance {
302 baseColorMap ImageTexture {
303 url [
304 "textures/circle.png"
305 ]
306 filtering 1
307 }
308 metalness 0
309 }
310 geometry IndexedFaceSet {
311 coord Coordinate {
312 point [
313 0 -0.3 -0.3
314 0 0.3 -0.3
315 0 0.3 0.3
316 0 -0.3 0.3
317 ]
318 }
319 texCoord TextureCoordinate {
320 point [
321 0 0
322 1 0
323 1 1
324 0 1
325 ]
326 }
327 coordIndex [
328 0, 1, 2, 3
329 ]
330 texCoordIndex [
331 0, 1, 2, 3
332 ]
333 }
334 }
335 ]
336 name "center target"
337}
338Solid {
339 translation 1 -1.5 0.0100094
340 rotation 0.7071072811860408 1.223050486354381e-06 0.7071062811856431 3.14159
341 children [
342 Shape {
343 appearance PBRAppearance {
344 baseColorMap ImageTexture {
345 url [
346 "textures/stripes.png"
347 ]
348 filtering 1
349 }
350 metalness 0
351 }
352 geometry IndexedFaceSet {
353 coord Coordinate {
354 point [
355 0 -0.3 -0.3
356 0 0.3 -0.3
357 0 0.3 0.3
358 0 -0.3 0.3
359 ]
360 }
361 texCoord TextureCoordinate {
362 point [
363 0 0
364 1 0
365 1 1
366 0 1
367 ]
368 }
369 coordIndex [
370 0, 1, 2, 3
371 ]
372 texCoordIndex [
373 0, 1, 2, 3
374 ]
375 }
376 }
377 ]
378 name "left target"
379}
380cartesian-target {
381 translation 2 0 0
382 radius 0.2
383}
Sample Arm Camera Controller¶
The controller reads the camera images and uses them to trigger different reactive movements.
1# arm_camera.py
2#
3# Sample Webots controller file for driving a two-link arm with three driven
4# joints and an end-mounted camera.
5
6# No copyright, 2020-2024, Garth Zeglin. This file is
7# explicitly placed in the public domain.
8
9print("zyy_camera.py waking up.")
10
11# Import the Webots simulator API.
12from controller import Robot, Display
13
14# Import the standard Python math library.
15import math, random, time
16
17# Import the third-party numpy library for matrix calculations.
18# Note: this can be installed using 'pip3 install numpy' or 'pip3 install scipy'.
19import numpy as np
20
21# Import the third-party OpenCV library for image operations.
22# Note: this can be installed using 'pip3 install opencv-python'
23import cv2 as cv
24
25# Import the bezier module from the same directory. Note: this requires numpy.
26import bezier
27
28# Import the vision module from the same directory. Note: this requires OpenCV.
29import vision
30
31# Define the time step in milliseconds between controller updates.
32EVENT_LOOP_DT = 50
33
34################################################################
35class ZYY(Robot):
36 def __init__(self):
37
38 super(ZYY, self).__init__()
39 self.robot_name = self.getName()
40 print("%s: controller connected." % (self.robot_name))
41
42 # Attempt to randomize the random library sequence.
43 random.seed(time.time())
44
45 # Initialize geometric constants. These should match
46 # the current geometry of the robot.
47 self.base_z = 0.29 # joint2 axis height: 0.12 fixed base height + 0.17 j2 z offset
48 self.link1 = 0.5 # link1 length, i.e. distance between j2 and j3
49 self.link2 = 0.5 # link2 length, i.e. distance between j3 and end
50
51 # Fetch handles for the joint motors.
52 self.motor1 = self.getDevice('motor1')
53 self.motor2 = self.getDevice('motor2')
54 self.motor3 = self.getDevice('motor3')
55
56 # Adjust the motor controller properties.
57 self.motor1.setAvailableTorque(20.0)
58 self.motor2.setAvailableTorque(15.0)
59 self.motor3.setAvailableTorque(10.0)
60
61 # Adjust the low-level controller gains.
62 print("%s: setting PID gains." % (self.robot_name))
63 self.motor1.setControlPID(100.0, 0.0, 25.0)
64 self.motor2.setControlPID( 50.0, 0.0, 25.0)
65 self.motor3.setControlPID( 50.0, 0.0, 25.0)
66
67 # Fetch handles for the joint sensors.
68 self.joint1 = self.getDevice('joint1')
69 self.joint2 = self.getDevice('joint2')
70 self.joint3 = self.getDevice('joint3')
71
72 # Save the most recent target position.
73 self.joint_target = np.zeros(3)
74
75 # Specify the sampling rate for the joint sensors.
76 self.joint1.enable(EVENT_LOOP_DT)
77 self.joint2.enable(EVENT_LOOP_DT)
78 self.joint3.enable(EVENT_LOOP_DT)
79
80 # Enable the overhead camera and set the sampling frame rate.
81 self.camera = self.getDevice('camera')
82 self.camera_frame_dt = EVENT_LOOP_DT # units are milliseconds
83 self.camera_frame_timer = 0
84 self.camera.enable(self.camera_frame_dt)
85 self.frame_count = 0
86 self.vision_target = None
87
88 # Connect to the display object to show debugging data within the Webots GUI.
89 # The display width and height should match the camera.
90 self.display = self.getDevice('display')
91
92 # Initialize behavior state machines.
93 self.state_timer = 2*EVENT_LOOP_DT
94 self.state_index = 0
95 self._init_spline()
96 return
97
98 #================================================================
99 def endpoint_forward_kinematics(self, q):
100 """Compute the forward kinematics for the end point. Returns the
101 body-coordinate XYZ Cartesian position of the end point for a given joint
102 angle vector.
103 :param q: three-element list with [q1, q2, q3] joint angles in radians
104 :return: three-element list with endpoint [x,y,z] location
105
106 """
107 j1 = q[0]
108 j2 = q[1]
109 j3 = q[2]
110
111 return [(self.link1*math.sin(j2) + self.link2*math.sin(j2 + j3))*math.cos(j1),
112 (self.link1*math.sin(j2) + self.link2*math.sin(j2 + j3))*math.sin(j1),
113 self.base_z + self.link1*math.cos(j2) + self.link2*math.cos(j2 + j3)]
114
115 #================================================================
116 def endpoint_inverse_kinematics(self, target):
117 """Compute the joint angles for a target end position. The target is a
118 XYZ Cartesian position vector in body coordinates, and the result vector
119 is a joint angles as list [j1, j2, j3]. If the target is out of reach,
120 returns the closest pose. With j1 between -pi and pi, and j2 and j3
121 limited to positive rotations, the solution is always unique.
122 """
123
124 x = target[0]
125 y = target[1]
126 z = target[2]
127
128 # the joint1 base Z rotation depends only upon x and y
129 j1 = math.atan2(y, x)
130
131 # distance within the XY plane from the origin to the endpoint projection
132 xy_radial = math.sqrt(x*x + y*y)
133
134 # find the Z offset from the J2 horizontal plane to the end point
135 end_z = z - self.base_z
136
137 # angle between the J2 horizonta plane and the endpoint
138 theta = math.atan2(end_z, xy_radial)
139
140 # radial distance from the J2 axis to the endpoint
141 radius = math.sqrt(x*x + y*y + end_z*end_z)
142
143 # use the law of cosines to compute the elbow angle solely as a function of the
144 # link lengths and the radial distance from j2 to end
145 # R**2 = l1**2 + l2**2 - 2*l1*l2*cos(pi - elbow)
146 acosarg = (radius*radius - self.link1**2 - self.link2**2) / (-2 * self.link1 * self.link2)
147 if acosarg < -1.0: elbow_supplement = math.pi
148 elif acosarg > 1.0: elbow_supplement = 0.0
149 else: elbow_supplement = math.acos(acosarg)
150
151 print("theta:", theta, "radius:", radius, "acosarg:", acosarg)
152
153 # use the law of sines to find the angle at the bottom vertex of the triangle defined by the links
154 # radius / sin(elbow_supplement) = l2 / sin(alpha)
155 if radius > 0.0:
156 alpha = math.asin(self.link2 * math.sin(elbow_supplement) / radius)
157 else:
158 alpha = 0.0
159
160 # calculate the joint angles
161 return [j1, math.pi/2 - theta - alpha, math.pi - elbow_supplement]
162
163
164 #================================================================
165 # motion primitives
166
167 def go_joints(self, target):
168 """Issue a position command to move to the given endpoint position expressed in joint angles as radians."""
169
170 self.motor1.setPosition(target[0])
171 self.motor2.setPosition(target[1])
172 self.motor3.setPosition(target[2])
173 self.joint_target = target
174 # print("%s: moving to (%f, %f, %f)" % (self.robot_name, target[0], target[1], target[2]));
175
176
177 #================================================================
178 # Polling function to process camera input. The API doesn't seem to include
179 # any method for detecting frame capture, so this just samples at the
180 # expected frame rate. It is possible it might retrieve duplicate frames or
181 # experience time aliasing.
182
183 def poll_camera(self):
184 self.camera_frame_timer -= EVENT_LOOP_DT
185 if self.camera_frame_timer < 0:
186 self.camera_frame_timer += self.camera_frame_dt
187 # read the camera
188 image = self.camera.getImage()
189 if image is not None:
190 # print("read %d byte image from camera" % (len(image)))
191 self.frame_count += 1
192
193 # convert the image data from a raw bytestring into a 3D matrix
194 # the pixel format is BGRA, which is the default for OpenCV
195 width = self.camera.getWidth()
196 height = self.camera.getHeight()
197 frame = np.frombuffer(image, dtype=np.uint8).reshape(height, width, 4)
198
199 # temporary: write image to file for offline testing
200 # cv.imwrite("capture-images/%04d.png" % (self.frame_count), frame)
201
202 # convert to grayscale and normalize levels
203 frame = cv.cvtColor(frame, cv.COLOR_BGR2GRAY)
204
205 # extract features from the image
206 render = self._find_round_image_targets(frame)
207
208 # display a debugging image in the Webots display
209 imageRef = self.display.imageNew(render.tobytes(), format=Display.RGB, width=width, height=height)
210 self.display.imagePaste(imageRef, 0, 0, False)
211
212 #================================================================
213 # Analyze a grayscale image for circular targets.
214 # Updates the internal state. Returns and annotated debugging image.
215 def _find_round_image_targets(self, grayscale):
216 circles = vision.find_circles(grayscale)
217
218 # generate controls from the feature data
219 if circles is None:
220 if self.vision_target is not None:
221 print("target not visible, tracking dropped")
222 self.vision_target = None
223 else:
224 # find the offset of the first circle center from the image center in pixels,
225 # ignoring the radius value
226 height, width = grayscale.shape
227 halfsize = np.array((width/2, height/2))
228 offset = circles[0][0:2] - halfsize
229
230 # normalize to unit scale, i.e. range of each dimension is (-1,1)
231 self.vision_target = offset / halfsize
232
233 # invert the Y coordinate so that positive values are 'up'
234 self.vision_target[1] *= -1.0
235 # print("target: ", self.vision_target)
236
237 # produce a debugging image
238 return vision.annotate_circles(grayscale, circles)
239
240 #================================================================
241
242 # Define joint-space movement sequences as a Bezier cubic spline
243 # trajectories specified in degrees. Each sequence should have three rows
244 # per spline segment.
245
246 # This initial motion brings the arm from the vertical home position to a
247 # 'work' pose with the camera pointing forward.
248
249 _home_to_work = np.array(
250 [
251 [ 0, 0, 0],
252 [ 0, 10, 60],
253 [ 0, 10, 60], # waypoint
254 ])
255
256 # This begins and ends at the 'work' pose, and is looped by poll_spline_path().
257 _path = np.array([
258 [ 0, 10, 60],
259 [ 15, 10, 60],
260 [ 15, 10, 60], # waypoint
261
262 [ 15, 10, 60],
263 [ -15, 10, 60],
264 [ -15, 10, 60], # waypoint
265
266 [ 15, 10, 60],
267 [ 0, 10, 60],
268 [ 0, 10, 60], # waypoint
269
270 [ 0, 10, 60],
271 [ 0, 10, 60],
272 [ 0, 10, 60], # waypoint
273 ])
274
275
276 def _init_spline(self):
277 """Create the Bezier spline interpolator and add the initial path from the home position."""
278 self.interpolator = bezier.PathSpline(axes=3)
279 self.interpolator.set_tempo(45.0)
280 self.interpolator.add_spline(self._home_to_work)
281 self._last_segment_count = self.interpolator.segments()
282
283 def poll_spline_path(self):
284 """Update function to loop a cubic spline trajectory."""
285 degrees = self.interpolator.update(dt=EVENT_LOOP_DT*0.001)
286 radians = [math.radians(theta) for theta in degrees]
287
288 # mirror the base rotation of the right robot for symmetric motion
289 if 'right' in self.robot_name:
290 radians[0] *= -1
291
292 self.go_joints(radians)
293 # print("u: ", self.interpolator.u, "knots:", self.interpolator.knots.shape[0])
294
295 segment_count = self.interpolator.segments()
296 if segment_count != self._last_segment_count:
297 self._last_segment_count = segment_count
298 print("Starting next spline segment.")
299
300 if self.interpolator.is_done():
301 print("Restarting the spline path.")
302 self.interpolator.add_spline(self._path)
303
304 #================================================================
305 # Define joint-space movement sequences. For convenience the joint angles
306 # are specified in degrees, then converted to radians for the controllers.
307 _right_poses = [[0, 0, 0],
308 [-45, 0, 60],
309 [45, 60, 60],
310 [0, 60, 0],
311 ]
312
313 _left_poses = [[0, 0, 0],
314 [45, 0, 60],
315 [-45, 60, 60],
316 [0, 60, 0],
317 ]
318
319 #================================================================
320 def poll_sequence_activity(self):
321 """State machine update function to walk through a series of poses at regular intervals."""
322
323 # Update the state timer
324 self.state_timer -= EVENT_LOOP_DT
325
326 # If the timer has elapsed, reset the timer and update the outputs.
327 if self.state_timer < 0:
328 self.state_timer += 2000
329
330 # Look up the next pose.
331 if 'left' in self.robot_name:
332 next_pose = self._left_poses[self.state_index]
333 self.state_index = (self.state_index + 1) % len(self._left_poses)
334 else:
335 next_pose = self._right_poses[self.state_index]
336 self.state_index = (self.state_index + 1) % len(self._right_poses)
337
338 # Convert the pose to radians and issue to the motor controllers.
339 angles = [math.radians(next_pose[0]), math.radians(next_pose[1]), math.radians(next_pose[2])]
340 self.go_joints(angles)
341
342 #================================================================
343 def apply_target_tracking(self):
344 # self.vision_target is a 2D vector representing the normalized target
345 # displacement from the image center. The following approximates the
346 # robot yaw and pitch error in radians using the half-angle of the field
347 # of view. The negative sign results from the robot kinematics, e.g. a
348 # positive yaw moves the camera to the left, so a positive image plane
349 # error needs a negative yaw to track toward the right.
350
351 field_of_view = 0.75 # horizontal FOV of Webots camera in radians
352 yaw_error, pitch_error = -0.5 * field_of_view * self.vision_target
353
354 # print("yaw and pitch error:", yaw_error, pitch_error)
355
356 # Calculate a joint velocity to reduce angular error. For now, this
357 # uses the base Z axis to control yaw and the elbow Y axis to control
358 # pitch; the shoulder Y axis is unchanged. The feedback gain scales
359 # angular error to angular velocity.
360 joint_velocity = np.array((0.5 * yaw_error, 0.0, 0.5 * pitch_error))
361 print("target tracking joint velocity:", joint_velocity)
362
363 # Update the position targets for this cycle for the given velocity.
364 dt = 0.001 * EVENT_LOOP_DT
365 new_joint_target = self.joint_target + dt * joint_velocity
366
367 self.go_joints(new_joint_target)
368 return
369
370
371 #================================================================
372 def run(self):
373 # Run loop to execute a periodic script until the simulation quits.
374 # If the controller returns -1, the simulator is quitting.
375 while self.step(EVENT_LOOP_DT) != -1:
376 # Read simulator clock time.
377 self.sim_time = self.getTime()
378
379 # Process available camera data.
380 self.poll_camera()
381
382 # If a target is visible, try tracking it
383 if self.vision_target is not None:
384 self.apply_target_tracking()
385
386 else:
387 # Update the spline trajectory generator.
388 self.poll_spline_path()
389
390################################################################
391# Start the script.
392robot = ZYY()
393robot.run()
Machine Vision Module¶
1# vision.py
2# OpenCV machine vision image processing functions.
3# No copyright, 2024, Garth Zeglin. This file is explicitly placed in the public domain.
4
5# Import the standard Python math library.
6import math
7
8# Import the third-party numpy library for matrix calculations.
9# Note: this can be installed using 'pip3 install numpy' or 'pip3 install scipy'.
10import numpy as np
11
12# Import the third-party OpenCV library for image operations.
13# Note: this can be installed using 'pip3 install opencv-python'
14import cv2 as cv
15
16################################################################
17def find_circles(grayscale_image):
18
19 # normalize levels to full range of pixel values
20 frame = cv.normalize(grayscale_image, None, 0, 255, cv.NORM_MINMAX, dtype=cv.CV_8U)
21
22 # reduce noise
23 img = cv.medianBlur(frame,5)
24
25 circles = cv.HoughCircles(img,
26 method=cv.HOUGH_GRADIENT,
27 dp=1, # accumulator has same resolution as the image
28 minDist=8, # minimum center to center distance
29 param1=200, # with HOUGH_GRADIENT, Canny edge threshold
30 param2=20, # with HOUGH_GRADIENT, accumulator threshold
31 minRadius=0,
32 maxRadius=0)
33
34 if circles is None:
35 return None
36 else:
37 # the result seems to be a 1 x num-circles x 3 matrix
38 # this reshapes it to a 2D matrix, each row is then [x y r]
39 num_circles = circles.shape[1]
40 return circles.reshape((num_circles, 3))
41
42################################################################
43def annotate_circles(grayscale_image, circles):
44
45 # create color image for visualization
46 cimg = cv.cvtColor(grayscale_image,cv.COLOR_GRAY2BGR)
47 if circles is not None:
48 circles = np.uint16(np.round(circles))
49 for i in circles:
50 # draw the perimeter in green
51 cv.circle(cimg,(i[0],i[1]),i[2],(0,255,0),2)
52
53 # draw the center of the circle in red
54 cv.circle(cimg,(i[0],i[1]),2,(255,0,0),3)
55 return cimg
56
57################################################################
58def find_lines(grayscale_image):
59 # convert to binary image
60 # ret, frame = cv.threshold(frame, 127, 255, cv.THRESH_BINARY)
61
62 # normalize levels to full range of pixel values
63 frame = cv.normalize(grayscale_image, None, 0, 255, cv.NORM_MINMAX, dtype=cv.CV_8U)
64
65 # find edges, return binary edge image
66 frame = cv.Canny(frame, 100, 200)
67
68 # extract possible lines
69 lines = cv.HoughLines(frame, rho=1, theta=math.radians(10), threshold=20)
70 if lines is not None:
71 print("found %d possible lines, shape is %s" % (len(lines), lines.shape))
72 # print(lines)
73 else:
74 print("no lines found")
75 return lines
76
77################################################################
78def annotate_lines(grayscale_image, lines):
79
80 # show the image in the debugging viewer window
81 render = cv.cvtColor(grayscale_image, cv.COLOR_GRAY2RGB)
82 if lines is not None:
83 for line in lines:
84 rho, theta = line[0] # line is a matrix with shape (1, 2)
85 a = np.cos(theta)
86 b = np.sin(theta)
87 x0 = a*rho
88 y0 = b*rho
89 x1 = int(x0 + 100*(-b))
90 y1 = int(y0 + 100*(a))
91 x2 = int(x0 - 100*(-b))
92 y2 = int(y0 - 100*(a))
93 cv.line(render,(x1,y1),(x2,y2),(255,0,0),1)
94
95 return render
96
97################################################################
98# Test script to perform when run this file is run as a standalone script.
99if __name__ == "__main__":
100 import argparse
101 import os.path
102 parser = argparse.ArgumentParser( description = """Test vision processing using a test image.""")
103 parser.add_argument('paths', nargs="+", help="One or more image files to analyze.")
104 args = parser.parse_args()
105
106 # read a test image
107 for path in args.paths:
108 frame = cv.imread(path)
109 if frame is None:
110 print("Unable to load %s." % (path))
111 else:
112 print("Read image %s with shape: %s" % (path, frame.shape))
113
114 # convert to grayscale and normalize levels
115 frame = cv.cvtColor(frame, cv.COLOR_BGR2GRAY)
116
117 circles = find_circles(frame)
118
119 if circles is None:
120 print("no circles detected")
121 else:
122 print("detected %d circle(s):" % (circles.shape[0]))
123 print(circles)
124
125 cimg = annotate_circles(frame, circles)
126 debug_path = os.path.join("debug-images", os.path.basename(path))
127 print("Writing ", debug_path)
128 cv.imwrite(debug_path, cimg)
Bezier Spline Module¶
1# bezier.py
2#
3# Support for Bezier cubic spline interpolation and path generation.
4
5# Import the third-party numpy library for matrix calculations.
6# Note: this can be installed using 'pip3 install numpy' or 'pip3 install scipy'.
7import numpy as np
8
9#================================================================
10class PathSpline:
11 """Representation of a multi-axis multi-segment Bezier cubic spline
12 trajectory. This may be useful for interpolating a path through joint space
13 or Cartesian space.
14 """
15
16 def __init__(self, axes=3):
17 # Initialize a default spline buffer. The invariant is that this will
18 # always retain at least one segment. It should always have (1+3*segs)
19 # rows where segs is the number of path segments.
20 self.knots = np.zeros((4, axes))
21
22 # Set the default execution rate in spline segments per second.
23 self.rate = 1.0
24
25 # Initialize the current position at the end of the path. The position
26 # advances by one unit per segment.
27 self.u = 1.0
28
29 # Initialize the current interpolated value.
30 self.value = np.zeros((axes))
31
32 def set_tempo(self, spm):
33 """Set the execution rate of the spline interpolator in units of
34 segments/minute. The initial default value is 60 segs/min (1 per
35 second)."""
36 self.rate = max(min(spm/60.0, 5), 0.0001)
37
38 def axes(self):
39 """Return the number of axes."""
40 return self.knots.shape[1]
41
42 def segments(self):
43 """Return the number of spline segments in the path."""
44 return (self.knots.shape[0]-1)//3
45
46 def update(self, dt):
47 """Advance the spline position given the elapsed interval dt (in
48 seconds) and recalculate the spline interpolation. Returns the current
49 interpolated value."""
50 if dt > 0.0:
51 self.u += self.rate * dt
52 # Determine whether to roll over to the next segment or not. This will
53 # always leave one segment remaining.
54 segs = (self.knots.shape[0]-1) // 3
55 if segs > 1:
56 if self.u >= 1.0:
57 # u has advanced to next segment, drop the first segment by dropping the first three knots
58 self.knots = self.knots[3:]
59 self.u -= 1.0
60
61 # always limit u to the first active segment
62 self.u = min(max(self.u, 0.0), 1.0)
63
64 # update interpolation
65 self._recalculate()
66 return self.value
67
68 def is_done(self):
69 """Returns True if the evaluation has reached the end of the last spline segment."""
70 return (self.u >= 1.0) and (self.knots.shape[0] == 4)
71
72 def _recalculate(self):
73 """Apply the De Casteljau algorithm to calculate the position of
74 the spline at the current path point."""
75
76 # unroll the De Casteljau iteration and save intermediate points
77 num_axes = self.knots.shape[1]
78 beta = np.ndarray((3, num_axes))
79
80 # note: could use more numpy optimizations
81 u = self.u
82 for k in range(3):
83 beta[k] = self.knots[k] * (1 - u) + self.knots[k+1] * u
84
85 for k in range(2):
86 beta[k] = beta[k] * (1 - u) + beta[k+1] * u;
87
88 self.value[:] = beta[0] * (1 - u) + beta[1] * u
89
90 def set_target(self, value):
91 """Reset the path generator to a single spline segment from the current
92 value to the given value. The path defaults to 'ease-out', meaning the
93 rate of change is discontinuous at the start and slows into the target
94
95 :param value: iterable with one value per axis"""
96 self.knots[0,:] = self.value # start at the current levels
97 self.knots[1,:] = value # derivative discontinuity
98 self.knots[2,:] = value # ease at the target
99 self.knots[3,:] = value # target endpoint
100
101 # reset the path position to start the new segment
102 self.u = 0.0;
103
104 def set_value(self, value):
105 """Reset the path generator immediately to the given value with no
106 interpolation.
107
108 :param value: iterable with one value per axis"""
109
110 self.knots[0,:] = value
111 self.knots[1,:] = value
112 self.knots[2,:] = value
113 self.knots[3,:] = value
114
115 # reset the path position to start the new segment
116 self.u = 1.0;
117
118 # reset current value
119 self.value[:] = value
120
121 def add_spline(self, knots):
122 """Append a sequence of Bezier spline segments to the path
123 generator, provided as a numpy matrix with three rows per segment and
124 one column per axis."""
125
126 self.knots = np.vstack((self.knots, knots))
127
128 # Note: the path position and current value are left unchanged. If the
129 # interpolator is at the end of the current segment, it will now begin
130 # to advance into these new segments.
131 return
Vision Target Animation¶
The arm-camera.wbt
world includes an instance of a controllable vision
target robot using 2D Cartesian gantry kinematics as defined in
cartesian-target.proto
. This model has no dynamics but can be positioned.
The following script produces a fixed target motion pattern.
1# cartesian_target.py
2# Copyright, 2024, Garth Zeglin.
3# Sample Webots controller file for moving a spherical vision target using a 2D cartesian mechanism.
4
5print("cartesian_target.py waking up.")
6
7# Import standard Python libraries.
8import math
9
10# Import the Webots simulator API.
11from controller import Robot
12
13# Define the time step in milliseconds between controller updates.
14EVENT_LOOP_DT = 20
15
16# Request a proxy object representing the robot to control.
17robot = Robot()
18robot_name = robot.getName()
19print("%s: controller connected." % (robot_name))
20
21# Fetch handles for the motors.
22ymotor = robot.getDevice('ymotor')
23zmotor = robot.getDevice('zmotor')
24
25# Set time constants for the overall oscillation.
26lateral_period = 30.0
27
28# run loop to execute a periodic script until the simulation quits.
29# If the controller returns -1, the simulator is quitting.
30while robot.step(EVENT_LOOP_DT) != -1:
31 # Read simulator clock time.
32 t = robot.getTime()
33
34 # Move the target through a Lissajous pattern.
35 yphase = t * (2*math.pi/lateral_period)
36 ymotor.setPosition(1.0 * math.sin(yphase))
37 zmotor.setPosition(1.0 + 0.25 * math.sin(2.25*yphase))