Interactive Control Over Parallel Computations#

Demonstrates the ability to drive and manipulate computations/simulations from within napari. The basis of the compute-intensive simulation in this example is a 3D implementation of Boids [1] which is accelerated by using multiple CPU cores either on a single machine (e.g. a laptop) via the multiprocessing [2] module from the Python Standard Library or across multiple nodes (e.g. a Linux cluster or even a supercomputer) via Dragon [3] which plugs into standard multiprocessing to extend it to execute across multiple nodes.

Within are examples of how to start/pause/restart/stop computation from the gui via QTWidgets and napari’s thread_worker.

Tags: interactivity, gui

boids simulation
Note: dragon unavailable; using standard multiprocessing
/home/runner/work/docs/docs/.venv/lib/python3.12/site-packages/napari/_qt/qt_event_loop.py:50: UserWarning: System theme detection requires a Qt6 backend. Please switch to PyQt6 or PySide6 to use it.
  theme_type=get_system_theme(),
/home/runner/work/docs/docs/.venv/lib/python3.12/site-packages/napari/_qt/qt_event_loop.py:50: UserWarning: System theme detection requires a Qt6 backend. Please switch to PyQt6 or PySide6 to use it.
  theme_type=get_system_theme(),

try:
    import dragon  # isort:skip  # noqa: F401
    import multiprocessing as mp  # Must be after import of dragon

    mp.set_start_method("dragon")
except (ImportError, ValueError):
    if __name__ == "__main__":
        print("Note: dragon unavailable; using standard multiprocessing")
except RuntimeError as re:
    print(f"Warning: {re}")
finally:
    import multiprocessing as mp

import sys
import time

import numpy as np
import qtpy.QtGui
import qtpy.QtWidgets

import napari


def radarray(N):
    return np.random.uniform(0, 2 * np.pi, N)


def count(mask, n):
    return np.maximum(mask.sum(axis=1), 1).reshape(n, 1)


def limit_acceleration(steer, n, maxacc=0.03):
    norm = np.sqrt((steer * steer).sum(axis=1)).reshape(n, 1)
    np.multiply(steer, maxacc / norm, out=steer, where=norm > maxacc)
    return norm, steer


