Lightsail(WordPress)の2023年11月時点のディレクトリ構造

前回の記事で書いた通り、このブログが稼働しているサーバーを新しいインスタンスに移行しました。その中で各作業をやっていてディレクトリ構成が昔と変わっているのが目についたので色々メモっておきます。

自分都合で申し訳ないのですが、今の構造自体の話よりも5年前の先代のインスタンスとの差分に着目して記録しています。自分の記事や手元に残したメモと対比するためです。

バージョンの違い

いつのバージョンと比較するのか明確にするために自分の新旧インスタンスの除法を載せておきます。このブログは2019年の1月から運用していますがサーバーを立てたのはその直前の2018年12月です。両インスタンスにSSHでログインして気づいたのですが、そもそもOSから違いますね。UbuntsだったのがDebianになってます。またアプリも「the Bitnami WordPress」が 「the WordPress packaged by Bitnami」に変わったようです。これは色々変更されていても納得です。

旧インスタンス

Welcome to Ubuntu 16.04.5 LTS (GNU/Linux 4.4.0-1128-aws x86_64)
       ___ _ _                   _
      | _ |_) |_ _ _  __ _ _ __ (_)
      | _ \ |  _| ' \/ _` | '  \| |
      |___/_|\__|_|_|\__,_|_|_|_|_|

  *** Welcome to the Bitnami WordPress 4.9.8-0 ***
  *** Documentation:  https://docs.bitnami.com/aws/apps/wordpress/ ***
  ***                 https://docs.bitnami.com/aws/ ***
  *** Bitnami Forums: https://community.bitnami.com/ ***

新インスタンス

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
       ___ _ _                   _
      | _ |_) |_ _ _  __ _ _ __ (_)
      | _ \ |  _| ' \/ _` | '  \| |
      |___/_|\__|_|_|\__,_|_|_|_|_|

  *** Welcome to the WordPress packaged by Bitnami 6.3.1-23        ***
  *** Documentation:  https://docs.bitnami.com/aws/apps/wordpress/ ***
  ***                 https://docs.bitnami.com/aws/                ***
  *** Bitnami Forums: https://github.com/bitnami/vms/              ***

WordPressのインストール先の変更

WordPressの利用のために使っているインスタンスですからこれが一番重要です。

旧インスタンスでは、Homeディレクトリにappsってディレクトリがあってその下にwordpressもありました。その配下にある、
/home/bitnami/apps/wordpress/htdocs
がドキュメントルートです。wp-config.php などのファイルもここにありました。

新環境では ~/apps はなくなり、wordpressは、/opt/bitnami の配下に移動しています。
/opt/bitnami/wordpress
がドキュメントルートで、wp-config.phpなどはこちらにも移動しています。

Google アドセンスのads.txtの置き場所もこちらへ変更です。

他のブログを見てると、/opt/bitnami/apps ってディレクトリに言及してる人もいるので、もしかしたら時代によっては /opt/bitnami の前に、そこが使われた頃もあったのかもしれません。

DB関係のディレクトリパスの変更

そもそもとして、インストールされているDBがMySQLからMariaDBに変わりました。

# 旧インスタンス
$ which mysql
/opt/bitnami/mysql/bin/mysql

# 新インスタンス
$ which mysql
/opt/bitnami/mariadb/bin/mysql

結果設定ファイルのmy.confの場所も、
/opt/bitnami/mysql/my.conf
から
/opt/bitnami/mariadb/conf/my.conf
に変わっています。

このmy.confの中を見ると、datadir って変数でDBのデータの実態ファイルの書くのディレクトリが指定されていますが、
/opt/bitnami/mysql/data
から
/bitnami/mariadb/data
へと変更されています。

/bitnami ってディレクトリ自体も新設されたものですね。

アップロードした画像等の配置場所

実はアップロードした画像なども先ほど書いた、/bitnami 配下に格納されています。データ関連の実態ファイルを一箇所にまとまったイメージでしょうか。

/opt/bitnami/wordpress で ls -la すると、
wp-content から /bitnami/wordpress/wp-content へシンボリックリンクが貼られていることがわかります。

実は、 wp-config.php も実態のファイルはここにあって、ドキュメントルートにあるのはシンボリックリンクです。

なんか、こう見るとads.txtも正しい置き場所はここで、ドキュメントルートにはシンボリックリンクで対応するべきだったのかなという気がしてきます。

apacheのディレクトリ名変更

これはめっちゃ細かい話ですが、/opt/bitnami/apache2 だったのが、2が取れて /opt/bitnami/apache になりました。ご丁寧にシンボリックリンクが貼ってあって旧パスでもたどり着けるようになっています。

$ ls -la | grep apache
drwxr-xr-x 16 root    root    4096 Oct 10 05:24 apache
lrwxrwxrwx  1 root    root       6 Oct 10 05:24 apache2 -> apache

この下に logs があってそこにアクセスログ等があるのは変わらずです。
/opt/bitnami/apache/logs
に access_log / error_log の名前で入ってます。

LightsailのWordPressを新しいインスタンスに移行する手順

更新遅延について

いつも月曜日更新ですが今週は更新が遅れました。といいうのも、このWordpressが稼働しているLightsailのインスタンスがメモリ不足になり、記事の一覧や新規作成ページが開けなくなったからです。
PHP Fatal error: Allowed memory size of 268435456 bytes exhausted (tried to allocate 20480 bytes)
みたいなエラーがエラーログに出ていました。

また、前々からPHPのバージョンが古い(7.0.3.1)ことで管理画面で警告が出ておりいつか対応しないといけないという状況でもありました。

LightsailのwordpressはBitnamiというソフトで稼働しているのですが、実はこれはPHPのバージョンアップができません。PHPのバージョンを上げたければインスタンスを作り直さないといけないという罠があります。

以上の二つの理由で、PHPのバージョンを上げないといけないし、メモリも増やさないといけないしとなったので、Wordpressの移行作業を行いました。

やり方はいろいろあると思うのですが、今回は「All-in-One WP Migration」というプラグインを使いました。

失敗した方法と正しい順序

実は一回しくじったのでそれについて先に書きます。

ブログにアクセスできない時間を最小限にするために実は最初、次の手順でやろうとしていました。

  1. 新しいWordpressのインスタンスを立てる。
  2. 新しいインスタンスに記事やメディアのデータを移行する。
  3. IPアドレスやドメインを新しいインスタンスに移す。
  4. https化などの追加作業を行う。

こうすれば、切り替えた瞬間から訪問者の方が記事を読めると思ってやったのですが、All-in-One WP Migration のプラグインが気が利きすぎていて困ったことが起きました。

それは記事中のテキストや、リンク等に含まれるこのブログのURLが、移行先のインスタンスのURLに勝手に書き換えられてしまったことです。要するに、https://analytics-note.xyz/ って文字列があったら全部、 http://{IPアドレス}/ になってしまいました。

そして、IPアドレスやドメインを設定しても置換されたテキストは元に戻りませんでした。
これを全部治すのは面倒だったので、そのインスタンスは破棄してやり直しました。記事を読めない時間が発生するのは諦めて、次の手順で行い、今のところ正常に動いているようです。

  1. 新しいWordpressのインスタンスを立てる。
  2. IPアドレスやドメインを新しいインスタンスに移す。
  3. https化などの追加作業を行う。
  4. 新しいインスタンスに記事やメディアのデータを移行する。
  5. 残作業の実施。

