File size: 8,198 Bytes
78a58c7
 
 
84a7074
78a58c7
 
 
 
84a7074
78a58c7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5558a7b
 
78a58c7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5558a7b
78a58c7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5558a7b
78a58c7
5558a7b
 
 
 
 
78a58c7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""PLUS ensemble: слияние нескольких транскрипций голосованием.

Один проход ошибается по-разному: MuScriptor теряет размытые ноты и добавляет
фантомы, Aria путает октавы, третий движок молчит на группе. Слияние берёт то, в чём
сошлись РАЗНЫЕ движки: кластер по питчу и онсету, выживает при сумме весов
источников >= порога.

CLI:
  python plus_ensemble.py --inputs m.mid aria.mid other.mid --out merged.mid \
      --tolerance 0.06 --min-votes 2 --reference truth.mid
"""
from __future__ import annotations

import argparse
import json
import os
import sys

import numpy as np

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from plus_core import Note, Piece, dump_json, load_midi, save_midi, step  # noqa: E402

__version__ = "1.0"


def _cluster(notes: list[Note], tolerance: float) -> list[list[int]]:
    """Индексы нот, сгруппированные по (питч, онсет в пределах допуска)."""
    order = sorted(range(len(notes)), key=lambda i: (notes[i].pitch, notes[i].start))
    clusters: list[list[int]] = []
    for index in order:
        note = notes[index]
        placed = False
        for cluster in reversed(clusters[-6:]):
            head = notes[cluster[0]]
            if head.pitch != note.pitch:
                continue
            if abs(head.start - note.start) <= tolerance:
                cluster.append(index)
                placed = True
                break
        if not placed:
            clusters.append([index])
    return clusters


def merge(sources: list[tuple[str, list[Note], float]], tolerance: float,
          min_votes: float, keep_strong: bool, audio: str | None = None,
          tempo: float | None = None) -> tuple[Piece, dict]:
    """Сливает источники и возвращает Piece плюс статистику согласия."""
    flattened: list[Note] = []
    owner: list[str] = []
    for name, notes, _weight in sources:
        for note in notes:
            flattened.append(note)
            owner.append(name)
    if not flattened:
        return Piece(), {"notes": 0}

    weight_of = {name: weight for name, _notes, weight in sources}
    clusters = _cluster(flattened, tolerance)
    merged: list[Note] = []
    rejected = 0
    agreements: list[int] = []
    for cluster in clusters:
        per_source: dict[str, list[Note]] = {}
        for index in cluster:
            per_source.setdefault(owner[index], []).append(flattened[index])
        votes = sum(weight_of[name] for name in per_source)
        airs = [n for notes_ in per_source.values() for n in notes_]
        strong = max((n.velocity for n in airs), default=0) >= 100 and max(
            (n.dur for n in airs), default=0) >= 0.08
        if votes < min_votes and not (keep_strong and strong):
            rejected += 1
            continue
        agreements.append(len(per_source))
        starts = sorted(n.start for n in airs)
        durs = sorted(n.dur for n in airs)
        velocities = sorted(n.velocity for n in airs)
        merged.append(Note(
            start=float(np.median(starts)),
            dur=float(np.median(durs)),
            pitch=int(sorted(n.pitch for n in airs)[len(airs) // 2]),
            velocity=int(np.median(velocities)),
            channel=0,
            track=airs[0].track,
        ))
    merged.sort(key=lambda n: (n.start, n.pitch))
    piece = Piece(merged, [(0.0, tempo if tempo else 120.0)])
    piece.meta["sources"] = [name for name, _n, _w in sources]
    stats = {
        "clusters": len(clusters), "kept": len(merged), "rejected": rejected,
        "agreement_mean": round(float(np.mean(agreements)), 2) if agreements else 0.0,
        "agreement_histogram": {str(k): agreements.count(k) for k in sorted(set(agreements))},
        "per_source": {name: len(notes) for name, notes, _w in sources},
    }
    return piece, stats


def compare(reference: list[Note], candidate: list[Note], tolerance: float = 0.05) -> dict:
    """Точность/полнота/F1 по совпадению питча и онсета."""
    used = set()
    hits = 0
    errors = []
    for ref in sorted(reference, key=lambda n: n.start):
        best, best_gap = None, 1e9
        for index, cand in enumerate(candidate):
            if index in used or cand.pitch != ref.pitch:
                continue
            gap = abs(cand.start - ref.start)
            if gap < best_gap:
                best, best_gap = index, gap
        if best is not None and best_gap <= tolerance:
            used.add(best)
            hits += 1
            errors.append(best_gap)
    precision = hits / max(1, len(candidate))
    recall = hits / max(1, len(reference))
    f1 = 2 * precision * recall / max(1e-9, precision + recall)
    # numpy-скаляры не сериализуются в JSON, поэтому всё приводим к питоновским
    return {
        "precision": float(round(float(precision), 3)),
        "recall": float(round(float(recall), 3)),
        "f1": float(round(float(f1), 3)),
        "onset_error_ms": float(round(1000 * float(np.mean(errors)), 1)) if errors else None,
        "reference": int(len(reference)), "candidate": int(len(candidate)),
    }


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="PLUS ensemble: слияние транскрипций")
    parser.add_argument("--inputs", nargs="+", required=True, help="MIDI от разных движков/проходов")
    parser.add_argument("--weights", nargs="*", type=float, help="вес на каждый вход")
    parser.add_argument("--out", required=True)
    parser.add_argument("--tolerance", type=float, default=0.06)
    parser.add_argument("--min-votes", type=float, default=2.0)
    parser.add_argument("--keep-strong", action="store_true", help="спасать одиночные громкие ноты")
    parser.add_argument("--reference", help="эталонный MIDI для оценки качества")
    parser.add_argument("--report")
    args = parser.parse_args(argv)

    weights = args.weights or [1.0] * len(args.inputs)
    if len(weights) != len(args.inputs):
        raise SystemExit("весов должно быть столько же, сколько входов")

    sources = []
    for index, path in enumerate(args.inputs, 1):
        piece = load_midi(path)
        name = os.path.splitext(os.path.basename(path))[0]
        step(index, len(args.inputs) + 2, "%s: %d нот" % (name, len(piece.notes)))
        sources.append((name, piece.notes, float(weights[index - 1])))

    piece, stats = merge(sources, args.tolerance, args.min_votes, args.keep_strong)
    step(len(args.inputs) + 1, len(args.inputs) + 2,
         "слияние: %d нот из %d кластеров" % (len(piece.notes), stats.get("clusters", 0)))
    save_midi(piece, args.out)

    report = {"version": __version__, "stats": stats, "tolerance": args.tolerance,
              "min_votes": args.min_votes, "keep_strong": args.keep_strong}
    if args.reference:
        reference = load_midi(args.reference).notes
        report["versus_reference"] = compare(reference, piece.notes)
        for name, notes, _weight in sources:
            report.setdefault("sources_versus_reference", {})[name] = compare(reference, notes)
    dump_json(report, args.report or os.path.splitext(args.out)[0] + "_ensemble.json")
    step(len(args.inputs) + 2, len(args.inputs) + 2, "готово: " + args.out)

    if args.reference:
        merged_f1 = report["versus_reference"]["f1"]
        best_source = max((entry["f1"], name) for name, entry
                          in report["sources_versus_reference"].items())
        print("      F1 слияния %.3f против лучшего входа %.3f (%s)"
              % (merged_f1, best_source[0], best_source[1]), flush=True)
    return 0


if __name__ == "__main__":
    sys.exit(main())