MatplotlibのSVG散布図マーカー:パスを確認する
単純なSVGパスをMatplotlibの散布図マーカーに使います。実際のSVGとPDFの書き出しで、中心、向き、穴、サイズの確認を再現します。
目次
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. 中心を確認し、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(平方ポイント) | 予想と実測の幅 | 予想と実測の高さ |
|---|---|---|
| 400 | 20 pt | 15 pt |
| 1,600 | 40 pt | 30 pt |
| 3,600 | 60 pt | 45 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コマンド、閉じた輪郭に対応します。非対応の描画、形状コマンド、変換、追加要素はエラーになります。
出典
- Matplotlib scatter API — サイズパラメーター、マーカー入力、縁幅の影響を定義。
- Matplotlib 3.10.7のマーカーAPI — 検証した版のPathマーカーと正規化マーカー座標を説明。
- Matplotlib Path API — 頂点、描画コード、閉じた輪郭を定義。
- SVGの座標系 — 向きの確認で使った初期座標方向を規定。
- SVGの塗り規則 — 輪郭方向がnonzeroでの穴にどう影響するか説明。
ラスターしかないシンボルなら、編集可能な図のデータを取り出し、輪郭と穴を元画像と比べ、実際の図で調整したパスを試してから納品してください。


