ノート

2026-06-16

NavigationToolbar2QT のカスタマイズ 〜 Matplotlib 〜

Matplotlib は、Python と NumPy のためのプロットライブラリです。Tkinter、wxPython、Qt、GTK のような汎用 GUI ツールキットを使ったアプリケーションにプロットを埋め込むためのオブジェクト指向 API を提供しています。

Wikipedia より引用、翻訳

PySide6 の GUI で Matplotlib のチャートを扱うときに利用する、ズームや保存などの便利な機能を提供するナビゲーション・ツールバー NavigationToolbar2QT について、ユーザー定義のボタンを追加する方法(+α)をまとめました。

下記の環境で動作確認をしています。

Fedora Linux 44
KDE Plasma Desktop x86_64
Python 3.14.5
matplotlib 3.11.0
pyside6 6.11.1
seaborn 0.13.2

実行例

サンプルとして正規分布の乱数のヒストグラムを表示していますが、今回のテーマは、このチャートの下に表示されているナビゲーション・ツールバーのカスタマイズです。

ナビゲーション・ツールバーのカスタマイズ例として、「保存」ボタンの右側に「Qt」アイコン (SP_TitleBarMenuButton) を表示しています。

qt_matplotlib/toolbar.py の実行例

NavigationToolbar2QT を継承

NavigationToolbar2QT を継承して「Qt」アイコン (SP_TitleBarMenuButton)(ボタン)を追加しています。

from matplotlib.backends.backend_qtagg import (
    NavigationToolbar2QT as NavigationToolbar,
)

class MyNavToolbar(NavigationToolbar):
    def __init__(self, canvas: FigureCanvas):
        super().__init__(canvas)
        user_action = QAction("User", self)
        icon = self.style().standardIcon(
            QStyle.StandardPixmap.SP_TitleBarMenuButton
        )
        user_action.setIcon(icon)
        user_action.triggered.connect(self.on_user_action)

        actions = self.actions()

        n = len(actions)
        self.insertAction(actions[n - 1], user_action)

    def on_user_action(self):
        # Zoomモードなら解除
        if self._actions["zoom"].isChecked():
            self._actions["zoom"].trigger()
        # Panモードなら解除
        if self._actions["pan"].isChecked():
            self._actions["pan"].trigger()

        print("User button clicked")

「Qt」アイコン (SP_TitleBarMenuButton)(ボタン)をクリックすると、on_user_action メソッドが実行されて文字列が標準出力されますが、その他の機能として、ズームボタン self._actions["zoom"] あるいはパン(移動)ボタン self._actions["pan"] が選択状態であれば解除するようにしています。

サンプル・コード

サンプルコード qt_matplotlib_toolbar.py を示しました。

qt_matplotlib/toolbar.py

参考サイト

  1. Embedding in Qt — Matplotlib documentation

ブログランキング・にほんブログ村へ bitWalk's - にほんブログ村 にほんブログ村 IT技術ブログ オープンソースへ
にほんブログ村

オープンソース - ブログ村ハッシュタグ
#オープンソース



このエントリーをはてなブックマークに追加

2026-05-08

【備忘録】product による多重ループ 〜 Python

このところ、利用するプログラミング言語は、すっかり Python だけになってしまいました。しかし、Python の入門書で網羅的に勉強してこなかったツケを感じています。あるデータの集計方法について、生成 AI に教えてもらった内容があまりに素晴らしくて悔しい思いをしました。いまだに Python の標準ライブラリを使いこなせないのは恥ずかしいかぎりです。

生成 AI に教えてもらってその場の対応だけで満足してしまうと、後日、同じような場面で同じような質問を生成 AI に尋ねて、もっと悔しい思いをするのは明白なので、忘れないように事象を単純化して備忘録にしました。

今回の目的
  • 完全実施要因計画 (Full Factorial Design) の実験データを各実験因子毎に集計したい。
    • ただし、実験因子候補のパラメータは多数あり、実用上、実験因子にするのはその一部で、実験の度に実験因子や実験水準を変更。
    • 実験因子をリストで指定するだけで、実験水準を調べて実験因子の水準の組み合わせ毎に集計したい。

