Skip to content

KiCadで偽タイムラプスを生成

KiCadPython

はじめに

ソースコードの書かれた過程をタイムラプス風に再生するOSS「gitlogue」を見かけて、KiCadの基板データで似たようなことをやりたくなりました。「kicad timelapse」でググったところマサチューセッツ大学の先生が既にやっていたのですが、彼の公開してくれているソースコードが(おそらくKiCadのバージョン違いなどもあり)うまく動かなかったため、私の方でもそれなりに頑張りました。

先に成果物↓を貼っておきます。

言うまでもなく、Arduino UNOの表側です

言うまでもなく、Arduino UNOの表側です

環境

  • Debian 13 on WSL2
  • KiCad 9.0.7
  • Python 3.13.5

他の環境で試せていませんが、少なくともWindowsに関しては何とかなりそうに見えます。

方針

早めに断っておきますが、本記事の方法では実際の作業工程に基づいたタイムラプスは生成できません。冒頭でgitlogueを紹介してしまいましたがGitも関係ありません。代わりに、一切の迷いなく図面を完成させた世界線の、偽のタイムラプスを捏造します

具体的には、KiCadには内部のC++をPythonでラップしたAPIがpcbnew.pyとして同梱されているので、これを叩いて任意の.kicad_pcbファイル内を走査しつつ図面を出力していきます。

TIP

むしろこのpcbnew.pyをGUI化したものがKiCadのPCBエディターなのかも…?

Debian 13からaptでインストールした場合は/usr/lib/python3/dist-packages/pnbnew.pyに、Windows 11からwingetでインストールした場合はC:\Users\username\AppData\Local\Programs\KiCad\9.0\bin\Lib\site-packages\pcbnew.pyに入っています。

ワークフロー

  1. .kicad_pcbファイルを入力(このあと色々されますが変更は保存されないので無事です)
  2. 塗りつぶし領域を一つ消去したうえで、基板をSVG画像にプロット
  3. 塗りつぶし領域が無くなるまで2を繰り返す
  4. 配線についても同様に2~3を行う
  5. フットプリントについても同様に2~3を行う
  6. 輪郭線についても同様に2~3を行う
  7. ImageMagickでSVG画像をPNG画像に変換
  8. PNG画像をFFmpegで逆順につなぎ合わせて出力
  9. 適宜トリミングして完成

実行

スクリプト

python
import sys
import os
import glob
from contextlib import redirect_stdout
from board_process import BoardProcessor
from external_tools import svg_to_png
from external_tools import compile_video


def main():
    # --- 定数 ---
    DPI = 300
    WIDTH = 1920
    CRF = 15
    FPS = 30
    categories = ["ZONE", "TRACK", "FOOTPRINT", "DRAWING"]
    print("\nkicad-logue: PCB timelapse generator")
    print("=" * 40)

    # --- 初期化 ---
    print("\n[Initialization]")
    layer = sys.argv[1] if len(sys.argv) > 1 else "F_Cu"
    input_file = sys.argv[2] if len(sys.argv) > 2 else "example.kicad_pcb"
    out_base = sys.argv[3] if len(sys.argv) > 3 else input_file
    board_name = os.path.splitext(os.path.basename(out_base))[0]
    out_dir = "./out"
    if os.path.exists(out_dir):
        for f in glob.glob(os.path.join(out_dir, "*")):
            os.remove(f)

    # --- ボードの読み込みとSVG生成 ---
    print("\n[Generating SVGs from PCB]")
    bp = BoardProcessor(input_file, out_dir, layer)
    items_dict = bp.get_all_items()
    print(f"\nBoard   : {board_name}")
    print(f"Layer   : {layer}")
    print(f"Mode    : {DPI} DPI / {WIDTH}px Width\n")

    global_idx = 0
    for cat in categories:
        items = items_dict[cat]
        if not items:
            continue
        print(f"Generating SVGs for {cat:10} ({len(items)} items)")
        for i, item in enumerate(items):
            bp.plot_current_state(f"step_{global_idx:02d}_{cat}", i)
            with redirect_stdout(open(os.devnull, "w")):
                bp.remove_item(item)
        global_idx += 1

    # --- SVGをPNGに変換 ---
    print("\n[Converting SVGs to PNGs]")
    svg_to_png(out_dir, DPI)

    # --- FFmpeg用リスト作成 ---
    print("\n[Preparing FFmpeg file list]")
    print(f"Total PNG files : {len(glob.glob(os.path.join(out_dir, '*.png')))}")
    print(f"FPS             : {FPS}")
    # PNG群はreverseして組み立て順にする
    png_files = sorted(glob.glob(os.path.join(out_dir, "*.png")), reverse=True)
    list_file = os.path.join(out_dir, "file_list.txt")
    with open(list_file, "w") as f:
        for png in png_files:
            f.write(f"file '{os.path.abspath(png)}'\nduration {1 / FPS}\n")
        # 最終フレームで2秒間停止
        f.write(f"file '{os.path.abspath(png_files[-1])}'\nduration 2.0\n")

    # --- FFmpegで動画に合成 ---
    print("\n[Compiling Video with FFmpeg]")
    compile_video(board_name, layer, list_file, FPS, WIDTH, CRF)
    print("\n" + "=" * 40)
    print("COMPLETED!")
    print(f"Output files: {board_name}_{layer}.mp4 , {board_name}_{layer}.gif")


