multipart/form-dataでアップロードされた画像を、拡張子やMIMEタイプの申告だけで信用せずに実形式まで確かめたい——そんな検査APIをPython FastAPIで作ってみました。
PillowのverifyとPydanticのレスポンスモデルを組み合わせ、破損画像や過大ファイルを安全に拒否して判定理由まで返す自己完結APIを、1ファイルで実装します。
FastAPIの基本概念、要件定義、実装、動作確認までを順番に学べる構成です。動画は目次から確認したい場面へ移動でき、本文だけでも手順と考え方が完結します。
動画の内容をテキストで確認する
オープニング。FastAPIとPydanticとPillowを使って画像ファイル検査APIを作るカリキュラムを始めます。概要紹介。
FastAPIとPydanticとPillowの役割と使い方を学ぶ 画像ファイル検査APIの要件を整理する 完成コードと実行結果を確認する 最後に完成した画面を実際に操作して確かめる 具体的にやること。
multipart/form-dataで画像ファイルを受け取る 空のファイルを拒否する5MB超のファイルを拒否するPillowのverifyで画像構造を検証する 破損した画像を拒否する 実装環境・必須アプリ。
OS:Windows 11 Pro Python:3.13.3シェル:PowerShell 5.1必須アプリ:コードエディター、Webブラウザ、ターミナル パッケージ:pip、fastapi、uvicorn、pydantic、pillow、python-multipart FastAPIとPydanticとPillowとは。
FastAPI:FastAPIはPythonで高速なWeb APIを構築するためのWebフレームワークPydantic:Pydanticは型注釈をもとにデータ検証とJSON変換を行うライブラリで、Pillowは画像を開いて形式や寸法を調べる画像処理ライブラリ FastAPIで作る画像検査APIの要点。
空ファイルを最初に弾く5MB超のサイズを次に弾くverifyで破損画像を弾く 画像ファイル検査APIの要件定義。
正しい画像で合格と判定理由が返る 空のファイルで拒否理由が返る5MB超のファイルが拒否される 画像でないデータが拒否される 拡張子を偽装したファイルが拒否される 許可外の形式が拒否される INTRO: Monaco Editorで画像ファイル検査APIを実装。コードを1行ずつ入力し、補完と自動インデントを使いながら実行結果を確認します。
LINE 001: ioモジュールのインポート。バイト列をファイルのように扱うためのioモジュールを読み込んでいます。後でBytesIOを使い、受け取った画像データをPillowに渡す際に利用します。
LINE 002: osモジュールのインポート。ファイルパスを操作するためのosモジュールを読み込んでいます。拡張子を取り出す処理で使用します。
LINE 003: warningsモジュールのインポート。警告メッセージの扱いを制御するためのwarningsモジュールを読み込んでいます。デコード時の警告を例外として扱うために使います。
LINE 005: FastAPI関連クラスのインポート。FastAPI本体とファイルアップロードに必要なFile、UploadFileを読み込んでいます。これによりアップロードAPIの構築が可能になります。
LINE 006: HTMLResponseのインポート。HTMLをそのままレスポンスとして返すためのHTMLResponseクラスを読み込んでいます。確認画面の表示に利用します。
LINE 007: pydanticの基本クラスのインポート。データモデルを定義するためのBaseModelと項目定義用のFieldを読み込んでいます。検査結果のレスポンスモデル作成に使います。
LINE 008: Pillow関連クラスのインポート。画像処理ライブラリPillowのImageクラスと、画像として認識できない場合の例外UnidentifiedImageErrorを読み込んでいます。LINE 012: ファイルサイズ上限の定義。
アップロードを許可する最大ファイルサイズを5MBとして定数MAX_FILE_SIZEに設定しています。この値は後段のサイズ検査で使われます。LINE 013: 総画素数上限の定義。
画像の幅と高さを掛けた総画素数の上限を2400万画素として定数MAX_PIXELSに設定しています。展開爆弾対策の判定に利用します。LINE 014: 許可MIME辞書の開始。
画像の実形式ごとに許可するMIMEタイプの集合を定義する辞書ALLOWED_MIMEの宣言を開始しています。LINE 015: PNG形式の許可MIME。実形式がPNGの場合に許可するMIMEタイプとしてimage/pngを登録しています。
LINE 016: JPEG形式の許可MIME。実形式がJPEGの場合に許可するMIMEタイプとしてimage/jpegとimage/jpgの両方を登録しています。LINE 017: GIF形式の許可MIME。
実形式がGIFの場合に許可するMIMEタイプとしてimage/gifを登録しています。LINE 018: WEBP形式の許可MIME。実形式がWEBPの場合に許可するMIMEタイプとしてimage/webpを登録しています。
LINE 019: 許可MIME辞書の終端。ALLOWED_MIME辞書の定義を閉じています。これで実形式とMIMEタイプの対応表が完成します。
LINE 020: 拡張子対応辞書の開始。ファイルの拡張子と実際の画像形式を対応付ける辞書EXT_TO_FORMATの宣言を開始しています。LINE 021: png・jpg・jpegの対応定義。
拡張子.pngをPNGに、.jpgと.jpegをJPEGに対応付けています。拡張子と実形式の一致確認に使われます。LINE 022: gif・webpの対応定義。
拡張子.gifをGIFに、.webpをWEBPに対応付けています。これで主要な画像拡張子の対応が揃います。LINE 023: 拡張子対応辞書の終端。
EXT_TO_FORMAT辞書の定義を閉じています。これで拡張子から実形式を引く対応表が完成します。LINE 026: Pillowの展開爆弾ガード設定。
PillowライブラリのMAX_IMAGE_PIXELS属性に上限値を設定し、極端に大きい画像を開いた際に警告や例外が発生するようにしています。RUN 1/6: 検査ポリシーの上限値を確認する。サイズ上限や画素数上限、許可する画像形式が意図どおり設定されているかを確認します。
CHECK 1/6: 途中実行に成功。サイズ上限MB: 5 画素上限: 24000000 許可形式: ['GIF', 'JPEG', 'PNG', 'WEBP'] RETURN 01: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。
LINE 030: 検査結果モデルの定義開始。pydanticのBaseModelを継承したInspectionResultクラスの定義を開始しています。このクラスがAPIのレスポンス形式になります。
LINE 031: ファイル名フィールドの定義。アップロードされたファイル名を保持するfilenameフィールドを必須項目として定義しています。LINE 032: 合否フィールドの定義。
検査に合格したかどうかを示すacceptedフィールドを必須項目として定義しています。LINE 033: 判定理由フィールドの定義。合否の理由を文字列で保持するreasonフィールドを必須項目として定義しています。
LINE 034: 申告MIMEフィールドの定義。クライアントが申告したMIMEタイプを保持するdeclared_mimeフィールドを、値がない場合を許容して定義しています。LINE 035: 実形式フィールドの定義。
Pillowが実際に判定した画像形式を保持するdetected_formatフィールドを、値がない場合を許容して定義しています。LINE 036: ファイルサイズフィールドの定義。アップロードされたファイルのバイト数を保持するfile_size_bytesフィールドを、初期値0として定義しています。
LINE 037: 幅フィールドの定義。画像の幅をピクセル単位で保持するwidthフィールドを、値がない場合を許容して定義しています。LINE 038: 高さフィールドの定義。
画像の高さをピクセル単位で保持するheightフィールドを、値がない場合を許容して定義しています。LINE 039: 総画素数フィールドの定義。幅と高さを掛け合わせた総画素数を保持するtotal_pixelsフィールドを、値がない場合を許容して定義しています。
RUN 2/6: レスポンスモデルを作って初期値を確認する。InspectionResultを最小限の項目で作り、既定値が想定どおりかを確認します。CHECK 2/6: 途中実行に成功。
ファイル名: sample.png 合否: False サイズ初期値: 0 実形式初期値: None RETURN 02: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。LINE 043: 画像検査関数の定義。
ファイル名、申告されたMIMEタイプ、実データを受け取り、検査結果をInspectionResultとして返す関数inspect_imageを定義しています。LINE 044: 検査結果オブジェクトの生成開始。検査結果を格納するInspectionResultインスタンスの生成を開始しています。
この後の各行で初期値を設定していきます。LINE 045: ファイル名の初期設定。filenameが空だった場合に備えて、値がなければ「(no name)」を設定するようにしています。
LINE 046: 合否の初期値設定。acceptedの初期値をFalseに設定し、検査に合格した場合のみ後でTrueへ変更する前提を作っています。LINE 047: 判定理由の初期化。
reasonの初期値を空文字に設定しています。検査が進むにつれて具体的な理由が上書きされていきます。LINE 048: 申告MIMEの設定。
引数で受け取ったdeclared_mimeの値をそのまま結果オブジェクトに設定しています。LINE 049: ファイルサイズの設定。受け取ったバイト列dataの長さをfile_size_bytesに設定し、ファイルサイズを記録しています。
LINE 050: 検査結果オブジェクトの生成終端。InspectionResultインスタンスの生成を終えています。この時点でresultには初期値のみが入っています。
LINE 053: 空ファイルの判定。データの長さが0かどうかを確認し、空ファイルであれば以降の検査に進まないようにしています。LINE 054: 空ファイル時の理由設定。
データが空だった場合の判定理由として「空のファイルです」を設定しています。LINE 055: 空ファイル時の早期return。空ファイルと判定された時点で検査を打ち切り、resultを呼び出し元に返しています。
LINE 056: ファイルサイズ上限の判定。データの長さがMAX_FILE_SIZEを超えていないかを確認し、超過していれば以降の検査に進まないようにしています。LINE 057: サイズ超過時の理由設定。
ファイルサイズが上限を超えた場合の理由を、上限値をMB単位で表示しながら設定しています。LINE 058: サイズ超過時の早期return。ファイルサイズ超過と判定された時点で検査を打ち切り、resultを呼び出し元に返しています。
LINE 061: 安全性検査のtry開始。画像の破損検知やデコード警告を捕まえるためのtryブロックを開始しています。この中でPillowによる検証を行います。
LINE 062: 警告キャッチ用コンテキストの開始。warnings.catch_warningsを使い、このブロック内だけで警告の扱いを一時的に変更できるようにしています。LINE 063: 警告を例外へ昇格させる設定。
simplefilterに"error"を指定し、通常なら警告として表示されるものを例外として発生させるように設定しています。LINE 064: 画像データのオープン。受け取ったバイト列をio.BytesIOでラップし、Pillowのopenで画像として開いてprobe変数に格納しています。
LINE 065: 画像構造の検証。probe.verify()を呼び出し、画像データの構造が壊れていないかを検証しています。破損があればここで例外が発生します。
LINE 066: 非画像・破損データの捕捉開始。UnidentifiedImageErrorを捕まえ、画像として認識できないデータだった場合の処理を開始しています。LINE 067: 非画像判定時の理由設定。
画像として認識できなかった場合の理由として「画像として認識できません(非画像または破損)」を設定しています。LINE 068: 非画像判定時の早期return。非画像と判定された時点で検査を打ち切り、resultを呼び出し元に返しています。
LINE 069: 展開爆弾例外の捕捉開始。Image.DecompressionBombErrorを捕まえ、総画素数が異常に大きい画像だった場合の処理を開始しています。LINE 070: 展開爆弾検知時の理由設定。
総画素数が大きすぎる場合の理由として「総画素数が大きすぎます(展開爆弾の疑い)」を設定しています。LINE 071: 展開爆弾検知時の早期return。展開爆弾の疑いと判定された時点で検査を打ち切り、resultを呼び出し元に返しています。
LINE 072: 警告例外の捕捉開始。Warningを捕まえ、デコード時に警告が例外として発生した場合の処理を開始しています。LINE 073: 警告発生時の理由設定。
デコード警告が発生した場合の理由を、警告内容wを含めて設定しています。LINE 074: 警告発生時の早期return。デコード警告により拒否と判定された時点で検査を打ち切り、resultを呼び出し元に返しています。
LINE 075: その他例外の捕捉開始。想定外のExceptionを捕まえ、検証処理で予期しないエラーが起きた場合の処理を開始しています。LINE 076: その他例外時の理由設定。
想定外のエラーが発生した場合の理由を、エラー内容eを含めて設定しています。LINE 077: その他例外時の早期return。予期しないエラーが発生した時点で検査を打ち切り、resultを呼び出し元に返しています。
LINE 080: 再デコード処理のtry開始。verify後にストリームを開き直し、実際の幅・高さを取得するためのtryブロックを開始しています。LINE 081: 警告キャッチ用コンテキストの再開始。
再度warnings.catch_warningsを使い、このブロック内だけ警告の扱いを一時的に変更できるようにしています。LINE 082: 警告を例外へ昇格させる再設定。simplefilterに"error"を指定し、再デコード時に発生する警告も例外として扱われるように設定しています。
LINE 083: 画像を再度開く。検証用に一度開いたバイト列をもう一度開き直しています。verifyメソッドで壊れていないかを確認した後、実際にデータを読み込むための新しい画像オブジェクトを用意しています。
LINE 084: 画像データの読み込み。img.loadメソッドで画像の実データを実際にメモリへ読み込んでいます。この処理を行うことで、ヘッダーだけでは分からない途中の破損も検知できるようになります。
LINE 085: 実形式の取得。読み込んだ画像からPillowが判定した実際の形式をdetected変数に取り出しています。ここで取得した値は後の許可形式チェックで使われます。
LINE 086: 幅と高さの取得。画像のサイズ情報をwidthとheightという2つの変数にまとめて取り出しています。この値は後で画素数の計算やレスポンスへの格納に利用されます。
LINE 087: 展開爆弾エラーの捕捉。画素数が極端に大きい画像を開こうとしたときに発生する例外を捕まえています。展開爆弾と呼ばれる悪意ある画像への対策として用意されています。
LINE 088: 展開爆弾の理由設定。総画素数が多すぎることを示す判定理由の文章をresultに設定しています。ユーザーに拒否された原因が分かりやすく伝わるようにしています。
LINE 089: 検査結果を返す。ここまでの判定結果をそのまま呼び出し元へ返しています。以降のチェック処理はスキップされ、拒否として扱われます。
LINE 090: デコード警告の捕捉。画像を読み込む際に発生した警告をWarningとして捕まえています。simplefilterで警告をエラー扱いにしているため、ここで検知できるようになっています。
LINE 091: 警告理由の設定。捕まえた警告の内容を含めた拒否理由の文章をresultに設定しています。どのような警告が原因だったのかが分かるようにしています。
LINE 092: 検査結果を返す。警告により拒否と判定された結果をそのまま呼び出し元へ返しています。この時点で処理は終了します。
LINE 093: その他の例外の捕捉。上記のいずれにも当てはまらない予期しない例外をここでまとめて捕まえています。想定外のエラーでも処理が止まらないようにしています。
LINE 094: デコード失敗理由の設定。画像のデコードに失敗した旨と例外の内容を含めた理由文をresultに設定しています。原因の特定に役立つ情報を残しています。
LINE 095: 検査結果を返す。デコード失敗と判定された結果をそのまま呼び出し元へ返しています。以降の処理は実行されません。
LINE 097: 検出形式の保存。取得した実形式detectedの値をresultオブジェクトのdetected_formatに保存しています。この情報は最終的なレスポンスとして返されます。
LINE 098: 幅と高さの保存。取得した幅と高さの値をresultオブジェクトのwidthとheightにまとめて代入しています。レスポンスに画像サイズ情報を含めるための処理です。
LINE 099: 総画素数の計算。幅と高さを掛け合わせて総画素数を計算し、resultのtotal_pixelsに保存しています。この値は後の上限チェックで使用されます。
LINE 102: 許可形式の確認。検出された実形式がALLOWED_MIMEに登録されている許可形式かどうかを確認しています。登録されていない形式は次の行で拒否されます。
LINE 103: 許可外形式の理由設定。許可されていない画像形式であることを示す理由文を検出形式の情報とともにresultに設定しています。LINE 104: 検査結果を返す。
許可されていない形式と判定された結果をそのまま呼び出し元へ返しています。この時点で以降の検査は行われません。LINE 106: 拡張子の取得。
アップロードされたファイル名から拡張子部分を取り出し、小文字に変換してext変数に格納しています。次の行での比較に使われます。LINE 107: 拡張子と実形式の比較。
拡張子が存在する場合に、対応表から得られる形式と実際に検出された形式が一致するかを確認しています。一致しない場合は不正なファイルとみなされます。LINE 108: 拡張子不一致の理由設定。
拡張子と実形式が一致しないことを示す理由文をresultに設定しています。拡張子偽装への対策として重要な処理です。LINE 109: 検査結果を返す。
拡張子と実形式が一致しないと判定された結果をそのまま呼び出し元へ返しています。LINE 111: 申告MIMEの照合。クライアントが申告したMIMEタイプが存在する場合に、実形式に対応する許可リストに含まれているかを確認しています。
小文字化して比較しています。LINE 112: MIME不一致の理由設定。申告されたMIMEタイプと実形式が一致しないことを示す理由文をresultに設定しています。
LINE 113: 検査結果を返す。MIMEタイプの不一致により拒否された結果をそのまま呼び出し元へ返しています。LINE 116: 総画素数の上限確認。
先ほど計算した総画素数がMAX_PIXELSで定めた上限を超えていないかを確認しています。超えている場合は次の行で拒否されます。LINE 117: 画素数超過の理由設定。
総画素数が多すぎることを示す理由文を上限値とともにresultに設定しています。LINE 118: 検査結果を返す。画素数超過により拒否された結果をそのまま呼び出し元へ返しています。
LINE 120: 検査合格の設定。ここまでのすべての検査を通過したため、result.acceptedをTrueに設定しています。この画像が安全であると判定されたことを表しています。
LINE 121: 合格理由の設定。すべての検査に合格したことを示す文章をresult.reasonに設定しています。ユーザーへ合格の旨を伝えるためのメッセージです。
LINE 122: 最終結果の返却。すべての検査を通過した最終的な判定結果を呼び出し元へ返しています。この戻り値がAPIのレスポンスとして使われます。
RUN 3/6: 検査ロジックへ正しい画像を渡す。生成したPNG画像をinspect_imageへ渡し、合格の判定と実形式が返るかを確認します。CHECK 3/6: 途中実行に成功。
合否: True 理由: すべての検査に合格しました 実形式: PNG 総画素数: 600 RETURN 03: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。LINE 126: HTML文字列の開始。
確認画面として表示するHTMLをINDEX_HTMLという文字列変数に定義し始めています。DOCTYPE宣言から書き始めています。LINE 127: html要素の開始。
日本語ページであることを示すlang属性を付けたhtmlタグを記述しています。LINE 128: head要素の開始。ページのメタ情報やスタイルを記述するためのheadタグを開始しています。
LINE 129: 文字コードの指定。ページの文字コードをutf-8に指定しています。日本語が正しく表示されるようにするための設定です。
LINE 130: ページタイトルの設定。ブラウザのタブなどに表示されるページタイトルを設定しています。LINE 131: style要素の開始。
ページの見た目を整えるためのCSSを記述するstyleタグを開始しています。LINE 132: body全体のスタイル。ページ全体のフォントや幅、余白、文字色などの基本スタイルを設定しています。
読みやすいレイアウトになるよう調整しています。LINE 133: カード枠のスタイル。フォームや結果表示を囲む枠線・角丸・余白のスタイルをcardクラスとして定義しています。
LINE 134: 合否表示のスタイル。合格を示すokクラスと拒否を示すngクラスの文字色と太さを定義しています。判定結果が見た目でも分かりやすくなるようにしています。
LINE 135: テーブルのスタイル。検査結果を表示する表の枠線結合や余白、幅を設定しています。LINE 136: 表セルのスタイル。
表の見出しとデータセルの枠線や余白、文字揃えを設定しています。LINE 137: ボタンのスタイル。検査するボタンの余白を設定し、クリックしやすい大きさにしています。
LINE 138: style要素の終了。ここまで記述してきたCSSの定義を締めくくっています。LINE 139: head要素の終了。
メタ情報とスタイルの記述を終え、headタグを閉じています。LINE 140: body要素の開始。実際に画面に表示される内容を記述するbodyタグを開始しています。
LINE 141: 見出しの表示。ページ上部に表示するメインタイトルを見出しタグで記述しています。LINE 142: 説明文の表示。
このアプリが何を検査するのかを利用者に説明する文章を表示しています。LINE 143: アップロードフォームの開始。ファイルを選択して送信するためのフォームをcardクラス付きで開始しています。
LINE 144: ファイル選択欄の設置。画像ファイルを選択できる入力欄を配置し、画像形式のみを候補として絞り込んでいます。LINE 145: 検査ボタンの設置。
フォームを送信して検査を実行するためのボタンを配置しています。LINE 146: フォームの終了。ファイル選択欄とボタンの記述を終え、formタグを閉じています。
LINE 147: 結果表示欄の設置。検査結果を表示するための領域を用意し、初期状態では案内文を表示するようにしています。LINE 148: script要素の開始。
画面の動作を制御するJavaScriptコードを記述するscriptタグを開始しています。LINE 149: フォーム要素の取得。先ほど定義したフォームをid指定で取得し、form変数に格納しています。
LINE 150: 結果表示欄の取得。検査結果を表示するための領域をid指定で取得し、result変数に格納しています。LINE 151: 送信イベントの登録。
フォームが送信されたときに実行される非同期処理を登録しています。ここから検査のリクエスト処理が始まります。LINE 152: デフォルト送信の抑止。
フォームの通常送信によるページ再読み込みを防いでいます。これによりJavaScriptで送信処理を制御できるようになります。LINE 153: ファイル入力欄の取得。
選択されたファイルを扱うために、ファイル入力欄の要素を取得しています。LINE 154: 未選択時の案内表示。ファイルが選択されていない場合に案内メッセージを表示し、以降の送信処理を中断しています。
LINE 155: フォームデータの作成。サーバーへ送信するためのFormDataオブジェクトを新規に作成しています。LINE 156: ファイルの追加。
選択された画像ファイルをFormDataに追加しています。この内容がAPIへ送信されるデータになります。LINE 157: 検査リクエストの送信。
作成したフォームデータを添えて/inspectエンドポイントへPOSTリクエストを送信し、結果が返るまで待機しています。LINE 158: レスポンスをJSONとして取得。サーバーから返ってきたレスポンスをJSON形式に変換し、変数dに格納しています。
これ以降の処理では、このdを使って検査結果の各項目を参照します。LINE 159: 合否メッセージの組み立て。d.acceptedがtrueかどうかで、画面に表示する合否のHTML文字列を切り替えています。
合格時は緑色の「合格」、不合格時は赤色の「拒否」という表示になります。LINE 160: 結果表示エリアの更新開始。result要素のinnerHTMLに新しい内容を代入する処理の開始行です。
ここから続く複数行の文字列をつなげて、検査結果全体のHTMLを作成します。LINE 161: 判定結果の表示行を作成。先ほど組み立てたverdictの文字列を使って、「判定: 合格」または「判定: 拒否」という段落を作成しています。
LINE 162: 判定理由の表示行を作成。サーバーから返された理由文d.reasonを使って、「理由: ○○」という段落を作成しています。合格・拒否どちらの場合も、この理由が画面に表示されます。
LINE 163: 詳細情報テーブルの開始タグ。検査結果の詳細を一覧表示するためのtable要素を開始しています。この後の行で各項目の行を追加していきます。
LINE 164: ファイル名の表示行を追加。アップロードしたファイルの名前d.filenameを、テーブルの1行として表示しています。LINE 165: 実形式の表示行を追加。
Pillowが判定した画像の実形式d.detected_formatを表示しています。値が存在しない場合は「-」を表示するようにしています。LINE 166: 申告MIMEタイプの表示行を追加。
ブラウザが申告したMIMEタイプd.declared_mimeを表示しています。値がない場合は「-」を代わりに表示します。LINE 167: ファイルサイズの表示行を追加。
アップロードされたファイルのサイズd.file_size_bytesをバイト単位でテーブルに表示しています。LINE 168: 幅と高さの表示行を追加。画像の幅d.widthと高さd.heightを「幅×高さ」の形式で表示しています。
値が取得できていない場合はそれぞれ「-」で表示します。LINE 169: 総画素数の表示行を追加。幅と高さから算出された総画素数d.total_pixelsをテーブルに表示しています。
値がない場合は「-」を表示します。LINE 170: テーブルの終了タグ。detailsを並べたtable要素を閉じています。
これでresult.innerHTMLに代入する文字列全体が完成します。LINE 171: 送信イベント処理の終了。submitイベントに登録した非同期処理関数を閉じています。
これでフォーム送信からJSON取得、画面表示更新までの一連の流れが定義されました。LINE 172: scriptタグの終了。HTML内に埋め込んだJavaScriptコードの終了を示すタグです。
ここまでの処理でフロントエンドの動作がすべて定義されます。LINE 173: bodyタグの終了。HTMLページの本文部分を閉じるタグです。
フォームや結果表示エリア、スクリプトを含む画面の内容がここで完結します。LINE 174: HTML文字列全体の終了。htmlタグを閉じ、Pythonの複数行文字列INDEX_HTMLを終了させています。
この文字列全体がトップページとして返されるHTMLになります。RUN 4/6: 確認画面のHTMLを確認する。アップロード画面のHTMLに見出しやフォームが含まれているかを確認します。
CHECK 4/6: 途中実行に成功。HTML文字数: 2082 見出しあり: True フォームあり: True RETURN 04: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。
LINE 178: FastAPIアプリケーションの生成。FastAPIのインスタンスを作成し、変数appに格納しています。titleパラメータでAPIドキュメントに表示されるタイトルを設定しています。
LINE 180: ルート一覧の定義。アプリで使用するパスの一覧をリストとして定義しています。現時点ではトップページのパス「/」のみが登録されています。
LINE 183: トップページ用ルートの登録。GETメソッドでパス「/」にアクセスされた際に呼び出される関数を、HTMLレスポンスとして返すよう登録するデコレータです。LINE 184: トップページ関数の定義。
トップページにアクセスされたときに実行される関数indexを定義しています。戻り値の型はstrとして宣言されています。LINE 185: アップロード画面の返却。
あらかじめ定義しておいたINDEX_HTMLの文字列をそのまま返し、ブラウザにアップロードフォームを表示させています。RUN 5/6: ルート画面の応答を確認する。アプリを起動してルートにアクセスし、アップロード画面が返るかを確認します。
CHECK 5/6: 途中実行に成功。ステータス: 200 見出し表示: True RETURN 05: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。
LINE 188: 検査エンドポイントの登録。POSTメソッドでパス「/inspect」にアクセスされた際に呼び出される関数を、InspectionResultモデル形式のレスポンスとして返すよう登録するデコレータです。LINE 189: 検査エンドポイント関数の定義。
ファイルアップロードを受け付ける非同期関数inspect_endpointを定義しています。引数fileにはUploadFile型でアップロードされたファイルが渡されます。LINE 190: アップロードファイルの読み込み。
アップロードされたファイルの中身を非同期に読み込み、バイト列として変数dataに格納しています。この後の検査処理でこのデータを使用します。LINE 191: 検査処理の呼び出しと結果返却。
ファイル名やMIMEタイプ、読み込んだバイト列を渡してinspect_image関数を呼び出し、その戻り値であるInspectionResultをそのままレスポンスとして返しています。RUN 6/6: 検査エンドポイントへ画像を送る。完成したAPIへPNG画像を送信し、合格の判定と理由がJSONで返るかを確認します。
CHECK 6/6: 途中実行に成功。判定: True 理由: すべての検査に合格しました RETURN 06: エディターへ戻りました。入力済みのコードを維持したまま、次の実装へ進みます。
PREVIEW: 完成した画像ファイル検査APIを操作。入力・送信・結果表示を実際のブラウザ画面で確認します。UPLOAD: 実際の入力データでアプリを操作。
PNG画像をアップロードして検査を実行し、実形式や各メトリクスとともに合格の判定理由が表示されることを確認できた 確認1/5: アップロード画面を表示する。ルートにアクセスすると、画像を選んで送信するためのフォームが表示されることを確認します。確認2/5: 正しいPNG画像を検査して合格させる。
正しいPNG画像を送ると、合格の判定と実形式や寸法が返ることを確認します。確認3/5: 拡張子を偽装した画像を拒否する。中身がPNGなのに拡張子をjpgに変えたファイルを送り、不一致として拒否されることを確認します。
確認4/5: 画像でないファイルを拒否する。テキストのような画像でないデータを送ると、画像として認識できないとして拒否されることを確認します。確認5/5: 空のファイルを拒否する。
中身が空のファイルを送ると、空のファイルとして拒否されることを確認します。学習内容のまとめ。UploadFileでmultipartのファイルを受け取るBaseModelで合否と理由のフィールドを定義する 空ファイルを最初に弾く ファイルサイズ上限を5MBに固定する 小さく実行確認しながら完成状態まで段階的に組み立てる エンディング。
Python研修はCodeCampでご確認ください。
FastAPIとPydanticとPillowとは
今回使用する主要なライブラリについて、役割と使い分けを順番に確認します。
Webアプリの土台を担うFastAPI
FastAPIはPythonで高速なWeb APIを構築するためのWebフレームワークです。関数に型注釈を書くだけでリクエストの受け取りとレスポンスの生成を任せられるため、開発者は検査ロジックそのものに集中できます。今回はアップロードを受け付けるエンドポイントと、動作を試せるHTML画面を提供する土台として利用します。
このAPIでFastAPIがリクエスト受付とレスポンス返却のために担う役割を挙げます
- UploadFileでmultipartのファイルを受け取る
- File(...)で必須のファイル項目を宣言する
- response_modelにInspectionResultを指定する
- HTMLResponseでアップロード画面を返す
- 非同期エンドポイントでawait file.readを呼ぶ
検証を支えるPydanticと画像を調べるPillow
Pydanticは型注釈をもとにデータ検証とJSON変換を行うライブラリで、Pillowは画像を開いて形式や寸法を調べる画像処理ライブラリです。レスポンスの形はPydanticのBaseModelで定義し、合否や判定理由を必ず含んだ結果を型付きで返します。受け取ったバイト列が本当に画像かどうかは、Pillowのopenとverifyで確認できます。
両者を組み合わせることで、申告された情報だけに頼らない検査結果を安全に組み立てられるという仕組みです。
検査の場面でPydanticとPillowがそれぞれ担う具体的な働きを挙げます
- BaseModelで合否と理由のフィールドを定義する
- Fieldでフィールドの説明と初期値を与える
- Image.openでバイト列から画像を開く
- verifyで画像構造の破損を検知する
- img.loadで実データを読み実寸を得る
- img.formatで実際の画像形式を判定する
Python・FastAPIで開発する場合の環境構築
この記事のセットアップ手順と掲載コードは、Windows 11 Pro、PowerShell 5.1、Python 3.13.3で動作確認しています。仮想環境を有効化せず、その中のPythonを直接指定するため、以下のコマンドはPowerShellとコマンドプロンプト(cmd)の両方で使えます。
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install --upgrade pip
.\.venv\Scripts\python.exe -m pip install fastapi uvicorn pydantic pillow python-multipart
macOS・Linuxでは仮想環境内のPythonパスが異なります。今回の動作確認環境とは異なるため、以下は環境差分を補う参考手順です。
python3 -m venv .venv
./.venv/bin/python -m pip install --upgrade pip
./.venv/bin/python -m pip install fastapi uvicorn pydantic pillow python-multipart
- python-multipartが無いとUploadFileでの受信に失敗するため必ず一緒に導入します。
- 起動は .\.venv\Scripts\python.exe -m uvicorn app:app --reloadで、http://127.0.0.1:8000/ を開きます。
- MAX_FILE_SIZEとMAX_PIXELSは用途に合わせて調整してください。
画像ファイル検査APIの要件定義
目的は、multipart/form-dataで受け取った画像を実形式まで検査し、合否と判定理由をJSONで返すAPIを作れるようになることです。
対象者として、FastAPIの基本に触れた経験があり、アップロード画像を安全に検証する実装を学びたい人を想定しています。
完成物は、FastAPIとPydanticとPillowで画像の実形式やサイズを検査し、判定理由付きの結果を返す検査APIです。
実装へ入る前に、機能・品質・受け入れ条件を分けて確認します。
機能要件
- multipart/form-dataで画像ファイルを受け取る
- 空のファイルを拒否する
- 5MB超のファイルを拒否する
- Pillowのverifyで画像構造を検証する
- 破損した画像を拒否する
- デコード警告の出る画像を拒否する
- 画像の実形式と寸法を取得する
- 許可リスト外の形式を拒否する
- 拡張子と実形式の不一致を拒否する
- 申告MIMEと実形式の不一致を拒否する
- 総画素数の上限超過を拒否する
- 合否と判定理由をJSONで返す
- アップロードフォームのHTML画面を表示する
非機能要件
- ファイルサイズ上限を5MBに固定する
- 総画素数上限を2400万画素に設定する
- Image.MAX_IMAGE_PIXELSで展開爆弾を防ぐ
- デコード警告を例外へ昇格させる
- 実形式を基準に合否を判定する
- 許可形式をPNG・JPEG・GIF・WEBPに限定する
- レスポンスをInspectionResultモデルで型定義する
- 全体を1ファイル構成でまとめる
実装方針
今回はFastAPIとPydanticとPillowの基本動作を追いやすくするため、画像ファイル検査API本体を1つのPythonファイルへまとめます。
入力、判定、結果表示の役割を分け、実行結果を確認しながら機能を積み上げます。
画像ファイル検査APIを安全に組み立てるための実装方針は次のとおりです。
- ファイルサイズ上限を5MBに固定する
- 総画素数上限を2400万画素に設定する
- Image.MAX_IMAGE_PIXELSで展開爆弾を防ぐ
- デコード警告を例外へ昇格させる
- 実形式を基準に合否を判定する
- 許可形式をPNG・JPEG・GIF・WEBPに限定する
- レスポンスをInspectionResultモデルで型定義する
- 全体を1ファイル構成でまとめる
完成と判断する条件
- 正しい画像で合格と判定理由が返る
- 空のファイルで拒否理由が返る
- 5MB超のファイルが拒否される
- 画像でないデータが拒否される
- 拡張子を偽装したファイルが拒否される
- 許可外の形式が拒否される
- 合格時に実形式と寸法と総画素数が返る
- ルートでアップロードフォームが表示される
FastAPIで画像ファイル検査APIを作る際の重要ポイント
inspect_imageは受け取ったバイト列を上から順に検査し、条件に触れた時点で理由を添えて拒否します。まずサイズを確認し、続いてPillowで構造とデコードを検証してから、形式や拡張子とMIMEの一致、総画素数の上限を順番に調べていきます。すべての関門を通過した画像だけをacceptedにして、判定理由とともに返す設計です。
軽い検査を先に置くことで、危険なデータを深く展開する前に弾けます。
inspect_imageが上から順に適用していく検査の関門を挙げます
- 空ファイルを最初に弾く
- 5MB超のサイズを次に弾く
- verifyで破損画像を弾く
- 許可リスト外の形式を弾く
- 拡張子と実形式の不一致を弾く
- 申告MIMEと実形式の不一致を弾く
- 総画素数の上限超過を弾く
- 全関門の通過でacceptedをTrueにする
サイズ検査を先頭に置く理由
検査の最初で空ファイルと上限超えのサイズを弾くのは、無駄なデコード処理を避けるためです。データが空なら画像として扱う意味がなく、大きすぎるファイルはメモリを圧迫します。軽い判定から順に並べることで、負荷の高い処理へ進む前に危険なリクエストを取り除けます。
サイズ検査の段階で拒否される入力を挙げます
- 中身が空のファイル
- 5MBを超える大きなファイル
実形式と申告情報を突き合わせる判定
Pillowが判定した実形式を基準に、拡張子と申告MIMEタイプが矛盾していないかを確認します。拡張子や申告は簡単に書き換えられるため、それだけを信用すると偽装ファイルを見逃します。実データから得た形式と照合することで、名前と中身が食い違うファイルを確実に拒否できる仕組みです。
実形式との照合で拒否される不一致のパターンを挙げます
- 許可リストにない画像形式
- 拡張子と実形式の食い違い
- 申告MIMEと実形式の食い違い
総画素数の上限で展開爆弾を防ぐ考え方
小さなファイルでも、展開すると膨大な画素数になる画像はメモリを一気に消費します。そこでImage.MAX_IMAGE_PIXELSを設定し、上限を超える画像はPillowの段階で例外として弾ける仕組みです。あわせて幅と高さから総画素数を計算し、上限を超えていないかを最後に確認します。
展開爆弾対策として設けている上限を挙げます
- Image.MAX_IMAGE_PIXELSの画素上限
- 幅と高さから求める総画素数の確認
- 2400万画素を超える画像の拒否
Pythonで画像ファイル検査APIの完成コード
全体は1ファイルのFastAPIアプリです。上部に検査ポリシーの定数、続いてレスポンスモデル、検査ロジックの順で並べています。
検査ロジックのinspect_imageは、サイズの足切り→Pillowでの検証→形式とMIMEの照合→画素数の確認の順で判定を進めます。危険なものほど早い段階で弾く設計です。
PillowのImage.openで画像を開いてからverifyで構造を検証し、警告は例外に昇格させて拒否します。判定結果はInspectionResultにまとめて返します。
実装で優先順位を間違えやすいのは、足切りの順序・実形式の優先・警告の扱いの3点です。次のポイントで、この3点の要点を確認します。
このセクションの用語
- Pydantic
- 型ヒントを使ってデータの形と値を検証するライブラリです。FastAPIの入出力モデルに使われます。
- Pillow
- Pythonで画像を読み書き・加工できるライブラリです。今回は形式判定と検証に使います。
- verify
- Pillowが画像ファイルの構造を軽く調べるメソッドです。中身をすべて展開せずに破損を見つけられます。
- 展開爆弾
- 小さなファイルが展開時に巨大な画素数へ膨らむ攻撃です。DecompressionBombErrorで検知します。
import io
import os
import warnings
from fastapi import FastAPI, File, UploadFile
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field
from PIL import Image, UnidentifiedImageError
# 検査ポリシー(上限値と許可リスト)
MAX_FILE_SIZE = 5 * 1024 * 1024 # ファイルサイズ上限: 5MB
MAX_PIXELS = 24_000_000 # 総画素数上限: 2400万画素
ALLOWED_MIME = { # 実形式ごとに許可するMIMEタイプ
"PNG": {"image/png"},
"JPEG": {"image/jpeg", "image/jpg"},
"GIF": {"image/gif"},
"WEBP": {"image/webp"},
}
EXT_TO_FORMAT = { # 拡張子と実形式の対応
".png": "PNG", ".jpg": "JPEG", ".jpeg": "JPEG",
".gif": "GIF", ".webp": "WEBP",
}
# Pillow側の展開爆弾ガード(この画素数を超えると警告/例外になる)
Image.MAX_IMAGE_PIXELS = MAX_PIXELS
# レスポンスモデル(合否と判定理由を必ず返す)
class InspectionResult(BaseModel):
filename: str = Field(..., description="アップロードされたファイル名")
accepted: bool = Field(..., description="検査に合格したか")
reason: str = Field(..., description="合否の判定理由")
declared_mime: str | None = Field(None, description="クライアント申告のMIMEタイプ")
detected_format: str | None = Field(None, description="Pillowが判定した実形式")
file_size_bytes: int = Field(0, description="ファイルサイズ(バイト)")
width: int | None = Field(None, description="画像の幅(px)")
height: int | None = Field(None, description="画像の高さ(px)")
total_pixels: int | None = Field(None, description="総画素数(幅×高さ)")
# 画像検査ロジック(形式・サイズ・安全性をまとめて判定)
def inspect_image(filename: str, declared_mime: str | None, data: bytes) -> InspectionResult:
result = InspectionResult(
filename=filename or "(no name)",
accepted=False,
reason="",
declared_mime=declared_mime,
file_size_bytes=len(data),
)
# 画像サイズ検査: 空ファイルと過大ファイルを先に拒否
if len(data) == 0:
result.reason = "空のファイルです"
return result
if len(data) > MAX_FILE_SIZE:
result.reason = f"ファイルが大きすぎます(上限 {MAX_FILE_SIZE // (1024 * 1024)}MB)"
return result
# 画像安全性検査: 破損検知とデコード警告の拒否(verifyで構造を検証)
try:
with warnings.catch_warnings():
warnings.simplefilter("error") # デコード警告を例外へ昇格
with Image.open(io.BytesIO(data)) as probe:
probe.verify()
except UnidentifiedImageError:
result.reason = "画像として認識できません(非画像または破損)"
return result
except Image.DecompressionBombError:
result.reason = "総画素数が大きすぎます(展開爆弾の疑い)"
return result
except Warning as w:
result.reason = f"デコード警告のため拒否しました: {w}"
return result
except Exception as e:
result.reason = f"画像の検証に失敗しました: {e}"
return result
# verify()後はストリームを開き直し、実データを読み込んで実寸を取得
try:
with warnings.catch_warnings():
warnings.simplefilter("error")
with Image.open(io.BytesIO(data)) as img:
img.load() # 途中破損もここで検知
detected = img.format
width, height = img.size
except Image.DecompressionBombError:
result.reason = "総画素数が大きすぎます(展開爆弾の疑い)"
return result
except Warning as w:
result.reason = f"デコード警告のため拒否しました: {w}"
return result
except Exception as e:
result.reason = f"画像のデコードに失敗しました: {e}"
return result
result.detected_format = detected
result.width, result.height = width, height
result.total_pixels = width * height
# 画像形式検査: 許可形式・拡張子/MIMEと実形式の一致を確認
if detected not in ALLOWED_MIME:
result.reason = f"許可されていない画像形式です: {detected}"
return result
ext = os.path.splitext(filename or "")[1].lower()
if ext and EXT_TO_FORMAT.get(ext) != detected:
result.reason = f"拡張子({ext})と実形式({detected})が一致しません"
return result
if declared_mime and declared_mime.lower() not in ALLOWED_MIME[detected]:
result.reason = f"MIMEタイプ({declared_mime})と実形式({detected})が一致しません"
return result
# 総画素数の上限を確認
if result.total_pixels > MAX_PIXELS:
result.reason = f"総画素数が多すぎます(上限 {MAX_PIXELS:,}px)"
return result
result.accepted = True
result.reason = "すべての検査に合格しました"
return result
# 確認画面(アップロードフォームと結果表示)
INDEX_HTML = """<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>画像ファイル検査API</title>
<style>
body { font-family: sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; color: #222; }
.card { border: 1px solid #ddd; border-radius: 8px; padding: 1rem 1.25rem; }
.ok { color: #137333; font-weight: bold; } .ng { color: #c5221f; font-weight: bold; }
table { border-collapse: collapse; margin-top: .75rem; width: 100%; }
th, td { border: 1px solid #eee; padding: .4rem .6rem; text-align: left; }
button { padding: .4rem 1rem; }
</style>
</head>
<body>
<h1>画像ファイル検査API</h1>
<p>画像をアップロードすると、実形式・MIMEタイプ・ファイルサイズ・幅と高さ・総画素数を検査します。</p>
<form id="upload-form" class="card">
<input type="file" id="file-input" name="file" accept="image/*">
<button type="submit" id="submit-btn">検査する</button>
</form>
<div id="result" class="card" style="margin-top:1rem;">ここに検査結果が表示されます。</div>
<script>
const form = document.getElementById('upload-form');
const result = document.getElementById('result');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const input = document.getElementById('file-input');
if (!input.files.length) { result.textContent = 'ファイルを選択してください。'; return; }
const fd = new FormData();
fd.append('file', input.files[0]);
const res = await fetch('/inspect', { method: 'POST', body: fd });
const d = await res.json();
const verdict = d.accepted ? '<span class="ok">合格</span>' : '<span class="ng">拒否</span>';
result.innerHTML =
'<p id="verdict">判定: ' + verdict + '</p>' +
'<p id="reason">理由: ' + d.reason + '</p>' +
'<table>' +
'<tr><th>ファイル名</th><td>' + d.filename + '</td></tr>' +
'<tr><th>実形式</th><td>' + (d.detected_format || '-') + '</td></tr>' +
'<tr><th>申告MIME</th><td>' + (d.declared_mime || '-') + '</td></tr>' +
'<tr><th>ファイルサイズ(byte)</th><td>' + d.file_size_bytes + '</td></tr>' +
'<tr><th>幅×高さ</th><td>' + (d.width || '-') + ' × ' + (d.height || '-') + '</td></tr>' +
'<tr><th>総画素数</th><td>' + (d.total_pixels || '-') + '</td></tr>' +
'</table>';
});
</script>
</body>
</html>"""
# FastAPIアプリと画像アップロード用エンドポイント
app = FastAPI(title="画像ファイル検査API")
ROUTES = ["/"]
@app.get("/", response_class=HTMLResponse)
def index() -> str:
return INDEX_HTML
@app.post("/inspect", response_model=InspectionResult)
async def inspect_endpoint(file: UploadFile = File(...)) -> InspectionResult:
data = await file.read()
return inspect_image(file.filename or "", file.content_type, data)
コード全文は上の折り畳みに入れてあるので、全部を上から読む必要はありません。ここでは特に重要な部分だけを抜き出して、何をしているのか順番に見ていきます。
検査ポリシーの上限値を定義する
MAX_FILE_SIZE = 5 * 1024 * 1024 # ファイルサイズ上限: 5MB
MAX_PIXELS = 24_000_000 # 総画素数上限: 2400万画素検査で使う上限値を最初に定数でまとめています。ファイルサイズは5MB、総画素数は2400万画素を超えたら拒否する方針です。数値を1か所に集めておくと、要件が変わっても直しやすくなります。
許可MIMEと実形式の対応表を作る
ALLOWED_MIME = { # 実形式ごとに許可するMIMEタイプ
"PNG": {"image/png"},
"JPEG": {"image/jpeg", "image/jpg"},
"GIF": {"image/gif"},
"WEBP": {"image/webp"},
}実形式ごとに、許可するMIMEタイプを辞書で対応づけています。たとえばPNGはimage/pngだけを認め、JPEGはimage/jpegとimage/jpgを許可します。実形式と申告MIMEが噛み合うかを、この表で照合する仕組みです。
MAX_IMAGE_PIXELSで展開爆弾に備える
Image.MAX_IMAGE_PIXELS = MAX_PIXELSPillow側にも画素数の上限を設定しておきます。この値を超える画像を開こうとすると、警告や例外が発生する仕組みです。小さなファイルが展開時に巨大化する、いわゆる展開爆弾への備えになります。
InspectionResultで合否と理由を返す
class InspectionResult(BaseModel):
filename: str = Field(..., description="アップロードされたファイル名")
accepted: bool = Field(..., description="検査に合格したか")
reason: str = Field(..., description="合否の判定理由")レスポンスの形をPydanticのBaseModelで定義しています。acceptedで合否を、reasonで判定理由を必ず返すのがこのAPIの肝です。Fieldのdescriptionは、そのまま自動生成のドキュメントに反映されます。
サイズで空・過大ファイルを足切り
if len(data) == 0:
result.reason = "空のファイルです"
return result
if len(data) > MAX_FILE_SIZE:
result.reason = f"ファイルが大きすぎます(上限 {MAX_FILE_SIZE // (1024 * 1024)}MB)"
return resultバイト列の長さを見て、空ファイルと過大ファイルを最初に弾く部分です。画像として開く前にサイズで足切りするので、無駄なデコードを避けられます。上限を超えたときは、その旨をreasonに入れて早めに返します。
verifyで破損とデコード警告を検知
try:
with warnings.catch_warnings():
warnings.simplefilter("error") # デコード警告を例外へ昇格
with Image.open(io.BytesIO(data)) as probe:
probe.verify()Image.openで画像を開き、verifyで構造だけを軽く検証する部分です。catch_warningsとsimplefilterでデコード警告を例外に昇格させ、あやしい画像を見逃さないようにします。中身を全部展開しないため、安全に破損だけを確かめられます。
例外ごとに判定理由を切り分ける
except UnidentifiedImageError:
result.reason = "画像として認識できません(非画像または破損)"
return result
except Image.DecompressionBombError:
result.reason = "総画素数が大きすぎます(展開爆弾の疑い)"
return result
except Warning as w:
result.reason = f"デコード警告のため拒否しました: {w}"
return result検査で起きうる例外を種類ごとに受け止め、それぞれ違う判定理由を設定しています。認識できない画像、展開爆弾、デコード警告を別々に切り分けて拒否します。理由が具体的だと、なぜ弾かれたのかが利用者に伝わりやすいです。
参考:
©Pillow (Python Imaging Library)公式ドキュメントthis method attempts to determine if the file is broken, without actually decoding the image data. If you need to load the image after using this method, you must reopen the image file.
足切り優先:デコード前にサイズ確認
実形式優先:拡張子より中身を信用
警告拒否:simplefilterでerror化
画像ファイル検査APIの動作確認
実際にブラウザからPNG画像を選び、検査を実行しました。操作前はアップロードフォームだけが表示された状態です。
送信後は、Pillowが判定した実形式や各メトリクスとともに、合格の判定理由が表として表示されることを確認できました。
画面にはファイル名やファイルサイズ、幅と高さ、総画素数が並び、accepted(合否)とreason(理由)も一緒に描画されます。
このセクションの用語
- HTMLResponse
- FastAPIでHTMLをそのまま返すためのレスポンスです。確認画面のフォームを返すのに使います。
- fetch
- ブラウザからサーバへ非同期に通信するJavaScriptの関数です。確認画面が/inspectを呼ぶのに使います。
- メトリクス
- 測定して数値にした指標です。ここでは幅や高さ、総画素数などの計測値を指します。



