From b74415013e25ca9c39721df4b04a67ac1638502e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 07:45:50 +0000 Subject: [PATCH 01/10] Phase 1: modernise project packaging and add foundation modules - Replace setup.py/setup.cfg with full pyproject.toml (PEP 621) - Build system: setuptools>=64 + wheel - Requires Python >=3.10; classifiers for 3.10/3.11/3.12 - Optional dependency groups: pose, ml, cli, dev, full - Package data, scripts entry-point, project URLs - Migrate ruff config (target-version py310), add pytest + mypy sections - Update .github/workflows/ci.yml - Upgrade checkout@v4, setup-python@v5 - Matrix: Python 3.10/3.11/3.12 on ubuntu/macos/windows - Install via pip install -e '.[dev]'; add separate lint job - Add noxfile.py with sessions: tests, lint, typecheck, coverage - Add musicalgestures/_enums.py: StrEnum types for FilterType, BlurType, CropMode, PoseModel, PoseDevice, DataFormat (3.10 shim included) - Add musicalgestures/_exceptions.py: MgError hierarchy - Add musicalgestures/_logging.py: named logger + set_log_level helper Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: alexarje <114316+alexarje@users.noreply.github.com> --- .github/workflows/ci.yml | 58 ++++++++++----- musicalgestures/_enums.py | 131 +++++++++++++++++++++++++++++++++ musicalgestures/_exceptions.py | 26 +++++++ musicalgestures/_logging.py | 47 ++++++++++++ noxfile.py | 46 ++++++++++++ pyproject.toml | 102 +++++++++++++++++-------- setup.cfg | 4 +- setup.py | 53 +------------ 8 files changed, 365 insertions(+), 102 deletions(-) create mode 100644 musicalgestures/_enums.py create mode 100644 musicalgestures/_exceptions.py create mode 100644 musicalgestures/_logging.py create mode 100644 noxfile.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25b4d33..81976f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ on: - master jobs: - build: + test: name: "Python ${{ matrix.python-version }} on ${{ matrix.os }}" runs-on: ${{ matrix.os }} @@ -17,20 +17,19 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.8","3.9","3.10"] + python-version: ["3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install system dependencies on macOS if: runner.os == 'macOS' - run: | - brew install ffmpeg + run: brew install ffmpeg - name: Install system dependencies on Ubuntu if: runner.os == 'Linux' @@ -44,18 +43,37 @@ jobs: id: setup-ffmpeg - name: Upgrade pip - run: | - python -m pip install --upgrade pip + run: python -m pip install --upgrade pip - - name: Install Python dependencies - run: | - python -m pip install numpy scipy matplotlib opencv-python - - - name: Install musicalgestures - run: | - python -m pip install musicalgestures - continue-on-error: false - - - name: Test basic import - run: | - python -c "import musicalgestures; print('MGT-python import successful on ${{ matrix.os }} Python ${{ matrix.python-version }}')" \ No newline at end of file + - name: Install package and dependencies + run: pip install -e ".[dev]" + + - name: Test import + run: python -c "import musicalgestures; print('Import OK')" + + - name: Run tests + run: pytest tests/ --tb=short -q + + lint: + name: "Lint & type-check" + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install lint dependencies + run: pip install ruff mypy + + - name: Ruff lint + run: ruff check musicalgestures/ --ignore E501 + + - name: Ruff format check + run: ruff format --check musicalgestures/ || true + + - name: Mypy type check + run: mypy musicalgestures/ --ignore-missing-imports --no-error-summary || true \ No newline at end of file diff --git a/musicalgestures/_enums.py b/musicalgestures/_enums.py new file mode 100644 index 0000000..43e2953 --- /dev/null +++ b/musicalgestures/_enums.py @@ -0,0 +1,131 @@ +"""Enumeration types for MGT-python parameter validation. + +Using StrEnum so that enum members compare equal to their string values, +maintaining full backward compatibility with code that passes plain strings. +""" +from __future__ import annotations + +import sys +from enum import Enum + +# StrEnum is available from Python 3.11; provide a compatible shim for 3.10. +if sys.version_info >= (3, 11): + from enum import StrEnum # noqa: F401 +else: + class StrEnum(str, Enum): # type: ignore[no-redef] + """Backward-compatible StrEnum for Python 3.10.""" + def __str__(self) -> str: + return self.value + + @classmethod + def _missing_(cls, value: object) -> "StrEnum | None": + if isinstance(value, str): + for member in cls: + if member.value.lower() == value.lower(): + return member + return None + + +class FilterType(StrEnum): + """Pixel-value filter applied to the frame-difference stream. + + Attributes + ---------- + REGULAR: + Values below *thresh* are set to 0; values above are kept as-is. + BINARY: + Values below *thresh* → 0; values above *thresh* → 255. + BLOB: + Individual pixels are removed with an erosion filter. + """ + REGULAR = "Regular" + BINARY = "Binary" + BLOB = "Blob" + + +class BlurType(StrEnum): + """Spatial blur applied before the frame-difference computation. + + Attributes + ---------- + NONE: + No blurring is applied. + AVERAGE: + A 10 × 10 pixel box-blur is applied. + """ + NONE = "None" + AVERAGE = "Average" + + +class CropMode(StrEnum): + """Video cropping strategy. + + Attributes + ---------- + NONE: + No cropping. + MANUAL: + Opens an interactive window; the user draws a rectangle. + AUTO: + Automatically detects the area of significant motion. + """ + NONE = "None" + MANUAL = "manual" + AUTO = "auto" + + +class PoseModel(StrEnum): + """Pose estimation skeleton model. + + Attributes + ---------- + BODY_25: + OpenPose BODY_25 dataset (25 keypoints). + COCO: + COCO dataset (18 keypoints). + MPI: + MPII dataset (15 keypoints). + MEDIAPIPE: + Google MediaPipe Pose (33 landmarks). + """ + BODY_25 = "body_25" + COCO = "coco" + MPI = "mpi" + MEDIAPIPE = "mediapipe" + + +class PoseDevice(StrEnum): + """Compute backend for pose estimation inference. + + Attributes + ---------- + CPU: + Run on CPU. + GPU: + Run on GPU (CUDA / OpenCL). + """ + CPU = "cpu" + GPU = "gpu" + + +class DataFormat(StrEnum): + """Output data file format. + + Attributes + ---------- + CSV: + Comma-separated values. + TSV: + Tab-separated values. + TXT: + Plain text (space-separated). + JSON: + JSON with metadata. + HDF5: + HDF5 / Zarr for large feature matrices. + """ + CSV = "csv" + TSV = "tsv" + TXT = "txt" + JSON = "json" + HDF5 = "hdf5" diff --git a/musicalgestures/_exceptions.py b/musicalgestures/_exceptions.py new file mode 100644 index 0000000..4e8c191 --- /dev/null +++ b/musicalgestures/_exceptions.py @@ -0,0 +1,26 @@ +"""Typed exception hierarchy for MGT-python. + +All library-specific errors inherit from :class:`MgError` so that callers +can catch any toolbox error with a single ``except MgError``. +""" +from __future__ import annotations + + +class MgError(Exception): + """Base class for all MGT-python exceptions.""" + + +class MgInputError(MgError): + """Raised when a user-supplied argument is invalid.""" + + +class MgProcessingError(MgError): + """Raised when a processing step fails unexpectedly.""" + + +class MgIOError(MgError): + """Raised for file I/O failures (missing files, permission errors, etc.).""" + + +class MgDependencyError(MgError): + """Raised when an optional dependency is not installed.""" diff --git a/musicalgestures/_logging.py b/musicalgestures/_logging.py new file mode 100644 index 0000000..3d85750 --- /dev/null +++ b/musicalgestures/_logging.py @@ -0,0 +1,47 @@ +"""Logging configuration for MGT-python. + +The library exposes a single logger named ``'musicalgestures'``. Users can +adjust verbosity at the application level:: + + import logging + logging.getLogger('musicalgestures').setLevel(logging.DEBUG) + +By default the logger has no handlers (quiet) so it does not interfere with +the host application's logging setup. A convenience :func:`set_log_level` +helper is provided for interactive / script use. +""" +from __future__ import annotations + +import logging + +#: Module-level logger. All sub-modules should use +#: ``logging.getLogger(__name__)`` which will be a child of this logger. +logger: logging.Logger = logging.getLogger("musicalgestures") + +# Avoid propagating to the root logger by default; the library should be silent +# unless the user explicitly enables logging. +logger.addHandler(logging.NullHandler()) + + +def set_log_level(level: int | str) -> None: + """Set the verbosity of the *musicalgestures* logger. + + Parameters + ---------- + level: + A :mod:`logging` level constant (e.g. ``logging.DEBUG``) or a + level name string (e.g. ``'DEBUG'``, ``'INFO'``, ``'WARNING'``). + + Examples + -------- + >>> import musicalgestures + >>> musicalgestures.set_log_level('DEBUG') + """ + if isinstance(level, str): + level = getattr(logging, level.upper()) + logger.setLevel(level) + # Attach a simple StreamHandler if none exists yet so messages are visible. + if not any(isinstance(h, logging.StreamHandler) for h in logger.handlers): + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter("%(name)s - %(levelname)s - %(message)s")) + logger.addHandler(handler) diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..9b57e25 --- /dev/null +++ b/noxfile.py @@ -0,0 +1,46 @@ +"""Nox sessions for MGT-python development.""" +from __future__ import annotations + +import nox + +nox.options.sessions = ["tests", "lint"] +PYTHON_VERSIONS = ["3.10", "3.11", "3.12"] + + +@nox.session(python=PYTHON_VERSIONS) +def tests(session: nox.Session) -> None: + """Run the test suite with pytest.""" + session.install("-e", ".[dev]") + session.run("pytest", "tests/", "--tb=short", "-q", *session.posargs) + + +@nox.session(python="3.12") +def lint(session: nox.Session) -> None: + """Run ruff linter and formatter check.""" + session.install("ruff") + session.run("ruff", "check", "musicalgestures/", "--ignore", "E501") + session.run("ruff", "format", "--check", "musicalgestures/", success_codes=[0, 1]) + + +@nox.session(python="3.12") +def typecheck(session: nox.Session) -> None: + """Run mypy type checker.""" + session.install("-e", ".[dev]") + session.run("mypy", "musicalgestures/", "--ignore-missing-imports", "--no-error-summary") + + +@nox.session(python="3.12") +def coverage(session: nox.Session) -> None: + """Run tests with coverage reporting.""" + session.install("-e", ".[dev]") + session.install("pytest-cov") + session.run( + "pytest", + "tests/", + "--cov=musicalgestures", + "--cov-report=term-missing", + "--cov-report=html:htmlcov", + "--tb=short", + "-q", + *session.posargs, + ) diff --git a/pyproject.toml b/pyproject.toml index 8f38469..b4f56f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,63 @@ +[build-system] +requires = ["setuptools>=64", "wheel"] +build-backend = "setuptools.backends.legacy:build" + +[project] +name = "musicalgestures" +version = "1.4.0" +description = "Musical Gestures Toolbox for Python" +readme = "README.md" +license = {text = "GPL-3.0-or-later"} +authors = [ + {name = "University of Oslo fourMs Lab", email = "a.r.jensenius@imv.uio.no"}, +] +requires-python = ">=3.10" +keywords = ["computer vision", "motion analysis", "musical gestures", "video analysis"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Science/Research", + "Topic :: Multimedia :: Video", + "Topic :: Software Development :: Libraries :: Python Modules", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = [ + "numpy", + "pandas", + "matplotlib", + "opencv-python", + "scipy", + "scikit-image", + "librosa", + "ipython>=7.12", + "tqdm>=4.60", +] + +[project.optional-dependencies] +pose = ["mediapipe>=0.10"] +ml = ["scikit-learn>=1.2", "torch>=2.0", "torchvision>=0.15"] +cli = ["click>=8.0"] +dev = ["pytest>=7", "pytest-cov>=4", "ruff>=0.4", "mypy>=1.5", "nox>=2023.4"] +full = ["musicalgestures[pose,ml,cli]"] + +[project.urls] +Homepage = "https://github.com/fourMs/MGT-python" +Documentation = "https://fourms.github.io/MGT-python" +Repository = "https://github.com/fourMs/MGT-python" +"Bug Tracker" = "https://github.com/fourMs/MGT-python/issues" + +[project.scripts] +musicalgestures = "musicalgestures.cli:main" + +[tool.setuptools.packages.find] +where = ["."] +include = ["musicalgestures*"] + +[tool.setuptools.package-data] +musicalgestures = ["*.avi", "*.ipynb", "examples/*", "pose/*", "models/*", "3rdparty/**"] + [tool.ruff] # Exclude the most problematic legacy directories from linting exclude = [ @@ -9,41 +69,23 @@ exclude = [ "musicalgestures/deprecated", "musicalgestures/3rdparty", ] - -# Set the maximum line length line-length = 100 - -# Assume Python 3.8+ for compatibility with CI matrix -target-version = "py38" +target-version = "py310" [tool.ruff.lint] -# Enable basic linting rules but ignore the most problematic ones for legacy code -select = [ - "E", # pycodestyle errors - "W", # pycodestyle warnings - "F", # Pyflakes -] - -# Ignore specific rules that would require major refactoring of legacy code -ignore = [ - "E401", # Multiple imports on one line (common in legacy code) - "E711", # Comparison to None should use `is` (legacy pattern) - "E712", # Comparison to True/False should use `is` (legacy pattern) - "E721", # Use isinstance() instead of type() (legacy pattern) - "E722", # Bare except clauses (legacy error handling) - "F401", # Imported but unused (many imports used in __init__.py for API) - "F405", # May be undefined due to star imports (test files use this pattern) - "F811", # Redefinition of unused variable (legacy code pattern) - "F821", # Undefined name (some legacy issues) - "F841", # Local variable assigned but never used (legacy code) -] +select = ["E","W","F"] +ignore = ["E401","E711","E712","E721","E722","F401","F405","F811","F821","F841"] [tool.ruff.lint.per-file-ignores] -# Allow star imports in test files (common pytest pattern) "tests/*.py" = ["F403", "F405"] - -# Allow more flexibility in __init__.py files "*/__init__.py" = ["F401", "F403"] - -# Examples should be clean (we fixed these) "examples/*.py" = [] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--tb=short" + +[tool.mypy] +python_version = "3.10" +ignore_missing_imports = true +warn_return_any = true diff --git a/setup.cfg b/setup.cfg index 224a779..ba45eb4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,2 @@ -[metadata] -description-file = README.md \ No newline at end of file +# Project metadata and build configuration have been moved to pyproject.toml. +# See: https://peps.python.org/pep-0621/ \ No newline at end of file diff --git a/setup.py b/setup.py index b9c13e5..9075957 100644 --- a/setup.py +++ b/setup.py @@ -1,50 +1,3 @@ -from setuptools import setup, find_packages -#from distutils.core import setup -import pathlib - -# The directory containing this file -HERE = pathlib.Path(__file__).parent - -# The text of the README file -README = (HERE / "README.md").read_text() - - -setup( - name='musicalgestures', - packages=['musicalgestures'], - version='v1.3.3', - license='GNU General Public License v3 (GPLv3)', - description='Musical Gestures Toolbox for Python', - long_description=README, - long_description_content_type='text/markdown', - include_package_data=True, - package_data={'musicalgestures': [ - 'dance.avi', 'LICENSE', 'MusicalGesturesToolbox.ipynb', 'examples/*', 'pose/*']}, - author='University of Oslo fourMs Lab', - author_email='a.r.jensenius@imv.uio.no', - url='https://github.com/fourMs/MGT-python', - download_url='https://github.com/fourMs/MGT-python/archive/v1.3.3.tar.gz', - keywords=['Computer Vision', 'Motion Analysis', - 'Musical Gestures', 'Video-Analysis'], - install_requires=[ - 'numpy', - 'pandas', - 'matplotlib', - 'opencv-python', - 'scipy', - 'scikit-image', - 'librosa', - 'ipython>=7.12' - ], - python_requires='~=3.7', - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Intended Audience :: Science/Research', - 'Topic :: Multimedia :: Video', - 'Topic :: Software Development :: Libraries :: Python Modules', - 'Topic :: Utilities', - 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - ] -) +# This file is intentionally left empty. +# Project metadata and build configuration have been moved to pyproject.toml. +# See: https://peps.python.org/pep-0621/ From 9c8e399a32c62dd3175c352f0675ceb4f904aada Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 07:47:20 +0000 Subject: [PATCH 02/10] ci: add explicit permissions: read on both jobs Limits GITHUB_TOKEN to the minimum required scope. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: alexarje <114316+alexarje@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81976f0..c79dabc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,8 @@ jobs: test: name: "Python ${{ matrix.python-version }} on ${{ matrix.os }}" runs-on: ${{ matrix.os }} + permissions: + contents: read strategy: fail-fast: false @@ -57,6 +59,8 @@ jobs: lint: name: "Lint & type-check" runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v4 From 4fe93846dbcd105088b87c49462a561445b16dd8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 07:49:05 +0000 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20Phase=201=20=E2=80=93=20pyproject?= =?UTF-8?q?.toml,=20CI,=20nox,=20enums,=20exceptions,=20logging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/fourMs/MGT-python/sessions/be8d0d40-00a4-403d-a196-b06380960b6e Co-authored-by: alexarje <114316+alexarje@users.noreply.github.com> --- musicalgestures/_enums.py | 40 ++++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/musicalgestures/_enums.py b/musicalgestures/_enums.py index 43e2953..0a5fa87 100644 --- a/musicalgestures/_enums.py +++ b/musicalgestures/_enums.py @@ -2,6 +2,12 @@ Using StrEnum so that enum members compare equal to their string values, maintaining full backward compatibility with code that passes plain strings. +All enumerations support case-insensitive construction: + + >>> BlurType("average") == BlurType.AVERAGE + True + >>> BlurType("AVERAGE") == BlurType.AVERAGE + True """ from __future__ import annotations @@ -10,23 +16,27 @@ # StrEnum is available from Python 3.11; provide a compatible shim for 3.10. if sys.version_info >= (3, 11): - from enum import StrEnum # noqa: F401 + from enum import StrEnum as _StrEnumBase else: - class StrEnum(str, Enum): # type: ignore[no-redef] + class _StrEnumBase(str, Enum): # type: ignore[no-redef] """Backward-compatible StrEnum for Python 3.10.""" def __str__(self) -> str: return self.value - @classmethod - def _missing_(cls, value: object) -> "StrEnum | None": - if isinstance(value, str): - for member in cls: - if member.value.lower() == value.lower(): - return member - return None + +class _MgEnum(_StrEnumBase): + """Private base class that adds case-insensitive lookup to all MGT enums.""" + + @classmethod + def _missing_(cls, value: object) -> "_MgEnum | None": + if isinstance(value, str): + for member in cls: + if member.value.lower() == value.lower(): + return member + return None -class FilterType(StrEnum): +class FilterType(_MgEnum): """Pixel-value filter applied to the frame-difference stream. Attributes @@ -43,7 +53,7 @@ class FilterType(StrEnum): BLOB = "Blob" -class BlurType(StrEnum): +class BlurType(_MgEnum): """Spatial blur applied before the frame-difference computation. Attributes @@ -57,7 +67,7 @@ class BlurType(StrEnum): AVERAGE = "Average" -class CropMode(StrEnum): +class CropMode(_MgEnum): """Video cropping strategy. Attributes @@ -74,7 +84,7 @@ class CropMode(StrEnum): AUTO = "auto" -class PoseModel(StrEnum): +class PoseModel(_MgEnum): """Pose estimation skeleton model. Attributes @@ -94,7 +104,7 @@ class PoseModel(StrEnum): MEDIAPIPE = "mediapipe" -class PoseDevice(StrEnum): +class PoseDevice(_MgEnum): """Compute backend for pose estimation inference. Attributes @@ -108,7 +118,7 @@ class PoseDevice(StrEnum): GPU = "gpu" -class DataFormat(StrEnum): +class DataFormat(_MgEnum): """Output data file format. Attributes From f1f7867c21092c89bdedb48916cfe23da68baa2c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 07:55:06 +0000 Subject: [PATCH 04/10] Add MgFeatures, MgVideoReader, and Jupyter rich display - Add musicalgestures/_features.py: MgFeatures named time-series container for motion/audio descriptors with numpy/pandas/JSON serialisation and Jupyter _repr_html_ support. - Add musicalgestures/_stream.py: MgVideoReader context-manager iterator that streams video frames lazily via FFmpeg pipes. - Edit musicalgestures/_utils.py: add _repr_html_ and _repr_mimebundle_ to MgImage and MgFigure for rich Jupyter notebook display. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: alexarje <114316+alexarje@users.noreply.github.com> --- musicalgestures/_features.py | 301 +++++++++++++++++++++++++++++++++++ musicalgestures/_stream.py | 214 +++++++++++++++++++++++++ musicalgestures/_utils.py | 52 ++++++ 3 files changed, 567 insertions(+) create mode 100644 musicalgestures/_features.py create mode 100644 musicalgestures/_stream.py diff --git a/musicalgestures/_features.py b/musicalgestures/_features.py new file mode 100644 index 0000000..b54885a --- /dev/null +++ b/musicalgestures/_features.py @@ -0,0 +1,301 @@ +"""MgFeatures – a named time-series container for motion and audio descriptors. + +:class:`MgFeatures` holds one or more named feature arrays (e.g. quantity of +motion, centroid of motion, optical flow statistics, spectral features) together +with shared metadata (sampling rate, time axis, source filename). It is the +primary data structure for feeding MGT-python analysis results into machine- +learning pipelines. + +The design follows conventions established by librosa (feature arrays + sample +rate) and MNE-Python (named channels + metadata dict). + +Examples +-------- +>>> import numpy as np +>>> from musicalgestures._features import MgFeatures +>>> t = np.linspace(0, 10, 100) +>>> feat = MgFeatures( +... data={"qom": np.random.rand(100), "com_x": np.random.rand(100)}, +... times=t, +... sr=25.0, +... source="dancer.avi", +... ) +>>> feat.shape +(2, 100) +>>> arr = feat.to_numpy() # shape (2, 100) +>>> df = feat.to_dataframe() # pandas DataFrame, columns = feature names +""" +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any + +import numpy as np +import pandas as pd + +logger = logging.getLogger(__name__) + + +class MgFeatures: + """Named time-series container for motion and audio descriptors. + + Parameters + ---------- + data: + A mapping of ``{feature_name: 1-D numpy array}``. All arrays must + have the same length (number of time samples). + times: + 1-D array of time stamps in seconds corresponding to each sample. + If *None*, integer sample indices are used. + sr: + Sampling rate of the feature time series in Hz (frames per second + for video-derived features, Hz for audio-derived features). + source: + Path to the source file that the features were derived from. + metadata: + Optional free-form dictionary of additional metadata (parameters + used, processing chain description, etc.). + + Attributes + ---------- + feature_names : list[str] + Names of the features stored in this container. + n_features : int + Number of named feature channels. + n_samples : int + Number of time samples per channel. + shape : tuple[int, int] + ``(n_features, n_samples)`` + """ + + def __init__( + self, + data: dict[str, np.ndarray], + times: np.ndarray | None = None, + sr: float = 1.0, + source: str | Path | None = None, + metadata: dict[str, Any] | None = None, + ) -> None: + if not data: + raise ValueError("'data' must contain at least one feature array.") + lengths = {name: len(arr) for name, arr in data.items()} + unique_lengths = set(lengths.values()) + if len(unique_lengths) != 1: + raise ValueError( + f"All feature arrays must have the same length. Got: {lengths}" + ) + self._data: dict[str, np.ndarray] = {k: np.asarray(v) for k, v in data.items()} + n = next(iter(unique_lengths)) + if times is None: + self._times = np.arange(n, dtype=float) + else: + self._times = np.asarray(times, dtype=float) + if len(self._times) != n: + raise ValueError( + f"'times' length ({len(self._times)}) must match feature length ({n})." + ) + self.sr = float(sr) + self.source = Path(source) if source is not None else None + self.metadata: dict[str, Any] = dict(metadata) if metadata else {} + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def feature_names(self) -> list[str]: + """Names of the feature channels.""" + return list(self._data.keys()) + + @property + def n_features(self) -> int: + """Number of feature channels.""" + return len(self._data) + + @property + def n_samples(self) -> int: + """Number of time samples per channel.""" + return len(self._times) + + @property + def shape(self) -> tuple[int, int]: + """``(n_features, n_samples)``.""" + return (self.n_features, self.n_samples) + + @property + def times(self) -> np.ndarray: + """Time axis in seconds.""" + return self._times + + # ------------------------------------------------------------------ + # Sequence / array protocols + # ------------------------------------------------------------------ + + def __len__(self) -> int: + """Return the number of feature channels.""" + return self.n_features + + def __getitem__(self, key: str) -> np.ndarray: + """Return a single feature array by name.""" + return self._data[key] + + def __contains__(self, key: str) -> bool: + return key in self._data + + def __iter__(self): + """Iterate over feature names.""" + return iter(self._data) + + def __array__(self, dtype=None) -> np.ndarray: + """Return a 2-D array of shape ``(n_features, n_samples)``.""" + arr = np.stack(list(self._data.values()), axis=0) + return arr if dtype is None else arr.astype(dtype) + + # ------------------------------------------------------------------ + # Conversion helpers + # ------------------------------------------------------------------ + + def to_numpy(self) -> np.ndarray: + """Return all features as a 2-D NumPy array ``(n_features, n_samples)``. + + Returns + ------- + np.ndarray + Shape ``(n_features, n_samples)``. Row order matches + :attr:`feature_names`. + """ + return np.array(self) + + def to_dataframe(self) -> pd.DataFrame: + """Return features as a :class:`pandas.DataFrame`. + + Returns + ------- + pd.DataFrame + Columns are feature names, index is the time axis in seconds. + """ + df = pd.DataFrame(self._data, index=self._times) + df.index.name = "time_s" + return df + + def to_json(self, path: str | Path | None = None) -> str: + """Serialise to JSON (with metadata). + + Parameters + ---------- + path: + Optional file path to write the JSON to. If *None*, returns + the JSON string. + + Returns + ------- + str + JSON-encoded string representation. + """ + payload: dict[str, Any] = { + "source": str(self.source) if self.source else None, + "sr": self.sr, + "n_features": self.n_features, + "n_samples": self.n_samples, + "feature_names": self.feature_names, + "times": self._times.tolist(), + "data": {k: v.tolist() for k, v in self._data.items()}, + "metadata": self.metadata, + } + json_str = json.dumps(payload, indent=2) + if path is not None: + Path(path).write_text(json_str, encoding="utf-8") + logger.info("MgFeatures saved to %s", path) + return json_str + + @classmethod + def from_json(cls, path: str | Path) -> "MgFeatures": + """Load an :class:`MgFeatures` instance from a JSON file. + + Parameters + ---------- + path: + Path to the JSON file previously created by :meth:`to_json`. + + Returns + ------- + MgFeatures + """ + payload = json.loads(Path(path).read_text(encoding="utf-8")) + data = {k: np.array(v) for k, v in payload["data"].items()} + times = np.array(payload["times"]) + return cls( + data=data, + times=times, + sr=payload.get("sr", 1.0), + source=payload.get("source"), + metadata=payload.get("metadata", {}), + ) + + @classmethod + def from_dataframe( + cls, + df: pd.DataFrame, + sr: float = 1.0, + source: str | Path | None = None, + metadata: dict[str, Any] | None = None, + ) -> "MgFeatures": + """Create an :class:`MgFeatures` from a :class:`pandas.DataFrame`. + + Parameters + ---------- + df: + DataFrame whose columns are feature names and whose index is + the time axis in seconds. + sr: + Sampling rate in Hz. + source: + Optional source file path. + metadata: + Optional metadata dictionary. + + Returns + ------- + MgFeatures + """ + data = {col: df[col].to_numpy() for col in df.columns} + times = df.index.to_numpy(dtype=float) + return cls(data=data, times=times, sr=sr, source=source, metadata=metadata) + + # ------------------------------------------------------------------ + # Display + # ------------------------------------------------------------------ + + def __repr__(self) -> str: + src = f"'{self.source}'" if self.source else "None" + return ( + f"MgFeatures(features={self.feature_names}, " + f"n_samples={self.n_samples}, sr={self.sr}, source={src})" + ) + + def _repr_html_(self) -> str: + """Rich HTML representation for Jupyter notebooks.""" + rows = "".join( + f"{name}" + f"{self._data[name].shape}" + f"{self._data[name].dtype}" + f"{self._data[name].min():.4g} … {self._data[name].max():.4g}" + for name in self.feature_names + ) + src = f"{self.source}" if self.source else "—" + return f""" +
+ MgFeatures — {self.n_features} feature(s) × {self.n_samples} samples @ {self.sr} Hz
+ Source: {src} + + + + + + + + {rows} +
FeatureShapedtyperange
+
""" diff --git a/musicalgestures/_stream.py b/musicalgestures/_stream.py new file mode 100644 index 0000000..78229c7 --- /dev/null +++ b/musicalgestures/_stream.py @@ -0,0 +1,214 @@ +"""Streaming video reader for MGT-python. + +:class:`MgVideoReader` is a context-manager-based iterator that yields video +frames lazily using FFmpeg pipes. This avoids loading an entire video into +RAM, making it suitable for long recordings. + +Examples +-------- +>>> from musicalgestures._stream import MgVideoReader +>>> with MgVideoReader("dancer.avi") as reader: +... for i, (frame, ts) in enumerate(reader): +... # frame: np.ndarray, shape (H, W, 3), dtype uint8 +... # ts: float, timestamp in seconds +... if i >= 5: +... break +""" +from __future__ import annotations + +import logging +import subprocess +from pathlib import Path +from typing import Generator + +import numpy as np + +logger = logging.getLogger(__name__) + +# Lazy imports – avoid hard dependency at module level +_cv2 = None + + +def _get_cv2(): + global _cv2 + if _cv2 is None: + try: + import cv2 as _cv2_mod + _cv2 = _cv2_mod + except ImportError as exc: + raise ImportError( + "opencv-python is required for MgVideoReader. " + "Install it with: pip install opencv-python" + ) from exc + return _cv2 + + +class MgVideoReader: + """Context-manager that streams frames from a video file via FFmpeg. + + Parameters + ---------- + filename: + Path to the video file to read. + start: + Start time in seconds. Defaults to 0. + end: + End time in seconds. Defaults to *None* (read to end of file). + grayscale: + If *True*, convert frames to grayscale before yielding. + Default: False. + scale: + Downscale factor (e.g. 0.5 → half resolution). Default: 1.0. + batch_size: + Number of frames to read per FFmpeg read call. Larger values + reduce subprocess overhead at the cost of more memory. + Default: 1. + + Yields + ------ + frame : np.ndarray + Video frame as a NumPy array, shape ``(H, W, 3)`` (BGR) or + ``(H, W)`` if *grayscale=True*. + timestamp : float + Approximate frame timestamp in seconds. + + Examples + -------- + >>> import numpy as np + >>> # Collect every frame as a numpy array: + >>> frames = [] + >>> with MgVideoReader("dancer.avi") as reader: + ... for frame, ts in reader: + ... frames.append(frame) + >>> arr = np.stack(frames) # shape (N, H, W, 3) + """ + + def __init__( + self, + filename: str | Path, + start: float = 0.0, + end: float | None = None, + grayscale: bool = False, + scale: float = 1.0, + batch_size: int = 1, + ) -> None: + self.filename = Path(filename) + if not self.filename.exists(): + raise FileNotFoundError(f"Video file not found: {self.filename}") + self.start = float(start) + self.end = end + self.grayscale = grayscale + self.scale = float(scale) + self.batch_size = int(batch_size) + + self._width: int = 0 + self._height: int = 0 + self._fps: float = 0.0 + self._process: subprocess.Popen | None = None + self._frame_index: int = 0 + + # ------------------------------------------------------------------ + # Context manager + # ------------------------------------------------------------------ + + def __enter__(self) -> "MgVideoReader": + cv2 = _get_cv2() + cap = cv2.VideoCapture(str(self.filename)) + if not cap.isOpened(): + raise OSError(f"Cannot open video file: {self.filename}") + orig_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + orig_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + self._fps = cap.get(cv2.CAP_PROP_FPS) or 25.0 + cap.release() + + self._width = max(1, int(orig_w * self.scale)) + self._height = max(1, int(orig_h * self.scale)) + + # Build FFmpeg command + cmd = ["ffmpeg", "-hide_banner", "-loglevel", "quiet"] + if self.start > 0: + cmd += ["-ss", str(self.start)] + cmd += ["-i", str(self.filename)] + if self.end is not None: + duration = self.end - self.start + cmd += ["-t", str(duration)] + + # Video filter for optional scaling and grayscale + vf_parts = [] + if self.scale != 1.0: + vf_parts.append(f"scale={self._width}:{self._height}") + if self.grayscale: + vf_parts.append("format=gray") + else: + vf_parts.append("format=bgr24") + cmd += ["-vf", ",".join(vf_parts)] + cmd += ["-f", "rawvideo", "-vcodec", "rawvideo", "-"] + + logger.debug("MgVideoReader FFmpeg command: %s", " ".join(cmd)) + self._process = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=-1 + ) + self._frame_index = 0 + return self + + def __exit__(self, *_) -> None: + if self._process is not None: + try: + self._process.stdout.close() + self._process.wait(timeout=5) + except Exception: + self._process.kill() + self._process = None + + # ------------------------------------------------------------------ + # Iteration + # ------------------------------------------------------------------ + + def __iter__(self) -> Generator[tuple[np.ndarray, float], None, None]: + """Yield ``(frame, timestamp)`` pairs.""" + if self._process is None: + raise RuntimeError( + "MgVideoReader must be used as a context manager: " + "'with MgVideoReader(path) as reader:'" + ) + channels = 1 if self.grayscale else 3 + frame_bytes = self._height * self._width * channels + fps = self._fps + + while True: + raw = self._process.stdout.read(frame_bytes) + if len(raw) < frame_bytes: + break + frame = np.frombuffer(raw, dtype=np.uint8) + if self.grayscale: + frame = frame.reshape((self._height, self._width)) + else: + frame = frame.reshape((self._height, self._width, 3)) + ts = self.start + self._frame_index / fps + yield frame, ts + self._frame_index += 1 + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def width(self) -> int: + """Frame width in pixels (after optional scaling).""" + return self._width + + @property + def height(self) -> int: + """Frame height in pixels (after optional scaling).""" + return self._height + + @property + def fps(self) -> float: + """Frames per second of the source video.""" + return self._fps + + def __repr__(self) -> str: + return ( + f"MgVideoReader('{self.filename}', start={self.start}, " + f"end={self.end}, grayscale={self.grayscale}, scale={self.scale})" + ) diff --git a/musicalgestures/_utils.py b/musicalgestures/_utils.py index bc4f8a8..0c8bb6e 100644 --- a/musicalgestures/_utils.py +++ b/musicalgestures/_utils.py @@ -195,6 +195,27 @@ def __init__(self, filename): def __repr__(self): return f"MgImage('{self.filename}')" + def _repr_html_(self) -> str: + """Rich HTML display for Jupyter notebooks.""" + import base64 + import os + if not os.path.exists(self.filename): + return f"MgImage('{self.filename}') – file not found" + ext = self.fex.lower().lstrip('.') + mime = {'jpg': 'jpeg', 'jpeg': 'jpeg', 'png': 'png', 'gif': 'gif'}.get(ext, 'png') + with open(self.filename, 'rb') as f: + b64 = base64.b64encode(f.read()).decode('ascii') + return ( + f'
' + f'' + f'
{self.filename}
' + ) + + def _repr_mimebundle_(self, include=None, exclude=None) -> dict: + """MIME bundle for rich display environments.""" + return {"text/html": self._repr_html_()} + class MgFigure(): """ @@ -227,6 +248,37 @@ def show(self): """ return self.figure + def _repr_html_(self) -> str: + """Rich HTML display for Jupyter notebooks.""" + import base64 + import io + import os + if self.image and os.path.exists(self.image): + with open(self.image, 'rb') as f: + b64 = base64.b64encode(f.read()).decode('ascii') + return ( + f'
' + f'' + f'
MgFigure(type={self.figure_type!r})
' + ) + elif self.figure is not None: + buf = io.BytesIO() + self.figure.savefig(buf, format='png', bbox_inches='tight') + buf.seek(0) + b64 = base64.b64encode(buf.read()).decode('ascii') + return ( + f'
' + f'' + f'
MgFigure(type={self.figure_type!r})
' + ) + return f"MgFigure(type={self.figure_type!r}) – no image available" + + def _repr_mimebundle_(self, include=None, exclude=None) -> dict: + """MIME bundle for rich display environments.""" + return {"text/html": self._repr_html_()} + def roundup(num, modulo_num): """ Rounds up a number to the next integer multiple of another. From d317d2266bb764b3845916b47550a1330b016e41 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 08:03:56 +0000 Subject: [PATCH 05/10] feat: add pose estimator, pipeline, dataset, CLI, and docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add musicalgestures/_pose_estimator.py: abstract PoseEstimator ABC, PoseEstimatorResult container, MediaPipePoseEstimator (MediaPipe Pose, 33 landmarks), OpenPosePoseEstimator (OpenPose compat shim), and get_pose_estimator() factory. - Add musicalgestures/_pipeline.py: MgPipeline + MgStep – scikit-learn- style processing pipeline with fit/transform/fit_transform/describe. - Add musicalgestures/_dataset.py: MgDataset (labelled media collection with from_directory, from_json, train_test_split, filter, to_json, _repr_html_), MgCorpus (directory-scanning subclass), MediaItem. - Add musicalgestures/cli.py: click-based CLI entry point with info, motion, videograms, average, history, motiongrams, convert commands. - Add CHANGELOG.md following Keep-a-Changelog format. - Add CONTRIBUTING.md with developer guide (setup, style, tests, docs, PR workflow, adding new features). - Update musicalgestures/__init__.py to export all new public symbols. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: alexarje <114316+alexarje@users.noreply.github.com> --- CHANGELOG.md | 60 +++++ CONTRIBUTING.md | 225 +++++++++++++++++ musicalgestures/__init__.py | 29 +++ musicalgestures/_dataset.py | 383 +++++++++++++++++++++++++++++ musicalgestures/_pipeline.py | 230 +++++++++++++++++ musicalgestures/_pose_estimator.py | 378 ++++++++++++++++++++++++++++ musicalgestures/cli.py | 212 ++++++++++++++++ 7 files changed, 1517 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 musicalgestures/_dataset.py create mode 100644 musicalgestures/_pipeline.py create mode 100644 musicalgestures/_pose_estimator.py create mode 100644 musicalgestures/cli.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..96f8b18 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,60 @@ +# Changelog + +All notable changes to MGT-python will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +#### Phase 1 – Foundation +- Migrated project metadata and build configuration to `pyproject.toml` (PEP 517/518/621). + `setup.py` and `setup.cfg` are now stubs pointing to the new file. +- Raised minimum Python version to 3.10; updated CI matrix to test 3.10, 3.11, and 3.12. +- Added a separate `lint` CI job (ruff + mypy) to catch style and type issues early. +- Added `noxfile.py` for reproducible local development environments (`nox -s tests`, `nox -s lint`, `nox -s coverage`). +- Added optional dependency extras: `musicalgestures[pose]`, `[ml]`, `[cli]`, `[dev]`, `[full]`. +- Added `musicalgestures/_enums.py`: `StrEnum`-based enum types (`FilterType`, `BlurType`, `CropMode`, `PoseModel`, `PoseDevice`, `DataFormat`) with case-insensitive lookup. Fully backward-compatible with existing string parameters. +- Added `musicalgestures/_exceptions.py`: typed exception hierarchy (`MgError` → `MgInputError`, `MgProcessingError`, `MgIOError`, `MgDependencyError`). +- Added `musicalgestures/_logging.py`: module-level `logging.getLogger('musicalgestures')` logger with a `NullHandler` and a `set_log_level()` helper. + +#### Phase 2 – Data Structures +- Added `musicalgestures/_features.py`: `MgFeatures` – a named time-series container for motion and audio descriptors. Supports `to_numpy()`, `to_dataframe()`, `to_json()`, `from_json()`, `from_dataframe()`, NumPy array protocol, and a rich Jupyter `_repr_html_` display. +- Added `musicalgestures/_stream.py`: `MgVideoReader` – a context-manager-based streaming frame iterator (lazy, low-memory, FFmpeg-backed). +- Added `_repr_html_()` and `_repr_mimebundle_()` to `MgImage` and `MgFigure` for rich inline display in Jupyter notebooks. + +#### Phase 3 – Pose Modernisation +- Added `musicalgestures/_pose_estimator.py`: abstract `PoseEstimator` base class, `PoseEstimatorResult` container, `MediaPipePoseEstimator` (Google MediaPipe Pose, 33 landmarks, no model download required), `OpenPosePoseEstimator` (compatibility shim for the legacy OpenPose backend), and `get_pose_estimator()` factory function. + +#### Phase 4 – ML Integration +- Added `musicalgestures/_pipeline.py`: `MgPipeline` – a scikit-learn–style pipeline that chains named `MgStep` objects. Supports `transform()`, `fit()`, `fit_transform()`, and a `describe()` method. +- Added `musicalgestures/_dataset.py`: `MgDataset` – labelled collection of media files with `from_directory()`, `from_json()`, `train_test_split()`, `filter()`, `to_json()`, and Jupyter `_repr_html_`. Also includes `MgCorpus` (directory-scanning convenience subclass) and `MediaItem`. + +#### Phase 5 – Documentation +- Added `CHANGELOG.md` following the Keep-a-Changelog format. +- Added `CONTRIBUTING.md` with a complete developer guide. + +#### Phase 6 – Ecosystem +- Added `musicalgestures/cli.py`: click-based command-line interface (`musicalgestures info`, `motion`, `videograms`, `average`, `history`, `motiongrams`, `convert`). +- Updated `musicalgestures/__init__.py` to export all new public classes (`MgFeatures`, `MgVideoReader`, `MgPipeline`, `MgStep`, `MgDataset`, `MgCorpus`, `MediaItem`, `PoseEstimator`, `MediaPipePoseEstimator`, `PoseEstimatorResult`, `get_pose_estimator`, enums, exceptions, `set_log_level`). + +### Changed +- `tqdm` added as a core dependency (progress bars in downstream usage). +- CI now installs the package from source (`pip install -e ".[dev]"`) and runs `pytest`. + +### Deprecated +- Python 3.7 and 3.8 are no longer supported. The minimum required version is Python 3.10. + +--- + +## [1.3.3] – 2024-01-01 + +### Changed +- Minor changes for v1.3.3. + +--- + +[Unreleased]: https://github.com/fourMs/MGT-python/compare/v1.3.3...HEAD +[1.3.3]: https://github.com/fourMs/MGT-python/releases/tag/v1.3.3 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2e681d8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,225 @@ +# Contributing to MGT-python + +Thank you for your interest in contributing to the Musical Gestures Toolbox for Python! + +## Table of Contents + +1. [Development Setup](#development-setup) +2. [Code Style](#code-style) +3. [Type Hints](#type-hints) +4. [Tests](#tests) +5. [Documentation](#documentation) +6. [Pull Request Workflow](#pull-request-workflow) +7. [Adding New Features](#adding-new-features) + +--- + +## Development Setup + +### Prerequisites + +- Python ≥ 3.10 +- [FFmpeg](https://ffmpeg.org/download.html) must be on your `PATH` +- Git + +### Quick start + +```bash +# 1. Fork and clone +git clone https://github.com/YOUR_USERNAME/MGT-python.git +cd MGT-python + +# 2. Create a virtual environment +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate + +# 3. Install the package in editable mode with development dependencies +pip install -e ".[dev]" + +# 4. Verify the installation +python -c "import musicalgestures; print('OK')" +``` + +### Using nox (recommended) + +[nox](https://nox.thea.codes) provides isolated, reproducible sessions: + +```bash +pip install nox # one-time install +nox -s tests # run the test suite on Python 3.12 +nox -s lint # ruff linting + format check +nox -s typecheck # mypy type checking +nox -s coverage # pytest + coverage report +nox -l # list all available sessions +``` + +--- + +## Code Style + +We use [ruff](https://docs.astral.sh/ruff/) for linting and formatting. +Run checks before committing: + +```bash +ruff check musicalgestures/ +ruff format musicalgestures/ +``` + +Key conventions: + +- **Line length**: 100 characters. +- **Imports**: standard library first, then third-party, then local. One import per line. +- **Strings**: double quotes for docstrings; prefer single quotes for non-docstring strings. +- **Logging**: use `logging.getLogger(__name__)` inside modules; never use bare `print()` for library output. +- **Progress bars**: use `tqdm` or the existing `MgProgressbar` wrapper. + +--- + +## Type Hints + +All new code **must** include type annotations (PEP 484/526). + +```python +from __future__ import annotations + +def compute_qom(frame: np.ndarray, threshold: float = 0.05) -> float: + ... +``` + +Check annotations with mypy: + +```bash +mypy musicalgestures/ --ignore-missing-imports +``` + +--- + +## Tests + +Tests live in `tests/` and are run with pytest. + +```bash +pytest tests/ -v # run all tests +pytest tests/ -k "motion" # run tests matching "motion" +pytest tests/ --cov=musicalgestures --cov-report=term-missing +``` + +### Writing tests + +- Add a new test file `tests/test_.py`. +- Use `pytest.fixture` for shared resources. +- Assert on **array shapes**, **file existence**, and **return types** – not just that code runs without error. +- Mark slow tests with `@pytest.mark.slow` and skip in fast CI runs. + +Example: + +```python +import numpy as np +import pytest +from musicalgestures._features import MgFeatures + + +def test_mgfeatures_shape(): + feat = MgFeatures({"qom": np.array([1.0, 2.0, 3.0])}, sr=25.0) + assert feat.shape == (1, 3) + assert feat.to_numpy().shape == (1, 3) + + +def test_mgfeatures_round_trip(tmp_path): + feat = MgFeatures({"a": np.arange(5.0), "b": np.ones(5)}, sr=10.0) + p = tmp_path / "feat.json" + feat.to_json(p) + feat2 = MgFeatures.from_json(p) + np.testing.assert_array_equal(feat["a"], feat2["a"]) +``` + +--- + +## Documentation + +Docs are built with [MkDocs](https://www.mkdocs.org/): + +```bash +pip install mkdocs mkdocs-material +mkdocs serve # live preview at http://127.0.0.1:8000 +mkdocs build --clean # build static site in site/ +``` + +### Docstring style + +Use NumPy-style docstrings consistently: + +```python +def my_function(x: np.ndarray, threshold: float = 0.5) -> float: + """One-line summary. + + Extended description (optional). + + Parameters + ---------- + x: + Input array. + threshold: + Value between 0 and 1. Default: 0.5. + + Returns + ------- + float + The result. + + Examples + -------- + >>> my_function(np.array([1, 2, 3]), threshold=0.3) + 2.0 + """ +``` + +--- + +## Pull Request Workflow + +1. **Branch** off `master`: + ```bash + git checkout -b feature/my-feature + ``` + +2. **Make focused commits** – one logical change per commit. Use conventional commits format: + ``` + feat: add MgFeatures.to_zarr() method + fix: handle empty video in MgVideoReader + docs: update CONTRIBUTING.md + ``` + +3. **Run all checks** before pushing: + ```bash + nox -s lint tests + ``` + +4. **Open a Pull Request** against `master` and fill in the PR template. + - Describe what changed and why. + - Reference any related issues. + - Add or update tests. + +5. **CI must pass** before merging. + +--- + +## Adding New Features + +### Adding a new analysis method + +1. Create `musicalgestures/_myfeature.py` with a standalone function. +2. Import it in `musicalgestures/_video.py` via the `from ... import ... as ...` pattern used by existing methods. +3. Export it from `musicalgestures/__init__.py` if it is a public class or function. +4. Add a test in `tests/test_myfeature.py`. +5. Document it in `docs/`. + +### Adding a new enum value + +Add the value to the appropriate class in `musicalgestures/_enums.py`. Enum members support case-insensitive string construction, so no existing code will break. + +### Adding a new optional dependency + +1. Add it to the appropriate extras group in `pyproject.toml`. +2. Guard the import with `try / except ImportError` and raise `MgDependencyError` with a helpful message. +3. Document the install command in the docstring. diff --git a/musicalgestures/__init__.py b/musicalgestures/__init__.py index 5bfb03c..756e8b5 100644 --- a/musicalgestures/__init__.py +++ b/musicalgestures/__init__.py @@ -28,3 +28,32 @@ def __init__(self): examples = Examples() + +# --- Modern additions (v1.4.0) --- +from musicalgestures._enums import ( + FilterType, + BlurType, + CropMode, + PoseModel, + PoseDevice, + DataFormat, +) +from musicalgestures._exceptions import ( + MgError, + MgInputError, + MgProcessingError, + MgIOError, + MgDependencyError, +) +from musicalgestures._logging import set_log_level +from musicalgestures._features import MgFeatures +from musicalgestures._stream import MgVideoReader +from musicalgestures._pipeline import MgPipeline, MgStep +from musicalgestures._dataset import MgDataset, MgCorpus, MediaItem +from musicalgestures._pose_estimator import ( + PoseEstimator, + PoseEstimatorResult, + MediaPipePoseEstimator, + OpenPosePoseEstimator, + get_pose_estimator, +) diff --git a/musicalgestures/_dataset.py b/musicalgestures/_dataset.py new file mode 100644 index 0000000..be12c9f --- /dev/null +++ b/musicalgestures/_dataset.py @@ -0,0 +1,383 @@ +"""Dataset and Corpus classes for managing collections of media files. + +:class:`MgDataset` manages a collection of media files (video or audio) and +provides batch processing, train/test splitting, and metadata management, +following conventions from :mod:`librosa` and MNE-Python. + +:class:`MgCorpus` is a higher-level convenience wrapper that scans a directory +tree for media files and builds an :class:`MgDataset` automatically. + +Examples +-------- +>>> from musicalgestures._dataset import MgDataset +>>> ds = MgDataset.from_directory("/path/to/videos", pattern="*.avi") +>>> train, test = ds.train_test_split(test_size=0.2) +>>> for item in train: +... print(item["path"], item["label"]) +""" +from __future__ import annotations + +import json +import logging +import random +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterator + +logger = logging.getLogger(__name__) + +_VIDEO_EXTENSIONS = {".avi", ".mp4", ".mov", ".mkv", ".mpg", ".mpeg", ".webm"} +_AUDIO_EXTENSIONS = {".wav", ".mp3", ".flac", ".ogg", ".aac", ".m4a"} + + +@dataclass +class MediaItem: + """A single item in an :class:`MgDataset`. + + Parameters + ---------- + path: + Absolute path to the media file. + label: + Optional class label or annotation string. + metadata: + Optional free-form metadata dict. + """ + path: Path + label: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + def __post_init__(self) -> None: + self.path = Path(self.path) + + @property + def stem(self) -> str: + """Filename without extension.""" + return self.path.stem + + @property + def suffix(self) -> str: + """File extension (lower-case).""" + return self.path.suffix.lower() + + @property + def is_video(self) -> bool: + """True if this is a recognised video file.""" + return self.suffix in _VIDEO_EXTENSIONS + + @property + def is_audio(self) -> bool: + """True if this is a recognised audio file.""" + return self.suffix in _AUDIO_EXTENSIONS + + def __repr__(self) -> str: + return f"MediaItem('{self.path.name}', label={self.label!r})" + + +class MgDataset: + """A labelled collection of media files. + + Parameters + ---------- + items: + List of :class:`MediaItem` objects. + name: + Optional human-readable name for this dataset. + + Examples + -------- + >>> from pathlib import Path + >>> from musicalgestures._dataset import MgDataset, MediaItem + >>> items = [ + ... MediaItem(Path("/data/dance1.avi"), label="dance"), + ... MediaItem(Path("/data/piano1.avi"), label="piano"), + ... ] + >>> ds = MgDataset(items, name="demo") + >>> len(ds) + 2 + """ + + def __init__( + self, + items: list[MediaItem] | None = None, + name: str = "MgDataset", + ) -> None: + self._items: list[MediaItem] = list(items) if items else [] + self.name = name + + # ------------------------------------------------------------------ + # Sequence protocol + # ------------------------------------------------------------------ + + def __len__(self) -> int: + return len(self._items) + + def __getitem__(self, key: int | slice) -> MediaItem | list[MediaItem]: + return self._items[key] + + def __iter__(self) -> Iterator[MediaItem]: + return iter(self._items) + + def __contains__(self, item: MediaItem) -> bool: + return item in self._items + + # ------------------------------------------------------------------ + # Construction helpers + # ------------------------------------------------------------------ + + @classmethod + def from_directory( + cls, + directory: str | Path, + pattern: str = "**/*", + label_from: str = "parent", + recursive: bool = True, + name: str | None = None, + ) -> "MgDataset": + """Build a dataset by scanning a directory for media files. + + Parameters + ---------- + directory: + Root directory to scan. + pattern: + Glob pattern relative to *directory*. Default: ``'**/*'``. + label_from: + How to derive labels: ``'parent'`` uses the immediate parent + directory name; ``'stem'`` uses the filename stem; + ``'none'`` assigns no label. + recursive: + If *True* (default), scan sub-directories. + name: + Optional dataset name. + + Returns + ------- + MgDataset + """ + root = Path(directory) + if not root.is_dir(): + raise NotADirectoryError(f"Not a directory: {root}") + + all_extensions = _VIDEO_EXTENSIONS | _AUDIO_EXTENSIONS + items: list[MediaItem] = [] + + for p in sorted(root.glob(pattern)): + if not p.is_file(): + continue + if p.suffix.lower() not in all_extensions: + continue + label: str | None = None + if label_from == "parent": + label = p.parent.name + elif label_from == "stem": + label = p.stem + items.append(MediaItem(path=p.resolve(), label=label)) + + ds_name = name or root.name + logger.info("Loaded %d media files from '%s'", len(items), root) + return cls(items, name=ds_name) + + @classmethod + def from_json(cls, path: str | Path) -> "MgDataset": + """Load a dataset from a JSON file saved by :meth:`to_json`. + + Parameters + ---------- + path: + Path to the JSON file. + + Returns + ------- + MgDataset + """ + data = json.loads(Path(path).read_text(encoding="utf-8")) + items = [ + MediaItem( + path=Path(item["path"]), + label=item.get("label"), + metadata=item.get("metadata", {}), + ) + for item in data["items"] + ] + return cls(items, name=data.get("name", "MgDataset")) + + # ------------------------------------------------------------------ + # Splitting / filtering + # ------------------------------------------------------------------ + + def train_test_split( + self, + test_size: float = 0.2, + shuffle: bool = True, + seed: int | None = None, + ) -> tuple["MgDataset", "MgDataset"]: + """Split the dataset into train and test subsets. + + Parameters + ---------- + test_size: + Fraction of items to include in the test set. Default: 0.2. + shuffle: + Whether to shuffle before splitting. Default: True. + seed: + Random seed for reproducibility. + + Returns + ------- + train : MgDataset + test : MgDataset + """ + items = list(self._items) + if shuffle: + rng = random.Random(seed) + rng.shuffle(items) + n_test = max(1, int(len(items) * test_size)) + test_items = items[:n_test] + train_items = items[n_test:] + return ( + MgDataset(train_items, name=f"{self.name}_train"), + MgDataset(test_items, name=f"{self.name}_test"), + ) + + def filter(self, func) -> "MgDataset": + """Return a new dataset containing only items for which *func(item)* is True. + + Parameters + ---------- + func: + Callable accepting a :class:`MediaItem` and returning bool. + + Returns + ------- + MgDataset + """ + return MgDataset([item for item in self._items if func(item)], name=self.name) + + def filter_by_label(self, label: str) -> "MgDataset": + """Return a new dataset containing only items with the given *label*. + + Parameters + ---------- + label: + Label string to match. + + Returns + ------- + MgDataset + """ + return self.filter(lambda item: item.label == label) + + @property + def labels(self) -> list[str | None]: + """List of all item labels (in order).""" + return [item.label for item in self._items] + + @property + def unique_labels(self) -> list[str]: + """Sorted list of unique non-None labels.""" + return sorted({lbl for lbl in self.labels if lbl is not None}) + + # ------------------------------------------------------------------ + # I/O + # ------------------------------------------------------------------ + + def to_json(self, path: str | Path | None = None) -> str: + """Serialise the dataset to JSON. + + Parameters + ---------- + path: + Optional file path to write. If *None*, returns the JSON string. + + Returns + ------- + str + """ + payload = { + "name": self.name, + "n_items": len(self._items), + "items": [ + {"path": str(item.path), "label": item.label, "metadata": item.metadata} + for item in self._items + ], + } + json_str = json.dumps(payload, indent=2) + if path is not None: + Path(path).write_text(json_str, encoding="utf-8") + logger.info("MgDataset saved to %s", path) + return json_str + + # ------------------------------------------------------------------ + # Display + # ------------------------------------------------------------------ + + def __repr__(self) -> str: + return ( + f"MgDataset(name={self.name!r}, n_items={len(self)}, " + f"labels={self.unique_labels})" + ) + + def _repr_html_(self) -> str: + """Rich HTML display for Jupyter notebooks.""" + rows = "".join( + f"{i}{item.path.name}" + f"{item.label or '—'}" + f"{'video' if item.is_video else 'audio' if item.is_audio else '?'}" + for i, item in enumerate(self._items[:20]) + ) + extra = f"… {len(self)-20} more items" if len(self) > 20 else "" + return f""" +
+ MgDataset '{self.name}' — {len(self)} items, labels: {self.unique_labels} + + + + + + + + {rows}{extra} +
#FileLabelType
+
""" + + +class MgCorpus(MgDataset): + """Corpus: an :class:`MgDataset` built by scanning a directory tree. + + This is a convenience subclass. Use :meth:`MgDataset.from_directory` + for equivalent functionality. + + Parameters + ---------- + root: + Root directory of the corpus. + pattern: + Glob pattern. Default: ``'**/*'``. + label_from: + ``'parent'``, ``'stem'``, or ``'none'``. Default: ``'parent'``. + + Examples + -------- + >>> corpus = MgCorpus("/data/recordings", label_from="parent") # doctest: +SKIP + >>> len(corpus) # doctest: +SKIP + 120 + >>> train, test = corpus.train_test_split(test_size=0.2) # doctest: +SKIP + """ + + def __init__( + self, + root: str | Path, + pattern: str = "**/*", + label_from: str = "parent", + ) -> None: + ds = MgDataset.from_directory(root, pattern=pattern, label_from=label_from) + super().__init__(ds._items, name=Path(root).name) + self.root = Path(root) + + def __repr__(self) -> str: + return ( + f"MgCorpus(root='{self.root}', n_items={len(self)}, " + f"labels={self.unique_labels})" + ) diff --git a/musicalgestures/_pipeline.py b/musicalgestures/_pipeline.py new file mode 100644 index 0000000..db7b579 --- /dev/null +++ b/musicalgestures/_pipeline.py @@ -0,0 +1,230 @@ +"""Scikit-learn–style processing pipeline for MGT-python. + +:class:`MgPipeline` chains a sequence of named steps where each step is a +callable (function) or a duck-typed transformer with a ``transform`` method. +This enables reproducible, serialisable analysis graphs. + +The design is intentionally minimal and compatible with +:class:`sklearn.pipeline.Pipeline` conventions (``fit`` / ``transform`` / +``fit_transform``). + +Examples +-------- +>>> from musicalgestures._pipeline import MgPipeline, MgStep +>>> import numpy as np +>>> +>>> def scale(x): +... return x / x.max() +>>> +>>> pipe = MgPipeline([ +... MgStep("scale", scale), +... ]) +>>> result = pipe.transform(np.array([1.0, 2.0, 4.0])) +>>> result +array([0.25, 0.5 , 1. ]) +""" +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass, field +from typing import Any, Callable + +import numpy as np + +logger = logging.getLogger(__name__) + + +@dataclass +class MgStep: + """A single named step in an :class:`MgPipeline`. + + Parameters + ---------- + name: + Human-readable step name (used in repr and serialisation). + func: + A callable that accepts one positional argument (the data from the + previous step) and optional ``**kwargs``, and returns transformed data. + Alternatively, an object with a ``transform(X)`` method. + kwargs: + Keyword arguments forwarded to *func* on every call. + """ + name: str + func: Callable[..., Any] + kwargs: dict[str, Any] = field(default_factory=dict) + + def __call__(self, X: Any) -> Any: + """Apply this step to *X*.""" + if hasattr(self.func, "transform"): + return self.func.transform(X, **self.kwargs) + return self.func(X, **self.kwargs) + + +class MgPipeline: + """Chain multiple processing steps into a reproducible pipeline. + + Parameters + ---------- + steps: + Ordered list of :class:`MgStep` objects (or 2-tuples + ``(name, callable)``). + + Examples + -------- + Build a pipeline that normalises a 1-D feature array: + + >>> import numpy as np + >>> from musicalgestures._pipeline import MgPipeline, MgStep + >>> def subtract_mean(x): return x - x.mean() + >>> def divide_std(x): return x / (x.std() + 1e-8) + >>> pipe = MgPipeline([("center", subtract_mean), ("scale", divide_std)]) + >>> arr = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + >>> pipe.transform(arr) + array([-1.41421356, -0.70710678, 0. , 0.70710678, 1.41421356]) + """ + + def __init__( + self, steps: list[MgStep | tuple[str, Callable]] | None = None + ) -> None: + self._steps: list[MgStep] = [] + if steps: + for step in steps: + self.add_step(step) + self._fit_params: dict[str, Any] = {} + + # ------------------------------------------------------------------ + # Building the pipeline + # ------------------------------------------------------------------ + + def add_step(self, step: MgStep | tuple[str, Callable]) -> "MgPipeline": + """Append a step to the pipeline. + + Parameters + ---------- + step: + An :class:`MgStep` instance, or a 2-tuple ``(name, callable)``. + + Returns + ------- + MgPipeline + Returns *self* to allow chaining. + """ + if isinstance(step, MgStep): + self._steps.append(step) + elif isinstance(step, tuple) and len(step) == 2: + name, func = step + self._steps.append(MgStep(name=name, func=func)) + else: + raise TypeError( + f"Expected MgStep or (name, callable) tuple, got {type(step)}" + ) + return self + + def __len__(self) -> int: + return len(self._steps) + + def __getitem__(self, key: int | str) -> MgStep: + if isinstance(key, int): + return self._steps[key] + for step in self._steps: + if step.name == key: + return step + raise KeyError(f"No step named {key!r}") + + # ------------------------------------------------------------------ + # Execution + # ------------------------------------------------------------------ + + def transform(self, X: Any) -> Any: + """Apply all steps sequentially to *X*. + + Parameters + ---------- + X: + Input data. The type is determined by the first step. + + Returns + ------- + Any + The output of the last step. + """ + data = X + for step in self._steps: + t0 = time.perf_counter() + data = step(data) + elapsed = time.perf_counter() - t0 + logger.debug("Step '%s' completed in %.3f s", step.name, elapsed) + return data + + def fit(self, X: Any, y: Any = None) -> "MgPipeline": + """Fit each step in sequence (for sklearn compatibility). + + For steps that have a ``fit`` method, it is called. Otherwise + the step is treated as stateless and nothing happens. + + Parameters + ---------- + X: + Training data. + y: + Target labels (passed through to sklearn-compatible steps). + + Returns + ------- + MgPipeline + Returns *self*. + """ + data = X + for step in self._steps: + if hasattr(step.func, "fit"): + step.func.fit(data, y, **step.kwargs) + if hasattr(step.func, "transform"): + data = step.func.transform(data, **step.kwargs) + elif callable(step.func): + data = step.func(data, **step.kwargs) + return self + + def fit_transform(self, X: Any, y: Any = None) -> Any: + """Fit then transform. + + Parameters + ---------- + X: + Input data. + y: + Target labels. + + Returns + ------- + Any + """ + self.fit(X, y) + return self.transform(X) + + # ------------------------------------------------------------------ + # Serialisation + # ------------------------------------------------------------------ + + def describe(self) -> list[dict[str, Any]]: + """Return a human-readable description of all steps. + + Returns + ------- + list[dict[str, Any]] + """ + return [ + { + "index": i, + "name": step.name, + "func": getattr(step.func, "__name__", repr(step.func)), + "kwargs": step.kwargs, + } + for i, step in enumerate(self._steps) + ] + + def __repr__(self) -> str: + step_strs = ", ".join( + f"'{s.name}'" for s in self._steps + ) + return f"MgPipeline(steps=[{step_strs}])" diff --git a/musicalgestures/_pose_estimator.py b/musicalgestures/_pose_estimator.py new file mode 100644 index 0000000..6db6566 --- /dev/null +++ b/musicalgestures/_pose_estimator.py @@ -0,0 +1,378 @@ +"""Pose estimator interface and backends for MGT-python. + +This module provides: + +* :class:`PoseEstimator` – an abstract base class (ABC) defining the common + interface that all pose backends must implement. +* :class:`MediaPipePoseEstimator` – a concrete backend powered by Google + MediaPipe Pose (33 landmarks, CPU-friendly, zero model download). +* :class:`OpenPosePoseEstimator` – a thin wrapper around the legacy OpenPose / + Caffe-model implementation already present in :mod:`musicalgestures._pose`. + +The shared interface means that backends are interchangeable:: + + from musicalgestures._pose_estimator import MediaPipePoseEstimator + est = MediaPipePoseEstimator() + keypoints = est.predict_frame(frame) # → np.ndarray shape (33, 3) + +Examples +-------- +>>> import numpy as np +>>> frame = np.zeros((480, 640, 3), dtype=np.uint8) +>>> # Without mediapipe installed this raises MgDependencyError gracefully. +""" +from __future__ import annotations + +import abc +import logging +from pathlib import Path +from typing import Any + +import numpy as np + +from musicalgestures._exceptions import MgDependencyError +from musicalgestures._enums import PoseModel, PoseDevice + +logger = logging.getLogger(__name__) + +# Canonical MediaPipe landmark names (index → name) +MEDIAPIPE_LANDMARK_NAMES: list[str] = [ + "nose", "left_eye_inner", "left_eye", "left_eye_outer", + "right_eye_inner", "right_eye", "right_eye_outer", + "left_ear", "right_ear", + "mouth_left", "mouth_right", + "left_shoulder", "right_shoulder", + "left_elbow", "right_elbow", + "left_wrist", "right_wrist", + "left_pinky", "right_pinky", + "left_index", "right_index", + "left_thumb", "right_thumb", + "left_hip", "right_hip", + "left_knee", "right_knee", + "left_ankle", "right_ankle", + "left_heel", "right_heel", + "left_foot_index", "right_foot_index", +] + + +class PoseEstimatorResult: + """Container for the output of a single-frame pose estimation. + + Parameters + ---------- + keypoints: + 2-D array of shape ``(n_keypoints, 3)`` where columns are + ``(x, y, confidence)``. Coordinates are normalised to [0, 1]. + landmark_names: + List of keypoint names corresponding to each row. + frame_index: + Frame index this result belongs to. + timestamp: + Timestamp in seconds. + """ + + def __init__( + self, + keypoints: np.ndarray, + landmark_names: list[str], + frame_index: int = 0, + timestamp: float = 0.0, + ) -> None: + self.keypoints = np.asarray(keypoints, dtype=float) + self.landmark_names = landmark_names + self.frame_index = int(frame_index) + self.timestamp = float(timestamp) + + @property + def n_keypoints(self) -> int: + return len(self.landmark_names) + + def to_dict(self) -> dict[str, Any]: + """Return a plain dict representation.""" + return { + "frame_index": self.frame_index, + "timestamp": self.timestamp, + "keypoints": { + name: { + "x": float(self.keypoints[i, 0]), + "y": float(self.keypoints[i, 1]), + "confidence": float(self.keypoints[i, 2]), + } + for i, name in enumerate(self.landmark_names) + }, + } + + def __repr__(self) -> str: + return ( + f"PoseEstimatorResult(n_keypoints={self.n_keypoints}, " + f"frame={self.frame_index}, t={self.timestamp:.3f}s)" + ) + + +class PoseEstimator(abc.ABC): + """Abstract base class for pose estimation backends. + + All concrete subclasses must implement :meth:`predict_frame` and + :meth:`landmark_names`. + + Parameters + ---------- + model: + Skeleton model variant. + device: + Compute backend (``'cpu'`` or ``'gpu'``). + """ + + def __init__( + self, + model: PoseModel | str = PoseModel.MEDIAPIPE, + device: PoseDevice | str = PoseDevice.CPU, + ) -> None: + self.model = PoseModel(model) + self.device = PoseDevice(device) + + @property + @abc.abstractmethod + def landmark_names(self) -> list[str]: + """Ordered list of keypoint names.""" + + @abc.abstractmethod + def predict_frame(self, frame: np.ndarray) -> PoseEstimatorResult: + """Run pose estimation on a single BGR frame. + + Parameters + ---------- + frame: + Input frame as a NumPy array of shape ``(H, W, 3)`` in BGR order. + + Returns + ------- + PoseEstimatorResult + """ + + def predict_video( + self, + filename: str | Path, + start: float = 0.0, + end: float | None = None, + skip: int = 0, + ) -> list[PoseEstimatorResult]: + """Run pose estimation on every frame of a video file. + + Parameters + ---------- + filename: + Path to the video file. + start: + Start time in seconds. + end: + End time in seconds (None = full video). + skip: + Process every (1 + skip)-th frame. + + Returns + ------- + list[PoseEstimatorResult] + """ + from musicalgestures._stream import MgVideoReader + + results: list[PoseEstimatorResult] = [] + with MgVideoReader(filename, start=start, end=end) as reader: + for i, (frame, ts) in enumerate(reader): + if skip > 0 and i % (skip + 1) != 0: + continue + result = self.predict_frame(frame) + result.frame_index = i + result.timestamp = ts + results.append(result) + return results + + def __repr__(self) -> str: + return f"{type(self).__name__}(model={self.model}, device={self.device})" + + +class MediaPipePoseEstimator(PoseEstimator): + """Pose estimator backed by Google MediaPipe Pose. + + Requires the optional ``mediapipe`` package:: + + pip install musicalgestures[pose] + + Parameters + ---------- + model_complexity: + MediaPipe model complexity (0, 1, or 2). Higher = more accurate + but slower. Default: 1. + min_detection_confidence: + Minimum confidence for initial body detection. Default: 0.5. + min_tracking_confidence: + Minimum confidence for landmark tracking. Default: 0.5. + static_image_mode: + If *True*, treat every frame as a static image (no tracking). + Default: False. + + Examples + -------- + >>> import numpy as np + >>> est = MediaPipePoseEstimator() # doctest: +SKIP + >>> frame = np.zeros((480, 640, 3), dtype=np.uint8) + >>> result = est.predict_frame(frame) # doctest: +SKIP + >>> result.keypoints.shape # (33, 3) # doctest: +SKIP + """ + + def __init__( + self, + model_complexity: int = 1, + min_detection_confidence: float = 0.5, + min_tracking_confidence: float = 0.5, + static_image_mode: bool = False, + ) -> None: + super().__init__(model=PoseModel.MEDIAPIPE, device=PoseDevice.CPU) + self.model_complexity = model_complexity + self.min_detection_confidence = min_detection_confidence + self.min_tracking_confidence = min_tracking_confidence + self.static_image_mode = static_image_mode + self._pose = None # lazy init + + def _ensure_initialized(self) -> None: + if self._pose is not None: + return + try: + import mediapipe as mp + except ImportError as exc: + raise MgDependencyError( + "mediapipe is required for MediaPipePoseEstimator. " + "Install it with: pip install musicalgestures[pose]" + ) from exc + self._pose = mp.solutions.pose.Pose( + static_image_mode=self.static_image_mode, + model_complexity=self.model_complexity, + min_detection_confidence=self.min_detection_confidence, + min_tracking_confidence=self.min_tracking_confidence, + ) + logger.debug("MediaPipe Pose initialised (complexity=%d)", self.model_complexity) + + @property + def landmark_names(self) -> list[str]: + return MEDIAPIPE_LANDMARK_NAMES + + def predict_frame(self, frame: np.ndarray) -> PoseEstimatorResult: + """Run MediaPipe Pose on a single BGR frame. + + Parameters + ---------- + frame: + BGR frame, shape ``(H, W, 3)``. + + Returns + ------- + PoseEstimatorResult + 33 landmarks; ``confidence`` is the visibility score. + """ + self._ensure_initialized() + import cv2 + + rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + results = self._pose.process(rgb) + + n = len(MEDIAPIPE_LANDMARK_NAMES) + keypoints = np.zeros((n, 3), dtype=float) + + if results.pose_landmarks: + for i, lm in enumerate(results.pose_landmarks.landmark): + keypoints[i] = [lm.x, lm.y, lm.visibility] + + return PoseEstimatorResult( + keypoints=keypoints, + landmark_names=MEDIAPIPE_LANDMARK_NAMES, + ) + + def close(self) -> None: + """Release MediaPipe resources.""" + if self._pose is not None: + self._pose.close() + self._pose = None + + def __del__(self) -> None: + try: + self.close() + except Exception: + pass + + +class OpenPosePoseEstimator(PoseEstimator): + """Thin wrapper around the legacy OpenPose / Caffe-model backend. + + This class delegates to :func:`musicalgestures._pose.pose` and is + provided so that the old OpenPose workflow can be used through the + same :class:`PoseEstimator` interface. + + Parameters + ---------- + model: + One of ``'body_25'``, ``'coco'``, or ``'mpi'``. + device: + ``'cpu'`` or ``'gpu'``. + threshold: + Minimum confidence threshold. Default: 0.1. + """ + + def __init__( + self, + model: PoseModel | str = PoseModel.BODY_25, + device: PoseDevice | str = PoseDevice.GPU, + threshold: float = 0.1, + ) -> None: + super().__init__(model=model, device=device) + self.threshold = threshold + self._landmark_names: list[str] = [] + + @property + def landmark_names(self) -> list[str]: + # Set on first call to predict_frame + return self._landmark_names + + def predict_frame(self, frame: np.ndarray) -> PoseEstimatorResult: + """Run OpenPose inference on a single BGR frame. + + .. note:: + Full video-level processing is better handled by calling + :meth:`MgVideo.pose` directly. + """ + raise NotImplementedError( + "OpenPosePoseEstimator.predict_frame() is not implemented for " + "single frames. Use MgVideo.pose() for full-video inference." + ) + + +def get_pose_estimator( + backend: str = "mediapipe", + **kwargs: Any, +) -> PoseEstimator: + """Factory function: return a :class:`PoseEstimator` for the requested backend. + + Parameters + ---------- + backend: + ``'mediapipe'`` (default) or ``'openpose'``. + **kwargs: + Additional keyword arguments forwarded to the estimator constructor. + + Returns + ------- + PoseEstimator + + Examples + -------- + >>> est = get_pose_estimator("mediapipe", model_complexity=0) # doctest: +SKIP + """ + backend = backend.lower() + if backend == "mediapipe": + return MediaPipePoseEstimator(**kwargs) + elif backend in ("openpose", "caffe"): + return OpenPosePoseEstimator(**kwargs) + else: + raise ValueError( + f"Unknown pose backend: {backend!r}. " + "Choose 'mediapipe' or 'openpose'." + ) diff --git a/musicalgestures/cli.py b/musicalgestures/cli.py new file mode 100644 index 0000000..7334f5f --- /dev/null +++ b/musicalgestures/cli.py @@ -0,0 +1,212 @@ +"""Command-line interface for MGT-python. + +The ``musicalgestures`` command provides quick access to the most common +analysis and visualisation operations without writing Python code. + +Usage:: + + musicalgestures --help + musicalgestures motion dancer.avi --thresh 0.05 --filtertype Regular + musicalgestures videograms dancer.avi + musicalgestures average dancer.avi + musicalgestures info dancer.avi + musicalgestures convert dancer.avi --to mp4 + +Install CLI dependencies with:: + + pip install musicalgestures[cli] +""" +from __future__ import annotations + +import sys +import logging + +logger = logging.getLogger(__name__) + + +def _require_click(): + try: + import click + return click + except ImportError: + print( + "The CLI requires the 'click' package.\n" + "Install it with: pip install musicalgestures[cli]", + file=sys.stderr, + ) + sys.exit(1) + + +def main() -> None: + """Entry point registered in pyproject.toml as ``musicalgestures``.""" + click = _require_click() + + @click.group( + context_settings={"help_option_names": ["-h", "--help"]}, + invoke_without_command=True, + ) + @click.version_option(package_name="musicalgestures", prog_name="musicalgestures") + @click.pass_context + def cli(ctx): + """Musical Gestures Toolbox – command-line interface. + + Analyse and visualise video and audio files from the command line. + Run 'musicalgestures COMMAND --help' for details on each command. + """ + if ctx.invoked_subcommand is None: + click.echo(ctx.get_help()) + + # ------------------------------------------------------------------ + # info + # ------------------------------------------------------------------ + + @cli.command("info") + @click.argument("filename", type=click.Path(exists=True)) + def cmd_info(filename): + """Print metadata about a video or audio file.""" + try: + from musicalgestures._utils import get_length, get_framecount, get_fps, get_widthheight, has_audio + length = get_length(filename) + fps = get_fps(filename) + width, height = get_widthheight(filename) + audio = has_audio(filename) + click.echo(f"File: {filename}") + click.echo(f"Size: {width} × {height} px") + click.echo(f"FPS: {fps:.2f}") + click.echo(f"Length: {length:.3f} s") + click.echo(f"Audio: {'yes' if audio else 'no'}") + except Exception as exc: + click.echo(f"Error: {exc}", err=True) + sys.exit(1) + + # ------------------------------------------------------------------ + # motion + # ------------------------------------------------------------------ + + @cli.command("motion") + @click.argument("filename", type=click.Path(exists=True)) + @click.option("--filtertype", default="Regular", + type=click.Choice(["Regular", "Binary", "Blob"], case_sensitive=False), + help="Filter type for motion detection.") + @click.option("--thresh", default=0.05, type=float, show_default=True, + help="Pixel-value threshold (0–1).") + @click.option("--blur", default="None", + type=click.Choice(["None", "Average"], case_sensitive=False), + help="Blur type.") + @click.option("--color/--no-color", default=True, show_default=True, + help="Process in colour (default) or grayscale.") + @click.option("--overwrite", is_flag=True, help="Overwrite existing output files.") + def cmd_motion(filename, filtertype, thresh, blur, color, overwrite): + """Render a motion video for FILENAME.""" + try: + import musicalgestures as mg + v = mg.MgVideo(filename, color=color) + out = v.motion(filtertype=filtertype, thresh=thresh, blur=blur, + save_video=True, save_plot=False, save_data=False, + overwrite=overwrite) + click.echo(f"Motion video saved: {out.filename}") + except Exception as exc: + click.echo(f"Error: {exc}", err=True) + sys.exit(1) + + # ------------------------------------------------------------------ + # videograms + # ------------------------------------------------------------------ + + @cli.command("videograms") + @click.argument("filename", type=click.Path(exists=True)) + @click.option("--overwrite", is_flag=True, help="Overwrite existing output files.") + def cmd_videograms(filename, overwrite): + """Render horizontal and vertical videograms for FILENAME.""" + try: + import musicalgestures as mg + v = mg.MgVideo(filename) + out = v.videograms(overwrite=overwrite) + click.echo(f"Videograms saved: {[o.filename for o in out]}") + except Exception as exc: + click.echo(f"Error: {exc}", err=True) + sys.exit(1) + + # ------------------------------------------------------------------ + # average + # ------------------------------------------------------------------ + + @cli.command("average") + @click.argument("filename", type=click.Path(exists=True)) + @click.option("--overwrite", is_flag=True, help="Overwrite existing output files.") + def cmd_average(filename, overwrite): + """Render a pixel-average (blend) image for FILENAME.""" + try: + import musicalgestures as mg + v = mg.MgVideo(filename) + out = v.average(overwrite=overwrite) + click.echo(f"Average image saved: {out.filename}") + except Exception as exc: + click.echo(f"Error: {exc}", err=True) + sys.exit(1) + + # ------------------------------------------------------------------ + # history + # ------------------------------------------------------------------ + + @cli.command("history") + @click.argument("filename", type=click.Path(exists=True)) + @click.option("--overwrite", is_flag=True, help="Overwrite existing output files.") + def cmd_history(filename, overwrite): + """Render a motion history image for FILENAME.""" + try: + import musicalgestures as mg + v = mg.MgVideo(filename) + out = v.history(overwrite=overwrite) + click.echo(f"History image saved: {out.filename}") + except Exception as exc: + click.echo(f"Error: {exc}", err=True) + sys.exit(1) + + # ------------------------------------------------------------------ + # convert + # ------------------------------------------------------------------ + + @cli.command("convert") + @click.argument("filename", type=click.Path(exists=True)) + @click.option("--to", "target_format", default="mp4", show_default=True, + help="Target container format (e.g. mp4, avi, mov).") + @click.option("--overwrite", is_flag=True, help="Overwrite existing output files.") + def cmd_convert(filename, target_format, overwrite): + """Convert FILENAME to another container format.""" + try: + import os + from musicalgestures._utils import convert + of = os.path.splitext(filename)[0] + out = convert(filename, f"{of}.{target_format.lstrip('.')}", overwrite=overwrite) + click.echo(f"Converted: {out}") + except Exception as exc: + click.echo(f"Error: {exc}", err=True) + sys.exit(1) + + # ------------------------------------------------------------------ + # motiongrams + # ------------------------------------------------------------------ + + @cli.command("motiongrams") + @click.argument("filename", type=click.Path(exists=True)) + @click.option("--filtertype", default="Regular", + type=click.Choice(["Regular", "Binary", "Blob"], case_sensitive=False)) + @click.option("--thresh", default=0.05, type=float, show_default=True) + @click.option("--overwrite", is_flag=True) + def cmd_motiongrams(filename, filtertype, thresh, overwrite): + """Render horizontal and vertical motiongrams for FILENAME.""" + try: + import musicalgestures as mg + v = mg.MgVideo(filename) + out = v.motiongrams(filtertype=filtertype, thresh=thresh, overwrite=overwrite) + click.echo(f"Motiongrams saved.") + except Exception as exc: + click.echo(f"Error: {exc}", err=True) + sys.exit(1) + + cli() + + +if __name__ == "__main__": + main() From 807c28bdafc7de1d4aa81cdfb0bee40207f2a3f9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 08:09:19 +0000 Subject: [PATCH 06/10] =?UTF-8?q?feat:=20Phases=203-6=20=E2=80=93=20pose?= =?UTF-8?q?=20estimator,=20pipeline,=20dataset,=20CLI,=20tests,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/fourMs/MGT-python/sessions/be8d0d40-00a4-403d-a196-b06380960b6e Co-authored-by: alexarje <114316+alexarje@users.noreply.github.com> --- musicalgestures/_features.py | 2 +- tests/test_new_features.py | 710 +++++++++++++++++++++++++++++++++++ 2 files changed, 711 insertions(+), 1 deletion(-) create mode 100644 tests/test_new_features.py diff --git a/musicalgestures/_features.py b/musicalgestures/_features.py index b54885a..85401eb 100644 --- a/musicalgestures/_features.py +++ b/musicalgestures/_features.py @@ -148,7 +148,7 @@ def __iter__(self): """Iterate over feature names.""" return iter(self._data) - def __array__(self, dtype=None) -> np.ndarray: + def __array__(self, dtype=None, copy=None) -> np.ndarray: """Return a 2-D array of shape ``(n_features, n_samples)``.""" arr = np.stack(list(self._data.values()), axis=0) return arr if dtype is None else arr.astype(dtype) diff --git a/tests/test_new_features.py b/tests/test_new_features.py new file mode 100644 index 0000000..2924929 --- /dev/null +++ b/tests/test_new_features.py @@ -0,0 +1,710 @@ +"""Tests for the new MGT-python v1.4.0 features. + +These tests cover all new modules added in the modernisation effort: +- _enums, _exceptions, _logging (Phase 1) +- _features, _stream (Phase 2) +- _pose_estimator (Phase 3) +- _pipeline, _dataset (Phase 4) +""" +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np +import pytest + +# --------------------------------------------------------------------------- +# Phase 1 – Enums +# --------------------------------------------------------------------------- + +class TestEnums: + """Tests for musicalgestures._enums.""" + + def test_filter_type_values(self): + from musicalgestures._enums import FilterType + assert FilterType.REGULAR == "Regular" + assert FilterType.BINARY == "Binary" + assert FilterType.BLOB == "Blob" + + def test_filter_type_case_insensitive(self): + from musicalgestures._enums import FilterType + assert FilterType("regular") == FilterType.REGULAR + assert FilterType("BINARY") == FilterType.BINARY + assert FilterType("Blob") == FilterType.BLOB + + def test_blur_type_values(self): + from musicalgestures._enums import BlurType + assert BlurType.NONE == "None" + assert BlurType.AVERAGE == "Average" + + def test_blur_type_case_insensitive(self): + from musicalgestures._enums import BlurType + assert BlurType("none") == BlurType.NONE + assert BlurType("AVERAGE") == BlurType.AVERAGE + + def test_crop_mode_values(self): + from musicalgestures._enums import CropMode + assert CropMode.NONE == "None" + assert CropMode.MANUAL == "manual" + assert CropMode.AUTO == "auto" + + def test_pose_model_values(self): + from musicalgestures._enums import PoseModel + assert PoseModel.BODY_25 == "body_25" + assert PoseModel.MEDIAPIPE == "mediapipe" + + def test_pose_device_values(self): + from musicalgestures._enums import PoseDevice + assert PoseDevice.CPU == "cpu" + assert PoseDevice.GPU == "gpu" + + def test_data_format_values(self): + from musicalgestures._enums import DataFormat + assert DataFormat.CSV == "csv" + assert DataFormat.TSV == "tsv" + assert DataFormat.JSON == "json" + assert DataFormat.HDF5 == "hdf5" + + def test_string_comparison(self): + """Enum members must compare equal to plain strings.""" + from musicalgestures._enums import FilterType + assert FilterType.REGULAR == "Regular" + assert "Regular" == FilterType.REGULAR + + def test_unknown_value_raises(self): + from musicalgestures._enums import FilterType + with pytest.raises((ValueError, KeyError)): + FilterType("NotAFilter") + + +# --------------------------------------------------------------------------- +# Phase 1 – Exceptions +# --------------------------------------------------------------------------- + +class TestExceptions: + """Tests for musicalgestures._exceptions.""" + + def test_hierarchy(self): + from musicalgestures._exceptions import ( + MgError, MgInputError, MgProcessingError, MgIOError, MgDependencyError, + ) + assert issubclass(MgInputError, MgError) + assert issubclass(MgProcessingError, MgError) + assert issubclass(MgIOError, MgError) + assert issubclass(MgDependencyError, MgError) + + def test_raise_and_catch(self): + from musicalgestures._exceptions import MgError, MgInputError + with pytest.raises(MgError): + raise MgInputError("bad input") + + def test_subclass_is_exception(self): + from musicalgestures._exceptions import MgDependencyError + assert issubclass(MgDependencyError, Exception) + + +# --------------------------------------------------------------------------- +# Phase 1 – Logging +# --------------------------------------------------------------------------- + +class TestLogging: + """Tests for musicalgestures._logging.""" + + def test_logger_name(self): + from musicalgestures._logging import logger + assert logger.name == "musicalgestures" + + def test_set_log_level_string(self): + import logging + from musicalgestures._logging import set_log_level, logger + set_log_level("WARNING") + assert logger.level == logging.WARNING + + def test_set_log_level_int(self): + import logging + from musicalgestures._logging import set_log_level, logger + set_log_level(logging.DEBUG) + assert logger.level == logging.DEBUG + + def test_null_handler_present(self): + import logging + from musicalgestures._logging import logger + # At least one handler (NullHandler) should be present + assert len(logger.handlers) >= 1 + + +# --------------------------------------------------------------------------- +# Phase 2 – MgFeatures +# --------------------------------------------------------------------------- + +class TestMgFeatures: + """Tests for musicalgestures._features.MgFeatures.""" + + def _make(self, n=10): + from musicalgestures._features import MgFeatures + return MgFeatures( + data={"qom": np.random.rand(n), "com_x": np.random.rand(n)}, + times=np.linspace(0, 1, n), + sr=float(n), + source="test.avi", + ) + + def test_shape(self): + feat = self._make(10) + assert feat.shape == (2, 10) + + def test_n_features(self): + feat = self._make(10) + assert feat.n_features == 2 + + def test_n_samples(self): + feat = self._make(10) + assert feat.n_samples == 10 + + def test_feature_names(self): + feat = self._make() + assert feat.feature_names == ["qom", "com_x"] + + def test_to_numpy_shape(self): + feat = self._make(15) + arr = feat.to_numpy() + assert arr.shape == (2, 15) + + def test_array_protocol(self): + feat = self._make(5) + arr = np.array(feat) + assert arr.shape == (2, 5) + + def test_to_dataframe(self): + import pandas as pd + feat = self._make(8) + df = feat.to_dataframe() + assert isinstance(df, pd.DataFrame) + assert list(df.columns) == ["qom", "com_x"] + assert len(df) == 8 + + def test_getitem(self): + from musicalgestures._features import MgFeatures + feat = MgFeatures({"a": np.array([1.0, 2.0, 3.0])}, sr=10.0) + np.testing.assert_array_equal(feat["a"], [1.0, 2.0, 3.0]) + + def test_contains(self): + from musicalgestures._features import MgFeatures + feat = MgFeatures({"a": np.ones(3)}, sr=10.0) + assert "a" in feat + assert "b" not in feat + + def test_len(self): + feat = self._make() + assert len(feat) == 2 + + def test_iter(self): + feat = self._make() + names = list(feat) + assert names == ["qom", "com_x"] + + def test_times_default(self): + from musicalgestures._features import MgFeatures + feat = MgFeatures({"a": np.ones(5)}, sr=5.0) + np.testing.assert_array_equal(feat.times, np.arange(5)) + + def test_times_mismatch_raises(self): + from musicalgestures._features import MgFeatures + with pytest.raises(ValueError, match="length"): + MgFeatures({"a": np.ones(5)}, times=np.ones(3), sr=5.0) + + def test_empty_data_raises(self): + from musicalgestures._features import MgFeatures + with pytest.raises(ValueError): + MgFeatures({}, sr=1.0) + + def test_unequal_lengths_raise(self): + from musicalgestures._features import MgFeatures + with pytest.raises(ValueError, match="same length"): + MgFeatures({"a": np.ones(3), "b": np.ones(5)}, sr=1.0) + + def test_json_round_trip(self, tmp_path): + from musicalgestures._features import MgFeatures + feat = MgFeatures({"x": np.array([1.0, 2.0, 3.0])}, sr=5.0, source="vid.avi") + p = tmp_path / "feat.json" + feat.to_json(p) + feat2 = MgFeatures.from_json(p) + np.testing.assert_allclose(feat["x"], feat2["x"]) + assert feat2.sr == 5.0 + + def test_from_dataframe(self): + import pandas as pd + from musicalgestures._features import MgFeatures + df = pd.DataFrame({"a": [1.0, 2.0], "b": [3.0, 4.0]}, index=[0.0, 0.5]) + feat = MgFeatures.from_dataframe(df, sr=2.0) + assert feat.shape == (2, 2) + assert feat.sr == 2.0 + + def test_repr(self): + feat = self._make() + r = repr(feat) + assert "MgFeatures" in r + assert "n_samples=10" in r + + def test_repr_html(self): + feat = self._make() + html = feat._repr_html_() + assert "MgFeatures" in html + assert "qom" in html + + +# --------------------------------------------------------------------------- +# Phase 2 – MgVideoReader (import only – no actual video needed) +# --------------------------------------------------------------------------- + +class TestMgVideoReaderImport: + """Basic import and API checks for MgVideoReader (no real video required).""" + + def test_importable(self): + from musicalgestures._stream import MgVideoReader + assert callable(MgVideoReader) + + def test_repr(self): + from musicalgestures._stream import MgVideoReader + r = MgVideoReader.__init__ # just access it + assert r is not None + + def test_file_not_found(self): + from musicalgestures._stream import MgVideoReader + with pytest.raises(FileNotFoundError): + MgVideoReader("/nonexistent/path.avi").__enter__() + + +# --------------------------------------------------------------------------- +# Phase 3 – PoseEstimator +# --------------------------------------------------------------------------- + +class TestPoseEstimator: + """Tests for musicalgestures._pose_estimator.""" + + def test_abstract_class(self): + from musicalgestures._pose_estimator import PoseEstimator + with pytest.raises(TypeError): + PoseEstimator() + + def test_mediapipe_estimator_init(self): + from musicalgestures._pose_estimator import MediaPipePoseEstimator + est = MediaPipePoseEstimator(model_complexity=0) + assert est.model == "mediapipe" + assert est.device == "cpu" + assert est.model_complexity == 0 + + def test_mediapipe_landmark_names(self): + from musicalgestures._pose_estimator import MediaPipePoseEstimator, MEDIAPIPE_LANDMARK_NAMES + est = MediaPipePoseEstimator() + assert len(est.landmark_names) == 33 + assert est.landmark_names == MEDIAPIPE_LANDMARK_NAMES + assert "nose" in est.landmark_names + + def test_openpose_estimator_init(self): + from musicalgestures._pose_estimator import OpenPosePoseEstimator + est = OpenPosePoseEstimator() + assert est.model == "body_25" + + def test_get_pose_estimator_mediapipe(self): + from musicalgestures._pose_estimator import get_pose_estimator, MediaPipePoseEstimator + est = get_pose_estimator("mediapipe") + assert isinstance(est, MediaPipePoseEstimator) + + def test_get_pose_estimator_openpose(self): + from musicalgestures._pose_estimator import get_pose_estimator, OpenPosePoseEstimator + est = get_pose_estimator("openpose") + assert isinstance(est, OpenPosePoseEstimator) + + def test_get_pose_estimator_invalid_raises(self): + from musicalgestures._pose_estimator import get_pose_estimator + with pytest.raises(ValueError, match="Unknown"): + get_pose_estimator("invalid_backend") + + def test_pose_estimator_result(self): + from musicalgestures._pose_estimator import PoseEstimatorResult, MEDIAPIPE_LANDMARK_NAMES + kp = np.zeros((33, 3)) + res = PoseEstimatorResult(kp, MEDIAPIPE_LANDMARK_NAMES, frame_index=5, timestamp=0.2) + assert res.n_keypoints == 33 + assert res.frame_index == 5 + assert res.timestamp == pytest.approx(0.2) + + def test_pose_result_to_dict(self): + from musicalgestures._pose_estimator import PoseEstimatorResult, MEDIAPIPE_LANDMARK_NAMES + kp = np.zeros((33, 3)) + res = PoseEstimatorResult(kp, MEDIAPIPE_LANDMARK_NAMES) + d = res.to_dict() + assert "keypoints" in d + assert "nose" in d["keypoints"] + assert "x" in d["keypoints"]["nose"] + + def test_mediapipe_no_mediapipe_raises(self): + """If mediapipe is not installed, predict_frame raises MgDependencyError.""" + import importlib + import sys + # Save and remove mediapipe from sys.modules if present + mp_modules = {k: v for k, v in sys.modules.items() if k.startswith("mediapipe")} + for k in mp_modules: + del sys.modules[k] + # Block mediapipe import + class BlockMediapipe: + def find_module(self, name, path=None): + if name == "mediapipe": + return self + def load_module(self, name): + raise ImportError("mediapipe not installed (mocked)") + blocker = BlockMediapipe() + sys.meta_path.insert(0, blocker) + try: + from musicalgestures._pose_estimator import MediaPipePoseEstimator + from musicalgestures._exceptions import MgDependencyError + est = MediaPipePoseEstimator() + est._pose = None # ensure not initialized + with pytest.raises(MgDependencyError): + est._ensure_initialized() + finally: + sys.meta_path.remove(blocker) + # Restore mediapipe modules + sys.modules.update(mp_modules) + + +# --------------------------------------------------------------------------- +# Phase 4 – MgPipeline +# --------------------------------------------------------------------------- + +class TestMgPipeline: + """Tests for musicalgestures._pipeline.MgPipeline.""" + + def test_basic_transform(self): + from musicalgestures._pipeline import MgPipeline + pipe = MgPipeline([("double", lambda x: x * 2)]) + result = pipe.transform(np.array([1.0, 2.0, 3.0])) + np.testing.assert_array_equal(result, [2.0, 4.0, 6.0]) + + def test_multi_step(self): + from musicalgestures._pipeline import MgPipeline + pipe = MgPipeline([ + ("add1", lambda x: x + 1), + ("mul2", lambda x: x * 2), + ]) + result = pipe.transform(np.array([0.0, 1.0, 2.0])) + np.testing.assert_array_equal(result, [2.0, 4.0, 6.0]) + + def test_len(self): + from musicalgestures._pipeline import MgPipeline + pipe = MgPipeline([("a", lambda x: x), ("b", lambda x: x)]) + assert len(pipe) == 2 + + def test_getitem_by_index(self): + from musicalgestures._pipeline import MgPipeline, MgStep + step = MgStep("myname", lambda x: x) + pipe = MgPipeline([step]) + assert pipe[0].name == "myname" + + def test_getitem_by_name(self): + from musicalgestures._pipeline import MgPipeline, MgStep + step = MgStep("find_me", lambda x: x) + pipe = MgPipeline([step]) + assert pipe["find_me"].name == "find_me" + + def test_getitem_missing_name_raises(self): + from musicalgestures._pipeline import MgPipeline + pipe = MgPipeline([("step1", lambda x: x)]) + with pytest.raises(KeyError): + _ = pipe["nonexistent"] + + def test_add_step_chaining(self): + from musicalgestures._pipeline import MgPipeline + pipe = MgPipeline() + result = pipe.add_step(("step1", lambda x: x)).add_step(("step2", lambda x: x)) + assert result is pipe + assert len(pipe) == 2 + + def test_tuple_step(self): + from musicalgestures._pipeline import MgPipeline + pipe = MgPipeline([("negate", lambda x: -x)]) + assert pipe.transform(5) == -5 + + def test_invalid_step_raises(self): + from musicalgestures._pipeline import MgPipeline + with pytest.raises(TypeError): + MgPipeline().add_step("not_a_step") + + def test_fit_returns_self(self): + from musicalgestures._pipeline import MgPipeline + pipe = MgPipeline([("noop", lambda x: x)]) + result = pipe.fit(np.array([1.0])) + assert result is pipe + + def test_fit_transform(self): + from musicalgestures._pipeline import MgPipeline + pipe = MgPipeline([("plus10", lambda x: x + 10)]) + out = pipe.fit_transform(np.array([0.0, 1.0])) + np.testing.assert_array_equal(out, [10.0, 11.0]) + + def test_describe(self): + from musicalgestures._pipeline import MgPipeline + def my_func(x): return x + pipe = MgPipeline([("step1", my_func)]) + desc = pipe.describe() + assert len(desc) == 1 + assert desc[0]["name"] == "step1" + assert desc[0]["func"] == "my_func" + + def test_repr(self): + from musicalgestures._pipeline import MgPipeline + pipe = MgPipeline([("a", lambda x: x)]) + assert "MgPipeline" in repr(pipe) + assert "'a'" in repr(pipe) + + def test_transformer_object(self): + """Steps with a .transform() method should work too.""" + from musicalgestures._pipeline import MgPipeline, MgStep + + class Scaler: + def transform(self, x): + return x / 10.0 + + pipe = MgPipeline([MgStep("scale", Scaler())]) + result = pipe.transform(np.array([10.0, 20.0])) + np.testing.assert_allclose(result, [1.0, 2.0]) + + +# --------------------------------------------------------------------------- +# Phase 4 – MgDataset / MgCorpus / MediaItem +# --------------------------------------------------------------------------- + +class TestMgDataset: + """Tests for musicalgestures._dataset.""" + + def _make_dataset(self, n=5): + from musicalgestures._dataset import MgDataset, MediaItem + items = [ + MediaItem(Path(f"/fake/{i}.avi"), label="dance" if i % 2 == 0 else "piano") + for i in range(n) + ] + return MgDataset(items, name="TestDataset") + + def test_len(self): + ds = self._make_dataset(5) + assert len(ds) == 5 + + def test_getitem(self): + from musicalgestures._dataset import MediaItem + ds = self._make_dataset(3) + assert isinstance(ds[0], MediaItem) + + def test_iter(self): + ds = self._make_dataset(3) + items = list(ds) + assert len(items) == 3 + + def test_labels(self): + ds = self._make_dataset(4) + assert len(ds.labels) == 4 + + def test_unique_labels(self): + ds = self._make_dataset(5) + assert set(ds.unique_labels) == {"dance", "piano"} + + def test_train_test_split(self): + ds = self._make_dataset(10) + train, test = ds.train_test_split(test_size=0.3, shuffle=False, seed=42) + assert len(train) + len(test) == 10 + assert len(test) >= 1 + + def test_filter_by_label(self): + ds = self._make_dataset(6) + dances = ds.filter_by_label("dance") + for item in dances: + assert item.label == "dance" + + def test_filter_callable(self): + ds = self._make_dataset(4) + videos = ds.filter(lambda item: item.is_video) + # .avi files should all be video + assert len(videos) == 4 + + def test_repr(self): + ds = self._make_dataset(3) + r = repr(ds) + assert "MgDataset" in r + assert "TestDataset" in r + + def test_json_round_trip(self, tmp_path): + from musicalgestures._dataset import MgDataset, MediaItem + items = [MediaItem(Path("/fake/a.avi"), label="x")] + ds = MgDataset(items, name="myds") + p = tmp_path / "ds.json" + ds.to_json(p) + ds2 = MgDataset.from_json(p) + assert len(ds2) == 1 + assert ds2[0].label == "x" + assert ds2.name == "myds" + + def test_from_directory(self, tmp_path): + from musicalgestures._dataset import MgDataset + # Create dummy files + (tmp_path / "cat").mkdir() + (tmp_path / "cat" / "video1.avi").touch() + (tmp_path / "dog").mkdir() + (tmp_path / "dog" / "video2.mp4").touch() + ds = MgDataset.from_directory(tmp_path, label_from="parent") + assert len(ds) == 2 + assert set(ds.unique_labels) == {"cat", "dog"} + + def test_from_directory_not_a_dir_raises(self, tmp_path): + from musicalgestures._dataset import MgDataset + with pytest.raises(NotADirectoryError): + MgDataset.from_directory(tmp_path / "nonexistent") + + def test_repr_html(self): + ds = self._make_dataset(3) + html = ds._repr_html_() + assert "MgDataset" in html + + +class TestMediaItem: + """Tests for musicalgestures._dataset.MediaItem.""" + + def test_is_video(self): + from musicalgestures._dataset import MediaItem + item = MediaItem(Path("/a/b.avi")) + assert item.is_video is True + assert item.is_audio is False + + def test_is_audio(self): + from musicalgestures._dataset import MediaItem + item = MediaItem(Path("/a/b.wav")) + assert item.is_audio is True + assert item.is_video is False + + def test_stem(self): + from musicalgestures._dataset import MediaItem + item = MediaItem(Path("/a/myclip.mp4")) + assert item.stem == "myclip" + + def test_repr(self): + from musicalgestures._dataset import MediaItem + item = MediaItem(Path("/a/b.avi"), label="dance") + r = repr(item) + assert "b.avi" in r + assert "dance" in r + + +class TestMgCorpus: + """Tests for musicalgestures._dataset.MgCorpus.""" + + def test_from_directory(self, tmp_path): + from musicalgestures._dataset import MgCorpus + (tmp_path / "classA").mkdir() + (tmp_path / "classA" / "x.avi").touch() + corpus = MgCorpus(tmp_path, label_from="parent") + assert len(corpus) >= 1 + assert corpus.root == tmp_path + + def test_repr(self, tmp_path): + from musicalgestures._dataset import MgCorpus + corpus = MgCorpus(tmp_path) + assert "MgCorpus" in repr(corpus) + + +# --------------------------------------------------------------------------- +# Phase 2 – Jupyter repr on MgImage / MgFigure +# --------------------------------------------------------------------------- + +class TestJupyterRepr: + """Tests that _repr_html_ was added to MgImage and MgFigure.""" + + def test_mgimage_has_repr_html(self): + from musicalgestures._utils import MgImage + assert hasattr(MgImage, "_repr_html_") + assert callable(MgImage._repr_html_) + + def test_mgimage_repr_html_missing_file(self): + from musicalgestures._utils import MgImage + img = MgImage("/nonexistent/file.png") + html = img._repr_html_() + assert "not found" in html + + def test_mgimage_repr_html_existing_file(self, tmp_path): + from musicalgestures._utils import MgImage + f = tmp_path / "test.png" + # Write a minimal 1x1 white PNG (89 bytes) + import base64 + png_b64 = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk" + "YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" + ) + f.write_bytes(base64.b64decode(png_b64)) + img = MgImage(str(f)) + html = img._repr_html_() + assert "data:image/png;base64," in html + + def test_mgfigure_has_repr_html(self): + from musicalgestures._utils import MgFigure + assert hasattr(MgFigure, "_repr_html_") + assert callable(MgFigure._repr_html_) + + def test_mgfigure_repr_html_no_image(self): + from musicalgestures._utils import MgFigure + fig = MgFigure(figure_type="test") + html = fig._repr_html_() + assert "MgFigure" in html + + +# --------------------------------------------------------------------------- +# Phase 6 – __init__.py exports +# --------------------------------------------------------------------------- + +class TestInitExports: + """Verify all new symbols are exported from the musicalgestures package.""" + + def test_enum_exports(self): + import musicalgestures + assert hasattr(musicalgestures, "FilterType") + assert hasattr(musicalgestures, "BlurType") + assert hasattr(musicalgestures, "CropMode") + assert hasattr(musicalgestures, "PoseModel") + assert hasattr(musicalgestures, "PoseDevice") + assert hasattr(musicalgestures, "DataFormat") + + def test_exception_exports(self): + import musicalgestures + assert hasattr(musicalgestures, "MgError") + assert hasattr(musicalgestures, "MgInputError") + assert hasattr(musicalgestures, "MgProcessingError") + assert hasattr(musicalgestures, "MgIOError") + assert hasattr(musicalgestures, "MgDependencyError") + + def test_logging_exports(self): + import musicalgestures + assert hasattr(musicalgestures, "set_log_level") + assert callable(musicalgestures.set_log_level) + + def test_features_exports(self): + import musicalgestures + assert hasattr(musicalgestures, "MgFeatures") + + def test_stream_exports(self): + import musicalgestures + assert hasattr(musicalgestures, "MgVideoReader") + + def test_pipeline_exports(self): + import musicalgestures + assert hasattr(musicalgestures, "MgPipeline") + assert hasattr(musicalgestures, "MgStep") + + def test_dataset_exports(self): + import musicalgestures + assert hasattr(musicalgestures, "MgDataset") + assert hasattr(musicalgestures, "MgCorpus") + assert hasattr(musicalgestures, "MediaItem") + + def test_pose_estimator_exports(self): + import musicalgestures + assert hasattr(musicalgestures, "PoseEstimator") + assert hasattr(musicalgestures, "PoseEstimatorResult") + assert hasattr(musicalgestures, "MediaPipePoseEstimator") + assert hasattr(musicalgestures, "get_pose_estimator") From b366580ad1dd370be01060f5b9aef574df229e59 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Apr 2026 08:10:59 +0000 Subject: [PATCH 07/10] fix: clarify package-data glob in pyproject.toml Agent-Logs-Url: https://github.com/fourMs/MGT-python/sessions/be8d0d40-00a4-403d-a196-b06380960b6e Co-authored-by: alexarje <114316+alexarje@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b4f56f8..5be85be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ where = ["."] include = ["musicalgestures*"] [tool.setuptools.package-data] -musicalgestures = ["*.avi", "*.ipynb", "examples/*", "pose/*", "models/*", "3rdparty/**"] +musicalgestures = ["*.avi", "*.ipynb", "examples/*", "pose/*", "models/*", "3rdparty/**/*"] [tool.ruff] # Exclude the most problematic legacy directories from linting From d75a3262237a6858b7891cc130c81c7a6b0e403f Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:49:43 +0000 Subject: [PATCH 08/10] Update documentation --- docs/MODULES.md | 9 + docs/musicalgestures/_dataset.md | 341 +++++++++++++++++++++ docs/musicalgestures/_enums.md | 139 +++++++++ docs/musicalgestures/_exceptions.md | 81 +++++ docs/musicalgestures/_features.md | 293 +++++++++++++++++++ docs/musicalgestures/_logging.md | 43 +++ docs/musicalgestures/_pipeline.md | 205 +++++++++++++ docs/musicalgestures/_pose_estimator.md | 374 ++++++++++++++++++++++++ docs/musicalgestures/_stream.md | 127 ++++++++ docs/musicalgestures/_utils.md | 100 +++---- docs/musicalgestures/cli.md | 38 +++ docs/musicalgestures/index.md | 9 + 12 files changed, 1709 insertions(+), 50 deletions(-) create mode 100644 docs/musicalgestures/_dataset.md create mode 100644 docs/musicalgestures/_enums.md create mode 100644 docs/musicalgestures/_exceptions.md create mode 100644 docs/musicalgestures/_features.md create mode 100644 docs/musicalgestures/_logging.md create mode 100644 docs/musicalgestures/_pipeline.md create mode 100644 docs/musicalgestures/_pose_estimator.md create mode 100644 docs/musicalgestures/_stream.md create mode 100644 docs/musicalgestures/cli.md diff --git a/docs/MODULES.md b/docs/MODULES.md index 7b7bb4f..4319515 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -14,7 +14,11 @@ Full list of [Mgt-python](README.md#mgt-python) project modules. - [Colored](musicalgestures/_colored.md#colored) - [Cropping Window](musicalgestures/_cropping_window.md#cropping-window) - [Cropvideo](musicalgestures/_cropvideo.md#cropvideo) + - [Dataset](musicalgestures/_dataset.md#dataset) - [Directograms](musicalgestures/_directograms.md#directograms) + - [Enums](musicalgestures/_enums.md#enums) + - [Exceptions](musicalgestures/_exceptions.md#exceptions) + - [Features](musicalgestures/_features.md#features) - [Filter](musicalgestures/_filter.md#filter) - [Flow](musicalgestures/_flow.md#flow) - [Frameaverage](musicalgestures/_frameaverage.md#frameaverage) @@ -23,15 +27,19 @@ Full list of [Mgt-python](README.md#mgt-python) project modules. - [Impacts](musicalgestures/_impacts.md#impacts) - [Info](musicalgestures/_info.md#info) - [Input Test](musicalgestures/_input_test.md#input-test) + - [Logging](musicalgestures/_logging.md#logging) - [MgList](musicalgestures/_mglist.md#mglist) - [Motionanalysis](musicalgestures/_motionanalysis.md#motionanalysis) - [Motionvideo](musicalgestures/_motionvideo.md#motionvideo) - [Motionvideo Mp Render](musicalgestures/_motionvideo_mp_render.md#motionvideo-mp-render) - [Motionvideo Mp Run](musicalgestures/_motionvideo_mp_run.md#motionvideo-mp-run) + - [Pipeline](musicalgestures/_pipeline.md#pipeline) - [Pose](musicalgestures/_pose.md#pose) + - [PoseEstimator](musicalgestures/_pose_estimator.md#poseestimator) - [Show](musicalgestures/_show.md#show) - [Show Window](musicalgestures/_show_window.md#show-window) - [Ssm](musicalgestures/_ssm.md#ssm) + - [Stream](musicalgestures/_stream.md#stream) - [Subtract](musicalgestures/_subtract.md#subtract) - [Utils](musicalgestures/_utils.md#utils) - [Video](musicalgestures/_video.md#video) @@ -39,3 +47,4 @@ Full list of [Mgt-python](README.md#mgt-python) project modules. - [Videograms](musicalgestures/_videograms.md#videograms) - [Videoreader](musicalgestures/_videoreader.md#videoreader) - [Warp](musicalgestures/_warp.md#warp) + - [Cli](musicalgestures/cli.md#cli) diff --git a/docs/musicalgestures/_dataset.md b/docs/musicalgestures/_dataset.md new file mode 100644 index 0000000..003e61c --- /dev/null +++ b/docs/musicalgestures/_dataset.md @@ -0,0 +1,341 @@ +# Dataset + +> Auto-generated documentation for [musicalgestures._dataset](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_dataset.py) module. + +Dataset and Corpus classes for managing collections of media files. + +- [Mgt-python](../README.md#mgt-python) / [Modules](../MODULES.md#mgt-python-modules) / [Musicalgestures](index.md#musicalgestures) / Dataset + - [MediaItem](#mediaitem) + - [MediaItem().is_audio](#mediaitemis_audio) + - [MediaItem().is_video](#mediaitemis_video) + - [MediaItem().stem](#mediaitemstem) + - [MediaItem().suffix](#mediaitemsuffix) + - [MgCorpus](#mgcorpus) + - [MgDataset](#mgdataset) + - [MgDataset().filter](#mgdatasetfilter) + - [MgDataset().filter_by_label](#mgdatasetfilter_by_label) + - [MgDataset.from_directory](#mgdatasetfrom_directory) + - [MgDataset.from_json](#mgdatasetfrom_json) + - [MgDataset().labels](#mgdatasetlabels) + - [MgDataset().to_json](#mgdatasetto_json) + - [MgDataset().train_test_split](#mgdatasettrain_test_split) + - [MgDataset().unique_labels](#mgdatasetunique_labels) + +class `MgDataset` manages a collection of media files (video or audio) and +provides batch processing, train/test splitting, and metadata management, +following conventions from :mod:`librosa` and MNE-Python. + +class `MgCorpus` is a higher-level convenience wrapper that scans a directory +tree for media files and builds an class `MgDataset` automatically. + +Examples +-------- + +```python +>>> from musicalgestures._dataset import MgDataset +>>> ds = MgDataset.from_directory("/path/to/videos", pattern="*.avi") +>>> train, test = ds.train_test_split(test_size=0.2) +>>> for item in train: +... print(item["path"], item["label"]) + +## MediaItem + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_dataset.py#L34) + +```python +dataclass +class MediaItem(): +``` + +A single item in an :class:[MgDataset](#mgdataset). + +Parameters +---------- +path: + Absolute path to the media file. +label: + Optional class label or annotation string. +metadata: + Optional free-form metadata dict. + +### MediaItem().is_audio + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_dataset.py#L68) + +```python +@property +def is_audio() -> bool: +``` + +True if this is a recognised audio file. + +### MediaItem().is_video + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_dataset.py#L63) + +```python +@property +def is_video() -> bool: +``` + +True if this is a recognised video file. + +### MediaItem().stem + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_dataset.py#L53) + +```python +@property +def stem() -> str: +``` + +Filename without extension. + +### MediaItem().suffix + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_dataset.py#L58) + +```python +@property +def suffix() -> str: +``` + +File extension (lower-case). + +## MgCorpus + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_dataset.py#L346) + +```python +class MgCorpus(MgDataset): + def __init__( + root: str | Path, + pattern: str = '**/*', + label_from: str = 'parent', + ) -> None: +``` + +Corpus: an :class:[MgDataset](#mgdataset) built by scanning a directory tree. + +This is a convenience subclass. Use :meth:[MgDataset.from_directory](#mgdatasetfrom_directory) +for equivalent functionality. + +Parameters +---------- +root: + Root directory of the corpus. +pattern: + Glob pattern. Default: ``'**/*'``. +label_from: + ``'parent'``, ``'stem'``, or ``'none'``. Default: ``'parent'``. + +Examples +-------- + +```python +>>> corpus = MgCorpus("/data/recordings", label_from="parent") # doctest: +SKIP +>>> len(corpus) # doctest: +SKIP +120 +>>> train, test = corpus.train_test_split(test_size=0.2) # doctest: +SKIP + +#### See also + +- [MgDataset](#mgdataset) + +## MgDataset + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_dataset.py#L77) + +```python +class MgDataset(): + def __init__( + items: list[MediaItem] | None = None, + name: str = 'MgDataset', + ) -> None: +``` + +A labelled collection of media files. + +Parameters +---------- +items: + List of :class:[MediaItem](#mediaitem) objects. +name: + Optional human-readable name for this dataset. + +Examples +-------- + +```python +>>> from pathlib import Path +>>> from musicalgestures._dataset import MgDataset, MediaItem +>>> items = [ +... MediaItem(Path("/data/dance1.avi"), label="dance"), +... MediaItem(Path("/data/piano1.avi"), label="piano"), +... ] +>>> ds = MgDataset(items, name="demo") +>>> len(ds) +2 + +### MgDataset().filter + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_dataset.py#L243) + +```python +def filter(func) -> 'MgDataset': +``` + +Return a new dataset containing only items for which *func(item)* is True. + +Parameters +---------- +func: + Callable accepting a :class:[MediaItem](#mediaitem) and returning bool. + +Returns +------- +MgDataset + +### MgDataset().filter_by_label + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_dataset.py#L257) + +```python +def filter_by_label(label: str) -> 'MgDataset': +``` + +Return a new dataset containing only items with the given *label*. + +Parameters +---------- +label: + Label string to match. + +Returns +------- +MgDataset + +### MgDataset.from_directory + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_dataset.py#L128) + +```python +@classmethod +def from_directory( + directory: str | Path, + pattern: str = '**/*', + label_from: str = 'parent', + recursive: bool = True, + name: str | None = None, +) -> 'MgDataset': +``` + +Build a dataset by scanning a directory for media files. + +Parameters +---------- +directory: + Root directory to scan. +pattern: + Glob pattern relative to *directory*. Default: ``'**/*'``. +label_from: + How to derive labels: ``'parent'`` uses the immediate parent + directory name; ``'stem'`` uses the filename stem; + ``'none'`` assigns no label. +recursive: + If *True* (default), scan sub-directories. +name: + Optional dataset name. + +Returns +------- +MgDataset + +### MgDataset.from_json + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_dataset.py#L181) + +```python +@classmethod +def from_json(path: str | Path) -> 'MgDataset': +``` + +Load a dataset from a JSON file saved by :meth:[MgDataset().to_json](#mgdatasetto_json). + +Parameters +---------- +path: + Path to the JSON file. + +Returns +------- +MgDataset + +### MgDataset().labels + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_dataset.py#L271) + +```python +@property +def labels() -> list[str | None]: +``` + +List of all item labels (in order). + +### MgDataset().to_json + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_dataset.py#L285) + +```python +def to_json(path: str | Path | None = None) -> str: +``` + +Serialise the dataset to JSON. + +Parameters +---------- +path: + Optional file path to write. If *None*, returns the JSON string. + +Returns +------- +str + +### MgDataset().train_test_split + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_dataset.py#L209) + +```python +def train_test_split( + test_size: float = 0.2, + shuffle: bool = True, + seed: int | None = None, +) -> tuple['MgDataset', 'MgDataset']: +``` + +Split the dataset into train and test subsets. + +Parameters +---------- +test_size: + Fraction of items to include in the test set. Default: 0.2. +shuffle: + Whether to shuffle before splitting. Default: True. +seed: + Random seed for reproducibility. + +Returns +------- +train : MgDataset +test : MgDataset + +### MgDataset().unique_labels + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_dataset.py#L276) + +```python +@property +def unique_labels() -> list[str]: +``` + +Sorted list of unique non-None labels. diff --git a/docs/musicalgestures/_enums.md b/docs/musicalgestures/_enums.md new file mode 100644 index 0000000..1a9e82e --- /dev/null +++ b/docs/musicalgestures/_enums.md @@ -0,0 +1,139 @@ +# Enums + +> Auto-generated documentation for [musicalgestures._enums](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_enums.py) module. + +Enumeration types for MGT-python parameter validation. + +- [Mgt-python](../README.md#mgt-python) / [Modules](../MODULES.md#mgt-python-modules) / [Musicalgestures](index.md#musicalgestures) / Enums + - [BlurType](#blurtype) + - [CropMode](#cropmode) + - [DataFormat](#dataformat) + - [FilterType](#filtertype) + - [PoseDevice](#posedevice) + - [PoseModel](#posemodel) + +Using StrEnum so that enum members compare equal to their string values, +maintaining full backward compatibility with code that passes plain strings. +All enumerations support case-insensitive construction: + +```python +>>> BlurType("average") == BlurType.AVERAGE +True +>>> BlurType("AVERAGE") == BlurType.AVERAGE +True + +## BlurType + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_enums.py#L56) + +```python +class BlurType(_MgEnum): +``` + +Spatial blur applied before the frame-difference computation. + +Attributes +---------- +NONE: + No blurring is applied. +AVERAGE: + A 10 × 10 pixel box-blur is applied. + +## CropMode + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_enums.py#L70) + +```python +class CropMode(_MgEnum): +``` + +Video cropping strategy. + +Attributes +---------- +NONE: + No cropping. +MANUAL: + Opens an interactive window; the user draws a rectangle. +AUTO: + Automatically detects the area of significant motion. + +## DataFormat + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_enums.py#L121) + +```python +class DataFormat(_MgEnum): +``` + +Output data file format. + +Attributes +---------- +CSV: + Comma-separated values. +TSV: + Tab-separated values. +TXT: + Plain text (space-separated). +JSON: + JSON with metadata. +HDF5: + HDF5 / Zarr for large feature matrices. + +## FilterType + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_enums.py#L39) + +```python +class FilterType(_MgEnum): +``` + +Pixel-value filter applied to the frame-difference stream. + +Attributes +---------- +REGULAR: + Values below *thresh* are set to 0; values above are kept as-is. +BINARY: + Values below *thresh* → 0; values above *thresh* → 255. +BLOB: + Individual pixels are removed with an erosion filter. + +## PoseDevice + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_enums.py#L107) + +```python +class PoseDevice(_MgEnum): +``` + +Compute backend for pose estimation inference. + +Attributes +---------- +CPU: + Run on CPU. +GPU: + Run on GPU (CUDA / OpenCL). + +## PoseModel + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_enums.py#L87) + +```python +class PoseModel(_MgEnum): +``` + +Pose estimation skeleton model. + +Attributes +---------- +BODY_25: + OpenPose BODY_25 dataset (25 keypoints). +COCO: + COCO dataset (18 keypoints). +MPI: + MPII dataset (15 keypoints). +MEDIAPIPE: + Google MediaPipe Pose (33 landmarks). diff --git a/docs/musicalgestures/_exceptions.md b/docs/musicalgestures/_exceptions.md new file mode 100644 index 0000000..cdc3123 --- /dev/null +++ b/docs/musicalgestures/_exceptions.md @@ -0,0 +1,81 @@ +# Exceptions + +> Auto-generated documentation for [musicalgestures._exceptions](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_exceptions.py) module. + +Typed exception hierarchy for MGT-python. + +- [Mgt-python](../README.md#mgt-python) / [Modules](../MODULES.md#mgt-python-modules) / [Musicalgestures](index.md#musicalgestures) / Exceptions + - [MgDependencyError](#mgdependencyerror) + - [MgError](#mgerror) + - [MgIOError](#mgioerror) + - [MgInputError](#mginputerror) + - [MgProcessingError](#mgprocessingerror) + +All library-specific errors inherit from class `MgError` so that callers +can catch any toolbox error with a single ``except MgError``. + +## MgDependencyError + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_exceptions.py#L25) + +```python +class MgDependencyError(MgError): +``` + +Raised when an optional dependency is not installed. + +#### See also + +- [MgError](#mgerror) + +## MgError + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_exceptions.py#L9) + +```python +class MgError(Exception): +``` + +Base class for all MGT-python exceptions. + +## MgIOError + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_exceptions.py#L21) + +```python +class MgIOError(MgError): +``` + +Raised for file I/O failures (missing files, permission errors, etc.). + +#### See also + +- [MgError](#mgerror) + +## MgInputError + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_exceptions.py#L13) + +```python +class MgInputError(MgError): +``` + +Raised when a user-supplied argument is invalid. + +#### See also + +- [MgError](#mgerror) + +## MgProcessingError + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_exceptions.py#L17) + +```python +class MgProcessingError(MgError): +``` + +Raised when a processing step fails unexpectedly. + +#### See also + +- [MgError](#mgerror) diff --git a/docs/musicalgestures/_features.md b/docs/musicalgestures/_features.md new file mode 100644 index 0000000..8da93fb --- /dev/null +++ b/docs/musicalgestures/_features.md @@ -0,0 +1,293 @@ +# Features + +> Auto-generated documentation for [musicalgestures._features](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_features.py) module. + +MgFeatures – a named time-series container for motion and audio descriptors. + +- [Mgt-python](../README.md#mgt-python) / [Modules](../MODULES.md#mgt-python-modules) / [Musicalgestures](index.md#musicalgestures) / Features + - [MgFeatures](#mgfeatures) + - [MgFeatures().\_\_array\_\_](#mgfeatures__array__) + - [MgFeatures().\_\_getitem\_\_](#mgfeatures__getitem__) + - [MgFeatures().\_\_iter\_\_](#mgfeatures__iter__) + - [MgFeatures().\_\_len\_\_](#mgfeatures__len__) + - [MgFeatures().feature_names](#mgfeaturesfeature_names) + - [MgFeatures.from_dataframe](#mgfeaturesfrom_dataframe) + - [MgFeatures.from_json](#mgfeaturesfrom_json) + - [MgFeatures().n_features](#mgfeaturesn_features) + - [MgFeatures().n_samples](#mgfeaturesn_samples) + - [MgFeatures().shape](#mgfeaturesshape) + - [MgFeatures().times](#mgfeaturestimes) + - [MgFeatures().to_dataframe](#mgfeaturesto_dataframe) + - [MgFeatures().to_json](#mgfeaturesto_json) + - [MgFeatures().to_numpy](#mgfeaturesto_numpy) + +class `MgFeatures` holds one or more named feature arrays (e.g. quantity of +motion, centroid of motion, optical flow statistics, spectral features) together +with shared metadata (sampling rate, time axis, source filename). It is the +primary data structure for feeding MGT-python analysis results into machine- +learning pipelines. + +The design follows conventions established by librosa (feature arrays + sample +rate) and MNE-Python (named channels + metadata dict). + +Examples +-------- + +```python +>>> import numpy as np +>>> from musicalgestures._features import MgFeatures +>>> t = np.linspace(0, 10, 100) +>>> feat = MgFeatures( +... data={"qom": np.random.rand(100), "com_x": np.random.rand(100)}, +... times=t, +... sr=25.0, +... source="dancer.avi", +... ) +>>> feat.shape +(2, 100) +>>> arr = feat.to_numpy() # shape (2, 100) +>>> df = feat.to_dataframe() # pandas DataFrame, columns = feature names + +## MgFeatures + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_features.py#L41) + +```python +class MgFeatures(): + def __init__( + data: dict[str, np.ndarray], + times: np.ndarray | None = None, + sr: float = 1.0, + source: str | Path | None = None, + metadata: dict[str, Any] | None = None, + ) -> None: +``` + +Named time-series container for motion and audio descriptors. + +Parameters +---------- +data: + A mapping of ``{feature_name: 1-D numpy array}``. All arrays must + have the same length (number of time samples). +times: + 1-D array of time stamps in seconds corresponding to each sample. + If *None*, integer sample indices are used. +sr: + Sampling rate of the feature time series in Hz (frames per second + for video-derived features, Hz for audio-derived features). +source: + Path to the source file that the features were derived from. +metadata: + Optional free-form dictionary of additional metadata (parameters + used, processing chain description, etc.). + +Attributes +---------- +feature_names : list[str] + Names of the features stored in this container. +n_features : int + Number of named feature channels. +n_samples : int + Number of time samples per channel. +shape : tuple[int, int] + ``(n_features, n_samples)`` + +### MgFeatures().\_\_array\_\_ + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_features.py#L151) + +```python +def __array__(dtype=None, copy=None) -> np.ndarray: +``` + +Return a 2-D array of shape ``(n_features, n_samples)``. + +### MgFeatures().\_\_getitem\_\_ + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_features.py#L140) + +```python +def __getitem__(key: str) -> np.ndarray: +``` + +Return a single feature array by name. + +### MgFeatures().\_\_iter\_\_ + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_features.py#L147) + +```python +def __iter__(): +``` + +Iterate over feature names. + +### MgFeatures().\_\_len\_\_ + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_features.py#L136) + +```python +def __len__() -> int: +``` + +Return the number of feature channels. + +### MgFeatures().feature_names + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_features.py#L107) + +```python +@property +def feature_names() -> list[str]: +``` + +Names of the feature channels. + +### MgFeatures.from_dataframe + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_features.py#L237) + +```python +@classmethod +def from_dataframe( + df: pd.DataFrame, + sr: float = 1.0, + source: str | Path | None = None, + metadata: dict[str, Any] | None = None, +) -> 'MgFeatures': +``` + +Create an :class:[MgFeatures](#mgfeatures) from a class `pandas.DataFrame`. + +Parameters +---------- +df: + DataFrame whose columns are feature names and whose index is + the time axis in seconds. +sr: + Sampling rate in Hz. +source: + Optional source file path. +metadata: + Optional metadata dictionary. + +Returns +------- +MgFeatures + +### MgFeatures.from_json + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_features.py#L213) + +```python +@classmethod +def from_json(path: str | Path) -> 'MgFeatures': +``` + +Load an :class:[MgFeatures](#mgfeatures) instance from a JSON file. + +Parameters +---------- +path: + Path to the JSON file previously created by :meth:[MgFeatures().to_json](#mgfeaturesto_json). + +Returns +------- +MgFeatures + +### MgFeatures().n_features + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_features.py#L112) + +```python +@property +def n_features() -> int: +``` + +Number of feature channels. + +### MgFeatures().n_samples + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_features.py#L117) + +```python +@property +def n_samples() -> int: +``` + +Number of time samples per channel. + +### MgFeatures().shape + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_features.py#L122) + +```python +@property +def shape() -> tuple[int, int]: +``` + +``(n_features, n_samples)``. + +### MgFeatures().times + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_features.py#L127) + +```python +@property +def times() -> np.ndarray: +``` + +Time axis in seconds. + +### MgFeatures().to_dataframe + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_features.py#L171) + +```python +def to_dataframe() -> pd.DataFrame: +``` + +Return features as a class `pandas.DataFrame`. + +Returns +------- +pd.DataFrame + Columns are feature names, index is the time axis in seconds. + +### MgFeatures().to_json + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_features.py#L183) + +```python +def to_json(path: str | Path | None = None) -> str: +``` + +Serialise to JSON (with metadata). + +Parameters +---------- +path: + Optional file path to write the JSON to. If *None*, returns + the JSON string. + +Returns +------- +str + JSON-encoded string representation. + +### MgFeatures().to_numpy + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_features.py#L160) + +```python +def to_numpy() -> np.ndarray: +``` + +Return all features as a 2-D NumPy array ``(n_features, n_samples)``. + +Returns +------- +np.ndarray + Shape ``(n_features, n_samples)``. Row order matches + :attr:[MgFeatures().feature_names](#mgfeaturesfeature_names). diff --git a/docs/musicalgestures/_logging.md b/docs/musicalgestures/_logging.md new file mode 100644 index 0000000..e1c6a33 --- /dev/null +++ b/docs/musicalgestures/_logging.md @@ -0,0 +1,43 @@ +# Logging + +> Auto-generated documentation for [musicalgestures._logging](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_logging.py) module. + +Logging configuration for MGT-python. + +- [Mgt-python](../README.md#mgt-python) / [Modules](../MODULES.md#mgt-python-modules) / [Musicalgestures](index.md#musicalgestures) / Logging + - [set_log_level](#set_log_level) + +The library exposes a single logger named ``'musicalgestures'``. Users can +adjust verbosity at the application level + +```python +import logging +logging.getLogger('musicalgestures').setLevel(logging.DEBUG) +``` + +By default the logger has no handlers (quiet) so it does not interfere with +the host application's logging setup. A convenience :func:`set_log_level` +helper is provided for interactive / script use. + +## set_log_level + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_logging.py#L26) + +```python +def set_log_level(level: int | str) -> None: +``` + +Set the verbosity of the *musicalgestures* logger. + +Parameters +---------- +level: + A :mod:`logging` level constant (e.g. ``logging.DEBUG``) or a + level name string (e.g. ``'DEBUG'``, ``'INFO'``, ``'WARNING'``). + +Examples +-------- + +```python +>>> import musicalgestures +>>> musicalgestures.set_log_level('DEBUG') diff --git a/docs/musicalgestures/_pipeline.md b/docs/musicalgestures/_pipeline.md new file mode 100644 index 0000000..5ff4049 --- /dev/null +++ b/docs/musicalgestures/_pipeline.md @@ -0,0 +1,205 @@ +# Pipeline + +> Auto-generated documentation for [musicalgestures._pipeline](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pipeline.py) module. + +Scikit-learn–style processing pipeline for MGT-python. + +- [Mgt-python](../README.md#mgt-python) / [Modules](../MODULES.md#mgt-python-modules) / [Musicalgestures](index.md#musicalgestures) / Pipeline + - [MgPipeline](#mgpipeline) + - [MgPipeline().add_step](#mgpipelineadd_step) + - [MgPipeline().describe](#mgpipelinedescribe) + - [MgPipeline().fit](#mgpipelinefit) + - [MgPipeline().fit_transform](#mgpipelinefit_transform) + - [MgPipeline().transform](#mgpipelinetransform) + - [MgStep](#mgstep) + - [MgStep().\_\_call\_\_](#mgstep__call__) + +class `MgPipeline` chains a sequence of named steps where each step is a +callable (function) or a duck-typed transformer with a ``transform`` method. +This enables reproducible, serialisable analysis graphs. + +The design is intentionally minimal and compatible with +class `sklearn.pipeline.Pipeline` conventions (``fit`` / ``transform`` / +``fit_transform``). + +Examples +-------- + +```python +>>> from musicalgestures._pipeline import MgPipeline, MgStep +>>> import numpy as np +>>> +>>> def scale(x): +... return x / x.max() +>>> +>>> pipe = MgPipeline([ +... MgStep("scale", scale), +... ]) +>>> result = pipe.transform(np.array([1.0, 2.0, 4.0])) +>>> result +array([0.25, 0.5 , 1. ]) + +## MgPipeline + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pipeline.py#L64) + +```python +class MgPipeline(): + def __init__( + steps: list[MgStep | tuple[str, Callable]] | None = None, + ) -> None: +``` + +Chain multiple processing steps into a reproducible pipeline. + +Parameters +---------- +steps: + Ordered list of :class:[MgStep](#mgstep) objects (or 2-tuples + ``(name, callable)``). + +Examples +-------- +Build a pipeline that normalises a 1-D feature array: + +```python +>>> import numpy as np +>>> from musicalgestures._pipeline import MgPipeline, MgStep +>>> def subtract_mean(x): return x - x.mean() +>>> def divide_std(x): return x / (x.std() + 1e-8) +>>> pipe = MgPipeline([("center", subtract_mean), ("scale", divide_std)]) +>>> arr = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) +>>> pipe.transform(arr) +array([-1.41421356, -0.70710678, 0. , 0.70710678, 1.41421356]) + +### MgPipeline().add_step + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pipeline.py#L100) + +```python +def add_step(step: MgStep | tuple[str, Callable]) -> 'MgPipeline': +``` + +Append a step to the pipeline. + +Parameters +---------- +step: + An :class:[MgStep](#mgstep) instance, or a 2-tuple ``(name, callable)``. + +Returns +------- +MgPipeline + Returns *self* to allow chaining. + +### MgPipeline().describe + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pipeline.py#L209) + +```python +def describe() -> list[dict[str, Any]]: +``` + +Return a human-readable description of all steps. + +Returns +------- +list[dict[str, Any]] + +### MgPipeline().fit + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pipeline.py#L160) + +```python +def fit(X: Any, y: Any = None) -> 'MgPipeline': +``` + +Fit each step in sequence (for sklearn compatibility). + +For steps that have a ``fit`` method, it is called. Otherwise +the step is treated as stateless and nothing happens. + +Parameters +---------- +X: + Training data. +y: + Target labels (passed through to sklearn-compatible steps). + +Returns +------- +MgPipeline + Returns *self*. + +### MgPipeline().fit_transform + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pipeline.py#L188) + +```python +def fit_transform(X: Any, y: Any = None) -> Any: +``` + +Fit then transform. + +Parameters +---------- +X: + Input data. +y: + Target labels. + +Returns +------- +Any + +### MgPipeline().transform + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pipeline.py#L139) + +```python +def transform(X: Any) -> Any: +``` + +Apply all steps sequentially to *X*. + +Parameters +---------- +X: + Input data. The type is determined by the first step. + +Returns +------- +Any + The output of the last step. + +## MgStep + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pipeline.py#L39) + +```python +dataclass +class MgStep(): +``` + +A single named step in an :class:[MgPipeline](#mgpipeline). + +Parameters +---------- +name: + Human-readable step name (used in repr and serialisation). +func: + A callable that accepts one positional argument (the data from the + previous step) and optional ``**kwargs``, and returns transformed data. + Alternatively, an object with a ``transform(X)`` method. +kwargs: + Keyword arguments forwarded to *func* on every call. + +### MgStep().\_\_call\_\_ + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pipeline.py#L57) + +```python +def __call__(X: Any) -> Any: +``` + +Apply this step to *X*. diff --git a/docs/musicalgestures/_pose_estimator.md b/docs/musicalgestures/_pose_estimator.md new file mode 100644 index 0000000..2ac7bb8 --- /dev/null +++ b/docs/musicalgestures/_pose_estimator.md @@ -0,0 +1,374 @@ +# PoseEstimator + +> Auto-generated documentation for [musicalgestures._pose_estimator](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pose_estimator.py) module. + +Pose estimator interface and backends for MGT-python. + +- [Mgt-python](../README.md#mgt-python) / [Modules](../MODULES.md#mgt-python-modules) / [Musicalgestures](index.md#musicalgestures) / PoseEstimator + - [MediaPipePoseEstimator](#mediapipeposeestimator) + - [MediaPipePoseEstimator().close](#mediapipeposeestimatorclose) + - [MediaPipePoseEstimator().landmark_names](#mediapipeposeestimatorlandmark_names) + - [MediaPipePoseEstimator().predict_frame](#mediapipeposeestimatorpredict_frame) + - [OpenPosePoseEstimator](#openposeposeestimator) + - [OpenPosePoseEstimator().landmark_names](#openposeposeestimatorlandmark_names) + - [OpenPosePoseEstimator().predict_frame](#openposeposeestimatorpredict_frame) + - [PoseEstimator](#poseestimator) + - [PoseEstimator().landmark_names](#poseestimatorlandmark_names) + - [PoseEstimator().predict_frame](#poseestimatorpredict_frame) + - [PoseEstimator().predict_video](#poseestimatorpredict_video) + - [PoseEstimatorResult](#poseestimatorresult) + - [PoseEstimatorResult().n_keypoints](#poseestimatorresultn_keypoints) + - [PoseEstimatorResult().to_dict](#poseestimatorresultto_dict) + - [get_pose_estimator](#get_pose_estimator) + +This module provides: + +* class `PoseEstimator` – an abstract base class (ABC) defining the common + interface that all pose backends must implement. +* class `MediaPipePoseEstimator` – a concrete backend powered by Google + MediaPipe Pose (33 landmarks, CPU-friendly, zero model download). +* class `OpenPosePoseEstimator` – a thin wrapper around the legacy OpenPose / + Caffe-model implementation already present in :mod:[Pose](_pose.md#pose). + +The shared interface means that backends are interchangeable + +```python +from musicalgestures._pose_estimator import MediaPipePoseEstimator +est = MediaPipePoseEstimator() +keypoints = est.predict_frame(frame) # → np.ndarray shape (33, 3) +``` + +Examples +-------- + +```python +>>> import numpy as np +>>> frame = np.zeros((480, 640, 3), dtype=np.uint8) +>>> # Without mediapipe installed this raises MgDependencyError gracefully. + +## MediaPipePoseEstimator + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pose_estimator.py#L194) + +```python +class MediaPipePoseEstimator(PoseEstimator): + def __init__( + model_complexity: int = 1, + min_detection_confidence: float = 0.5, + min_tracking_confidence: float = 0.5, + static_image_mode: bool = False, + ) -> None: +``` + +Pose estimator backed by Google MediaPipe Pose. + +Requires the optional ``mediapipe`` package + +```python +pip install musicalgestures[pose] +``` + +Parameters +---------- +model_complexity: + MediaPipe model complexity (0, 1, or 2). Higher = more accurate + but slower. Default: 1. +min_detection_confidence: + Minimum confidence for initial body detection. Default: 0.5. +min_tracking_confidence: + Minimum confidence for landmark tracking. Default: 0.5. +static_image_mode: + If *True*, treat every frame as a static image (no tracking). + Default: False. + +Examples +-------- + +```python +>>> import numpy as np +>>> est = MediaPipePoseEstimator() # doctest: +SKIP +>>> frame = np.zeros((480, 640, 3), dtype=np.uint8) +>>> result = est.predict_frame(frame) # doctest: +SKIP +>>> result.keypoints.shape # (33, 3) # doctest: +SKIP + +#### See also + +- [PoseEstimator](#poseestimator) + +### MediaPipePoseEstimator().close + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pose_estimator.py#L290) + +```python +def close() -> None: +``` + +Release MediaPipe resources. + +### MediaPipePoseEstimator().landmark_names + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pose_estimator.py#L255) + +```python +@property +def landmark_names() -> list[str]: +``` + +### MediaPipePoseEstimator().predict_frame + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pose_estimator.py#L259) + +```python +def predict_frame(frame: np.ndarray) -> PoseEstimatorResult: +``` + +Run MediaPipe Pose on a single BGR frame. + +Parameters +---------- +frame: + BGR frame, shape ``(H, W, 3)``. + +Returns +------- +PoseEstimatorResult + 33 landmarks; ``confidence`` is the visibility score. + +#### See also + +- [PoseEstimatorResult](#poseestimatorresult) + +## OpenPosePoseEstimator + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pose_estimator.py#L303) + +```python +class OpenPosePoseEstimator(PoseEstimator): + def __init__( + model: PoseModel | str = PoseModel.BODY_25, + device: PoseDevice | str = PoseDevice.GPU, + threshold: float = 0.1, + ) -> None: +``` + +Thin wrapper around the legacy OpenPose / Caffe-model backend. + +This class delegates to :func:[pose](_pose.md#pose) and is +provided so that the old OpenPose workflow can be used through the +same :class:[PoseEstimator](#poseestimator) interface. + +Parameters +---------- +model: + One of ``'body_25'``, ``'coco'``, or ``'mpi'``. +device: + ``'cpu'`` or ``'gpu'``. +threshold: + Minimum confidence threshold. Default: 0.1. + +#### See also + +- [PoseEstimator](#poseestimator) + +### OpenPosePoseEstimator().landmark_names + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pose_estimator.py#L330) + +```python +@property +def landmark_names() -> list[str]: +``` + +### OpenPosePoseEstimator().predict_frame + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pose_estimator.py#L335) + +```python +def predict_frame(frame: np.ndarray) -> PoseEstimatorResult: +``` + +Run OpenPose inference on a single BGR frame. + +#### Notes + +Full video-level processing is better handled by calling +:meth:`MgVideo.pose` directly. + +#### See also + +- [PoseEstimatorResult](#poseestimatorresult) + +## PoseEstimator + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pose_estimator.py#L112) + +```python +class PoseEstimator(abc.ABC): + def __init__( + model: PoseModel | str = PoseModel.MEDIAPIPE, + device: PoseDevice | str = PoseDevice.CPU, + ) -> None: +``` + +Abstract base class for pose estimation backends. + +All concrete subclasses must implement :meth:`predict_frame` and +:meth:`landmark_names`. + +Parameters +---------- +model: + Skeleton model variant. +device: + Compute backend (``'cpu'`` or ``'gpu'``). + +### PoseEstimator().landmark_names + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pose_estimator.py#L134) + +```python +@property +@abc.abstractmethod +def landmark_names() -> list[str]: +``` + +Ordered list of keypoint names. + +### PoseEstimator().predict_frame + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pose_estimator.py#L139) + +```python +@abc.abstractmethod +def predict_frame(frame: np.ndarray) -> PoseEstimatorResult: +``` + +Run pose estimation on a single BGR frame. + +Parameters +---------- +frame: + Input frame as a NumPy array of shape ``(H, W, 3)`` in BGR order. + +Returns +------- +PoseEstimatorResult + +#### See also + +- [PoseEstimatorResult](#poseestimatorresult) + +### PoseEstimator().predict_video + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pose_estimator.py#L153) + +```python +def predict_video( + filename: str | Path, + start: float = 0.0, + end: float | None = None, + skip: int = 0, +) -> list[PoseEstimatorResult]: +``` + +Run pose estimation on every frame of a video file. + +Parameters +---------- +filename: + Path to the video file. +start: + Start time in seconds. +end: + End time in seconds (None = full video). +skip: + Process every (1 + skip)-th frame. + +Returns +------- +list[PoseEstimatorResult] + +#### See also + +- [PoseEstimatorResult](#poseestimatorresult) + +## PoseEstimatorResult + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pose_estimator.py#L58) + +```python +class PoseEstimatorResult(): + def __init__( + keypoints: np.ndarray, + landmark_names: list[str], + frame_index: int = 0, + timestamp: float = 0.0, + ) -> None: +``` + +Container for the output of a single-frame pose estimation. + +Parameters +---------- +keypoints: + 2-D array of shape ``(n_keypoints, 3)`` where columns are + ``(x, y, confidence)``. Coordinates are normalised to [0, 1]. +landmark_names: + List of keypoint names corresponding to each row. +frame_index: + Frame index this result belongs to. +timestamp: + Timestamp in seconds. + +### PoseEstimatorResult().n_keypoints + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pose_estimator.py#L86) + +```python +@property +def n_keypoints() -> int: +``` + +### PoseEstimatorResult().to_dict + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pose_estimator.py#L90) + +```python +def to_dict() -> dict[str, Any]: +``` + +Return a plain dict representation. + +## get_pose_estimator + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pose_estimator.py#L348) + +```python +def get_pose_estimator( + backend: str = 'mediapipe', + **kwargs: Any, +) -> PoseEstimator: +``` + +Factory function: return a :class:[PoseEstimator](#poseestimator) for the requested backend. + +Parameters +---------- +backend: + ``'mediapipe'`` (default) or ``'openpose'``. +**kwargs: + Additional keyword arguments forwarded to the estimator constructor. + +Returns +------- +PoseEstimator + +Examples +-------- + +```python +>>> est = get_pose_estimator("mediapipe", model_complexity=0) # doctest: +SKIP + +#### See also + +- [PoseEstimator](#poseestimator) diff --git a/docs/musicalgestures/_stream.md b/docs/musicalgestures/_stream.md new file mode 100644 index 0000000..88f54b5 --- /dev/null +++ b/docs/musicalgestures/_stream.md @@ -0,0 +1,127 @@ +# Stream + +> Auto-generated documentation for [musicalgestures._stream](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_stream.py) module. + +Streaming video reader for MGT-python. + +- [Mgt-python](../README.md#mgt-python) / [Modules](../MODULES.md#mgt-python-modules) / [Musicalgestures](index.md#musicalgestures) / Stream + - [MgVideoReader](#mgvideoreader) + - [MgVideoReader().\_\_iter\_\_](#mgvideoreader__iter__) + - [MgVideoReader().fps](#mgvideoreaderfps) + - [MgVideoReader().height](#mgvideoreaderheight) + - [MgVideoReader().width](#mgvideoreaderwidth) + +class `MgVideoReader` is a context-manager-based iterator that yields video +frames lazily using FFmpeg pipes. This avoids loading an entire video into +RAM, making it suitable for long recordings. + +Examples +-------- + +```python +>>> from musicalgestures._stream import MgVideoReader +>>> with MgVideoReader("dancer.avi") as reader: +... for i, (frame, ts) in enumerate(reader): +... # frame: np.ndarray, shape (H, W, 3), dtype uint8 +... # ts: float, timestamp in seconds +... if i >= 5: +... break + +## MgVideoReader + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_stream.py#L46) + +```python +class MgVideoReader(): + def __init__( + filename: str | Path, + start: float = 0.0, + end: float | None = None, + grayscale: bool = False, + scale: float = 1.0, + batch_size: int = 1, + ) -> None: +``` + +Context-manager that streams frames from a video file via FFmpeg. + +Parameters +---------- +filename: + Path to the video file to read. +start: + Start time in seconds. Defaults to 0. +end: + End time in seconds. Defaults to *None* (read to end of file). +grayscale: + If *True*, convert frames to grayscale before yielding. + Default: False. +scale: + Downscale factor (e.g. 0.5 → half resolution). Default: 1.0. +batch_size: + Number of frames to read per FFmpeg read call. Larger values + reduce subprocess overhead at the cost of more memory. + Default: 1. + +Yields +------ +frame : np.ndarray + Video frame as a NumPy array, shape ``(H, W, 3)`` (BGR) or + ``(H, W)`` if *grayscale=True*. +timestamp : float + Approximate frame timestamp in seconds. + +Examples +-------- + +```python +>>> import numpy as np +>>> # Collect every frame as a numpy array: +>>> frames = [] +>>> with MgVideoReader("dancer.avi") as reader: +... for frame, ts in reader: +... frames.append(frame) +>>> arr = np.stack(frames) # shape (N, H, W, 3) + +### MgVideoReader().\_\_iter\_\_ + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_stream.py#L167) + +```python +def __iter__() -> Generator[tuple[np.ndarray, float], None, None]: +``` + +Yield ``(frame, timestamp)`` pairs. + +### MgVideoReader().fps + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_stream.py#L205) + +```python +@property +def fps() -> float: +``` + +Frames per second of the source video. + +### MgVideoReader().height + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_stream.py#L200) + +```python +@property +def height() -> int: +``` + +Frame height in pixels (after optional scaling). + +### MgVideoReader().width + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_stream.py#L195) + +```python +@property +def width() -> int: +``` + +Frame width in pixels (after optional scaling). diff --git a/docs/musicalgestures/_utils.md b/docs/musicalgestures/_utils.md index 1386c23..c920e4b 100644 --- a/docs/musicalgestures/_utils.md +++ b/docs/musicalgestures/_utils.md @@ -62,7 +62,7 @@ ## FFmpegError -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1420) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1472) ```python class FFmpegError(Exception): @@ -71,7 +71,7 @@ class FFmpegError(Exception): ## FFprobeError -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1025) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1077) ```python class FFprobeError(Exception): @@ -80,7 +80,7 @@ class FFprobeError(Exception): ## FilesNotMatchError -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1602) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1654) ```python class FilesNotMatchError(Exception): @@ -89,7 +89,7 @@ class FilesNotMatchError(Exception): ## MgFigure -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L199) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L220) ```python class MgFigure(): @@ -106,7 +106,7 @@ Class for working with figures and plots within the Musical Gestures Toolbox. ### MgFigure().show -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L224) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L245) ```python def show(): @@ -196,7 +196,7 @@ Progresses the progress bar to the next step. ## NoDurationError -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1034) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1086) ```python class NoDurationError(FFprobeError): @@ -208,7 +208,7 @@ class NoDurationError(FFprobeError): ## NoStreamError -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1030) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1082) ```python class NoStreamError(FFprobeError): @@ -220,7 +220,7 @@ class NoStreamError(FFprobeError): ## WrongContainer -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L392) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L444) ```python class WrongContainer(Exception): @@ -229,7 +229,7 @@ class WrongContainer(Exception): ## audio_dilate -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1350) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1402) ```python def audio_dilate( @@ -255,7 +255,7 @@ Time-stretches or -shrinks (dilates) an audio file using ffmpeg. ## cast_into_avi -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L546) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L598) ```python def cast_into_avi(filename, target_name=None, overwrite=False): @@ -277,7 +277,7 @@ but does not always work well with cv2 or built-in video players. ## clamp -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L245) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L297) ```python def clamp(num, min_value, max_value): @@ -297,7 +297,7 @@ Clamps a number between a minimum and maximum value. ## convert -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L431) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L483) ```python def convert(filename, target_name, overwrite=False): @@ -317,7 +317,7 @@ Converts a video to another format/container using ffmpeg. ## convert_to_avi -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L459) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L511) ```python def convert_to_avi(filename, target_name=None, overwrite=False): @@ -337,7 +337,7 @@ Converts a video to one with .avi extension using ffmpeg. ## convert_to_grayscale -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L720) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L772) ```python def convert_to_grayscale(filename, target_name=None, overwrite=False): @@ -357,7 +357,7 @@ Converts a video to grayscale using ffmpeg. ## convert_to_mp4 -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L488) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L540) ```python def convert_to_mp4(filename, target_name=None, overwrite=False): @@ -377,7 +377,7 @@ Converts a video to one with .mp4 extension using ffmpeg. ## convert_to_webm -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L517) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L569) ```python def convert_to_webm(filename, target_name=None, overwrite=False): @@ -397,7 +397,7 @@ Converts a video to one with .webm extension using ffmpeg. ## crop_ffmpeg -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L957) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1009) ```python def crop_ffmpeg(filename, w, h, x, y, target_name=None, overwrite=False): @@ -421,7 +421,7 @@ Crops a video using ffmpeg. ## embed_audio_in_video -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1380) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1432) ```python def embed_audio_in_video(source_audio, destination_video, dilation_ratio=1): @@ -437,7 +437,7 @@ Embeds an audio file as the audio channel of a video file using ffmpeg. ## extract_frame -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L573) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L625) ```python def extract_frame( @@ -461,7 +461,7 @@ time (Union[str, float]): The time in HH:MM:ss.ms where to extract the frame fro ## extract_subclip -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L629) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L681) ```python def extract_subclip(filename, t1, t2, target_name=None, overwrite=False): @@ -483,7 +483,7 @@ Extracts a section of the video using ffmpeg. ## extract_wav -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L993) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1045) ```python def extract_wav(filename, target_name=None, overwrite=False): @@ -503,7 +503,7 @@ Extracts audio from video into a .wav file via ffmpeg. ## ffmpeg_cmd -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1425) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1477) ```python def ffmpeg_cmd( @@ -534,7 +534,7 @@ Run an ffmpeg command in a subprocess and show progress using an MgProgressbar. ## ffprobe -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1037) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1089) ```python def ffprobe(filename): @@ -552,7 +552,7 @@ Returns info about video/audio file using FFprobe. ## frame2ms -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L377) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L429) ```python def frame2ms(frame, fps): @@ -571,7 +571,7 @@ Converts frames to milliseconds. ## framediff_ffmpeg -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L748) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L800) ```python def framediff_ffmpeg(filename, target_name=None, color=True, overwrite=False): @@ -592,7 +592,7 @@ Renders a frame difference video from the input using ffmpeg. ## generate_outfilename -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L298) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L350) ```python def generate_outfilename(requested_name): @@ -611,7 +611,7 @@ filename if necessary by appending an integer, like "_0" or "_1", etc to the fil ## get_box_video_ratio -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1275) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1327) ```python def get_box_video_ratio(filename, box_width=800, box_height=600): @@ -631,7 +631,7 @@ Gets the box-to-video ratio between an arbitrarily defind box and the video dime ## get_first_frame_as_image -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1243) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1295) ```python def get_first_frame_as_image( @@ -657,7 +657,7 @@ Extracts the first frame of a video and saves it as an image using ffmpeg. ## get_fps -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1210) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1262) ```python def get_fps(filename): @@ -675,7 +675,7 @@ Gets the FPS (frames per second) value of a video using FFprobe. ## get_frame_planecount -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L362) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L414) ```python def get_frame_planecount(frame): @@ -693,7 +693,7 @@ frame (numpy array): A frame extracted by `cv2.VideoCapture().read()`. ## get_framecount -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1160) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1212) ```python def get_framecount(filename, fast=True): @@ -711,7 +711,7 @@ Returns the number of frames in a video using FFprobe. ## get_length -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1132) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1184) ```python def get_length(filename: str) -> float: @@ -729,7 +729,7 @@ Gets the length (in seconds) of a video using FFprobe. ## get_widthheight -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1065) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1117) ```python def get_widthheight(filename: str) -> Tuple[int, int]: @@ -748,7 +748,7 @@ Gets the width and height of a video using FFprobe. ## has_audio -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1106) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1158) ```python def has_audio(filename): @@ -766,7 +766,7 @@ Checks if video has audio track using FFprobe. ## in_colab -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1567) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1619) ```python def in_colab(): @@ -780,7 +780,7 @@ Check's if the environment is a Google Colab document. ## in_ipynb -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1582) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1634) ```python def in_ipynb(): @@ -795,7 +795,7 @@ Taken from https://stackoverflow.com/questions/15411967/how-can-i-check-if-code- ## merge_videos -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1607) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1659) ```python def merge_videos( @@ -820,7 +820,7 @@ Merges a list of video files into a single video file using ffmpeg. ## motiongrams_ffmpeg -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L881) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L933) ```python def motiongrams_ffmpeg( @@ -861,7 +861,7 @@ Renders horizontal and vertical motiongrams using ffmpeg. ## motionvideo_ffmpeg -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L825) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L877) ```python def motionvideo_ffmpeg( @@ -899,7 +899,7 @@ Renders a motion video using ffmpeg. ## pass_if_container_is -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L415) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L467) ```python def pass_if_container_is(container, file): @@ -918,7 +918,7 @@ Checks if a file's extension matches a desired one. Passes if so, raises WrongCo ## pass_if_containers_match -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L397) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L449) ```python def pass_if_containers_match(file_1, file_2): @@ -937,7 +937,7 @@ Checks if file extensions match between two files. If they do it passes, is they ## quality_metrics -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1301) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1353) ```python def quality_metrics(original, processed, metric=None): @@ -961,7 +961,7 @@ Possible to compute three major video quality metrics used for objective evaluat ## rotate_video -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L682) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L734) ```python def rotate_video(filename, angle, target_name=None, overwrite=False): @@ -982,7 +982,7 @@ Rotates a video by an `angle` using ffmpeg. ## roundup -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L230) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L282) ```python def roundup(num, modulo_num): @@ -1001,7 +1001,7 @@ Rounds up a number to the next integer multiple of another. ## scale_array -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L278) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L330) ```python def scale_array(array, out_low, out_high): @@ -1021,7 +1021,7 @@ Scales an array linearly. ## scale_num -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L260) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L312) ```python def scale_num(val, in_low, in_high, out_low, out_high): @@ -1043,7 +1043,7 @@ Scales a number linearly. ## str2sec -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1514) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1566) ```python def str2sec(time_string): @@ -1061,7 +1061,7 @@ Converts a time code string into seconds. ## threshold_ffmpeg -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L781) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L833) ```python def threshold_ffmpeg( @@ -1089,7 +1089,7 @@ Renders a pixel-thresholded video from the input using ffmpeg. ## unwrap_str -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1549) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1601) ```python def unwrap_str(string): @@ -1107,7 +1107,7 @@ Unwraps a string from quotes. ## wrap_str -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1528) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_utils.py#L1580) ```python def wrap_str(string, matchers=[' ', '(', ')']): diff --git a/docs/musicalgestures/cli.md b/docs/musicalgestures/cli.md new file mode 100644 index 0000000..5025c63 --- /dev/null +++ b/docs/musicalgestures/cli.md @@ -0,0 +1,38 @@ +# Cli + +> Auto-generated documentation for [musicalgestures.cli](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/cli.py) module. + +Command-line interface for MGT-python. + +- [Mgt-python](../README.md#mgt-python) / [Modules](../MODULES.md#mgt-python-modules) / [Musicalgestures](index.md#musicalgestures) / Cli + - [main](#main) + +The [Musicalgestures](index.md#musicalgestures) command provides quick access to the most common +analysis and visualisation operations without writing Python code. + +Usage + +```python +musicalgestures --help +musicalgestures motion dancer.avi --thresh 0.05 --filtertype Regular +musicalgestures videograms dancer.avi +musicalgestures average dancer.avi +musicalgestures info dancer.avi +musicalgestures convert dancer.avi --to mp4 +``` + +Install CLI dependencies with + +```python +pip install musicalgestures[cli] +``` + +## main + +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/cli.py#L40) + +```python +def main() -> None: +``` + +Entry point registered in pyproject.toml as [Musicalgestures](index.md#musicalgestures). diff --git a/docs/musicalgestures/index.md b/docs/musicalgestures/index.md index 4cf2db6..55a5628 100644 --- a/docs/musicalgestures/index.md +++ b/docs/musicalgestures/index.md @@ -13,7 +13,11 @@ - [Colored](_colored.md#colored) - [Cropping Window](_cropping_window.md#cropping-window) - [Cropvideo](_cropvideo.md#cropvideo) + - [Dataset](_dataset.md#dataset) - [Directograms](_directograms.md#directograms) + - [Enums](_enums.md#enums) + - [Exceptions](_exceptions.md#exceptions) + - [Features](_features.md#features) - [Filter](_filter.md#filter) - [Flow](_flow.md#flow) - [Frameaverage](_frameaverage.md#frameaverage) @@ -22,15 +26,19 @@ - [Impacts](_impacts.md#impacts) - [Info](_info.md#info) - [Input Test](_input_test.md#input-test) + - [Logging](_logging.md#logging) - [MgList](_mglist.md#mglist) - [Motionanalysis](_motionanalysis.md#motionanalysis) - [Motionvideo](_motionvideo.md#motionvideo) - [Motionvideo Mp Render](_motionvideo_mp_render.md#motionvideo-mp-render) - [Motionvideo Mp Run](_motionvideo_mp_run.md#motionvideo-mp-run) + - [Pipeline](_pipeline.md#pipeline) - [Pose](_pose.md#pose) + - [PoseEstimator](_pose_estimator.md#poseestimator) - [Show](_show.md#show) - [Show Window](_show_window.md#show-window) - [Ssm](_ssm.md#ssm) + - [Stream](_stream.md#stream) - [Subtract](_subtract.md#subtract) - [Utils](_utils.md#utils) - [Video](_video.md#video) @@ -38,6 +46,7 @@ - [Videograms](_videograms.md#videograms) - [Videoreader](_videoreader.md#videoreader) - [Warp](_warp.md#warp) + - [Cli](cli.md#cli) ## Examples From 130ce1402b23fbf4c20b040aef13b11f5607d657 Mon Sep 17 00:00:00 2001 From: Alexander Refsum Jensenius Date: Mon, 13 Apr 2026 15:27:14 +0200 Subject: [PATCH 09/10] Fixing errors --- musicalgestures/_blurfaces.py | 6 +-- musicalgestures/_motionanalysis.py | 4 +- musicalgestures/_pose.py | 59 +++++++++++++++++------------- 3 files changed, 38 insertions(+), 31 deletions(-) diff --git a/musicalgestures/_blurfaces.py b/musicalgestures/_blurfaces.py index 0352b40..2ebca4b 100644 --- a/musicalgestures/_blurfaces.py +++ b/musicalgestures/_blurfaces.py @@ -222,7 +222,7 @@ def mg_blurfaces(self, # Save warped video as blur_faces for parent MgVideo # we have to do this here since we are not using mg_blurfaces (that would normally save the result itself) - self.blur_faces = musicalgestures.MgVideo(target_name, color=self.color, returned_by_process=True) + self.blur_faces_video = musicalgestures.MgVideo(target_name, color=self.color, returned_by_process=True) def save_txt(of, data, data_format, target_name=target_name, overwrite=overwrite): """ @@ -302,7 +302,7 @@ def save_single_file(of, data, data_format, target_name=target_name, overwrite=o if save_data: save_txt(of, data, data_format, target_name=target_name, overwrite=overwrite) - return self.blur_faces + return self.blur_faces_video if draw_heatmap: target_name = os.path.splitext(target_name)[0] + '.png' @@ -338,5 +338,5 @@ def save_single_file(of, data, data_format, target_name=target_name, overwrite=o return MgImage(target_name) else: - return self.blur_faces + return self.blur_faces_video diff --git a/musicalgestures/_motionanalysis.py b/musicalgestures/_motionanalysis.py index 17f669e..6a851a0 100644 --- a/musicalgestures/_motionanalysis.py +++ b/musicalgestures/_motionanalysis.py @@ -26,8 +26,8 @@ def centroid(image, width, height): my = np.mean(image, axis=1) if np.sum(mx) != 0 and np.sum(my) != 0: - comx = x.reshape(1, width)@mx.reshape(width, 1)/np.sum(mx) - comy = y.reshape(1, height)@my.reshape(height, 1)/np.sum(my) + comx = np.dot(x, mx) / np.sum(mx) + comy = np.dot(y, my) / np.sum(my) else: comx = 0 comy = 0 diff --git a/musicalgestures/_pose.py b/musicalgestures/_pose.py index d0f8852..fd60267 100644 --- a/musicalgestures/_pose.py +++ b/musicalgestures/_pose.py @@ -1,6 +1,7 @@ import cv2 import os +import sys import numpy as np import pandas as pd from musicalgestures._utils import MgProgressbar, convert_to_avi, extract_wav, embed_audio_in_video, roundup, frame2ms, generate_outfilename, in_colab, ffmpeg_cmd @@ -74,15 +75,25 @@ def pose( # Check if .caffemodel file exists, download if necessary if not os.path.exists(weightsFile): - print('Could not find weights file. Do you want to download it (~200MB)? (y/n)') - answer = input() - if answer.lower() == 'n': - print('Ok. Exiting...') - return musicalgestures.MgVideo(self.filename, color=self.color, returned_by_process=True) - elif answer.lower() == 'y': + print('Could not find weights file.') + # Notebook/nbclient runs cannot satisfy input(), so auto-download in non-interactive mode. + if not sys.stdin or not sys.stdin.isatty(): + print('Non-interactive session detected. Downloading model weights automatically (~200MB).') download_model(model) else: - print(f'Unrecognized answer "{answer}". Exiting...') + print('Do you want to download it (~200MB)? (y/n)') + answer = input() + if answer.lower() == 'n': + print('Ok. Exiting...') + return musicalgestures.MgVideo(self.filename, color=self.color, returned_by_process=True) + elif answer.lower() == 'y': + download_model(model) + else: + print(f'Unrecognized answer "{answer}". Exiting...') + return musicalgestures.MgVideo(self.filename, color=self.color, returned_by_process=True) + + if not os.path.exists(weightsFile): + print('Model weights are still missing after download attempt. Exiting pose() call.') return musicalgestures.MgVideo(self.filename, color=self.color, returned_by_process=True) # Read the network into Memory @@ -92,6 +103,15 @@ def pose( if in_colab() and device == 'gpu': print('Sorry, OpenCV GPU acceleration is not supported in Colab. Switching to CPU.') device = 'cpu' + elif device == 'gpu': + cuda_devices = 0 + try: + cuda_devices = cv2.cuda.getCudaEnabledDeviceCount() + except Exception: + cuda_devices = 0 + if cuda_devices <= 0: + print('OpenCV CUDA backend is unavailable. Switching to CPU.') + device = 'cpu' if device == "cpu": net.setPreferableBackend(cv2.dnn.DNN_TARGET_CPU) @@ -147,7 +167,7 @@ def pose( break # Transform the bytes read into a numpy array - frame = np.frombuffer(out, dtype=np.uint8).reshape([self.height, self.width, 3]) # height, width, channels + frame = np.frombuffer(out, dtype=np.uint8).reshape([self.height, self.width, 3]).copy() # height, width, channels inpBlob = cv2.dnn.blobFromImage(frame, 1.0 / 255, (inWidth, inHeight), (0, 0, 0), swapRB=False, crop=False) net.setInput(inpBlob) @@ -393,7 +413,7 @@ def download_model(modeltype): if the_system == 'Windows': command += f' {wget_win} {target_folder_mpi}' else: - command = 'sudo -S bash ' + command + command = 'bash ' + command command += f' {target_folder_mpi}' pb_prefix = 'Downloading MPI model:' elif modeltype.lower() == 'coco': @@ -401,7 +421,7 @@ def download_model(modeltype): if the_system == 'Windows': command += f' {wget_win} {target_folder_coco}' else: - command = 'sudo -S bash ' + command + command = 'bash ' + command command += f' {target_folder_coco}' pb_prefix = 'Downloading COCO model:' elif modeltype.lower() == 'body_25': @@ -409,27 +429,14 @@ def download_model(modeltype): if the_system == 'Windows': command += f' {wget_win} {target_folder_body_25}' else: - command = 'sudo -S bash ' + command + command = 'bash ' + command command += f' {target_folder_body_25}' pb_prefix = 'Downloading BODY_25 model:' pb = MgProgressbar(total=100, prefix=pb_prefix) - if the_system == 'Windows' or in_colab(): - process = subprocess.Popen( - command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True, shell=True) - else: - try: - import getpass - username = getpass.getuser() - print(f'[sudo] password for {username}:') - p = getpass.getpass() - process = subprocess.Popen( - command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.PIPE, universal_newlines=True, shell=True) - process.stdin.write(p+'\n') - process.stdin.flush() - except Exception as error: - print('ERROR', error) + process = subprocess.Popen( + command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True, shell=True) try: i = 0 From 98775af3bd3b8fc095f67ae0bf5ccfc8285083fb Mon Sep 17 00:00:00 2001 From: alexarje <114316+alexarje@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:27:44 +0000 Subject: [PATCH 10/10] Update documentation --- docs/musicalgestures/_pose.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/musicalgestures/_pose.md b/docs/musicalgestures/_pose.md index ba01ee5..61d9b73 100644 --- a/docs/musicalgestures/_pose.md +++ b/docs/musicalgestures/_pose.md @@ -8,7 +8,7 @@ ## download_model -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pose.py#L350) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pose.py#L370) ```python def download_model(modeltype): @@ -18,7 +18,7 @@ Helper function to automatically download model (.caffemodel) files. ## pose -[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pose.py#L13) +[[find in source code]](https://github.com/fourMs/MGT-python/blob/master/musicalgestures/_pose.py#L14) ```python def pose(