下記の OS 環境で動作確認をしています。

Fedora Linux 44
Workstation Edition x86_64
Python 3.14.4
jupyterlab 4.5.7

単純化した問題

問題を単純化して Jupyter Lab 上で集計する例をまとめました。

ライブラリのインポート

まず、使用するライブラリをまとめてインポートします。

import operator
from functools import reduce
from itertools import product

import pandas as pd

実験データの読み込み

実験データ(サンプル)は、下記からダウンロードできます。

sample_doe.zip
# 実験結果を読み込む
file = "sample_doe.zip"
df = pd.read_csv(file)
df

実験因子は A と B で、それぞれ2水準です。Y は結果(特性値)です。

BLK は実験の実施単位(ブロック因子)です。仮に日付と見なせば、3日間、完全実施要因計画 (Full Factorial Design) の(ランダマイズ無しに)実験を繰り返したことになります。

実験因子の指定と実験水準の確認

実験因子の指定は仕方がないとしても、実験水準の確認はプログラムで調べます。

# 実験因子
list_factor = ["A", "B"]
# 各実験因子毎の実験水準
levels = [sorted(list(df[factor].unique())) for factor in list_factor]
levels
[[np.int64(1), np.int64(2)], [np.int64(1), np.int64(2)]]

サンプルを単純化しているので触れませんが、現実に使っている実験因子+結果のデータは、実験因子に指定していないパラメータ列も含んでいるので、水準が複数あるパラメータを実験因子として抽出するようにしています。

集計処理

集計ループの部分が、今回の目的になります。itertools.product を利用すると各リストの要素の組み合わせをタプルで取得できます。

# 各因子、各実験水準毎に平均値を集計
rows = []
for combo in product(*levels):
    condition = dict(zip(list_factor, combo))
    mask = reduce(operator.and_, (df[k] == v for k, v in condition.items()))
    avg = df[mask]["Y"].mean()
    row = {**condition, "avg": avg}
    rows.append(row)

rows
[{'A': np.int64(1), 'B': np.int64(1), 'avg': np.float64(0.38033333333333336)},
 {'A': np.int64(1), 'B': np.int64(2), 'avg': np.float64(0.5573333333333333)},
 {'A': np.int64(2), 'B': np.int64(1), 'avg': np.float64(0.701)},
 {'A': np.int64(2), 'B': np.int64(2), 'avg': np.float64(0.7893333333333333)}]

ループを抜けた後ですが、気になる変数の内容を確認します。

combo
(np.int64(2), np.int64(2))
condition
{'A': np.int64(2), 'B': np.int64(2)}
mask
0     False
1     False
2     False
3      True
4     False
5     False
6     False
7      True
8     False
9     False
10    False
11     True
dtype: bool

集計結果をデータフレーム化

最後に、rows をデータフレームにします。

df_summary = pd.DataFrame(rows)
# 行、列が長い時に省略されないように一時的に最大設定を解除
with pd.option_context('display.max_rows', None, 'display.max_columns', None):
    print(df_summary)
   A  B       avg
0  1  1  0.380333
1  1  2  0.557333
2  2  1  0.701000
3  2  2  0.789333

まとめ

今回使用した product は、Python のドキュメント [2] によると、「デカルト積、ネストしたforループと等価」とあります。Python のコーディングではいつもお世話になっているサイト note.nkmk.me にも、profuct についてサンプル付きで紹介されています [4]

しかし、問題意識が無い状態で読んでも心に響きません。心に響かなければ記憶にも残らないので、いざ必要な場面に遭遇しても結びつかず、泥臭いやり方でコーディングをする愚を冒します。

今回は実験因子が A と B の2つだけなので、product のありがたみが薄いのですが、因子数が増えても同じループで処理できるので、とても助かります。あやうく、因子数に応じた多重ループを用意してしまうところでした。

生成 AI に尋ねると、自分が知らなかったこと、記憶にあるけど活用できないことをぐいぐい活用していて驚くことがあります。これは、単に自分がノウハウを持っていないことに他ならないので、コーディングが面倒だと感じたら、迷わず生成 AI に尋ねる習慣がついてしまいました。

