@@ -56,20 +56,33 @@ class PsoPathPlanner:
5656 # Collision penalty per colliding segment
5757 COLLISION_PENALTY = 1e4
5858
59- def __init__ (self , start , goal , map_file , * ,
60- x_lim = None , y_lim = None ,
61- path_filename = None , gif_name = None ,
62- n_particles = 40 , n_waypoints = 5 ,
63- max_iter = 120 , w = 0.6 , c1 = 1.8 , c2 = 1.8 ,
64- line_check_samples = 20 , seed = 42 ):
59+ def __init__ (
60+ self ,
61+ start ,
62+ goal ,
63+ map_file ,
64+ * ,
65+ x_lim = None ,
66+ y_lim = None ,
67+ path_filename = None ,
68+ gif_name = None ,
69+ n_particles = 40 ,
70+ n_waypoints = 5 ,
71+ max_iter = 120 ,
72+ w = 0.6 ,
73+ c1 = 1.8 ,
74+ c2 = 1.8 ,
75+ line_check_samples = 20 ,
76+ seed = 42 ,
77+ ):
6578 self .start = np .array (start , dtype = float )
6679 self .goal = np .array (goal , dtype = float )
6780 self .n_particles = n_particles
6881 self .n_waypoints = n_waypoints
6982 self .max_iter = max_iter
70- self .w = w # inertia weight
71- self .c1 = c1 # cognitive coefficient
72- self .c2 = c2 # social coefficient
83+ self .w = w # inertia weight
84+ self .c1 = c1 # cognitive coefficient
85+ self .c2 = c2 # social coefficient
7386 self .line_check_samples = line_check_samples
7487
7588 # Load grid
@@ -103,15 +116,15 @@ def __init__(self, start, goal, map_file, *,
103116 @staticmethod
104117 def _load_grid (file_path ):
105118 ext = Path (file_path ).suffix
106- if ext == ' .npy' :
119+ if ext == " .npy" :
107120 return np .load (file_path )
108- if ext == ' .png' :
121+ if ext == " .png" :
109122 g = plt .imread (file_path )
110123 if g .ndim == 3 :
111124 g = np .mean (g , axis = 2 )
112125 return (g > 0.5 ).astype (float )
113- if ext == ' .json' :
114- with open (file_path , 'r' ) as f :
126+ if ext == " .json" :
127+ with open (file_path , "r" ) as f :
115128 return np .array (json .load (f ))
116129 raise ValueError (f"Unsupported grid format: { ext } " )
117130
@@ -124,8 +137,7 @@ def _world_to_grid(self, point):
124137
125138 def _is_free (self , point ):
126139 gx , gy = self ._world_to_grid (point )
127- return (0 <= gx < self .cols and 0 <= gy < self .rows
128- and self .grid [gy , gx ] == 0 )
140+ return 0 <= gx < self .cols and 0 <= gy < self .rows and self .grid [gy , gx ] == 0
129141
130142 def _line_collision_free (self , p1 , p2 ):
131143 for i in range (self .line_check_samples + 1 ):
@@ -161,6 +173,14 @@ def _fitness(self, position):
161173 penalty += self .COLLISION_PENALTY
162174 return total_length + penalty
163175
176+ def _path_collides (self , position ):
177+ """Return True if any segment of this particle's path collides."""
178+ path = self ._decode_path (position )
179+ for i in range (len (path ) - 1 ):
180+ if not self ._line_collision_free (path [i ], path [i + 1 ]):
181+ return True
182+ return False
183+
164184 # -- PSO core ----------------------------------------------------------
165185
166186 def _run_pso (self ):
@@ -175,9 +195,11 @@ def _run_pso(self):
175195 noise_x = self ._rng .uniform (- 8.0 , 8.0 )
176196 noise_y = self ._rng .uniform (- 8.0 , 8.0 )
177197 positions [i , 2 * w ] = np .clip (
178- interp [0 ] + noise_x , self .x_min , self .x_max )
198+ interp [0 ] + noise_x , self .x_min , self .x_max
199+ )
179200 positions [i , 2 * w + 1 ] = np .clip (
180- interp [1 ] + noise_y , self .y_min , self .y_max )
201+ interp [1 ] + noise_y , self .y_min , self .y_max
202+ )
181203
182204 velocities = self ._rng .uniform (- 1.0 , 1.0 , (self .n_particles , dim ))
183205
@@ -190,20 +212,22 @@ def _run_pso(self):
190212 gbest_fit = pbest_fit [gbest_idx ]
191213
192214 # Record initial state
215+ colliding = np .array ([self ._path_collides (p ) for p in positions ])
193216 self ._history .append ((
194- positions .copy (),
195- self ._decode_path (gbest_pos ),
196- float (gbest_fit ),
217+ positions .copy (), self ._decode_path (gbest_pos ),
218+ float (gbest_fit ), colliding .copy (),
197219 ))
198220
199221 for it in range (self .max_iter ):
200222 r1 = self ._rng .random ((self .n_particles , dim ))
201223 r2 = self ._rng .random ((self .n_particles , dim ))
202224
203225 # Update velocities
204- velocities = (self .w * velocities
205- + self .c1 * r1 * (pbest_pos - positions )
206- + self .c2 * r2 * (gbest_pos - positions ))
226+ velocities = (
227+ self .w * velocities
228+ + self .c1 * r1 * (pbest_pos - positions )
229+ + self .c2 * r2 * (gbest_pos - positions )
230+ )
207231
208232 # Update positions
209233 positions += velocities
@@ -231,10 +255,11 @@ def _run_pso(self):
231255
232256 # Record history (subsample for animation)
233257 if it % 2 == 0 or it == self .max_iter - 1 :
258+ colliding = np .array (
259+ [self ._path_collides (p ) for p in positions ])
234260 self ._history .append ((
235- positions .copy (),
236- self ._decode_path (gbest_pos ),
237- float (gbest_fit ),
261+ positions .copy (), self ._decode_path (gbest_pos ),
262+ float (gbest_fit ), colliding .copy (),
238263 ))
239264
240265 # Final path
@@ -243,13 +268,16 @@ def _run_pso(self):
243268
244269 collision_free = all (
245270 self ._line_collision_free (
246- np .array (self .path [i ]), np .array (self .path [i + 1 ]))
271+ np .array (self .path [i ]), np .array (self .path [i + 1 ])
272+ )
247273 for i in range (len (self .path ) - 1 )
248274 )
249- print (f"PSO: converged after { self .max_iter } iterations, "
250- f"fitness={ gbest_fit :.2f} , "
251- f"collision_free={ collision_free } , "
252- f"waypoints={ len (self .path )} " )
275+ print (
276+ f"PSO: converged after { self .max_iter } iterations, "
277+ f"fitness={ gbest_fit :.2f} , "
278+ f"collision_free={ collision_free } , "
279+ f"waypoints={ len (self .path )} "
280+ )
253281
254282 # -- Path utilities ----------------------------------------------------
255283
@@ -261,17 +289,22 @@ def _make_sparse_path(self, path, num_points=20):
261289
262290 def _save_path (self , path , filename ):
263291 Path (filename ).parent .mkdir (parents = True , exist_ok = True )
264- with open (filename , 'w' ) as f :
292+ with open (filename , "w" ) as f :
265293 json .dump (path , f )
266294
267295 # -- Visualisation -----------------------------------------------------
268296
269297 def visualize_search (self , gif_name = None ):
270298 """
271- Render a GIF showing PSO convergence:
272- Phase 0: Swarm iterations (particles + current global best path)
273- Phase 1: Final path drawing
274- Phase 2: Hold final
299+ Render a GIF showing PSO convergence with colour-coded particles:
300+
301+ Phase 0: Swarm iterations
302+ - Collision-free particles in blue, colliding ones in red/orange
303+ - Dashed connectors from start→first waypoint, last→goal
304+ - Global best path drawn prominently in green
305+ - Fitness counter in title shows convergence
306+ Phase 1: Final path progressively drawn
307+ Phase 2: Hold final result
275308 """
276309 if gif_name is None :
277310 return
@@ -281,8 +314,8 @@ def visualize_search(self, gif_name=None):
281314 # Colour map for grid
282315 cmap = ListedColormap ([
283316 [1.0 , 1.0 , 1.0 ], # free
284- [0.5 , 0.5 , 0.5 ], # clearance
285- [0.0 , 0.0 , 0.0 ], # obstacle
317+ [0.75 , 0.75 , 0.75 ], # clearance
318+ [0.15 , 0.15 , 0.15 ], # obstacle
286319 ])
287320
288321 def _disc (g ):
@@ -293,6 +326,12 @@ def _disc(g):
293326
294327 grid_disc = _disc (self .grid )
295328
329+ # Colour palette
330+ _COL_FREE = '#42A5F5' # blue — collision-free particle
331+ _COL_COLLIDE = '#EF5350' # red — colliding particle
332+ _COL_BEST = '#2E7D32' # green — global best
333+ _COL_CONNECTOR = '#78909C' # grey-blue — start/goal connectors
334+
296335 # Frame plan
297336 n_hist = len (self ._history )
298337 path_frames = 20
@@ -307,47 +346,97 @@ def _phase(i):
307346 return p , i - int (offsets [p ])
308347 return 2 , hold_frames - 1
309348
310- def update (i , ax ):
311- phase , local = _phase (i )
312- ax .clear ()
313-
314- # Draw grid
349+ def _draw_grid (ax ):
315350 ax .imshow (grid_disc ,
316351 extent = [self .x_range [0 ], self .x_range [- 1 ],
317352 self .y_range [0 ], self .y_range [- 1 ]],
318353 origin = 'lower' , cmap = cmap , vmin = 0 , vmax = 2 ,
319- alpha = 0.8 )
354+ alpha = 0.85 )
355+
356+ def _draw_endpoints (ax ):
357+ ax .plot (self .start [0 ], self .start [1 ], 'o' ,
358+ color = _COL_BEST , markersize = 11 , markeredgecolor = 'white' ,
359+ markeredgewidth = 1.2 , label = "Start" , zorder = 8 )
360+ ax .plot (self .goal [0 ], self .goal [1 ], 'o' ,
361+ color = _COL_COLLIDE , markersize = 11 ,
362+ markeredgecolor = 'white' , markeredgewidth = 1.2 ,
363+ label = "Goal" , zorder = 8 )
364+
365+ def update (i , ax ):
366+ phase , local = _phase (i )
367+ ax .clear ()
368+ _draw_grid (ax )
320369
321370 if phase == 0 :
322371 snap_idx = min (local , n_hist - 1 )
323- positions , gbest_path , gfit = self ._history [snap_idx ]
372+ positions , gbest_path , gfit , colliding = \
373+ self ._history [snap_idx ]
374+ n_free = int (np .sum (~ colliding ))
375+ n_coll = int (np .sum (colliding ))
324376
325- # Draw all particles' waypoints
377+ # Draw each particle's path colour-coded
326378 for p_idx in range (positions .shape [0 ]):
327379 pts = self ._decode_path (positions [p_idx ])
328380 px = [pt [0 ] for pt in pts ]
329381 py = [pt [1 ] for pt in pts ]
330- ax .plot (px , py , '-' , color = '#90CAF9' , linewidth = 0.5 ,
331- alpha = 0.3 , zorder = 2 )
332- ax .scatter (px [1 :- 1 ], py [1 :- 1 ], c = '#42A5F5' , s = 6 ,
333- alpha = 0.4 , zorder = 3 )
334-
335- # Draw global best path
382+ is_bad = colliding [p_idx ]
383+ col = _COL_COLLIDE if is_bad else _COL_FREE
384+ alpha_line = 0.20 if is_bad else 0.35
385+ alpha_dot = 0.30 if is_bad else 0.50
386+
387+ # Start→wp1 and wp_last→goal as dashed connectors
388+ ax .plot ([px [0 ], px [1 ]], [py [0 ], py [1 ]],
389+ '--' , color = _COL_CONNECTOR ,
390+ linewidth = 0.4 , alpha = 0.25 , zorder = 2 )
391+ ax .plot ([px [- 2 ], px [- 1 ]], [py [- 2 ], py [- 1 ]],
392+ '--' , color = _COL_CONNECTOR ,
393+ linewidth = 0.4 , alpha = 0.25 , zorder = 2 )
394+
395+ # Internal segments as solid
396+ if len (px ) > 2 :
397+ ax .plot (px [1 :- 1 ], py [1 :- 1 ], '-' , color = col ,
398+ linewidth = 0.6 , alpha = alpha_line , zorder = 3 )
399+
400+ # Waypoint dots (exclude start/goal)
401+ ax .scatter (px [1 :- 1 ], py [1 :- 1 ], c = col , s = 8 ,
402+ alpha = alpha_dot , zorder = 4 ,
403+ edgecolors = 'none' )
404+
405+ # Global best path — prominent
336406 if gbest_path :
337407 gx = [pt [0 ] for pt in gbest_path ]
338408 gy = [pt [1 ] for pt in gbest_path ]
339- ax .plot (gx , gy , '-' , color = '#2E7D32' , linewidth = 2.0 ,
340- zorder = 5 , label = f"Best (L={ gfit :.1f} )" )
341- ax .scatter (gx [1 :- 1 ], gy [1 :- 1 ], c = '#2E7D32' , s = 20 ,
342- zorder = 6 )
343-
344- iter_num = snap_idx * 2 if snap_idx < n_hist - 1 else self .max_iter
409+ # Start/goal connectors for best
410+ ax .plot ([gx [0 ], gx [1 ]], [gy [0 ], gy [1 ]],
411+ '--' , color = _COL_BEST , linewidth = 1.5 ,
412+ alpha = 0.6 , zorder = 5 )
413+ ax .plot ([gx [- 2 ], gx [- 1 ]], [gy [- 2 ], gy [- 1 ]],
414+ '--' , color = _COL_BEST , linewidth = 1.5 ,
415+ alpha = 0.6 , zorder = 5 )
416+ # Internal path
417+ ax .plot (gx , gy , '-' , color = _COL_BEST , linewidth = 2.5 ,
418+ zorder = 6 )
419+ ax .scatter (gx [1 :- 1 ], gy [1 :- 1 ], c = _COL_BEST , s = 30 ,
420+ zorder = 7 , edgecolors = 'white' , linewidths = 0.5 )
421+
422+ iter_num = (snap_idx * 2 if snap_idx < n_hist - 1
423+ else self .max_iter )
345424 ax .set_title (
346- f"PSO — Iteration { iter_num } /{ self .max_iter } " ,
347- fontsize = 14 )
425+ f"PSO — Iter { iter_num } /{ self .max_iter } | "
426+ f"fitness={ gfit :.1f} | "
427+ f"\u2713 { n_free } \u2717 { n_coll } " ,
428+ fontsize = 13 )
429+
430+ # Legend proxies
431+ ax .plot ([], [], '-' , color = _COL_FREE , linewidth = 2 ,
432+ label = f"Free ({ n_free } )" )
433+ ax .plot ([], [], '-' , color = _COL_COLLIDE , linewidth = 2 ,
434+ label = f"Colliding ({ n_coll } )" )
435+ ax .plot ([], [], '-' , color = _COL_BEST , linewidth = 2.5 ,
436+ label = f"Global Best" )
348437
349438 elif phase >= 1 :
350- # Draw final path
439+ # Draw final optimised path
351440 if self .path :
352441 if phase == 1 :
353442 frac = min (local + 1 , path_frames )
@@ -359,20 +448,24 @@ def update(i, ax):
359448 if len (seg ) > 1 :
360449 px = [p [0 ] for p in seg ]
361450 py = [p [1 ] for p in seg ]
362- ax .plot (px , py , '-' , color = '#2E7D32' ,
363- linewidth = 2.5 , zorder = 5 , label = "Path" )
364- ax .scatter (px [1 :- 1 ], py [1 :- 1 ], c = '#2E7D32' ,
365- s = 25 , zorder = 6 )
451+ # Dashed connectors to start/goal
452+ ax .plot ([px [0 ], px [1 ]], [py [0 ], py [1 ]],
453+ '--' , color = _COL_BEST , linewidth = 2.0 ,
454+ alpha = 0.6 , zorder = 5 )
455+ if n >= len (self .path ):
456+ ax .plot ([px [- 2 ], px [- 1 ]], [py [- 2 ], py [- 1 ]],
457+ '--' , color = _COL_BEST , linewidth = 2.0 ,
458+ alpha = 0.6 , zorder = 5 )
459+ ax .plot (px , py , '-' , color = _COL_BEST ,
460+ linewidth = 3.0 , zorder = 6 , label = "Path" )
461+ ax .scatter (px [1 :- 1 ], py [1 :- 1 ], c = _COL_BEST ,
462+ s = 40 , zorder = 7 , edgecolors = 'white' ,
463+ linewidths = 0.8 )
366464
367465 ax .set_title ("PSO — Optimised Path" , fontsize = 14 )
368466
369- # Start and goal
370- ax .plot (self .start [0 ], self .start [1 ], 'go' , markersize = 10 ,
371- label = "Start" , zorder = 7 )
372- ax .plot (self .goal [0 ], self .goal [1 ], 'ro' , markersize = 10 ,
373- label = "Goal" , zorder = 7 )
374-
375- ax .legend (loc = 'upper left' )
467+ _draw_endpoints (ax )
468+ ax .legend (loc = 'upper left' , fontsize = 9 , framealpha = 0.85 )
376469 ax .set_xlabel ("X [m]" , fontsize = 12 )
377470 ax .set_ylabel ("Y [m]" , fontsize = 12 )
378471 ax .set_aspect ("equal" )
@@ -403,8 +496,11 @@ def update(i, ax):
403496 goal = (50 , - 10 )
404497
405498 planner = PsoPathPlanner (
406- start , goal , map_file ,
407- x_lim = x_lim , y_lim = y_lim ,
499+ start ,
500+ goal ,
501+ map_file ,
502+ x_lim = x_lim ,
503+ y_lim = y_lim ,
408504 path_filename = path_file ,
409505 gif_name = gif_path ,
410506 )
0 commit comments