趣味と学びの雑記帳

プログラミング学習や開発、農業の仕事のことなど。思考の整理と備忘録です。

【スプレッドシート】QUERY関数で複雑な条件での抽出にトライする

農場の経営管理・資材管理システムを作るために活用したいと思い、QUERY関数の使い方を学んでいます。

前回は、QUERY関数の基本の活用方法について整理しました。

nouka-it.hatenablog.com

今回の記事では、実データを使ってみた際に複雑な条件での抽出が必要になったので、その際にやったことを整理しておきます。QUERY関数の応用編となります。

やりたいこと

以下のような「単価情報」シートがあります。肥料や農薬などの購入資材の規格や単価などが入ったデータベースです。

「単価情報」シート

(営農管理システム「アグリノート」から出力したcsvファイルをスプレッドシートに読み込んだものです)

この「単価情報」シートには「適用開始日」と「適用終了日」という列があり、その資材単価が適用される期間を示しています。この期間について、以下のようなことを考慮します。

  • 同じ資材に対し複数の単価が存在するが、期間が重複することはない
  • 「適用終了日」が空白のものは現在もその単価が適用中である

そこで今回やりたいことは、新たに「単価情報(期間抽出)」というシートを作成し、指定した日付(例:本日2022年9月23日)に当てはまる単価を抽出した新たなデータベースをQUERY関数を使って作成したいというものです。

完成したシートと関数

結果からいくと、以下のような方法で、複雑な条件抽出を実現することができました。

新たに「単価情報(期間抽出)」シートを作成し、ここに「単価情報」シートから今日の日付に該当する単価情報をQUERY関数で抽出します。

セルA1

=QUERY('単価情報'!A:K, "select * where J <= date '"&TEXT(TODAY(),"YYYY-MM-DD")&"' and (K >= date '"&TEXT(TODAY(),"YYYY-MM-DD")&"' or K is null)")

第2引数のクエリ部分がかなり複雑ですね。クエリは以下のような構造になっています。

select * where 条件① and (条件② or 条件③)
  • 条件①:J列の「適用開始日」が、今日の日付よりも前
  • 条件②:K列の「適用開始日」が、今日の日付よりも後
  • 条件③:K列の「適用開始日」が空白

順番に整理していきます。

QUERY関数を使っていろいろな条件で抽出する

今日の日付と比較して抽出を行う方法

まず、シンプルにJ列の「適用開始日」が今日の日付より前にあるデータをQUERY関数で抽出してみます。

=QUERY('単価情報'!A:K, "select * where J <= date '"&TEXT(TODAY(),"YYYY-MM-DD")&"'")

今回のデータだと、全ての単価情報が今日の日付よりも前に適用開始となっているので、元のデータベースと同じものになります。

今日の日付での抽出方法は、こちらの記事を参考にさせていただきました。

tonari-it.com

同様の表記方法で、K列の「適用終了日」が今日の日付より後にあるデータを抽出する関数は、以下のようになります。

=QUERY('単価情報'!A:K, "select * where K >= date '"&TEXT(TODAY(),"YYYY-MM-DD")&"'")

今回のデータだと、何もデータが抽出されません。

これは、「適用終了日」が空白になっているものが抽出されていないためです。実際には、「適用終了日」が空白になっているものはその単価が適用されているので、このような行を抽出できるようにしたいです。

空白の有無で抽出を行う方法

指定列が空白になっているデータを抽出したい場合は、以下のようにwhere句にis nullを使います。

=QUERY('単価情報'!A:K, "select * where K is null")

逆に空白のデータを取り除きたい場合は、is not nullが使えます。

=QUERY('単価情報'!A:K, "select * where K is not null")

今回のデータでは、前者のis nullを使います。

2つ以上の条件で抽出を行う方法

最後に、ここまでで見てきた条件を組み合わせます。複数の条件を組み合わせるには、where句の抽出条件にandorを使います。今回の抽出条件でクエリを書くと、以下のような構造になります。

select * where 条件① and (条件② or 条件③)
  • 条件①:J列の「適用開始日」が、今日の日付よりも前

 J <= date '"&TEXT(TODAY(),"YYYY-MM-DD")&"'

  • 条件②:K列の「適用開始日」が、今日の日付よりも後

 K >= date '"&TEXT(TODAY(),"YYYY-MM-DD")&"'

  • 条件③:K列の「適用開始日」が空白

 K is null

これを関数にまとめると、以下のようになります。

=QUERY('単価情報'!A:K, "select * where J <= date '"&TEXT(TODAY(),"YYYY-MM-DD")&"' and (K >= date '"&TEXT(TODAY(),"YYYY-MM-DD")&"' or K is null)")

QUERY関数で複数条件を指定して抽出する方法は、こちらの記事を参考にさせていただきました。

tonari-it.com

おわりに

いろいろと試しながら実現できました。実運用でかなり重宝しそうです。

参考

  • 公式ドキュメント

