Photoruction工事中!

Photoructionの開発ブログです!

ドキュメント配信のインフラリソースをCloudFormationでコード化した

はじめに

こんにちは、エンジニアの酒井です。

以前、チームで頻繁に使用する関数やメソッドを共通ライブラリとして開発効率を向上させる取り組みを行い、ライブラリのドキュメントはS3とCloudFrontを用いた構成で配信することにしました。

その際、今後も同じような静的コンテンツの配信をするときにすぐにこの構成を再現できるようCloudFormationでインフラリソースをコード化しました。

今回はその際のテンプレートの内容を記述したいと思います。

構成

レスポンス速度の向上のため、S3だけではなく、CloudFrontを間に設置し、エッジロケーション経由でコンテンツを配信する。

方法

  • 全体のソースコード

      AWSTemplateFormatVersion: 2010-09-09
      Description: Static contents distribution using S3 and CloudFront.
    
      Resources:
        # S3 バケット
        MyBucket:
          Type: AWS::S3::Bucket
          Properties:
            BucketName: "sample-bucket"
    
    
        # S3 バケットポリシー
        MyBucketPolicy:
          Type: AWS::S3::BucketPolicy
          Properties:
            Bucket: !Ref MyBucket
            PolicyDocument:
              Statement:
                - Action: s3:GetObject
                  Effect: Allow
                  Resource: !Sub arn:aws:s3:::${MyBucket}/*
                  Principal:
                    AWS: !Sub arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${CloudFrontOriginAccessIdentity}
    
    
        # CloudFront ディストリビューション
        CloudfrontDistribution:
          Type: AWS::CloudFront::Distribution
          Properties:
            DistributionConfig:
              Origins:
                - Id: S3Origin
                  DomainName: !GetAtt MyBucket.RegionalDomainName
                  S3OriginConfig:
                    OriginAccessIdentity: !Sub origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}
              Enabled: true
              DefaultRootObject: index.html
              Comment: !Sub ${AWS::StackName} distribution
              DefaultCacheBehavior:
                TargetOriginId: S3Origin
                ForwardedValues:
                  QueryString: false
                ViewerProtocolPolicy: redirect-to-https
    
        # CloudFront OAI
        CloudFrontOriginAccessIdentity:
          Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
          Properties:
            CloudFrontOriginAccessIdentityConfig:
              Comment: !Ref AWS::StackName
    

S3バケットの設定

Resources:
  MyBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: "sample-bucket"

CloudFront ディストリビューションの設定

  • DistributionConfigディストリビューションの設定をしていきます。
  • Originsでコンテンツを配信する元となる場所(サービス)を指定します。
    • 今回はS3なのでそれぞれ設定します。
      • Idは任意の名前を記述します。
      • DomainNameでは先ほど設定したMyBucketのRegionalDomainNameを指定します。
      • S3OriginConfigのOriginAccessIdentityで次に作成するOrigin Access Identityを指定します。
  • Enabledディストリビューションを有効にするか無効にするかを指定します。
  • DefaultRootObjectディストリビューションのルートURLを要求したときに、CloudFrontがオリジンから要求したいオブジェクトを指定します。
  • Commentディストリビューションの説明を記述します。
  • DefaultCacheBehaviorでキャッシュの動作を指定します。
    • TargetOriginIdで先ほど指定したOriginのIdを指定します。
    • ForwardedValues は非推奨ですが、指定が簡単なので使用しました。
      • QueryString: false によってクエリ文字列が異なっても同じコンテンツとみなしてキャッシュします。
    • ViewerProtocolPolicyはPathPattern のパスパターンに合致する要求があった場合に、TargetOriginId で指定されたオリジンのファイルにアクセスするために視聴者が使用できるプロトコルを指定します。
      • 今回は redirect-to-https として「ビューアー(クライアント)がHTTPリクエストを送信すると、CloudFrontはHTTPステータスコード301(Moved Permanently)をHTTPS URLとともにビューアーに返します。
        • クライアントはその後、新しいURLを使用してリクエストを再送信します。
Resources:
  CloudfrontDistribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Origins:
          - Id: S3Origin
            # DomainName: !GetAtt MyBucket.DomainName
            DomainName: !GetAtt MyBucket.RegionalDomainName
            S3OriginConfig:
              OriginAccessIdentity: !Sub origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}
        Enabled: true
        DefaultRootObject: index.html
        Comment: !Sub ${AWS::StackName} distribution
        DefaultCacheBehavior:
          TargetOriginId: S3Origin
          ForwardedValues:
            QueryString: false
          ViewerProtocolPolicy: redirect-to-https

OriginAccessIdentityの設定

Resources:
  CloudFrontOriginAccessIdentity:
    Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
    Properties:
      CloudFrontOriginAccessIdentityConfig:
        Comment: !Ref AWS::StackName

S3バケットポリシーの設定

  • PropertiesBucket で先ほど設定したバケットの論理IDを指定します。
  • PolycyDocumentStatement でポリシーの内容を記述します。
    • Principal:誰が
    • Resource:どのサービスどのリソースに対して
    • Action:どの操作を
    • Effect:許可(Allow) or 禁止(Deny)する
    • 記述内容以下のようになります。
      • 「CloudFrontのOrigin Access Identity(ユーザー)」が「MyBucket(先ほど設定したバケット)の全てのオブジェクト」に対して「オブジェクトの読み込み操作」を行うことを「許可」する
Resources:
  MyBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref MyBucket
      PolicyDocument:
        Statement:
          - Action: s3:GetObject
            Effect: Allow
            Resource: !Sub arn:aws:s3:::${MyBucket}/*
            Principal:
              AWS: !Sub arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${CloudFrontOriginAccessIdentity}

さいごに

インフラをコードベースで管理することでインフラリソースの状態を把握できますし、今後同じ構成で何かを開発する際はインフラの構築時間を削減できると感じました。

今後も業務の中で人が行う必要がない部分は積極的に自動化して、自分を含めチームメンバーが本質的な部分に力を注げるよう行動していきたいなと思います!

株式会社フォトラクションでは一緒に働く仲間を募集しています

corporate.photoruction.com www.wantedly.com

web hookを利用したAPIのテストにngrok(pyengrok)を使いローカルサーバーにレスポンスを送る

はじめに

こんにちは、AIエンジニアの山下です。

これまではweb hookを利用したAPIの解析結果を確認・テストするのにPostmanwebhook.siteを使用していました。

少し確認する程度ならこれでもよかったのですが、何度もテストを行いたい場合などは毎回ブラウザーを開いて確認するとなると結構面倒。

そこで、python+Flask(Webアプリケーションフレームワーク)で簡易的なローカルサーバーを構築し、ngrokというツール(ローカルサーバーと外部ネットワークを繋ぐツール)を使用し、ブラウザーやターミナルを開かず、jupyter notebook内でAPIへのリクエストの送信からローカルサーバーでのレスポンスのデータの保存までできることを目指しました。

(*ネットワークやwebアプリケーションに慣れていないので細かい部分で認識が間違っていたらすみませんmm)

*OSはMac

前提として

  • ngrokにユーザー登録が済んでいる
  • 今回想定しているのはwebAPIにリクエストを送り、そのレスポンス(解析結果)をweb hookとして送る(指定したURLにPOSTリクエストする)という構成
  • jupyter notebookでpythonモジュール(app.py)を読み込んだ上でリクエスト送信→APIからwebhookをローカルサーバーに送る→ローカルサーバーでレスポンス内容をjsonに変換したものを保存するまでの処理を行いたい
  • ngrokにはngrokコマンドをインストールして使えるが、処理をjupyter notebook内で完結させたいため、Pythonラッパーのpyngrokというライブラリーを使用
  • python内で”pip install flask pyengrok”が事前に必要

流れ

  1. 作成したpythonファイル(モジュール)とその説明
  2. 1.を読み込むjupyter notebookの説明

app.py

  • 作成したモジュールです、この後に内容を説明していきます。
import json
import logging
import os
import threading
from logging import config

from flask import Flask, jsonify, request
from pyngrok import ngrok
from werkzeug.serving import run_simple

from config.logging_config import LOGGING_CONFIG

class CreateApp():
    ana_js = None

    def __init__(self, on_colab: bool = False) -> None:
        os.environ["FLASK_DEBUG"] = "development"

        # Open a ngrok tunnel to the HTTP server
        port = 8888  # ポート番号
        public_url = ngrok.connect(port).public_url
        self.public_url = public_url.replace('http', 'https')  # httpsに変換
        print(" * ngrok tunnel \"{}\" -> \"http://127.0.0.1:{}\"".format(
            self.public_url, port))

        app = Flask(__name__)
        app.debug = True
        app.config["SECRET_KEY"] = "secret key"  # 要変更
        # Update any base URLs to use the public ngrok URL
        app.config["BASE_URL"] = self.public_url
        app.config["ENV"] = "development"
        app.config['JSON_AS_ASCII'] = False  # JSON日本語文字化け対策
        # .. Update inbound traffic via APIs to use the public-facing ngrok URL

        # logging設定
        config.dictConfig(LOGGING_CONFIG)
        logger = logging.getLogger()

        # Start the Flask server in a new thread
        if on_colab is False:
            threading.Thread(
                target=lambda: run_simple('localhost', port, app)).start()
        else:
            # on Colab, you can use the following code instead of the above
            # line
            threading.Thread(
                target=app.run, kwargs={"use_reloader": False}).start()

        # Define Flask routes
        @app.route("/", methods=['GET'])
        def index():
            return "Web App with Python Flask!"

        @app.route('/receiver', methods=['POST'])
        def get_json():
            js = request.get_json()
            if js is not None:  # JSONが送られてきた場合、DB & ローカルに保存
                logger.error('ERROR')
                self.ana_js = js  # class変数に保存
                with open(f'./result/{js["metadata"]["pdf_name"]}.json',
                          'w') as f:
                    json.dump(js, f, indent=4)
            return jsonify(js)

    def receive(self) -> dict:
        while True:
            if self.ana_js is not None:
                return self.ana_js
            else:
                continue

pyngrokを使用し、HTTPサーバーを開く

  • port は何でも良さそうでdefaultだと5000になります
  • public_urlがngrokが作成したトンネルのURLになります
# Open a ngrok tunnel to the HTTP server
port = 8888  # ポート番号
public_url = ngrok.connect(port).public_url
self.public_url = public_url.replace('http', 'https')  # httpsに変換
print(" * ngrok tunnel \"{}\" -> \"http://127.0.0.1:{}\"".format(
    self.public_url, port))

Flask側の設定

  • app.config[”BASE_URL”]に上記の”public_url”を指定する
  • loggingも設定しましたが、本筋ではないので詳細は省きます
app = Flask(__name__)
app.debug = True
app.config["SECRET_KEY"] = "secret key"  # 要変更
# Update any base URLs to use the public ngrok URL
app.config["BASE_URL"] = self.public_url
app.config["ENV"] = "development"
app.config['JSON_AS_ASCII'] = False  # JSON日本語文字化け対策
# .. Update inbound traffic via APIs to use the public-facing ngrok URL

# logging設定
config.dictConfig(LOGGING_CONFIG)
logger = logging.getLogger()

python HTTPの設定

  • このあたりちゃんと理解できていませんが二つの処理を同時に実行させるために並列処理を行いFlaskサーバーをスレッドを使用し、実行しているようです
# Start the Flask server in a new thread
if on_colab is False:
    threading.Thread(
        target=lambda: run_simple('localhost', port, app)).start()
else:
    # on Colab, you can use the following code instead of the above
    # line
    threading.Thread(
        target=app.run, kwargs={"use_reloader": False}).start()

Flaskの処理とルーティング

  • こちらなくても良いのですが、サーバーが起動しているかどうかをブラウザーlocalhost:8888にアクセスし確認するために処理を入れました。
# Define Flask routes
@app.route("/", methods=['GET'])
def index():
    return "Web App with Python Flask!"
  • こちら、ローカルサーバーで受け取ったレスポンスをjsonに変換し、ローカルの指定したディレクトリに保存する処理になります。(また、そのjsonをクラス変数に代入する処理も行ってます、この変数に代入されたデータは後ほど使います)
@app.route('/receiver', methods=['POST'])
def get_json():
    js = request.get_json()
    if js is not None:  # JSONが送られてきた場合、DB & ローカルに保存
        logger.error('ERROR')
        self.ana_js = js  # class変数に保存
        with open(f'./result/{js["metadata"]["pdf_name"]}.json',
                  'w') as f:
            json.dump(js, f, indent=4)
    return jsonify(js)

クラス内関数を定義

  • APIにリクエストを送り、レスポンスが返ってくるまでにタイムラグがあり、ローカルサーバーにレスポンスが送られるまで実行し続ける関数になります
  • 何のための関数か分かりづらいかもしれませんが、後のjupyter notebook内の全ての処理(セル)を一度の実行で行いため実装しています
def receive(self) -> dict:
    while True:
        if self.ana_js is not None:
            return self.ana_js
        else:
            continue

JupyterNotebook

  • 1番目のセル
    • 必要なライブラリを読み込んでます(app→先に作成したモジュール)

  • 2番目のセル

    • 1行目:ローカルサーバーの起動
    • 2行目:public_urlの取得(パブリックngrokURL)
    • 3行目:アクティブなトンネルをリストで取得

  • 3番目のセル

    • 各パラメーターを指定し、APIにPOSTリクエストし、APIの処理が実行されローカルサーバーにweb hookを送るまでの処理

    *url, api_key, pdf_path, dataの内容はダミー

  • 4番目のセル

    • app.pyのCreateAppクラス内関数receiveを実行
    • このセルが実行完了(ローカルサーバーがレスポンスを受信、かつ、レスポンスjsonの保存)次第、次のセルが実行される

    *この処理を入れずに次のセルを実行するとローカルサーバーがレスポンスを受信する前にトンネルを閉じてしまう場合がある

  • 5番目のセル

    • 開いているトンネル閉じる

    *jupyter notebookを再起動するだけでトンネルは閉じられる

jupyter notebook コード

import json

import requests
from pyngrok import ngrok

from app import CreateApp
# local server を起動
ca = CreateApp()
public_url = ca.public_url
tunnels = ngrok.get_tunnels()
# APIにデータを送信
url = 'https://OOOO.com/OOO' # ダミー
api_key = 'api_key'. # ダミー
webhook_url = public_url + '/receiver'

pdf_path = 'OOOO/OOOO/OOO.pdf'
metadata = {'pdf_name': '0000.pdf'}
# postする
data = {
    'data:': 'data', webhook_url: 'webhook_url', metadata: {'hoge': 'test'}}
js_str = json.dumps(data).encode('utf-8')
response_local = requests.post(
    url, data=js_str, headers={'x-api-key': api_key})
print(f'response_local: {response_local}')
ca.receive().keys()
print(f'Before tunnels:\n{ngrok.get_tunnels()}')
# Close all open ngrok tunnels
_ = [ngrok.disconnect(tnnl.public_url) for tnnl in tunnels]
print(f'After tunnels:\n{ngrok.get_tunnels()}')

最後に

webやネットワークに慣れてないこともあり、言語化するのに苦労しました。

(*今回のものはあくまで、開発テスト確認用になります)

次はFlaskのローカルサーバーにログイン機能を実装してみたいと思います。

株式会社フォトラクションでは一緒に働く仲間を募集しています

corporate.photoruction.com www.wantedly.com

エンジニアのファーストキャリアをCREチームで働いてみて感じたこと。

CREチームでWEBエンジニアをしている桑畑です。 WEBエンジニアをするまでは、マーケティング、データアナリストなどをしてきました。 個人開発でのWebアプリケーション開発経験はありましたが、職業としてWEBエンジニアをさせて頂いたのはフォトラクションのCREチームが初めてです。

エンジニアファーストキャリアをCREチームでスタートした感想を述べたいと思います。

CREチームとは


まずは、フォトラクションのCREチームについて紹介します。

ミッション

CREチームのミッションは、顧客が抱く自社サービスへの技術的な不安や不満を解消してカスタマーサクセスを実現していくことです。

日々多くのお客様がサービスをご利用頂く中で、様々なトラブルや要望の声を頂きます。そういったお客様の声に対して、技術的な側面からサポートしております。サービスをより快適に、安心してご利用頂けるような環境を作っていくことがテーマです。

チーム構成

現在は、WEBエンジニアが4名在籍

技術要件

フロントエンド、バックエンド、インフラ、データベースなど

CREチームで働くメリット


CREチームで働くメリットを紹介していきます。

幅広い技術領域に挑戦できる

前述の技術要件でも紹介しましたが、幅の広さが一番の魅力だと感じています。トラブルや要望は様々な技術レイヤーで頂きます。そのレイヤーに応じて、必要となるスキルが異なります。スキルが弱い部分でも要件に応じて、知識をキャッチアップして問題を解決していく過程で、幅広い技術領域への経験が積める環境だと思います。

企業によってはエンジニアの役割が細分化されているところもあるかと思います。専門性を伸ばしていく上では非常に大事だと思いますが、それと同時に、大局的にサービスと関わることによって様々な技術レイヤーを横断したシステム全体の理解に繋がるかと思っております。

不具合時の問題切り分けに能力がつく

緊急な不具合の対応などもCREチームが請け負うことが多いです。不具合のときに初めて触る機能やコードもたくさんあります。そういった場合に、落ち着いて問題の処理を特定することが求められます。現時点で出来ているか分かりませんが、求められる状況はたくさんありますので鍛えられていると思います。不具合時に限らず、問題の切り分け能力は大事だなと感じています。

プロダクトへの評価を目近で聞こえる

フォトラクションでは、お客様との接点はサポートやフィールドセールスのチームが担っています。ただし、開発組織におりながら、トラブルや要望といった声を目近で聞くことが出来るのもCREチームの魅了だと思います。

開発組織とビジネス組織が縦割りで情報共有が乏しい状態は不健全な状態かと思います。CREチームでは、プロダクト改善の目線を持ちながら開発に深く関わることが出来ます。

他部署/チームとの関わり


CREチームでは様々な部署の方との関わりがあります。技術理解を深める上でも、サービスに関わった開発をしていく上でも、多くの組織との関わりは魅力かもしれません。

ビジネス組織との関わり

コミュニケーション接点が1番多いのはサポートチームの皆さんです。フォトラクションでは、サービス利用のお客様からの声をサポートチームが集約し、快適に安心して利用頂けるよう努めています。

サポートチームからは緊急対応の依頼や、調査、開発ロードマップに載っていないが改善したいことなど、様々なオーダーや提案を頂きます。そういった声からCREチームでは開発やトラブル対応などをしていきます。

開発組織の他チームとの関わり

フォトラクションでは、CREチーム以外にも目的別に複数のチームが構成されております。他チームの方々との連携も日々あります。

WEBエンジニア全員が集う定例ミーティング・開発レビュー・開発/技術相談など、オンラインコミュニケーションを中心にして、チームの垣根無くコミュニケーションしております。

まとめ


フォトラクションの開発組織やCREチームで働く魅力、エンジニアファーストキャリアとしてのCREチームで働くメリットなどを感じて頂けましたでしょうか。

課題もまだまだ沢山ありますが、それらも含めて楽しめるエンジニアの仲間を募集しております。少しでも興味を持って頂けた方は連絡頂けると嬉しいです。

最後まで読んで頂き、ありがとうございました。

 

株式会社フォトラクションでは一緒に働く仲間を募集しています

PHPUnit勉強会を開催しました

自己紹介

今年3月に入社した北原です。普段は、サーバーサイドをメインに開発をしつつ、スクラムマスターも兼務しています。

今回書かさせていただく内容は、社内で開催したPHPUnit勉強会の話です。

※弊社は、PHPのFWであるLaravelを採用しています。

PHPUnit勉強会を開催した理由

テストを書く文化が根付いていない

  • テストが現状0ということではなかったんですが、基本的にテストを書くという文化が根付いていなくて、コードを信頼できる状態かどうか怪しいという現状
  • テストが書かれている箇所があっても一般的な状態ではなかった。
    • ここでいう一般的というのは、Laravel wayに則って書くということを指しています。(データ作成やディレクトリ、継承元)

将来的に

  • PHPUnitは書くもんだろ!って人たちからすれば当たり前ですがテストが書かれていない状態でのPRのマージを原則NGにする
  • エンジニア全員がテストをかける状態にする
    • 少なくともサーバーサイドをメインにする人は、書ける

勉強会

以下のことをまず準備しました。

  • Laravelのサンプルプロジェクト
    • 環境毎の差異をなくすためです(色々察していただけると)
  • スライドの作成
    • 今回説明は割愛します。

どんな形式の勉強会にしたのか?

結果的には、Liveコーディングになりましたが、ハンズオン形式のつもりで進めました。

全体の流れ

  • スライドにて今回勉強する内容の説明と前提情報を共有。
  • 実装(主に時間割きました)

基礎編と応用編の2回開催

以下に実際に勉強会で使用したコードの一部を貼っておきます

  • 基礎編
    • 基本的なPHPUnitの書き方の学習
    • 内容としては、AdminがOrganizationに関連付くUserの作成を行うエンドポイントのテストとなっています。
      • 実際には、異常系のテストも書いております。

テスト対象のControllerの記述

<?php

namespace App\Http\Controllers\Api\Admin;

use App\Admin;
use App\Organization;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Auth;
use App\Http\Requests\UserStoreRequest;
use Illuminate\Auth\Access\AuthorizationException;

class UserController extends Controller
{
    private $organization;

    public function __construct(Organization $organization)
    {
        $this->organization = $organization;
    }

    /**
     * Store a newly created resource in storage.
     *
     * @param  UserStoreRequest  $request
     * @param int $organizationId
     * @return \\Illuminate\\Http\\Response
     */
    public function store(UserStoreRequest $request, int $organizationId)
    {
        $admin = Auth::user();
        if ($admin->role_id !== Admin::MASTER) {
            throw new AuthorizationException('権限がありません');
        }
        $validated = $request->validated();
        $organization = $this->organization->findOrFail($organizationId);

        $user = $organization->users()->create($validated);

        return $user;
    }
}