class Boids3D:
    """Implementation of Boids in 3D.  Ref: http://www.red3d.com/cwr/boids/

    Directly inspired by implementation for HoloViz by jlstevens.
    Ref: https://examples.holoviz.org/gallery/boids/boids.html
    """

    def __init__(self, N=500, width=400, height=400, depth=400):
        self.width, self.height, self.depth, self.iteration = width, height, depth, 0
        self.vel = np.vstack(
            [
                np.cos(radarray(N)),
                np.sin(radarray(N)),
                np.cos(radarray(N)),
            ]
        ).T
        r = min(width, height) / 2 * np.random.uniform(0, 1, N)
        self.pos = np.vstack(
            [
                width / 2 + np.cos(radarray(N)) * r / 4,
                height / 2 + np.sin(radarray(N)) * r,
                depth / 2 + np.sin(radarray(N)) * r,
            ]
        ).T

    def flock(
        self,
        min_vel=0.5,
        max_vel=2.0,
        veryclose_radius=25,
        kindaclose_radius=50,
        slice_tot=1,
        pool=None,
    ):
        assert veryclose_radius <= kindaclose_radius
        kwargs = {
            'pos': self.pos,
            'vel': self.vel,
            'width': self.width,
            'height': self.height,
            'depth': self.depth,
            'min_vel': min_vel,
            'max_vel': max_vel,
            'veryclose_radius': veryclose_radius,
            'kindaclose_radius': kindaclose_radius,
        }
        if pool is None:
            self.pos[:], self.vel[:] = self.compute_flock_step_forward(**kwargs)
        else:
            tasks = [
                pool.apply_async(
                    self.compute_flock_step_forward,
                    kwds=(kwargs | {"slice_idx": slice_idx, "slice_tot": slice_tot}),
                )
                for slice_idx in range(slice_tot)
            ]
            for slice_idx in range(slice_tot):
                self.pos[slice_idx::slice_tot], self.vel[slice_idx::slice_tot] = tasks[
                    slice_idx
                ].get(timeout=30)
        self.iteration += 1

    @staticmethod
    def compute_flock_step_forward(
        pos,
        vel,
        width,
        height,
        depth,
        min_vel=0.5,
        max_vel=2.0,
        veryclose_radius=25,
        kindaclose_radius=50,
        slice_idx=0,
        slice_tot=1,
    ):
        assert slice_tot > slice_idx >= 0
        all_pos, all_vel = pos, vel
        pos = all_pos[slice_idx::slice_tot]
        vel = all_vel[slice_idx::slice_tot]
        n_pos, n_all_pos = len(pos), len(all_pos)

        dx = np.subtract.outer(pos[:, 0], all_pos[:, 0])
        dy = np.subtract.outer(pos[:, 1], all_pos[:, 1])
        dz = np.subtract.outer(pos[:, 2], all_pos[:, 2])

        dist = np.hypot(np.hypot(dx, dy), dz)
        mask_veryclose, mask_kindaclose = (
            (dist > 0) * (dist < veryclose_radius),
            (dist > 0) * (dist < kindaclose_radius),
        )
        target = np.dstack((dx, dy, dz))
        target = np.divide(
            target,
            dist.reshape(n_pos, n_all_pos, 1) ** 2,
            out=target,
            where=dist.reshape(n_pos, n_all_pos, 1) != 0,
        )
        steer = (target * mask_veryclose.reshape(n_pos, n_all_pos, 1)).sum(
            axis=1
        ) / count(mask_veryclose, n_pos)
        norm = np.sqrt((steer * steer).sum(axis=1)).reshape(n_pos, 1)
        steer = max_vel * np.divide(steer, norm, out=steer, where=norm != 0) - vel
        norm, separation = limit_acceleration(steer, n_pos)
        target = np.dot(mask_kindaclose, all_vel) / count(mask_kindaclose, n_pos)
        norm = np.sqrt((target * target).sum(axis=1)).reshape(n_pos, 1)
        target = max_vel * np.divide(target, norm, out=target, where=norm != 0)
        steer = target - vel
        norm, alignment = limit_acceleration(steer, n_pos)
        target = np.dot(mask_kindaclose, all_pos) / count(mask_kindaclose, n_pos)
        desired = target - pos
        norm = np.sqrt((desired * desired).sum(axis=1)).reshape(n_pos, 1)
        desired *= max_vel / norm
        steer = desired - vel
        norm, cohesion = limit_acceleration(steer, n_pos)
        vel += 1.5 * separation + alignment + cohesion
        norm = np.sqrt((vel * vel).sum(axis=1)).reshape(n_pos, 1)
        np.multiply(vel, max_vel / norm, out=vel, where=norm > max_vel)
        np.multiply(vel, min_vel / norm, out=vel, where=norm < min_vel)
        pos += vel + (width, height, depth)
        pos %= (width, height, depth)

        return pos, vel


@napari.qt.thread_worker
def iterate_flock_forever(boids):
    while True:
        boids.flock()
        yield boids.pos


@napari.qt.thread_worker
def iterate_accelerated_flock_forever(boids, num_workers=8):
    with mp.Pool(num_workers) as pool:
        while True:
            boids.flock(pool=pool, slice_tot=num_workers)
            yield boids.pos


timestamp_last_update = time.monotonic()
boids_layer = None
control_widget = None


def update_points_in_display(boids, state):
    global timestamp_last_update
    if boids_layer is not None:
        boids_layer.data = boids.pos
        timestamp_this_update = time.monotonic()
        elapsed_time_update, timestamp_last_update = (
            (timestamp_this_update - timestamp_last_update),
            timestamp_this_update,
        )
        boids_layer.name = (
            f"Iter_{boids.iteration} ({elapsed_time_update:.3f}s/iter) "
            f"{state['num_boids']}-boids-on-{state['num_workers']}-procs"
        )