それでは、細かく手順を説明していきますね。

旧インスタンス側の作業

データのエクスポート

移行するためにデータをローカルに取り出します。先述の通り「All-in-One WP Migration」ってプラグインを使いました。プラグインの一覧から有効化すると左部メニューに登場しますので、エクスポートを選択します。エクスポート先が選べるので、「ファイル」を選ぶとそのままダウンロードが始まります。僕のブログは画像とか少ないのですがそれでも250MBくらいになりました。ちなみに、オプションがいくつかあって僕は次の二つ選びました。これはあまり重要ではないと思います。

  • スパムコメントをエクスポートしない
  • 投稿リビジョンをエクスポートしない

リダイレクト処理の停止

これは、httpやIPアドレスでのアクセスをリダイレクトしてる人だけの作業なので必須ではありません。

旧環境からはドメインを剥奪するので今後はIPアドレス直打ちでしかアクセスできなくなります。僕は普通にドメインでアクセスされなかったらリダイレクトするように設定していたのでそれを戻します。要するに次の記事の逆の手順を行いました。

参考: httpのアクセスをhttpsにリダイレクトする

以上で旧環境での作業は終了です。

新環境作業

インスタンス作成

続いて新環境側の作業ですが、最初にやるのは当然ですが新環境の作成です。以下の記事を参考に新しいインスタンスを立てます。静的IPアドレスは旧環境で使ってたのを使い回すのでインスタンスだけ立てたら大丈夫です。

参考記事: Amazon Lightsail で WordPressサイトを作成する手順

今回はメモリ不足の解消を兼ねていたので、$5の、1GB/2vCPU/40GB SSD を選択しました。

ここでちょっとLightsailの宣伝なのですが、最近$3.5や$5のインスタンスでもvCPUが2個になってます。以前は1個だったので同じ値段で倍のCPUが使える計算になりますね。とはいえ、僕が不足したのはCPUではなくメモリだったので結局以前より高いプランに変えたのですが。これは、特に何か理由がない人も移行するメリットがありそうです。

静的IPの付け替え

失敗した方法のところで説明しましたが、インスタンス作ったらもう早速IPアドレスとそれに紐づいているドメインを新インスタンスに渡します。

インスタンスができたらSSH接続してパスワードを確認します。pemキーはデフォルトでは同じのが使えるのでIPアドレスだけ変えたら繋がります。

GUIで簡単に作業できます。

Lightsail の ネットワーキングにアクセスし、静的IPアドレスを選択。
管理からデタッチし、インスタンスへのアタッチでプルダウンから新しいWordpressを選んでアタッチ、と画面に沿って作業していけば完了です。

これでもう、URL叩いたら新しいWordpress(記事とか何もない初期画面)が開くようになります。

今回、PHPのバージョンアップも目的の一つだったので、SSHで繋いで $php –version してみておきましょう。無事に上がっていますね・

$ php --version
PHP 8.2.11 (cli) (built: Oct  6 2023 09:57:45) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.2.11, Copyright (c) Zend Technologies
    with Zend OPcache v8.2.11, Copyright (c), by Zend Technologies

https化

Let’s Encryptを使ってhttps化します。

最近は昔と手順が変わっていて、ssh接続して以下のコマンドを叩いてプロンプトに従っていくだけでできるようです。ドメイン名とかメールアドレスを途中で聞かれます。

$ sudo /opt/bitnami/bncert-tool

パスワードの確認と初回ログイン

https化したらデータの移行作業へと入っていきます。wordpressの管理画面にログインしないといけないので初期パスワードを確認します。

SSH接続したホームディレクトリの中に、bitnami_application_password
ってファイルがあり、その中に書かれています。

これを使ってログインします。

データのインポート

All-in-One WP Migrationを使ってインポートするので、最初にやるのはこのプラグインの有効化です。ついでに最新化もしておきましょう。

左メニューにImportが追加されるのでそれを選びます。すると、実はこの時点ではアップロードできるファイルの上限が設定されているのでそれを確認します。おそらくインスタンスサイズによって異なります。

僕の場合は、 Maximum upload file size: 80 MB. と表示されていて、保存していたファイルがそのままだと上げられませんでした。

そこで、PHPの設定を変更してアップロードできるファイルサイズを一時的に増やします。

以下のファイルを開きます。
/opt/bitnami/php/etc/php.ini

その中に、post_max_size と、 upload_max_filesize って設定があるので、それぞれ80Mを512Mとかに書き換えます。(修正前にファイルはバックアップ取りましょう。)

書き換えたら、次のコマンドで再起動します。

$ sudo /opt/bitnami/ctlscript.sh restart

これでアップロードできるファイルサイズが上がるので、エクスポートしていたファイルをアップロードします。

PHP7からPHP8への移行だったので色々警告とか出ましたが仕方ないので全て認めて作業進めます。

この時点で、記事や画像等のメディア、テーマ、小テーマ、プラグイン情報などが移行されほぼほぼ元通り見れるサイトになります。

/opt/bitnami/php/etc/php.ini の中身は元に戻して、もう一回再起動しておきましょう。

再ログインとDB更新

データをインポートすると、wordpressのユーザー情報、要するにパスワードなども復元されるので一回セッションが切れてログアウトします。

次にログインするときは、旧環境のパスワードでログインするので間違えないようにしてください。

ただし、パスワードが戻るのはアプリ(wordpress)のユーザーだけで、MySQLコマンド等で接続するときに使う、DB自体のパスワードは新しい環境のパスワードそのままです。あくまでも書き換わるのはDBの中身だけってことですね。

wordpressの管理画面に再ログインすると、「データベースの更新が必要です」ってメッセージが出るので更新します。

これでほぼ移行完了です。

残作業

残りは細かい残作業やっていきます。

例えばこれとかやる必要があります。
WordPressの一般設定にあるURLをhttpsに変える
LightsailのWordPressにads.txtを設置する
– 不要になったインスタンスの削除

ads.txtの設置場所は
/opt/bitnami/wordpress
に変わってました。

また、旧インスタンスは即消すのではなくしばらく並行しておいておく方がお勧めです。ぼくもしばらくは放置しておこうと思っています。

この後も何か残作業見つかったら順次対応していこうと思います。

boto3のAPIエラー発生時のリトライ回数の設定を変更する

先日、boto3でAmazon Comprehendを使っていたら、大量のデータを処理する中で次のエラーが頻発する様になってしまいました。

ClientError: An error occurred (ThrottlingException) when calling the BatchDetectSentiment operation (reached max retries: 4): Rate exceeded

短時間に実行しすぎてレートの上限に引っかかり、リトライも規定回数失敗してしまった様です。

Comprehend の使い方自体は過去の記事で書いてます。
参考: Amazon Comprehend でテキストのセンチメント分析

軽く紹介しておくとこんな感じですね。

import boto3


comprehend = boto3.client("comprehend")
comprehend_result = comprehend.batch_detect_sentiment(
        TextList=text_list,  # ここで判定したいテキストを渡す。
        LanguageCode="ja"
    )