参考サイト

  1. functools --- 高階関数と呼び出し可能オブジェクトの操作 — Python ドキュメント
  2. itertools --- 効率的なループ用のイテレータ生成関数群 — Python ドキュメント
  3. operator --- 関数形式の標準演算子 — Python ドキュメント
  4. Pythonで複数のリストの直積(デカルト積)を生成するitertools.product | note.nkmk.me

 

ブログランキング・にほんブログ村へ bitWalk's - にほんブログ村 にほんブログ村 IT技術ブログ オープンソースへ
にほんブログ村

オープンソース - ブログ村ハッシュタグ
#オープンソース



このエントリーをはてなブックマークに追加

2026-04-07

【備忘録】SB3 と TensorBoard

Stable Baselines3 (SB3) は、PyTorch をベースに実装した、信頼性の高い強化学習アルゴリズム(PPO, DQN, SAC 等)のオープンソースライブラリです。OpenAI Baselines の改良版で、統一された API により、数行のコードで環境の学習・評価・保存が容易に行えるため、研究やプロトタイピングの効率を大幅に向上できます。

強化学習モデルの学習中に、エピソード毎に解析したい情報を引き出す方法について Microsoft Copilot(以降、単に Copilot)に相談していたところ TensorBoard で可視化する話がでたので、突っ込んで聞いてみました。TensorBoard はその名前から TensorFlow の専用ツールだと勝手に思っていたのですが、全くの誤解でした。

知らない」っていうのは怖いですね。TensorBoard を利用することで RL(強化学習)モデルの学習時の内部状態を(ほぼ)リアルタイムで確認できるようになりました。

TensorBoard について

Copilot が TensorBoard について下記のようにまとめてくれました。

  • 学習中の指標(episode reward, loss など)をグラフでリアルタイム可視化するツール
  • ブラウザで http://localhost:6006 を開いて眺めるスタイル
  • TensorBoard は TensorFlow 専用ではない
  • ログファイルを読み取るだけの汎用可視化サーバー
  • SB3 は標準で TensorBoard 出力に対応している
  • RL の “見えない内部状態” を可視化する最強ツール

TensorBoard の基本的な使い方

備忘録として、以前とりあげた倒立振子問題 (CartPole) [3] の学習を TensorBoard で可視化します。

動作環境は、パッケージのバージョンが更新されていることを除けば、倒立振子問題 (CartPole) の記事 [3] と同じですが、さらに TensorBoard をインストールします。

$ pip install tensorboard

TensorBoard を利用するには、モデル生成時、tensorboard_log に TensorBoard 用ログの出力ディレクトリ(存在していなくとも良い)を指定します。

model = PPO(
    "MlpPolicy",
    env,
    verbose=1,
    tensorboard_log="./ppo_cartpole_tensorboard/",
)

編集したプログラム cartpole_rl.py を起動して学習を始めた後に、別ターミナルで TensorBoard を起動します。その際に --logdir に、モデル生成時に指定したログの出力ディレクトリを指定します。

$ tensorboard --logdir ./ppo_cartpole_tensorboard/

ブラウザで http://localhost:6006 を開きます。

他の PC からアクセスする場合 [AlmaLinux 10]

LAN 内の他の PC のブラウザからアクセスをしたい場合は、TensorBoard のポート番号がファイアウォールを通るようにしておきます。

$ sudo firewall-cmd --add-port=6006/tcp --permanent
$ sudo firewall-cmd --reload

TensorBoard を起動する時に外部からアクセスできるように --bind_all を付けます。

$ tensorboard --logdir ./ppo_cartpole_tensorboard/ --bind_all

実行例

TensorBoard 用のログを出力できるようにしたプログラム cartpole_rl.py を起動します。

