diff --git a/partitura/display.py b/partitura/display.py index 31ece28a..7149a931 100644 --- a/partitura/display.py +++ b/partitura/display.py @@ -10,10 +10,15 @@ import warnings import os import subprocess +import shutil from tempfile import NamedTemporaryFile, TemporaryFile +from typing import Optional from partitura import save_musicxml from partitura.io.musescore import render_musescore +from partitura.score import ScoreLike + +from partitura.utils.misc import PathLike, deprecated_alias __all__ = ["render"] @@ -36,7 +41,13 @@ # return s -def render(part, fmt="png", dpi=90, out_fn=None): +@deprecated_alias(out_fn="out", part="score_data") +def render( + score_data: ScoreLike, + fmt: str = "png", + dpi: int = 90, + out: Optional[PathLike] = None, +) -> None: """Create a rendering of one or more parts or partgroups. The function can save the rendered image to a file (when @@ -49,25 +60,23 @@ def render(part, fmt="png", dpi=90, out_fn=None): Parameters ---------- - part : :class:`partitura.score.Part` or :class:`partitura.score.PartGroup` - or a list of these + score_data : ScoreLike The score content to be displayed fmt : {'png', 'pdf'}, optional The image format of the rendered material out_fn : str or None, optional The path of the image output file. If None, the rendering will be displayed in a viewer. - """ - img_fn = render_musescore(part, fmt, out_fn, dpi) + img_fn = render_musescore(score_data, fmt, out, dpi) if img_fn is None or not os.path.exists(img_fn): - img_fn = render_lilypond(part, fmt) + img_fn = render_lilypond(score_data, fmt) if img_fn is None or not os.path.exists(img_fn): return - if not out_fn: + if not out: # NOTE: the temporary image file will not be deleted. if platform.system() == "Linux": subprocess.call(["xdg-open", img_fn]) @@ -77,9 +86,33 @@ def render(part, fmt="png", dpi=90, out_fn=None): os.startfile(img_fn) -def render_lilypond(part, fmt="png"): +@deprecated_alias(part="score_data") +def render_lilypond( + score_data, + fmt="png", + out=None, +) -> Optional[PathLike]: + """ + Render a score-like object using Lilypond + + Parameters + ---------- + score_data : ScoreLike + Score-like object to be rendered + fmt : {'png', 'pdf'} + Output image format + out : str or None, optional + The path of the image output file, if not specified, the + rendering will be saved to a temporary filename. Defaults to + None. + + Returns + ------- + out : PathLike + Path of the generated output image (or None if no image was generated). + """ if fmt not in ("png", "pdf"): - print("warning: unsupported output format") + warnings.warn("warning: unsupported output format") return None prvw_sfx = ".preview.{}".format(fmt) @@ -89,7 +122,7 @@ def render_lilypond(part, fmt="png"): ) as img_fh: # save part to musicxml in file handle xml_fh - save_musicxml(part, xml_fh) + save_musicxml(score_data, xml_fh) # rewind read pointer of file handle before we pass it to musicxml2ly xml_fh.seek(0) @@ -141,4 +174,9 @@ def render_lilypond(part, fmt="png"): ) return - return img_fh.name + if out is not None: + shutil.copy(img_fh.name, out) + else: + out = img_fh.name + + return out diff --git a/partitura/io/exportparangonada.py b/partitura/io/exportparangonada.py index 57ceef69..1aa31c82 100644 --- a/partitura/io/exportparangonada.py +++ b/partitura/io/exportparangonada.py @@ -147,10 +147,30 @@ def save_parangonada_csv( np.savetxt( os.path.join(outdir, "ppart.csv"), # outdir + os.path.sep + "perf_note_array.csv", - perf_note_array, + perf_note_array[ + [ + "onset_sec", + "duration_sec", + "pitch", + "velocity", + "track", + "channel", + "id", + ] + ], fmt="%.20s", delimiter=",", - header=",".join(perf_note_array.dtype.names), + header=",".join( + [ + "onset_sec", + "duration_sec", + "pitch", + "velocity", + "track", + "channel", + "id", + ] + ), comments="", ) np.savetxt( diff --git a/partitura/io/musescore.py b/partitura/io/musescore.py index f5b2eb76..7c7f6a30 100644 --- a/partitura/io/musescore.py +++ b/partitura/io/musescore.py @@ -7,6 +7,7 @@ import platform import warnings +import glob import os import shutil import subprocess @@ -16,12 +17,14 @@ from partitura.io.importmusicxml import load_musicxml from partitura.io.exportmusicxml import save_musicxml -from partitura.score import Score +from partitura.score import Score, ScoreLike from partitura.utils.misc import ( deprecated_alias, deprecated_parameter, PathLike, + concatenate_images, + PIL_EXISTS, ) @@ -61,7 +64,7 @@ def find_musescore3(): result = shutil.which("/Applications/MuseScore 3.app/Contents/MacOS/mscore") elif platform.system() == "Windows": - result = shutil.which(r"C:\Program Files\MuseScore 3\bin\MuseScore.exe") + result = shutil.which(r"C:\Program Files\MuseScore 3\bin\MuseScore3.exe") return result @@ -108,45 +111,56 @@ def load_via_musescore( raise MuseScoreNotFoundException() - with NamedTemporaryFile(suffix=".musicxml") as xml_fh: + xml_fh = os.path.splitext(os.path.basename(filename))[0] + ".musicxml" - cmd = [mscore_exec, "-o", xml_fh.name, filename] + cmd = [mscore_exec, "-o", xml_fh, filename] - try: + try: - ps = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + ps = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) - if ps.returncode != 0: - - raise FileImportException( - ( - "Command {} failed with code {}. MuseScore " - "error messages:\n {}" - ).format(cmd, ps.returncode, ps.stderr.decode("UTF-8")) - ) - except FileNotFoundError as f: + if ps.returncode != 0: raise FileImportException( - 'Executing "{}" returned {}.'.format(" ".join(cmd), f) + ( + "Command {} failed with code {}. MuseScore " + "error messages:\n {}" + ).format(cmd, ps.returncode, ps.stderr.decode("UTF-8")) ) + except FileNotFoundError as f: - return load_musicxml( - filename=xml_fh.name, - validate=validate, - force_note_ids=force_note_ids, + raise FileImportException( + 'Executing "{}" returned {}.'.format(" ".join(cmd), f) ) - -def render_musescore(part, fmt, out_fn=None, dpi=90): - """Render a part using musescore. + score = load_musicxml( + filename=xml_fh, + validate=validate, + force_note_ids=force_note_ids, + ) + + os.remove(xml_fh) + + return score + + +@deprecated_alias(out_fn="out", part="score_data") +def render_musescore( + score_data: ScoreLike, + fmt: str, + out: Optional[PathLike] = None, + dpi: Optional[int] = 90, +) -> Optional[PathLike]: + """ + Render a score-like object using musescore. Parameters ---------- - part : Part - Part to be rendered + score_data : ScoreLike + Score-like object to be rendered fmt : {'png', 'pdf'} Output image format - out_fn : str or None, optional + out : str or None, optional The path of the image output file, if not specified, the rendering will be saved to a temporary filename. Defaults to None. @@ -154,6 +168,10 @@ def render_musescore(part, fmt, out_fn=None, dpi=90): Image resolution. This option is ignored when `fmt` is 'pdf'. Defaults to 90. + Returns + ------- + out : Optional[PathLike] + Path to the output generated image (or None if no image was generated) """ mscore_exec = find_musescore3() @@ -172,14 +190,14 @@ def render_musescore(part, fmt, out_fn=None, dpi=90): xml_fh = Path(tmpdir) / "score.musicxml" img_fh = Path(tmpdir) / f"score.{fmt}" - save_musicxml(part, xml_fh) + save_musicxml(score_data, xml_fh) cmd = [ mscore_exec, "-T", "10", "-r", - "{}".format(dpi), + "{}".format(int(dpi)), "-o", os.fspath(img_fh), os.fspath(xml_fh), @@ -215,12 +233,27 @@ def render_musescore(part, fmt, out_fn=None, dpi=90): # ps.stderr.decode('UTF-8'))) if fmt == "png": - img_fh = (img_fh.parent / (img_fh.stem + "-1")).with_suffix(img_fh.suffix) + + if PIL_EXISTS: + # get all generated image files + img_files = glob.glob( + os.path.join(img_fh.parent, img_fh.stem + "-*.png") + ) + concatenate_images( + filenames=img_files, + out=img_fh, + concat_mode="vertical", + ) + else: + # The first image seems to be blank (MuseScore adds an empy page) + img_fh = (img_fh.parent / (img_fh.stem + "-2")).with_suffix( + img_fh.suffix + ) if img_fh.is_file(): - if out_fn is None: - out_fn = os.path.join(gettempdir(), "partitura_render_tmp.png") - shutil.copy(img_fh, out_fn) - return out_fn + if out is None: + out = os.path.join(gettempdir(), "partitura_render_tmp.png") + shutil.copy(img_fh, out) + return out return None diff --git a/partitura/utils/misc.py b/partitura/utils/misc.py index da02564d..9ea64138 100644 --- a/partitura/utils/misc.py +++ b/partitura/utils/misc.py @@ -7,7 +7,18 @@ import os import warnings -from typing import Union, Callable, Dict, Any, Iterable +from typing import Union, Callable, Dict, Any, Iterable, Optional + +import numpy as np + +try: + from PIL import Image + from PIL.ImageFile import ImageFile + PIL_EXISTS = True +except ImportError: + Image = None + ImageFile = Any + PIL_EXISTS = False # Recommended by PEP 519 PathLike = Union[str, bytes, os.PathLike] @@ -153,3 +164,93 @@ def to_be_deprecated( ) # Remove deprecated kwarg from kwargs kwargs.pop(deprecated_kwarg) + + +def concatenate_images( + filenames: Iterable[PathLike], + out: Optional[PathLike] = None, + concat_mode: str = "vertical", +) -> Optional[ImageFile]: + """ + Concatenate Images to form one single image. + + Parameters + ---------- + filenames: Iterable[PathLike] + A list of images to be concatenated. This method assumes + that all of the images have the same resolution and color mode + (that is the case for png files generated by MuseScore). + See `partitura.io.musescore.render_musescore`. + out : Optional[PathLike] + The output file where the image will be saved. + concat_mode : {"vertical", "horizontal"} + Whether to concatenate the images vertically or horizontally. + Default is vertical. + + Returns + ------- + new_image : Optional[PIL.Image.Image] + The output image. This is only returned if `out` is not None. + + Notes + ----- + + If the Pillow library is not installed, this method will return None. + """ + if not PIL_EXISTS: + warnings.warn( + message=( + "The pillow library was not found. This method " + "will just return None. (You can install it with " + "`pip install pillow`)." + ), + category=ImportWarning, + ) + return None + # Check that concat mode is vertical or horizontal + if concat_mode not in ("vertical", "horizontal"): + raise ValueError( + f"`concat_mode` should be 'vertical' or 'horizontal' but is {concat_mode}" + ) + + # Load images + images = [Image.open(fn) for fn in filenames] + + # Get image sizes + image_sizes = np.array([img.size for img in images], dtype=int) + + # size of the output image according to the concatenation mode + if concat_mode == "vertical": + output_size = (image_sizes[:, 0].max(), image_sizes[:, 1].sum()) + elif concat_mode == "horizontal": + output_size = (image_sizes[:, 0].sum(), image_sizes[:, 1].max()) + + # Color mode (assume it is the same for all images) + mode = images[0].mode + + # DPI (assume that it is the same for all images) + info = images[0].info + + # Initialize new image + new_image = Image.new(mode=mode, size=output_size, color=0) + + # coordinates to place the image + anchor_x = 0 + anchor_y = 0 + for img, size in zip(images, image_sizes): + + new_image.paste(img, (anchor_x, anchor_y)) + + # update coordinates according to the concatenation mode + if concat_mode == "vertical": + anchor_y += size[1] + + elif concat_mode == "horizontal": + anchor_x += size[0] + + # save image file + if out is not None: + new_image.save(out, **info) + + else: + return new_image diff --git a/tests/__init__.py b/tests/__init__.py index b5712548..3322cbbb 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -6,6 +6,7 @@ """ import os +import glob BASE_PATH = os.path.dirname(os.path.realpath(__file__)) DATA_PATH = os.path.join(BASE_PATH, "data") @@ -17,6 +18,7 @@ MIDI_PATH = os.path.join(DATA_PATH, "midi") PARANGONADA_PATH = os.path.join(DATA_PATH, "parangonada") WAV_PATH = os.path.join(DATA_PATH, "wav") +PNG_PATH = os.path.join(DATA_PATH, "png") # this is a list of files for which importing and subsequent exporting should # yield identical MusicXML @@ -191,3 +193,5 @@ "example_linear_equal_temperament_sr8000.wav", ] ] + +PNG_TESTFILES = glob.glob(os.path.join(PNG_PATH, '*.png')) diff --git a/tests/data/png/example_score.png b/tests/data/png/example_score.png new file mode 100644 index 00000000..9f85fa61 Binary files /dev/null and b/tests/data/png/example_score.png differ diff --git a/tests/test_display.py b/tests/test_display.py new file mode 100644 index 00000000..fad84419 --- /dev/null +++ b/tests/test_display.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +This module includes tests for display utilities. Since these utilities +rely on externally installed software (e.g., MuseScore, Lilypond), +they cannot be automatically tested by GitHub. +""" +import os +import unittest + +from tempfile import TemporaryDirectory + +import numpy as np + +from partitura.utils.misc import concatenate_images, PIL_EXISTS, Image + +from tests import PNG_TESTFILES + + +class TestMuseScoreExport(unittest.TestCase): + def test_concat_images(self): + """ + Test `partitura.utils.misc.concatenate_images` + """ + if PIL_EXISTS: + + for fn in PNG_TESTFILES: + filenames = [fn, fn] + + # original image + oimage = Image.open(fn) + + # images concatenated vertically + cimage_vertical = concatenate_images( + filenames=filenames, + out=None, + concat_mode="vertical", + ) + + with TemporaryDirectory() as tmpdir: + ofn = os.path.join(tmpdir, "test_output.png") + concatenate_images( + filenames=filenames, + out=ofn, + concat_mode="vertical", + ) + reloaded_image = Image.open(ofn) + + self.assertTrue( + np.allclose( + np.asarray(reloaded_image), + np.asarray(cimage_vertical), + ) + ) + + cimage_horizontal = concatenate_images( + filenames=filenames, + out=None, + concat_mode="horizontal", + ) + + expected_size_vertical = (oimage.size[0], oimage.size[1] * 2) + expected_size_horizontal = (oimage.size[0] * 2, oimage.size[1]) + self.assertTrue(cimage_vertical.size == expected_size_vertical) + self.assertTrue(cimage_horizontal.size == expected_size_horizontal) + + oimage.close() + reloaded_image.close()