応急処置的な対応としては、エラーをキャッチして自分でリトライを実装するという手もあります。
参考: 失敗しやすい処理にリトライをスクラッチで実装する

ただ、boto3はどうやら標準機能でリトライ設定の回数を設定できる様なのです。batch_detect_sentiment メソッドのドキュメントをいくら読んでもそれらしい引数がないのでできないと思ってました。
この設定は、その一歩手前、クライアントインスタンスを生成する時点で設定しておく必要があります。

ドキュメントはこちら。
参考: Retries – Boto3 1.26.142 documentation

ドキュメントでは ec2 のAPIに適用していますが、comprehendでも同じ様に使えます。以下の様にするとリトライ回数の上限を10回に上げられる様です。

import boto3
from botocore.config import Config

config = Config(
   retries = {
      'max_attempts': 10,
      'mode': 'standard'
   }
)

comprehend = boto3.client('comprehend', config=config)

mode は、 legacy, standard, adaptive の3種類があります。とりあえず、standardを選んだら良いのではないでしょうか。対応しているエラーがlegacyより多く、以下のエラーに対してリトライしてくれます。

# Transient errors/exceptions
RequestTimeout
RequestTimeoutException
PriorRequestNotComplete
ConnectionError
HTTPClientError

# Service-side throttling/limit errors and exceptions
Throttling
ThrottlingException
ThrottledException
RequestThrottledException
TooManyRequestsException
ProvisionedThroughputExceededException
TransactionInProgressException
RequestLimitExceeded
BandwidthLimitExceeded
LimitExceededException
RequestThrottled
SlowDown
EC2ThrottledException

max_attempts の方が、リトライ回数の設定ですが、正直、何回に設定したら成功する様になるのかは状況による部分が多く、僕もまだ良い策定方法がわかっていません。(というより、これまでこんなに失敗することがなかった)。一応、responseのメタデータの中に、RetryAttemptsって項目があって、何回目の再実行で成功したかとか見れるのですが、ほとんど成功するので大体これ0なんですよね。ここを監視してたら参考指標になるのかもしれませんが、これを監視できる実装にするのは面倒です。基本的には成功するものですから。

もし、上に列挙したエラーのどれかが発生してboto3の実行が止まってしまうよ、という状況が発生したら、リトライ回数の設定変更を検討に入れてみてください。

ただ、リトライというのはあくまでも同じ処理を繰り返すだけなので、百発百中で失敗する様な処理をリトライしても無意味です。ほとんど確実に成功するのに超低確率で失敗する事象が起きてしまう場合に、その失敗確率をもっと下げるという用途でのみ有効です。

boto3で Amazon S3 のファイルを直接読み書きする

以前の記事で、S3にディスク上のファイルをアップロードしたり逆にS3からディスクにダウンロードしたりする方法を紹介しました。
参考: boto3でS3のファイル操作

ただ、実際に使っているとディスクを経由せずにS3のファイルを読み込んでそのままプログラムで処理したかったり、結果を直接S3に書き込みたい場面もあります。その方法をまだ書いてなかったのでまとめておきます。

正直に言うと、本当にやりたかったのはS3上のテキストファイルに直接どんどんテキストを追記していく操作だったのですが、どうやらS3はオブジェクトの出し入れにしか対応しておらず、追記などの編集はできないそうです。結局やりたかったことはできなかったのですが、この方法を探すためにドキュメントを読み込んだんのでその時間を無駄にしないようにしようと言う記事です。

読んだドキュメントはこれです。putが書き込み、getが読み込み
S3 — Boto3 Docs 1.24.42 documentation の S3.Object.put
S3 — Boto3 Docs 1.24.42 documentation の S3.Object.get

さて、見ていきましょう。boto3はS3に関してはresource APIも用意されているのでこちらを使います。

まず、S3への書き込みです。これは、S3上のオブジェクトを取得し、オブジェクトputメソッドを使います。オブジェクトが存在しない状態でもkeyを取得できる、ってことと、putするときにデータはバイナリにエンコードしておかないといけないと言う2点がつまずきポイントでしょうか。また、上で追記はできないって書いてますが、既存のオブジェクトのキーを指定しまうとファイルが丸ごと上書きされていまいます。その点も注意しましょう。

import boto3


text = "書き込みたいテキストの内容"
data = text.encode("utf-8")  # エンコードしてバイナリデータにする
# もちろん、 data = "書き込みたいテキスト".encode("utf-8")でも良い。

# 書き込み先のバケットと、オブジェクトキーを設定。
bucket_name = "{バケット名}"
key = "{書き込み先ファイル名}"  # hogehoge.text など。

s3 = boto3.resource("s3")
# オブジェクトを取得
s3_object = s3.Object(bucket_name, key)
result = s3_object.put(Body=data)  # resultに書き込み結果のステータスコードなどの辞書が戻る

これで、バケットにデータが作成されます。「s3.Object(bucket_name, key)」としてオブジェクトを一発で取ってますが、これはまずバケットを指定して、その後、そのバケット内のオブジェクトを指定しても良いです。一つのバケットに何ファイルも書き込む場合などに使えるかもしれません。以降に紹介するgetでも状況は同様です。

# 以下の二行で、 s3_object = s3.Object(bucket_name, key) と同じ
s3_bucket = s3.Bucket(bucket_name)
s3_object = s3_bucket.Object(key)

PandasのデータフレームをS3に保存したい場合などは、to_csvと組み合わせると良いでしょう。これファイル名のところにNoneを渡せばファイルに書き込まずにCSVのテキストを返してくれます。それをエンコードして書き込んだらOKです。

df = pd.DataFrame({
    "id": [0, 1, 2],
    "text": ["あいうえお", "かきくけこ", "さしすせそ"],
})
print(df)
"""
   id   text
0   0  あいうえお
1   1  かきくけこ
2   2  さしすせそ
"""

bucket_name = "{バケット名}"
key = "df.csv"  # 保存先ファイル名

s3_object = s3.Object(bucket_name, key)
result = s3_object.put(Body=df.to_csv(None, index=None).encode("utf-8"))

次は読み込みです。これはオブジェクトのメソッド、getを利用します。
さっき書き込んだCSVファイル読み込んでみましょうかね。結果は辞書で返ってきますが、key=”Body”に対応しているのが欲しいデータです。

bucket_name = "{バケット名}"
key = "df.csv"  # 保存先ファイル名

s3_object = s3.Object(bucket_name, key)
s3_object.get()["Body"]
# <botocore.response.StreamingBody at 0x1179bc910>

StreamingBody なる型で返ってきました。これ、with openして 通常のファイルを読み込むときと同じように read()のメソッドを持っているので、それを使えます。また、読み込んだデータはバイナリ型なので文字列に戻すならdecodeが必要です。

csv_text = s3_object.get()["Body"].read().decode("utf-8")
print(csv_text)
"""
id,text
0,あいうえお
1,かきくけこ
2,さしすせそ
"""

元のファイルがテキストファイルであればこれで読み込み完了ですね。

ちなみに、元々DataFrameを書き込んだものなので、DataFrameに読み込みたいと言うケースもあると思います。その場合、テキストに変換を終えたデータではなく、StreamingBodyを使います。つまりこうです。