<?php

namespace Tests\Feature;

use App\Admin;
use Tests\TestCase;
use App\Organization;

class ExampleTest extends TestCase
{
    private Organization $organization;
    protected function setUp(): void
    {
        parent::setUp();

        $admin = factory(Admin::class)->create(['role_id' => Admin::MASTER]);
        $this->actingAs($admin, 'admin');
        $this->organization = factory(Organization::class)->create();
    }

    public function test_create_user_success()
    {
        $params = $this->params();

        $response = $this->postJson(route('admin.user-store', ['organization_id' => $this->organization->id]), $params);
        $response->assertStatus(201);

        $data = $response->json();
        $this->assertEquals($params['name'], $data['name']);
        $this->assertEquals($params['email'], $data['email']);
        $this->assertEquals($this->organization->id, $data['organization_id']);
    }

    private function params(): array
    {
        return [
            'name' => 'test',
            'email' => 'test@gmail.com',
            'password' => 'hogehoge12'
        ];
    }
}

  • 応用編
    • Reflectionを使用してのprivateメソッドに対してのテストの実装
    • dataProviderを使用してのテストの実装
    • 下記が実際に勉強会で使用したコードになります。
      • 目的がdataProviderの使い方とprivateメソッドに対してのテストの書き方なのでコードは複雑なものにはしておりません。

