粗大メモ置き場

個人用,たまーに来訪者を意識する雑記メモ

PytorchのPretrained Modelを使ってSegmentationを行う個人メモ

はじめに

本記事はあくまでML初心者の筆者の個人メモです。

pytorchの出来合いのモデルを使って画像認識タスクのうちSegmentationを行うことを目標にします。

実行環境

環境が汚れにくく、実行も高速なGoogleColabを使用します。

必要なデータはwgetなどでDLしてきても良いですし、下記のコマンドで簡単にGoogleDriveとも接続できるので簡単で便利です。

# mount drive
from google.colab import drive
drive.mount('/content/drive')

torchvisionのモデルを使ったsegmentation例

pytorchで使用できる既製のモデルはいくつかありますが、ひとまずtorchvisionで使えるモデルを使ってsegmentationを行っていきます。

pytorch.org

先に作例を示すと 某所から借りてきた星野源氏の下記写真から人物の部分のみを抜き出すことができたりします。

f:id:ossyaritoori:20211205164710p:plain
星野源 氏の写真

f:id:ossyaritoori:20211205165400p:plain
人物の領域の抜き出し例(マスク未Normalize)

1. モデルを選んでロード

はじめに、欲しい機能を実現するモデル(と学習済み重み)を選びます。
モデルはCNNのネットワーク構造、学習済みの重みはどのデータセットで学習したかを表します。

学習済み重みをどのデータセットで学習したかにはきちんと気を配る必要があり、例えばtorchvisionのモデルは人等を含む20クラス分類でしか学習していないのでそこにない物体を検知・抽出するには新たに転移学習をする必要があります。

下記では試しにFCNのresnet50を選んで試してみます。

import torchvision
# 試しにresnet50を用いる
model = torchvision.models.segmentation.fcn_resnet50(pretrained=True) # pretrained = Trueとすることで学習済みのモデルがセットされた状態になる。
model.eval() # モデルを評価用に切り替える。逆に学習するときはmodel.train()とする。おまじないと思って良い。

2. モデルのパラメータを確認

モデルを選んだら次は下記のパラメータを事前に確認しておきます。これは後述の画像を入力するときに必要になります。

  • モデルの入力となる画像のサイズ
  • 学習時の正規化項(mean, std)

f:id:ossyaritoori:20211205173540p:plain
詳しくはこのあたりを参照にしてください

今回のFCNのresnet50の場合、

  • 入力の画像サイズ:224x224
  • 正規化項:mean = [0.485, 0.456, 0.406] and std = [0.229, 0.224, 0.225] (範囲が0〜1であることに注意)

となります。

3. 画像の読み込みとモデルへの入力

以上の情報をもとに画像をモデルへとmodel(img)と入力すれば結果を得られるのですが、ここで前処理と型変換が問題になってきます。

よくあるNumpyの画像が[縦、横、RGBチャンネル数]のnp.arrayとなっているのに対し、今回使うsegmentationのモデルでは[バッチ数, カラーのチャンネル数, 横, 縦]という並びのTensorになっていなければいけません。

実際のコードでは同じ画像を下記の形式で行ったり来たり初学者にはとてもconfusingです

  • numpyのarray(OpenCVと連携する用)
  • PILのimage (pytorchのTensorとの相性よし)
  • pytorchのTensor(modelに入力する用)

PILとtorchvision.transformsを用いた前処理

一番簡単かつ便利な手法で、torchvisionのtransformsを用いることで簡単に前処理を実装することができます。

具体的には下記のようなコードで前処理を書くことができます。

from torchvision import transforms
from PIL import Image

# 前処理用
preprocess = torchvision.transforms.Compose([
    transforms.Resize((224,224)),
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])

# load image
img = Image.open("img.png")

# Get Normalized image
img_tensor = preprocess(img) 

# バッチサイズにあたる次元を一つ追加
img_input = img_tensor.unsqueeze(0)

# 推論
output = model(img_input)

途中のpreprocessでは画像のリサイズやTensor形式の変換、画像の正規化を定義しています。 そしてこのオブジェクトに直接PIL形式の画像を与えることで任意の変換を行うことができます。

先程述べた、下記のパラメータをきちんと反映させていることを確認してください。

  • 入力の画像サイズ:224x224
  • 正規化項:mean = [0.485, 0.456, 0.406] and std = [0.229, 0.224, 0.225] (範囲が0〜1であることに注意)

他のtransformsに関しては公式か下記が参考になると思います。

qiita.com

numpyを用いた際の前処理

一応numpyを使っても前処理はできるのですがtorchvision.transformsが便利すぎて素直に変換したほうが良いです。

# PIL Image -> numpy array
np_img = np.array(pil_img)

# numpy array -> PIL Image
pil_img = Image.fromarray(np_img))

なお、この場合でもuint8かfloat32かどうかや、RGBかBGRかは気を使う必要があります。

nixeneko.hatenablog.com

4. 結果の解釈

サクッと飛ばしましたがモデルへの入力はmodel(x)のように計算できます。 model.forward()やそのままmodel.predict() でもできることがあるようですが違いは追々調べます…

出力結果がどの結果に属するかのマスクになるのですがこちらもTensor形式なのでnumpy arrayかPIL Image 形式にして図示する必要があります。

tzmi.hatenablog.com

今回はなるべくPILへと変換します。何度も言いますがtransformsが楽なので。

可視化の際には公式のチュートリアルと同様にsoftmaxで正規化すると良いです。

pytorch.org

  • マスクの作成
from torch.nn.functional import softmax

# torch.Size([1, 21, 224, 224]) -> torch.Size([21,224,224])
output_ = output['out'].squeeze()
# normalize
normalized_masks = softmax(output_, dim=0)
  • 可視化
def visualize_tensor(tensors):
  n = len(tensors)
  plt.figure(figsize=(24, 5))
  for i in range(n):
    img = transforms.ToPILImage()(tensors[i])
    plt.subplot(1, n, i + 1)
    plt.xticks([])
    plt.yticks([])
    plt.imshow(img)
  plt.show()

visualize_tensor(normalized_masks)

可視化した結果が下記のとおりです。VOCでは1番目が背景、16番目がPersonとなっていますがそれに該当する箇所がハイライトされていることがわかります。

f:id:ossyaritoori:20211207233331p:plain
21クラスの分類結果

draw_segmentation_masksを使った可視化

draw_segmentation_masksというTensorを引数にとる関数があるっぽいので試してみました。

pytorch.org

一見便利そうですがTensorを引数にするのがちょっと癖があって難しいなと思いました。numpyならマスキングは非常に簡単だと思います。

import torch

wid,hei = img.size
reshape_tensor = transforms.Compose([
    transforms.ToPILImage(),
    transforms.Resize((hei,wid)),
    transforms.ToTensor(),
    ])  

img_to_tensor = transforms.Compose([
    transforms.ToTensor(),
    transforms.ConvertImageDtype(torch.uint8)
])

person_mask = reshape_tensor(normalized_masks[15]) > 0.5
bg_mask = reshape_tensor(normalized_masks[0]) > 0.5

person_img = torchvision.utils.draw_segmentation_masks(img_to_tensor(img), person_mask)
bg_img = torchvision.utils.draw_segmentation_masks(img_to_tensor(img), bg_mask)