df = pd.read_csv(s3_object.get()["Body"])
print(df)
"""
   id   text
0   0  あいうえお
1   1  かきくけこ
2   2  さしすせそ
"""

これで、ディスクを経由することなくメモリ上のテキストやデータフレームをS3に書き込んだり逆にS3からメモリに読み込んだりできるようになりました。

最初にやりたいと言ってた追記なんですが、これはもう一度読み込んでテキストなりデータフレームなりに新しいデータを追加して改めて書き込むしか無さそうです。

Lambda の関数URLで送信されたデータを扱う

前回に引き続き、Lambdaの関数URLの話です。URLでLambdaを起動できるのは便利ですが、せっかくならアクセスするときに何かしらの情報を渡して処理を変えたいということは多くあると思います。その場合に、あり得るパターンの数だけLambda関数と個別のURLを用意しておくというのは現実的ではありません。
普通は、世の中のたいていのAPIがやっているように、クエリパラメーターや、POSTされたデータに応じて挙動を変える作りにします。

受け取った値に応じて挙動を変えるっていうのは、ただのPythonのコーディングの話なので、今回の記事では、どうやってクエリパラメーターとかPOSTされたデータを受け取るかって部分を扱います。これまでAPI GatewayでAPI作ってたような人にとっては常識的なことしか書いてないと思いますがご了承ください。(僕はWebエンジニアではなく、これまでLambdaは手動かスケジュール実行で使って来たのでこの辺の挙動に詳しくないのです。)

さて、前置きが長くなって来ましたが、実はタネは非常に簡単で、Lamda関数の定義にデフォルトで入っている引数「event」、これが今回の主役です。

def lambda_handler(event, context):  # この第一引数eventに欲しいデータが渡される
    # 処理の中身

関数URLからLambdaが呼び出されたとき、lambda_handlerの第一引数には、以下のドキュメントのリクエストペイロードと呼ばれているデータが渡され、関数中ではeventという変数名で扱えます。
参考: Lambda 関数 URL の呼び出し – AWS Lambda の リクエストペイロードの形式 の部分

以下のような関数を作って実際に動かしてみるのが一番わかりやすいと思います。
テキストエリア2個と送信ボタンを持つフォームをGET/POSTでそれぞれ作成し、event変数の中身を表示します。

import pprint


def lambda_handler(event, context):
    html = """
<!DOCTYPE html>
<h2>GETのフォーム</h2>
<form action="/" method="get">
    <input type="text" name="text1"><br>
    <input type="text" name="text2"><br>
    <input type="submit">
</form>
<h2>POSTのフォーム</h2>
<form action="/" method="post">
    <input type="text" name="text3"><br>
    <input type="text" name="text4"><br>
    <input type="submit">
</form>
    """

    html +="<h2>eventの内容</h2>\n"
    html += "<pre>" + pprint.pformat(event, compact=True) + "</pre>"

    return {
        "headers": {
            "Content-Type": "text/html;charset=utf-8",
        },
        'statusCode': 200,
        "body": html,
    }

上記のコードで関数を作成し、関数URLからアクセスすると、フォーム二つとpprintで成形されたeventの辞書データが表示されます。また、それぞのフォームに値を入れて送信すると、eventのどこに結果が入るかが確認できます。
(キャプチャとか貼るとわかりやすいと思うのですが、idっぽいものが大量にあるので省略させていただきます。上のコードを試してただくのが一番良いです。)

まず、メソッド(GET/POSTなど)の区別ですが、次の部分にあります。
Pythonで言えば、 event[“requestContext”][“http”][“method”] で取得できますね。

{
    # 略
    'requestContext': {
        # 略
        'http': {
            # 略
            'method': 'GET',
        }
    }
}

それでは、 GET/ POST 順番にフォームを実行して、どのように値が取れるか見て行きましょう。
フォームには以下の値を入れて送信します。
1つ目のテキスト: あ&い=う%え,お
2つ目のテキスト: abc?123

フォームからGETで送信する場合、URLの末尾に入力内容が添付され、次のURLにアクセスされます。特殊文字はパーセントエンコーディングされていますね。

https://{url-id}.lambda-url.{region}.on.aws/?text1=あ%26い%3Dう%25え%2Cお&text2=abc%3F123

eventの中ではevent[“rawQueryString”] と event[“queryStringParameters”]の部分に現れます。
クエリ文字列がないときは、rawQueryStringは空文字””ですが、queryStringParametersの方はキー自体が存在しないので、コードで使うときは注意してください。

{
    # 略
    'queryStringParameters': {'text1': 'あ&い=う%え,お', 'text2': 'abc?123'},
    # 略
    'rawQueryString': 'text1=%E3%81%82%26%E3%81%84%3D%E3%81%86%25%E3%81%88%2C%E3%81%8A&text2=abc%3F123',
    # 略
}

上記の内容で分かる通り、rawQueryStringの方はURIエンコーディングされていますが、queryStringParametersの方は使いやすいように辞書型にパースしてURIエンコードも元に戻してくれています。こちらを使って行きましょう。
フォームに入れた値がURLに載ってしまうというのは大きなデメリットですが、ぶっちゃけると単純にテキスト等を送るフォームならPOSTよりGETの方が渡した値使いやすいな、と感じています。(Web系開発素人の発想ですが。)

続いて、POSTの場合です。個人的にはこの種のフォームは普通はPOSTで使うものだと思っています。
POSTの場合は、GETと違って少しわかりにくく、eventを表示しても送ったデータがそのままでは見つかりません。ではどこにあるのかというと、実は event[“body”]に、base64エンコードされて入ってます。これはフォームからPOSTした場合の挙動です。curl等でテキストのままポストしてあげればそのまま表示されます。
base64の判定は、event[“isBase64Encoded”] で行います。

{
    # 略
    'body': 'dGV4dDM9JUUzJTgxJTgyJTI2JUUzJTgxJTg0JTNEJUUzJTgxJTg2JTI1JUUzJTgxJTg4JTJDJUUzJTgxJThBJnRleHQ0PWFiYyUzRjEyMw==',
    # 略
    'isBase64Encoded': True,
    # 略
}

base64なので自分でこれをデコードする必要があります。
参考: PythonでBase64エンコードとデコード

該当部分のコードだけ書くとこんな感じです。

import base64

base64.b64decode(event["body"]).decode()
# text3=%E3%81%82%26%E3%81%84%3D%E3%81%86%25%E3%81%88%2C%E3%81%8A&text4=abc%3F123

パーセントエンコードされていますね。これを以下の手順で処理する必要があります。
&で区切って、フォームの各要素ごとの値に分割する。
=で区切ってキーと値のペアに変える。
パーセントエンコードをデコードする。

自分でやるのは面倒なので、ライブラリ使いましょう。
参考: PythonでURL文字列を要素に分解する

次のようになります。

import base64
from urllib.parse import parse_qs


parse_qs(base64.b64decode(event["body"]).decode())
# {'text3': ['あ&い=う%え,お'], 'text4': ['abc?123']}

キー対値 の辞書ではなく、 キー対値の配列 の辞書が結果として得られるので注意が必要です。特殊文字たちも元の形に戻っていますね。

さて、以上で関数URLにクエリストリングに付加されたデータや、POSTされて来たデータをLambdaの関数で取り出せるようになりました。