cartpole_rl.py の実行例
$ python cartpole_rl.py
Using cpu device
Wrapping the env in a DummyVecEnv.
Logging to ./ppo_cartpole_tensorboard/PPO_1
---------------------------------
| rollout/           |          |
|    ep_len_mean     | 20.3     |
|    ep_rew_mean     | 20.3     |
| time/              |          |
|    fps             | 46       |
|    iterations      | 1        |
|    time_elapsed    | 44       |
|    total_timesteps | 2048     |
---------------------------------
-----------------------------------------
| rollout/                |             |
|    ep_len_mean          | 26.4        |
|    ep_rew_mean          | 26.4        |
| time/                   |             |
|    fps                  | 46          |
|    iterations           | 2           |
|    time_elapsed         | 88          |
|    total_timesteps      | 4096        |
| train/                  |             |
|    approx_kl            | 0.008908875 |
|    clip_fraction        | 0.0802      |
|    clip_range           | 0.2         |
|    entropy_loss         | -0.686      |
|    explained_variance   | 0.000353    |
|    learning_rate        | 0.0003      |
|    loss                 | 6.18        |
|    n_updates            | 10          |
|    policy_gradient_loss | -0.0121     |
|    value_loss           | 45.3        |
-----------------------------------------
...
(以降省略)

別ターミナルで TensorBoard を起動します。

$ tensorboard --logdir ./ppo_cartpole_tensorboard/

Firefox を起動して http://localhost:6006 にアクセスします。

Firefox 上に表示した TensorBoard(例)

参考サイト

  1. Tensorboard Integration — Stable Baselines3 documentation
  2. tensorflow/tensorboard: TensorFlow's Visualization Toolkit
  3. bitWalk's: 倒立振子問題 (CartPole) [2025-10-03]

 

ブログランキング・にほんブログ村へ bitWalk's - にほんブログ村 にほんブログ村 IT技術ブログ オープンソースへ
にほんブログ村

オープンソース - ブログ村ハッシュタグ
#オープンソース



このエントリーをはてなブックマークに追加

2026-03-12

TA-Lib の Streaming API

2001 年にリリースされた TA-Lib, Technical Analysis Library は、150 以上のテクニカル指標をサポートしていて、長年にわたる検証を経ています。現在の Python 用パッケージは、Cython と Numpy を用いて効率的かつクリーンに TA-Lib をバインドしています。TA-Lib は BSD License (BSD-2-Clause license) の元で配布されているオープンソースのライブラリです。

株価の値動きなどを分析する際に TA-Lib のパッケージを利用していますが、デイトレ用のリアルタイム・システムで利用しようとすると、ちょっと扱いにくいと感じていました。それは、TA-Lib では全てのデータ列に対して計算するからです。そのため、リアルタイムの処理では専用の処理をするクラスを用意して対応しているのですが、計算負荷を少なくするための検討は結構面倒な作業です。

Github の TA-Lib / ta-lib-python プロジェクトサイトにある README をよく読むと、実験段階ながら Streaming API が提供されていることがわかりました。

詳細なベンチマークテストをしていませんが(下に追記しました [2026-03-12])、リアルタイム風に動作するサンプルを作ったので紹介します。

下記の OS 環境で動作確認をしています。

Fedora Linux 43 x86_64
Workstation Edition
Python 3.14.3
numpy 2.4.3
pandas 3.0.1
pyqtgraph 0.14.0
pyside6 6.10.2
ta-lib 0.6.8

TA-Lib の Streaming API とは

従来の API は、過去データ全体に対してインジケーターを計算する設計です。

import numpy as np
import talib as ta

# 過去のデータ全体を一度に処理
prices = np.array([100, 101, 102, 103, 104, 105], dtype=float)
ma = ta.SMA(prices, timeperiod=3)
# 結果:
print(ma)
[ nan  nan 101. 102. 103. 104.]

Streaming API は、直近のデータウィンドウに対してインジケーターを計算します。

import numpy as np
from talib import stream

# 新しいデータポイントが到着するたびに計算
buffer = np.array([100, 101, 102], dtype=float)
ma = stream.SMA(buffer, timeperiod=3)
print(ma)

# 次のデータが到着
buffer = np.array([101, 102, 103], dtype=float)
ma = stream.SMA(buffer, timeperiod=3)
print(ma)
101.0
102.0

