Skip to content

Commit cbe9648

Browse files
authored
Merge pull request #239 from ReproNim/enh-qr-parse-optimization
Optimize `qr_parse` processing
2 parents 6bd9170 + 2b17d4d commit cbe9648

10 files changed

Lines changed: 1607 additions & 106 deletions

File tree

.ai/context.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Main Python package with CLI tools and analysis utilities.
3232
**Key Modules:**
3333
- **cli/** - Command-line interface (Click-based with DYMGroup for suggestions)
3434
- `entrypoint.py` - Main CLI dispatcher
35-
- `cmd_qr_parse.py` - Parse QR codes from `.mkv` videos (PARSE/INFO modes)
35+
- `cmd_qr_parse.py` - Parse QR codes from `.mkv` videos (PARSE/INFO modes) (see [spec-qr-parse.md](.ai/spec-qr-parse.md))
3636
- `cmd_timesync_stimuli.py` - PsychoPy integration for QR/audio code generation
3737
- `cmd_detect_noscreen.py` - Detect no-signal/rainbow frames with fixup capabilities
3838
- `cmd_list_displays.py` - List available GUI displays (cross-platform)
@@ -43,7 +43,7 @@ Main Python package with CLI tools and analysis utilities.
4343
- `cmd_echo.py` - Simple echo command for testing
4444

4545
- **qr/** - QR code processing and time synchronization
46-
- `qr_parse.py` - Parse `.mkv` files, extract QR codes, audio codes, metadata → JSONL
46+
- `qr_parse.py` - Parse `.mkv` files, extract QR codes, audio codes, metadata → JSONL (see [spec-qr-parse.md](.ai/spec-qr-parse.md))
4747
- `timesync_stimuli.py` - PsychoPy-based MRI/BIRCH/Magewell synchronization
4848
- `disp_mon.py` - Cross-platform display monitoring (Linux/macOS/Windows)
4949
- `psychopy.py` - PsychoPy framework integration utilities

.ai/spec-qr-parse.md

Lines changed: 211 additions & 0 deletions
Large diffs are not rendered by default.

.ai/task-qr-parse.md

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# `qr-parse` Task List
2+
3+
Tracks implementation progress against [spec-qr-parse.md](spec-qr-parse.md).
4+
5+
---
6+
7+
## CLI Options
8+
9+
- [x] `PATH` argument — path to video file or directory
10+
- [x] `-m / --mode [PARSE|INFO]` — execution mode
11+
- [x] `-g / --grayscale [none|numpy|opencv]` — frame grayscale conversion method; default `cvtcolor`
12+
- [x] `-t / --std-threshold FLOAT` — grayscale std-deviation pre-filter; skip decode when std < threshold; disabled when ≤ 0; default `10.0`
13+
- [x] `-x / --scale FLOAT` — frame downscale factor `(0, 1]`; `1.0` = no resize; default `1.0`
14+
- [x] `-s / --skip INT` — frames to skip after each processed frame; `0` = every frame; default `0`
15+
- [x] `-q / --qr-decoder [none|opencv|pyzbar]` — QR backend; `none` skips decode; default `pyzbar`
16+
- [x] `-v / --video-decoder [opencv]` — video frame backend; only `opencv` supported now; placeholder for `ffmpeg`/`pyav`; default `opencv`
17+
- [x] `-Q / --qrdet` — enable qrdet-based frame pre-filter; default `False`
18+
- [x] `-M / --qrdet-model-size [n|s|m|l]` — qrdet model size; default `s`; only used when `--qrdet` is set
19+
- [x] `-W / --qr-decoder-workers INT` — worker threads for parallel QR decoding; `0`/`1` = sequential (streaming); `N > 1` = parallel (buffered, iframe-ordered)
20+
21+
---
22+
23+
## Core Logic
24+
25+
### PARSE mode
26+
- [x] Read frames via `cv2.VideoCapture`
27+
- [x] Grayscale conversion (`np.mean` — current; candidate for `cv2.cvtColor` optimisation)
28+
- [x] QR detection via `pyzbar.decode`
29+
- [x] Deduplicate consecutive identical QR codes
30+
- [x] Output JSONL (`ParseSummary` + per-code records) to stdout
31+
- [x] Std-deviation pre-filter: compute grayscale std before decode, skip frame if below `--std-threshold`
32+
- [x] Replace `np.mean` grayscale with `cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)` (×10 speedup)
33+
- [x] Replace `np.std` with `cv2.meanStdDev` on grayscale frame (faster, same result)
34+
35+
### INFO mode
36+
- [x] Enumerate `.mkv` files in directory (or single file)
37+
- [x] Output `InfoSummary` JSONL (path, duration, size, rate)
38+
39+
---
40+
41+
## Tests
42+
43+
### CLI option handling
44+
- [x] `--grayscale none` — verify raw BGR frame is passed through without conversion
45+
- [x] `--grayscale numpy` — verify `np.mean(frame, axis=2)` path is taken
46+
- [x] `--grayscale opencv` — verify `cv2.cvtColor` path is taken (default)
47+
- [x] `--std-threshold 0` — verify pre-filter is disabled, all frames reach decoder
48+
- [x] `--std-threshold 40` — verify frames below threshold are skipped
49+
- [x] `--scale 0.5` — verify frames are resized before decode
50+
- [x] `--scale 1.0` — verify no resize is applied (default, no-op)
51+
- [x] `--skip 0` — verify every frame is processed
52+
- [x] `--skip 2` — verify only every 3rd frame is processed
53+
- [x] `--qr-decoder none` — verify decode is skipped, output still produced
54+
- [x] `--qr-decoder opencv` — verify `cv2.QRCodeDetector.detectAndDecode` is used
55+
- [x] `--qr-decoder pyzbar` — verify `pyzbar.decode` is used (default)
56+
- [x] `--video-decoder opencv` — verify `cv2.VideoCapture` is used (default)
57+
- [x] `--qrdet` — verify qrdet pre-filter is activated (mocked, no GPU needed)
58+
- [x] `--qrdet-model-size n/s/m/l` — verify correct model variant is loaded (mocked)
59+
- [x] `--qr-decoder-workers 0` — verify sequential path is taken (ThreadPoolExecutor not instantiated)
60+
- [x] `--qr-decoder-workers 1` — verify sequential path is taken (threshold is `> 1`)
61+
- [x] `--qr-decoder-workers 4` — verify ThreadPoolExecutor instantiated with `max_workers=4`
62+
- [x] `--qr-decoder-workers 4` — verify `_process_frame` called once per non-skipped frame
63+
- [x] `--qr-decoder-workers 4` — verify output records match sequential path (data, frame_start, time_start)
64+
65+
### Integration
66+
- [ ] Combined `--grayscale cvtcolor --std-threshold 40 --skip 1` — verify all three interact correctly on a real video
67+
- [ ] `--qrdet --qr-decoder pyzbar` — verify qrdet pre-filter feeds into pyzbar decode
68+
- [ ] `--qr-decoder none --std-threshold 40` — verify std filter still runs even when decode is disabled
69+
- [ ] QR codes are detected and match expected output on reference test video
70+
71+
### Coverage
72+
- [x] `qr_parse.py` ≥ 80% — achieved **93%**
73+
- [x] `cmd_qr_parse.py` ≥ 80% — achieved **100%**
74+
- [x] `get_video_time_info` edge cases: invalid filename, start-only filename, start ≥ end
75+
- [x] `_decode_qr_pyzbar` / `_decode_qr_opencv` found-code paths
76+
- [x] `_qr_state_machine` — two different QR codes in sequence; QR code at end of video
77+
- [x] `do_parse``summary_only=True`; `ignore_errors=True`; `cap.isOpened()=False`
78+
- [x] `do_info` / `do_info_file` — file, directory, invalid path
79+
- [x] `do_main` — path not found, invalid scale, invalid skip, INFO mode, unknown mode, PARSE mode success
80+
- [x] CLI (`cmd_qr_parse`) — basic invocation, `--mode INFO`, option forwarding, invalid path
81+
82+
### Regression
83+
- [ ] Existing PARSE mode output unchanged when all new options are at their defaults
84+
- [ ] Existing INFO mode output unchanged
85+
86+
---
87+
88+
## Performance / Optimisation (from spec benchmarks)
89+
90+
- [x] `cv2.cvtColor` grayscale (proposal 1) — 23.7 → 46.1 fps
91+
- [x] `cv2.meanStdDev` std deviation (proposal 2)
92+
- [x] Std pre-filter with `--std-threshold` (proposal 3)
93+
- [x] Optional frame downscaling `-x / --scale` (proposal 4)
94+
- [x] Parallel decoding via `ThreadPoolExecutor` + `-W / --qr-decoder-workers` (proposal 5)
95+
- [ ] GPU / ZXing decoder (proposal 6)
96+
97+
---
98+
99+
## Future: Video Decoder Backends
100+
101+
Extend `-v / --video-decoder` with additional backends once `opencv` path is stable.
102+
103+
- [ ] `ffmpeg` — drive frame extraction via `ffmpeg` subprocess or `ffmpeg-python` bindings; useful for formats/codecs OpenCV cannot handle
104+
- [ ] `pyav` — use `av` (PyAV) bindings for libavcodec/libavformat; lower overhead than subprocess, supports hardware-accelerated decode (VAAPI, NVDEC)
105+
- [ ] ? `decord` — GPU-accelerated video reader (`decord` package); designed for ML workloads, supports batch frame reads and CUDA tensors
106+
- [ ] Abstract `_open_video(ctx) -> iterator[frame]` API in `qr_parse.py` so backends are swappable without touching the main frame loop

containers/repronim-reprostim/setup_container.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,12 @@ PSYCHOPY_VENV_BIN=${PSYCHOPY_HOME}/.venv/bin
2727

2828
# Install psychopy_linux_installer from GitHub
2929
echo "Install psychopy_linux_installer from GitHub..."
30-
git clone --branch v2.2.5 --depth 1 https://github.com/wieluk/psychopy_linux_installer/ /opt/psychopy-installer
30+
git clone --branch v2.2.7 --depth 1 https://github.com/wieluk/psychopy_linux_installer/ /opt/psychopy-installer
3131
cd /opt/psychopy-installer
3232

3333
# Install PsychoPy via psychopy_linux_installer
3434
echo "Install PsychoPy v${PSYCHOPY_VERSION} via psychopy_linux_installer..."
35-
/opt/psychopy-installer/psychopy_linux_installer --install-dir=${PSYCHOPY_INSTALL_DIR} --venv-name=${PSYCHOPY_VENV_NAME} --psychopy-version=${PSYCHOPY_VERSION} --additional-packages=psychopy_bids==2025.1.2,psychopy-mri-emulator==0.0.2,con-duct==0.18.0 --python-version=${PYTHON_VERSION} --wxpython-version=4.2.3 --cleanup -v -f
35+
/opt/psychopy-installer/psychopy_linux_installer --install-dir=${PSYCHOPY_INSTALL_DIR} --venv-name=${PSYCHOPY_VENV_NAME} --psychopy-version=${PSYCHOPY_VERSION} --additional-packages=psychopy_bids==2025.1.2,psychopy-mri-emulator==0.0.2,con-duct==0.18.0 --python-version=${PYTHON_VERSION} --wxpython-version=4.2.3 --cleanup --log-level=debug -f
3636
# Create symlink to psychopy executable
3737
ln -sf "${PSYCHOPY_HOME}/start_psychopy" /usr/local/bin/psychopy
3838

pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@ psychopy = [
7676
"psychopy",
7777
"psychopy-sounddevice",
7878
]
79+
# GPU-accelerated QR pre-filter via qrdet (YOLOv8) + PyTorch
80+
# Note: install the CUDA-enabled torch variant manually if a GPU is available:
81+
# pip install torch --index-url https://download.pytorch.org/whl/cu121
82+
gpu = [
83+
"torch>=2.0.0",
84+
"qrdet>=2.2",
85+
]
7986
all = [
8087
"reprostim[audio]",
8188
"reprostim[disp_mon]",

src/reprostim/cli/cmd_qr_parse.py

Lines changed: 129 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# SPDX-License-Identifier: MIT
44

55
import logging
6-
import os
6+
import time
77

88
import click
99

@@ -18,36 +18,146 @@
1818
)
1919
@click.argument("path", type=click.Path(exists=True))
2020
@click.option(
21+
"-m",
2122
"--mode",
2223
default="PARSE",
2324
type=click.Choice(["PARSE", "INFO"]),
25+
show_default=True,
2426
help="Specify execution mode. Default is `PARSE`, "
2527
"normal execution. "
2628
"Use `INFO` to dump video file info like duration, "
2729
"bitrate, file size etc, (in this case "
2830
"`PATH` argument specifies video file or directory "
2931
"containing video files).",
3032
)
33+
@click.option(
34+
"-g",
35+
"--grayscale",
36+
default="opencv",
37+
type=click.Choice(["none", "numpy", "opencv"]),
38+
show_default=True,
39+
help="Grayscale conversion method applied to each frame before QR decoding. "
40+
"`opencv` uses cv2.cvtColor (fast, recommended). "
41+
"`numpy` uses np.mean (slow, legacy). "
42+
"`none` passes raw frame as is — may cause errors with some decoders.",
43+
)
44+
@click.option(
45+
"-x",
46+
"--scale",
47+
default=1.0,
48+
type=click.FloatRange(min=0.0, min_open=True, max=1.0),
49+
show_default=True,
50+
help="Frame downscale factor in (0, 1]. At 0.5 frame area is reduced to 25%, "
51+
"cutting decode cost. At 1.0 (default) no resize is applied.",
52+
)
53+
@click.option(
54+
"-s",
55+
"--skip",
56+
default=0,
57+
type=click.IntRange(min=0),
58+
show_default=True,
59+
help="Number of frames to skip after each processed frame. "
60+
"0 = process every frame. 1 = process 1 of 2. 2 = process 1 of 3, etc.",
61+
)
62+
@click.option(
63+
"-t",
64+
"--std-threshold",
65+
default=10.0,
66+
type=float,
67+
show_default=True,
68+
help="Grayscale std-deviation pre-filter threshold. Frames with std dev below "
69+
"this value are skipped before QR decode. Set to 0 or less to disable.",
70+
)
71+
@click.option(
72+
"-q",
73+
"--qr-decoder",
74+
default="pyzbar",
75+
type=click.Choice(["none", "opencv", "pyzbar"]),
76+
show_default=True,
77+
help="QR decoding backend. "
78+
"`pyzbar` uses pyzbar.decode (default). "
79+
"`opencv` uses cv2.QRCodeDetector.detectAndDecode. "
80+
"`none` disables QR decoding entirely — useful for benchmarking.",
81+
)
82+
@click.option(
83+
"-v",
84+
"--video-decoder",
85+
default="opencv",
86+
type=click.Choice(["opencv"]),
87+
show_default=True,
88+
help="Video frame decoding backend. "
89+
"Currently only `opencv` (cv2.VideoCapture) is supported. "
90+
"Placeholder for future backends such as `ffmpeg` or `pyav`.",
91+
)
92+
@click.option(
93+
"-Q",
94+
"--qrdet",
95+
is_flag=True,
96+
default=False,
97+
show_default=True,
98+
help="Enable qrdet-based GPU frame pre-filter. "
99+
"A YOLOv8 QR detector runs on each frame before the full QR decode; "
100+
"frames with no detected QR region are skipped. "
101+
"Requires `qrdet` and `torch` packages (pip install reprostim[gpu]).",
102+
)
103+
@click.option(
104+
"-M",
105+
"--qrdet-model-size",
106+
default="s",
107+
type=click.Choice(["n", "s", "m", "l"]),
108+
show_default=True,
109+
help="qrdet model size. "
110+
"`n` (nano) is fastest; `s` (small) balances speed/accuracy (default); "
111+
"`m` and `l` give higher accuracy at greater cost. "
112+
"Only used when --qrdet is set.",
113+
)
114+
@click.option(
115+
"-W",
116+
"--qr-decoder-workers",
117+
default=0,
118+
type=click.IntRange(min=0),
119+
show_default=True,
120+
help="Number of worker threads for parallel QR decoding. "
121+
"0 or 1 = sequential (default). N > 1 = parallel with N threads.",
122+
)
31123
@click.pass_context
32-
def qr_parse(ctx, path: str, mode: str):
124+
def qr_parse(
125+
ctx,
126+
path: str,
127+
mode: str,
128+
grayscale: str,
129+
scale: float,
130+
skip: int,
131+
std_threshold: float,
132+
qr_decoder: str,
133+
video_decoder: str,
134+
qrdet: bool,
135+
qrdet_model_size: str,
136+
qr_decoder_workers: int,
137+
):
33138
"""Parse QR codes in captured videos."""
34139

35-
from ..qr.qr_parse import do_info, do_parse
140+
from ..qr.qr_parse import do_main
36141

37142
logger.debug("qr_parse(...)")
38-
logger.debug(f"Working dir : {os.getcwd()}")
39-
logger.info(f"Video full path : {path}")
40-
41-
if not os.path.exists(path):
42-
logger.error(f"Path does not exist: {path}")
43-
return 1
44-
45-
if mode == "PARSE":
46-
for item in do_parse(path):
47-
print(item.model_dump_json())
48-
elif mode == "INFO":
49-
for item in do_info(path):
50-
print(item.model_dump_json())
51-
else:
52-
logger.error(f"Unknown mode: {mode}")
53-
return 0
143+
144+
start_time_sec = time.time()
145+
146+
res = do_main(
147+
path=path,
148+
mode=mode,
149+
grayscale=grayscale,
150+
scale=scale,
151+
skip=skip,
152+
std_threshold=std_threshold,
153+
qr_decoder=qr_decoder,
154+
video_decoder=video_decoder,
155+
qrdet=qrdet,
156+
qrdet_model_size=qrdet_model_size,
157+
qr_decoder_workers=qr_decoder_workers,
158+
out_func=click.echo,
159+
)
160+
161+
elapsed_sec = round(time.time() - start_time_sec, 1)
162+
logger.debug(f"Command 'qr-parse' completed in {elapsed_sec} sec, exit code {res}")
163+
return res

src/reprostim/cli/entrypoint.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,8 @@ def main(ctx, log_level: str, log_format):
5656
logger.debug(f"main(...), command={ctx.invoked_subcommand}")
5757

5858

59-
from .cmd_bids_inject import bids_inject # noqa: E402
60-
6159
# Import all CLI commands
60+
from .cmd_bids_inject import bids_inject # noqa: E402
6261
from .cmd_detect_noscreen import detect_noscreen # noqa: E402
6362
from .cmd_echo import echo # noqa: E402
6463
from .cmd_list_displays import list_displays # noqa: E402

0 commit comments

Comments
 (0)