File size: 12,434 Bytes
6a1e48b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
"""Hugging Face Inference Endpoints custom handler: lead sheet image -> MusicXML with chord symbols.

Model: JuanCarlosMartinezSevilla/jazzmus-model (MIT): a YOLO staff detector, then the SMT
image-to-**kern model on each staff. The detector runs as ONNX on onnxruntime (MIT) with the
letterbox, box decoding and NMS below in numpy; the ultralytics package is not used. The
staff-cropping functions are copied from ../predict.py (2026-10-18 fixes: STAFF_PAD,
select_staff_boxes) and work on an in-memory image.

Errors are raised as EndpointError with a fixed short code. The Inference Toolkit turns any
exception from a handler into HTTP 400 with body {"error": str(exception)}; a handler cannot
choose the status code (see README.md).
"""
import base64
import binascii
import io
import logging
import os
import threading
from pathlib import Path

os.environ.setdefault("HF_HUB_OFFLINE", "1")  # load nothing from the network

import numpy as np  # noqa: E402
import onnxruntime as ort  # noqa: E402
import torch  # noqa: E402
from PIL import Image  # noqa: E402
from safetensors.torch import load_file  # noqa: E402
from torch.nn import Conv1d  # noqa: E402

from jazzmus_smt.configuration_smt import SMTConfig  # noqa: E402
from jazzmus_smt.modeling_smt import SMTModelForCausalLM  # noqa: E402
from kern2musicxml import kern_to_musicxml  # noqa: E402

log = logging.getLogger(__name__)

TORCH_THREADS = 2
MAX_BYTES = 760 * 1024
MAX_B64_CHARS = 4 * ((MAX_BYTES + 2) // 3)
MAX_SIDE = 6000  # refused above this
# Refused above this many pixels (6000 x 4000): a well-compressed page under MAX_BYTES can decode to
# hundreds of MB, so the bound is on pixels, not bytes or sides.
MAX_PIXELS = 24_000_000
# One decode at a time, whatever the toolkit's request concurrency: each holds up to a few hundred MB
# (four concurrent 6000 x 6000 decodes measured +1.5 GB on the endpoint's 4 GB).
DECODE_LOCK = threading.Lock()
WORK_SIDE = 3000  # larger images are scaled down before detection, as in app.py
MAX_STAVES = 12
CONTENT_TYPES = {"image/png": "PNG", "image/jpeg": "JPEG", "image/webp": "WEBP"}

# ---- YOLO staff detector on onnxruntime ------------------------------------------------------
# The same steps and defaults as ultralytics 8.4 predict on the .pt model: a rectangular letterbox
# (long side 640, padded to a multiple of 32 with grey 114), class-wise NMS at IoU 0.7 over boxes
# with confidence >= 0.25, at most 300, then boxes scaled back to the image.
DETECT_SIZE = 640
STRIDE = 32
DETECT_CONF = 0.25
NMS_IOU = 0.7
MAX_DET = 300
STAFF_CLASS = 12  # "staff" in the model's names (stored in the .onnx metadata)


def resize_linear(image: Image.Image, size: tuple[int, int]) -> Image.Image:
    """Bilinear resize without antialiasing, sampling at pixel centres as cv2.INTER_LINEAR does.

    The detector and the SMT model were used with cv2.resize; PIL's resize() antialiases when it
    shrinks, which moved staff boxes by up to 18 px. An affine transform samples like cv2.
    """
    if size == image.size:
        return image
    sx, sy = image.width / size[0], image.height / size[1]
    return image.transform(size, Image.AFFINE, (sx, 0, 0, 0, sy, 0), resample=Image.BILINEAR)


def letterbox(image: Image.Image) -> tuple[np.ndarray, tuple[float, float], tuple[int, int]]:
    """RGB image -> (1, 3, H, W) float32 input, (gain_x, gain_y), (pad_x, pad_y)."""
    w, h = image.size
    r = min(DETECT_SIZE / h, DETECT_SIZE / w)
    new_w, new_h = round(w * r), round(h * r)
    dw, dh = (DETECT_SIZE - new_w) % STRIDE / 2, (DETECT_SIZE - new_h) % STRIDE / 2
    top, bottom, left, right = round(dh - 0.1), round(dh + 0.1), round(dw - 0.1), round(dw + 0.1)
    canvas = Image.new("RGB", (new_w + left + right, new_h + top + bottom), (114, 114, 114))
    canvas.paste(resize_linear(image, (new_w, new_h)), (left, top))
    x = np.asarray(canvas, dtype=np.float32).transpose(2, 0, 1)[None] / 255.0
    return x, (new_w / w, new_h / h), (left, top)


def nms(boxes: np.ndarray, scores: np.ndarray, iou: float) -> list[int]:
    order, keep = scores.argsort()[::-1], []
    areas = (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1])
    while order.size:
        i, rest = order[0], order[1:]
        keep.append(int(i))
        xx1 = np.maximum(boxes[i, 0], boxes[rest, 0])
        yy1 = np.maximum(boxes[i, 1], boxes[rest, 1])
        xx2 = np.minimum(boxes[i, 2], boxes[rest, 2])
        yy2 = np.minimum(boxes[i, 3], boxes[rest, 3])
        inter = np.clip(xx2 - xx1, 0, None) * np.clip(yy2 - yy1, 0, None)
        order = rest[inter / (areas[i] + areas[rest] - inter + 1e-9) <= iou]
    return keep