Streaming API の主な特徴

  1. 固定長バッファで動作
    • 計算に必要な期間(例:30 期間)のデータのみを保持
    • メモリ効率が良い
  2. 最新値のみを返す
    • 従来の API は配列全体を返すが、Streaming API は単一の値を返す
      • Streaming API は、渡された配列全体に対してインジケーターを計算し、最後の値だけを返す関数です。つまり 1000 個のデータを渡せば 1000 個分の計算をしてしまいます。
      • そのため、プログラム側で固定長バッファを管理する必要があります。
    • リアルタイムモニタリングに最適
  3. 実験的機能 (Experimental)
    • まだ開発中の機能なので、将来的に仕様が変更される可能性がある
    • プロダクション環境では慎重に使用すること

サンプル

ティックデータから単純な移動平均 MA (n=30) を算出して、株価トレンドと一緒にプロットするサンプルです。チャート作成には PyQtGraph を利用しています。

ツールバーにある ▶(再生ボタン)をクリックすると過去のティックデータ(下記サンプル)を読み込み、100 msec 間隔で新しい点を繋げてプロットします。MA を算出できるようになったら緑線で表示されるようになります。

放っておけばデータの最後までプロットしますが、途中で止める場合は、ツールバーにある ⏹(停止ボタン)をクリックします。

sample_data.zip サンプルプログラムが読み込むティックデータ

※ このティックデータは、楽天証券のマーケットスピード2 RSS 経由で自作アプリが収集した 2 秒間隔のティックデータです。

上記のサンプルデータを zip のままで、下記サンプルと同じディレクトリ内に保存してサンプルを実行してください。

import sys
from typing import Optional

import numpy as np
import pandas as pd
import pyqtgraph as pg
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication, QMainWindow, QStyle, QToolBar, QToolButton
from talib import stream


class SampleChart(pg.PlotWidget):
    def __init__(self, ma_period: int = 30) -> None:
        super().__init__()
        self.ma_period = ma_period

        # リストで保持(append が高速)
        self.data_x: list[float] = []
        self.data_y: list[float] = []
        self.data_ma: list[float] = []

        # streaming APIの状態を保持するためのバッファ
        self.y_buffer = np.array([], dtype=float)

        self.line: pg.PlotDataItem = self.plot([], [], pen=pg.mkPen(width=0.5))
        self.ma: pg.PlotDataItem = self.plot([], [], pen=pg.mkPen((0, 255, 0, 192), width=1))

    def add_point(self, x: float, y: float) -> None:
        """新しいデータポイントを追加"""
        self.data_x.append(x)
        self.data_y.append(y)

        # バッファを更新(直近ma_period個のデータのみ保持)
        self.y_buffer = np.append(self.y_buffer, y)
        if len(self.y_buffer) > self.ma_period:
            self.y_buffer = self.y_buffer[-self.ma_period:]

        # streaming APIで移動平均を計算
        if len(self.y_buffer) >= self.ma_period:
            self.data_ma.append(stream.SMA(self.y_buffer, timeperiod=self.ma_period))

        # グラフを更新
        self.line.setData(self.data_x, self.data_y)  # type: ignore

        # MA期間に達したらMAラインを表示
        if len(self.data_ma) > 0:
            ma_start = self.ma_period - 1
            self.ma.setData(self.data_x[ma_start:], self.data_ma)  # type: ignore