テスト対象のServiceの記述

<?php

namespace App\Services;

use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;

class SampleService
{
    const TYPE_NUMBER = [
        1 => 'first',
        2 => 'second',
        3 => 'third',
        4 => 'four'
    ];

    /**
     * [
     *   'isXxxx' => true / false
     *   'canXxx' => true / false
     * ]
     * @param array $params
     * @return array
     */
    public function getXxxx(array $params): array
    {
        if ($params['is_xxx']) {

            if ($params['can_xxx']) {
                return [
                    'message' => 'どちらもtrueです',
                    'can_edit' => true
                ];
            }

            return [
                'message' => 'is_xxxはtrueでcan_xxxはfalseです',
                'can_edit' => true
            ];
        }

        if ($params['can_xxx']) {
            return [
                'message' => 'is_xxxはfalseでcan_xxxはtrueです',
                'can_edit' => false
            ];
        }

        return [
            'message' => 'どちらもfalseです',
            'can_edit' => false
        ];
    }

    private function firstMethod(int $typeNumber)
    {
        if (!in_array($typeNumber, array_keys(self::TYPE_NUMBER), true)) {
            throw new BadRequestHttpException('正しい選択を行なってください');
        }

        return self::TYPE_NUMBER[$typeNumber];
    }
}

<?php

