PerfectVector
著者: Irene Kim

MatplotlibのSVG散布図マーカー:パスを確認する

単純なSVGパスをMatplotlibの散布図マーカーに使います。実際のSVGとPDFの書き出しで、中心、向き、穴、サイズの確認を再現します。

4 分で読める
目次

SVGを独自のMatplotlib散布図マーカーとして使うには、対応する形状を matplotlib.path.Path に変換し、(0, 0) を中心に置き、そのオブジェクトを scatter(marker=...) に渡します。マーカーの色とサイズはグラフ側から指定します。MatplotlibのマーカーAPIはPathを受け付けますが、任意のSVG文書をマーカーファイル名として受け取るわけではありません。

このチュートリアルは、長方形の穴を持つ独自の非対称な多角形を使います。以下の完全なスクリプトは、意図的に小さく限定したSVGの一部の機能だけを扱い、保持できない機能を拒否します。Matplotlib 3.10.7で実行し、保存したSVGとPDFのパスを調べ、両方の描画を確認しました。

書き出したグラフの隣にイラストを1つ置きたい場合は、別のMatplotlibでSVGデザインを合成するガイドを使います。散布図マーカーはデータ座標に繰り返し置くため、中心、向き、サイズを別の方法で確認する必要があります。

1. 対応する1つの形から始める

MatplotlibとNumPyを使えるPythonと、出力ディレクトリへの書き込み権限が必要です。例には独自のSVGが含まれるため、アイコンをダウンロードする必要はありません。プロットする3つの座標は説明用の値であり、研究の観測値ではありません。

パーサーは、viewBox を持つSVGルートの中にある、黒で塗った path 1つだけを受け付けます。各輪郭は Z で閉じ、座標の組ごとに大文字の M または L を明示する必要があります。fill-rule="nonzero" の下で逆向きの輪郭を使い、外周と穴を持つ複合パスに対応します。

曲線、相対コマンド、変換、ストローク、CSS、複数のパス要素、テキスト、クリップ、グラデーション、他の色は拒否します。width や height のような追加のルート属性も拒否します。この制限によって例を確認しやすくしていますが、汎用SVGインポーターではありません。対応する入力を準備する前に、変更していないデザインのコピーを残します。

MatplotlibのPathは、SVGの描画モデル全体ではなく、頂点と描画コードを保存します。Path APIは、ここで使う移動、直線、閉じる操作を定義しています。複数の色や効果が必要なシンボルなら、このパーサーに合わせて黙って削除するのではなく、その機能を保持できる手順を選びます。

2. 完全なマーカー例を実行する

これを scatter_marker.py として保存し、同じディレクトリで python scatter_marker.py を実行します。marker-output/marker.svg、scatter.svg、scatter.pdf とPNGプレビューを作ります。生成するSVGソースとグラフの全設定は、ここに含まれています。

from pathlib import Path as FilePath
import re
import xml.etree.ElementTree as ET
 
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.path import Path
import numpy as np
 
SVG = '''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 80">
  <path fill="#000000" fill-rule="nonzero"
        d="M10 10 L70 10 L70 30 L90 30 L90 70 L10 70 Z
           M25 25 L25 55 L45 55 L45 25 Z"/>
</svg>'''
 
 
def svg_marker(svg_text):
    """One black, nonzero-filled path; explicit absolute M/L/Z only."""
    namespace = "{http://www.w3.org/2000/svg}"
    root = ET.fromstring(svg_text)
    if root.tag != namespace + "svg" or set(root.attrib) != {"viewBox"}:
        raise ValueError("Use only an SVG root with a viewBox")
    view = np.array([float(value) for value in root.attrib["viewBox"].split()])
    if view.shape != (4,) or not np.isfinite(view).all() or (view[2:] <= 0).any():
        raise ValueError("Invalid viewBox")
    if len(root) != 1 or root[0].tag != namespace + "path":
        raise ValueError("Use exactly one path, with no groups or other elements")
    node = root[0]
    if any((text or "").strip() for text in [root.text, node.text, node.tail]):
        raise ValueError("Text content is not supported")
    if set(node.attrib) - {"d", "fill", "fill-rule"} or len(node):
        raise ValueError("Unsupported path attribute or child element")
    if node.get("fill", "black") not in {"black", "#000", "#000000"}:
        raise ValueError("Use one solid black fill")
    if node.get("fill-rule", "nonzero") != "nonzero":
        raise ValueError("Use nonzero winding; evenodd is not converted here")
    data = node.get("d", "")
    pattern = r"[MLZ]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?"
    tokens = re.findall(pattern, data)
    if re.sub(pattern, "", data).strip(" ,\n\r\t"):
        raise ValueError("Only explicit absolute M, L and Z commands are supported")
    vertices, codes = [], []
    index, start, open_contour, lines = 0, None, False, 0
    while index < len(tokens):
        command = tokens[index]
        if command in {"M", "L"}:
            if index + 2 >= len(tokens) or tokens[index + 1] in {"M", "L", "Z"} or tokens[index + 2] in {"M", "L", "Z"}:
                raise ValueError("Each M/L needs one explicit coordinate pair")
            point = tuple(float(value) for value in tokens[index + 1:index + 3])
            if not np.isfinite(point).all():
                raise ValueError("Coordinates must be finite")
            if command == "M":
                if open_contour:
                    raise ValueError("Close each contour with Z")
                start, open_contour, lines = point, True, 0
                codes.append(Path.MOVETO)
            else:
                if not open_contour:
                    raise ValueError("Start each contour with M")
                lines += 1
                codes.append(Path.LINETO)
            vertices.append(point)
            index += 3
        elif command == "Z":
            if not open_contour or lines < 2:
                raise ValueError("A closed contour needs at least three vertices")
            vertices.append(start)
            codes.append(Path.CLOSEPOLY)
            open_contour = False
            index += 1
        else:
            raise ValueError("Repeat M/L explicitly before coordinate pairs")
    if open_contour or not vertices:
        raise ValueError("Supply nonempty closed contours")
    path = Path(np.array(vertices), codes)
    box = path.get_extents()
    if box.width <= 0 or box.height <= 0:
        raise ValueError("Path needs nonzero width and height")
    if box.x0 < view[0] or box.y0 < view[1] or box.x1 > view[0] + view[2] or box.y1 > view[1] + view[3]:
        raise ValueError("Path outside viewBox: resolve clipping before import")
    center = np.array([(box.x0 + box.x1) / 2, (box.y0 + box.y1) / 2])
    centered = (path.vertices - center) * np.array([1, -1])
    return Path(centered, path.codes)
 
 