class SampleTaLib(QMainWindow):
    def __init__(self) -> None:
        super().__init__()
        self.file_csv = "sample_data.zip"  # ZIP圧縮されたCSVファイルを読み込む
        self.df: Optional[pd.DataFrame] = None
        self.row: int = 0

        self.setWindowTitle("TA-Lib Streaming API Demo")
        self.resize(800, 600)

        # ツールバーの設定
        toolbar = QToolBar()

        but_play = QToolButton()
        but_play.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay))
        but_play.clicked.connect(self.on_play_clicked)
        toolbar.addWidget(but_play)

        but_stop = QToolButton()
        but_stop.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaStop))
        but_stop.clicked.connect(self.on_stop_clicked)
        toolbar.addWidget(but_stop)

        self.addToolBar(toolbar)

        # チャートの設定
        self.chart = SampleChart(ma_period=30)
        self.setCentralWidget(self.chart)

        # タイマーの設定
        self.timer = QTimer()
        self.timer.setInterval(100)
        self.timer.timeout.connect(self.set_new_data)

    def on_play_clicked(self) -> None:
        """再生ボタンクリック時の処理"""
        try:
            # データ未読み込みの場合のみ読み込む
            if self.df is None:
                self.df = pd.read_csv(self.file_csv)
                print(f"CSVファイルを読み込みました: {len(self.df)}行")

                # データの検証
                if len(self.df.columns) < 2:
                    print("エラー: CSVファイルには少なくとも2列必要です")
                    return

            if not self.timer.isActive():
                self.timer.start()
                print("タイマーを開始しました。")

        except FileNotFoundError:
            print(f"エラー: ファイル '{self.file_csv}' が見つかりません")
        except Exception as e:
            print(f"エラー: {e}")

    def on_stop_clicked(self) -> None:
        """停止ボタンクリック時の処理"""
        if self.timer.isActive():
            self.timer.stop()
            print("タイマーを停止しました。")

    def set_new_data(self) -> None:
        """新しいデータをチャートに追加"""
        if self.df is None or self.row >= len(self.df):
            self.on_stop_clicked()
            print("データの最後に到達しました。")
            return

        try:
            x, y = self.df.iloc[self.row, 0], self.df.iloc[self.row, 1]
            self.chart.add_point(float(x), float(y))
            self.row += 1
        except Exception as e:
            print(f"データ追加エラー: {e}")
            self.on_stop_clicked()


def main() -> None:
    app = QApplication(sys.argv)
    win = SampleTaLib()
    win.show()
    sys.exit(app.exec())


if __name__ == "__main__":
    main()
CSVファイルを読み込みました: 9691行
タイマーを開始しました。
タイマーを停止しました。
サンプルの実行例

ベンチマーク 追記 [2026-03-12]

Anthropic Claude にベンチマーク用のコードを作ってもらいました。現行のアプリに採用している移動平均を算出するクラスのコア部分を抽出したクラスと、今回の TA-Lib の Streaming API を適用したクラスで比較しました。

import time
from collections import deque
import numpy as np
from talib import stream

# 現在の実装
class MovingAverage:
    def __init__(self, window_size: int):
        self.window_size = window_size
        self.queue = deque()
        self.running_sum = 0.0
        self.ma = 0.0
        self.prev_ma = 0.0
    
    def update(self, value: float) -> float:
        if len(self.queue) >= self.window_size:
            self.running_sum -= self.queue.popleft()
        self.queue.append(value)
        self.running_sum += value
        self.prev_ma = self.ma
        self.ma = self.running_sum / len(self.queue)
        return self.ma

# TA-Lib版
class MovingAverageTA:
    def __init__(self, window_size: int):
        self.window_size = window_size
        self.buffer = deque(maxlen=window_size)
        self.ma = 0.0
        self.prev_ma = 0.0
    
    def update(self, value: float) -> float:
        self.buffer.append(value)
        if len(self.buffer) >= self.window_size:
            self.prev_ma = self.ma
            buffer_array = np.array(self.buffer, dtype=float)
            self.ma = stream.SMA(buffer_array, timeperiod=self.window_size)
        return self.ma

# ベンチマーク
def benchmark(ma_class, iterations=100000):
    ma = ma_class(30)
    start = time.perf_counter()
    for i in range(iterations):
        ma.update(float(i))
    elapsed = time.perf_counter() - start
    return elapsed

# 実行
time_custom = benchmark(MovingAverage)
time_talib = benchmark(MovingAverageTA)

print(f"現在の実装: {time_custom:.4f}秒")
print(f"TA-Lib版:   {time_talib:.4f}秒")
print(f"速度比:     {time_talib/time_custom:.2f}倍遅い")

デイトレ・アプリを稼働させている Intel N150 搭載の Windows 11 PC 上でのベンチマークの結果は下記のとおりでした。残念ながら Python だけで記述したクラスの方が全然速いという結果になりました。

現在の実装: 0.0263秒
TA-Lib版:   0.5771秒
速度比:     21.98倍遅い