if __name__ == "__main__":
    main()
python
import sys

pcbnew_path = "/usr/lib/python3/dist-packages"
if pcbnew_path not in sys.path:
    sys.path.insert(0, pcbnew_path)
try:
    import pcbnew
except ImportError as e:
    print(f"Error: Could not import pcbnew. {e}")


class BoardProcessor:
    def __init__(self, board_file, out_dir, target_layer_name):
        self.board = pcbnew.LoadBoard(board_file)
        self.out_dir = out_dir
        self.pctl = pcbnew.PLOT_CONTROLLER(self.board)
        self.target_layer = pcbnew.B_Cu if target_layer_name == "B_Cu" else pcbnew.F_Cu
        self._setup_plot_options()

    def _setup_plot_options(self):
        popt = self.pctl.GetPlotOptions()
        popt.SetOutputDirectory(self.out_dir)
        popt.SetPlotFrameRef(False)
        popt.SetSketchPadLineWidth(pcbnew.FromMM(0.1))
        popt.SetAutoScale(False)
        popt.SetScale(1)
        popt.SetMirror(False)

    def plot_current_state(self, prefix, index):
        filename = f"{prefix}_{index:05d}"
        layers = pcbnew.LSEQ()
        layers.append(self.target_layer)
        layers.append(pcbnew.Edge_Cuts)

        self.pctl.OpenPlotfile(filename, pcbnew.PLOT_FORMAT_SVG, "Step Plot")
        self.pctl.PlotLayers(layers)
        self.pctl.ClosePlot()

    def get_all_items(self):
        return {
            "ZONE": list(self.board.Zones()),
            "TRACK": list(self.board.GetTracks()),
            "FOOTPRINT": list(self.board.GetFootprints()),
            "DRAWING": list(self.board.GetDrawings()),
        }

    def remove_item(self, item):
        """物理削除。Cレベルの警告出力を完全に封じ込める"""
        try:
            self.board.Remove(item)
        except Exception:
            pass
python
import subprocess
import sys
import os
import glob


def run_command(cmd, description):
    """外部コマンドを実行し、エラーがあれば停止"""
    print(f"\n>>> {description}...")
    try:
        subprocess.run(cmd, shell=True, check=True)
    except subprocess.CalledProcessError as e:
        print(f"\nError during {description}")
        sys.exit(1)


def svg_to_png(out_dir, dpi):
    """SVGをPNGに変換"""
    run_command(
        f"mogrify -format png -density {dpi} {out_dir}/*.svg",
        "It may take a while...",
    )
    for f in glob.glob(os.path.join(out_dir, "*.svg")):
        os.remove(f)


def compile_video(board_name, layer, list_file, fps, width, crf):
    """PNG画像をつなぎ合わせて動画にする"""
    out_mp4 = f"{board_name}_{layer}.mp4"
    out_gif = f"{board_name}_{layer}.gif"
    filters = f"pad=ceil(iw/2)*2:ceil(ih/2)*2,scale={width}:-1:flags=lanczos"

    run_command(
        f"ffmpeg -y -f concat -safe 0 -i {list_file} -vf '{filters},format=yuv420p' -r {fps} -c:v libx264 -crf {crf} {out_mp4} >/dev/null 2>&1",
        "Compiling High-Quality Video",
    )

    # GIFに変換
    run_command(
        f"ffmpeg -y -i {out_mp4} {out_gif} >/dev/null 2>&1",
        "Converting MP4 to High-Quality GIF",
    )

ワークフローのうち2~6をboard_process.pyの関数が、また7と8をexternal_tools.pyの関数がそれぞれ担っています。

main.pyの期待する引数は以下の通りです。

引数説明デフォルト
第1引数レイヤー選択(F_Cu or B_CuF_Cu
第2引数入力ファイルへの相対パスsample.kicad_pcb
第3引数出力する動画ファイルの名前部分sample

シェルコマンドの一例

shell
apt install kicad imagemagick ffmpeg

python3 main.py B_Cu awesome.kicad_pcb awesome_timelapse

TIP

実行の可否に関わらず、以下のような警告が大量に出力されると予想されます。

swig/python detected a memory leak of type 'PCB_TRACK *', no destructor found.

ターミナルでは最後にまとめて出力されるかもしれませんが、これは実際にはboard_process.py内のremove_item()で逐一発生しており、itemをremoveすることによって内部のC++からitemへのポインタがnullになってしまうことが原因です。今回は単発のスクリプト内での事象なので危険視せず放置してしまっています。

なお正攻法としてはitemをUser1など無関係なレイヤーに逃がすという手があるのですが、これを試してみると一部のitemがプロット上に残ってしまう(最終的に何も無い状態にならない)という謎の不具合が代わりに生じてしまったため、採用していません。

TIP

当然ですが、基板の規模によっては(特にSVG→PNGの処理に)膨大なリソースと時間を要します。筆者の割と強いデスクトップPCでも冒頭のArduino UNOの処理に8分ほどかかっているので、お手元の計算機の性能には十分に留意してください。

おわりに

無事にMP4動画とGIF画像が出力されたでしょうか?そのままだと空白部分が大きすぎるのでクロップしたいところですが、GIF画像をアニメーションのままクロップする方法は意外と限られていて少し不便です。私はEZgifを利用しました。

CC-BY-SA-4.0