namespace Tests\Unit;

use ReflectionClass;
use App\Services\SampleService;
use Tests\TestCase;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;

class ExampleTest extends TestCase
{
    private $service;
    private $reflection;
    protected function setUp(): void
    {
        parent::setUp();
        $this->service = app()->make(SampleService::class);
        $this->reflection = new ReflectionClass($this->service);
    }

    /**
     * @dataProvider params
     * @param array $params
     * @param string $message
     * @param bool $canEdit
     */
    public function test_getXxx(array $params, string $message, bool $canEdit)
    {
        $result = $this->service->getXxxx($params);

        $this->assertEquals($message, $result['message']);
        $this->assertEquals($canEdit, $result['can_edit']);
    }

    public function params()
    {
        return [
            'どちらもtrue' => [
                [
                    'is_xxx' => true,
                    'can_xxx' => true
                ],
                'どちらもtrueです',
                true
            ],
            'canXxxはfalse' => [
                [
                    'is_xxx' => true,
                    'can_xxx' => false
                ],
                'isXxxはtrueでcanXxxはfalseです',
                true
            ],
            'canXxxはtrue' => [
                [
                    'is_xxx' => false,
                    'can_xxx' => true
                ],
                'isXxxはfalseでCanXxxはtrueです',
                false
            ],
            'どちらもfalse' => [
                [
                    'is_xxx' => false,
                    'can_xxx' => false
                ],
                'どちらもfalseです',
                false
            ]
        ];
    }