if __name__ == "__main__":
    output = FilePath("marker-output")
    output.mkdir(exist_ok=True)
    (output / "marker.svg").write_text(SVG)
    marker = svg_marker(SVG)
    plt.rcParams.update({"svg.fonttype": "none", "pdf.fonttype": 42,
                         "font.family": "DejaVu Sans", "font.size": 11})
    fig, ax = plt.subplots(figsize=(8, 3), dpi=144)
    fig.subplots_adjust(left=.1, right=.96, bottom=.24, top=.8)
    ax.scatter([1, 2, 3], [1.3, 2.4, 1.6], s=[400, 1600, 3600],
               marker=marker, color="#2f6977", linewidths=0, edgecolors="none")
    ax.set(xlim=(0, 4), ylim=(0, 4), xlabel="Illustrative x", ylabel="Illustrative y",
           title="Original SVG marker: s = 400, 1600, 3600 points squared")
    ax.spines[["top", "right"]].set_visible(False)
    fig.savefig(output / "scatter.svg", format="svg")
    fig.savefig(output / "scatter.pdf", format="pdf")
    fig.savefig(output / "scatter.png", format="png")
    plt.close(fig)

scatter.svg と scatter.pdf の両方を開きます。同じ形が青緑で3つ表示されるはずです。左には長方形の穴があり、右下には段状の張り出しがあります。最も小さいマーカーは拡大表示だけでなく、予定する表示サイズでも比べます。

独自の非対称マーカー、その穴、確認した3サイズを示す実際のMatplotlib SVGとPDFの書き出し
独自のサンプルと、Matplotlib 3.10.7から実際に保存した書き出しです。PDF表示は保存したベクターファイルの描画であり、データ値は説明用です。

3. 中心を確認し、Yを1回だけ反転する

元のデザインの範囲は x=10…90 と y=10…70 です。バウンディングボックスの中心は (50, 40) です。この中心を引くと、横は −40…40、縦は −30…30 の範囲になります。これは外接矩形による中心合わせであり、図心や見た目のバランスの推定ではありません。

元デザインの右下の段差は、対称な円では分からない上下反転を確認するのに役立ちます。初期のSVG座標系では、正のYは下へ向かいます。スクリプトはYの符号を1回だけ反転し、通常のYが上へ向かうグラフで、元画像の表示上の向きを保ちます。

両方の輪郭に同じ変換を適用します。相対的な周回方向は逆のままなので、長方形の穴が残ります。SVGのnonzero塗り規則は輪郭の向きに依存します。ここでは evenodd の元データを同等に扱わず、明示的に拒否します。

その後Matplotlibは、中心合わせしたPathを正規化します。検証した版では、結果の範囲はXが −0.5…0.5、Yが −0.375…0.375 です。scatter に渡した点は、この範囲の中心に置かれます。特殊なシンボルに別の基準点を意図的に選ぶことはできますが、元SVGの左上隅が適切なデータの基準点だとは考えないでください。

4. scatterのサイズを平方ポイントで読む

scatter APIは s を平方ポイントで定義し、組版の1ポイントは1/72インチです。マーカーの縁幅がゼロでないと、見える範囲が広がる場合があります。例では linewidths=0 と edgecolors="none" を指定し、塗り形状だけのサイズを確認します。

この正規化したマーカーの幅は1マーカー単位、高さは0.75単位です。そのため、保存した幅は sqrt(s) ポイント、高さは 0.75 * sqrt(s) ポイントです。

s(平方ポイント)予想と実測の幅予想と実測の高さ
40020 pt15 pt
1,60040 pt30 pt
3,60060 pt45 pt