visualize_tensor([bg_img, person_img])

f:id:ossyaritoori:20211208003947p:plain
閾値0.5での切り抜き結果

numpyを使うパターン

numpyの方は変換さえできればstraightforwardなのでさっくり書くにとどめます。

# tensor to numpy
out_np = reshape_tensor(normalized_masks[15]).detach().numpy().copy()

mask = (out_np > 0.5)
mask = cv2.cvtColor(mask.astype(np.uint8), cv2.COLOR_GRAY2RGB)

masked = img_np * mask

参考・その他

とりあえず書き溜めておきます。汚ければ後で消すかもしれません。

参考になりそうな記事たち

大量の記事を斜め読みしたのでどれがどの参考になったかちょっと忘れてしまったのですがこれは確実に読んだというのを下記に記しておきます。

超初心者の抱えていた疑問と回答

とりあえず動かしていくにあたって感じたが疑問と現時点での自分の理解を書いておきます。

  • modelの入力に入れるTensorのサイズがよくわからない。なぜ四次元?
    • segmentationに関して言えば[バッチ数, カラーのチャンネル数, 横, 縦]という次元になっている 参考

夫婦共働きにおける家計管理どうしてますか? 〜 我が家のケース 〜

はじめに

現在の日本にて夫婦共働きという家庭は結構あるかと思います。

しかし、肝心な家計管理の手法については下記のようなまとめサイトばかりで実際どのようにやりくりしているのかといった情報が少ないように思います。

www.smbc-card.com

この記事はひとまず自身のケースを備忘録として公開して、あわよくば他の方の情報も聞きたいという趣旨のもと書かれています。

目指す運用

前提

大前提として、

  • 夫婦共働き
  • 年収や労働時間に極端な差がない

こととします。また、ちゃんと話していませんが家計管理の目的は「支出の管理と貯蓄」にあるものとします。

家計管理に求めるもの

とりあえず家計管理のシステムに求めるのは下記の2点です。

  • 貯蓄や日常生活の支出については明確に管理したい
  • 私費や趣味に使ったお金はある程度相手からわからないようにしたい


また、原則として以下のような思想のもとやりくりを考えています。

  • 必須なもの・面倒なものは自動化
  • それ以外は人の手で調整できる余地を残す

家計管理サンプル

とりあえず、我が家でやっていることを下記に記します。

共有口座

f:id:ossyaritoori:20211027001659p:plain

個人で持つ口座の他に共有口座を一つ作って、そこから家賃などの固定費を支払うようにしています。

入金額はお互い相談して決めますが、固定費より多めに入れることで貯蓄用の口座としても振る舞えるようにしています。

良い点

  • 年ごとに決まった固定額を入れるだけなので管理が楽。
  • 個人の細かな出費を共有しなくても良いので気楽。

課題に感じる点

  • 結局、共有口座よりも個別口座にお金が溜まっている(いくら入れるか問題)。
  • 相手の個別口座や資産の状況についてあまり把握できない。
    • 年1で年間の収支を共有するイベントをするのが良いと思うがまだやってないのでわからない。

貯蓄用と固定費用の口座は分けるという意見もあるようですが、口座を増やすと管理がめんどくさそうなので今のところはやっていないです。

家のための支払いの集計

食べ物の買い出しなどを始めとする「家のための買い物」はお互いが気づいたときにそれぞれ行うためどうしても出費に差が出てしまいます。

会社での経費申請のように購入時のレシートをもとに集計して支払いの差額を可視化するようにしています。

f:id:ossyaritoori:20211027003118p:plain

※ Zaimの採用理由(クリックして展開) ちゃんと検討したわけではありませんが、下記のような理由でZaimを採用しました。

  • 著名なアプリで信用できると思った
  • Money forwardをすでに個人で使っていた
  • 集計結果をcsvでダウンロードする機能が無課金でできる(PCでの扱いが楽)

Tips

  • 共有Gmailアカウントを使うことで双方のデバイスから経費登録できる。
  • Zaim上でどちらの支払いか区別するためにカテゴリを2つに絞る。(例:夫の支払いは全て「車」カテゴリとする)

良い点

  • レシートを撮影するだけで全て登録できるので慣れれば早い

課題

  • 個人の収支管理(Money forward)と二度手間な感じがする
  • 物品を確認することをしないので悪意があれば容易に経費申請しまくれる(脆弱)

わからないこと

個別口座と共有口座の貯蓄の比率

大体の場合、個別口座と共有口座のそれぞれで貯蓄をしていくこととなるかと思いますがこれをどちらに振るべきかわかっていません。

共有口座はどちらかの名義で作られることが多いかと思いますが、場合によっては口座名義人へのパートナーからの「贈与」として税務署に目をつけられるケースもあるようです。(もっとずっと先の話な気もする。)

diamond.jp

style.nikkei.com

個別口座に貯めるとするとお互いにどれくらい貯めるかなどの合意をきちんと取る必要があるのが大変そうに感じます。

個別口座情報の共有(全体の支出をどう集計するか?)

今まで話したとおり、お互いに個別管理の口座には突っ込まないのが前提になっていますが、とはいえ家計全体の収支やその内訳は把握しておきたいわけで年1などで決算報告などをする必要があるイメージでいますがこのあたりを具体的にどうやるかまだ決めていません。

最低限、

  • 収入と支出額、投資口座の金額
  • 支出の内訳(カテゴリ毎)

は知りたいはずですがうまいこと細かい情報にマスクしつつ知りたい情報を共有する仕組みがあればいいなと思います。

調べると、今はOsidoriというアプリが無料でそのあたりの機能をそろえているそうなので気になっているところです。

www.osidori.co

おわりに

ということで家計管理@我が家のケースをご紹介しました。

共有口座とレシートで家計での経費管理をすることでそれなりに公平そうな仕組みにはできていそうですがもっと良い方法があるなら知りたいです。

一つ確実にオススメできるのは夫婦で何かと外部とやり取りする際、共有のGoogleアカウントがかなり便利(メール・TODOの共同編集やアプリ連携など)ということです。

オドメトリを連結している別の座標系に移す時の座標変換計算(ROS)

概要

下記のようなシチュエーションのオドメトリ変換を考えます。

Bodyに固定したセンサでとったodometryをbase_linkでのオドメトリに変換するのが目的です。 (ROSでよくあるシチュエーションだと思います。)

f:id:ossyaritoori:20210828003827p:plain

注意:速度変換の部分に自信がないです。詳しい方訂正・コメントお願いします。

tfを使った解法

ROSを使う人なら位置変換に関しては普通にROSのtfを

/base_link(q_0) -> /sensor_frame(q_0) -> /sensor_frame(q_c) -> /base_link(q_c)

のような感じでつないでlookupTransformで解決すれば位置の変換が取得できるとお思いになるかと思います。 一方で、速度(twist)の解決は私の知る限りサポートされていないように思います。

一応、lookupTwistというのがあるのですがこれはlookupTransformの結果を数値微分しているっぽいのであまり正確な値は期待できません。