    public function test_first_method_success()
    {
        $method = $this->reflection->getMethod('firstMethod');
        $method->setAccessible(true);
        $result = $method->invoke($this->service, 1);
        $this->assertEquals(
            $this->service::TYPE_NUMBER[1],
            $result
        );
    }

    public function test_first_method_fail()
    {
        $this->expectException(BadRequestHttpException::class);
        $method = $this->reflection->getMethod('firstMethod');
        $method->setAccessible(true);
        $method->invoke($this->service, 5);
    }
}

勉強会のその後

  • 少しずつですがテストの書かれたPRが増えつつあるのかな?という所感です。
  • また私自身は、テスト警察ではないですがPRに対して「テストは書けないですか?」という問いを文化が根付くまで続けていく予定です。

今後

応用編で扱いきれなかったモックに関しての勉強会が開けていないのでやらないとなーと思っています。

また少しずつでもいいからテストに慣れてもらい、今よりもコードの質(仕様通り動くよねという意味で)を上げれていけたらなと思っています。

 

株式会社フォトラクションでは一緒に働く仲間を募集しています

自分のリバースSSHトンネルサーバーを立てましょう

背景

開発者として、間違いなくローカルで環境を立ち上げて開発するのは一番楽です。

ただし、localhost で開発するには限界がある。

  • localhost と言うドメインはブラウザとシステムに特別な扱いされている
    • もちろん hosts ファイルを更新したらある程度避ける問題ですが、メンテは面倒です。
  • SSL 証明書を発行出来ない
  • 簡単に外部からアクセス出来ない
  • 同じ LAN にあるデバイスでも簡単にアクセス出来ない

そういう時リバース SSH トンネルと言う裏技を使えばクリアできる!

ゴール

  • ローカルマシンで走っているサーバーを任意のドメイン名でアクセスできるように
  • SSL 接続が可能
  • SSL 証明書はLet’s Encryptで自動的に更新される
  • モバイル端末の設定(証明書、host files など)を変えず、自然にアクセスできる

リバース SSH トンネルとは?

基本的に、SSH トンネルの中に、逆方向の SSH トンネルを開くと言う事です。

クライアント(=ローカルマシン)がサーバーに接続して、更にサーバーのポートをクライアントに繋げるトンネルを作成する。

クライアントとサーバーの間にファイアウォールがあったとしても、サーバーと通してクライアントにアクセスできる。

オンラインサービス・ツール

待て待て待て、そういうのはすでに存在しているでしょう?

はい、おそらく一番人気のあるサービスは ngrok です。

ngrok は間違いなくとても便利で強力なツールです。

ngrok なら、カスタムドメインのため、有料プランが必要です。

ただし、最近の値上げの影響で(月$8から$20に)、諦めました。



じゃあ、他になにかあるのではないですか?実はたくさんあります。無料や低コストのオプションはいくつかがありますが、だいたい、自由にドメイン名を選べないまたは不安定であまり使えないサービスが多いです。

ソリューションにたどり着いた

SSHトンネルサーバー

これだ!

 

自分のサーバーにこのコードをデプロイすれば、

  • 自分の好みのドメインサブドメインを使用できる。
  • 自動的にLet'sEncryptでSSL証明証を発行してもらえる
    • ワイルドカード証明書で運用したいため、DNS バリデーションが必要
    • Route53プロバイダがあるから、インフラと簡単に連携できる
  • 特別なクライアントをインストールする必要がない
    • SSHのクライアントがあれば十分
  • 欲しければ、SSHの公開鍵でアクセス制限できる
  • 好きなサーバーでデプロイできるので、レイテンシに気にする必要がない

このトンネルサーバーはSSHサーバーですが、トンネル専用になっている、自分のポートでListenしている。

その他の材料

  • Docker
    • コンテナで走らせるのは一番楽
  • AWS
    • EC2でサーバーをホストする(もちろん Fargate などでも可能ですが、Fargate が使えるなら、自分でも改造できると思うから、この記事の対象外とさせていただく)。とりあえずサーバーで走るのが一番簡単
    • Route53(DNS)でドメイン名の確認を自動的に行うため

Dockerコンテナの準備

まずはEC2インスタンスにログインする。

ユーザーのホームで上記プロジェクトのコードをクローンする

git clone https://github.com/antoniomika/sish.git

これで sish フォルダができる。

~]$ ls -l
total 0
drwxrwxr-x 11 ec2-user ec2-user 317 Jun 16 07:20 sish

そのフォルダの中に入って、一旦新しいブランチで変更を行いましょう(野蛮人ではないから)

sish]$ git switch -c localconfig

そして、変えたいファイルは2つがある:

  • deploy/docker-compose.yml
  • deploy/le-config.yml

deploy/docker-compose.yml

デフォルト状態はこんな感じ

version: '3.7'

services:
  letsencrypt:
    image: adferrand/dnsrobocert:latest
    container_name: letsencrypt-dns
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./letsencrypt:/etc/letsencrypt
      - ./le-config.yml:/etc/dnsrobocert/config.yml
    restart: always
  sish:
    image: antoniomika/sish:latest
    container_name: sish
    depends_on: 
      - letsencrypt
    volumes:
      - ./letsencrypt:/etc/letsencrypt
      - ./pubkeys:/pubkeys
      - ./keys:/keys
      - ./ssl:/ssl
    command: |
      --ssh-address=:22
      --http-address=:80
      --https-address=:443
      --https=true
      --https-certificate-directory=/ssl
      --authentication-keys-directory=/pubkeys
      --private-keys-directory=/keys
      --bind-random-ports=false
      --bind-random-subdomains=false
      --domain=ssi.sh
    network_mode: host
    restart: always

自分の好みに変更しないといけない!

  1. 特定ポート番号( 2222 )で運用する。そうすれば、SSHとかぶらない
  2. ドメイン名を変える。仮に私の会社が totemohoge.comドメイン名を使ったら、サブドメインを自由に取れば困るだろう。よって、サブサブドメインでやる。このサーバーはtunnel.totemohoge.com になる。ユーザーが作成するトンネルはそのサブドメインの直下になる。例えば nasu.tunnel.totemohoge.com , kyuuri.tunnel.totemohoge.com などなど
  3. デフォルトタイムアウトは5秒でアップロードの時ちょっと厳しいと思うので、60秒まで上げる