あとはこれを受け取ってそれに応じた処理をする関数を作るだけで、柔軟な処理を行えるようになると思います。

AWS Lambda Function URLs を試してみた

発表されてから少し時間が経ってしまったので、今更感が出ていますが AWS Lambdaの新機能である Function URLs (関数URL) を試してみました。
参考: AWS Lambda Function URLs の提供開始: 単一機能のマイクロサービス向けの組み込み HTTPS エンドポイント

これ何かと言うと、AWSのLambda に API Gateway を使わずに、Lambda内部の機能だけでURLを発行して、そのURLにアクセスするだけで関数を実行できると言うものです。

ドキュメントはこちらかな。
参考: Lambda 関数 URL

ちょっとお試しで動かしてみましょう。関数URLが有効な新しい関数を作る手順は以下の通りです。

1. Lambdaのコンソールにアクセス。
2. 関数の作成をクリック。
3. 関数名/ ランタイム(NodeやPythonのバージョン)/ アーキテクチャ などを入力。
4. アクセス権限でロールの設定。
5. ▼詳細設定のメニューを開く。ここに「関数URLを有効化」が新登場しています。
6. 「関数URLを有効化」にチェックを入れる。
7. 認証タイプ を選択。お試しなので、一旦NONEにしてパブリックにしました。
8. 関数の作成をクリック。

これで関数が出来上がります。デフォルトでは以下のコードが用意されています。

import json

def lambda_handler(event, context):
    # TODO implement
    return {
        'statusCode': 200,
        'body': json.dumps('Hello from Lambda!')
    }

ここで、コードソースのウィンドウの上の、関数の概要ウィンドウの右側に、関数URLというのが作られ、以下の形式のURLが用意されているのがわかります。
この時点でURL用意されているんですね。

https://{url-id}.lambda-url.{region}.on.aws/

ドキュメントによると、url-id 部分からわかる人はその人のアカウントIDが取得できるそうです。そのため、僕のサンプルで作った関数URLがお見せできませんが、たったこれだけの手順で試せるので是非やってみてください。

このURLを開くと、”Hello from Lambda” の文字が表示されます。curlでも良いです。

$ curl https://{url-id}.lambda-url.ap-northeast-1.on.aws/
"Hello from Lambda!"

折角ブラウザで開けるので、HTMLを表示してみましょう。
ここで注意しないといけないのは、単純に”body”にHTMLの文字列を書くだけだと、JSONとして扱われて、ブラウザがHTMLソースをレンダリングせずにそのまま表示してしまいます。

それを防ぐためには、Content-Type を “text/html” に指定してあげる必要があります。
指定は、ドキュメントのこちらのページの、「レスポンスペイロードの形式」というセクションに沿って行います。
参考: Lambda 関数 URL の呼び出し – AWS Lambda

{
   "statusCode": 201,
    "headers": {
        "Content-Type": "application/json",
        "My-Custom-Header": "Custom Value"
    },
    "body": "{ \"message\": \"Hello, world!\" }",
    "cookies": [
        "Cookie_1=Value1; Expires=21 Oct 2021 07:48 GMT",
        "Cookie_2=Value2; Max-Age=78000"
    ],
    "isBase64Encoded": false
}

Content-Type の デフォルトは “application/json” ですね。

このブログへのリンクでも表示してみましょうか。
コードを以下の内容に書き換えて、 Deploy をクリックします。

def lambda_handler(event, context):
    html = """<!DOCTYPE html>
<a href="https://analytics-note.xyz/" target="brank_">分析ノート</a>"""

    return {
        "headers": {
            "Content-Type": "text/html;charset=utf-8",
        },
        "statusCode": 200,
        "body": html
    }

これで、関数URLにアクセスすると、このブログへのリンクだけの簡素なページが表示されます。

Content-Type を “text/html” ではなく、 “text/html;charset=utf-8” にしているのは、こうしないと日本語が文字化けするからです。

ここまで、新規の関数について関数URLを設定する方法を書いて来ましたが、既存の関数についても「設定」のところに関数URLの項目が追加されており、非常に簡単に浸かるようになっています。

こうしてURLを簡単に使えると、たとえばEC2の起動/停止などの処理も今までboto3のバッチでやっていましたが、ブラウザでアクセスするだけで起動できるようになります。
jupyter notebook のサーバーを操作するも一層手軽になりそうです。
(端末のコンソール起動する一手間がなくなるだけですが、地味に面倒だったので)

ただ、気をつけないといけないのはグローバルにアクセスできるので、誰でも実行できるという点ですね。もしクリティカルな処理で使う場合は、何かしらの制限をかけた方が良いでしょう。

Amazon Aurora Serverless v2が出たので使ってみた

以前の記事で、 Aurora Serverless を紹介しましたが、その v2 が登場していたので使用感を確かめてみました。
参考: Amazon Aurora Serverlessを使ってみる
Amazonからの発表: Amazon Aurora Serverless v2 の一般提供を開始
Amazon Aurora Serverless

かつてのAurora Serverless (要するにv1)には、ServerlessではないAuroraに比べてさまざまな制約がありましたが、v2ではそれらの制約がだいぶ改善されています。
個人的に期待しているというより嬉しいのは、グローバルデータベースが作れる点ですね。
これまでのAurora ServerlessはAWS内部からしかアクセスできなかったのでローカルからアクセスする場合はEC2などで踏み台作ることが必須でした。

注意点として、ACUあたりの料金が2倍になっています。ただし、スケーリング等がよりきめ細やかに行われるようになっているらしいので、同じ使い方した場合の請求金額がそのまま2倍になるようなことはないとも聞いています。これは実際に使ってみないとわからないですね。

クラスター接続編

さて、前置きが長くなって来たので使ってみましょう。手順は以下の通りです。

1. AWSのコンソールにログインし、RDSの管理画面に移動する。
2. [データベースの作成]をクリック
3. 標準作成を選択 (簡単作成でも良い)
4. エンジンのタイプ は [Amazon Aurora]を選択
5. エディション は [MySQLとの互換性を持つ Amazon Aurora]
6. MySQLのバージョンを選ぶ。
以前は、大量の選択肢の中からドキュメントで指定されているバージョンを自分で選んでおかないと以降の選択肢からサーバレスが消えていたのですが、今は「フィルターの表示」で、「Serverless v2 をサポートするバージョンを表示」として絞り込むことができます。(v1も同じフィルターが欲しいが無いようです。)
現時点では、「Aurora MySQL 3.02.0 (compatible with MySQL 8.0.23)」だけに絞られるのでこれを選びます。

7. テンプレートは個人利用の検証なので「開発/テスト」にしました。
8. DBクラスター識別子 は何か名前をつける。 (yutaro-aurora-serverless-v2-test)
9. マスターユーザー名 を指定 (デフォルトは admin)
10. パスワードの設定。 (自分はパスワードの自動作成にしました)
11. インスタンスの設定で、DBインスタンスクラスでサーバレスを選択。
12. キャパシティーの設定を変更。(0.5〜128で設定可能ですが、予算を抑えるため低めに。)
13. 接続のパブリックアクセスで「あり」を選択。
14. [データベースの作成]をクリック。