class BoidsControlWidget(qtpy.QtWidgets.QWidget):
    """Dock widget with controls for a running Boids simulation.

    All controls are built once. Button enabled states and text change as
    the simulation moves between idle, running, and finished states.
    """

    def __init__(self, viewer, worker, state):
        super().__init__()
        self._viewer = viewer
        self._worker = worker
        self._state = state
        self._paused = False

        layout = qtpy.QtWidgets.QVBoxLayout(self)

        form = qtpy.QtWidgets.QFormLayout()
        self._edit_num_boids = qtpy.QtWidgets.QLineEdit(
            str(state.get("num_boids", 1500))
        )
        self._edit_num_boids.setValidator(qtpy.QtGui.QIntValidator(1, 100_000))
        self._edit_num_procs = qtpy.QtWidgets.QLineEdit(
            str(state.get("num_workers", 1))
        )
        self._edit_num_procs.setValidator(qtpy.QtGui.QIntValidator(1, 256))
        form.addRow("Number of Boids", self._edit_num_boids)
        form.addRow("Number of Processes", self._edit_num_procs)
        layout.addLayout(form)

        self._hide_prev_check = qtpy.QtWidgets.QCheckBox("Hide previous sim")
        self._hide_prev_check.setChecked(True)
        self._hide_prev_check.setVisible(False)
        layout.addWidget(self._hide_prev_check)

        self._start_btn = qtpy.QtWidgets.QPushButton("Start Sim")
        self._pause_btn = qtpy.QtWidgets.QPushButton("Pause")
        self._stop_btn = qtpy.QtWidgets.QPushButton("Terminate")
        for btn in (self._start_btn, self._pause_btn, self._stop_btn):
            layout.addWidget(btn)
        self._pause_btn.setEnabled(False)
        self._stop_btn.setEnabled(False)

        self._start_btn.clicked.connect(self._on_start)
        self._pause_btn.clicked.connect(self._on_pause_resume)
        self._stop_btn.clicked.connect(self._on_stop)
        worker.finished.connect(self._on_worker_finished)

    def _on_start(self):
        self._worker.start()
        self._start_btn.setEnabled(False)
        self._edit_num_boids.setEnabled(False)
        self._edit_num_procs.setEnabled(False)
        self._pause_btn.setEnabled(True)
        self._stop_btn.setEnabled(True)

    def _on_pause_resume(self):
        if self._paused:
            self._worker.resume()
            self._pause_btn.setText("Pause")
        else:
            self._worker.pause()
            self._pause_btn.setText("Resume")
        self._paused = not self._paused

    def _on_stop(self):
        global boids_layer
        self._worker.quit()
        boids_layer = None

    def _on_worker_finished(self):
        self._edit_num_boids.setEnabled(True)
        self._edit_num_procs.setEnabled(True)
        self._pause_btn.setEnabled(False)
        self._pause_btn.setText("Pause")
        self._stop_btn.setEnabled(False)
        self._hide_prev_check.setVisible(True)
        self._start_btn.setText("Launch New Sim!")
        self._start_btn.setEnabled(True)
        self._start_btn.clicked.disconnect(self._on_start)
        self._start_btn.clicked.connect(self._on_relaunch)

    def _on_relaunch(self):
        num_boids = int(self._edit_num_boids.text())
        num_procs = int(self._edit_num_procs.text())
        if self._hide_prev_check.isChecked() and self._viewer.layers:
            self._viewer.layers[-1].visible = False
        self._state.update(
            num_boids=num_boids,
            num_workers=num_procs,
            parallel=num_procs > 1,
        )
        main(**self._state)


def main(
    num_boids=500, width=600, height=600, depth=800, parallel=False, num_workers=1
):
    global boids_layer, control_widget
    state = {
        'num_boids': num_boids,
        'width': width,
        'height': height,
        'depth': depth,
        'parallel': parallel,
        'num_workers': num_workers,
    }
    boids = Boids3D(num_boids, width, height, depth)

    title = f"napari+{'Dragon' if 'dragon' in sys.modules else 'multiprocessing'}"
    viewer = napari.current_viewer() or napari.Viewer(title=title, ndisplay=3)
    viewer.dims.ndisplay = 3  # ensure 3-D display when reusing an existing viewer

    if control_widget is not None:
        viewer.window.remove_dock_widget(control_widget)
        control_widget = None

    boids_layer = viewer.add_points(
        boids.pos, 3, name="Boids3D"
    )

    if not parallel:
        worker = iterate_flock_forever(boids)
    else:
        worker = iterate_accelerated_flock_forever(boids, num_workers=num_workers)
    worker.yielded.connect(lambda _: update_points_in_display(boids, state))

    control_widget = BoidsControlWidget(viewer, worker, state)
    viewer.window.add_dock_widget(control_widget, area='right', name='Boids Controls')

    return boids, viewer, worker


if __name__ == "__main__":
    b, v, w = main(1500, parallel=True, num_workers=4)
    napari.run()

Gallery generated by Sphinx-Gallery