パラメターはすべてcommandあたりで調整できる。詳細が必要だったら、プロジェクトのCLI Flagsセクションに参考してください。

するとdocker-compose.ymlはこうなる。

version: '3.7'

services:
  letsencrypt:
    image: adferrand/dnsrobocert:latest
    container_name: letsencrypt-dns
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./letsencrypt:/etc/letsencrypt
      - ./le-config.yml:/etc/dnsrobocert/config.yml
    restart: always
  sish:
    image: antoniomika/sish:latest
    container_name: sish
    depends_on: 
      - letsencrypt
    volumes:
      - ./letsencrypt:/etc/letsencrypt
      - ./pubkeys:/pubkeys
      - ./keys:/keys
      - ./ssl:/ssl
    command: |
      --ssh-address=:2222
      --http-address=:80
      --https-address=:443
      --https=true
      --https-certificate-directory=/ssl
      --authentication-keys-directory=/pubkeys
      --private-keys-directory=/keys
      --bind-random-ports=false
      --bind-random-subdomains=false
      --domain=tunnel.totemohoge.com
      --idle-connection-timeout 60s
      --authentication=true
    network_mode: host
    restart: always

deploy/le-config.yml

このファイルを使って、自動的にLet'sEncryptで証明書を発行してもらうようになる。

Let'sEncrypt で自動化するのはかなり便利で、とてもメンテしやすい。

ただし、ワイルドカード証明書を発行するため、DNS バリデーションをしないといけない。通常のHTTPバリデーションはドメイン毎に証明書を一つ発行する必要があって、かなり面倒で大変。

ワイルドカードだったら、一つで結構です。設定はちょっと大変ですけれど。

さて、やっちゃいましょう

デフォルトは以下の通りです。

acme:
  email_account: AUTH_EMAIL
certificates:
- autorestart:
  - containers:
    - sish
  domains:
  - ssi.sh
  - '*.ssi.sh'
  name: ssi.sh
  profile: cloudflare
profiles:
- name: cloudflare
  provider: cloudflare
  provider_options:
    auth_token: AUTH_TOKEN
    auth_username: AUTH_EMAIL

まずはメールアカウントを書かないといけない。LetsEncrypt から知らせが来るので、ちゃんと存在しているアドレスを使ってください(インフラ部のメーリングリストなど)。

次は証明書のドメイン一覧です。今回はトンネルサーバーのドメイン名とそのワイルドカードサブドメインが欲しいので、

  • tunnel.totemohoge.com
  • *.tunnel.totemohoge.com

になる。

name 属性は管理用の名前なので、分かりやすくするため、メインドメイン名にする

最後にプロファイル(=プロバイダー)の設定です。あれはプロバイダー毎設定は微妙に変わるので、Route53意外だったら、正しいパラメターを調べてください

Route53の場合は、プロバイダー名は route53 で、

  • auth_access_key
  • auth_access_secret
  • private_zone falseで固定(公開するから)
  • zone_id プロバイダーが変更できるDNSゾーンのID(セキュリティのため、専用ゾーンで運用する)

AWS の IAM で専用ユーザーを作って auth_access_keyauth_access_secret を発行して、取得できる。

AWSの設定

Route53 でホストゾーン作成して、一覧画面に戻れば、こんな感じです。

上記の zone_idは一番右のカラムの値です。ホストゾーンの詳細画面からでも取れる。

まずはEC2インスタンスへのDNSレコードを登録しないといけない。

tunnel.totemohoge.com ゾーンのなかでゾーンのトップレベルのAレコードを作り、EC2インスタンスのエラスティックIPを登録する。

そして、メインゾーンに参考レコードを作らないといけない。

tunnel.totemohoge.comのNSレコードを作って、ゾーン詳細画面からネームスペースサーバーをコピペする。

そして、IAM ユーザーを作成しましょう。APIのみのユーザー(Webコンソールアクセス不要)で、ポリシーはたった一つでいいです。 <ZONE_ID> だけを書き直してください。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "route53:ListHostedZones",
                "route53:GetChange",
                "route53:GetHostedZone",
                "route53:ListResourceRecordSets"
            ],
            "Resource": [
                "*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "route53:ChangeResourceRecordSets"
            ],
            "Resource": [
                "arn:aws:route53:::hostedzone/<ZONE_ID>"
            ]
        }
    ]
}

このポリシーのおかげでユーザーがこの特定ゾーンのレコードを自由に作成、編集、削除できる。

作成したユーザーのキーをメモって、 deploy/le-deploy.yml ファイルに貼ってください。

acme:
  email_account: naoto.date@totemohoge.com
certificates:
- autorestart:
  - containers:
    - sish
  domains:
  - tunnel.totemohoge.com
  - '*.tunnel.totemohoge.com'
  name: tunnel.totemohoge.com
  profile: route53
profiles:
- name: route53
  provider: route53
  provider_options:
    auth_token: <AUTH_ACCESS_KEY>
    auth_username: <AUTH_ACCESS_SECRET>
    private_zone: false
    zone_id: Z096534621PU0FUIVLDB2

そして、リンクをつくらなければならない:

sish]$ sudo ln -s /etc/letsencrypt/live/<ドメイン名>/fullchain.pem deploy/ssl/<ドメイン名>.crt
sish]$ sudo ln -s /etc/letsencrypt/live/<ドメイン名>/privkey.pem deploy/ssl/<ドメイン名>.key

ドメイン名は証明書の第一ドメイン名となるので、上記の設定だったら、 tunnel.totemohoge.com になるはずです。

準備が出来た

では実行する時です!

docker compose で管理されているので、かなり使いやすいです。

初期化の際に様子を見ていた方が良さそうです

deploy]$ docker-compose up

一番重要はLet’sEncryptとの連携です。最初はちょっと時間がかかることがあるので、心配しないで、結果を待ってください。

エラーが起きたら、まぁ・・・頑張ってくださいとしか言えない!エラーメッセージは結構丁寧で、原因が楽に特定できる。

特に問題がなければ、おめでとうございます。

Ctrl+Cで終了して、もう一度バックグランドで実行してください。

deploy]$ docker-compose up -d

これでサーバーが自動的に走るし、 letsencrypt コンテナのおかげで自動的に証明証を更新する。 restartalways に設定されているので、インスタンスを停止し再起動すれば、コンテナも自動的に立ち上がる。

公開鍵登録

ユーザーのアクセスを制限するために公開鍵サーバーに登録する必要がある。

だれでも勝手にSSHトンネルを登録できると困る!

やり方は非常に簡単です。