これで、DBの作成が始まります。
[認証情報の詳細を表示] から、 adminのパスワードを入手しておきましょう。

アクセスにはエンドポイントの名前も必要です。DBクラスター識別子とは違うので別途確認します。(DBの一覧から先ほど作ったクラスターを選択すると表示されます。)

あとは、セキュリティグループを設定して自分のPCからアクセスできるようにしたら準備完了です。

DBアクセス編

ローカル端末からPythonでアクセスしてみます。まだ空っぽのDBなので、とりあえずSHOW DATABASES文でも打ってみましょう。

import pymysql.cursors

con_args = {
    "host": "{エンドポイント名}",
    "port": 3306,
    "user": "{DBユーザー名}",
    "password": "{DBパスワード}",
    "charset": "utf8mb4",
    "cursorclass": pymysql.cursors.DictCursor,
}
# 通常は "database": "{データベース名}" も指定するが、まだ何も作ってないのでなし。

connection = pymysql.connect(**con_args)

with connection.cursor() as cursor:
    sql = "SHOW DATABASES"
    cursor.execute(sql)
    result = cursor.fetchall()
print(result)

# [{'Database': 'information_schema'}, {'Database': 'mysql'}, {'Database': 'performance_schema'}, {'Database': 'sys'}]

無事接続できたようです。

日本語対応編

ちなみに、 v1の頃は、日本語を使うために文字コードなどの設定が必要でした。
参考: Amazon RDS(Aurora)で日本語文字列や絵文字を使えるようにする

それがどうやら、v2では(というより、選択した互換MySQLのバージョンがそうだった可能性もありますが) 最初からutf8対応の設定のようです。

with connection.cursor() as cursor:
    sql = "SHOW VARIABLES LIKE 'char%'"
    cursor.execute(sql)
    result = cursor.fetchall()


for r in result:
    print(r)
"""
{'Variable_name': 'character_set_client', 'Value': 'utf8mb4'}
{'Variable_name': 'character_set_connection', 'Value': 'utf8mb4'}
{'Variable_name': 'character_set_database', 'Value': 'utf8mb4'}
{'Variable_name': 'character_set_filesystem', 'Value': 'binary'}
{'Variable_name': 'character_set_results', 'Value': 'utf8mb4'}
{'Variable_name': 'character_set_server', 'Value': 'utf8mb4'}
{'Variable_name': 'character_set_system', 'Value': 'utf8'}
{'Variable_name': 'character_sets_dir', 'Value': '/rdsdbbin/oscar-8.0.mysql_aurora.3.02.0.0.12759.0/share/charsets/'}
"""

未使用時の停止について

最後に、一番気になる未使用時の自動停止について。個人利用においては正直 Aurora Serverless を使う1番の理由は使わない時は止まって料金がかからない点です。

v1 の時は、 「数分間アイドル状態のままの場合コンピューティング性能を一時停止する」という設定があり、これを設定しておくと止まってくれていたのですが、 v2 ではこれに該当する設定を見つけられていません。(キャパシティーの設定にあるはずなのですが。)

ドキュメントにある、「Aurora Serverless v2 を使用すると、SaaS ベンダーは、プロビジョニングされた容量のコストを気にすることなく、個々の顧客ごとに Aurora データベースクラスターをプロビジョニングできます。データベースが使用されていないときは自動的にシャットダウンしてコストを節約し、変化するアプリケーション要件に合わせてデータベースの容量を即座に調整します。」の部分はどこで使うのでしょう。
v1 の時、デフォルトだと思って設定し忘れていたらDBが停止しておらず、普通に料金がかかったのでここが心配です。

もし、 v2 で自動的なシャットダウンが実装されていなかったら個人開発では引き続きv1を使わないといけないですね。一旦、放置して様子を見ようと思います。

(追記)
こちらに情報がありました。
Comparison of Aurora Serverless v2 and Aurora Serverless v1

Stopping a cluster のところを見ると、v2は手動で停止しないといけなくて(You can manually stop and start the cluster by using the same cluster stop and start feature as provisioned clusters.)、v1は自動的に停止できる(The cluster pauses automatically after a timeout.)とありますね。これだとちょっと使えないので残念です。

タイムゾーンについて

もう一点1気になる問題があって、タイムゾーンの設定変更が反映されないんですよね。反映されるまでに時間がかかるのかな。DB クラスターのパラメータグループの time_zone を Asia/Tokyo に変えたのですが、 SELECT NOW() すると UTCの時刻が返って来ます。これも要調査です。

(追記)こちらはクラスターに設定した後、インスタンスを再起動したら反映されました。

Amazon Rekognitionで物体の検出

前回に引き続いて、Amazon Rekognitionの話です。
Rekognitionでは顔だけでなく、画像に写っている物体やシーンについてのラベル情報を得ることができます。
参考: オブジェクトおよびシーンの検出 – Amazon Rekognition

boto3で使う方法は、顔検出の時とよく似ていて、
detect_faces() の代わりに、 detect_labels() を呼び出すだけです。
参考: Rekognition — Boto3 Docs 1.17.95 documentation

サンプルとして、ドキュメントに掲載されている車の並んだ道でスケーボーやっている人の画像(ファイル名: skateboard.jpg)でやってみます。
顔検出の場合と同様にローカルの画像ファイルを読み込む方法と、S3にアップロードされた画像を使う方法があります。

ローカルのファイルを使う場合は次のようにします。


import boto3

with open("./skateboard.jpg", "rb") as f:
    img = f.read()

client = boto3.client('rekognition')
response = client.detect_labels(Image={'Bytes': img})

S3にアップロードしたデータを使う場合は次のようにします。


client = boto3.client('rekognition')
response = client.detect_labels(
    Image={
        'S3Object': {
            'Bucket': '{バケット名}',
            'Name': 'skateboard.jpg',
        }
    },
)

結果は辞書型で戻ってきます。
キー: Labels の値がメインの結果です。


response.keys()
# dict_keys(['Labels', 'LabelModelVersion', 'ResponseMetadata'])

Labels の値は、検出できたものの配列になっています。
試しに二つほど表示すると次のようになります。


import json

print(json.dumps(response["Labels"][4], indent=4))
"""
{
    "Name": "Person",
    "Confidence": 98.37577819824219,
    "Instances": [
        {
            "BoundingBox": {
                "Width": 0.1903613954782486,
                "Height": 0.27238351106643677,
                "Left": 0.43754446506500244,
                "Top": 0.3520295023918152
            },
            "Confidence": 98.37577819824219
        },
        {
            "BoundingBox": {
                "Width": 0.037608712911605835,
                "Height": 0.06765095144510269,
                "Left": 0.9162867665290833,
                "Top": 0.50001460313797
            },
            "Confidence": 86.00637817382812
        }
    ],
    "Parents": []
}
"""
print(json.dumps(response["Labels"][6], indent=4))
"""
{
    "Name": "Pedestrian",
    "Confidence": 97.18687438964844,
    "Instances": [],
    "Parents": [
        {
            "Name": "Person"
        }
    ]
}
"""

Name にラベル名が格納され、 Instances にそれが画像のどこに含まれていたが示されています。
Instances は空の配列のこともあります。要するに画像のどこかに写っているけど、場所は不明ということです。
位置が出力されるものとそうでないものにどんな規則性があるのかはいまいちわかりませんでした。
このほか、Parentsという属性があり、親概念になるラベル名が取得されます。

