From dc21a76b532b3c0f4072420a0aa5965da9659c77 Mon Sep 17 00:00:00 2001
From: Padraig Gleeson
Date: Sun, 12 Jul 2026 16:29:54 +0800
Subject: [PATCH 1/3] Updated to allow showing grid lines or following worm
along by zooming in
---
Player.py | 29 ++++++++++++
WormView.py | 128 +++++++++++++++++++++++++++++++++++++++++++++-------
test_all.sh | 6 +--
3 files changed, 143 insertions(+), 20 deletions(-)
diff --git a/Player.py b/Player.py
index 94ac666..ee8a97b 100644
--- a/Player.py
+++ b/Player.py
@@ -21,6 +21,10 @@ def __init__(
pos=(0.125, 0.92),
times=None,
t_units="",
+ grid_state=False,
+ zoom_state=False,
+ on_toggle_grid=None,
+ on_toggle_zoom=None,
**kwargs,
):
self.i = 0
@@ -30,6 +34,10 @@ def __init__(
self.forwards = True
self.fig = fig
self.func = func
+ self.grid_state = grid_state
+ self.zoom_state = zoom_state
+ self.on_toggle_grid = on_toggle_grid
+ self.on_toggle_zoom = on_toggle_zoom
self.setup(pos)
FuncAnimation.__init__(
self,
@@ -120,6 +128,27 @@ def setup(self, pos):
# self.text_box = matplotlib.widgets.TextBox(textax, label="", initial="0 ms")
+ checkax = self.fig.add_axes([0.02, 0.01, 0.13, 0.08])
+ self.check_labels = ["Grid", "Zoom"]
+ self.check_buttons = matplotlib.widgets.CheckButtons(
+ checkax, self.check_labels, [self.grid_state, self.zoom_state]
+ )
+ self.check_buttons.on_clicked(self.on_check_clicked)
+
+ def on_check_clicked(self, label):
+ status = dict(zip(self.check_labels, self.check_buttons.get_status()))
+ if label == "Grid":
+ self.grid_state = status["Grid"]
+ if self.on_toggle_grid is not None:
+ self.on_toggle_grid(self.grid_state)
+ elif label == "Zoom":
+ self.zoom_state = status["Zoom"]
+ if self.on_toggle_zoom is not None:
+ self.on_toggle_zoom(self.zoom_state)
+
+ self.func(self.i)
+ self.fig.canvas.draw_idle()
+
def set_pos(self, i):
self.i = int(self.slider.val)
self.func(self.i)
diff --git a/WormView.py b/WormView.py
index 8ea037c..1d769ed 100644
--- a/WormView.py
+++ b/WormView.py
@@ -1,4 +1,5 @@
from matplotlib import pyplot as plt
+from matplotlib.ticker import MultipleLocator, FormatStrFormatter
import numpy as np
import math
import os
@@ -8,6 +9,17 @@
from SimpleWCON import SimpleWCON
+class HalfIntegerLocator(MultipleLocator):
+ """Locator placing ticks at half-integer values (..., -0.5, 0.5, 1.5, ...),
+ regardless of the current view limits (so it stays fixed while panning/zooming)."""
+
+ def __init__(self):
+ super().__init__(base=1.0)
+
+ def tick_values(self, vmin, vmax):
+ return super().tick_values(vmin - 0.5, vmax - 0.5) + 0.5
+
+
def validate_file(file_path):
if not os.path.exists(file_path):
raise argparse.ArgumentTypeError(f"The file {file_path} does not exist.")
@@ -22,8 +34,17 @@ class WormView:
head_plot = None
times = None
- def __init__(self, show_head=False):
+ zoom_to_worm = False
+ show_grid = False
+ zoom_side = 1.5
+
+ def __init__(
+ self, show_head=False, zoom_to_worm=False, show_grid=False, zoom_side=1.5
+ ):
self.show_head = show_head
+ self.zoom_to_worm = zoom_to_worm
+ self.show_grid = show_grid
+ self.zoom_side = zoom_side
print(" - Initializing WormView")
def get_perimeter(self, x, y, r):
@@ -78,6 +99,50 @@ def reset(self):
self.head_plot = None
plt.close("all")
+ def set_full_view_limits(self):
+ factor = 0.05
+ if abs(self.wcon.xmax - self.wcon.xmin) > abs(self.wcon.ymax - self.wcon.ymin):
+ side_x = abs(self.wcon.xmax - self.wcon.xmin)
+ self.ax.set_xlim(
+ [self.wcon.xmin - side_x * factor, self.wcon.xmax + side_x * factor]
+ )
+ mid = (self.wcon.ymax + self.wcon.ymin) / 2
+ self.ax.set_ylim(
+ [mid - side_x * (0.5 + factor), mid + side_x * (0.5 + factor)]
+ )
+ else:
+ side_y = abs(self.wcon.ymax - self.wcon.ymin)
+ self.ax.set_ylim(
+ [self.wcon.ymin - side_y * factor, self.wcon.ymax + side_y * factor]
+ )
+ mid = (self.wcon.xmax + self.wcon.xmin) / 2
+ self.ax.set_xlim(
+ [mid - side_y * (0.5 + factor), mid + side_y * (0.5 + factor)]
+ )
+
+ def set_zoom_to_worm(self, value):
+ print(" - Setting zoom_to_worm: %s" % value)
+ self.zoom_to_worm = value
+ if not value:
+ self.set_full_view_limits()
+
+ def set_show_grid(self, value):
+ print(" - Setting show_grid: %s" % value)
+ self.show_grid = value
+ if value:
+ self.ax.xaxis.set_major_locator(MultipleLocator(1))
+ self.ax.yaxis.set_major_locator(MultipleLocator(1))
+ self.ax.xaxis.set_minor_locator(HalfIntegerLocator())
+ self.ax.yaxis.set_minor_locator(HalfIntegerLocator())
+ self.ax.xaxis.set_minor_formatter(FormatStrFormatter("%.1f"))
+ self.ax.yaxis.set_minor_formatter(FormatStrFormatter("%.1f"))
+ self.ax.tick_params(axis="both", which="minor", labelsize=8)
+ self.ax.grid(True, which="major", linestyle="-", linewidth=0.5, alpha=0.7)
+ self.ax.grid(True, which="minor", linestyle=":", linewidth=0.5, alpha=0.7)
+ else:
+ self.ax.grid(False, which="major")
+ self.ax.grid(False, which="minor")
+
def get_plot(self, args):
# global times, t_units, x, y, px, py, ax
@@ -91,21 +156,10 @@ def get_plot(self, args):
self.ax.set_xlabel("x (%s)" % self.wcon.x_units_used)
self.ax.set_ylabel("y (%s)" % self.wcon.y_units_used)
- factor = 0.05
- if abs(self.wcon.xmax - self.wcon.xmin) > abs(self.wcon.ymax - self.wcon.ymin):
- side = abs(self.wcon.xmax - self.wcon.xmin)
- self.ax.set_xlim(
- [self.wcon.xmin - side * factor, self.wcon.xmax + side * factor]
- )
- mid = (self.wcon.ymax + self.wcon.ymin) / 2
- self.ax.set_ylim([mid - side * (0.5 + factor), mid + side * (0.5 + factor)])
- else:
- side = abs(self.wcon.ymax - self.wcon.ymin)
- self.ax.set_ylim(
- [self.wcon.ymin - side * factor, self.wcon.ymax + side * factor]
- )
- mid = (self.wcon.xmax + self.wcon.xmin) / 2
- self.ax.set_xlim([mid - side * (0.5 + factor), mid + side * (0.5 + factor)])
+ if not self.zoom_to_worm:
+ self.set_full_view_limits()
+
+ self.set_show_grid(self.show_grid)
if "@CelegansNeuromechanicalGaitModulation" in self.wcon.extras:
center_x_arr = self.wcon.extras["@CelegansNeuromechanicalGaitModulation"][
@@ -196,6 +250,21 @@ def update(self, ti):
else:
self.head_plot.set_data([self.wcon.x[0, ti]], [self.wcon.y[0, ti]])
+ if self.zoom_to_worm:
+ xi = self.wcon.x[:, ti]
+ yi = self.wcon.y[:, ti]
+ if self.px is not None and self.py is not None:
+ xi = np.concatenate([xi, self.px[:, ti]])
+ yi = np.concatenate([yi, self.py[:, ti]])
+
+ cx = (np.nanmax(xi) + np.nanmin(xi)) / 2
+ cy = (np.nanmax(yi) + np.nanmin(yi)) / 2
+
+ half = self.zoom_side / 2
+
+ self.ax.set_xlim([cx - half, cx + half])
+ self.ax.set_ylim([cy - half, cy + half])
+
return self.midline_plot, self.perimeter_plot, self.head_plot
@@ -239,6 +308,18 @@ def parse_args():
action="store_true",
help="Show the head of the worm.",
)
+ parser.add_argument(
+ "-zoom",
+ "--zoom_to_worm",
+ action="store_true",
+ help="Zoom the view to the extent of the worm.",
+ )
+ parser.add_argument(
+ "-grid",
+ "--show_grid",
+ action="store_true",
+ help="Show the grid in the view.",
+ )
args = parser.parse_args()
@@ -252,7 +333,11 @@ def main():
print(" - Arguments parsed: %s" % args)
- wv = WormView(show_head=args.show_head)
+ wv = WormView(
+ show_head=args.show_head,
+ zoom_to_worm=args.zoom_to_worm,
+ show_grid=args.show_grid,
+ )
fig, ax = wv.get_plot(args)
@@ -266,8 +351,17 @@ def update(ti):
maxi=len(wv.wcon.times) - 1,
times=[t for t in wv.wcon.times],
t_units=wv.wcon.t_units,
+ grid_state=wv.show_grid,
+ zoom_state=wv.zoom_to_worm,
+ on_toggle_grid=wv.set_show_grid,
+ on_toggle_zoom=wv.set_zoom_to_worm,
)
+ if args.nogui:
+ # Checkboxes are only useful for interactive control; don't bake them
+ # into the frames used for the saved movie.
+ anim.check_buttons.ax.set_visible(False)
+
if not args.nogui:
plt.show()
else:
diff --git a/test_all.sh b/test_all.sh
index b2b06be..831f82f 100755
--- a/test_all.sh
+++ b/test_all.sh
@@ -4,8 +4,8 @@ set -ex
ruff format *.py
ruff check *.py
-python WormView.py -f examples/simdata.wcon -nogui # Test reloading WCON in Player
-python WormView.py -f examples/worm_motion_log.wcon -nogui
-python WormView.py -f examples/output_W2D.wcon -nogui
+python WormView.py -f examples/simdata.wcon -nogui -z # Test reloading WCON in Player
+python WormView.py -f examples/worm_motion_log.wcon -nogui -g
+python WormView.py -f examples/output_W2D.wcon -nogui -g -z
#python WormView.py -f examples/N2\ on\ food\ L_2009_09_15__11_01_01___8___1.wcon.zip -nogui
From 0b0d874f08f5092f4ea7d34b27fd0ce2aad88885 Mon Sep 17 00:00:00 2001
From: Padraig Gleeson
Date: Sun, 12 Jul 2026 16:39:47 +0800
Subject: [PATCH 2/3] Adding fast forward button
---
Player.py | 18 ++++++++++++++++--
1 file changed, 16 insertions(+), 2 deletions(-)
diff --git a/Player.py b/Player.py
index ee8a97b..b42e928 100644
--- a/Player.py
+++ b/Player.py
@@ -32,6 +32,7 @@ def __init__(
self.max = maxi
self.runs = True
self.forwards = True
+ self.step = 1
self.fig = fig
self.func = func
self.grid_state = grid_state
@@ -54,10 +55,11 @@ def __init__(
def play(self):
while self.runs:
- self.i = self.i + self.forwards - (not self.forwards)
- if self.i > self.min and self.i < self.max:
+ self.i = self.i + self.step * (1 if self.forwards else -1)
+ if self.min < self.i < self.max:
yield self.i
else:
+ self.i = self.max if self.forwards else self.min
self.stop()
yield self.i
@@ -71,10 +73,17 @@ def stop(self, event=None):
def forward(self, event=None):
self.forwards = True
+ self.step = 1
self.start()
def backward(self, event=None):
self.forwards = False
+ self.step = 1
+ self.start()
+
+ def fastforward(self, event=None):
+ self.forwards = True
+ self.step = 10
self.start()
def oneforward(self, event=None):
@@ -103,17 +112,22 @@ def setup(self, pos):
bax = divider.append_axes("right", size="80%", pad=0.05)
sax = divider.append_axes("right", size="80%", pad=0.05)
fax = divider.append_axes("right", size="80%", pad=0.05)
+ ffax = divider.append_axes("right", size="80%", pad=0.05)
ofax = divider.append_axes("right", size="100%", pad=0.05)
sliderax = divider.append_axes("right", size="500%", pad=0.07)
self.button_oneback = matplotlib.widgets.Button(playerax, label="$\u29cf$")
self.button_back = matplotlib.widgets.Button(bax, label="$\u25c0$")
self.button_stop = matplotlib.widgets.Button(sax, label="$\u25a0$")
self.button_forward = matplotlib.widgets.Button(fax, label="$\u25b6$")
+ self.button_fastforward = matplotlib.widgets.Button(
+ ffax, label="$\u25b6\u25b6$"
+ )
self.button_oneforward = matplotlib.widgets.Button(ofax, label="$\u29d0$")
self.button_oneback.on_clicked(self.onebackward)
self.button_back.on_clicked(self.backward)
self.button_stop.on_clicked(self.stop)
self.button_forward.on_clicked(self.forward)
+ self.button_fastforward.on_clicked(self.fastforward)
self.button_oneforward.on_clicked(self.oneforward)
self.slider = matplotlib.widgets.Slider(
sliderax, "", self.min, self.max, valinit=self.i
From 53d99623c38e49521c9965edb13bcc39a1c936ad Mon Sep 17 00:00:00 2001
From: Padraig Gleeson
Date: Mon, 13 Jul 2026 20:30:11 +0800
Subject: [PATCH 3/3] More testing
---
WormView.py | 2 +-
app.py | 50 ++++++++++++++++++++++++++++++++++++++++----------
2 files changed, 41 insertions(+), 11 deletions(-)
diff --git a/WormView.py b/WormView.py
index 1d769ed..3c67d37 100644
--- a/WormView.py
+++ b/WormView.py
@@ -36,7 +36,7 @@ class WormView:
zoom_to_worm = False
show_grid = False
- zoom_side = 1.5
+ zoom_side = 1.5 # in mm
def __init__(
self, show_head=False, zoom_to_worm=False, show_grid=False, zoom_side=1.5
diff --git a/app.py b/app.py
index 96c1a3e..f6ed73f 100644
--- a/app.py
+++ b/app.py
@@ -20,11 +20,11 @@ def make_args(wcon_file):
)
-def load_view(wcon_file, show_head):
+def load_view(wcon_file, show_head, show_grid=False, zoom_to_worm=False):
"""Load and parse the WCON file once and build the figure. The result is
kept in session_state so the (potentially large) file is parsed only when
the file or options change, not on every frame."""
- wv = WormView(show_head=show_head)
+ wv = WormView(show_head=show_head, show_grid=show_grid, zoom_to_worm=zoom_to_worm)
wv.get_plot(make_args(wcon_file))
return wv
@@ -33,13 +33,17 @@ def load_view(wcon_file, show_head):
wcon_file = st.text_input("WCON file", value=DEFAULT_WCON)
show_head = st.checkbox("Show head", value=False)
+show_grid = st.checkbox("Show grid", value=False)
+zoom_to_worm = st.checkbox("Zoom to worm", value=False)
# Load only when the file or options change; otherwise reuse the cached view.
-key = (wcon_file, show_head)
+key = (wcon_file, show_head, show_grid, zoom_to_worm)
if st.session_state.get("loaded_key") != key:
try:
with st.spinner(f"Loading {wcon_file}..."):
- st.session_state.wv = load_view(wcon_file, show_head)
+ st.session_state.wv = load_view(
+ wcon_file, show_head, show_grid, zoom_to_worm
+ )
except Exception as e:
st.error(f"Could not load '{wcon_file}': {e}")
st.stop()
@@ -54,15 +58,41 @@ def load_view(wcon_file, show_head):
st.session_state.setdefault("running", False)
# --- Controls (rendered before any rerun so they are always clickable) ---
-c1, c2, c3, c4 = st.columns(4)
-if c1.button("Play", use_container_width=True):
+c1, c2, c3, c4, c5, c6, c7 = st.columns(7)
+
+# one back
+if c1.button("$\u29cf$", use_container_width=True):
+ st.session_state.running = False
+ st.session_state.t -= 1
+
+# play backwards
+if c2.button("$\u25c0$", use_container_width=True):
st.session_state.running = True
-if c2.button("Pause", use_container_width=True):
+ st.session_state.t -= 1
+ st.session_state.step = -1
+
+# stop
+if c3.button("$\u25a0$", use_container_width=True):
st.session_state.running = False
-if c3.button("Step", use_container_width=True):
+
+# play
+if c4.button("$\u25b6$", use_container_width=True):
+ st.session_state.running = True
+ st.session_state.t += 1
+ st.session_state.step = 1
+
+# fast forward
+if c5.button("$\u25b6\u25b6$", use_container_width=True):
+ st.session_state.running = True
+ st.session_state.t += 10
+ st.session_state.step = 10
+
+# step forward
+if c6.button("$\u29d0$", use_container_width=True):
st.session_state.running = False
st.session_state.t += 1
-if c4.button("Reset", use_container_width=True):
+
+if c7.button("Reset", use_container_width=True):
st.session_state.running = False
st.session_state.t = 0
@@ -86,6 +116,6 @@ def load_view(wcon_file, show_head):
if ti >= n_frames - 1:
st.session_state.running = False # reached the end
else:
- st.session_state.t += 1
+ st.session_state.t += st.session_state.step
time.sleep(FRAME_DELAY)
st.rerun()