クライアントマシンの公開鍵を ~/sish/deploy/pubkeysにコピーすれば完了です。

基本的に通常のSSHのauthorized_keysファイル形式です。

認証なしで運用

deploy/docker-compose.yml ファイルを更新する必要がある。

      --authentication=true
を
      --authentication=false

にするだけで、公開鍵チェックをスキップできる。

またはパスワード認証も対応です。個人的に好きではないが・・・

では使ってみよう!

自分のマシン簡単なWebサーバー(例:ポート8080)を起動してから、次のコマンドを実行する

~]$ ssh -p <トンネルサーバーポート> <サブドメイン名>:80:localhost:<ローカルサーバーポート> <トンネルサーバー名>

今回の設定によると下記のようなコマンドです

~]$ ssh -p 2222 datenaoto:80:localhost:8080 tunnel.totemohoge.com

SSHクライアントのバージョンによって、エラーが発生する場合がある。

sign_and_send_pubkey: no mutual signature supported

その時、そのドメイン用のSSH config (通常 ~/.ssh/config)に下記の行を追加してください

Host tunnel.totemohoge.com
PubkeyAcceptedKeyTypes=+ssh-rsa

成功したらこういうメッセージが表示される:

~]$ ssh -p 2222 datenaoto:80:localhost:8080 tunnel.totemohoge.com
Press Ctrl-C to close the session.

Starting SSH Forwarding service for http:8080. Forwarded connections can be accessed via the following methods:
HTTP: http://datenaoto.tunnel.totemohoge.com
HTTPS: https://datenaoto.tunnel.totemohoge.com

ここまでたどり着いたら、どんなブラウザーでも開ける。

Ctrl+Cでトンネルを閉じれば、外部からアクセスできなくなる。

かなり便利なツールです!

最後に

sish はもっと色々できるアプリケーションです。HTTP・HTTPSのトンネルだけではなく、どんな TCP ポートも対応できる。一つのドメインを管理するだけではなく、どんなドメインでも使えるようにもできる。本当に素晴らしいツールです。詳しくは Github ページに参考してください。

今のところは数ヶ月続けて使っていて、特に問題がなかったです。

ただ、そのトンネルの存在を忘れないでください。トンネルサーバーもタイムアウトがあるので、自分に合わせたユースケースタイムアウトを設定してください。

sishプロジェクトが役に立てたら、開発者にビール一杯奢っていいと思います。🍺

 

株式会社フォトラクションでは一緒に働く仲間を募集しています

Pythonコードをドキュメント化した

はじめに

こんにちは、エンジニアの酒井です。

最近、チームで頻繁に使う関数、処理を共通ライブラリとして集約し、そのドキュメントを作成する業務に取り組みました。

調べたところPython製のドキュメントツールであるSphinxを使うと、ソースコードに記載したdocstringから自動でドキュメントを作成できるとのことなので、実際に使用してみました。

手順

ディレクトリ構成は以下のようになっており、sampleディレクトリ内にモジュールを一つ追加しています。

.
├── Dockerfile
├── docker-compose.yml
└── sample
    ├── __init__.py
    └── sample_1.py

1. Dockerfile, docker-compose.ymlの作成

FROM python:3.8

ENV APP_PATH=/code \\
    PYTHONPATH=.

WORKDIR $APP_PATH

RUN apt-get update && \\
    apt-get upgrade -y && \\
    pip install poetry

# `poetry init`をしたいので最初はコメントアウトする
# COPY poetry.lock pyproject.toml ./
# RUN poetry install

COPY . .

EXPOSE 8080
version: '3.8'

services:
  sample:
    container_name: sample
    build:
      context: .
      dockerfile: ./Dockerfile
    ports:
      - "8080:8080"
    tty: true
    volumes:
      - .:/code/
      - ${PIP_CACHE_DIR:-cache-sample}:/root/.cache

volumes:
  cache-sample:

2. poetryの導入、必要ライブラリのインストール

2-1. コンテナ上でbashを起動

$ docker-compose up -d --build
$ docker-compose exec sample bash

2-2. poetryの導入

# poetry init

2-3. 必要ライブラリをインストール

# poetry add sphinx sphinx_rtd_theme
  • sphinxsphinx_rtd_themeをインストールします。
  • pyproject.tomlに上記のライブラリが追記され、poetry.lock が生成されます。

3. ドキュメント作成

3-1. sphinx-apidocコマンドでドキュメント作成に必要なファイルを作成

# poetry run sphinx-apidoc -F -H sample -o docs sample
  • 指定したディレクトリは以下(今回はdocs)にファイルが生成されます。