さて、検出されたラベル名と、 Instances の数、 Parentsの一覧を出力してみましょう。
結構色々検出されていますね。


for label in response["Labels"]:
    print(f'ラベル名: {label["Name"]}', f'インスタンス数: {len(label["Instances"])}')
    if len(label["Parents"]) > 0:
        print(f'親ラベル: {label["Parents"]}')


"""
ラベル名: Car インスタンス数: 14
親ラベル: [{'Name': 'Vehicle'}, {'Name': 'Transportation'}]
ラベル名: Automobile インスタンス数: 0
親ラベル: [{'Name': 'Vehicle'}, {'Name': 'Transportation'}]
ラベル名: Vehicle インスタンス数: 0
親ラベル: [{'Name': 'Transportation'}]
ラベル名: Transportation インスタンス数: 0
ラベル名: Person インスタンス数: 2
ラベル名: Human インスタンス数: 0
ラベル名: Pedestrian インスタンス数: 0
親ラベル: [{'Name': 'Person'}]
ラベル名: Skateboard インスタンス数: 1
親ラベル: [{'Name': 'Sport'}, {'Name': 'Person'}]
ラベル名: Sport インスタンス数: 0
親ラベル: [{'Name': 'Person'}]
ラベル名: Sports インスタンス数: 0
親ラベル: [{'Name': 'Person'}]
ラベル名: Road インスタンス数: 0
ラベル名: Wheel インスタンス数: 10
親ラベル: [{'Name': 'Machine'}]
ラベル名: Machine インスタンス数: 0
ラベル名: Path インスタンス数: 0
ラベル名: Downtown インスタンス数: 0
親ラベル: [{'Name': 'City'}, {'Name': 'Urban'}, {'Name': 'Building'}]
ラベル名: City インスタンス数: 0
親ラベル: [{'Name': 'Urban'}, {'Name': 'Building'}]
ラベル名: Urban インスタンス数: 0
ラベル名: Building インスタンス数: 0
ラベル名: Town インスタンス数: 0
親ラベル: [{'Name': 'Urban'}, {'Name': 'Building'}]
ラベル名: Tarmac インスタンス数: 0
ラベル名: Asphalt インスタンス数: 0
ラベル名: Parking Lot インスタンス数: 0
親ラベル: [{'Name': 'Car'}, {'Name': 'Vehicle'}, {'Name': 'Transportation'}]
ラベル名: Parking インスタンス数: 0
親ラベル: [{'Name': 'Car'}, {'Name': 'Vehicle'}, {'Name': 'Transportation'}]
ラベル名: Intersection インスタンス数: 0
親ラベル: [{'Name': 'Road'}]
ラベル名: Architecture インスタンス数: 0
親ラベル: [{'Name': 'Building'}]
ラベル名: Office Building インスタンス数: 0
親ラベル: [{'Name': 'Building'}]
ラベル名: Sidewalk インスタンス数: 0
親ラベル: [{'Name': 'Path'}]
ラベル名: Pavement インスタンス数: 0
親ラベル: [{'Name': 'Path'}]
ラベル名: Neighborhood インスタンス数: 0
親ラベル: [{'Name': 'Urban'}, {'Name': 'Building'}]
ラベル名: Street インスタンス数: 0
親ラベル: [{'Name': 'City'}, {'Name': 'Road'}, {'Name': 'Urban'}, {'Name': 'Building'}]
ラベル名: Coupe インスタンス数: 0
親ラベル: [{'Name': 'Sports Car'}, {'Name': 'Car'}, {'Name': 'Vehicle'}, {'Name': 'Transportation'}]
ラベル名: Sports Car インスタンス数: 0
親ラベル: [{'Name': 'Car'}, {'Name': 'Vehicle'}, {'Name': 'Transportation'}]
ラベル名: Sedan インスタンス数: 0
親ラベル: [{'Name': 'Car'}, {'Name': 'Vehicle'}, {'Name': 'Transportation'}]
"""

結果が多すぎる場合は、 MaxLabels 引数で出力される結果の数を絞り込むこともできます。

さて、ラベル名の一覧だけみてもどのくらい正確なのかわかりにくいので、画像中に図示してみましょう。
インスタンス数が0のものは、図示できないので、インスタンスが返されたものだけ、ボックスを描いていきます。


# %%pycodestyle
import matplotlib.pyplot as plt
from matplotlib import patches
from skimage import io

# matplotlibの可視化用に画像の読み込み
img_array = io.imread("./skateboard.jpg")

# 画像の高さと幅の取得
image_h, image_w, _ = img_array.shape

fig = plt.figure(facecolor="w", figsize=(12, 12))
ax = fig.add_subplot(111)
# 元の画像を表示する
ax.imshow(img_array)

for label in response["Labels"]:
    for instance in label["Instances"]:
        left = instance["BoundingBox"]["Left"] * image_w
        top = instance["BoundingBox"]["Top"] * image_h
        width = instance["BoundingBox"]["Width"] * image_w
        height = instance["BoundingBox"]["Height"] * image_h
        patch = patches.Rectangle(
            xy=(left, top),
            width=width,
            height=height,
            fill=False,
            ec="c",
            linewidth=2
        )
        ax.add_patch(patch)
        ax.text(x=left, y=top, s=label["Name"], fontsize=15, c="b")

出力された結果がこちらです。

人や車などの位置がしっかり検出できていますね。

Amazon Rekognitionで顔検出

AWSの機械学習・画像認識サービスであるAmazon Rekognition を試してみたので記録を残しておきます。
今回はまず、画像中の人の顔を検出するタスクをやってみました。
また、例によってPython(boto3)を使っています。

使い方はめちゃくちゃ簡単で、boto3のクライアントAPIから、detect_faces()というメソッドを呼び出すだけでした。
ドキュメントはこちらです。
参照: Rekognition — Boto3 Docs 1.17.93 documentation

対象のデータはローカルのファイルをバイト列のデータとして渡す方法と、S3にアップロードしてそのバケット名とファイル名を渡す方法の2種類があります。
ドキュメントには、
The input image as base64-encoded bytes or an S3 object.
と書いてあるので、Base64エンコードしないといけないのかと思ったのですが、これはどうやらドキュメントの誤りです。
Base64エンコードして渡すと逆にエラーになりますので、バイト列で読み込む時はファイルを読み込んだバイトデータをそのまま渡してください。
(予想ですが、boto3のライブラリが内部処理で Base64エンコードしてくれてると思います。)
せっかくこのブログでもBase64エンコーディングの方法を紹介する記事を書いて準備していたのにいらなかったですね。

引数の渡し方は少し特殊で、名前付き引数に辞書型で渡す必要があります。
ローカルのファイルを使う場合は次のようにします。

ちなみに画像は、AWSのコンソールでサンプルとして表示される家族写真を使います。(ファイル名:family.jpg)


import boto3


# 画像データを読み込む。
with open("./family.jpg", "rb") as f:
    img = f.read()

client = boto3.client('rekognition')
response = client.detect_faces(Image={'Bytes': img})

S3にアップロードしたデータを使う場合は次のようにします。


