From aafaa2e7df392234f53d18422b21b8a2f7e2c13f Mon Sep 17 00:00:00 2001 From: "Axel.Cffrd.Dnty" <150222552+AxelNoun@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:04:10 +0200 Subject: [PATCH 1/8] Guard CoW read-only .to_numpy()/.values mutations (#3362) Under pandas 3.0 Copy-on-Write, .to_numpy()/.values return read-only arrays for single-dtype selections; in-place mutation raises ValueError. Use copy=True at the five affected sites. Pin unchanged. --- deeplabcut/pose_estimation_3d/plotting3D.py | 17 ++++++++++++++--- deeplabcut/pose_estimation_3d/triangulation.py | 9 +++++++-- deeplabcut/pose_estimation_pytorch/data/ctd.py | 7 +++++-- deeplabcut/post_processing/filtering.py | 6 ++++-- deeplabcut/refine_training_dataset/tracklets.py | 5 +++-- 5 files changed, 33 insertions(+), 11 deletions(-) diff --git a/deeplabcut/pose_estimation_3d/plotting3D.py b/deeplabcut/pose_estimation_3d/plotting3D.py index d39967a86..0b11f17be 100644 --- a/deeplabcut/pose_estimation_3d/plotting3D.py +++ b/deeplabcut/pose_estimation_3d/plotting3D.py @@ -215,15 +215,26 @@ def create_labeled_video_3d( bodyparts2plot = list(np.unique([val for sublist in bodyparts2connect for val in sublist])) # Format data + # copy=True: under pandas 3 CoW, to_numpy() may be read-only. mask2d = df_cam1.columns.get_level_values("bodyparts").isin(bodyparts2plot) - xy1 = df_cam1.iloc[: len(df_3d)].loc[:, mask2d].to_numpy().reshape((len(df_3d), -1, 3)) + xy1 = ( + df_cam1.iloc[: len(df_3d)] + .loc[:, mask2d] + .to_numpy(copy=True) + .reshape((len(df_3d), -1, 3)) + ) visible1 = xy1[..., 2] >= pcutoff xy1[~visible1] = np.nan - xy2 = df_cam2.iloc[: len(df_3d)].loc[:, mask2d].to_numpy().reshape((len(df_3d), -1, 3)) + xy2 = ( + df_cam2.iloc[: len(df_3d)] + .loc[:, mask2d] + .to_numpy(copy=True) + .reshape((len(df_3d), -1, 3)) + ) visible2 = xy2[..., 2] >= pcutoff xy2[~visible2] = np.nan mask = df_3d.columns.get_level_values("bodyparts").isin(bodyparts2plot) - xyz = df_3d.loc[:, mask].to_numpy().reshape((len(df_3d), -1, 3)) + xyz = df_3d.loc[:, mask].to_numpy(copy=True).reshape((len(df_3d), -1, 3)) xyz[~(visible1 & visible2)] = np.nan bpts = df_3d.columns.get_level_values("bodyparts")[mask][::3] diff --git a/deeplabcut/pose_estimation_3d/triangulation.py b/deeplabcut/pose_estimation_3d/triangulation.py index c213bfd31..b57d79a21 100644 --- a/deeplabcut/pose_estimation_3d/triangulation.py +++ b/deeplabcut/pose_estimation_3d/triangulation.py @@ -326,8 +326,13 @@ def triangulate( num_frames = dataFrame_camera1_undistort.shape[0] ### Assign nan to [X,Y] of low likelihood predictions ### # Convert the data to a np array to easily mask out the low likelihood predictions - data_cam1_tmp = dataFrame_camera1_undistort.to_numpy().reshape((num_frames, -1, 3)) - data_cam2_tmp = dataFrame_camera2_undistort.to_numpy().reshape((num_frames, -1, 3)) + # copy=True: under pandas 3 CoW, to_numpy() may be read-only. + data_cam1_tmp = dataFrame_camera1_undistort.to_numpy(copy=True).reshape( + (num_frames, -1, 3) + ) + data_cam2_tmp = dataFrame_camera2_undistort.to_numpy(copy=True).reshape( + (num_frames, -1, 3) + ) # Assign [X,Y] = nan to low likelihood predictions data_cam1_tmp[data_cam1_tmp[..., 2] < pcutoff, :2] = np.nan data_cam2_tmp[data_cam2_tmp[..., 2] < pcutoff, :2] = np.nan diff --git a/deeplabcut/pose_estimation_pytorch/data/ctd.py b/deeplabcut/pose_estimation_pytorch/data/ctd.py index b192f5126..8f6dfb39c 100644 --- a/deeplabcut/pose_estimation_pytorch/data/ctd.py +++ b/deeplabcut/pose_estimation_pytorch/data/ctd.py @@ -238,8 +238,11 @@ def load_conditions_h5( """ def _parse_row(df_row) -> np.ndarray: - # Row to numpy and reshape - pose = df_row.to_numpy().reshape((num_conditions, num_bodyparts, 3)) + # Row to numpy and reshape. copy=True: under pandas 3 CoW, + # to_numpy() may return a read-only view. + pose = df_row.to_numpy(copy=True).reshape( + (num_conditions, num_bodyparts, 3) + ) # Remove missing data missing_keypoints = np.any(np.isnan(pose) | (pose < 0), axis=2) diff --git a/deeplabcut/post_processing/filtering.py b/deeplabcut/post_processing/filtering.py index e37591772..323409204 100644 --- a/deeplabcut/post_processing/filtering.py +++ b/deeplabcut/post_processing/filtering.py @@ -245,8 +245,10 @@ def filterpredictions( elif filtertype == "spline": data = df.copy() mask_data = data.columns.get_level_values("coords").isin(("x", "y")) - xy = data.loc[:, mask_data].values - prob = data.loc[:, ~mask_data].values + # copy=True: under pandas 3 CoW, homogeneous float selections + # via .values/.to_numpy() are read-only views. + xy = data.loc[:, mask_data].to_numpy(copy=True) + prob = data.loc[:, ~mask_data].to_numpy(copy=True) missing = np.isnan(xy) xy_filled = columnwise_spline_interp(xy, windowlength) filled = ~np.isnan(xy_filled) diff --git a/deeplabcut/refine_training_dataset/tracklets.py b/deeplabcut/refine_training_dataset/tracklets.py index 4e07e02d0..50b49a976 100644 --- a/deeplabcut/refine_training_dataset/tracklets.py +++ b/deeplabcut/refine_training_dataset/tracklets.py @@ -221,8 +221,9 @@ def load_tracklets_from_hdf(self, filename): self.filename = filename df = pd.read_hdf(filename) - # Fill existing gaps - data = df.to_numpy() + # Fill existing gaps. copy=True: under pandas 3 CoW, to_numpy() may + # return a read-only view that cannot be mutated in place. + data = df.to_numpy(copy=True) mask = ~df.columns.get_level_values(level="coords").str.contains("likelihood") xy = data[:, mask] prob = data[:, ~mask] From 85291460806541469fdfcf4ce9dd3694e58ac417 Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter van Steveninck <32810691+deruyter92@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:05:17 +0200 Subject: [PATCH 2/8] linting --- deeplabcut/pose_estimation_3d/plotting3D.py | 14 ++------------ deeplabcut/pose_estimation_3d/triangulation.py | 8 ++------ deeplabcut/pose_estimation_pytorch/data/ctd.py | 4 +--- 3 files changed, 5 insertions(+), 21 deletions(-) diff --git a/deeplabcut/pose_estimation_3d/plotting3D.py b/deeplabcut/pose_estimation_3d/plotting3D.py index 0b11f17be..593e71203 100644 --- a/deeplabcut/pose_estimation_3d/plotting3D.py +++ b/deeplabcut/pose_estimation_3d/plotting3D.py @@ -217,20 +217,10 @@ def create_labeled_video_3d( # Format data # copy=True: under pandas 3 CoW, to_numpy() may be read-only. mask2d = df_cam1.columns.get_level_values("bodyparts").isin(bodyparts2plot) - xy1 = ( - df_cam1.iloc[: len(df_3d)] - .loc[:, mask2d] - .to_numpy(copy=True) - .reshape((len(df_3d), -1, 3)) - ) + xy1 = df_cam1.iloc[: len(df_3d)].loc[:, mask2d].to_numpy(copy=True).reshape((len(df_3d), -1, 3)) visible1 = xy1[..., 2] >= pcutoff xy1[~visible1] = np.nan - xy2 = ( - df_cam2.iloc[: len(df_3d)] - .loc[:, mask2d] - .to_numpy(copy=True) - .reshape((len(df_3d), -1, 3)) - ) + xy2 = df_cam2.iloc[: len(df_3d)].loc[:, mask2d].to_numpy(copy=True).reshape((len(df_3d), -1, 3)) visible2 = xy2[..., 2] >= pcutoff xy2[~visible2] = np.nan mask = df_3d.columns.get_level_values("bodyparts").isin(bodyparts2plot) diff --git a/deeplabcut/pose_estimation_3d/triangulation.py b/deeplabcut/pose_estimation_3d/triangulation.py index b57d79a21..3b945166c 100644 --- a/deeplabcut/pose_estimation_3d/triangulation.py +++ b/deeplabcut/pose_estimation_3d/triangulation.py @@ -327,12 +327,8 @@ def triangulate( ### Assign nan to [X,Y] of low likelihood predictions ### # Convert the data to a np array to easily mask out the low likelihood predictions # copy=True: under pandas 3 CoW, to_numpy() may be read-only. - data_cam1_tmp = dataFrame_camera1_undistort.to_numpy(copy=True).reshape( - (num_frames, -1, 3) - ) - data_cam2_tmp = dataFrame_camera2_undistort.to_numpy(copy=True).reshape( - (num_frames, -1, 3) - ) + data_cam1_tmp = dataFrame_camera1_undistort.to_numpy(copy=True).reshape((num_frames, -1, 3)) + data_cam2_tmp = dataFrame_camera2_undistort.to_numpy(copy=True).reshape((num_frames, -1, 3)) # Assign [X,Y] = nan to low likelihood predictions data_cam1_tmp[data_cam1_tmp[..., 2] < pcutoff, :2] = np.nan data_cam2_tmp[data_cam2_tmp[..., 2] < pcutoff, :2] = np.nan diff --git a/deeplabcut/pose_estimation_pytorch/data/ctd.py b/deeplabcut/pose_estimation_pytorch/data/ctd.py index 8f6dfb39c..d11abcab3 100644 --- a/deeplabcut/pose_estimation_pytorch/data/ctd.py +++ b/deeplabcut/pose_estimation_pytorch/data/ctd.py @@ -240,9 +240,7 @@ def load_conditions_h5( def _parse_row(df_row) -> np.ndarray: # Row to numpy and reshape. copy=True: under pandas 3 CoW, # to_numpy() may return a read-only view. - pose = df_row.to_numpy(copy=True).reshape( - (num_conditions, num_bodyparts, 3) - ) + pose = df_row.to_numpy(copy=True).reshape((num_conditions, num_bodyparts, 3)) # Remove missing data missing_keypoints = np.any(np.isnan(pose) | (pose < 0), axis=2) From 18bc897c7ba688f949d8049361172c26d46ba7ba Mon Sep 17 00:00:00 2001 From: "Axel.Cffrd.Dnty" <150222552+AxelNoun@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:08:07 +0200 Subject: [PATCH 3/8] Guard remaining CoW read-only .to_numpy()/.values mutations Follow-up to the pandas 3.0 CoW sweep (#3362). Three more sites extract a NumPy array from a pandas object and mutate it in place; under pandas 3 CoW these can be read-only views, raising "assignment destination is read-only". - refine_training_dataset/tracklets.py: self.data backs self.xy/self.prob, which swap_tracklets and the refine GUI mutate in place (HDF load path; flagged in review). - utils/make_labeled_video.py: coords is a view into xyp, masked in place in both the first-frame and per-frame branches. - pose_estimation_tensorflow/.../pose_multianimal_imgaug.py: a single-row float Series is read-only; kpts is masked in place when mask_kpts_below_thresh is set. Co-authored-by: Cursor --- .../datasets/pose_multianimal_imgaug.py | 4 +++- deeplabcut/refine_training_dataset/tracklets.py | 6 +++++- deeplabcut/utils/make_labeled_video.py | 4 +++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/deeplabcut/pose_estimation_tensorflow/datasets/pose_multianimal_imgaug.py b/deeplabcut/pose_estimation_tensorflow/datasets/pose_multianimal_imgaug.py index 525ca297e..a2645754f 100644 --- a/deeplabcut/pose_estimation_tensorflow/datasets/pose_multianimal_imgaug.py +++ b/deeplabcut/pose_estimation_tensorflow/datasets/pose_multianimal_imgaug.py @@ -135,7 +135,9 @@ def _load_pseudo_data_from_h5(self, cfg, threshold=0.5, mask_kpts_below_thresh=F item = DataItem() data = df.loc[imagename] # 3 for likelihood - kpts = data.to_numpy().reshape(-1, 3) + # copy=True: `kpts` is masked in place below (mask_kpts_below_thresh); + # under pandas 3 CoW a single-row float Series returns a read-only view. + kpts = data.to_numpy(copy=True).reshape(-1, 3) item.num_joints = kpts.shape[0] joint_ids = np.arange(item.num_joints)[..., np.newaxis] frame_name = "frame_" + str(int(imagename.split("frame")[1])) + ".png" diff --git a/deeplabcut/refine_training_dataset/tracklets.py b/deeplabcut/refine_training_dataset/tracklets.py index 50b49a976..929726855 100644 --- a/deeplabcut/refine_training_dataset/tracklets.py +++ b/deeplabcut/refine_training_dataset/tracklets.py @@ -246,7 +246,11 @@ def load_tracklets_from_hdf(self, filename): self.bodyparts = idx.get_level_values("bodyparts") self.nframes = len(df) self.times = np.arange(self.nframes) - self.data = df.values.reshape((self.nframes, -1, 3)).swapaxes(0, 1) + # copy=True: `self.xy`/`self.prob` are views into `self.data` and are + # mutated in place (e.g. by `swap_tracklets`), so the backing array must + # stay writable. Under pandas 3 CoW, `.values` on a homogeneous float + # DataFrame returns a read-only view. + self.data = df.to_numpy(copy=True).reshape((self.nframes, -1, 3)).swapaxes(0, 1) self.xy = self.data[:, :, :2] self.prob = self.data[:, :, 2] individuals = idx.get_level_values("individuals") diff --git a/deeplabcut/utils/make_labeled_video.py b/deeplabcut/utils/make_labeled_video.py index b8bde8447..8f0d9cf79 100644 --- a/deeplabcut/utils/make_labeled_video.py +++ b/deeplabcut/utils/make_labeled_video.py @@ -1017,7 +1017,9 @@ def create_video_with_keypoints_only( ny = int(np.nanmax(df.xs("y", axis=1, level="coords"))) n_frames = df.shape[0] - xyp = df.values.reshape((n_frames, -1, 3)) + # copy=True: `coords` is a view into `xyp` and is masked in place below; + # under pandas 3 CoW `.values` would return a read-only view. + xyp = df.to_numpy(copy=True).reshape((n_frames, -1, 3)) if color_by == "bodypart": map_ = bodyparts.map(dict(zip(bodypart_names, range(n_bodyparts), strict=False))) From b9534165fab1f59d01cedc46bed1fba0c8ec0560 Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter van Steveninck <32810691+deruyter92@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:55:15 +0200 Subject: [PATCH 4/8] fix pandas future mode tool: enable instead of "warn" --- deeplabcut/utils/pandas_future_mode.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deeplabcut/utils/pandas_future_mode.py b/deeplabcut/utils/pandas_future_mode.py index cfb9d952e..fb8adf40a 100644 --- a/deeplabcut/utils/pandas_future_mode.py +++ b/deeplabcut/utils/pandas_future_mode.py @@ -23,7 +23,7 @@ def configure_pandas_future_if_enabled() -> None: raise RuntimeError(f"pandas future mode requires pandas 2.3.x, got {pd.__version__}") pd.options.future.infer_string = True - pd.options.mode.copy_on_write = "warn" + pd.options.mode.copy_on_write = True print( f"pandas future mode enabled: pandas={pd.__version__}, " From 7101f3c8568fb5ee633e95e38bd55b1432784378 Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter van Steveninck <32810691+deruyter92@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:56:48 +0200 Subject: [PATCH 5/8] add tests for pd future mode tool --- tests/utils/test_pandas_cow.py | 106 +++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 tests/utils/test_pandas_cow.py diff --git a/tests/utils/test_pandas_cow.py b/tests/utils/test_pandas_cow.py new file mode 100644 index 000000000..343199f50 --- /dev/null +++ b/tests/utils/test_pandas_cow.py @@ -0,0 +1,106 @@ +# +# DeepLabCut Toolbox (deeplabcut.org) +# (c) A. & M.W. Mathis Labs +# https://github.com/DeepLabCut/DeepLabCut +# +# Please see AUTHORS for contributors. +# https://github.com/DeepLabCut/DeepLabCut/blob/master/AUTHORS +# +# Licensed under GNU Lesser General Public License v3.0 +# + +"""Verify that ``configure_pandas_future_if_enabled`` (copy_on_write=True) +surfaces mutation patterns that should be guarded by ``copy=True``. +""" + +import numpy as np +import pandas as pd +import pytest + +from deeplabcut.utils.pandas_future_mode import ( + configure_pandas_future_if_enabled, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _configure_cow_true(monkeypatch) -> None: + """Configure pandas CoW via the DLC future-mode machinery.""" + monkeypatch.setenv("DLC_PANDAS_FUTURE", "1") + configure_pandas_future_if_enabled() + + +def _make_dlc_df(n_frames: int = 5) -> pd.DataFrame: + """Build a small DLC-style MultiIndex DataFrame (scorer x bodypart x coord).""" + bodyparts = ["snout", "leftear", "rightear"] + coords = ["x", "y", "likelihood"] + arrays = {} + for bp in bodyparts: + for c in coords: + arrays[("scorer1", bp, c)] = np.random.randn(n_frames) + df = pd.DataFrame(arrays) + df.columns = pd.MultiIndex.from_tuples( + df.columns, + names=["scorer", "bodyparts", "coords"], + ) + return df + + +# --------------------------------------------------------------------------- +# Sanity: the machinery actually engages. +# --------------------------------------------------------------------------- + + +def test_future_mode_sets_cow_true(monkeypatch): + """``configure_pandas_future_if_enabled()`` sets copy_on_write=True.""" + _configure_cow_true(monkeypatch) + assert pd.options.mode.copy_on_write is True, f"Expected copy_on_write=True, got {pd.options.mode.copy_on_write!r}" + assert pd.options.future.infer_string is True + + +# --------------------------------------------------------------------------- +# The PR's patterns: extracting a numpy array, then mutating it in place. +# Without copy=True each would blow up at runtime under pandas 3. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "label, extract", + [ + pytest.param( + lambda df: df.to_numpy().reshape((len(df), -1, 3)), + id="to_numpy_reshape", + ), + pytest.param( + lambda df: df.values.reshape((len(df), -1, 3)), + id="values_reshape", + ), + pytest.param( + lambda df: df.loc[:, df.columns.get_level_values("coords").isin(("x", "y"))].to_numpy(), + id="column_subset", + ), + pytest.param( + lambda df: df.loc[df.index[0]].to_numpy().reshape(-1, 3), + id="single_row", + ), + ], +) +def test_future_mode_makes_numpy_arrays_read_only(extract, monkeypatch): + """``configure_pandas_future_if_enabled()`` with copy_on_write=True + returns read-only numpy arrays that cannot be mutated in place. + + These are the patterns that this PR guards with ``copy=True``. + Without the guard, each would raise ``ValueError`` (read-only array) + under pandas 3. + """ + _configure_cow_true(monkeypatch) + df = _make_dlc_df() + + arr = extract(df) + + assert not arr.flags.writeable, "array unexpectedly writeable — CoW may not be active" + + with pytest.raises(ValueError, match="read-only"): + arr.flat[0] = 999.0 From 22ffb1b9306357dd8423cc9dc9f723edd5d44eff Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter van Steveninck <32810691+deruyter92@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:36:17 +0200 Subject: [PATCH 6/8] fix test parametrization in test_pandas_cow --- tests/utils/test_pandas_cow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utils/test_pandas_cow.py b/tests/utils/test_pandas_cow.py index 343199f50..029bcfd11 100644 --- a/tests/utils/test_pandas_cow.py +++ b/tests/utils/test_pandas_cow.py @@ -67,7 +67,7 @@ def test_future_mode_sets_cow_true(monkeypatch): @pytest.mark.parametrize( - "label, extract", + "extract", [ pytest.param( lambda df: df.to_numpy().reshape((len(df), -1, 3)), From 7c997771a5326588363563306be2d9a9cdea5ed0 Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter van Steveninck <32810691+deruyter92@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:43:29 +0200 Subject: [PATCH 7/8] Add copy=True to to_numpy in evaluate_multianimal peaks_gt Guards against the in-place write on the next line producing a read-only view when the intermediate frame becomes single-block. --- .../pose_estimation_tensorflow/core/evaluate_multianimal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deeplabcut/pose_estimation_tensorflow/core/evaluate_multianimal.py b/deeplabcut/pose_estimation_tensorflow/core/evaluate_multianimal.py index 5cd893f7e..0dc290cc0 100644 --- a/deeplabcut/pose_estimation_tensorflow/core/evaluate_multianimal.py +++ b/deeplabcut/pose_estimation_tensorflow/core/evaluate_multianimal.py @@ -328,7 +328,7 @@ def evaluate_multianimal_full( ) temp["sample"] = 0 - peaks_gt = temp.loc[:, ["sample", "y", "x", "bodyparts"]].to_numpy() + peaks_gt = temp.loc[:, ["sample", "y", "x", "bodyparts"]].to_numpy(copy=True) peaks_gt[:, 1:3] = (peaks_gt[:, 1:3] - stride // 2) / stride pred = predictma.predict_batched_peaks_and_costs( From a3977ff926cc941d68be5176a07210e731ff849f Mon Sep 17 00:00:00 2001 From: Jaap de Ruyter van Steveninck <32810691+deruyter92@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:45:48 +0200 Subject: [PATCH 8/8] fix test_pandas_cow leaking global `infer_string` and `copy_on_write` options --- tests/utils/test_pandas_cow.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/utils/test_pandas_cow.py b/tests/utils/test_pandas_cow.py index 029bcfd11..84a5fee4d 100644 --- a/tests/utils/test_pandas_cow.py +++ b/tests/utils/test_pandas_cow.py @@ -28,6 +28,8 @@ def _configure_cow_true(monkeypatch) -> None: """Configure pandas CoW via the DLC future-mode machinery.""" + monkeypatch.setattr(pd.options.future, "infer_string", pd.options.future.infer_string) + monkeypatch.setattr(pd.options.mode, "copy_on_write", pd.options.mode.copy_on_write) monkeypatch.setenv("DLC_PANDAS_FUTURE", "1") configure_pandas_future_if_enabled()