support.google.com

  • いつも隣にITのお仕事

tonari-it.com

tonari-it.com

  • カワムラさん著「会社員がVLOOKUPの次に覚えるQUERY関数超入門」

techbookfest.org

【スプレッドシート】QUERY関数の基本の活用方法を学ぶ

農場の経営管理・資材管理システムを作るために活用したいと思い、QUERY関数の使い方を学んでいます。

教科書として、ノンプロ研メンバーのカワムラさんが執筆された技術同人誌で勉強させていただきました。入門としてとてもわかりやすく、体系立ててQUERY関数について学ぶことができる書籍です!

techbookfest.org

今回の記事では、QUERY関数を実データを使って触ってみたので、基本の使い方についてまとめていきます。

QUERY関数とは

Googleスプレッドシートで使えるQUERY関数は、指定したデータ範囲から条件を指定して抽出することができる便利な関数です。

指定範囲からデータを抽出する関数にはVLOOKUP関数が思い浮かびます。しかしVLOOKUP関数では単一のデータしか取得できなかったり、複雑な条件で抽出できないなど不便なことがあります。そういう時にQUERY関数を使うと、とてもシンプルな式でやりたいことを実現できたりします。

QUERY関数の基本の使い方

同一スプレッドシート内のデータを参照する方法

QUERY関数を使って同一スプレッドシート内のデータを参照する場合は、以下のように使います。

セルA1

=QUERY('農薬情報'!A:J, "select *")
  • 第1引数には参照するデータ範囲を指定
  • 第2引数にはクエリ(抽出するための条件)をダブルクォーテーションで囲って入力

この例では、「農薬情報」シートのA列〜J列をデータ範囲に指定し、その全てのデータを抽出(参照)しています。

関数を入力するセルは左上の1つのセルだけで大丈夫です。ただし、もし範囲に余計なデータが存在しているとエラーになってしまうので注意が必要です。

第2引数のクエリには、いくつかの句を使うことができます。基本の句として以下の3つがあります。

  • select句:表示したい列と表示順を指定して抽出
  • where句:列の値の条件を指定して行を抽出
  • order by句:指定した列の値を基準として並べ替え

これらを活用することで、いろいろな条件でのデータの抽出や並べ替えが可能になります。

列を指定して抽出する select句

第2引数のクエリにselect句を使って、表示したい列を指定して抽出することができます。

=QUERY('農薬情報'!A:J, "select A, B, C, D, E, F, G, H, I, J")
=QUERY('農薬情報'!A:J, "select *")

selectの後に抽出したい列をアルファベットで指定します。

ここでアスタリスク*を使うと、第1引数に指定したデータ範囲を全て抽出します。

また、たとえば以下のようにselect句を記述することで、表示する列や表示順を自在に変えることができます。

=QUERY('農薬情報'!A:J, "select A, B, F, E, G")

条件を指定して行を抽出する where句

第2引数のクエリにwhere句を使って、条件を指定して行の抽出をすることができます。

=QUERY('農薬情報'!A:J, "select A, B, F, E, G where E = '殺虫剤'")

この例では、参照するデータ範囲のE列「用途」が「殺虫剤」にあたる行だけを抽出しています。

指定した列の値を基準として並べ替える order by句

第2引数のクエリにorder by句を使って、データの並べ替えをすることができます。

=QUERY('農薬情報'!A:J, "select A, B, F, E, G order by B desc")

この例では、order by B descと記述することで、参照するデータ範囲のB列「農薬名」を降順にソートしています。

また、以下のようにorder by B ascと指定することで、B列を昇順にソートすることができます。昇順ソートの場合、空白行を省くためにwhere B is not nullという記述も併せて必要です。

=QUERY('農薬情報'!A:J, "select A, B, F, E, G where B is not null order by B asc")

QUERY関数の細かい活用方法

 他のスプレッドシートのデータを参照する方法

QUERY関数の第1引数に指定するデータ範囲を他のスプレッドシートのデータにしたい場合は、IMPORTRANGE関数を利用します。

セルA1

=QUERY(IMPORTRANGE("<スプレッドシートID>", "作業項目情報!A:D"), "select *")
=QUERY(IMPORTRANGE("<スプレッドシートID>", "作業項目情報!A:D"), "select Col1, Col2, Col3, Col4")
  • IMPORTRANGE関数の第1引数には参照するスプレッドシートを指定、第2引数にデータ範囲を指定
  • この場合、QUERY関数の第2引数のクエリでの列指定は、アルファベットではなくCol1, Col2, ...を用いる

この例では、別のスプレッドシートにある「作業項目情報」シートのA列〜D列をデータ範囲に指定し、その全てのデータを抽出(参照)しています。

クエリの条件抽出にセル参照を使う方法

QUERY関数の第2引数に指定するクエリでwhere句で条件抽出する際に、セル参照を用いることも可能です。

