DExter / vendor /prepare_data.py
LING
Deploy DExter Space
f55264b
Raw History Blame Contribute Delete
23.7 kB
"""Compute performance / score / perceptual codecs from aligned datasets.
Produces a single HDF5 file under ``data_root`` (one group per piece, segmented
to ``MAX_NOTE_LEN``) plus per-segment ``snote_ids`` ``.npy`` files.
Path configuration precedence: CLI flags > environment variables > config/paths.yaml.
"""
from __future__ import annotations
import argparse
import glob
import json
import logging
import os
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Optional
import h5py
import numpy as np
import numpy.lib.recfunctions as rfn
import pandas as pd
import partitura as pt
from omegaconf import OmegaConf
from tqdm import tqdm
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Path resolution
# ---------------------------------------------------------------------------
DEFAULT_PATHS_YAML = Path(__file__).parent / "config" / "paths.yaml"
@dataclass
class DatasetPaths:
data_root: Path # where outputs (hdf5, snote_ids) are written
datasets_root: Path # parent of source corpora
atepp_root: Path
atepp_meta: Path
asap_root: Path
vienna_match_dir: Path
vienna_musicxml_dir: Path
vienna_cep_dir: Path
def load_paths(
config_path: Optional[Path] = None,
data_root: Optional[str] = None,
datasets_root: Optional[str] = None,
) -> DatasetPaths:
"""Resolve dataset paths from yaml + environment + CLI overrides."""
cfg = OmegaConf.load(config_path or DEFAULT_PATHS_YAML)
resolved_data_root = Path(
data_root
or os.environ.get("DEXTER_DATA_ROOT")
or cfg.data_root
).expanduser().resolve()
resolved_datasets_root = Path(
datasets_root
or os.environ.get("DEXTER_DATASETS_ROOT")
or cfg.datasets_root
).expanduser().resolve()
return DatasetPaths(
data_root=resolved_data_root,
datasets_root=resolved_datasets_root,
atepp_root=resolved_datasets_root / cfg.datasets.ATEPP.root,
atepp_meta=resolved_datasets_root / cfg.datasets.ATEPP.metadata,
asap_root=resolved_datasets_root / cfg.datasets.ASAP.root,
vienna_match_dir=resolved_datasets_root / cfg.datasets.VIENNA422.match_dir,
vienna_musicxml_dir=resolved_datasets_root / cfg.datasets.VIENNA422.musicxml_dir,
vienna_cep_dir=resolved_datasets_root / cfg.datasets.VIENNA422.cep_dir,
)
# ---------------------------------------------------------------------------
# Per-dataset path discovery
# ---------------------------------------------------------------------------
@dataclass
class PieceTuple:
score_path: Path
performance_path: Optional[Path] # None for VIENNA422 (alignment carries the perf)
alignment_path: Path
cep_path: Path
piece_name: str
def discover_vienna(paths: DatasetPaths) -> list[PieceTuple]:
alignment_paths = sorted(glob.glob(str(paths.vienna_match_dir / "*[!e].match")))
out = []
for a in alignment_paths:
a_p = Path(a)
stem = a_p.name[:-10] # strip "_pNN.match"
out.append(PieceTuple(
score_path=paths.vienna_musicxml_dir / f"{stem}.musicxml",
performance_path=None,
alignment_path=a_p,
cep_path=paths.vienna_cep_dir / f"{a_p.stem}.csv",
piece_name=stem,
))
return out
def discover_asap(paths: DatasetPaths) -> list[PieceTuple]:
perf_paths = sorted(glob.glob(str(paths.asap_root / "**/*[!e].mid"), recursive=True))
out = []
for p in perf_paths:
p_p = Path(p)
out.append(PieceTuple(
score_path=p_p.parent / "xml_score.musicxml",
performance_path=p_p,
alignment_path=p_p.parent / f"{p_p.stem}_note_alignments" / "note_alignment.tsv",
cep_path=p_p.parent / f"{p_p.stem}_cep_features.csv",
piece_name=str(p_p.relative_to(paths.asap_root).parent).replace("/", "_"),
))
return out
def discover_atepp(paths: DatasetPaths) -> list[PieceTuple]:
"""ATEPP layout: <piece_dir>/<seg_id>/align.csv with perf at <piece_dir>/<seg_id>.mid."""
alignment_paths = sorted(glob.glob(str(paths.atepp_root / "**/[!z]*n.csv"), recursive=True))
out = []
for a in alignment_paths:
a_p = Path(a)
seg_id = a_p.parent.name
piece_dir = a_p.parent.parent
perf = piece_dir / f"{seg_id}.mid"
score_candidates = glob.glob(str(piece_dir / "*.*l")) # .xml, .mxl, .musicxml
if not score_candidates:
continue
out.append(PieceTuple(
score_path=Path(score_candidates[0]),
performance_path=perf,
alignment_path=a_p,
cep_path=piece_dir / f"{seg_id}_cep_features.csv",
piece_name=seg_id,
))
return out
DISCOVERERS = {
"VIENNA422": discover_vienna,
"ASAP": discover_asap,
"ATEPP": discover_atepp,
}
def get_atepp_overlap(paths: DatasetPaths) -> set[str]:
"""ATEPP score folders with multiple performances (mixup-eligible)."""
meta = pd.read_csv(paths.atepp_meta)
score_groups = meta.groupby(["score_path"])["midi_path"].count()
return {
str(paths.atepp_root / Path(score_path).parent)
for score_path in score_groups.index
}
# ---------------------------------------------------------------------------
# Codec extraction (per piece)
# ---------------------------------------------------------------------------
def get_codecs(score_path, alignment_path, cep_path, performance_path=None, score=None):
"""Encode a single (score, performance, alignment) triple into p_codec / c_codec."""
score_path = str(score_path)
alignment_path = str(alignment_path)
cep_path = str(cep_path) if cep_path is not None else None
if alignment_path.endswith(".match"):
performance, alignment = pt.load_match(alignment_path)
elif alignment_path.endswith(".tsv"):
alignment = pt.io.importparangonada.load_alignment_from_ASAP(alignment_path)
elif alignment_path.endswith(".csv"): # ATEPP / parangonar
alignment = pt.io.importparangonada.load_parangonada_alignment(alignment_path)
if score is None:
score = pt.load_musicxml(score_path, force_note_ids="keep")
else:
raise ValueError(f"Unknown alignment format: {alignment_path}")
if score is None:
score = pt.load_musicxml(score_path)
# if the alignment has unfolded score IDs but the score doesn't, unfold the score
if (("score_id" in alignment[0])
and ("-" in alignment[0]["score_id"])
and ("-" not in score.note_array(include_divs_per_quarter=False)["id"][0])):
score = pt.score.unfold_part_maximal(pt.score.merge_parts(score.parts))
if performance_path is not None:
performance = pt.load_performance(str(performance_path))
parameters, snote_ids, _, m_score = pt.musicanalysis.encode_performance(
score, performance, alignment,
)
p_codec = rfn.structured_to_unstructured(parameters)
if cep_path and os.path.exists(cep_path):
cep_feats = pd.read_csv(cep_path)
c_codec = rfn.structured_to_unstructured(get_cep_codec(cep_feats, m_score))
else:
if cep_path:
logger.info("No cep_features for %s", alignment_path)
c_codec = np.zeros((len(p_codec), 7))
return p_codec, c_codec, snote_ids, score
def get_cep_codec(cep_feats: pd.DataFrame, m_score) -> np.ndarray:
"""Align perceptual features with the performance, return structured array."""
rows = []
for note in m_score:
window = cep_feats[cep_feats["frame_start_time"] <= note["p_onset"]]
if len(window):
rows.append(window.iloc[-1])
else:
rows.append(cep_feats.iloc[-1])
c_codec_df = pd.DataFrame(rows).drop(columns=["frame_start_time"])
records = c_codec_df.to_records(index=False)
return np.array(records, dtype=records.dtype.descr)
# ---------------------------------------------------------------------------
# Main processing loop
# ---------------------------------------------------------------------------
def hdf5_path(paths: DatasetPaths, max_note_len: int, mixup: bool) -> Path:
suffix = "_mixup" if mixup else ""
return paths.data_root / f"codec_N={max_note_len}{suffix}.hdf5"
def process_dataset_codec(
paths: DatasetPaths,
max_note_len: int,
mix_up: bool = False,
datasets: Iterable[str] = ("ATEPP", "ASAP", "VIENNA422"),
test_split_p: float = 0.15,
rng_seed: Optional[int] = None,
) -> Path:
"""Compute and persist codecs for the requested datasets.
Returns the path to the written HDF5 file.
Per-piece errors are logged to ``data_root / errors.jsonl``.
"""
rng = np.random.default_rng(rng_seed)
paths.data_root.mkdir(parents=True, exist_ok=True)
snote_dir = paths.data_root / f"snote_ids/N={max_note_len}"
snote_dir.mkdir(parents=True, exist_ok=True)
out_path = hdf5_path(paths, max_note_len, mix_up)
errors_path = paths.data_root / "errors.jsonl"
atepp_overlap = get_atepp_overlap(paths) if "ATEPP" in datasets else set()
with h5py.File(out_path, "a") as hdf5_file, errors_path.open("a") as err_log:
for dataset in datasets:
pieces = DISCOVERERS[dataset](paths)
logger.info("[%s] discovered %d pieces", dataset, len(pieces))
same_score_p, mixup_p = [], []
same_score_c, mixup_c = [], []
prev_score_path = None
split = "train"
for j, piece in enumerate(tqdm(pieces, desc=dataset)):
group_key = f"{dataset}_{j}"
if group_key in hdf5_file:
continue
if not (piece.score_path.exists() and piece.alignment_path.exists()):
err_log.write(json.dumps({
"dataset": dataset, "idx": j, "reason": "missing_files",
"score": str(piece.score_path), "alignment": str(piece.alignment_path),
}) + "\n")
continue
# ATEPP-only: drop alignments with <50% match
if dataset == "ATEPP":
try:
alignment = pt.io.importparangonada.load_parangonada_alignment(str(piece.alignment_path))
except Exception as e:
err_log.write(json.dumps({
"dataset": dataset, "idx": j, "reason": "alignment_load_error",
"alignment": str(piece.alignment_path), "error": str(e),
}) + "\n")
continue
counts = {label: 0 for label in ("match", "insertion", "deletion")}
for a in alignment:
if a["label"] in counts:
counts[a["label"]] += 1
total = sum(counts.values())
if total == 0 or counts["match"] / total < 0.5:
err_log.write(json.dumps({
"dataset": dataset, "idx": j, "reason": "low_alignment_match_ratio",
"ratio": counts["match"] / total if total else 0.0,
}) + "\n")
continue
reuse_score = (prev_score_path == piece.score_path)
try:
p_codec, c_codec, snote_ids, score = get_codecs(
piece.score_path, piece.alignment_path, piece.cep_path,
performance_path=piece.performance_path,
score=(score if reuse_score else None),
)
except Exception as e:
err_log.write(json.dumps({
"dataset": dataset, "idx": j, "reason": "codec_extraction_error",
"score": str(piece.score_path), "error": str(e),
}) + "\n")
continue
# tempo sanity check
if 60 / p_codec[:, 0].mean() > 200:
hdf5_file.create_group(group_key).create_dataset("error", data="tempo_outlier")
continue
if not reuse_score:
same_score_p, mixup_p = [], []
same_score_c, mixup_c = [], []
split = "train" if rng.random() > test_split_p else "test"
else:
eligible = (
dataset == "VIENNA422"
or (dataset == "ATEPP" and str(piece.score_path.parent) in atepp_overlap)
)
if mix_up and eligible:
mixup_p = [np.mean([p_codec, ssp], axis=0) for ssp in same_score_p]
mixup_c = [np.mean([c_codec, ssc], axis=0) for ssc in same_score_c]
same_score_p.append(p_codec)
same_score_c.append(c_codec)
sna = score.note_array()
sna = sna[np.in1d(sna["id"], snote_ids)]
s_codec = rfn.structured_to_unstructured(
sna[["onset_div", "duration_div", "pitch", "voice"]]
)
if not (len(p_codec) == len(s_codec) == len(snote_ids)):
hdf5_file.create_group(group_key).create_dataset("error", data="length_mismatch")
err_log.write(json.dumps({
"dataset": dataset, "idx": j, "reason": "length_mismatch",
"p": len(p_codec), "s": len(s_codec), "snote_ids": len(snote_ids),
}) + "\n")
continue
data = _segment_piece(
snote_dir=snote_dir,
dataset=dataset,
piece_name=piece.piece_name,
score_path=piece.score_path,
p_codecs=[p_codec, *mixup_p],
c_codecs=[c_codec, *mixup_c],
s_codec=s_codec,
snote_ids=snote_ids,
split=split,
max_note_len=max_note_len,
)
grp = hdf5_file.create_group(group_key)
for key in ("p_codec", "s_codec", "c_codec", "snote_id_path",
"score_path", "piece_name"):
grp.create_dataset(key, data=data[key])
grp.create_dataset(
"split", data=data["split"], dtype=h5py.string_dtype(encoding="utf-8")
)
prev_score_path = piece.score_path
logger.info("Wrote %s", out_path)
return out_path
def _segment_piece(
*, snote_dir, dataset, piece_name, score_path,
p_codecs, c_codecs, s_codec, snote_ids, split, max_note_len,
):
"""Segment a piece (and its mixup variants) into fixed-length windows.
Pads short tail segments to ``max_note_len`` with zeros.
"""
data = defaultdict(list)
for variant_idx, (p_codec, c_codec) in enumerate(zip(p_codecs, c_codecs)):
variant_name = piece_name + ("_mixup" if variant_idx > 0 else "")
save_id_prefix = snote_dir / f"{dataset}_{piece_name}"
for seg_idx, start in enumerate(range(0, len(p_codec), max_note_len)):
end = start + max_note_len
seg_p = p_codec[start:end]
seg_s = s_codec[start:end]
seg_c = c_codec[start:end]
seg_ids = snote_ids[start:end]
if len(seg_ids) == 0:
continue
if len(seg_p) < max_note_len:
pad = max_note_len - len(seg_p)
seg_p = np.pad(seg_p, ((0, pad), (0, 0)))
seg_s = np.pad(seg_s, ((0, pad), (0, 0)))
seg_c = np.pad(seg_c, ((0, pad), (0, 0)))
seg_id_path = f"{save_id_prefix}_seg{seg_idx}.npy"
np.save(seg_id_path, seg_ids)
data["p_codec"].append(seg_p)
data["s_codec"].append(seg_s)
data["c_codec"].append(seg_c)
data["snote_id_path"].append(seg_id_path)
data["score_path"].append(str(score_path))
data["piece_name"].append(variant_name)
data["split"].append(split)
return data
# ---------------------------------------------------------------------------
# HDF5 → in-memory loader (consumed by train.py)
# ---------------------------------------------------------------------------
def _decode(x) -> str:
"""h5py returns numpy bytes for string datasets; decode to plain str."""
return x.decode() if hasattr(x, "decode") else str(x)
def _apply_path_remap(path: str, path_remap: Optional[dict]) -> str:
"""Prefix-substitute a path, first match wins. Pass-through if no remap matches."""
if not path_remap:
return path
for old, new in path_remap.items():
if path.startswith(old):
return new + path[len(old):]
return path
def load_data_from_hdf5(hdf5_path, path_remap: Optional[dict] = None):
"""Load codec dicts from HDF5, optionally rewriting absolute paths.
``path_remap`` is a dict of ``{old_prefix: new_prefix}``; both ``score_path``
and ``snote_id_path`` are rewritten when they start with one of the keys.
Use this to consume an HDF5 produced on a different machine — score paths
embedded by ``prepare_data.py`` are absolute, so cross-host portability needs
a runtime override.
"""
train_data, test_data = [], []
logger.info("Loading data from %s", hdf5_path)
if path_remap:
logger.info("path_remap: %s", dict(path_remap))
with h5py.File(hdf5_path, "r") as hdf5_file:
for group_name in tqdm(hdf5_file):
group = hdf5_file[group_name]
if "error" in group:
continue
p_codec = np.array(group["p_codec"])
s_codec = np.array(group["s_codec"])
c_codec = np.array(group["c_codec"])
snote_id_path = np.array(group["snote_id_path"])
score_path = np.array(group["score_path"])
piece_name = np.array(group["piece_name"])
split = np.array(group["split"])
piece_data = [{
"p_codec": p_codec[i],
"s_codec": s_codec[i],
"c_codec": c_codec[i],
"snote_id_path": _apply_path_remap(_decode(snote_id_path[i]), path_remap),
"score_path": _apply_path_remap(_decode(score_path[i]), path_remap),
"piece_name": _decode(piece_name[i]),
"split": _decode(split[i]),
} for i in range(len(p_codec))]
if piece_data[0]["split"] == "train":
train_data.extend(piece_data)
else:
test_data.extend(pd_ for pd_ in piece_data if "mixup" not in pd_["piece_name"])
return train_data, test_data
# ---------------------------------------------------------------------------
# Transfer pairing (segment-level pairs of real performances of the same piece)
# ---------------------------------------------------------------------------
def make_transfer_pair(codec_data, paths: DatasetPaths, K=50000, N=200):
"""Generate K pairs of segments (same piece + same segment idx, different performance).
Cached at ``data_root/codec_N={N}_mixup_paired_K={K}.npy`` and ``..._unpaired_K={K}.npy``.
"""
paired_path = paths.data_root / f"codec_N={N}_mixup_paired_K={K}.npy"
unpaired_path = paths.data_root / f"codec_N={N}_mixup_unpaired_K={K}.npy"
if paired_path.exists() and unpaired_path.exists():
return (
np.load(paired_path, allow_pickle=True),
np.load(unpaired_path, allow_pickle=True),
)
codec_data = np.array(codec_data)
real_only = codec_data[[i for i, x in enumerate(codec_data) if "mu" not in x["piece_name"]]]
np.random.shuffle(real_only)
unpaired = codec_data
transfer_pairs = []
for cd in tqdm(real_only):
if len(transfer_pairs) > K:
break
seg_id = cd["snote_id_path"].split("seg")[-1].split(".")[0]
mask = [
(x["score_path"] == cd["score_path"]
and f"seg{seg_id}." in x["snote_id_path"]
and (x["p_codec"] != cd["p_codec"]).any())
for x in real_only
]
same_piece = real_only[mask]
if len(same_piece):
for scd in same_piece:
transfer_pairs.extend([cd, scd])
unpaired = unpaired[[x["score_path"] != cd["score_path"] for x in unpaired]]
transfer_pairs = np.array(transfer_pairs).reshape(2, -1, order="F")
np.random.shuffle(transfer_pairs.T)
transfer_pairs = transfer_pairs.ravel(order="F")
np.save(paired_path, transfer_pairs)
np.save(unpaired_path, unpaired)
return transfer_pairs, unpaired
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _build_argparser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="DExter codec preprocessing")
p.add_argument("--compute_codec", action="store_true",
help="compute and persist p/s/c codecs to HDF5")
p.add_argument("--pairing", action="store_true",
help="build transfer pairs from a precomputed codec HDF5")
p.add_argument("--MAX_NOTE_LEN", type=int, default=200,
help="segment length in notes")
p.add_argument("--K", type=int, default=2000,
help="number of transfer pairs to draw")
p.add_argument("--mixup", action="store_true", default=True,
help="enable mixup augmentation across same-score performances")
p.add_argument("--no-mixup", dest="mixup", action="store_false")
p.add_argument("--datasets", nargs="+", default=["ATEPP", "ASAP", "VIENNA422"],
choices=list(DISCOVERERS.keys()))
p.add_argument("--data_root", type=str, default=None,
help="output directory; overrides $DEXTER_DATA_ROOT and config/paths.yaml")
p.add_argument("--datasets_root", type=str, default=None,
help="parent of source corpora; overrides $DEXTER_DATASETS_ROOT")
p.add_argument("--paths_config", type=str, default=None,
help="path to a paths.yaml override")
p.add_argument("--seed", type=int, default=None)
p.add_argument("--log_level", default="INFO")
return p
def main():
args = _build_argparser().parse_args()
logging.basicConfig(level=args.log_level, format="%(asctime)s [%(levelname)s] %(message)s")
paths = load_paths(
config_path=Path(args.paths_config) if args.paths_config else None,
data_root=args.data_root,
datasets_root=args.datasets_root,
)
logger.info("data_root=%s datasets_root=%s", paths.data_root, paths.datasets_root)
if args.compute_codec:
process_dataset_codec(
paths,
max_note_len=args.MAX_NOTE_LEN,
mix_up=args.mixup,
datasets=args.datasets,
rng_seed=args.seed,
)
elif args.pairing:
codec_path = paths.data_root / f"codec_N={args.MAX_NOTE_LEN}_mixup_test.npy"
codec_data = np.load(codec_path, allow_pickle=True)
make_transfer_pair(codec_data, paths, K=args.K, N=args.MAX_NOTE_LEN)
else:
_build_argparser().print_help()
if __name__ == "__main__":
main()