client = boto3.client('rekognition')
response = client.detect_faces(
    Image={
        'S3Object': {
            'Bucket': '{バケット名}',
            'Name': 'family.jpg',
        }
    },
)

結果は、辞書型で帰ってきます。FaceDetailsというキーの中身が、メインの検出結果で、
ResponseMetadataのほうはRequestのIdや、HTTPレスポンスのステータスコード、処理を実行した時刻などのメタデータが入ってます。


print(response.keys())
# dict_keys(['FaceDetails', 'ResponseMetadata'])

response[“FaceDetails”] の中身は配列で、検出された顔一人分ごとに辞書型で検出された情報が入っています。
今回のサンプルでは3人検出されているのですが、全部表示すると長いので一人分お見せすると次のようになります。
(整形のためにjsonライブラリ使います)


import json
print(json.dumps(response["FaceDetails"][0], indent=4))
"""
{
    "BoundingBox": {
        "Width": 0.1937681883573532,
        "Height": 0.3873019516468048,
        "Left": 0.2916979193687439,
        "Top": 0.13570082187652588
    },
    "Landmarks": [
        {
            "Type": "eyeLeft",
            "X": 0.34084638953208923,
            "Y": 0.2765427529811859
        },
        {
            "Type": "eyeRight",
            "X": 0.4209189713001251,
            "Y": 0.31195494532585144
        },
        {
            "Type": "mouthLeft",
            "X": 0.32429391145706177,
            "Y": 0.40175312757492065
        },
        {
            "Type": "mouthRight",
            "X": 0.39117804169654846,
            "Y": 0.43135175108909607
        },
        {
            "Type": "nose",
            "X": 0.3650367856025696,
            "Y": 0.3684481084346771
        }
    ],
    "Pose": {
        "Roll": 17.15113067626953,
        "Yaw": -3.947751760482788,
        "Pitch": -1.8470479249954224
    },
    "Quality": {
        "Brightness": 62.19182586669922,
        "Sharpness": 78.64350128173828
    },
    "Confidence": 99.99921417236328
}
"""

BoundingBox の中にあるのが、顔が検出された位置です。顔を囲む長方形の情報が含まれています。
Landmarksの下に、目、鼻、口の両端の位置が含まれます。
Poseは顔の向きです。
Qualityは画像の明るさなどの情報で、Confidenceは境界ボックス内に顔が含まれている信頼度になります。
詳しくはこちら
参考: イメージ内の顔を検出する – Amazon Rekognition

顔やそのパーツの位置の座標が0〜1の範囲に収まっていることから分かる通り、これらは
画像の左上を(0%,0%)、右下を(100%, 100%)とした時の相対的な位置を示しています。

上のJSON型データを見てもどのくらい正確に検出できているかわからないと思うので可視化してみましょう。
使い慣れているので、Matplotlibでやってみました。
別途、skimageで画像をNumpy配列として読み取り、画像の幅と高さを取得しています。
そして、それをRekognitionで取得した相対的な位置と掛け合わせることで、絶対値での座標に変換しています。


import matplotlib.pyplot as plt
from matplotlib import patches
from skimage import io


# matplotlib表示用に画像を配列で読み込み
img_array = io.imread("./family.jpg")
# 画像の高さと横幅を取得
image_h, image_w, _ = img_array.shape

fig = plt.figure(facecolor="w", figsize=(12, 12))
ax = fig.add_subplot(111)
# 元の画像を表示する
ax.imshow(img_array)

for face_detail in response["FaceDetails"]:
    # 検出された顔の位置を取得し、座標に変換する
    left = face_detail["BoundingBox"]["Left"] * image_w
    top = face_detail["BoundingBox"]["Top"] * image_h
    width = face_detail["BoundingBox"]["Width"] * image_w
    height = face_detail["BoundingBox"]["Height"] * image_h
    # 取得した座標の位置に長方形を描写する
    patch = patches.Rectangle(
        xy=(left, top),
        width=width,
        height=height,
        fill=False,
        ec="w",
        linewidth=2,
    )
    ax.add_patch(patch)

    # 目、鼻、口の両端に点をプロット
    ax.scatter(
        [landmark["X"] * image_w for landmark in face_detail["Landmarks"]],
        [landmark["Y"] * image_h for landmark in face_detail["Landmarks"]],
        c="w",
        s=3
    )

このコードで出力されるのが次の画像です。

3人分の顔が精度良く検出できていることがわかりますね。

さて、detect_faces()ですが、実はもう一つ引数を持っています。
それが、Attributes です。
Attributes=[“DEFAULT”] (こちらがデフォルト)
または、
Attributes=[“ALL”]
と指定します。 []も必須です。


response = client.detect_faces(
    Image={'Bytes': img},
    Attributes=["ALL"]
)

のように、 [“ALL”]を指定すると、取得できる情報が一気に増えます。
表情(笑顔かどうか)や、メガネやサングラスの有無、髭の有無や口が開いているかどうか、
大まかな年齢の推定なども行ってくれます。
また、目や口の位置情報はより詳細になり、輪郭や眉毛などに関する位置も取得されます。

結果がものすごく大きくなるのでこの記事には載せませんが、ぜひ一度試してみてください。

EC2(Amazon Linux 2)に日本語フォント(IPAフォント)をインストールする

以前、MacにインストールしたやつのEC2版です。
参考: MacにIPAフォントをインストールする

Macではフォントファイルをダウンロードしてきて自分で配置する必要がありましたが、
EC2では、yumでインストールできます。

yumでインストール可能なものの一覧の中に、IPAフォントが含まれていることを見ておきましょう。


$ yum list | grep ipa- | grep fonts
ipa-gothic-fonts.noarch                003.03-5.amzn2                amzn2-core
ipa-mincho-fonts.noarch                003.03-5.amzn2                amzn2-core
ipa-pgothic-fonts.noarch               003.03-5.amzn2                amzn2-core
ipa-pmincho-fonts.noarch               003.03-5.amzn2                amzn2-core

一つ入れれば十分なのですが、せっかくなので4つともインストールしておきましょう。


sudo yum install ipa-gothic-fonts ipa-mincho-fonts ipa-pgothic-fonts ipa-pmincho-fonts

インストールしたフォントは、 /usr/share/fonts という場所に置かれるようです。


$ cd /usr/share/fonts
$ ls
dejavu  ipa-gothic  ipa-mincho  ipa-pgothic  ipa-pmincho

Linux では fc-list というコマンドで利用可能なフォントが一覧取得できるのですが、その中にも登場します。


$ fc-list | grep ipa
/usr/share/fonts/ipa-gothic/ipag.ttf: IPAGothic:style=Regular
/usr/share/fonts/ipa-mincho/ipam.ttf: IPAMincho:style=Regular
/usr/share/fonts/ipa-pmincho/ipamp.ttf: IPAPMincho:style=Regular
/usr/share/fonts/ipa-pgothic/ipagp.ttf: IPAPGothic:style=Regular

あとはこれを jupyter & matplotlib で使うにはMacの場合と同じように設定すればOKです。

参考: matplotlibのデフォルトのフォントを変更する

font.family :
に指定する文字列は、IPAGothic/IPAPGothic/IPAMincho/IPAPMincho
のいずれかです。