Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
.PHONY: clean clean-test clean-pyc clean-build docs help
.DEFAULT_GOAL := help

MESON_BUILD_DIR ?= builddir

define BROWSER_PYSCRIPT
import os, webbrowser, sys

Expand Down Expand Up @@ -32,11 +34,12 @@ help:
clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts

clean-build: ## remove build artifacts
rm -fr build/
rm -fr dist/
rm -fr .eggs/
find . -name '*.egg-info' -exec rm -fr {} +
find . -name '*.egg' -exec rm -f {} +
rm -fr build/
rm -fr dist/
rm -fr $(MESON_BUILD_DIR)/
rm -fr .eggs/
find . -name '*.egg-info' -exec rm -fr {} +
find . -name '*.egg' -exec rm -f {} +

clean-pyc: ## remove Python file artifacts
find . -name '*.pyc' -exec rm -f {} +
Expand All @@ -60,7 +63,7 @@ test-all: ## run tests on every Python version with tox
tox

coverage: ## check code coverage quickly with the default Python
coverage run --source dtw setup.py test
coverage run --source dtw -m pytest
coverage report -m
coverage html
$(BROWSER) htmlcov/index.html
Expand All @@ -80,13 +83,12 @@ servedocs: docs ## compile the docs watching for changes
release: dist ## package and upload a release
twine upload dist/*

dist: clean ## builds source and wheel package
python setup.py sdist
python setup.py bdist_wheel
ls -l dist
dist: clean ## builds source and wheel package using the Meson backend
python -m build
ls -l dist

install: clean ## install the package to the active Python's site-packages
python setup.py install
python -m pip install .


docstrings:
Expand Down
124 changes: 124 additions & 0 deletions dtw/_dtw_utils_py.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
"""Pure Python fallback implementation for :mod:`dtw._dtw_utils`.

This module replicates the behaviour of the Cython extension that powers the
:func:`dtw._globalCostMatrix._globalCostMatrix` helper so that the package
remains importable when the compiled extension is unavailable (for example
when running the test-suite directly from a source checkout before building
artifacts).
"""

from __future__ import annotations

import math
from typing import Any, Dict

import numpy as np

__all__ = ["_computeCM_wrapper"]

# The compiled extension uses INT_MIN as the NA marker for the step matrix.
R_NA_INT = np.iinfo(np.int32).min


def _normalise_dir_matrix(dir_array: np.ndarray, nsteps: int) -> np.ndarray:
"""Return the direction matrix with shape ``(nsteps, 4)``.

``step_pattern._get_p()`` yields a 2-D array but callers occasionally pass
flattened views. The Cython implementation treats the buffer as a
Fortran-ordered matrix, therefore we mirror that behaviour and reshape
accordingly when needed.
"""

if dir_array.ndim == 2:
if dir_array.shape[0] == nsteps:
return dir_array
if dir_array.shape[1] == nsteps:
return np.reshape(dir_array, (nsteps, -1), order="F")
elif dir_array.ndim == 1:
return np.reshape(dir_array, (nsteps, -1), order="F")

return np.reshape(dir_array, (nsteps, -1), order="F")


def _computeCM_wrapper(
wm: np.ndarray,
lm: np.ndarray,
nstepsp: np.ndarray,
dir: np.ndarray,
cm: np.ndarray,
sm: np.ndarray | None = None,
) -> Dict[str, Any]:
"""Pure Python drop-in replacement for the Cython wrapper.

The implementation mirrors :func:`dtw._dtw_utils._computeCM_wrapper` and is
intentionally written with explicit loops so that the behaviour matches the
original C routine.
"""

wm_arr = np.asarray(wm, dtype=np.int32)
lm_arr = np.asarray(lm, dtype=np.double)
cm_arr = np.asarray(cm, dtype=np.double)

if wm_arr.shape != lm_arr.shape or cm_arr.shape != lm_arr.shape:
raise ValueError("All matrices must share the same shape")

rows, cols = lm_arr.shape

nsteps = int(np.asarray(nstepsp, dtype=np.int32).ravel()[0])
dir_matrix = _normalise_dir_matrix(np.asarray(dir, dtype=np.double), nsteps)
if dir_matrix.shape[1] != 4:
raise ValueError("Direction matrix must have four columns")

pattern_ids = dir_matrix[:, 0].astype(np.int32) - 1
delta_i = dir_matrix[:, 1].astype(np.int32)
delta_j = dir_matrix[:, 2].astype(np.int32)
step_cost = dir_matrix[:, 3]

npats = (int(pattern_ids.max()) + 1) if nsteps else 0
clist = np.empty(npats, dtype=np.double)

if sm is None:
sm_arr = np.empty_like(lm_arr, dtype=np.int32)
else:
sm_arr = np.asarray(sm, dtype=np.int32)
if sm_arr.shape != lm_arr.shape:
raise ValueError("Direction matrix must match the local matrix shape")

sm_arr.fill(R_NA_INT)

for j in range(rows):
for i in range(cols):
if not wm_arr[j, i]:
continue
if not math.isnan(cm_arr[j, i]):
continue

clist.fill(np.nan)
for s_idx in range(nsteps):
p = pattern_ids[s_idx]
ii = i - delta_i[s_idx]
jj = j - delta_j[s_idx]
if ii < 0 or jj < 0:
continue

cost = step_cost[s_idx]
if cost == -1.0:
clist[p] = cm_arr[jj, ii]
else:
clist[p] = clist[p] + cost * lm_arr[jj, ii]

if npats == 0:
continue

valid = ~np.isnan(clist)
if not np.any(valid):
continue

valid_indices = np.nonzero(valid)[0]
best_local = np.argmin(clist[valid_indices])
best_index = valid_indices[best_local]
cm_arr[j, i] = clist[best_index]
sm_arr[j, i] = best_index + 1

return {"costMatrix": cm_arr, "directionMatrix": sm_arr}

6 changes: 5 additions & 1 deletion dtw/_globalCostMatrix.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import numpy
from dtw.window import noWindow
from dtw._dtw_utils import _computeCM_wrapper

try: # pragma: no cover - exercised in tests via the compiled module
from dtw._dtw_utils import _computeCM_wrapper
except ModuleNotFoundError: # pragma: no cover - exercised when extension missing
from dtw._dtw_utils_py import _computeCM_wrapper


def _globalCostMatrix(lm,
Expand Down
47 changes: 47 additions & 0 deletions meson.build
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
project('dtw-python', ['c', 'cython'], version: '1.5.3', license: 'GPL-2.0-or-later', default_options: ['warning_level=1'])

py = import('python').find_installation(modules: ['cython', 'numpy'])

py.install_sources(
'dtw/__init__.py',
'dtw/__main__.py',
'dtw/_backtrack.py',
'dtw/_globalCostMatrix.py',
'dtw/_dtw_utils_py.py',
'dtw/countPaths.py',
'dtw/dtw.py',
'dtw/dtwPlot.py',
'dtw/dtw_test_data.py',
'dtw/mvm.py',
'dtw/stepPattern.py',
'dtw/warp.py',
'dtw/warpArea.py',
'dtw/window.py',
subdir: 'dtw',
pure: true,
)

py.install_sources(
'dtw/data/README.txt',
'dtw/data/aami3a.csv',
'dtw/data/aami3b.csv',
subdir: 'dtw/data',
pure: true,
)

dtw_include = include_directories('dtw')

numpy_inc_result = run_command(py, ['-c', 'import numpy; print(numpy.get_include())'], check: true)
numpy_include_dir = numpy_inc_result.stdout().strip()
numpy_c_args = ['-I' + numpy_include_dir]

py.extension_module(
'dtw._dtw_utils',
[
'dtw/_dtw_utils.pyx',
'dtw/dtw_core.c',
],
include_directories: [dtw_include],
c_args: numpy_c_args,
install: true,
)
12 changes: 8 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,21 @@
[build-system]
# Minimum requirements for the build system to execute.
# PEP 508 specifications.
requires = ["setuptools",
"wheel",
requires = [
"meson-python>=0.15",
"meson>=1.4.0",
"Cython",
'numpy>=2.0.0rc1; python_version>="3.9"',
'numpy; python_version<"3.9"',
]
build-backend = "setuptools.build_meta"
build-backend = "mesonpy"


# https://numpy.org/devdocs/dev/depending_on_numpy.html#for-downstream-package-authors

[project]
name = "dtw-python"
dynamic = ["version"]
version = "1.5.3"
dependencies = [
'numpy>=1.23.5; python_version>="3.9"',
'numpy; python_version<"3.9"',
Expand Down Expand Up @@ -113,5 +114,8 @@ exclude = [
[dependency-groups]
dev = [
"pytest>=7.0.1",
"build>=1.2.1; python_version>='3.8'",
"Cython>=3.0; python_version>='3.8'",
"meson>=1.4.0; python_version>='3.8'",
]

10 changes: 7 additions & 3 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ current_version = 1.5.3
commit = True
tag = True

[bumpversion:file:setup.py]
search = version="{current_version}"
replace = version="{new_version}"
[bumpversion:file:pyproject.toml]
search = version = "{current_version}"
replace = version = "{new_version}"

[bumpversion:file:meson.build]
search = version: '{current_version}'
replace = version: '{new_version}'

[bumpversion:file:dtw/__init__.py]
search = __version__ = '{current_version}'
Expand Down
25 changes: 0 additions & 25 deletions setup.py

This file was deleted.

18 changes: 18 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from __future__ import annotations

import os
import sys
from pathlib import Path


def _ensure_meson_editable_skip() -> None:
build_path = Path(__file__).resolve().parent.parent / "build" / f"cp{sys.version_info.major}{sys.version_info.minor}"
existing = os.environ.get("MESONPY_EDITABLE_SKIP", "")
paths = [p for p in existing.split(os.pathsep) if p]
path_str = str(build_path)
if path_str not in paths:
paths.append(path_str)
os.environ["MESONPY_EDITABLE_SKIP"] = os.pathsep.join(paths)


_ensure_meson_editable_skip()
Loading