From 6da116518f45127f3e8f5aecb6ce443cd00a8972 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 11 Oct 2026 07:51:24 +0000 Subject: [PATCH] Rewrite oemer U-Net ConvTranspose pads before packaging. ONNX Runtime 1.30+ rejects pads [0, 0, -1, -1] on the unet checkpoint. The build now rewrites those nodes to pads [0, 0, 0, 0] and output_padding [1, 1], which keeps the same output size. Version 0.1.1. --- README.md | 10 ++-- package-lock.json | 4 +- package.json | 2 +- scripts/build-appimage.sh | 39 +++++++++++- scripts/patch_oemer_unet.py | 115 ++++++++++++++++++++++++++++++++++++ 5 files changed, 160 insertions(+), 10 deletions(-) create mode 100644 scripts/patch_oemer_unet.py diff --git a/README.md b/README.md index f57cfb8..9e98649 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,14 @@ The usual way to run it is one Linux AppImage. That file contains the window, th ## Run the AppImage ```bash -chmod +x Recorder-Tab-Converter-0.1.0.AppImage -./Recorder-Tab-Converter-0.1.0.AppImage +chmod +x Recorder-Tab-Converter-0.1.1.AppImage +./Recorder-Tab-Converter-0.1.1.AppImage ``` If the file will not start because FUSE is missing: ```bash -APPIMAGE_EXTRACT_AND_RUN=1 ./Recorder-Tab-Converter-0.1.0.AppImage +APPIMAGE_EXTRACT_AND_RUN=1 ./Recorder-Tab-Converter-0.1.1.AppImage ``` Open **Twinkle, Twinkle, Little Star** or **Ode to Joy** to see tabs immediately. Or drop a PNG, JPG, or WEBP of a page. The note list under the tab is editable. Fix a bad pitch or duration and the fingerings update. If a photo cannot be read, the app says so and does not invent notes. @@ -30,9 +30,11 @@ From a checkout, one command: That installs Node packages, downloads a relocatable Python plus oemer and its ONNX models, builds the app, and writes: ```text -dist/Recorder-Tab-Converter-0.1.0.AppImage +dist/Recorder-Tab-Converter-0.1.1.AppImage ``` +The U-Net checkpoint from oemer 0.1.8 has ConvTranspose pads `[0, 0, -1, -1]`. The build rewrites those nodes to pads `[0, 0, 0, 0]` and `output_padding` `[1, 1]` before packaging. That is the same output size, and it is what current ONNX Runtime will load. The rewrite does not change fingerings. + The build needs network access the first time. The AppImage is not committed; keep it under 600MB. ## Develop in a browser diff --git a/package-lock.json b/package-lock.json index 4bcee40..c50ce3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "recorder-tab-converter", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "recorder-tab-converter", - "version": "0.1.0", + "version": "0.1.1", "dependencies": { "@base-ui/react": "^1.8.0", "class-variance-authority": "^0.7.1", diff --git a/package.json b/package.json index 5be34b0..d939f4b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "recorder-tab-converter", - "version": "0.1.0", + "version": "0.1.1", "private": true, "description": "Turn a sheet-music image into soprano recorder fingering tabs, with rhythm kept.", "main": "electron/main.cjs", diff --git a/scripts/build-appimage.sh b/scripts/build-appimage.sh index 9c16342..f5e6fe5 100755 --- a/scripts/build-appimage.sh +++ b/scripts/build-appimage.sh @@ -7,12 +7,13 @@ ROOT="$(pwd)" echo "== Install Node packages ==" npm install +mkdir -p "$ROOT/build/cache" +TARBALL="$ROOT/build/cache/cpython.tar.gz" +URL="https://github.com/astral-sh/python-build-standalone/releases/download/20261003/cpython-3.12.15%2B20261003-x86_64-unknown-linux-gnu-install_only.tar.gz" + PY="$ROOT/build/python/bin/python3" if ! "$PY" -c "import oemer.ete, onnxruntime" >/dev/null 2>&1; then echo "== Download a relocatable Python and oemer ==" - mkdir -p "$ROOT/build/cache" - TARBALL="$ROOT/build/cache/cpython.tar.gz" - URL="https://github.com/astral-sh/python-build-standalone/releases/download/20261003/cpython-3.12.15%2B20261003-x86_64-unknown-linux-gnu-install_only.tar.gz" if [[ ! -f "$TARBALL" ]]; then curl -fL --retry 3 -o "$TARBALL" "$URL" fi @@ -36,6 +37,38 @@ if [[ ! -f "$SITE/checkpoints/seg_net/model.onnx" ]]; then curl -fL --retry 3 -o "$SITE/checkpoints/seg_net/model.onnx" \ "https://github.com/BreezeWhite/oemer/releases/download/checkpoints/2nd_model.onnx" fi + +echo "== Rewrite U-Net ConvTranspose pads ==" +# oemer 0.1.8's unet has ConvTranspose pads [0, 0, -1, -1]. ONNX Runtime +# 1.30+ rejects those. The rewrite keeps the same output size. onnx itself +# is not packaged. +if ! "$PY" -m pip --version >/dev/null 2>&1; then + if [[ ! -f "$TARBALL" ]]; then + echo "pip was removed and the Python tarball is missing, so the U-Net cannot be rewritten." >&2 + exit 1 + fi + PIP_RESTORE="$ROOT/build/cache/pip-restore" + rm -rf "$PIP_RESTORE" + mkdir -p "$PIP_RESTORE" + tar -xzf "$TARBALL" -C "$PIP_RESTORE" --wildcards \ + 'python/lib/python3.12/site-packages/pip' \ + 'python/lib/python3.12/site-packages/pip-*.dist-info' + rm -rf "$ROOT/build/python/lib/python3.12/site-packages/pip" \ + "$ROOT/build/python/lib/python3.12/site-packages/pip-"*.dist-info + cp -a "$PIP_RESTORE/python/lib/python3.12/site-packages/pip" \ + "$ROOT/build/python/lib/python3.12/site-packages/" + cp -a "$PIP_RESTORE"/python/lib/python3.12/site-packages/pip-*.dist-info \ + "$ROOT/build/python/lib/python3.12/site-packages/" + rm -rf "$PIP_RESTORE" +fi +PATCH_PY="$ROOT/build/cache/onnx-patch" +rm -rf "$PATCH_PY" +"$PY" -m pip install --disable-pip-version-check --target "$PATCH_PY" 'onnx>=1.16,<2' +PYTHONPATH="$PATCH_PY${PYTHONPATH:+:$PYTHONPATH}" \ + "$PY" "$ROOT/scripts/patch_oemer_unet.py" \ + "$SITE/checkpoints/unet_big/model.onnx" +rm -rf "$PATCH_PY" + MPLBACKEND=Agg "$PY" -c "import oemer.ete, cv2; from cv2.typing import RotatedRect; print('oemer', cv2.__version__)" echo "== Trim the Python prefix ==" diff --git a/scripts/patch_oemer_unet.py b/scripts/patch_oemer_unet.py new file mode 100644 index 0000000..238a488 --- /dev/null +++ b/scripts/patch_oemer_unet.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Rewrite oemer U-Net ConvTranspose pads so ONNX Runtime can load the model. + +oemer 0.1.8's 1st_model.onnx ships ConvTranspose nodes with pads +[0, 0, -1, -1]. ONNX Runtime 1.30 and newer reject those during shape +inference. pads [0, 0, 0, 0] with output_padding [1, 1] is the same +output size for these stride-2, kernel-1 nodes. Weights are left as they are. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import onnx +from onnx import helper + +BAD_PADS = [0, 0, -1, -1] +FIXED_PADS = [0, 0, 0, 0] +FIXED_OUTPUT_PADDING = [1, 1] +CRASH_NODE = "model/conv2d_transpose_1/conv2d_transpose" + + +def _attr(node: onnx.NodeProto, name: str) -> onnx.AttributeProto | None: + for attr in node.attribute: + if attr.name == name: + return attr + return None + + +def _ints(node: onnx.NodeProto, name: str) -> list[int] | None: + attr = _attr(node, name) + if attr is None: + return None + return list(attr.ints) + + +def _set_ints(attr: onnx.AttributeProto, values: list[int]) -> None: + del attr.ints[:] + attr.ints.extend(values) + + +def rewrite(model_path: Path) -> int: + model = onnx.load(model_path) + changed = 0 + for node in model.graph.node: + if node.op_type != "ConvTranspose": + continue + pads = _ints(node, "pads") + if pads is None or pads == FIXED_PADS: + continue + if pads != BAD_PADS: + if any(value < 0 for value in pads): + raise SystemExit( + f"{model_path}: {node.name} has unexpected negative pads {pads}" + ) + continue + pads_attr = _attr(node, "pads") + if pads_attr is None: + raise SystemExit(f"{model_path}: {node.name} is missing pads") + _set_ints(pads_attr, FIXED_PADS) + output_padding = _attr(node, "output_padding") + if output_padding is None: + node.attribute.append( + helper.make_attribute("output_padding", FIXED_OUTPUT_PADDING) + ) + else: + _set_ints(output_padding, FIXED_OUTPUT_PADDING) + changed += 1 + print( + f"rewrote {node.name}: pads {FIXED_PADS} output_padding {FIXED_OUTPUT_PADDING}" + ) + if changed: + onnx.save(model, model_path) + verify(model_path) + print(f"rewrote {changed} ConvTranspose node(s) in {model_path}") + return changed + + +def verify(model_path: Path) -> None: + model = onnx.load(model_path) + found = False + for node in model.graph.node: + if node.op_type != "ConvTranspose": + continue + pads = _ints(node, "pads") or [] + if any(value < 0 for value in pads): + raise SystemExit(f"{model_path}: {node.name} still has negative pads {pads}") + if node.name != CRASH_NODE: + continue + found = True + output_padding = _ints(node, "output_padding") + if pads != FIXED_PADS or output_padding != FIXED_OUTPUT_PADDING: + raise SystemExit( + f"{model_path}: {node.name} is {pads} / {output_padding}, " + f"expected {FIXED_PADS} / {FIXED_OUTPUT_PADDING}" + ) + if not found: + raise SystemExit(f"{model_path}: missing {CRASH_NODE}") + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: patch_oemer_unet.py MODEL.onnx", file=sys.stderr) + return 2 + path = Path(sys.argv[1]) + if not path.is_file(): + print(f"missing model: {path}", file=sys.stderr) + return 1 + rewrite(path) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())