また、複数のセンサがある場合などは複数の経路ができてtf treeの構造を壊しかねないのでエスケープのために余計なリンクをたくさん定義することになります。

自分で計算するときの数式

以上の課題を解決するために自分で計算していきます。

位置の変換

同次行列をつないでいけば、base_linkの座標系で見たOdometryのPoseは

 \displaystyle
\begin{bmatrix} R & t \\ 0 & 1 \end{bmatrix} = \begin{bmatrix} R_c & t_c \\ 0 & 1 \end{bmatrix}\begin{bmatrix} R_o & t_o \\ 0 & 1 \end{bmatrix}\begin{bmatrix} R_c & t_c \\ 0 & 1 \end{bmatrix}^{-1}

で計算できます。これを全て計算すると下記のようになります。

 \displaystyle
\begin{bmatrix} R & t \\ 0 & 1 \end{bmatrix} = \begin{bmatrix} R_c R_o R_c ^\top & -R_c R_o R_c ^\top t_c + t_c + R_c t_o \\ 0 & 1 \end{bmatrix}


ここで、 R_o,R_cが可換の時、具体的にはyaw回転しかしない自動車などのアプリケーションでカメラを水平に構えた時などは

 \displaystyle
\begin{bmatrix} R & t \\ 0 & 1 \end{bmatrix} = \begin{bmatrix} R_o  & - R_o t_c + t_c + R_c t_o \\ 0 & 1 \end{bmatrix} \mbox{(R_oとR_c が可換のケース)}

とすることができます。

どこかが違うtwistの変換 (Pose変換の微分で解く)

Poseの変換がわかったので上記の式を時刻qで微分することで、Twist変換に変換することができます。(よね?)

なんか上記の仮定が間違っている気がしてきました。 一応途中式は残しておきます。結果が少し異なるのですが何が違うのかちょっと自信がないので

何かがおかしい気がする導出

記述量削減のため  \frac{dR_x}{dq} = \tilde{\omega_x} R_x, \frac{dt_x}{dq} = v_x と記述することにします。 ここで \omega_xは角速度で、 \tilde{\omega_x}はその交代行列です。この辺の話は面白いのでぜひ参考文献をご覧あれ。

 R_o,t_o のみが時変なので微分は下記のように計算できます。

 \displaystyle
\begin{bmatrix} \tilde{\omega} R & v \\ 0 & 1 \end{bmatrix} = \begin{bmatrix} R_c \tilde{\omega_o} R_o R_c ^\top &  -R_c \tilde{\omega_o} R_o R_c ^\top t_c + R_c  v_o  \\ 0 & 1 \end{bmatrix}

角度の関係は

 \displaystyle
\tilde{\omega} R = \tilde{\omega} R_c R_o R_c ^\top =R_c \tilde{\omega_o} R_o R_c ^\top

を解いて

 \displaystyle
\tilde{\omega} =R_c \tilde{\omega_o}  R_c^\top

となります。角速度の関係は参考文献[2]から \omega = R_c \omega_oと簡単な形に求まります。

並進速度は下記の感じになります。

 \displaystyle
v = -R_c \tilde{\omega_o} R_o R_c ^\top t_c + R_c  v_o

また、R_o R_c が可換のケースでは下記のようになります。

 \displaystyle
\tilde{\omega} = \tilde{\omega_o}  \\
v = - \tilde{\omega} R  t_c + R_c  v_o \\
\mbox{(R_o R_c が可換のケース)}

並進速度の第一項は回転に伴うモーメントのような項ですね。

twistの変換 (多分こっちが正しい。)

twist(速度・加速度)の変換に自信がなく結構調べたのですが 調べる際によく出る例として下記の質疑があります。参考文献[4]のスライドがいい感じだと思われます。

physics.stackexchange.com

要約すると座標Aで見た速度を座標A'で見たときにどうなるかを表す式は

 \displaystyle
