Skip to content

Commit f7c81fa

Browse files
committed
improve PSO search visualization with colour-coded particles
- Blue particles for collision-free paths, red for colliding - Dashed connectors showing start→wp1 and wp_last→goal - Fitness counter and free/colliding counts in title - Global best drawn with prominent green line and white-edged markers
1 parent 759de54 commit f7c81fa

2 files changed

Lines changed: 170 additions & 74 deletions

File tree

src/components/plan/pso/pso_path_planner.py

Lines changed: 170 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -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
)
265 KB
Loading

0 commit comments

Comments
 (0)