def detect(session: ort.InferenceSession, image: Image.Image) -> list[tuple[float, int, tuple[float, float, float, float]]]:
    """All detections as (confidence, class, (x1, y1, x2, y2)) in image pixels."""
    x, (gx, gy), (px, py) = letterbox(image)
    pred = session.run(None, {"images": x})[0][0].T  # (anchors, 4 + classes): cx, cy, w, h, scores
    cls = pred[:, 4:].argmax(1)
    conf = pred[np.arange(len(pred)), 4 + cls]
    m = conf >= DETECT_CONF
    pred, cls, conf = pred[m], cls[m], conf[m]
    boxes = np.stack([pred[:, 0] - pred[:, 2] / 2, pred[:, 1] - pred[:, 3] / 2,
                      pred[:, 0] + pred[:, 2] / 2, pred[:, 1] + pred[:, 3] / 2], 1)
    keep = nms(boxes + cls[:, None] * 7680.0, conf, NMS_IOU)[:MAX_DET]  # offset by class: class-wise NMS
    w, h = image.size
    out = []
    for i in keep:
        x1, y1, x2, y2 = boxes[i]
        out.append((float(conf[i]), int(cls[i]), (
            float(np.clip((x1 - px) / gx, 0, w)), float(np.clip((y1 - py) / gy, 0, h)),
            float(np.clip((x2 - px) / gx, 0, w)), float(np.clip((y2 - py) / gy, 0, h)))))
    return out


# ---- copied from ../predict.py ------------------------------------------------------------
FIXED_IMG_HEIGHT = 128
MAX_IMG_WIDTH = 1000
STAFF_PAD = 0.30
MIN_CONFIDENCE = 0.5
MAX_OVERLAP = 0.5


def _vertical_overlap(a: tuple[int, int, int, int], b: tuple[int, int, int, int]) -> float:
    inter = min(a[3], b[3]) - max(a[1], b[1])
    return max(0, inter) / min(a[3] - a[1], b[3] - b[1])


def select_staff_boxes(detections: list[tuple[float, tuple[int, int, int, int]]]) -> list[tuple[int, int, int, int]]:
    """Keep confident boxes, best first, dropping any that overlap a kept one; return them top to bottom."""
    kept: list[tuple[int, int, int, int]] = []
    for conf, box in sorted(detections, key=lambda d: -d[0]):
        if conf < MIN_CONFIDENCE:
            continue
        if any(_vertical_overlap(box, k) > MAX_OVERLAP for k in kept):
            continue
        kept.append(box)
    return sorted(kept, key=lambda b: b[1])


def detect_staves(image: Image.Image, session: ort.InferenceSession) -> list[Image.Image]:
    """predict.detect_staves on an RGB PIL image, with the ONNX detector; returns greyscale crops."""
    gray = image.convert("L")
    detections = [(conf, tuple(map(int, box))) for conf, cls, box in detect(session, image) if cls == STAFF_CLASS]

    crops = []
    for x1, y1, x2, y2 in select_staff_boxes(detections)[:MAX_STAVES]:
        pad = int(round(STAFF_PAD * (y2 - y1)))
        crops.append(gray.crop((x1, max(0, y1 - pad), x2, min(gray.height, y2 + pad))))
    return crops


def preprocess_staff(img: Image.Image) -> torch.Tensor:
    width = int(np.ceil(img.width * FIXED_IMG_HEIGHT / img.height))
    width = min(width, MAX_IMG_WIDTH)
    img = resize_linear(img, (width, FIXED_IMG_HEIGHT))
    # jazzmus convert_img_to_tensor: greyscale, (1, H, W) float in [0, 1]
    return torch.from_numpy(np.asarray(img, dtype=np.float32) / 255.0)[None]


