Replace sheet reading with a fingering editor.
AppImage release / appimage (push) Successful in 53s

Drag Baroque charts onto a line, reorder them, set a duration, or remove a note. The AppImage no longer packages oemer, ONNX, or the models. Version 0.2.0.

Co-authored-by: catpersonisme <catpersonisme@gmail.com>
This commit is contained in:
Cursor Agentandchen committed 2026-10-11 09:06:33 +00:00
1 parent 229a8d4723
commit b354ea9f88
22 files changed
+569 -2231

No files matched your search

+4 -100
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# One-command AppImage build. Needs network the first time (Python, models, Electron).
# One-command AppImage of the fingering editor. No Python, oemer, or ONNX models.
set -euo pipefail
cd "$(dirname "$0")/.."
ROOT="$(pwd)"
@@ -7,113 +7,17 @@ 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 =="
if [[ ! -f "$TARBALL" ]]; then
curl -fL --retry 3 -o "$TARBALL" "$URL"
fi
rm -rf "$ROOT/build/python"
tar -xzf "$TARBALL" -C "$ROOT/build"
"$PY" -m pip install --upgrade pip
"$PY" -m pip install \
"opencv-python-headless>=4.10,<5" \
"onnxruntime>=1.20" \
numpy scipy scikit-learn pillow matplotlib typing_extensions
"$PY" -m pip install --no-deps oemer
fi
SITE="$("$PY" -c "import oemer, pathlib; print(pathlib.Path(oemer.__file__).parent)")"
mkdir -p "$SITE/checkpoints/unet_big" "$SITE/checkpoints/seg_net"
if [[ ! -f "$SITE/checkpoints/unet_big/model.onnx" ]]; then
curl -fL --retry 3 -o "$SITE/checkpoints/unet_big/model.onnx" \
"https://github.com/BreezeWhite/oemer/releases/download/checkpoints/1st_model.onnx"
fi
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 =="
rm -rf \
"$ROOT/build/python/lib/python3.12/test" \
"$ROOT/build/python/lib/python3.12/ensurepip" \
"$ROOT/build/python/lib/python3.12/idlelib" \
"$ROOT/build/python/lib/python3.12/turtledemo" \
"$ROOT/build/python/lib/python3.12/site-packages/pip" \
"$ROOT/build/python/lib/python3.12/site-packages/pip-"*.dist-info \
"$ROOT/build/python/lib/tcl9.0" \
"$ROOT/build/python/lib/tcl9" \
"$ROOT/build/python/lib/tk9.0" \
"$ROOT/build/python/lib/itcl4.3.8" \
"$ROOT/build/python/lib/libtcl9tk9.0.so" \
"$ROOT/build/python/lib/libtcl9.0.so" \
"$SITE/checkpoints/unet_big/weights.h5" \
"$SITE/checkpoints/seg_net/weights.h5" || true
find "$ROOT/build/python" -type d -name __pycache__ -prune -exec rm -rf {} + || true
find "$ROOT/build/python" -type d -name tests -path '*/site-packages/*' -prune -exec rm -rf {} + || true
find "$ROOT/build/python" -name '*.a' -delete || true
# Leave the CPython binary and libpython intact. Stripping those breaks the interpreter.
find "$ROOT/build/python/lib/python3.12/site-packages" -type f -name '*.so' \
-exec strip --strip-unneeded {} + || true
if [[ ! -f "$ROOT/electron/icon.png" ]]; then
"$PY" - <<'PY'
from PIL import Image, ImageDraw
image = Image.new("RGB", (512, 512), "#f3ecdf")
draw = ImageDraw.Draw(image)
draw.rounded_rectangle((206, 36, 306, 470), radius=48, fill="#e4cfae", outline="#6a4328", width=8)
draw.ellipse((156, 92, 214, 150), outline="#6a4328", width=6)
for y in (160, 210, 260, 330, 390, 450):
draw.ellipse((228, y, 284, y + 48), fill="#1c3830")
image.save("electron/icon.png")
PY
echo "electron/icon.png is missing." >&2
exit 1
fi
echo "== Build the Next.js server =="
npm run build
rm -rf .next/standalone/public .next/standalone/.next/static .next/standalone/scripts
rm -rf .next/standalone/public .next/standalone/.next/static
mkdir -p .next/standalone/.next
cp -a public .next/standalone/public
cp -a .next/static .next/standalone/.next/static
cp -a scripts .next/standalone/scripts
echo "== Package the AppImage =="
export CSC_IDENTITY_AUTO_DISCOVERY=false
-115
View File
@@ -1,115 +0,0 @@
#!/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())
-84
View File
@@ -1,84 +0,0 @@
#!/usr/bin/env python3
"""Read one sheet-music image with oemer and print the MusicXML path.
oemer asks ONNX Runtime for CUDA first. This wrapper forces the CPU provider
so the AppImage does not need a GPU.
"""
from __future__ import annotations
import os
import sys
import traceback
def force_cpu() -> None:
import onnxruntime as rt
original = rt.InferenceSession
def cpu_session(model, providers=None, **kwargs): # type: ignore[no-untyped-def]
kwargs.pop("providers", None)
return original(model, providers=["CPUExecutionProvider"], **kwargs)
rt.InferenceSession = cpu_session # type: ignore[method-assign]
def prepare_png(src: str, dest: str) -> None:
from PIL import Image, ImageOps
with Image.open(src) as image:
rgb = ImageOps.exif_transpose(image).convert("RGB")
if min(rgb.size) < 80:
raise ValueError("This image is too small to read as sheet music.")
rgb.thumbnail((2200, 2200))
rgb.save(dest, "PNG")
def recognize(png: str, out_dir: str) -> str:
from argparse import Namespace
from oemer.ete import clear_data, extract
def once(deskew: bool) -> str:
clear_data()
args = Namespace(
img_path=png,
output_path=out_dir,
use_tf=False,
save_cache=False,
without_deskew=not deskew,
)
return extract(args)
try:
return once(True)
except (Exception, SystemExit):
traceback.print_exc()
return once(False)
def main() -> int:
if len(sys.argv) != 3:
print("usage: run_oemer.py IMAGE OUT_DIR", file=sys.stderr)
return 2
src, out_dir = sys.argv[1], sys.argv[2]
os.makedirs(out_dir, exist_ok=True)
png = os.path.join(out_dir, "page.png")
try:
prepare_png(src, png)
force_cpu()
musicxml = recognize(png, out_dir)
except Exception as exc: # noqa: BLE001 — surface a single readable failure
print(f"{exc.__class__.__name__}: {exc}", file=sys.stderr)
traceback.print_exc()
return 1
if not musicxml or not os.path.isfile(musicxml):
print("oemer did not write MusicXML", file=sys.stderr)
return 1
print(f"MUSICXML={musicxml}")
return 0
if __name__ == "__main__":
raise SystemExit(main())