その場合、イコールの後の書き方にひと工夫が必要です。

  • 数値を参照する場合は、アンパサンドとダブルクォーテーションで囲む("&セル番号&"
  • 文字列を参照する場合は、上に加えてさらにシングルクォーテーションで囲む('"&セル番号&"'

例えば、セルB1セルに参照したい文字列を記入する欄を作成し、そのセルを参照して条件抽出する場合は、以下のようになります。

セルA2

=QUERY('農薬情報'!A:J, "select A, B, F, E, G where E = '"&B1&"'")

この例では文字列を参照したいので、セル番号をアンパサンドとダブルクォーテーション、さらにシングルクォーテーションで囲んでいます。ちょっと複雑ですね。

数値や文字列のほかに、日付を参照することもできます。そのやり方は、次回の記事で応用として使ってみたいと思います。

おわりに

QUERY関数の基本の活用方法について、学習したことをまとめてみました。

改めて、カワムラさんの書籍は入門にピッタリで、引き続き活用させていただきたいと思います。ありがとうございます!

techbookfest.org

参考

support.google.com

【Python】正規表現テキストパターンの検索と置換 - reとRegex・Matchオブジェクト

本記事は、過去に他のブログで書いたものを引っ越ししてきた記事になります。

Python正規表現を扱うためのreモジュールと、Regexオブジェクト・Matchオブジェクトについて、よく利用する操作を自分用にまとめました。必要に応じて追記していきます。

正規表現とreモジュール

正規表現とは、テキストパターンを表現するための方法のことです。たとえば部分一致する文字列を探す「部分検索」とか、大文字小文字の区別を無くすような「曖昧検索」をしたい時に役立ちます。

英語ではregular expression、略して「regex」と呼ばれたりします。

参考リンク:正規表現とは | 「分かりそう」で「分からない」でも「分かった」気になれるIT用語辞典

今回扱う

Python正規表現を扱うには、reモジュールを使用します。

docs.python.org

テキストパターン検索の基本の流れ

Pythonスクリプトreモジュールをインポートしたのち、以下の手順で検索します。

  1. re.compile()関数に正規表現パターンを渡してRegexオブジェクトを生成
  2. Regexオブジェクトのsearch()メソッドに検索対象の文字列を渡してパターンを検索してMatchオブジェクトを取得
  3. Matchオブジェクトのgroup()メソッドを使ってパターンマッチした文字列を取得
import re

regex = re.compile(r'\d\d\d') # Regexオブジェクトを生成(例:3つの数値が並んだパターン)
mo = regex.search('警察は110番、消防は119番') # パターンを検索してMatchオブジェクトを取得
print(mo.group()) # パターンマッチした文字列を取得
<実行ログ>
101

各手順での処理を具体的にみていきます。

re.compile()関数でRegexオブジェクトを生成

まず、Regexオブジェクトを生成します。

  • re.compile()の引数に正規表現パターンを渡す
  • 戻り値はRegexオブジェクト
  • この際に、正規表現パターンにはraw文字列を使うと、バックスラッシュを使った文字をエスケープせずそのまま表示させることができる(例:r'\n'
import re

regex = re.compile(r'\d\d\d') # Regexオブジェクトを生成(例:3つの数値が並んだパターン)
print(regex)
print(type(regex))
<実行ログ>
re.compile('\\d\\d\\d')
<class 're.Pattern'>

search()メソッドでMatchオブジェクトを取得

次に、パターンを検索してMatchオブジェクトを取得します。

  • Regexオブジェクトのserch()メソッドに検索対象の文字列を渡す
  • 戻り値はMatchオブジェクト。パターンマッチした最初の場所を探して返す。マッチするパターンが見つからなければNoneを返す
import re

regex = re.compile(r'\d\d\d') # Regexオブジェクトを生成(例:3つの数値が並んだパターン)
mo = regex.search('警察は110番、消防は119番') # パターンを検索してMatchオブジェクトを取得
print(mo)
print(type(mo))
<実行ログ>
<re.Match object; span=(3, 6), match='110'>
<class 're.Match'>

group()メソッドでパターンマッチした文字列を取得

最後に、パターンマッチした文字列を取得します。

  • Matchオブジェクトのgroup()メソッドを使うと、パターンマッチした文字列を返す
import re

regex = re.compile(r'\d\d\d') # Regexオブジェクトを生成(例:3つの数値が並んだパターン)
mo = regex.search('警察は110番、消防は119番') # パターンを検索してMatchオブジェクトを取得
print(mo.group()) # パターンマッチした文字列を取得
<実行ログ>
110

ちなみに、正規表現パターンを()でグルーピングすることもできます。その場合は、group()の引数に1, 2, 3, ..., 99とインデックスを渡すことで、グループごとに文字列を取り出すことができるようになります。

また、引数に複数のインデックスをカンマ区切りで指定することで、複数の文字列を取り出すことができます。

findall()メソッドで複数のパターンを検索

パターンの検索の際には、Regexオブジェクトのsearch()メソッドの他にfindall()メソッドがあります。それぞれの違いは以下の通りです。

  • search()メソッド: 最初に見つかった文字列のMatchオブジェクトを返す
  • findall()メソッド: 見つかった全ての文字列のリストを返す(※Matchオブジェクトではないので注意)
import re

regex = re.compile(r'\d\d\d')
items = regex.findall('警察は110番、消防は119番')
print(items)
<実行ログ>
['101', '119']

ちなみに正規表現パターンを()でグルーピングした場合は、findall()メソッドの戻り値はタプルのリストとなります。

sub()で文字列を置換する

パターンマッチした文字列を置換することもできます。

  • Regexオブジェクトのsub()メソッドを使う
  • 第1引数に置き換える文字列、第2引数に検索置換対象の文字列を入れると、文字列を全て置換した結果を返す
import re

regex = re.compile(r'(\d)\d\d')

# 文字列を置換
after = regex.sub(r'\1??', '警察は110番、消防は119番')
print(after)
<実行ログ>
警察は1??番、消防は1??番

ちなみに、Regexオブジェクトに()を使ってグルーピングした場合、置き換える文字列にグループの番号を使って/1, /2などと記述できます。これによりマッチした一部のグループを置換せずそのまま表示させることができます(後方参照と呼ぶ)。

参考

docs.python.org

【Python】時間や日時の表現 - time・datetime

本記事は、過去に他のブログで書いたものを引っ越ししてきた記事になります。

Pythonで時間や日時を扱うtimeモジュールとdatetimeモジュールについて、よく利用する操作を自分用にまとめました。必要に応じて追記していきます。

時間や日時を表すモジュール

Pythonで時間や日時に関する処理を行うモジュールには、よく使うものにtimeとdatetimeがあります。

docs.python.org

docs.python.org

主なデータ型

これらのモジュールを使って扱う主なデータ型は、次のように整理できます。

呼称 データ型 モジュール 表示例 備考
UNIXエポックに基づくタイムスタンプ float型 time 1612587698.479415 エポックタイムからの秒数
整数値または浮動小数点値
②特定の日時 datetime型 datetime 2021-02-06 14:02:23.592996 date(), time()メソッドの戻り値はdatetime型
他それぞれの属性は整数値
③datetime同士の時間差 timedelta型 datetime 0:00:03.003681 それぞれの属性は整数値
  • UNIXエポックタイムは、多くのプログラミング言語で使われる標準的な参照時間(1970/1/1 0:00 UTC協定世界時))のこと。
  • そのUNIXエポックタイムからの経過時間をエポックタイムスタンプと呼びます。
import time
import datetime

# ①UNIXエポックに基づくタイムスタンプ float型
epoch = time.time()
print(epoch)  # -> 1612587698.479415
print(type(epoch)) # -> <class 'float'>

# ②特定の日時 datetime型
dt = datetime.datetime.now()
print(dt)  # -> 2021-02-06 14:02:23.592996
print(type(dt))  # -> <class 'datetime.datetime'>

# ③datetime同士の時間差 timedelta型
td = datetime.timedelta(seconds=3)
print(td)  # -> 0:00:03.003681
print(type(td))  # -> <class 'datetime.timedelta'>

ちなみに、「②特定の日時」を表すデータ型には、他にも「date型」と「time型」というのがあります。ですが、基本的には日付と時刻がまとまっている「datetime型」を使えば問題ないのかなと思ってます。

datetime型を扱う

datetime型の使い方を整理しておきます。

datetimeオブジェクトの生成

特定の日時を表すdatetimeオブジェクトを扱うには、datetimeモジュールのdatetimeクラスを使います(まぎらわしい)。

現在日時を表すdatetimeオブジェクトを生成するには、datetime.now()を使います(datetimeモジュールのdatetimeクラスのnow()メソッドを呼び出す)。

指定した日時のdatetimeオブジェクトを生成するには、datetime()を使います(datetimeモジュールのdatetimeクラスのコンストラクタを呼び出す)。

from datetime import datetime

now = datetime.now()  # 現在日時
dt = datetime(2021, 1, 1, 10, 00, 00)  # 日時を指定(年、月、日、時間、分、秒)

生成したdatetimeオブジェクトは、以下のような属性にアクセスすることができます。

属性 意味
year
month
day
hour 時間
minute
second
microsecond ミリ秒
from datetime import datetime

dt = datetime(2021, 1, 1, 10, 00, 00)  # 日時を指定(年、月、日、時間、分、秒)
print(dt.year, dt.month, dt.day) # 2021 1 1

datetime→文字列に型変換

datetimeオブジェクトは、strftime()メソッドでフォーマットした文字列として表示させることができます。

メソッドの引数には、変換したい書式コード文字列(後述の表参照)を渡します。

ちなみにメソッド名の「str」は文字列、「f」はフォーマット(整形)の意味のようです。

from datetime import datetime

dt = datetime.now()
print(dt.strftime('%Y/%m/%d'))

よく使う書式コード一覧

書式 意味
%Y 西暦 4桁 '2021'
%y 西暦 2桁 '00'〜'99'
%m 月(0埋め) '01'〜'12'
%d '01'〜'31'
%w 曜日 '0'(日曜)〜'6'(土)
%H 時(24時間制) '00'〜'23'
%h 時(12時間制) '01'〜'12'
%M 分(0埋め) '00'〜'59'
%S 秒(0埋め) '01'〜'31'

こんな書式コードもあるよ

書式 意味
%B 月名 'January'
%b 月の略称 'Jan'
%A 曜日名 'Sunday'
%a 曜日の略称 'Sun'
%j 年初からの日数 '001'〜'366'
%p AMかPMか 'AM', 'PM'
%c 日時をまとめて表示 'Sun Feb 7 12:57:18 2021'
%x 日付をまとめて表示 '02/07/21'
%X 時間をまとめて表示 '12:57:18'
%% %文字

※書式コードの詳細については公式ドキュメント参照

docs.python.org

文字列→datetimeに型変換

今度は先ほどと逆に、日付情報の入った文字列を、datetime.strptime()メソッドでdatetimeオブジェクトに変換することができます。

メソッドの第1引数には変換したい文字列を、第2引数に対応する書式コード文字列を渡します。文字列が正確にマッチしないとValueError例外が発生するので、注意しましょう。

ちなみにメソッド名の「str」は文字列、「p」はパース(構文解析)の意味のようです。

from datetime import datetime

date = datetime.strptime('20200207', '%Y%m%d')

print(date)  # -> 2020-02-07 00:00:00
print(type(date))  # -> <class 'datetime.datetime'>

参考

docs.python.org

docs.python.org

【Python】ファイル操作 - pathlibとPathオブジェクト

本記事は、過去に他のブログで書いたものを引っ越ししてきた記事になります。

Pythonでファイル操作を行うのに便利な、pathlibモジュールとPathオブジェクトについて、よく利用する操作を自分用にまとめました。必要に応じて追記していきます。

pathlibについて

pathlibはPython3.4から追加されたモジュールで、ファイルやディレクトリを操作するために便利な機能を提供しています。

docs.python.org

それ以前のバージョンでは、os.pathを使うのが一般的でした。公式ドキュメントにもosモジュールとの対応表がまとめられています。

Pathオブジェクトを生成する

ファイルやフォルダのパスをオブジェクトとして扱うには、Pathクラスを使います。

pathlib.Path(パス)と記述することで、Pathオブジェクトを生成します。このパスには絶対パス相対パスのどちらでも使用できます。また、複数の部分パスを指定することもできます。

パスオブジェクト同士は/演算子を使って結合することができます。

from pathlib import Path

p_cur = Path('.') # カレントディレクトリのPathオブジェクトを生成

p = Path(r'/Users/xxx/Desktop/image.png') # 絶対パスでPathオブジェクトを生成
p_dir = Path('folder') # 相対パスでPathオブジェクトを生成
p_txtfile = Path('documents', 'file.txt') # 複数の部分パスからPathオブジェクトを生成

print(type(p), p)
print(p_dir / p_txtfile) # Pathオブジェクトの結合
<実行ログ>
<class 'pathlib.PosixPath'> /Users/xxx/Desktop/image.png
folder/documents/file.txt

Pathオブジェクトの属性

カレントディレクトリを調べる

現在操作しているディレクトリを調べる時は、Path.cwd()を使います。新しいPathオブジェクトを返します。

このcwd()メソッドはクラスメソッドのため、インスタンスを生成せずに使用します。

パスの存在確認

パスが存在しているかどうかを確認する時は、Pathオブジェクトのexists()メソッドを使います。

パスがディレクトリなのかファイルなのかを調べる時は、is_dir(), is_file()メソッドをそれぞれ使います。

p = Path('sample.txt')

print(p.exists()) # True
print(p.is_dir()) # False
pritn(p.is_file()) #True

パスの情報

Pathオブジェクトの親ディレクトリのパスは、Pathオブジェクトのparent属性で調べることができます。

ファイル名はname属性、拡張子はsuffix属性でそれぞれ文字列で得られます。

p = Path('sample.txt')

print(p.parent)
print(p.name)
pritn(p.suffix)

パスの一覧を取得するメソッド

iterdir()メソッドは、Pathオブジェクトが表すディレクトリ内のPathオブジェクト一覧を調べ、そのジェネレータを返します。

glob()メソッドは、Pathオブジェクトが表すディレクトリ内の文字列パターンにマッチしたPathオブジェクト一覧を調べ、そのジェネレータを返します。

文字列パターン

正規表現と同様に[0-9]b(o|a)b)などのようなパターンも使えます。

書式 意味
** Pathオブジェクト自身とそのサブフォルダ全てを再帰的に操作
* 長さ0以上の任意の文字列
? 任意の1文字
[] 特定の1文字
sorted(Path('.').glob('*/*.py'))
sorted(Path('.').glob('**/*.py'))

参考

docs.python.org

【Python】画像入りフォルダをドラッグ&ドロップでまとめて余白削除するツール

技術文章の執筆用に、画像の余白部分を削除するツールをPythonで作りました。

以前の記事で、Pillowというライブラリを使用したスクリプトを紹介しています。

nouka-it.hatenablog.com

今回の記事では、これを元に「画像の入ったフォルダをターミナル上にドラッグ&ドロップすることで画像をまとめて処理できるツール」を作成したので、その内容を残しておくことにしました。

スクリプトがやや長く、この記事の中では細かく解説ができませんでした。「Pythonでこんなことができるよー」という参考になればよいなと思います。

もし何か知りたいことがあればコメントいただけたらと思います!

やりたいこと

今回のツールでやりたいことは、複数の画像の余白処理をまとめて簡単にしたい、というところでした(PCはMacを使用しています)。

実際にこのツールを使って行う手順は、以下の通りです。

  1. Pythonスクリプトを実行すると、ターミナルでパスの入力を求められる
  2. その状態でターミナルに画像の入ったフォルダをドラッグ&ドロップすると、絶対パスが入力される
  3. Enterキーを押すと、フォルダ内の全ての画像について余白の削除が適用される
  4. 新しい画像は、作成された「cropped」フォルダに保存される

動画にしてみました。ここではVSCodeのターミナルを使っています。

ちなみにこの「ドラッグ&ドロップで処理する」というのは、ノンプロ研メンバーのこはたさんの記事を読んで良いなと思って、アイデアをいただきました(実際に処理する内容は違うのですが)。

note.com

pyファイルの実行に関して、Windowsだとドラッグ&ドロップでpyファイルの実行をすることが可能なようなのですが、Macだとどうもそれができません。

どうすれば簡単にできるのかなーと考えたところ、このようにターミナルを使ってやるのが一番簡単そうでした。

スクリプト

こちらが今回のスクリプトです。

from pathlib import Path
from PIL import Image, ImageChops

def input_path():
    """入力を受け付けてパスのタプルとして返す関数

    Returns:
        path(Path): 入力されたパス
    """
    data = input('パスを入力 > ')
    path = Path(data.replace("\'", ''))

    return path

  
def create_files_tuple(path):
    """パスを元に画像ファイルのパスが格納されたタプルを返す関数

    パスがfileならそのパスを、folderならその中身のパスをタプルに格納

    Args:
        path(Path): 入力されたパス
    Returns:
        files(tuple): 画像ファイルのパスが格納されたタプル
    """
    if path.is_dir():
        _files = path.iterdir()
        files = tuple(_files)
    if path.is_file():
        files = (path,)

    return files


def crop_margin(path, bg_color=(255, 255, 255), override=False):
    """画像の余白を削除する関数

    Args:
        path(Path): 元画像ファイルのパス
        bg_color(tuple): 削除したい余白カラー(デフォルトは白)
        override(boolean): 処理した画像を上書き保存するかどうか(Trueで画像を上書き保存、Falseでcroppedフォルダを作成し保存)
    """
    # 保存先パスの決定
    if override:
        dest_path = path
    else:
        dir_path = Path(f'{path.parent}/cropped')
        dir_path.mkdir(exist_ok=True)
        dest_path = Path(f'{dir_path}/{path.name}')

    # 余白削除のための差分画像の作成
    img = Image.open(path)
    bg_img = Image.new('RGBA', img.size, bg_color)
    diff_img = ImageChops.difference(img, bg_img)

    # クロップ範囲の取得と実行
    crop_range = diff_img.convert('RGB').getbbox()
    crop_img = img.crop(crop_range)
    crop_img.save(dest_path)


if __name__ == '__main__':
    path = input_path()
    files = create_files_tuple(path)
    for file in files:
        crop_margin(file)

スクリプトの補足

ツールを作っていて詰まったところ、工夫したところなどなど補足をしておきます。

ドラッグ&ドロップでパスを入力するときにシングルクォートがつく問題

ターミナルにドラッグ&ドロップしてパスを取得すると、自動でシングルクォートがついてしまいます。

最初このことに気づかず、input_path()関数でこのパスの取得を行う際に、このシングルクォートが邪魔でその後の処理が正常に作動してくれませんでした。

そこで、文字列型のreplace()メソッドを使ってシングルクォートを空文字列に置き換えることで、正しいパスを取得できるようにしています。

def input_path():
 〜省略〜
    data = input('パスを入力 > ')
    path = Path(data.replace("\'", ''))
 〜省略〜

入力するパスはファイルでもフォルダでも対応可能

パスの入力時にフォルダをドラッグ&ドロップすることで、フォルダ内の画像すべてに同じ処理をします。

それをするために、create_files_tuple()関数の中で、フォルダの中の画像ファイルのパスを一度タプルに格納しています。このときに、画像の入フォルダだけでなく、画像ファイル単体をドラッグ&ドロップしても対応できるようにしています。

def create_files_tuple(path):
 〜省略〜
    if path.is_dir():
        _files = path.iterdir()
        files = tuple(_files)
    if path.is_file():
        files = (path,)

    return files

画像を上書き保存するようにもできるよ

実際の画像の処理は、crop_margin()関数で行っています。この中でオプション引数としてoverrideを用意して、出力する画像を上書き保存するかどうかを指定できるようにしてみました。

def crop_margin(path, bg_color=(255, 255, 255), override=False):
 〜省略〜
    # 保存先パスの決定
    if override:
        dest_path = path
    else:
        dir_path = Path(f'{path.parent}/cropped')
        dir_path.mkdir(exist_ok=True)
        dest_path = Path(f'{dir_path}/{path.name}')
 〜省略〜

このcrop_margin関数を実行する際に、override=Trueを引数に記述してあげれば、余白を削除した画像を元画像に上書き保存するようになります。

if __name__ == '__main__':
 〜省略〜
    for file in files:
        crop_margin(file, override=True)

スクリプトの問題点

フォルダの中に画像ファイル以外が入っていたらどうなるの?という問題がありますが、そこはちゃんと作っていません。汗

運用でなんとでもなるので、あまりスクリプトを複雑にしなくても良いのかなと思っています。

おわりに

このままでも使えますが、実際にはこのpyファイルをダブルクリックで実行できるようにツール化して活用しています。

個人的にはなかなか便利なツールができたので満足です。

【Python】画像の余白を削除するスクリプト

技術文章の執筆用に、画像の余白部分を削除するツールをPythonで作りました。

思ったよりシンプルにできたのが印象的です。いくつかやり方がありましたが、Pillowという画像処理用のモジュールを使用して「背景差分法」という手法を使って実現しています。

やりたいこと

こんな感じで、上下左右に白の余白のある画像があったとします(赤い枠線は画像の範囲がわかりやすいようにつけています)。

この余白を削除(トリミング)して、画像の必要な部分のみ切り出して新しい画像を生成します。

生成した画像は、上書きせずに新しい画像ファイルとして出力するやり方を取ることにします。

スクリプト

スクリプトの全体像

こちらが全体のスクリプトです。

from PIL import Image, ImageChops


"""グローバル定数"""
TARGET_IMG_PATH = '/Users/massa/hogehoge/fig01.png' # 対象の画像ファイルのパス
NEW_IMG_PATH = '/Users/massa/hogehoge/new_fig01.png' # 新たに作成する画像ファイルのパス
BG_COLOR = (255, 255, 255) # 背景色RGBをタプルで指定


def crop_margin():
    """
    TARGET_IMG_PATHの画像の余白を削除してNEW_IMG_PATHとして出力する関数。
    """

    # 画像ファイルの取得
    img = Image.open(TARGET_IMG_PATH)

    # 背景画像の生成
    bg_img = Image.new('RGBA', img.size, BG_COLOR)

    # 差分画像の生成
    diff_img = ImageChops.difference(img, bg_img)

    # 切り出し範囲の取得と実行
    crop_range = diff_img.convert('RGB').getbbox()
    crop_img = img.crop(crop_range)

    # 画像ファイルの出力
    crop_img.save(NEW_IMG_PATH)

if __name__ == '__main__':
    crop_margin()

スクリプトはこちらを参考にさせていただきました!

water2litter.net

余白切り抜きの手法についても記事内でわかりやすく解説されています。

グローバル定数

スクリプトを使用する際は、グローバル定数部分を書き換えて使用できます。

"""パラメータ"""
TARGET_IMG_PATH = '/Users/massa/hogehoge/fig01.png' # 対象の画像ファイルのパス
NEW_IMG_PATH = '/Users/massa/hogehoge/new_fig01.png' # 新たに作成する画像ファイルのパス
BG_COLOR = (255, 255, 255) # 背景色RGBをタプルで指定

TARGET_IMG_PATHには元画像のファイルパスを、NEW_IMG_PATHには余白を削除した新しいファイルパスを指定します。

また、背景色BG_COLORには、削除したい余白のRGBの値を(n, m, l)という形で0〜255の数値で指定します。今回は白色を指定しています。

Pillowと使用するクラス

Pillow

Pillowは、Pythonで画像処理を行うためのモジュールです。画像のリサイズや回転、トリミングなどの単純な処理を簡単に行うことができます。

モジュールをインストールしていない場合は、あらかじめターミナル等で以下のコマンドを使ってインストールしておきましょう。

pip install Pillow

今回のスクリプトでは、PillowのImageクラス、ImageChopsクラスを利用するので、このようにインポートします。

from PIL import Image, ImageChops

モジュール名はPILとする必要があるので、注意してください。

Imageクラス

Pillowで画像ファイルを扱う際には、Imageオブジェクトを使います。「Imageオブジェクト=画像ファイルをPython上で扱えるようにしたもの」というイメージで良いのかなと思います。

下記に、Imageクラスのよく使いそうな属性をまとめておきます。

imはImageオブジェクト
※★は今回のスクリプトで使用しているもの

Imageオブジェクトの読み込み・生成・保存

属性 解説 備考
im.mode 画像のモードを取得 文字列'L', 'RGB', 'CMYK'など
★im.size 画像のサイズを取得 タプル(幅, 高さ)
im.format 画像のフォーマットを取得 文字列'PNG'など
★im.open(fp, mode='r', formats=None) 画像ファイルを読み込む 引数fpは画像ファイルのパス
戻り値はImageオブジェクト
★im.new(mode, size, color=0) 単一色のImage画像を生成する 戻り値はImageオブジェクト
★im.save(fp, format=None, **params) 画像を保存する 引数fpは画像ファイルのパス
戻り値はImageオブジェクト
im.show(title=None) 画像をプレビュー表示する

Imageオブジェクトの切り抜き・色の取得

属性 解説 備考
im.getpixel(xy) 画像の指定した座標の色を取得する 引数xyは座標を表すタプル(x, y)
戻り値はカラーを表すタプル
★im.getbbox() 画像内で値が0 でない最小領域の長方形を返す 戻り値は長方形領域を表すタプル(左, 上, 右, 下)
★im.crop(box=None) 画像を指定した長方形でトリミングする 引数boxは長方形領域を表すタプル(左, 上, 右, 下)
戻り値はImageオブジェクト

pillow.readthedocs.io

note.nkmk.me

ImageChopsクラス

切り抜き範囲を指定するために、ImageChopsクラスを使います。

これはちょっとイメージが難しいのですが、ざっくり「Imageオブジェクトの各ピクセルに対して演算処理を行う」機能を提供してくれるモジュール、という感じでしょうか。chopsは「Channel Operations」の略のようですね。

今回使用するImageChopsクラスのメソッドはこちらです。

ImageChopsクラスのメソッド

メソッド 解説 備考
★ImageChops.difference(image1, image2) 2つの画像のピクセルごとの値の差の絶対値を返す 引数は2つのImageオブジェクト
戻り値はImageオブジェクト

pillow.readthedocs.io

スクリプトの解説

すべて解説できませんでしたが、気になった点を補足しておきます。

Imageオブジェクトの生成

画像ファイルをパスを指定して開くには、Imageクラスのopen()メソッド、単一色の背景画像を生成するにはImageクラスのnew()メソッドを使用します。

    # 画像ファイルの取得
    img = Image.open(TARGET_IMG_PATH)

    # 背景画像の生成
    bg_img = Image.new('RGBA', img.size, BG_COLOR)

new()メソッドの引数は以下のように指定します。

  • 第1引数にはRGBAを指定
  • 第2引数にimg.sizeを指定。元画像imgと同じサイズになる
  • 第3引数にBG_COLORを指定。今回は白の単一色画像が生成される

ちなみに、第1引数に最初はRGBを指定したところ、僕の環境だとなんだかうまくいかず…。色々試したところ、RGBAを指定するとうまくいきました。

差分画像を生成し切り出し範囲を決める

元画像と背景色画像との差分画像を生成し、その差分画像を使って切り出し範囲を決める、というのが背景差分法のポイントみたいです。

    # 差分画像の生成
    diff_img = ImageChops.difference(img, bg_img)

    # 切り出し範囲の取得と実行
    crop_range = diff_img.convert('RGB').getbbox()
    crop_img = img.crop(crop_range)

まず、差分画像の生成。ここではImageChopsクラスのdifference()メソッドを使って、元画像imgと背景色画像bg_imgとの差分となるImageオブジェクトを生成しています。

画像の座標ピクセルごとに差分を取り、背景色が一致する部分は黒で埋め尽くされた差分画像が出来上がります。

次に、切り出し範囲の取得。ここでは差分画像diff_imgに対して、getbbox()メソッドで画像の境界線を長方形で取得しています。bboxは「bounding box」から来ているようですね。

ちなみにこの際にconvert('RGB')を適用しておかないと、うまく切り出しができませんでした。

画像ファイルの出力

Imageオブジェクトを画像ファイルとして出力するには、Imageクラスのsave()メソッドを使用します。

    # 画像ファイルの出力
    crop_img.save(NEW_IMG_PATH)

ここで元の画像のパスを指定すれば、生成された画像を上書き保存するような仕様にすることができますね。

おわりに

今回はPillowというライブラリを使いましたが、OpenCVでも同じような(むしろより高度な)画像処理を行うことができるようです。

また、余白の削除には「背景差分法」のほかに「二値法」というやり方があり、そのやり方を使うと単一色でなくても切り抜きが可能そうです。画質が荒くなった画像の余白を削除するには、この二値法が使えそうですね。

参考

water2litter.net

note.nkmk.me

pillow.readthedocs.io

pillow.readthedocs.io