@torch.no_grad()
def transcribe_staff(model: SMTModelForCausalLM, image_tensor: torch.Tensor) -> str:
    predicted_sequence, _ = model.predict(input=image_tensor, convert_to_str=True)
    # jazzmus.dataset.tokenizer.untokenize
    return (
        "".join(predicted_sequence).replace("<t>", "\t").replace("<n>", "\n").replace("<s>", " ")
        .replace("<chord-pitch>", "").replace("<chord-extension>", "")
    )


def _sync_pe_device(model: SMTModelForCausalLM, device: torch.device):
    model.positional_2D.pe = model.positional_2D.pe.to(device)
    model.decoder.positional_1D.pe = model.decoder.positional_1D.pe.to(device)
# ---- end of copy ----------------------------------------------------------------------------


class EndpointError(ValueError):
    """A client-facing failure; str() is only the fixed code, never request content."""


MAGIC = {
    "image/png": lambda b: b.startswith(b"\x89PNG\r\n\x1a\n"),
    "image/jpeg": lambda b: b.startswith(b"\xff\xd8\xff"),
    "image/webp": lambda b: len(b) >= 12 and b[:4] == b"RIFF" and b[8:12] == b"WEBP",
}


def decode_image(data) -> Image.Image:
    """Validate the request body and return the image as RGB; raise EndpointError with a fixed code."""
    if not isinstance(data, dict):
        raise EndpointError("invalid_request")
    inputs = data.get("inputs")
    params = data.get("parameters") or {}
    if not isinstance(inputs, str) or not isinstance(params, dict):
        raise EndpointError("invalid_request")
    content_type = params.get("contentType")
    if content_type not in CONTENT_TYPES:
        raise EndpointError("unsupported_content_type")
    if len(inputs) > MAX_B64_CHARS:
        raise EndpointError("image_too_large")
    try:
        raw = base64.b64decode(inputs, validate=True)
    except (binascii.Error, ValueError):
        raise EndpointError("invalid_base64") from None
    if len(raw) > MAX_BYTES:
        raise EndpointError("image_too_large")
    if not MAGIC[content_type](raw):
        raise EndpointError("content_type_mismatch")
    with DECODE_LOCK:
        try:
            with Image.open(io.BytesIO(raw), formats=[CONTENT_TYPES[content_type]]) as im:
                # The header's size, checked before any pixel is decoded.
                if max(im.size) > MAX_SIDE or im.width * im.height > MAX_PIXELS:
                    raise EndpointError("image_dimensions_too_large")
                im.load()
                rgba = im.convert("RGBA")
        except EndpointError:
            raise
        except Exception:
            raise EndpointError("invalid_image") from None
        # Transparent areas become white paper, not black.
        image = Image.new("RGB", rgba.size, "white")
        image.paste(rgba, mask=rgba.getchannel("A"))
        del rgba
        image.thumbnail((WORK_SIDE, WORK_SIDE))
        return image


class EndpointHandler:
    def __init__(self, path: str = ""):
        torch.set_num_threads(TORCH_THREADS)
        model_dir = Path(path)
        self.device = torch.device("cpu")
        opts = ort.SessionOptions()
        opts.intra_op_num_threads = TORCH_THREADS
        opts.inter_op_num_threads = 1
        self.detector = ort.InferenceSession(
            str(model_dir / "yolo_staff_detector.onnx"), opts, providers=["CPUExecutionProvider"]
        )

        config = SMTConfig.from_pretrained(str(model_dir))
        sd = load_file(str(model_dir / "model.safetensors"))
        embed_size = sd["decoder.embedding.weight"].shape[0]
        out_size = sd["decoder.out_layer.weight"].shape[0]
        orig_out = config.out_categories
        config.out_categories = embed_size
        model = SMTModelForCausalLM(config)
        if embed_size != out_size:
            model.decoder.out_layer = Conv1d(config.d_model, out_size, kernel_size=1)
        model.load_state_dict(sd, strict=True)
        config.out_categories = orig_out
        model.to(self.device)
        _sync_pe_device(model, self.device)
        model.eval()
        self.smt = model
        self.lock = threading.Lock()  # one page at a time on the two cores

    def __call__(self, data: dict) -> dict:
        image = decode_image(data)
        try:
            with self.lock:
                staves = detect_staves(image, self.detector)
                if not staves:
                    raise EndpointError("no_staff_found")
                kerns = [transcribe_staff(self.smt, preprocess_staff(s).to(self.device)) for s in staves]
            musicxml, _ = kern_to_musicxml(kerns)
        except EndpointError:
            raise
        except Exception:
            log.exception("transcription failed")  # the traceback holds no request content
            raise EndpointError("transcription_failed") from None
        return {"musicxml": musicxml, "staves": len(staves)}