\begin{pmatrix}
v_{A'} \\ 
\omega_{A'}
\end{pmatrix} = \begin{pmatrix}
R_{A'A} & \hat{t}_{A'A}R_{A'A} \\ 
0 & R_{A'A}
\end{pmatrix} \begin{pmatrix}
v_{A} \\ 
\omega_{A}
\end{pmatrix}

とかけるというものです。

自分の変数に書き下すと下記の通りになります。vは並進tを時間微分したものです。

 \displaystyle
\begin{pmatrix}
v \\ 
\omega
\end{pmatrix} = \begin{pmatrix}
R_c & \tilde{t}_cR_c \\ 
0 & R_c
\end{pmatrix} \begin{pmatrix}
v_{o} \\ 
\omega_{o}
\end{pmatrix}

ここで、 \tilde{\omega}は角速度ベクトルの交代行列であり、 \dot{R_x} = \tilde{\omega_x} R_x のように微小回転行列を表せます。 この辺は結構面白いのでリー代数や三次元回転についての記述を参照してください。(参考文献1)

展開すると並進速度は外積の性質などを用いて、

 \displaystyle
v = \tilde{t}_cR_c \omega_o   + R_c v_o\\
= t_c \times R_c \omega_o   + R_c v_o\\
=  - \omega \times t_c + R_c v_o\\
= - \tilde{\omega} t_c + R_c v_o

とかけます。

  • 導出

導出の元になる数式は下記のようになっています。

 \displaystyle
\begin{bmatrix} \tilde{\omega} & v \\ 0 & 1 \end{bmatrix} = \begin{bmatrix} R_c & t_c \\ 0 & 1 \end{bmatrix}\begin{bmatrix} \tilde{\omega}_o & v_o \\ 0 & 1 \end{bmatrix}\begin{bmatrix} R_c & t_c \\ 0 & 1 \end{bmatrix}^{-1}

角度について展開すると

 \displaystyle
\tilde{\omega} =R_c \tilde{\omega_o}  R_c^\top

となります。角速度の関係は参考文献[2]から \omega = R_c \omega_oと簡単な形に求まります。

並進速度は下記のように書けます。

 \displaystyle
v = -R_c \tilde{\omega_o}  R_c ^\top t_c + R_c  v_o 
= -\tilde{\omega} t_c + R_c v_o

補足

  • 交代行列について

  \omega = \begin{bmatrix} r , p ,  y \end{bmatrix} ^\top の時、

 \displaystyle
\tilde{\omega} = \begin{bmatrix}  0 & -y & p\\ y & 0 & -r\\ -p & r &0  \end{bmatrix}

この行列は下記の性質を持ちます。

 \displaystyle
\tilde{\omega}^\top  = - \tilde{\omega}
  •  \omega = R_c \omega_o の導出

スマートな解釈としては参考文献[3]の式(8)あたりを見てもらうと良いですが、実は以下の2式をゴリゴリ成分計算することでも求まります。

 \displaystyle
\tilde{\omega} =R_c \tilde{\omega_o}  R_c^\top

f:id:ossyaritoori:20210828131106p:plain
rpyの角度から算出する三次元回転行列('sxyz')

まとめ

ということで機体から  R_c,t_cの位置に取り付けられたセンサのオドメトリ R_o,t_o,\omega_o,t_oから得られる機体のオドメトリ R,t

 \displaystyle
R = R_c R_o R_c ^\top \\
t = -R_c R_o R_c ^\top t_c + t_c + R_c t_o  \hspace{5mm}= -R t_c + t_c + R_c t_o\\
\omega =R_c \omega_o    \\
v = -R_c \tilde{\omega_o}  R_c ^\top t_c + R_c  v_o  \hspace{5mm} = - \tilde{\omega}  t_c + R_c v_o


で表されます。上から順に計算するなら右側の別解を使ったほうがスムースかと思われます。


なお、回転がYawしかないような特殊ケースでR_o,R_cが可換の場合は

 \displaystyle
R = R_o  \\
t = - R_o t_c + t_c + R_c t_o \\
\omega =R_c \omega_o    \\
v = - \tilde{\omega}  t_c + R_c v_o

となります。

正直Twistの速度の変換は下記の数式と異なるためどこか間違えている気がしないでもないのでご指摘よろしくおねがいします。

参考文献

フォーマット適当ですが下記の文書が参考になります。

[1] 金谷先生 「3次元回転: パラメータ計算とリー代数による最適化」

[2] 角速度ベクトルと回転行列の時間微分【力学の道具箱】 | スカイ技術研究所ブログ

[3] ベクトルの成分表示と座標変換【力学の道具箱】 | スカイ技術研究所ブログ

[4] http://www.eeci-institute.eu/pdf/M5-textes/M5_slides4.pdf

matplotlibのインタラクティブなプロットを作る覚書(スクロールでズーム、ドラッグで移動)

背景

オレオレGUIを作る際にインタラクティブなPlotがしたいという動機です。

インタラクティブなPlotについては下記が結構ボリュームがあって良いと思います。

qiita.com

サンプルコード①:スクロールで拡大縮小、ドラッグで移動

下記StackOverflowの議論からコードをもらってPython3用に改変しました。

stackoverflow.com

動作は下記の2つで、それぞれ関数が割り当てられています。

  • スクロールで拡大縮小
  • クリック&ドラッグで移動
from matplotlib.pyplot import figure, show
import numpy

class ZoomPan:
    def __init__(self):
        self.press = None
        self.cur_xlim = None
        self.cur_ylim = None
        self.x0 = None
        self.y0 = None
        self.x1 = None
        self.y1 = None
        self.xpress = None
        self.ypress = None


    def zoom_factory(self, ax, base_scale = 2.):
        def zoom(event):
            cur_xlim = ax.get_xlim()
            cur_ylim = ax.get_ylim()

            xdata = event.xdata # get event x location
            ydata = event.ydata # get event y location

            if event.button == 'down':
                # deal with zoom in
                scale_factor = 1 / base_scale
            elif event.button == 'up':
                # deal with zoom out
                scale_factor = base_scale
            else:
                # deal with something that should never happen
                scale_factor = 1
                print(event.button)

            new_width = (cur_xlim[1] - cur_xlim[0]) * scale_factor
            new_height = (cur_ylim[1] - cur_ylim[0]) * scale_factor

            relx = (cur_xlim[1] - xdata)/(cur_xlim[1] - cur_xlim[0])
            rely = (cur_ylim[1] - ydata)/(cur_ylim[1] - cur_ylim[0])

            ax.set_xlim([xdata - new_width * (1-relx), xdata + new_width * (relx)])
            ax.set_ylim([ydata - new_height * (1-rely), ydata + new_height * (rely)])
            ax.figure.canvas.draw()

        fig = ax.get_figure() # get the figure of interest
        fig.canvas.mpl_connect('scroll_event', zoom)

        return zoom

    def pan_factory(self, ax):
        def onPress(event):
            if event.inaxes != ax: return
            self.cur_xlim = ax.get_xlim()
            self.cur_ylim = ax.get_ylim()
            self.press = self.x0, self.y0, event.xdata, event.ydata
            self.x0, self.y0, self.xpress, self.ypress = self.press

        def onRelease(event):
            self.press = None
            ax.figure.canvas.draw()

        def onMotion(event):
            if self.press is None: return
            if event.inaxes != ax: return
            dx = event.xdata - self.xpress
            dy = event.ydata - self.ypress
            self.cur_xlim -= dx
            self.cur_ylim -= dy
            ax.set_xlim(self.cur_xlim)
            ax.set_ylim(self.cur_ylim)

            ax.figure.canvas.draw()

        fig = ax.get_figure() # get the figure of interest

        # attach the call back
        fig.canvas.mpl_connect('button_press_event',onPress)
        fig.canvas.mpl_connect('button_release_event',onRelease)
        fig.canvas.mpl_connect('motion_notify_event',onMotion)

        #return the function
        return onMotion


fig = figure()

ax = fig.add_subplot(111, xlim=(0,1), ylim=(0,1), autoscale_on=False)

ax.set_title('Click to zoom')
x,y,s,c = numpy.random.rand(4,200)
s *= 200

ax.scatter(x,y,s,c)
scale = 1.1
zp = ZoomPan()
figZoom = zp.zoom_factory(ax, base_scale = scale)
figPan = zp.pan_factory(ax)
show()

出力は下記のようになります。

f:id:ossyaritoori:20210726210806p:plain
サンプルとして用いる散布図グラフ。ホイールで拡大縮小、ドラッグで移動ができます。

ズーム動作

関数の最初にzoom用の関数を定義して、下記のコードでコールバック関数として渡しているのがわかります。

fig.canvas.mpl_connect('scroll_event', zoom)

そして、受け手の関数では変数eventから得られるスクロールの上下に関する情報をもとに図の拡大縮小を行っています。

ドラッグ動作

一方ドラッグ動作では、クリックされたときと離されたとき、マウスをドラッグしたときの動作にそれぞれ関数を割り当てて、 コールバックを呼んでいます。

# attach the call back
        fig.canvas.mpl_connect('button_press_event',onPress)
        fig.canvas.mpl_connect('button_release_event',onRelease)
        fig.canvas.mpl_connect('motion_notify_event',onMotion)

その他の動作

つまり、どんなイベントがあるかだけ把握すれば適切なコールバック関数を渡してあげることでいろいろな動作ができるということです。

では、実際にどんなイベントがあるかについては下記公式ページを参照してください。

matplotlib.org

次のサンプルではkey_press_eventを使います。

GUI動作は組み合わせで行うことが多いのでサンプルコードのようにクラスを定義してその中でどのボタンがホールドされているかなどの変数を保持しておくと捗ると思います。

サンプルコード②:スクロールで左右ズーム、Ctrl押しながらのスクロールで上下ズーム

先程のコードを時系列Plot用に改良しました。

主な動作としては下記の通りになります。

  • スクロール:X軸のみズーム
  • Ctrl+スクロール:Y軸のみズーム
  • ドラッグ:並行移動
  • 「r」キー:描画範囲リセット
from matplotlib.pyplot import figure, show
import numpy
import matplotlib
matplotlib.use('TKAgg')

class ZoomPan:
    def __init__(self,ax):
        self.press = None
        self.cur_xlim = None
        self.cur_ylim = None
        self.x0 = None
        self.y0 = None
        self.x1 = None
        self.y1 = None
        self.xpress = None
        self.ypress = None

        self.ctrl_press = False

        self.ax = ax
        self.orig_xlim = ax.get_xlim()
        self.orig_ylim = ax.get_ylim()


        self.zoom_factory(ax,base_scale=1.1)
        self.ctrl_key(ax)
        self.pan_factory(ax)

    def zoom_factory(self, ax, base_scale = 2.):

        def zoomX(event,scale_factor):
            cur_xlim = ax.get_xlim()
            xdata = event.xdata # get event x location
            new_width = (cur_xlim[1] - cur_xlim[0]) * scale_factor
            relx = (cur_xlim[1] - xdata)/(cur_xlim[1] - cur_xlim[0])

            ax.set_xlim([xdata - new_width * (1-relx), xdata + new_width * (relx)])
            ax.figure.canvas.draw()

        def zoomY(event,scale_factor):
            cur_ylim = ax.get_ylim()
            ydata = event.ydata # get event y location
            new_height = (cur_ylim[1] - cur_ylim[0]) * scale_factor
            rely = (cur_ylim[1] - ydata)/(cur_ylim[1] - cur_ylim[0])

            ax.set_ylim([ydata - new_height * (1-rely), ydata + new_height * (rely)])
            ax.figure.canvas.draw()

        def zoom(event):
            if event.button == 'down':
                # deal with zoom in
                scale_factor = 1 / base_scale
            elif event.button == 'up':
                # deal with zoom out
                scale_factor = base_scale
            else:
                # deal with something that should never happen
                scale_factor = 1
                print(event.button)

            ####### Switch zoom X or Y #########
            if self.ctrl_press:
                zoomY(event,scale_factor)
            else:
                zoomX(event,scale_factor)

        fig = ax.get_figure() # get the figure of interest
        fig.canvas.mpl_connect('scroll_event', zoom)

        return zoom

    def ctrl_key(self,ax):
        def onPress(event):
            #print(event.key)
            if event.inaxes != ax: return
            if event.key == "control":
                self.ctrl_press = True
            elif event.key == "r": # reset zoom
                ax.set_xlim(self.orig_xlim)
                ax.set_ylim(self.orig_ylim)
                ax.figure.canvas.draw()

        def onRelease(event):
            #print(event.key)
            if event.inaxes != ax: return
            if event.key == "control":
                self.ctrl_press = False
        
        fig = ax.get_figure() # get the figure of interest

        # attach the call back
        fig.canvas.mpl_connect('key_press_event',onPress)
        fig.canvas.mpl_connect('key_release_event',onRelease)

    def pan_factory(self, ax):
        def onPress(event):
            if event.inaxes != ax: return
            self.cur_xlim = ax.get_xlim()
            self.cur_ylim = ax.get_ylim()
            self.press = self.x0, self.y0, event.xdata, event.ydata
            self.x0, self.y0, self.xpress, self.ypress = self.press

        def onRelease(event):
            self.press = None
            ax.figure.canvas.draw()

        def onMotion(event):
            if self.press is None: return
            if event.inaxes != ax: return
            dx = event.xdata - self.xpress
            dy = event.ydata - self.ypress
            self.cur_xlim -= dx
            self.cur_ylim -= dy
            ax.set_xlim(self.cur_xlim)
            ax.set_ylim(self.cur_ylim)

            ax.figure.canvas.draw()

        fig = ax.get_figure() # get the figure of interest

        # attach the call back
        fig.canvas.mpl_connect('button_press_event',onPress)
        fig.canvas.mpl_connect('button_release_event',onRelease)
        fig.canvas.mpl_connect('motion_notify_event',onMotion)

        #return the function
        return onMotion


fig = figure()

ax = fig.add_subplot(111, xlim=(0,1), ylim=(0,1), autoscale_on=False)

ax.set_title('Click to zoom')
x,y,s,c = numpy.random.rand(4,200)
s *= 200

ax.scatter(x,y,s,c)
scale = 1.1
zp = ZoomPan(ax)
show()

key_press_event を使ったフラグ管理と注意点

コントロールキーを押しているか管理するためにkey_press_eventを使っています。

キーが押されたらフラグを立てて、キーを話したらフラグを下ろすという2つのコールバックを定義しています。 その他に「r」を押した際に描画範囲をリセットする機能も同時に書いています。

ハマったバグ
Macで開発しているときにハマったのがOS Xのバックエンドだとcontrolキーが押されたかチェックできない問題です。

python - Close pyplot figure using the keyboard on Mac OS X - Stack Overflow

上記の質疑のようにバックエンドをTKAggに変えてことなきを得ました。ちょっと画質が荒くなる感じがしてあまり好きではありませんが。。。

import matplotlib
matplotlib.use('TKAgg')

これ以外にもキーの同時押しのときはバックエンドによって出てくる値が変わるなどこのあたりは結構気をつけることがありそうです。

スマホ外付けの望遠レンズで月は撮れるか(OpenCVで実倍率を検証)

概要

スマホの外付けレンズというのが果たして実用に堪えるのか前から気になっていたので夏休みに買ってみて検証してみました。

先に所感をまとめると以下のとおりです。

  • クリップは固定に不安
  • 望遠とマクロはそこそこ楽しい
  • 真面目にやるなら三脚は必須
  • 中華のズーム倍率は信用してはいけない

また,一応スマホでも月は撮れます。

f:id:ossyaritoori:20210723185405p:plain
Pixel4,3.7倍と望遠レンズ(約10倍)で撮影した上弦の月

スマホと外付けレンズ

検証に用いたのは下記のレンズキットです。

自称望遠22倍,マクロ,広角,魚眼レンズを備えているということでした。

また,スマホとしてPixel4とHuaweiのNova3を用いました。

レンズを使ってみての感想

100円レンズだと曇ったり周辺がぼやけたりするそうですが,全般的にレンズをつけて著しく画質が劣化するということはなかったです。

ただ,広角も魚眼レンズもほしい場面があまりなく後述のクリップの手間を考えても持ち運んで気軽にスマホにつけるというような運用ではないと思いました。

レンズ おすすめ度 感想
広角 広角で撮りたいシーンが自撮りくらいしか思いつかない。
魚眼 ちゃんと魚眼になるけど何に使うのか不明。
マクロ スマホでマクロ撮影できるのは意外と楽しい。位置合わせも楽。
望遠 ちゃんとセッティングすれば結構遊びがいがある。が,準備がだるい。

f:id:ossyaritoori:20210723190338p:plain
昔買ったルースをマクロで撮影。生活感のない撮影対象が他になかった。

クリップの使用感

商品写真の通り,クリップにレンズをはめてスマホのカメラと位置合わせをすることで撮影ができます。 これは正直慣れが必要で,

  • 位置合わせが難しい(特に望遠レンズ)
  • 望遠レンズ着用時は自重でズレが起きやすい

という問題があるため,特に望遠レンズで「正しくはめて」「正しく目標物に向ける」というプロセスが非常に撮影において時間がかかります。

もう少しきちんとした三脚があるとこのあたりの安定度が全然違うので,望遠レンズを使う方は今後のカメラ購入も見越して用意しておいたほうがいいと思います。

冒頭の画像はセッティングに5分程度かかっており,初めての場合はもっと掛かると思ったほうが良さそうです。

望遠倍率が表記と違う問題

商品説明では22倍ズームとあるのですが正直そんなにズームしている感じはしませんでした。 口コミでも倍率が低めとあったのでOpenCVを使って確認してみます。

Gistソースコード

特徴点のマッチングを用いてレンズの有無の画像の間の倍率を計算すると大体9.72倍と出ました。 22倍とは…

f:id:ossyaritoori:20210723183532p:plain
望遠レンズなし(左)と有り(右)。SIFT特徴点のマッチング結果を線で示しています。

この手の商品あるあるとして,1つのコア技術の製造元に対して外装をちょっと変更していろんな業者が売るという構造になっている事が多いので他の似た製品でも望遠レンズの倍率はせいぜい10倍弱になっているというのはあると思います。

製品仕様くらいはきちんと記述してほしいです。

参考:一眼レフで撮るとこうなる

いろいろあってSONYのα6400というカメラを手に入れました。

で、これで月を撮るとこんな感じになりました。

f:id:ossyaritoori:20210723010837p:plain
α6400の付属レンズで撮った月(3.8倍、JPEG画質)。一眼レフはやはり違った。

一番感動したのは手ブレ補正か倍率低さのおかげか三脚なしの手持ちでちゃんと撮影できたことです。 スマホの設定だと基本的に露光が長くなるので三脚なしはありえないのでこのあたりの撮影の手間は断然こちらの方が楽でした。

ちなみにコンデジで撮ると下記のような感じになります。

ossyaritoori.hatenablog.com

まとめ

ということで今回の所感です。

  • 安い外付けレンズでもスマホで月を撮れるが設備投資(特に三脚)が必要。
  • マクロと望遠意外は特に用がなさそう。
  • 望遠倍率だいたいサバ読んでる。
  • 一眼レフはいいぞ。

Python,PyQtで簡単にGUIを作る ② Splitterを使った配置とcsvのPlotter

目標

PythonでちゃっちゃとGUIを作ることを目標にしています。

前回に引き続き下記の2つを満たすようなものを作成していきます。

  • 複数ある要素を選択
  • 選択した要素をグラフに随時プロット

ossyaritoori.hatenablog.com

f:id:ossyaritoori:20210718014237p:plain
今回つくるやつです。左でcsvのHeaderを選択して右の領域に該当する値がPlotされます。

今回やること

ざっくりと下記のことをやっていきます。

  • Splitterを用いた配置
  • pandasと組み合わせて数値をPlot

Splitterを用いた配置

SetGeometryなどを用いずにSplitterという仕切りを使ってWidgetを配置する方法を用います。

下記のチュートリアルを見てみましょう。

www.finddevguides.com

使い方はVboxたちと似通っていて定義後はaddWidgetでWidgetを追加できます。

#from PyQt5.QtWidgets import *
      splitter1 = QSplitter(Qt.Horizontal) #横向きにStackする
      textedit = QTextEdit()
      textedit2 = QTextEdit()
      splitter1.addWidget(textedit2)
      splitter1.addWidget(textedit)
# 中略
# 最後に予め作成したQVboxlayoutに登録
      vbox.addWidget(splitter1)

また,SplitterにSplitterを追加できるため下記のようにプログラムを書けばいろいろ追加できます。

f:id:ossyaritoori:20210718103855p:plain
上がSplitter1で分けた領域,そしてSplitter2ではSplitter1と下のWidgetを分けている

#!/usr/bin/python3
# -*- coding: utf-8 -*-

import sys
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *

"""
Qt splitter のチュートリアル
https://www.finddevguides.com/Pyqt-qsplitter-widget

Example:Qwidgetから継承
SplitterはaddWidgetで部品を追加可能。Splitter自身も追加可能
基本的に一つの領域に一つの部品を配置できる。
"""

class Example(QWidget):

   def __init__(self):
      super(Example, self).__init__()

      self.initUI()

   def initUI(self):

      hbox = QHBoxLayout(self)
      
      # フレームを作る
      topleft = QFrame()
      topleft.setFrameShape(QFrame.StyledPanel)
      bottom = QFrame()
      bottom.setFrameShape(QFrame.StyledPanel)

      # 水平に分割
      splitter1 = QSplitter(Qt.Horizontal)
      textedit = QTextEdit()
      textedit2 = QTextEdit()
      textedit3 = QTextEdit()
      splitter1.addWidget(textedit2)
      splitter1.addWidget(textedit)
      splitter1.setSizes([100,200])

      splitter2 = QSplitter(Qt.Vertical)
      splitter2.addWidget(splitter1)
      splitter2.addWidget(textedit3)

      # Widgetを追加
      hbox.addWidget(splitter2)

      # レイアウト適用
      self.setLayout(hbox)
      QApplication.setStyle(QStyleFactory.create('Cleanlooks'))

      # Windowサイズを規定
      self.setGeometry(300, 300, 300, 200)
      self.setWindowTitle('QSplitter demo')
      self.show()

def main():
   app = QApplication(sys.argv)
   ex = Example()
   sys.exit(app.exec_())

if __name__ == '__main__':
   main()

csvファイルを読んでPlotする

前回の記事と合わせると冒頭で示したようなcsvファイルから数値列を読んでPlotするGUIが作れます。

流れは下記のとおりです。

  • csvを読み込んでPandasのDataFrameに落とす
  • 数値列のみを抽出してColumn名をリスト化
  • GUIを作成,選択したColumnリストをもとにPlot

データとして使うCSVはAtushi Sakai大先生のリポジトリから拝借してきました。

github.com

pandasで非数値列を弾く方法

CSVを読んでPlotするのは良いですがその際,非数値が混じってエラー終了するのは避けたいです。

下記のようにselect_dtypesを使えば非数値列を排除できて安全です。

        # dfの非数値の列のみ選択
        self.df = df.select_dtypes(include='number')

コード

#!/usr/bin/python3
# -*- coding: utf-8 -*-

import sys
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
import pandas as pd 
import matplotlib.pyplot as plt
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas


"""
Qt splitter のチュートリアル
https://www.finddevguides.com/Pyqt-qsplitter-widget

Example:Qwidgetから継承
SplitterはaddWidgetで部品を追加可能。Splitter自身も追加可能
基本的に一つの領域に一つの部品を配置できる。
"""

class Example(QWidget):
    def __init__(self,filename=None):
        super(Example, self).__init__()
        
        self.loadcsv(filename)
        self.initUI()
    
    def loadcsv(self,filename):
        df = pd.read_csv(filename)
        # 非数値の列のみ選択
        self.df = df.select_dtypes(include='number')
        print(self.df)
        self.columnlist = self.df.columns # list

    def initUI(self):
        hbox = QHBoxLayout(self)
    
        # list/figure作成
        self.create_listwidget()
        self.create_figure()

        # 左側作成
        splitter_l = QSplitter(Qt.Vertical)
        splitter_l.addWidget(self.add_label("Plotする要素を選択"))
        splitter_l.addWidget(self.listWidget)
        # 右側作成
        splitter = QSplitter(Qt.Horizontal)
        splitter.addWidget(splitter_l)
        splitter.addWidget(self.FigureCanvas)
        #splitter.setSizes([100,200])

        # Widgetを追加
        hbox.addWidget(splitter)

        # レイアウト適用
        self.setLayout(hbox)
        QApplication.setStyle(QStyleFactory.create('Cleanlooks'))

        # Windowサイズを規定
        #self.setGeometry(300, 300, 300, 200)
        self.setWindowTitle('CSV plotter demo')
        self.show()

    def create_listwidget(self):
        # リスト作成
        self.listWidget = QListWidget()
        self.listWidget.setSelectionMode( #複数選択可能にする
            QAbstractItemView.MultiSelection
        )
        for col in self.columnlist:
            item = QListWidgetItem(col)
            self.listWidget.addItem(item)
        self.listWidget.itemClicked.connect(self.printItemText)
    
    def create_figure(self):
        # Figureを作成
        self.Figure = plt.figure()
        self.FigureCanvas = FigureCanvas(self.Figure)  # FigureをFigureCanvasに追加
        self.axis = self.Figure.add_subplot(1,1,1) # axを持っておく



    def add_label(self,text):
        # ラベルを作る
        label=QLabel(self)
        label.setText(text)
        label.adjustSize()
        return label


    def printItemText(self):
        # 選択したアイテムを表示
        items = self.listWidget.selectedItems()
        self.selected_columns = []
        for i in range(len(items)):
            self.selected_columns.append(self.listWidget.selectedItems()[i].text())

        self.plot_selected_columns()

    # Plotをアップデート
    def plot_selected_columns(self):
        self.axis.cla() # リセットを掛ける必要がある。
        df = self.df[self.selected_columns]
        df.plot(ax=self.axis)
        plt.grid(); 
        self.FigureCanvas.draw()


def main():
   app = QApplication(sys.argv)
   ex = Example("samplecsv.csv")
   sys.exit(app.exec_())

if __name__ == '__main__':
   main()

余談:Checkbox形式への変更

Listwidgetは結構見た目があれなので自分的にはCheckboxのほうが好きだったりします。 下記のように設定すればCheckboxを導入できます。

    # Checkbox Widgetの作成
    def add_checkbox(self,name):
      chxlist = QCheckBox(name, self)
      chxlist.stateChanged.connect(self.checklist_clicked)
      return chxlist
    
    #  クリックされたCheckboxをみてリストに追加 or 削除
    def checklist_clicked(self, state):
        checked_name = str(self.sender().text())
        if state == Qt.Checked: # if checked
            # add to the list
            self.clicked_list.append(checked_name)
        else: # if unchecked
            # remove from list
            self.clicked_list = [x for x in self.clicked_list if not x == checked_name]

余談2: Checkboxをまとめて導入

Checkboxをまとめて導入したいときは,上記のように一個一個Widgetを作るのではなく,VboxLayoutを用意してその中に入れていくのが良いです。

ちょっと長めの記述になります。

    def create_checkboxes(self):
        vbox = QVBoxLayout()
        for col in self.columnlist:
            vbox.addWidget(self.add_checkbox(col))
        # 箱を用意する
        frame = QFrame(self) # Frame定義
        frame.setFrameShape(QFrame.StyledPanel) # 形状決定。パネル。
        vbox.addStretch(1) # 行間を定義
        frame.setLayout(vbox)
        return frame

補足:Checkboxに変更した版のGUI

以上をまとめると下記のような感じになります。

f:id:ossyaritoori:20210718115306p:plain
記述量は増えますが自分はこっちのほうがスッキリしていて好みです。

#!/usr/bin/python3
# -*- coding: utf-8 -*-

import sys
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
import pandas as pd 
import matplotlib.pyplot as plt
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas


"""
Qt splitter のチュートリアル
https://www.finddevguides.com/Pyqt-qsplitter-widget

Example:Qwidgetから継承
SplitterはaddWidgetで部品を追加可能。Splitter自身も追加可能
基本的に一つの領域に一つの部品を配置できる。
"""

class Example(QWidget):
    def __init__(self,filename=None):
        super(Example, self).__init__()
        
        self.loadcsv(filename)
        self.initUI()
    
    def loadcsv(self,filename):
        df = pd.read_csv(filename)
        # 非数値の列のみ選択
        self.df = df.select_dtypes(include='number')
        print(self.df)
        self.columnlist = self.df.columns # list

    def initUI(self):
        hbox = QHBoxLayout(self)
    
        # list/figure作成
        #self.create_listwidget()
        self.create_figure()

        # 左側作成
        splitter_l = QSplitter(Qt.Vertical)
        splitter_l.addWidget(self.add_label("Plotする要素を選択"))
        #splitter_l.addWidget(self.listWidget)
        splitter_l.addWidget(self.create_checkboxes())
        # 右側作成
        splitter = QSplitter(Qt.Horizontal)
        splitter.addWidget(splitter_l)
        splitter.addWidget(self.FigureCanvas)
        #splitter.setSizes([100,200])

        # Widgetを追加
        hbox.addWidget(splitter)

        # レイアウト適用
        self.setLayout(hbox)
        QApplication.setStyle(QStyleFactory.create('Cleanlooks'))

        # Windowサイズを規定
        #self.setGeometry(300, 300, 300, 200)
        self.setWindowTitle('CSV plotter demo')
        self.show()

    def create_listwidget(self):
        # リスト作成
        self.listWidget = QListWidget()
        self.listWidget.setSelectionMode( #複数選択可能にする
            QAbstractItemView.MultiSelection
        )
        for col in self.columnlist:
            item = QListWidgetItem(col)
            self.listWidget.addItem(item)
        self.listWidget.itemClicked.connect(self.printItemText)
    
    def create_figure(self):
        # Figureを作成
        self.Figure = plt.figure()
        self.FigureCanvas = FigureCanvas(self.Figure)  # FigureをFigureCanvasに追加
        self.axis = self.Figure.add_subplot(1,1,1) # axを持っておく

    def create_checkboxes(self):
        vbox = QVBoxLayout()
        for col in self.columnlist:
            vbox.addWidget(self.add_checkbox(col))
        # 箱を用意してはる
        frame = QFrame(self) # Frame定義
        frame.setFrameShape(QFrame.StyledPanel) # 形状決定。パネル。
        vbox.addStretch(1) # 行間を定義
        frame.setLayout(vbox)
        return frame

    def add_checkbox(self,name):
        chxlist = QCheckBox(name, self)
        chxlist.stateChanged.connect(self.checklist_clicked)
        return chxlist

    #  クリックされたCheckboxをみてリストに追加 or 削除
    def checklist_clicked(self, state):
        checked_name = str(self.sender().text())
        
        try:
            self.clicked_list
        except:
            self.clicked_list = []

        if state == Qt.Checked: # if checked
            # add to the list
            self.clicked_list.append(checked_name)
        else: # if unchecked
            # remove from list
            self.clicked_list = [x for x in self.clicked_list if not x == checked_name]
        
        self.selected_columns = self.clicked_list
        self.plot_selected_columns()


    def add_label(self,text):
        # ラベルを作る
        label=QLabel(self)
        label.setText(text)
        label.adjustSize()
        return label


    def printItemText(self):
        # 選択したアイテムを表示
        items = self.listWidget.selectedItems()
        self.selected_columns = []
        for i in range(len(items)):
            self.selected_columns.append(self.listWidget.selectedItems()[i].text())

        self.plot_selected_columns()

    # Plotをアップデート
    def plot_selected_columns(self):
        self.axis.cla() # リセットを掛ける必要がある。
        df = self.df[self.selected_columns]
        df.plot(ax=self.axis)
        plt.grid(); 
        self.FigureCanvas.draw()


def main():
   app = QApplication(sys.argv)
   ex = Example("samplecsv.csv")
   sys.exit(app.exec_())

if __name__ == '__main__':
   main()

TODO

いくつか目処は立っていますが下記の要素を徐々に追加していくとそれっぽくなるものと思われます。

Python,PyQtで簡単にGUIを作る ① リスト表示とグラフPlot

モチベーション

身の回りのありとあらゆる便利パッケージにGUIがあったらな,と思うことが多いためやり方をメモします。

初回はひとまず

  • リストの複数を選択
  • matplotlibの図をリストの選択に応じて表示

ができるようにします。

サンプルを通した理解

わかりやすいサンプルコードを介して理解を深めるのが手っ取り早いです。 今回の目的には下記の内容が大部分で合致しているので参考にしました。

qiita.com

配置

構造としてはまずはQwidgetというキャンバスを用意して,QVboxlayoutというコンテナの中にグラフのプロットであったり,チェックボックスであったり具体的な要素を詰めていくという構造をとります。(理解がUpdateされ次第Updateします。)

f:id:ossyaritoori:20210717170350p:plain

超最低限に書くと下記のようになります。

# 初期化のサンプル
class Test(QtWidgets): # QWidgetを継承
    def __init__(self):
        super().__init__() # 継承元のInit
        vbox = QtWidgets.QVBoxLayout(self) #Vboxlayoutの定義。縦にオブジェクトを並べる。(QHboxなら横に) 
        vbox.addWidget( QtWidgets.QListWidget() ) # リスト表示Widgetを追加。
        vbox.addWidget( QtWidgets.QLabel(self) ) # ラベルWidgetを追加。(テキスト設定していないからこの段階では無)
        # ...このように追加していって
        self.setLayout(vbox) # VboxlayoutをWidgetにはめ込む

他にオブジェクトを配置する手法としては最後の部分はsetGeometryなどでゴリゴリ配置しても良さそうですし,

        # 配置
        self.setGeometry(0,0,900,600)
        self.FigureWidget.setGeometry(200,0,700,600)
        self.FileList.setGeometry(0,0,200,600)

配置をこりたい人は,Qt Designer というGUIがあるのでそれでレイアウトを作ったほうがいいと思います。

www7a.biglobe.ne.jp

自分は今はSplitterという機能を使って画面を分割して配置するようにしています。また次回にメモします。

イベントの設定

配置したオブジェクトたちにはイベントが起きたときにどうするかという設定をすることができます。

# インデント略
self.listWidget = QtWidgets.QListWidget()
self.listWidget.itemClicked.connect(self.printItemText) #クリックされたらprintItemText関数を呼ぶ

# 呼ばれる側の関数
    def printItemText(self):
        # 選択したアイテムを表示
        items = self.listWidget.selectedItems()
        x = []
        for i in range(len(items)):
            x.append(str(self.listWidget.selectedItems()[i].text()))
        print (x)

実行

Qtにおいては、QApplicationというクラスがメインのアプリケーションを定義するので, 最後に作ったWidgetクラスを表示する際には下記のようにします。

#アプリケーション本体のインスタンスを作る
app = QtWidgets.QApplication(sys.argv)    #アプリケーションのインスタンスを作る
Test.show()                        #作ったWidgetを表示する
app.exec()                          #アプリケーションのメッセージループを開始する

小ネタ

大体のオブジェクトは配置してから初期設定などをする必要があります。 そうするとaddWidgetでの配置が見づらくなるので関数化して一行で表せるようにしたほうがはかどります。

ラベルテキスト追加

ラベルオブジェクトを配置するならこうします。

    def add_label(self,text):
        # ラベルを作る
        label1=QtWidgets.QLabel(self)
        label1.setText(text)
        label1.adjustSize()
        return label1

リストでの複数選択

QListWidgetでSelectionModeを設定すると複数選択可能にできます。

毎回リセットするなら ExtendedSelection,トグルするなら MultiSelectionがおすすめです。 下記のように書きます。

        self.listWidget = QtWidgets.QListWidget()
        self.listWidget.setSelectionMode( #複数選択可能にする https://doc.qt.io/qt-5/qabstractitemview.html
            QtWidgets.QAbstractItemView.MultiSelection # QtWidgets.QAbstractItemView.ExtendedSelection 
        )

参考: QAbstractItemView Class | Qt Widgets 5.15.5

リストで番号を選択してPlotするサンプル

ということでリストで番号を選択してPlotするサンプルを下記に載せます。

f:id:ossyaritoori:20210717182134p:plain
選択した順番に下の図にPlotされます。

# -*- coding: utf-8 -*-
"""
Stackoverflowの文章より原型を拝借
QtのListwidgetで複数選択をしてPlotする。
"""

from PyQt5 import QtWidgets, QtCore
import matplotlib.pyplot as plt
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas

class Test(QtWidgets.QDialog):
    def __init__(self, parent=None):
        super(Test, self).__init__(parent)
        self.layout = QtWidgets.QVBoxLayout()
        # リスト作成
        self.listWidget = QtWidgets.QListWidget()
        self.listWidget.setSelectionMode( #複数選択可能にする
            QtWidgets.QAbstractItemView.MultiSelection
        )
        self.listWidget.setGeometry(QtCore.QRect(10, 10, 211, 291))
        for i in range(10):
            item = QtWidgets.QListWidgetItem("%i" % i)
            self.listWidget.addItem(item)
        self.listWidget.itemClicked.connect(self.printItemText)

        # Figureを作成
        self.Figure = plt.figure()
        self.FigureCanvas = FigureCanvas(self.Figure)  # FigureをFigureCanvasに追加
        self.axis = self.Figure.add_subplot(1,1,1) # axを持っておく

        self.layout.addWidget(self.add_label("Ctrlを押しながらで複数選択"))
        self.layout.addWidget(self.listWidget)
        self.layout.addWidget(self.FigureCanvas)
        self.setLayout(self.layout)


    def add_label(self,text):
        # ラベルを作る
        label1=QtWidgets.QLabel(self)
        label1.setText(text)
        label1.adjustSize()
        return label1


    def printItemText(self):
        # 選択したアイテムを表示
        items = self.listWidget.selectedItems()
        x = []
        for i in range(len(items)):
            x.append(int(self.listWidget.selectedItems()[i].text()))

        print(x)
        self.update_Figure(x)

    # Figure
    def update_Figure(self,x):
        self.axis.cla() # リセットを掛ける必要がある。
        self.axis.plot(x,'-o')
        plt.grid(); plt.legend(["selected number"])
        self.FigureCanvas.draw()

if __name__ == "__main__":
    import sys
    app = QtWidgets.QApplication(sys.argv)
    form = Test()
    form.show()
    sys.exit(app.exec_())