参考サイト

  1. TA-Lib - Technical Analysis Library
  2. TA-Lib/ta-lib: TA-Lib (Core C Library)
  3. TA-Lib/ta-lib-python: Python wrapper for TA-Lib
  4. TA-Lib · PyPI

 

ブログランキング・にほんブログ村へ bitWalk's - にほんブログ村 にほんブログ村 IT技術ブログ オープンソースへ
にほんブログ村

オープンソース - ブログ村ハッシュタグ
#オープンソース



このエントリーをはてなブックマークに追加

2026-01-24

シンプルな PDF Viewer 〜 PySide6

PySide (Qt for Python) は、Qt(キュート)の Python バインディングで、GUI などを構築するためのクロスプラットフォームなライブラリです。Linux, macOS および Microsoft Windows をサポートしています。配布ライセンスは LGPL で公開されています。

PySide6 の GUI アプリで PDF ファイルを閲覧するサンプルを Microsoft Copilot に手伝ってもらいながら作成しました。

ここからカスタマイズしたい処理を加えていく予定なのですが、閲覧のための最低限の機能をコンパクトに実装できたので、一旦はサンプルとしてまとめました。

下記の OS 環境で動作確認をしています。

Fedora Linux 43
Workstation Edition x86_64
Python 3.13.11
PySide6 6.10.1
qt_pdfview.py

以下に実行例を示しました。

ツールバーのアイコンは、Qt にビルトイン・アイコンを利用しました。そのため、ダークモードではアイコンが見えなくなってしまう場合があります。

qt_pdfview.py の実行例

参考サイト

  1. PySide6.QtPdf.QPdfDocument - Qt for Python
  2. PySide6.QtPdfWidgets.QPdfView - Qt for Python

 

ブログランキング・にほんブログ村へ bitWalk's - にほんブログ村 にほんブログ村 IT技術ブログ オープンソースへ
にほんブログ村

オープンソース - ブログ村ハッシュタグ
#オープンソース



このエントリーをはてなブックマークに追加

2026-01-22

【備忘録】xlwings と QThread

xlwings は、Python を使って Excel を直接操作したり、Excel から Python コードを呼び出したりできるライブラリです[BSD ライセンス]。VBA マクロの代替として Python で Excel の自動化(スクリプト作成、マクロ、ユーザー定義関数(UDF))を実現でき、Excel と Python のデータをシームレスに連携させ、双方の得意な部分を組み合わせて強力な自動化・データ処理を可能にします。

今回の目的
  • PySide6 の GUI アプリから Excel を操作しようと、Excel とやり取りをする部分をスレッド化したところ、うまくいかずにハマってしまいました。
  • 生成 AI から「PySide6 × xlwings を使う人が必ず通る“最大の罠”なんだよ」なんて言われていたのに、二度も同じ間違いをしてしまったので、備忘録にまとめました。

下記の OS 環境で動作確認をしています。

Windows 11 Pro 25H2
Excel 2024 MSO
Python 3.13.9
PySide6 6.10.1
xlwings 0.33.20

簡単なサンプル(GUI 無し)

まずは、xlwings を利用した、ごく簡単な GUI 無しのサンプルです。

import xlwings as xw

if __name__ == "__main__":
    wb = xw.Book()  # 新しいワークブックを開く
    sheet = wb.sheets["Sheet1"]  # シート・オブジェクトをインスタンス化
    sheet["A1"].value = "Python から書き込みました。"  # 値の書き込み
「簡単なサンプル」の実行例

GUI サンプル

GUI のプッシュボタンをクリックして、簡単なサンプル(GUI 無し)と同じ処理を実行するサンプルです。

このサンプルは Excel へ文字列を書き込むだけですが、複雑な処理を加えても GUI のイベントループに影響を与えないように、「別スレッド」で Excel へアクセスする処理をするようにしています。

import sys

import xlwings as xw
from PySide6.QtCore import (
    QObject,
    QThread,
    Signal,
    Slot,
)
from PySide6.QtGui import QCloseEvent
from PySide6.QtWidgets import (
    QApplication,
    QPushButton,
    QVBoxLayout,
    QWidget,
)