PythonのFastAPIとPydanticとPillowのエラー対処
画像検枻APIでは、Pillowのインストール漏れや形式判定の勘違いでつまずきやすいです。ここでは一般に起こりやすいエラーを整理します。
特にverifyの後にload系の処理をそのまま続けると失敗しやすいです。verifyを呼んだImageオブジェクトは、再利用できないためです。
このセクションの用語
- UnidentifiedImageError
- Pillowがファイルを画像として認識できないときに出す例外です。非画像や壊れたデータで発生します。
- 422 Unprocessable Entity
- FastAPIが受け取った入力を検証できないときに返すHTTPステータスです。フィールド名や型の食い違いで起きます。
| エラー例 | 原因 | 対処 |
|---|---|---|
| ModuleNotFoundError: No module named 'PIL' | Pillowが未インストール | pip install Pillowで導入する |
| UnidentifiedImageError | 非画像や破損データを開こうとした | 画像として認識できない旨をreasonに入れて拒否する |
| verify後にload/属性取得で失敗 | verify済みのオブジェクトを再利用した | Image.openからやり直して開き直す |
| Image.DecompressionBombError | 総画素数がMAX_IMAGE_PIXELSを超過 | 上限を確認し、過大画素の画像を拒否する |
| 422 Unprocessable Entity | POST /inspectのフィールド名がFileと不一致 | UploadFileの引数名とフォームのname属性を合わせる |
画像ファイル検査APIで注意したい点
検査の順序を間違えると、危険なファイルを無駄にデコードしてしまいます。サイズの足切りは必ずPillowで開く前に行うのが安全です。
また拡張子とMIME、実形式は食い違うことがあります。3つを別々に確認し、実形式を最優先で信用するのが確実です。
verifyは便利ですが、呼んだ後のオブジェクトはそのまま使えません。幅や高さを取りたいときはImage.openをやり直します。
ポイントとしては、検査の順序と実形式の扱い、verify後の再オープンを押さえておくと安定します。
再open:verifyの後は再度開く
MIME照合:拡張子と実形式も確認
足切り:デコード前にサイズ判定
画像検枻APIをPythonで応用できる場面
この検査APIは、ユーザー投稿を受け付けるサービスの入り口として応用できます。実形式まで確かめる仕組みは、そのまま前段のガードとして働く点が強みです。
アップロード基盤の一部に組み込めば、保存前に危険なファイルを弾けます。判定理由をログに残しておくと、拒否の原因も後から追いやすくなります。
このセクションの用語
- バッチ
- たまったデータをまとめて一括処理する方式です。フォルダ内の画像を一度に検査する場面で使います。
| 使える場面 | 具体的な使い方 |
|---|---|
| 投稿画像の受付フォーム | 保存前にPOST /inspectへ通し、不合格の画像はreasonを添えて弾く |
| 社内の画像アップロード基盤 | アイコンや資料の形式・画素数を検査し、上限超過を自動で拒否する |
| フォルダ画像の一括点検 | 既存画像をバッチでinspect_imageに渡し、破損や過大画素を洗い出す |
| 外部連携APIの入力検証 | 受信した画像を実形式で照合し、申告MIMEとの不一致を検出する |
画像ファイル検査API開発のまとめ
Python FastAPIとPillow、Pydanticを組み合わせ、画像を実形式まで検査するAPIを1ファイルで作りました。サイズの足切りからverify、形式照合まで順に判定する流れです。
実際にPNG画像をアップロードすると、実形式や各メトリクスとともに合格の判定理由が画面に表示されました。危険な入力を早い段階で弾く設計の効果を確かめられました。
判定理由を必ず返すレスポンスモデルにしておくと、拒否の理由がそのまま利用者に伝わります。まずは上限値や許可リストを、自分の要件に合わせて調整するのがおすすめです。
このセクションの用語
- レスポンスモデル
- APIが返すデータの形をPydanticで定義したものです。ここではInspectionResultが合否と理由を表します。
参考にした一次情報
- ^ FastAPI - Request Files. https://fastapi.tiangolo.com/tutorial/request-files/, (参照26-07-30).
- ^ Pillow - Image Module (open / verify / MAX_IMAGE_PIXELS). https://pillow.readthedocs.io/en/stable/reference/Image.html, (参照26-07-30).
- ^ Pydantic - Models. https://docs.pydantic.dev/latest/concepts/models/, (参照26-07-30).
※内容は執筆時点のものです。ライブラリやサイトの仕様は変わる可能性があるため、公式ドキュメントもあわせてご確認ください。
ITやプログラミングに関するコラム
【Python】FastAPIで料金プラン見積もりシミュレーターを作ってみた
【Python】pandasとmatplotlibで在庫データのABC分析と構成比を可視化してみた
【Python】Flaskで社内FAQをカテゴリ検索できるWebアプリを作ってみた
【Python】argparseでJSON整形・構文検証・キー検索CLIを試してみた
【Python】NumPyとmatplotlibでモンテカルロ法による円周率推定と収束過程の可視化を試してみた
【Python】Playwrightでスクレイピングを試してみた
【CSS】notで複数の件を除外する方法
【Git】remote設定を変更する方法
【VBA】コメントアウトを設定する方法
x86とx64の違いを分かりやすく解説
ITやプログラミングに関するニュース
VercelがAI GatewayにSeedream 5.0 Proを追加、AI SDKのモデル指定で画像生成と編集が可能に
AWSがAmazon LocationのPlaces APIを強化、住所表記の指定と移動手段別の検索が可能に
VercelがトレースにTree・Waterfallビューを追加、ログ画面で処理の階層と所要時間を確認可能に
Googleがエージェント評価の再考を提唱、難易度を情報量で測るDiscovery Benchを解説
Google CloudがCloud Runサンドボックスを公開プレビューで提供、サービスヘルスは一般提供に
Google Cloud EMEAが英国金融の重要第三者に指定、イングランド銀行・PRA・FCAの直接監督下に
AWS DMS Schema ConversionがSQL Serverのオフライン変換に対応、ソースDBへ接続せずスキーマを変換可能に
EC2 G7インスタンスが米国東部(バージニア北部)で利用可能に、G6比でAI推論性能が最大4.6倍
SageMaker HyperPodが継続プロビジョニングでのAMIベース構成に対応、S3のスクリプト管理なしでSlurmクラスターを作成可能に
AWSがEMR on EKSでSparkトラブルシューティングエージェントに対応、失敗ジョブの原因分析を自然言語で依頼可能に
