diff --git a/.autorc b/.autorc new file mode 100644 index 00000000..27720ccd --- /dev/null +++ b/.autorc @@ -0,0 +1,16 @@ +{ + "onlyPublishWithReleaseLabel": true, + "baseBranch": "master", + "author": "DANDI Bot ", + "noVersionPrefix": true, + "plugins": [ + "git-tag", + [ + "exec", + { + "afterRelease": "python -m build && twine upload dist/*" + } + ], + "released" + ] +} diff --git a/.codespell-ignore b/.codespell-ignore new file mode 100644 index 00000000..a549dd64 --- /dev/null +++ b/.codespell-ignore @@ -0,0 +1 @@ +acend diff --git a/.codespellrc b/.codespellrc new file mode 100644 index 00000000..fdb10ec4 --- /dev/null +++ b/.codespellrc @@ -0,0 +1,3 @@ +[codespell] +ignore-words = .codespell-ignore +skip = venv,reprostim-capture,build,cmake-build-debug diff --git a/.github/workflows/ccpp.yml b/.github/workflows/ccpp.yml index 59a533bb..a03f9fa5 100644 --- a/.github/workflows/ccpp.yml +++ b/.github/workflows/ccpp.yml @@ -28,21 +28,21 @@ jobs: cd build cmake .. make - working-directory: Capture + working-directory: src/reprostim-capture - name: Test "reprostim-videocapture -h" run: | ./reprostim-videocapture -V ./reprostim-videocapture -h - working-directory: Capture/build/videocapture + working-directory: src/reprostim-capture/build/videocapture - name: Test "reprostim-screencapture -h" run: | ./reprostim-screencapture -V ./reprostim-screencapture -h - working-directory: Capture/build/screencapture + working-directory: src/reprostim-capture/build/screencapture - name: Run tests with CTest run: | ctest --output-on-failure - working-directory: Capture/build + working-directory: src/reprostim-capture/build diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml new file mode 100644 index 00000000..3ebbf550 --- /dev/null +++ b/.github/workflows/codespell.yml @@ -0,0 +1,22 @@ +--- +name: Codespell + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + codespell: + name: Check for spelling errors + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Codespell + uses: codespell-project/actions-codespell@v2 diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml new file mode 100644 index 00000000..1c700074 --- /dev/null +++ b/.github/workflows/pytest.yml @@ -0,0 +1,57 @@ +--- +name: Test with pytest and hatch + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + pytest: + name: Test with pytest + runs-on: ubuntu-latest + strategy: + matrix: + python: + - '3.10' + steps: + - name: Configure environment + run: | + git config --global user.name "reprostim-test" + git config --global user.email "reprostim-test@example.com" + uname -a + date -Is + date -u + + - name: Checkout source code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python }} + + - name: Install hatch and virtual environment + run: | + pwd + ls -l + pip install --upgrade pip + pip install hatch + hatch env create + shell: bash + + - name: Run pytest + run: | + hatch run pytest --cov=. --cov-report=xml + shell: bash + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..f5c68141 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,40 @@ +name: Auto-release on PR merge + +on: + # ATM, this is the closest trigger to a PR merging + push: + branches: + - master + +jobs: + auto-release: + runs-on: ubuntu-latest + if: "!contains(github.event.head_commit.message, 'ci skip') && !contains(github.event.head_commit.message, 'skip ci')" + steps: + - name: Checkout source + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Download latest auto + run: | + auto_download_url="$(curl -fsSL https://api.github.com/repos/intuit/auto/releases/latest | jq -r '.assets[] | select(.name == "auto-linux.gz") | .browser_download_url')" + wget -O- "$auto_download_url" | gunzip > ~/auto + chmod a+x ~/auto + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.9 + + - name: Install build & twine + run: python -m pip install build twine + + - name: Create release + run: ~/auto shipit -vv + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + +# vim:set sts=2: diff --git a/.gitignore b/.gitignore index 59680d09..405c4df6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,16 @@ Videos/ *.pyc Capture/C++/VideoCapture venvs +build/ +.coverage* +.dir-locals.el +dist/ +*.egg-info +.env.* +!.env.local +.flake8 +.idea/ +.mypy_cache/ +*.pid +*.pyc +.python-version diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..c37a5a1d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,37 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: check-added-large-files + - id: check-json + - id: check-toml + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + + - repo: https://github.com/PyCQA/isort + rev: 5.13.2 + hooks: + - id: isort + + - repo: https://github.com/psf/black + rev: 24.4.2 + hooks: + - id: black + + - repo: https://github.com/pycqa/flake8 + rev: 7.0.0 + hooks: + - id: flake8 + # B008 Do not perform function calls in argument defaults. + # A003 class attribute "id" is shadowing a python builtin + args: ["-j8", "--ignore=B008,A003", "--max-line-length=89"] + additional_dependencies: + - flake8-bugbear + - flake8-builtins + # - flake8-unused-arguments + +exclude: '^src/reprostim-capture/.*|.codespellrc' + +default_language_version: + python: python3.10 diff --git a/Events/README.md b/Events/README.md index c4d4030f..f73b5d55 100644 --- a/Events/README.md +++ b/Events/README.md @@ -10,4 +10,4 @@ Handheld input pads which are usable inside an MR scanner are connected to the d ## Installation ### Micropython -A device-type-specific micropython distribution file (e.g. `.uf2` or `.dfu`) will need to be downloaded and flashed onto the microcontroller. +A device-type-specific micropython distribution file (e.g. `.uf2` or `.dfu`) will need to be downloaded and flashed onto the microcontroller. diff --git a/Events/listeners.py b/Events/listeners.py index a6227baf..af7b34a5 100644 --- a/Events/listeners.py +++ b/Events/listeners.py @@ -10,7 +10,7 @@ def listen(command, pyb): Notes ----- * This could be set up as a global listener, though that might be overkill just for conveying stimulus events. - * Using `-1` polling to disable timouts, we could use ipoll instead: + * Using `-1` polling to disable timeouts, we could use ipoll instead: https://docs.micropython.org/en/latest/library/select.html?highlight=poll#select.poll.ipoll """ diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 00000000..60dbe0f4 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2020-2024 ReproNim Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 51e8bca7..7d7796a5 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ psychology experiments. Its goal is to provide experimenters with a complete record of audio and visual stimulation for every data collection session by making it possible to easily collect high fidelity copies of the actual stimuli shown to each subject in the form of video files that can be -stored alongside behavioral or neuroimaging data in public repositories. +stored alongside behavioral or neuroimaging data in public repositories. ReproStim provides for enhanced experimental reproducibility and a safeguard against data loss in cases of data-collection irregularites. Because @@ -17,18 +17,18 @@ such as aborted fMRI runs, unexpected glitches in trial timing, or programming errors that cause records of trial conditions to be lost, valuable data (which can be especially costly in cases of fMRI of ECog, for example) can be recoded and recovered using the audio-visual record provided -by ReproStim. +by ReproStim. ReproStim requires minimal effort on behalf of investigators. Once it is setup as the default mode within a behavioral lab or neuroimaging center, investigators can reap the benefits of ReproStim without any additional effort on the part of invidiual experimenters. When successfully set up, ReproStim runs in the background, silently collecting, cataloging, and -storing all audio and visual stimulation delivered to experimental subjects. +storing all audio and visual stimulation delivered to experimental subjects. # Development -## Hardware needed +## Hardware needed Before using ReproStim you will need a minimum of the the following components: @@ -65,9 +65,9 @@ would be something like: ------ | ------- V ------- ------ - | MWC | -- USB-C --> | VC | + | MWC | -- USB-C --> | VC | ------- ------ - + ### Original set up without ReproStim Most experimental setups include something like Schematic A, with a stimulus @@ -78,13 +78,13 @@ control room for SC. The External Presentation Device for video (EPDv) in the DBIC MRI suite is a projector that projects through the wall of the shielded scan room to a rear-projection screen located at the back of the MRI scanner bore; and the EPDa (audio) comprises MRI-safe headphones worn on -the subject's head. +the subject's head. The A/V out connections from SC can be any standard as long as you have the appropriate adapters, dongles, etc. However, if your Video out does not support embedded audio (e.g. VGA), then you will need a separate audio out set of splitters and cables. The Magewell device has standard audio ports to -accomodate this eventuality. +accommodate this eventuality. Note: Missing from Schematics A and B, is any connection back to SC that records subject response information. That's because ReproStim is not @@ -113,7 +113,7 @@ coming over the connection, it gets recorded for posterity. Current development of ReproStim, including our working setup at the DBIC, uses a Linux box running Debian Linux. We anticipate that any Nix/Mac setup running on a modern desktop will be amply sufficient as a ReproStim Server, -and should be relatively painless to configure. +and should be relatively painless to configure. The current DBIC computer is a small-profile desktop that resides in the control of the scan suite, quietly recording all video presented to all @@ -123,26 +123,30 @@ subjects. On Debian +```shell apt-get install -y ffmpeg libudev-dev libasound-dev libv4l-dev libyaml-cpp-dev libspdlog-dev catch2 v4l-utils libopencv-dev libcurl4-openssl-dev nlohmann-json3-dev cmake g++ +``` "Parsing/parse_wQR.py" script requires in zbar to be installed as well: +```shell apt-get install -y libzbar0 - +```` ## Build - cd Capture +```shell + cd src/reprostim-capture mkdir build cd build cmake .. make - +``` ## Subdirectories Structure -### Capture +### src/reprostim-capture Contains all code needed for setting up video capture. This includes C++ code for interfacing with the video capture device, and scheme for setting @@ -159,23 +163,23 @@ Contains code needed for segmenting videos to include just the parts of the videos that are demarcated by embedded QR codes marking the beginning and end of experimental runs. There are also helper tools for identifying experimental runs and matching them to the parent experimental paradigm and -neuroimaging data acquisitions. +neuroimaging data acquisitions. # Installation and Configuration ## Setup USB device with "udev" access VideoCapture utility internally uses Magewell Capture SDK and to make it working -properly should be executed under "root" account or in case of other account - special -"udev" rules should be applied there. Program will produce following error when +properly should be executed under "root" account or in case of other account - special +"udev" rules should be applied there. Program will produce following error when executed in environment without proper ownership and permissions for informational purposes: ERROR[003]: Access or permissions issue. Please check /etc/udev/rules.d/ configuration and docs. -For more information refer to item #14 from Magewell FAQ on https://www.magewell.com/kb/detail/010020005/All : +For more information refer to item #14 from Magewell FAQ on https://www.magewell.com/kb/detail/010020005/All : - 14. Can the example codes associated with USB Capture (Plus) devices in SDKv3 work + 14. Can the example codes associated with USB Capture (Plus) devices in SDKv3 work without root authority (sudo) on Linux? Yes. Click here to download the file "189-usbdev.rules" (http://www.magewell.com/files/sdk/189-usbdev.zip) , @@ -188,7 +192,9 @@ it can accidentally produce the same error message ERROR[003]. This is optional step, only for information purposes: +```shell lsusb +``` And locate line with device, e.g.: @@ -198,12 +204,13 @@ In this sample, 2935 is the "Vendor ID", and 0008 is the "Product ID". Optionally Magewell device name and serial number can be quickly checked with this command: +```shell lsusb -d 2935: -v | grep -E 'iSerial|iProduct' - +``` ### 2) Create "udev" rules Create text file under "/etc/udev/rules.d/189-reprostim.rules" location with -appropriate content depending on system type. +appropriate content depending on system type. For an active/desktop user logged in via session manager it should be like: @@ -213,27 +220,33 @@ For a daemon configuration we should provide explicit permissions like: SUBSYSTEM=="usb", ATTR{idVendor}=="2935", MODE="0660", OWNER="reprostim", GROUP="plugdev" -Note: we can see that "ATTR{idVendor}" value 2935 is equal to one we got in +Note: we can see that "ATTR{idVendor}" value 2935 is equal to one we got in step 1) from lsusb utility. -Also sample udev rules configuration added to project under -"Capture/etc/udev/189-reprostim.rules" location. +Also sample udev rules configuration added to project under +"src/reprostim-capture/etc/udev/189-reprostim.rules" location. Note: make sure the file has owner "root", group "root" and 644 permissions: +```shell ls -l /etc/udev/rules.d/189* +```` +``` -rw-r--r-- 1 root root 72 ... /etc/udev/rules.d/189-reprostim.rules +``` ### 3) Add user to "plugdev" group -Make sure the user running VideoCapture utility is a member of the +Make sure the user running VideoCapture utility is a member of the "plugdev" group, e.g.: +```shell sudo usermod -aG plugdev TODO_user +``` ### 4) Restart computer Restart computer to make changes effect. -Note: we tested "sudo udevadm control --reload-rules" command without OS +Note: we tested "sudo udevadm control --reload-rules" command without OS restart, but somehow it didn't help, and complete restart was necessary anyway. @@ -246,4 +259,4 @@ anyway. - ... - [Parsing/repro-vidsort](./Parsing/repro-vidsort) script (has hardcoded paths) sorts from `incoming/` to folders per DICOM accession -- +- diff --git a/docs/design/rf-library.md b/docs/design/rf-library.md new file mode 100644 index 00000000..b214d90c --- /dev/null +++ b/docs/design/rf-library.md @@ -0,0 +1,43 @@ +# ReproStim Python library/CLI + +## Current status + +This repository collects various sub-projects + +- Magewell SDK +- C++ code of ReproStim `reprostim-videocapture` (and `reprostim-screencapture`) +- ReproEvents for MicroPython board +- ReproStim videograbber +- ReproStim stimuli for calibration + +## The Goal + +Refactor stuff here into cleanly separated and documented libraries etc. + +## Approach/Layout + +- `src/` + - `reprostim/` - Python library and CLIs for working with ReproStim + - `audio/` - Audio fingerprinting/processing + - `cli/` - CLI entrypoints (for `reprostim CMD`, could be hierarchical like `reprostim qr-parse`) + - `base.py` - common base commands for CLI + - `cmd_timesync_stimuli.py` - CLI to replace `tools/reprostim-timesync-stimuli` + - `entrypoint.py` - entrypoint for all reprostim CLI commands + - `qr/` - QR code utilities + - `__init__.py` - sets up the library + - `reprostim-capture/` - C++ code(s) relating to capturing + - `3rdparty/` - Magewell MWCapture SDK + - `reproevents/` - move MicroPython ReproEvents here (do not strive to make it work) +- `test/` - some global tests possibly for integration testing etc + +## Refactor log + +| Old | New | +|----------------------------------------|---------------------------------------------| +| [x] `Capture` | `src/reprostim-capture` | +| [x] `Parsing/parse_wQR.py` | `src/reprostim/cli/cmd_qr_parse.py` | +| [x] `Parsing/generate_qrinfo.sh` | `tools/reprostim-generate-qrinfo` | +| [ ] `tools/reprostim-timesync-stimuli` | `src/reprostim/cli/cmd_timesync_stimuli.py` | +| [ ] `Capture/nosignal` | `src/reprostim/cli/cmd_detect_nosignal.py` | +| [ ] `Events` | `src/reproevents` | +| [ ] `TBD` | `TODO` | diff --git a/docs/reproflow_abstract_ohbm2024.md b/docs/reproflow_abstract_ohbm2024.md index 9d73c72e..0c00c4fa 100644 --- a/docs/reproflow_abstract_ohbm2024.md +++ b/docs/reproflow_abstract_ohbm2024.md @@ -22,7 +22,7 @@ We have developed a number of Free and Open Source Software (FOSS) solutions, an The ReproFlow environment consists of 8 core tools developed by the ReproNim project. HeuDiConv provides configurable MRI conversion from DICOM to a desired layout. ReproIn provides configuration for HeuDiConv via an extensive heuristic syntax, as well as a user assistance utility. -ReproEvents provides audio and video capture capabilites to integrate complex stimuli with MRI data. +ReproEvents provides audio and video capture capabilities to integrate complex stimuli with MRI data. ReproStim provides support for capturing behavioral events from participants. Con/noisseur captures and performs QA on operator input at the scanner console. ReproMon complements the QA capabilities by providing support for online operator feedback and alerts in case of incidents or anomalous metadata input. @@ -41,4 +41,4 @@ ReproEvents, ReproMon, and Con/noisseur are currently in early deployment and pr We argue based on our results that data integration remains a non-trivial matter for multi-modal set-ups and that significant improvements in automation and transparency are necessary to ensure data reliability. In particular, general-purpose open-source tools are needed in order to ensure sustainability of acquisition frameworks over time, and to ensure relevant know-how is shared across centers. -We propose ReproFlow as a solution for these requirements and encourage re-use of this environment. +We propose ReproFlow as a solution for these requirements and encourage reuse of this environment. diff --git a/overview.md b/overview.md index 365e085a..7e6795c3 100644 --- a/overview.md +++ b/overview.md @@ -1,27 +1,27 @@ The RepoStim project provides a data solution for archiving and cataloging stimulus presentation records for fMRI experiments. -A “record” in this context refers to a digital media file that contains the audio and visual stimulation presented to a subject while undergoing fMRI scanning for a particular session. -The goal is to provide one record for every acquisition collected at an fMRI research center where audio/visual stimulation was presented. +A “record” in this context refers to a digital media file that contains the audio and visual stimulation presented to a subject while undergoing fMRI scanning for a particular session. +The goal is to provide one record for every acquisition collected at an fMRI research center where audio/visual stimulation was presented. The ultimate goal of the project is to incorporate ReproStim within the ReproIn/heudiconv data conversion pipeline and to have all stimulus records stored within respective BIDS datasets (see also: https://github.com/bids-standard/bids-specification/issues/751). While the development of the ReproStim project targets data collection for fRMI research, such a system may also be employed for purely behavioral experiments, which are supported by BIDS as well. -Within its current scope, ReproStim is meant to serve as a center-wide resource for a brain imaging center. -Thus, the setup and maintenance of ReproStim is expected to fall under the purview of an authorized IT specialist charged with center operations and data archiving. +Within its current scope, ReproStim is meant to serve as a center-wide resource for a brain imaging center. +Thus, the setup and maintenance of ReproStim is expected to fall under the purview of an authorized IT specialist charged with center operations and data archiving. The goal of this project is to make establishing such a setup very easy and turnkey as much as possible. -End users, including researchers who collect and analyze data at the center, ideally need not interact with ReproStim in any way except to benefit from having access to stimulus records associated with their fMRI data as procured by the center. -Given this general overview of the scope and purpose of the ReproStim project, there are four major components to the project that must be considered. -These include hardware requirements and setup, server software configuration, tools for record procurement, and documentation. +End users, including researchers who collect and analyze data at the center, ideally need not interact with ReproStim in any way except to benefit from having access to stimulus records associated with their fMRI data as procured by the center. +Given this general overview of the scope and purpose of the ReproStim project, there are four major components to the project that must be considered. +These include hardware requirements and setup, server software configuration, tools for record procurement, and documentation. -Setting up ReproStim requires an initial investment in hardware, including a video capture device, a computer that runs the ReproStim capture server software, -a USB “sniffer” device to capture scanner trigger pulses for use by the capture service, -and necessary cables and connectors including audio and video splitter cables. +Setting up ReproStim requires an initial investment in hardware, including a video capture device, a computer that runs the ReproStim capture server software, +a USB “sniffer” device to capture scanner trigger pulses for use by the capture service, +and necessary cables and connectors including audio and video splitter cables. [These details will be filled in later, and there is a reasonable sketch of the hardware set up already in the README.md file.] -The server software runs continuously on a dedicated computer that is connected to audio/video capturing device interjected between the stimulus presentation system (SPS) at the fMRI scanner suite. -The software monitors the audio and video streams that are sent over the SPS so that whenever there is a video feed to the projector in the scanner, -all content is recorded and time-stamped. +The server software runs continuously on a dedicated computer that is connected to audio/video capturing device interjected between the stimulus presentation system (SPS) at the fMRI scanner suite. +The software monitors the audio and video streams that are sent over the SPS so that whenever there is a video feed to the projector in the scanner, +all content is recorded and time-stamped. Any change in the parameter of captured video (connect/disconnect, change of resolution) triggers creation of a new captured content file. -These recordings are not yet considered “records” in that they are not matched to specific fMRI scans. -The server simply monitors the SPS for content and records everything. -These raw recordings will often contain long periods of screen capture that show the content of the display on experimenter’s stimulus presentation computer (often a lab laptop). +These recordings are not yet considered “records” in that they are not matched to specific fMRI scans. +The server simply monitors the SPS for content and records everything. +These raw recordings will often contain long periods of screen capture that show the content of the display on experimenter’s stimulus presentation computer (often a lab laptop). **Warning: as a result, captured video could contain sensitive data if it was displayed.** -Therefore, it is necessary to take some precautions to store these files securely to protect experimenters information and privacy in case some personal information is exposed, such as an email inbox. -Consideration should be taken as to how long these raw recordings are kept, when they should be deleted, and where they will be stored. -For example, operators will need to provide sufficient hard drive space for this data and implement some policies about how long to keep the original raw data, after “records” parsed and procured to the fMRI archive. +Therefore, it is necessary to take some precautions to store these files securely to protect experimenters information and privacy in case some personal information is exposed, such as an email inbox. +Consideration should be taken as to how long these raw recordings are kept, when they should be deleted, and where they will be stored. +For example, operators will need to provide sufficient hard drive space for this data and implement some policies about how long to keep the original raw data, after “records” parsed and procured to the fMRI archive. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..642be4a7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,116 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "reprostim" +dynamic = ["version"] +description = 'ReproStim is a video capture and recording suite for neuroimaging and psychology experiments.' +readme = "README.md" +#requires-python = "3.10" +license = "MIT" +license-files = { paths = ["LICENSE.txt"] } +keywords = [ + "ReproStim", + "reprostim-videocapture", + "reprostim-screncapture", +] +authors = [ + { name = "Yaroslav Halchenko", email = "yoh@dartmouth.edu" }, + { name = "Andy Connolly", email = "andrew.c.connolly@dartmouth.edu" }, + { name = "Horea Christian", email = "chr@chymera.eu" }, + { name = "Vadim Melnik", email = "vmelnik@docsultant.com" }, +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Programming Language :: Python", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] + +dependencies = [ + "click>=8.1.7", + "click-didyoumean>=0.3.1", + "pydantic>=2.7.1", + "numpy>=1.26.4", + "pyzbar>=0.1.9", + "qrcode>=8.0", + "opencv-python>=4.9.0.80", +] + +[project.optional-dependencies] +test = [ + "pytest>=8.0", +] +audio = [ + "sounddevice>=0.5.1", + "scipy>=1.14.1", + "pydub>=0.25.1", + "pyaudio>=0.2.14", + "reedsolo>=1.7.0", +] +psychopy = [ + "psychopy", + "psychopy-sounddevice", +] +all = [ + "pytest>=8.0", + "sounddevice>=0.5.1", + "scipy>=1.14.1", + "pydub>=0.25.1", + "pyaudio>=0.2.14", + "reedsolo>=1.7.0", + "psychopy", + "psychopy-sounddevice", +] + +[project.urls] +Documentation = "https://github.com/ReproNim/reprostim" +Issues = "https://github.com/ReproNim/reprostim/issues" +Source = "https://github.com/ReproNim/reprostim" + +[project.scripts] +reprostim = "reprostim.cli.entrypoint:main" + +[tool.hatch.build.targets.sdist] +include = [ + "/src/reprostim/", + "/tests", +] + +[tool.hatch.version] +path = "src/reprostim/__about__.py" + +[tool.hatch.envs.types] +extra-dependencies = [ + "mypy>=1.0.0", +] +[tool.hatch.envs.types.scripts] +check = "mypy --install-types --non-interactive {args:src/reprostim tests}" + +[tool.coverage.run] +source_pkgs = ["reprostim", "tests"] +branch = true +parallel = true +omit = [ + "src/reprostim/__about__.py", +] + +[tool.coverage.paths] +reprostim = ["src/reprostim", "*/reprostim/src/reprostim"] +tests = ["tests", "*/reprostim/tests"] + +[tool.coverage.report] +exclude_lines = [ + "no cov", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/src/reprostim-capture/CMakeLists.txt b/src/reprostim-capture/CMakeLists.txt index 9faeb75f..6a20074d 100644 --- a/src/reprostim-capture/CMakeLists.txt +++ b/src/reprostim-capture/CMakeLists.txt @@ -1,7 +1,7 @@ -# CMakeLists.txt (Main Capture project) +# CMakeLists.txt (Main reprostim-capture project) cmake_minimum_required (VERSION 3.10) -project(Capture) +project(reprostim-capture) option(CTEST_ENABLED "Specify CTest build and run are enabled" ON) diff --git a/src/reprostim-capture/README.md b/src/reprostim-capture/README.md index 69c61686..c62287f4 100644 --- a/src/reprostim-capture/README.md +++ b/src/reprostim-capture/README.md @@ -1,23 +1,27 @@ -# ReproStim Capture +# reprostim-capture ## Overview -Capture project is set of tools and utilities to capture video/audio signal with Magewell +`reprostim-capture` project is set of tools and utilities to capture video/audio signal with Magewell USB Capture devices and save it to a file. It is a part of the ReproStim project. ## Dependencies ### On Debian: +```shell apt-get install -y ffmpeg libudev-dev libasound-dev libv4l-dev libyaml-cpp-dev libspdlog-dev catch2 v4l-utils libopencv-dev libcurl4-openssl-dev nlohmann-json3-dev cmake g++ +```` Optionally, in case `con/duct` tool is used and `conduct_opts.enabled` is set to true in reprostim-videocapture `config.yaml`: +```shell apt-get install -y python3-pip python3 -m venv venv source venv/bin/activate pip install con-duct duct --version +``` ### Project requirements: - OS Linux @@ -44,29 +48,33 @@ Optionally, in case `con/duct` tool is used and `conduct_opts.enabled` is set to ## Build -Capture uses CMake as build system. To build the project, run the following commands: +`reprostim-capture` uses CMake as build system. To build the project, run the following commands: - cd Capture +```shell + cd src/reprostim-capture mkdir build cd build cmake .. make +``` ## Installation To install the project, once the build done, run the following command: - cd Capture +```shell + cd src/reprostim-capture sudo cmake --install build +``` It will copy all necessary files to the `/usr/local/bin` location (`reprostim-videocapture`, `reprostim-screencapture`, `reprostim-nosignal`). ## Project Structure -Capture project consists of the following components: +`reprostim-capture` project consists of the following components: - `capturelib` - shared static C++ library with common code across all utilities. - `screencapture` - project source code for "reprostim-screencapture" command-line utility. The program captures screenshots from Magewell USB Capture device and saves @@ -98,9 +106,11 @@ set the version. Where {MAJOR}, {MINOR} and {PATCH} values should be set manuall We also have CMake script "version-auto-inc.cmake" which can be used to increment build number in version file during development process: - cd Capture +```shell + cd src/reprostim-capture cmake -P version-auto-inc.cmake +``` E.g. in CLion IDE, you can add "version-auto-inc.cmake" as External Tool under @@ -116,8 +126,10 @@ with build process (as pre-build hook) to increment build number automatically. In short words project uses CMake + CTest + Catch2 for unit testing. To run tests, build project and execute the following command: - cd Capture/build +```shell + cd src/reprostim-capture/build ctest +``` Root project and each subproject have their own tests located in the "test" directory along with CMakeList.txt and C++ sources. diff --git a/src/reprostim/__about__.py b/src/reprostim/__about__.py new file mode 100644 index 00000000..b30b13a1 --- /dev/null +++ b/src/reprostim/__about__.py @@ -0,0 +1,6 @@ +# +# SPDX-License-Identifier: MIT +__version__ = "0.0.3" + +# specify the name of reprostim tool +__reprostim_name__ = "reprostim" diff --git a/src/reprostim/__init__.py b/src/reprostim/__init__.py new file mode 100644 index 00000000..21f84a97 --- /dev/null +++ b/src/reprostim/__init__.py @@ -0,0 +1,35 @@ +# +# SPDX-License-Identifier: MIT +import logging +import sys + +# setup logging +root_logger = logging.getLogger(__name__) +root_logger.setLevel(logging.NOTSET) + + +def _init_logger( + log_level: str, + log_format: str = "%(asctime)s [%(levelname)s] %(message)s", + _to_stderr: bool = False, +): + """Initialize logger with the specified log level.""" + + handler = None + # optionally send all log messages to stderr + if _to_stderr: + handler = logging.StreamHandler(sys.stderr) + else: + handler = logging.StreamHandler(sys.stdout) + + if handler: + if log_format: + formatter = logging.Formatter(log_format) + handler.setFormatter(formatter) + logging.getLogger().addHandler(handler) + + # set the logging level + if log_level: + root_logger.setLevel(log_level) + + root_logger.debug(f"Logging level set to: {log_level}") diff --git a/src/reprostim/__main__.py b/src/reprostim/__main__.py new file mode 100644 index 00000000..6425db6e --- /dev/null +++ b/src/reprostim/__main__.py @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: 2024-present Vadim Melnik +# +# SPDX-License-Identifier: MIT +import sys + +if __name__ == "__main__": + from .cli.entrypoint import main + + sys.exit(main()) diff --git a/src/reprostim/cli/__init__.py b/src/reprostim/cli/__init__.py new file mode 100644 index 00000000..5df0bd79 --- /dev/null +++ b/src/reprostim/cli/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2024-present Vadim Melnik +# +# SPDX-License-Identifier: MIT diff --git a/src/reprostim/cli/cmd_detect_noscreen.py b/src/reprostim/cli/cmd_detect_noscreen.py new file mode 100644 index 00000000..d960cd15 --- /dev/null +++ b/src/reprostim/cli/cmd_detect_noscreen.py @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2024-present Vadim Melnik +# +# SPDX-License-Identifier: MIT +import click + + +@click.command() +def detect_noscreen(): + """Detect no screen/no signal frames in captured videos.""" + click.echo("TODO: Implement detect-noscreen") diff --git a/src/reprostim/cli/cmd_echo.py b/src/reprostim/cli/cmd_echo.py new file mode 100644 index 00000000..cda96d6a --- /dev/null +++ b/src/reprostim/cli/cmd_echo.py @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: 2024-present Vadim Melnik +# +# SPDX-License-Identifier: MIT +import click + + +@click.command() +@click.argument("message", default="Hello Reprostim World!", required=False) +def echo(message): + """Echo the provided message or the default value.""" + click.echo(message) diff --git a/src/reprostim/cli/cmd_qr_parse.py b/src/reprostim/cli/cmd_qr_parse.py new file mode 100644 index 00000000..8987d2e1 --- /dev/null +++ b/src/reprostim/cli/cmd_qr_parse.py @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: 2024-present Vadim Melnik +# +# SPDX-License-Identifier: MIT +import logging +import os + +import click + +from ..qr.qr_parse import do_info, do_parse + +# setup logging +logger = logging.getLogger(__name__) + + +@click.command(help="Utility to parse video and locate integrated " "QR time codes.") +@click.argument("path", type=click.Path(exists=True)) +@click.option( + "--mode", + default="PARSE", + type=click.Choice(["PARSE", "INFO"]), + help='Specify execution mode. Default is "PARSE", ' + "normal execution. " + 'Use "INFO" to dump video file info like duration, ' + "bitrate, file size etc, (in this case " + '"path" argument specifies video file or directory ' + "containing video files).", +) +@click.pass_context +def qr_parse(ctx, path: str, mode: str): + """Parse QR codes in captured videos.""" + logger.debug("qr_parse(...)") + logger.debug(f"Working dir : {os.getcwd()}") + logger.info(f"Video full path : {path}") + + if not os.path.exists(path): + logger.error(f"Path does not exist: {path}") + return 1 + + if mode == "PARSE": + for item in do_parse(path): + print(item.model_dump_json()) + elif mode == "INFO": + for item in do_info(path): + print(item.model_dump_json()) + else: + logger.error(f"Unknown mode: {mode}") + return 0 diff --git a/src/reprostim/cli/cmd_timesync_stimuli.py b/src/reprostim/cli/cmd_timesync_stimuli.py new file mode 100644 index 00000000..641755b4 --- /dev/null +++ b/src/reprostim/cli/cmd_timesync_stimuli.py @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: 2024-present Vadim Melnik +# +# SPDX-License-Identifier: MIT +import click + + +@click.command() +def timesync_stimuli(): + """Run psychopy script with QR video and audio codes.""" + click.echo("TODO: Implement timesync-stimuli") diff --git a/src/reprostim/cli/entrypoint.py b/src/reprostim/cli/entrypoint.py new file mode 100644 index 00000000..33f3af32 --- /dev/null +++ b/src/reprostim/cli/entrypoint.py @@ -0,0 +1,72 @@ +# SPDX-FileCopyrightText: 2024-present Vadim Melnik +# +# SPDX-License-Identifier: MIT +import logging + +import click +from click_didyoumean import DYMGroup + +from .. import _init_logger +from ..__about__ import __reprostim_name__, __version__ + +# setup logging +logger = logging.getLogger(__name__) + + +def print_version(ctx, value): + if not value or ctx.resilient_parsing: + return + click.echo(__version__) + ctx.exit() + + +# group to provide commands +@click.group(cls=DYMGroup) +@click.version_option(version=__version__, prog_name=__reprostim_name__) +@click.option( + "-l", + "--log-level", + default="DEBUG", + type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), + help="Set the logging level.", +) +@click.option( + "-f", + "--log-format", + default="%(asctime)s [%(levelname)s] %(message)s", + help="Set the logging format string. For the pattern details see standard " + "Python 'logging.Formatter' documentation.", +) +@click.pass_context +def main(ctx, log_level, log_format): + """Command-line interface to run ReproStim tools and services. + To see help for the specific command, run: + + reprostim COMMAND --help + + e.g. reprostim timesync-stimuli --help + """ + # some commands require logging to stderr + log_to_stderr: bool = ctx.invoked_subcommand in ("qr-parse",) + _init_logger(log_level, log_format, log_to_stderr) + logger.debug(f"{__reprostim_name__} v{__version__}") + logger.debug(f"main(...), command={ctx.invoked_subcommand}") + + +# Import all CLI commands +from .cmd_detect_noscreen import detect_noscreen # noqa: E402 +from .cmd_echo import echo # noqa: E402 +from .cmd_qr_parse import qr_parse # noqa: E402 +from .cmd_timesync_stimuli import timesync_stimuli # noqa: E402 + +# List all CLI commands to be included in the main group +__all_commands__ = ( + detect_noscreen, + echo, + qr_parse, + timesync_stimuli, +) + +# Register all CLI commands +for cmd in __all_commands__: + main.add_command(cmd) diff --git a/Parsing/parse_wQR.py b/src/reprostim/qr/qr_parse.py old mode 100755 new mode 100644 similarity index 54% rename from Parsing/parse_wQR.py rename to src/reprostim/qr/qr_parse.py index 062512a9..48efa8e7 --- a/Parsing/parse_wQR.py +++ b/src/reprostim/qr/qr_parse.py @@ -1,35 +1,37 @@ -#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2024-present Vadim Melnik +# +# SPDX-License-Identifier: MIT -import json import logging import os import re +import time from datetime import datetime, timedelta from pathlib import Path -from re import match from typing import Optional -from pydantic import BaseModel, Field -from pyzbar.pyzbar import decode, ZBarSymbol +import click import cv2 -import sys import numpy as np -import time -import click +from pydantic import BaseModel, Field +from pyzbar.pyzbar import ZBarSymbol, decode # initialize the logger -# Note: all logs goes to stderr +# Note: all logs out to stderr logger = logging.getLogger(__name__) -logging.getLogger().addHandler(logging.StreamHandler(sys.stderr)) +# logging.getLogger().addHandler(logging.StreamHandler(sys.stderr)) logger.debug(f"name={__name__}") + # Define class video info details class InfoSummary(BaseModel): path: Optional[str] = Field(None, description="Video file path") - rate_mbpm: Optional[float] = Field(0.0, description="Video file 'byterate' " - "in MB per minute.") - duration_sec: Optional[float] = Field(0.0, description="Duration of the video " - "in seconds") + rate_mbpm: Optional[float] = Field( + 0.0, description="Video file 'byterate' " "in MB per minute." + ) + duration_sec: Optional[float] = Field( + 0.0, description="Duration of the video " "in seconds" + ) size_mb: Optional[float] = Field(0.0, description="Video file size in MB.") @@ -39,62 +41,86 @@ class VideoTimeInfo(BaseModel): error: Optional[str] = Field(None, description="Error message if any") start_time: Optional[datetime] = Field(None, description="Start time of the video") end_time: Optional[datetime] = Field(None, description="End time of the video") - duration_sec: Optional[float] = Field(None, description="Duration of the video " - "in seconds") + duration_sec: Optional[float] = Field( + None, description="Duration of the video " "in seconds" + ) + # Define model for parsing summary info class ParseSummary(BaseModel): type: Optional[str] = Field("ParseSummary", description="JSON record type/class") qr_count: Optional[int] = Field(0, description="Number of QR codes found") - parsing_duration: Optional[float] = Field(0.0, description="Duration of the " - "parsing in seconds") + parsing_duration: Optional[float] = Field( + 0.0, description="Duration of the " "parsing in seconds" + ) # exit code exit_code: Optional[int] = Field(-1, description="Number of QR codes found") - video_full_path: Optional[str] = Field(None, description="Full path " - "to the video file") - video_file_name: Optional[str] = Field(None, description="Name of the " - "video file") - video_isotime_start: Optional[datetime] = Field(None, description="ISO datetime " - "video started") - video_isotime_end: Optional[datetime] = Field(None, description="ISO datetime " - "video ended") - video_duration: Optional[float] = Field(None, description="Duration of the video " - "in seconds") - video_frame_width: Optional[int] = Field(None, description="Width of the " - "video frame in px") - video_frame_height: Optional[int] = Field(None, description="Height of the " - "video frame in px") - video_frame_rate: Optional[float] = Field(None, description="Frame rate of the " - "video in FPS") - video_frame_count: Optional[int] = Field(None, description="Number of frames " - "in video file") + video_full_path: Optional[str] = Field( + None, description="Full path " "to the video file" + ) + video_file_name: Optional[str] = Field( + None, description="Name of the " "video file" + ) + video_isotime_start: Optional[datetime] = Field( + None, description="ISO datetime " "video started" + ) + video_isotime_end: Optional[datetime] = Field( + None, description="ISO datetime " "video ended" + ) + video_duration: Optional[float] = Field( + None, description="Duration of the video " "in seconds" + ) + video_frame_width: Optional[int] = Field( + None, description="Width of the " "video frame in px" + ) + video_frame_height: Optional[int] = Field( + None, description="Height of the " "video frame in px" + ) + video_frame_rate: Optional[float] = Field( + None, description="Frame rate of the " "video in FPS" + ) + video_frame_count: Optional[int] = Field( + None, description="Number of frames " "in video file" + ) # Define the data model for the QR record class QrRecord(BaseModel): type: Optional[str] = Field("QrRecord", description="JSON record type/class") - index: Optional[int] = Field(None, description="Zero-based i ndex of the QR code") - frame_start: Optional[int] = Field(None, description="Frame number where QR code starts") - frame_end: Optional[int] = Field(None, description="Frame number where QR code ends") - isotime_start: Optional[datetime] = Field(None, description="ISO datetime where QR " - "code starts") - isotime_end: Optional[datetime] = Field(None, description="ISO datetime where QR " - "code ends") - time_start: Optional[float] = Field(None, description="Position in seconds " - "where QR code starts") - time_end: Optional[float] = Field(None, description="Position in seconds " - "where QR code ends") - duration: Optional[float] = Field(None, description="Duration of the QR code " - "in seconds") + index: Optional[int] = Field( + None, description="Zero-based i ndex of the QR code" + ) + frame_start: Optional[int] = Field( + None, description="Frame number where QR code starts" + ) + frame_end: Optional[int] = Field( + None, description="Frame number where QR code ends" + ) + isotime_start: Optional[datetime] = Field( + None, description="ISO datetime where QR " "code starts" + ) + isotime_end: Optional[datetime] = Field( + None, description="ISO datetime where QR " "code ends" + ) + time_start: Optional[float] = Field( + None, description="Position in seconds " "where QR code starts" + ) + time_end: Optional[float] = Field( + None, description="Position in seconds " "where QR code ends" + ) + duration: Optional[float] = Field( + None, description="Duration of the QR code " "in seconds" + ) data: Optional[dict] = Field(None, description="QR code data") def __str__(self): - return (f"QrRecord(frames=[{self.frame_start}, {self.frame_end}], " - f"times=[{self.time_start}, {self.time_end} sec], " - f"duration={self.duration} sec, " - f"isotimes=[{self.isotime_start}, {self.isotime_end}], " - f"data={self.data})" - ) + return ( + f"QrRecord(frames=[{self.frame_start}, {self.frame_end}], " + f"times=[{self.time_start}, {self.time_end} sec], " + f"duration={self.duration} sec, " + f"isotimes=[{self.isotime_start}, {self.isotime_end}], " + f"data={self.data})" + ) def calc_time(ts: datetime, pos_sec: float) -> datetime: @@ -108,16 +134,21 @@ def get_iso_time(ts: str) -> datetime: def get_video_time_info(path_video: str) -> VideoTimeInfo: - res: VideoTimeInfo = VideoTimeInfo(success=False, error=None, - start_time=None, end_time=None) + res: VideoTimeInfo = VideoTimeInfo( + success=False, error=None, start_time=None, end_time=None + ) # Define the regex pattern for the timestamp and file extension # (either .mkv or .mp4) - pattern1 = (r'^(\d{4}\.\d{2}\.\d{2}\.\d{2}\.\d{2}\.\d{2}\.\d{3})' - r'_(\d{4}\.\d{2}\.\d{2}\.\d{2}\.\d{2}\.\d{2}\.\d{3})\.(mkv|mp4)$') + pattern1 = ( + r"^(\d{4}\.\d{2}\.\d{2}\.\d{2}\.\d{2}\.\d{2}\.\d{3})" + r"_(\d{4}\.\d{2}\.\d{2}\.\d{2}\.\d{2}\.\d{2}\.\d{3})\.(mkv|mp4)$" + ) # add support for new file format - pattern2 = (r'^(\d{4}\.\d{2}\.\d{2}\-\d{2}\.\d{2}\.\d{2}\.\d{3})' - r'\-\-(\d{4}\.\d{2}\.\d{2}\-\d{2}\.\d{2}\.\d{2}\.\d{3})\.(mkv|mp4)$') + pattern2 = ( + r"^(\d{4}\.\d{2}\.\d{2}\-\d{2}\.\d{2}\.\d{2}\.\d{3})" + r"\-\-(\d{4}\.\d{2}\.\d{2}\-\d{2}\.\d{2}\.\d{2}\.\d{3})\.(mkv|mp4)$" + ) file_name: str = os.path.basename(path_video) logger.info(f"Video file name : {file_name}") @@ -133,7 +164,7 @@ def get_video_time_info(path_video: str) -> VideoTimeInfo: # Define the format for datetime parsing ts_format = "%Y.%m.%d-%H.%M.%S.%f" else: - res.error = "Filename does not match the required pattern." + res.error = f"Filename '{path_video}' does not match the required pattern." return res start_ts, end_ts, extension = match1.groups() @@ -159,10 +190,9 @@ def get_video_time_info(path_video: str) -> VideoTimeInfo: return res -def finalize_record(ps: ParseSummary, - vti: VideoTimeInfo, - record: QrRecord, iframe: int, - pos_sec: float) -> QrRecord: +def finalize_record( + ps: ParseSummary, vti: VideoTimeInfo, record: QrRecord, iframe: int, pos_sec: float +) -> QrRecord: record.frame_end = iframe # Note: unclear should we also use last frame duration or not record.isotime_end = calc_time(vti.start_time, pos_sec) @@ -171,16 +201,20 @@ def finalize_record(ps: ParseSummary, record.index = ps.qr_count logger.info(f"QR: {str(record)}") # dump times - event_time = get_iso_time(record.data['time_formatted']) - keys_time = get_iso_time(record.data['keys_time_str']) + event_time = get_iso_time(record.data["time_formatted"]) + keys_time = get_iso_time(record.data["keys_time_str"]) logger.info(f" - QR code isotime : {record.isotime_start}") - logger.info(f" - Event isotime : " - f"{event_time} / " - f"dt={(event_time - record.isotime_start).total_seconds()} sec") - logger.info(f" - Keys isotime : " - f"{keys_time} / " - f"dt={(keys_time - record.isotime_start).total_seconds()} sec") - #print(record.json()) + logger.info( + f" - Event isotime : " + f"{event_time} / " + f"dt={(event_time - record.isotime_start).total_seconds()} sec" + ) + logger.info( + f" - Keys isotime : " + f"{keys_time} / " + f"dt={(keys_time - record.isotime_start).total_seconds()} sec" + ) + # print(record.json()) ps.qr_count += 1 return record @@ -195,9 +229,9 @@ def do_info_file(path: str): o.path = path o.duration_sec = round(vti.duration_sec, 1) size: float = os.path.getsize(path) - o.size_mb = round(size/(1000*1000), 1) - if o.duration_sec>0.0001: - o.rate_mbpm = round(size*60/(o.duration_sec*1000*1000), 1) + o.size_mb = round(size / (1000 * 1000), 1) + if o.duration_sec > 0.0001: + o.rate_mbpm = round(size * 60 / (o.duration_sec * 1000 * 1000), 1) return o @@ -209,7 +243,7 @@ def do_info(path: str): logger.info(f"Processing video directory: {path}") for root, _, files in os.walk(path): for file in files: - if file.endswith('.mkv'): + if file.endswith(".mkv"): yield do_info_file(os.path.join(root, file)) # Uncomment to visit only top-level dir # break @@ -243,7 +277,7 @@ def do_parse(path_video: str): frame_width: int = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) frame_height: int = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) duration_sec: float = frame_count / fps if fps > 0 else -1.0 - logger.info(f"Video media info : ") + logger.info("Video media info : ") logger.info(f" - resolution : {frame_width}x{frame_height}") logger.info(f" - frame rate : {str(fps)} FPS") logger.info(f" - duration : {str(duration_sec)} sec") @@ -259,19 +293,12 @@ def do_parse(path_video: str): ps.video_file_name = os.path.basename(path_video) if abs(duration_sec - vti.duration_sec) > 120.0: - logger.error(f"Video duration significant mismatch (real/file name):" - f" {duration_sec} sec vs {vti.duration_sec} sec") - - clips = [] - qrData = {} - - inRun = False + logger.error( + f"Video duration significant mismatch (real/file name):" + f" {duration_sec} sec vs {vti.duration_sec} sec" + ) - clip_start = None - acqNum = None - runNum = None - - #for f in vid.iter_frames(with_times=True): + # for f in vid.iter_frames(with_times=True): # TODO: just use tqdm for progress indication iframe: int = 0 @@ -281,7 +308,7 @@ def do_parse(path_video: str): while True: iframe += 1 # pos time in ms - pos_sec = round((iframe-1) / fps, 3) + pos_sec = round((iframe - 1) / fps, 3) ret, frame = cap.read() if not ret: break @@ -297,13 +324,13 @@ def do_parse(path_video: str): cod = decode(f, symbols=[ZBarSymbol.QRCODE]) if len(cod) > 0: - logger.debug("Found QR code: " + str(cod)); + logger.debug("Found QR code: " + str(cod)) assert len(cod) == 1, f"Expecting only one, got {len(cod)}" - data = eval(eval(str(cod[0].data)).decode('utf-8')) + data = eval(eval(str(cod[0].data)).decode("utf-8")) if record is not None: if data == record.data: # we are still in the same QR code record - logger.debug(f"Same QR code: continue") + logger.debug("Same QR code: continue") continue # It is a different QR code! we need to finalize current one yield finalize_record(ps, vti, record, iframe, pos_sec) @@ -328,29 +355,31 @@ def do_parse(path_video: str): ps.exit_code = 0 ps.parsing_duration = round(time.time() - dt, 1) yield ps - #print(ps.json()) - - -@click.command(help='Utility to parse video and locate integrated ' - 'QR time codes.') -@click.argument('path', type=click.Path(exists=True)) -@click.option('--mode', default='PARSE', - type=click.Choice(['PARSE', 'INFO']), - help='Specify execution mode. Default is "PARSE", ' - 'normal execution. ' - 'Use "INFO" to dump video file info like duration, ' - 'bitrate, file size etc, (in this case ' - '"path" argument specifies video file or directory ' - 'containing video files).') -@click.option('--log-level', default='INFO', - type=click.Choice(['DEBUG', 'INFO', - 'WARNING', 'ERROR', - 'CRITICAL']), - help='Set the logging level') + # print(ps.json()) + + +@click.command(help="Utility to parse video and locate integrated " "QR time codes.") +@click.argument("path", type=click.Path(exists=True)) +@click.option( + "--mode", + default="PARSE", + type=click.Choice(["PARSE", "INFO"]), + help='Specify execution mode. Default is "PARSE", ' + "normal execution. " + 'Use "INFO" to dump video file info like duration, ' + "bitrate, file size etc, (in this case " + '"path" argument specifies video file or directory ' + "containing video files).", +) +@click.option( + "--log-level", + default="INFO", + type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), + help="Set the logging level", +) @click.pass_context def main(ctx, path: str, mode: str, log_level): - logger.setLevel(log_level) - logger.debug("parse_wQR.py tool") + logger.debug("qr-parse command") logger.debug(f"Working dir : {os.getcwd()}") logger.info(f"Video full path : {path}") @@ -358,17 +387,13 @@ def main(ctx, path: str, mode: str, log_level): logger.error(f"Path does not exist: {path}") return 1 - if mode=="PARSE": + if mode == "PARSE": for item in do_parse(path): - print(item.model_dump_json()) - elif mode=="INFO": + click.echo(item.model_dump_json()) + elif mode == "INFO": for item in do_info(path): - print(item.model_dump_json()) + click.echo(item.model_dump_json()) else: logger.error(f"Unknown mode: {mode}") + return -1 return 0 - - -if __name__ == "__main__": - code = main() - sys.exit(code) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..88b46b8e --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,2 @@ +# +# SPDX-License-Identifier: MIT diff --git a/tests/data/reprostim-videos/2024.06.04-13.51.24.278--2024.06.04-13.51.31.057.mkv b/tests/data/reprostim-videos/2024.06.04-13.51.24.278--2024.06.04-13.51.31.057.mkv new file mode 100644 index 00000000..e69975c4 Binary files /dev/null and b/tests/data/reprostim-videos/2024.06.04-13.51.24.278--2024.06.04-13.51.31.057.mkv differ diff --git a/tools/README.md b/tools/README.md index 244f231f..67bc1ff4 100644 --- a/tools/README.md +++ b/tools/README.md @@ -39,7 +39,7 @@ cd ./containers/images/repronim datalad get . ``` -Check that X11 system is used by default in Linux (Ubuntu 24.04), +Check that X11 system is used by default in Linux (Ubuntu 24.04), psychopy will not work well with Wayland: ``` @@ -56,7 +56,7 @@ It should return `x11`. If not, switch to X11: ### C. Run ReproNim TimeSync Script -Make sure the current directory is one under singularity container +Make sure the current directory is one under singularity container path created in the previous step B: ```shell @@ -67,21 +67,21 @@ Run the script: ```shell singularity exec ./repronim-psychopy--2024.1.4.sing ${REPROSTIM_PATH}/tools/reprostim-timesync-stimuli output.log 1 -``` +``` Where `REPROSTIM_PATH` is the local clone of https://github.com/ReproNim/reprostim repository. -Last script parameter is the display ID, which is `1` in this case. +Last script parameter is the display ID, which is `1` in this case. ### D. Update Singularity Container Locally (Optionally) -Optionally, you can update the container locally for development +Optionally, you can update the container locally for development and debugging purposes (with overlay): ```shell singularity overlay create \ --size 1024 \ repronim-psychopy--2024.1.4.overlay - + sudo singularity exec \ --overlay repronim-psychopy--2024.1.4.overlay \ repronim-psychopy--2024.1.4.sing \ @@ -108,16 +108,16 @@ singularity exec \ --overlay ./repronim-psychopy--2024.1.4.overlay \ ./repronim-psychopy--2024.1.4.sing \ ${REPROSTIM_PATH}/tools/reprostim-timesync-stimuli output.log 1 -``` +``` -Where `/run/user/321/pulse` is sample external pulseaudio device path bound to the container. Usually +Where `/run/user/321/pulse` is sample external pulseaudio device path bound to the container. Usually when you run the script w/o binding it will report error like: ```shell Failed to create secure directory (/run/user/321/pulse): No such file or directory -``` +``` -NOTE: Make sure `PULSE_SERVER` is specified in the container environment and +NOTE: Make sure `PULSE_SERVER` is specified in the container environment and points to the host pulseaudio server. e.g.: ```shell @@ -128,12 +128,12 @@ export PULSE_SERVER=unix:/run/user/321/pulse/native ```shell cd ~/Projects/Dartmouth/branches/datalad/containers/images/repronim -export REPROSTIM_PATH=~/Projects/Dartmouth/branches/reprostim +export REPROSTIM_PATH=~/Projects/Dartmouth/branches/reprostim singularity overlay create \ --size 1024 \ repronim-psychopy--2024.1.4.overlay - + sudo singularity exec \ --overlay repronim-psychopy--2024.1.4.overlay \ repronim-psychopy--2024.1.4.sing \ @@ -149,7 +149,7 @@ exit sudo singularity exec \ --overlay repronim-psychopy--2024.1.4.overlay \ repronim-psychopy--2024.1.4.sing \ - python3 -m pip install pyzbar opencv-python numpy click pydantic sounddevice scipy pydub pyaudio reedsolo psychopy-sounddevice + python3 -m pip install pyzbar opencv-python numpy click pydantic sounddevice scipy pydub pyaudio reedsolo psychopy-sounddevice # and run the script rm output.log @@ -164,5 +164,3 @@ singularity exec \ ${REPROSTIM_PATH}/tools/reprostim-timesync-stimuli output.log 1 ``` - - diff --git a/tools/audio-codes-notes.md b/tools/audio-codes-notes.md index 9bac0bec..f5f951cb 100644 --- a/tools/audio-codes-notes.md +++ b/tools/audio-codes-notes.md @@ -101,7 +101,7 @@ sudo apt install python3.10 sudo apt install python3.10-venv ``` -After this `python3.10` failed to create `venv` so used following commans to create it: +After this `python3.10` failed to create `venv` so used following commands to create it: ``` python3.10 -m venv --without-pip venv diff --git a/tools/audio-codes.py b/tools/audio-codes.py index bab5a87e..374cbc92 100644 --- a/tools/audio-codes.py +++ b/tools/audio-codes.py @@ -114,7 +114,7 @@ def list_audio_devices(): logger.debug("[psychopy.backend_ptb]") # TODO: investigate why only single out device listed from - # USB capture but defult one is not shown + # USB capture but default one is not shown # logger.debug(sound.backend_ptb.getDevices()) diff --git a/Parsing/generate_qrinfo.sh b/tools/reprostim-generate-qrinfo similarity index 79% rename from Parsing/generate_qrinfo.sh rename to tools/reprostim-generate-qrinfo index af687308..5c3add8b 100755 --- a/Parsing/generate_qrinfo.sh +++ b/tools/reprostim-generate-qrinfo @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash if [ -z "$1" ]; then echo "Script to generate QR info JSONL data and logs for ReproNim session with parse_wQR.py tool" @@ -33,13 +33,13 @@ do base_name=$(basename "$file" .mkv) echo "Processing $counter/$total_files : $file..." # this is normal video parsing: - #./parse_wQR.py --log-level $LOG_LEVEL $file >$OUT_DIR/$base_name.qrinfo.jsonl 2>$OUT_DIR/$base_name.qrinfo.log + #reprostim --log-level $LOG_LEVEL qr-parse $file >$OUT_DIR/$base_name.qrinfo.jsonl 2>$OUT_DIR/$base_name.qrinfo.log # but we have invalid videos, so cleanup it first tmp_mkv_file=$OUT_DIR/$base_name.mkv echo "Generating tmp *.mkv file $tmp_mkv_file..." ffmpeg -i $file -an -c copy $tmp_mkv_file - ./parse_wQR.py --log-level $LOG_LEVEL $tmp_mkv_file >$OUT_DIR/$base_name.qrinfo.jsonl 2>$OUT_DIR/$base_name.qrinfo.log + reprostim --log-level $LOG_LEVEL qr-parse $tmp_mkv_file >$OUT_DIR/$base_name.qrinfo.jsonl 2>$OUT_DIR/$base_name.qrinfo.log if [ -e "$tmp_mkv_file" ]; then echo "Deleting tmp *.mkv file: $tmp_mkv_file" rm "$tmp_mkv_file" @@ -50,5 +50,5 @@ done # Generate QR info #echo "Generating QR info data..." -#./parse_wQR.py --log-level $LOG_LEVEL $SESSION_DIR >$OUT_DIR/dump_qrinfo.jsonl 2>$OUT_DIR/dump_qrinfo.log +#reprostim --log-level $LOG_LEVEL qr-parse $SESSION_DIR >$OUT_DIR/dump_qrinfo.jsonl 2>$OUT_DIR/dump_qrinfo.log #echo "dump_qrinfo.py exit code: $?" diff --git a/tools/soundcode.py b/tools/soundcode.py index e601639a..97a9229b 100644 --- a/tools/soundcode.py +++ b/tools/soundcode.py @@ -494,7 +494,7 @@ def list_audio_devices(): logger.debug("[psychopy.backend_ptb]") # TODO: investigate why only single out device listed from - # USB capture but defult one is not shown + # USB capture but default one is not shown # logger.debug(sound.backend_ptb.getDevices())