class ExcelWorker(QObject):
    def __init__(self):
        super().__init__()
        self.sheet = None

    @Slot()
    def initWorker(self):
        wb = xw.Book()  # 新しいワークブックを開く
        self.sheet = wb.sheets["Sheet1"]  # シート・オブジェクトをインスタンス化

    @Slot(str)
    def excel_write(self, msg: str):
        self.sheet["A1"].value = msg  # 値の書き込み


class SampleXlwings(QWidget):
    requestWorkerInit = Signal()
    requestWrite = Signal(str)

    def __init__(self):
        super().__init__()
        # xlwings用スレッド
        self.thread = thread = QThread()
        self.worker = worker = ExcelWorker()
        worker.moveToThread(thread)
        thread.started.connect(self.requestWorkerInit.emit)
        self.requestWorkerInit.connect(worker.initWorker)
        self.requestWrite.connect(worker.excel_write)
        thread.start()
        # GUI
        layout = QVBoxLayout()
        self.setLayout(layout)
        but = QPushButton("新規 Excel へ文字列を書き込む")
        but.clicked.connect(self.request_write)
        layout.addWidget(but)

    def closeEvent(self, event: QCloseEvent):
        if self.thread is not None:
            self.thread.quit()
            self.thread.wait()
        if self.worker is not None:
            self.worker.deleteLater()
            self.worker = None
        event.accept()

    @Slot()
    def request_write(self):
        msg = "Python から書き込みました。"
        self.requestWrite.emit(msg)


def main():
    app = QApplication(sys.argv)
    win = SampleXlwings()
    win.show()
    sys.exit(app.exec())


if __name__ == "__main__":
    main()

なお、PySide6 では @Slot のデコレータを省略しても動作します。

「GUI サンプル」の実行例

GUI サンプルを実行すると、Excel の新しいワークブックが開きます。

新規 Excel へ文字列を書き込む ボタンをクリックすると、「簡単なサンプル」と同じように、文字列が Excel シートに書き込まれます。

注意点

ExcelWorker クラスのコンストラクタ __init__ メソッドで、以下のように xlwings のインスタンスを定義すると動作しません。

class ExcelWorker(QObject):
    def __init__(self):
        super().__init__()
        wb = xw.Book()  # 新しいワークブックを開く
        self.sheet = wb.sheets["Sheet1"]  # シート・オブジェクトをインスタンス化

この場合、下記のようなエラーが出ます。

pywintypes.com_error: (-2147417842, 'アプリケーションは、別のスレッドにマーシャリングされたインターフェイスを呼び出しました。', None, None)

メインスレッドで ExcelWorker のインスタンスを定義するので、ExcelWorker.__init__ も当然、「メインスレッド」で実行されます。

「別スレッド」で Excel とやり取りをしたければ、「別スレッド」を thread.start() で開始した後に、その「別スレッド」内で xlwings のインスタンスを定義する必要があります。

そういうわけで、「別スレッド」が開始されたときに初期化用のメソッド ExcelWorker.initWorker を実行するようにしています。

self.thread = thread = QThread()
self.worker = worker = ExcelWorker()
worker.moveToThread(thread)
thread.started.connect(self.requestWorkerInit.emit)  # 「別スレッド」が開始されればシグナルを発行
self.requestWorkerInit.connect(worker.initWorker)
self.requestWrite.connect(worker.excel_write)
thread.start()  # 「別スレッド」開始
    @Slot()
    def initWorker(self):
        wb = xw.Book()  # 新しいワークブックを開く
        self.sheet = wb.sheets["Sheet1"]  # シート・オブジェクトをインスタンス化

参考サイト

  1. xlwings Documentation
  2. ASCII.jp:COM(Component Object Model)は古い技術だが、いまだに現役 あらためて解説する (1/2) [2023-04-23]

 

ブログランキング・にほんブログ村へ bitWalk's - にほんブログ村 にほんブログ村 IT技術ブログ オープンソースへ
にほんブログ村

オープンソース - ブログ村ハッシュタグ
#オープンソース



このエントリーをはてなブックマークに追加