これはこの特定の正規化形状の寸法であり、塗った面積ではありません。縦横比、基準点、縁のストロークが変わると、サイズパラメーターと見えるシンボルの関係も変わります。この例では s を4倍にすると寸法が2倍になります。

実際に保存したSVG座標と、PDFの塗り付きパス操作を、0.000001ポイントの数値許容差で調べました。3つのマーカーすべてで、範囲、データ位置への中心合わせ、変換した頂点は予想どおりでした。保存したSVGに画像要素はなく、PDFに画像オブジェクトはありませんでした。

また、保存したPDFを144 dpiで描画し、指定した内部の3点を確認しました。穴は白、右上の切り欠きは白、右下の張り出しは青緑でした。実際のSVGのネイティブなブラウザー描画でも、同じ内部確認を通過しました。これは選んだ向きと穴を検証するもので、描画エンジン間でアンチエイリアスの縁が同一であることや、任意のSVGデザインへの対応を主張するものではありません。

5. サイズを調整する前に原因を直す

症状確認と修正
パーサーが非対応機能のエラーを出す指摘された機能を調べる。対応する多角形入力を別に準備するか、より完全な変換方法を使って検証する。効果を保たず属性だけを消すと、デザインが変わる場合がある。
形が上下逆になるこの関数より前にYを反転していないか確認する。座標変換を1回だけ適用し、非対称な特徴を比べる。
シンボルがデータ点から離れている範囲と選んだ基準点を調べる。この関数は viewBox 内の空白に関係なく、デザインの範囲を中心合わせする。
穴が埋まる輪郭の向きと塗り規則を確認する。このnonzeroのサンプルでは逆向きを保つ。パーサーはevenodd形状を変換しない。
保存ファイルにラスターのシンボルがある書き出した要素やリソースを調べる。ファイル名がSVGやPDFというだけでは、すべての描画要素がベクターとは限らない。
小さいマーカーで重要な細部が消える仕上がりサイズで図を見る。マーカーを大きくする、シンボルを簡略化する、分類がより明確に伝わるなら組み込みマーカーを使う。

この例以外のファイル確認には、SVG内のラスター内容の探し方を参照してください。図全体では、出版サイズでの文字サイズと線幅も確認します。パスが正しいマーカーでも、小さな凡例や混み合ったパネルには複雑すぎる場合があります。

完成図で自分のマーカーを使う前に、最小サイズ、凡例の見た目、点の重なり、書き出したSVGとPDFを確認します。データ、軸、ラベル、縮尺の対応はグラフ作成の工程に残します。独自のアイコンは、これらの数値定義の代わりにはなりません。

ラスターのデザインに新しいパスが必要な場合

シンボルがフラットなラスターの絵にしか残っていないなら、PerfectVectorの科学図からSVGへの変換手順で編集可能なデザインを取り出せます。マーカーの処理に合わせる前に、取り出した輪郭とすべての穴を原本と比べます。きれいなSVGがすでにあれば、それを元データとして保ちます。

ベクターの取り出しは、グラフの基になるデータを再構成したり、この制限のあるパーサーとの互換性を保証したりしません。写真や連続階調のイラストは、そのラスターの見た目が図の一部であるならそのまま保ち、内容に合う画像ベースの配置方法を選びます。

よくある質問

MatplotlibのscatterにSVGファイル名を直接渡せますか? いいえ。マーカーAPIはMatplotlibのPathなど、対応するマーカー記述を受け付けます。この例ではscatterを呼ぶ前に、制限したSVGパスをそのオブジェクトへ変換します。

独自のマーカーが上下逆になるのはなぜですか? 元のSVGとグラフでY方向が異なる場合があります。この例はSVGのYの符号を1回反転します。二重反転を避けるため、非対称な特徴を調べてください。

sはSVG形状の塗った面積に等しいですか? 塗った面積の実測値として使わないでください。Scatterはsを平方ポイントで定義し、正規化した形と縁のストロークが見える寸法を決めます。この例はsが400、1600、3600のとき、幅が20、40、60ポイントになります。

このコードはSVGのすべての機能を保持しますか? いいえ。黒いnonzeroの塗り付きパス1つ、明示した絶対座標のM、L、Zコマンド、閉じた輪郭に対応します。非対応の描画、形状コマンド、変換、追加要素はエラーになります。

出典

  1. Matplotlib scatter API — サイズパラメーター、マーカー入力、縁幅の影響を定義。
  2. Matplotlib 3.10.7のマーカーAPI — 検証した版のPathマーカーと正規化マーカー座標を説明。
  3. Matplotlib Path API — 頂点、描画コード、閉じた輪郭を定義。
  4. SVGの座標系 — 向きの確認で使った初期座標方向を規定。
  5. SVGの塗り規則 — 輪郭方向がnonzeroでの穴にどう影響するか説明。

ラスターしかないシンボルなら、編集可能な図のデータを取り出し、輪郭と穴を元画像と比べ、実際の図で調整したパスを試してから納品してください。

ブログのその他の記事

PerfectVector

編集しやすい
よりきれいなSVGから始めましょう

クレジットカード不要