3-2. 作成された conf.pyの編集

  • conf.pyでは大きく分けて、以下4つの設定が可能。

    1. Path setup(パスの設定)
    2. Project information(プロジェクト情報の設定)
    3. General configuration(一般的な設定)
    4. Options for HTML output(HTML出力に関するオプション)
  • 今回は以下の3つを変更する

    1. pathの指定
    2. 拡張モジュールの追加
      1. sphinx.ext.autodoc
      2. sphinx.ext.viewcode
      3. sphinx.ext.todo
      4. sphinx.ext.napoleon
      5. sphinx_rtd_theme
    3. デザインの設定(sphinx_rtd_theme
    # Configuration file for the Sphinx documentation builder.
    #
    # This file only contains a selection of the most common options. For a full
    # list see the documentation:
    # <https://www.sphinx-doc.org/en/master/usage/configuration.html>
    
    # -- Path setup --------------------------------------------------------------
    
    # If extensions (or modules to document with autodoc) are in another directory,
    # add these directories to sys.path here. If the directory is relative to the
    # documentation root, use os.path.abspath to make it absolute, like shown here.
    #
    
    import sphinx_rtd_theme # sphinx_rtd_themeのインポート
    
    import os # コメントアウトを外す
    import sys # コメントアウトを外す
    
    sys.path.insert(0, os.path.abspath('../')) # パスの指定(conf.pyから見てルートディレクトリを指定する)
    
    # -- Project information -----------------------------------------------------
    
    project = 'sample'
    copyright = '2022, sample'
    author = 'sample'
    
    # -- General configuration ---------------------------------------------------
    
    # Add any Sphinx extension module names here, as strings. They can be
    # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
    # ones.
    extensions = [
        'sphinx.ext.autodoc', # 追加
        'sphinx.ext.viewcode', # 追加
        'sphinx.ext.todo', # 追加
        'sphinx.ext.napoleon', # 追加
        'sphinx_rtd_theme' # 追加
    ]
    
    # Add any paths that contain templates here, relative to this directory.
    templates_path = ['_templates']
    
    # List of patterns, relative to source directory, that match files and
    # directories to ignore when looking for source files.
    # This pattern also affects html_static_path and html_extra_path.
    exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
    
    # -- Options for HTML output -------------------------------------------------
    
    # The theme to use for HTML and HTML Help pages.  See the documentation for
    # a list of builtin themes.
    #
    html_theme = 'sphinx_rtd_theme' # 編集
    html_theme_path = ["_themes", sphinx_rtd_theme.get_html_theme_path()] # 追加
    
    # Add any paths that contain custom static files (such as style sheets) here,
    # relative to this directory. They are copied after the builtin static files,
    # so a file named "default.css" will overwrite the builtin "default.css".
    html_static_path = ['_static']
    

3-2. HTMLファイルをビルドする

$ poetry run sphinx-build docs docs/_build

4. 動作確認

docs/_buildディレクトリに作成されたindex.htmlを確認すると、以下のようなページが出来上がっているのことが確認できる。

  • トップページ
  • モジュールのドキュメント例

5. 公開

これらをGitHubPagesやS3に配置し公開することでSphinxを用いたドキュメントが閲覧することができます。

※ 実際にはGitHub Actionsを用いて docs/_build 配下のファイルをS3にアップロードしてそれをCloudFront経由で配信するという形を取りました。こうすることでソースコードを変更するたびにドキュメントが更新され、運用がより楽になります。

次回記事を書く機会がありましたらまとめたいと思います。

さいごに

ドキュメントの作成をSphinxに任せることで共通ライブラリの管理、運用の手間が省けると感じました!

今後も業務の中で人が行う必要がない部分は積極的に自動化して、自分を含めチームメンバーが本質的な部分に力を注げるよう行動していきたいなと思います!

 

株式会社フォトラクションでは一緒に働く仲間を募集しています

エレガントなIoUの計算方法

はじめに

こんにちは、AIエンジニアの志賀です。

最近はとっても暑い毎日が続いていますね。つい1ヶ月前と比べるとこんな暑さは信じられないですね。冬には「極寒より猛暑の方がマシ」と考えるのですが、やはりいざ夏になると「猛暑より極寒の方がマシ」と考えてしまいます。

最近、行動経済学に関連した本を読んだりするのですが、これもアンカリング効果とやらの所業なのでしょうか?

さて、今回はIoUの様々な計算方法の中でも、numpyのclip関数を使用したIoUの計算方法を紹介していきたいと思います。個人的には、最もスマートでエレガントだなあ。と思った計算方法だったので、この計算方法を紹介する次第です。

おさらい

IoUとは

IoUとは、Intersection over Unionの略で、2つの領域の重なり具合を示す指標となっております。

具体的な計算方法は、仮にAとBの領域があった場合に、AかつB / AまたはBとなります。

より詳細を知りたい方はこちらの記事を参照すると分かりやすいです。

 

参考図:

intersection = オレンジ部分

union = オレンジ部分 + 青部分



numpyのclip関数を使ったIoUの計算方法

numpyのclip関数とは

numpyのclip関数は、入力されたnumpy配列の各要素を指定された任意の値の範囲に収める加工を行う関数です。clip関数の第一引数にnumpy配列、第二, 第三引数にそれぞれ最小値、最大値を入力します。

以下、使用例になります。

x = np.arange(10)
print(x)
# [0 1 2 3 4 5 6 7 8 9]

print(np.clip(x, 3, 6))
# [3 3 3 3 4 5 6 6 6 6]

numpyのclip関数を使ってどのようにIoUを計算するか

clip関数は、IoUの計算の中でも、分子であるintersectionの計算に使用します。

具体的には、x, y座標のそれぞれにおいて、片方のbox1の最小値最大値を使って、もう片方のbox2に対してnp.clipを適用します。

このようにする事で、box1とbox2が重なる部分、つまりintersection領域のみが残るという具合です。

例えば、box1 = [0, 0, 10, 10], box2 = [5, 5, 13, 13]だった場合、

box1の最小値、最大値を使用したclip関数をbox2に適用してあげると次のようになります。

box1 = np.array([0, 0, 10, 10])
box2 = np.array([5, 5, 13, 13])
x_min, y_min, x_max, y_max = box1
# x軸方向にclip関数を適用
box2[0::2] = np.clip(box2[0::2], x_min, x_max)
print(box2)
# [ 5  5 10 13]

# y軸方向にclip関数を適用
box2[1::2] = np.clip(box2[0::2], y_min, y_max)
# intersectionの領域を獲得
print(box2)
# [ 5  5 10 10] (= intersectionの領域)

あとは intersectionの領域の面積(=intersection)の計算及びunionを計算してあげれば、IoUが求まります。

unionは、box1の面積 + box2の面積 - intersectionによって求まります。

実際のコード

以下実際のコードです。

より利便性を追求する為に、1つの領域(box)と他の複数の領域(other_boxes)のIoUsをまとめて計算します。

def calc_ious(box, other_boxes):
		eps=1e-5
    other_boxes = np.array(other_boxes).reshape(-1, 4)
		#intersectionsの計算
    other_boxes_cpy = np.copy(other_boxes)
    np.clip(other_boxes_cpy[:, 0::2], box[0], box[2], out=other_boxes_cpy[:, 0::2])
    np.clip(other_boxes_cpy[:, 1::2], box[1], box[3], out=other_boxes_cpy[:, 1::2])
    inters = (other_boxes_cpy[:, 2] - other_boxes_cpy[:, 0]) * (other_boxes_cpy[:, 3] - other_boxes_cpy[:, 1])
		#unionsの計算
		box_area = (box[2] - box[0]) * (box[3] - box[1])
    other_boxes_areas = (other_boxes[:, 2] - other_boxes[:, 0]) * (other_boxes[:, 3] - other_boxes[:, 1])
    unions = other_boxes_areas + box_area - inters
		#iousの計算
    ious = inters / (unions + eps)
    ious = ious.tolist()
    return ious

box = [5, 5, 12, 12]
other_boxes = [[5, 5, 13, 13], [10, 10, 30, 30]]
ious = calc_ious(box, other_boxes)
print(ious)
# [0.7656248803711124, 0.008988763842949127]

感想

個人的には、この計算方法を知った時はスパイファミリーのヘンダーソン先生並みに「エレガント!」と感動したのですが、如何だったでしょうか。中にはそんなの最初から知ってるよという方もいるかもしれませんが、温かい目で見て頂けると嬉しいです。笑

 

株式会社フォトラクションでは一緒に働く仲間を募集しています