Briswell Tech Blog

ブリスウェルのテックブログです

WebRTCを使ってビデオ通話アプリを作ってみました

皆さん、こんにちは! 最近は天気が暖かくなってきましたね。もうすぐ花見の時期になってきて楽しみですね。

皆さん、仕事をする時はビデオ通話のサービスをよく使っていますか?Google MeetsかZoomか、色んなビデオ通話のサービスがないと仕事に困りますね。 自分の方はこれらのサービスはどうすれば作れるかちょっと気になったので、調べて作ってみました。 新しい技術ではないですが、WebRTCという技術が存在します。 WebRTCを使ったら、誰でもビデオ通話アプリを作れます。

WebRTC(英語: Web Real-Time Communication)は、ウェブブラウザやモバイルアプリケーションにシンプルなAPI経由でリアルタイム通信(英語: real-time communication; RTC)を提供する自由かつオープンソースのプロジェクトである。ウェブページ内で直接ピア・ツー・ピア通信を行うことによって、プラグインのインストールやネイティブアプリのダウンロードを行わなくても、ウェブブラウザ間のボイスチャットビデオチャット、ファイル共有などを実装できるようになる。

wikipediaによる


アプリの概要

  • ビデオ通話の時、ビデオORオーディオの通信を自由に切れます。
  • 画面共有はできます。

使う技術:WebRTC + socket.io

処理のフロー

処理のフローはこの下のフローチャートを参考してください。

flowchart
処理のフローチャート

一部のサンプルのソースコード

クライアント側のソースコード

  • navigator.mediaDevices.getUserMediaで自分のPCのビデオとオーディオのデータストリームを取得する:
const localStream = ref(undefined);
...
localStream.value = await navigator.mediaDevices.getUserMedia({
    video: true,
    audio: true,
  });
  • RTCPeerConnectionを作成して、オファーを送る:
// 後の処理に必要
const pcArr: { toSocketId: string; pc: RTCPeerConnection }[] = [];
...
const pc = new RTCPeerConnectionRTCPeerConnection({
  iceServers: [
    { urls: 'stun:stun.services.mozilla.com' },
    { urls: 'stun:stun.l.google.com:19302' },
  ],
});
pcArr.push({ toSocketId: user.socketId, pc });

// ビデオを表示するため
const remoteVideoData = reactive({
  socketId: user.socketId,
  mediaStream: undefined as MediaStream | undefined,
  userName: user.name,
});
if (!remoteVideo.value.find((video) => video.socketId === user.socketId)) {
  remoteVideo.value.push(remoteVideoData);
}

pc.ontrack = (event) => {
  if (
    remoteVideoData.mediaStream === undefined ||
    remoteVideoData.mediaStream.id === event.streams[0].id
  ) {
    // ウェブカムからのビデオ
    remoteVideoData.mediaStream = event.streams[0];
  } else {
    // 画面共有からのビデオ
    screenSharingActive.value = true;
    screenSharingStream.value = event.streams[0];
    screenSharingVideo.value!.srcObject = screenSharingStream.value;
  }
};

localStream.value!.getTracks().forEach((track) => {
  pc.addTrack(track, localStream.value!);
});

const offer = await pc.createOffer();
await pc.setLocalDescription(offer);

io.emit('room.offer', user.socketId, pc.localDescription, userName.value);

pc.onicecandidate = (event) => {
  if (event.candidate) {
    io.emit('room.candidate', user.socketId, event.candidate);
  }
};
  • オファー情報を保存する、アンサー情報を作って送る:
io.on('room.offer', async (socketId: string, offer: RTCSessionDescriptionInit, name: string) => {
  const pc = new RTCPeerConnection({
    iceServers: [
      { urls: 'stun:stun.services.mozilla.com' },
      { urls: 'stun:stun.l.google.com:19302' },
    ],
  });

  localStream.value!.getTracks().forEach((track) => {
    pc.addTrack(track, localStream.value!);
  });

  const remoteVideoData = reactive({
    socketId,
    mediaStream: undefined as MediaStream | undefined,
    userName: name,
  });
  if (!remoteVideo.value.find((video) => video.socketId === socketId)) {
    remoteVideo.value.push(remoteVideoData);
  }

  pc.ontrack = (event) => {
    if (
      remoteVideoData.mediaStream === undefined ||
      remoteVideoData.mediaStream.id === event.streams[0].id
    ) {
      // ウェブカムからのビデオ
      remoteVideoData.mediaStream = event.streams[0];
    } else {
      // 画面共有からのビデオ
      screenSharingActive.value = true;
      screenSharingStream.value = event.streams[0];
      screenSharingVideo.value!.srcObject = screenSharingStream.value;
    }
  };

  await pc.setRemoteDescription(new RTCSessionDescription(offer));
  pcArr.push({ toSocketId: socketId, pc });

  const answer = await pc.createAnswer();
  await pc.setLocalDescription(answer);

  io.emit('room.answer', socketId, pc.localDescription);

  pc.onicecandidate = (event) => {
    if (event.candidate) {
      io.emit('room.candidate', socketId, event.candidate);
    }
  };
});
  • オファーとアンサー共通の情報を保存する:
io.on('room.candidate', async (socketId: string, candidate: RTCIceCandidate) => {
  const pc = pcArr.find((pc) => pc.toSocketId === socketId)?.pc;
  if (pc) {
    await pc.addIceCandidate(new RTCIceCandidate(candidate));
  } else {
    alert('RTC not found!');
  }
});
  • アンサーを保存する:
io.on('room.answer', async (socketId: string, answer: RTCSessionDescription) => {
  const pc = pcArr.find((pc) => pc.toSocketId === socketId)?.pc;
  if (pc) {
    await pc.setRemoteDescription(new RTCSessionDescription(answer));
  } else {
    alert('RTC not found!');
  }
});
  • 画面共有:
const screenSharingVideo = ref(null);
const screenSharingStream = ref(undefined);
...
screenSharingStream.value = await navigator.mediaDevices.getDisplayMedia({
  video: { cursor: 'always' } as any,
  audio: false,
});
screenSharingVideo.value!.srcObject = screenSharingStream.value!;
screenSharingStream.value.getVideoTracks()[0].onended = () => {
  stopScreenSharing();
};

for (const pcData of pcArr) {
  screenSharingStream.value.getTracks().forEach((track) => {
    pcData.pc.addTrack(track, screenSharingStream.value!);
  });

  // 再びオファー送る
  const offer = await pcData.pc.createOffer();
  await pcData.pc.setLocalDescription(offer);
  io.emit('room.reoffer', pcData.toSocketId, offer);
}
  • 画面共有のオファーを保存する、新しいアンサーを作って送る:
io.on('room.reoffer', async (socketId: string, offer: RTCSessionDescription) => {
  const pc = pcArr.find((pc) => pc.toSocketId === socketId)?.pc;
  if (pc) {
    await pc.setRemoteDescription(new RTCSessionDescription(offer));
    const answer = await pc.createAnswer();
    await pc.setLocalDescription(answer);
    io.emit('room.answer', socketId, pc.localDescription);
  } else {
    alert('RTC not found!');
  }
});
  • ビデオORオーディオ通信を切る
const toogleVideo = () => {
  const videoTracks = localStream.value.getVideoTracks();
  videoTracks?.forEach((track) => {
    track.enabled = false;
  });
};

const toogleAudio = () => {
  const audioTracks = localStream.value.getAudioTracks();
  audioTracks?.forEach((track) => {
    track.enabled = false;
  });
};

バックエンド側のソースコード

socket.on('room.candidate', (userSocketId: string, candidate: RTCIceCandidate) => {
  socket.to(userSocketId).emit('room.candidate', socket.id, candidate);
});

socket.on('room.offer', (userSocketId: string, offer: RTCSessionDescription, name: string) => {
  socket.to(userSocketId).emit('room.offer', socket.id, offer, name);
});

// 画面共有で使う
socket.on('room.reoffer', (userSocketId: string, offer: RTCSessionDescription) => {
  socket.to(userSocketId).emit('room.reoffer', socket.id, offer);
});

socket.on('room.answer', (userSocketId: string, answer: RTCSessionDescription) => {
  socket.to(userSocketId).emit('room.answer', socket.id, answer);
});

今日の記事はここまでです。 何か質問があれば、遠慮なくコメント欄に投稿してください。

CIからAWSにOIDCの方法でデプロイしてみた

みな様こんにちは。
私はBriswellのSonと申します。

最近、CIでAWSにOIDC方法でデプロイしてみました。

最初に私が作成した図を見ましょう!

添付した画像を参照したら、大体な処理がわかると思いますが、詳しい流れを書きます。

初めに

普通にAWSソースコードをデプロイするために、aws_access_key_idaws_secret_access_keyが必須な情報じゃないでしょうか。 しかし、OIDC方法を利用する際には、上記の2つの情報が先に設定する必要がない、その情報をAWS側で提供します。

基本的に流れは下記の通りです。

I. idPでTokenの発行

最初に、Buildは、CI idPへTokenの作成の命令を送ります。CI idPでもらったパラメーターとPrivateキーを利用し、Tokenを作成します。

Tokenの内容については、例えば:AWS側で案件のIdとユーザのIdで確認したい場合、Buildがパラメーターを渡す情報をTokenに入れる必要があります。

作成が完了した後にBuildに返却します。

II. AWSへTokenの送信

CI idPにTokenを作成してもらったら、BuildでAWS cliassume-role-with-web-identityの関数でrole-arnweb-identity-tokenAWS送信します。送信の目的としてはAWSサービスにアクセスする時に使うためのAccessKeyIdSecretAccessKeySessionTokenを3つ取得したいからです。

 - role-arnは、AWSでロールを作成しておきました。例えば:arn:aws:iam::xxxxxxxxxxx:role/Test-Role-deploy-dev
 - web-identity-tokenは、CI idPでTokenを発行したばかりです。

assume-role-with-web-identityのパラメーターの中にrole-arnweb-identity-tokenの以外、他のパラメーターもあります。
例えば:duration-seconds, role-session-name

III. AWSでIAMの認証

AWS側では、IAMがもらったrole-arnで「ID プロバイダ」を参照します。

上の画像はサンプルのロールです。

上の画像はサンプルの「ID プロバイダ」です。

設定したプロバイダの連絡情報でCI idPに連絡して、Publicのキーを取得します。連絡先は「プロバイダのURL + '.well-known/openid-configuration'」の形です。

Publicのキーを取得したら、IAMがTokenをパラメーターに変換します。

その後に、パラメーターとロールの設定した「信頼されたエンティティ」の比較を行います。ロールにしている場合、一時的な情報(AccessKeyId、SecretAccessKey、SessionToken)を発行し、Buildに返却します。もらったデータが指定していない場合、エラーを返却する。

IV. BuildでAWS認証の設定

最後にBuild側ではAWS認証情報をもらったら、「aws configure」のコマンドで保持します。

AWSにデプロイやアクセスなどするために、この情報を使います。この情報は一時的な情報なので、Buildが完了したら、このキーがなくなり、CI側に保存しないです。

最後に

上記は、CIからAWSにOIDCの方法でデプロイに関する基本的な共有です。
拙い文章でしたが、最後まで読んでいただきありがとうございました。
疑問があれば、コメントをしてください。

よろしくお願いします。

PHPのバージョンアップ影響調査(PHP7.4 to 8.2)

はじめに

PHP案件のバージョンアップを行う機会がありましたので、進め方を紹介します。 表題の通り、PHP7.4から8.2へのアップデートでした。

バージョンアップに向けた心構え

システムの規模に比例してバージョンアップのインパクトも大きくなっていきます。

バージョンアップを行うことで今まで動いていたコードが動かなくなることがあり、この動かなくなる部分 = バージョンアップ向けの改修 がどの程度発生するかの規模感を知るところから始めます。

(闇雲にバージョンアップ作業から始めてもスケジュールを立てられず、苦しいです)

大体は何かしら動かなくなる部分が出てきてしまい、バージョンアップ用の修正が必要となります。

バージョンアップ時の注意点を以下にまとめます。

  • 関数の振る舞いが変わる、廃止される等、互換性がなくなりソースの修正が必要になることがある
    • オフィシャルの変更点一覧など、一通り確認しておくと良い
    • (複数回のメジャーバージョンアップが必要な場合、バージョンアップよりも最新バージョンをベースに作り直したほうがトータルで安くつくこともある)
  • ライブラリもPHPの新しいバージョンに合わせて更新
    • 依存関係の問題で使えなくなる場合もある
    • ライブラリの更新が止まっており、PHPの新しいバージョンに追従できないこともある
  • バージョンアップ作業後は一通りの機能に問題がないか、実際に動かして検証する必要がある
    • 目に見える形でエラーは出ていないがバリデーションのかかり方が以前と異なる、のように実際に動かしてみないとわからないケースもある

また、日々進んでいる改修作業を止められない場合、本流の環境とは別にバージョンアップ環境を準備し、平行で進めていくとよいケースもあります。

  • STEP1:バージョンアップの検証環境にて、バージョンアップ作業を行いとりあえずで動くところまでを目指す
  • STEP2:バージョンアップの検証環境にて、入念に検証を行う
  • STEP2.5:本流の改修で出た差分を取り込む
  • STEP3:2~2.5でエラーや問題が出れば修正する
  • STEP4:2~3を繰り返し、安定した頃合いで本番へリリース
  • ※改修は止まっている、または本番を一定期間止めてもOKということであれば、直接本流の環境で作業を行うほうが対応コストは低いです

調査方法

ソースコードに修正が必要な箇所がどれだけ存在するか確認を行っていきますが、目検でソースコードを一つずつチェックしていくのはしんどいです。

PHPにはバージョンアップ時にも使える便利な静的解析ツールが存在しています。

静的解析ツールだけで100%の検証が保証されるというものではなく、あくまでも最低限確実に変更が必要な箇所を見つけてくれる程度であり、実際に動かしての検証は必ず行うものと考えておくと良いです。

機械的に必要箇所を見つけてくれるだけでも作業はかなり楽になるものです。

今回はPHP8向けの以下のツールを使用させていただきました。 (製作者様、大変感謝🙏 )

odan.github.io

コーディング規約をチェックをするphp_codesnifferをベースに、PHP8の規約に違反していないか、というルールを追加するphp-compatibilityとの組み合わせになります。 インストール手順はリンク先のページを参照ください。

実行例

PHP 8 Compatibility Check
実行してみると上記のように変更が必要な箇所を知らせてくれます。 ちなみに、今回のケースではERRORが230件、WARNが228件の結果です。...なかなかの件数が出てしまっていますね。

ERRORは互換性がなくなる等必ず修正しなくてはいけないもの、WARNは今後非推奨の予定が立っている等どうせなら一緒に修正したほうが良いもの、と理解しています。もしかしたらWARNでも修正しないと動かないというものがあるのかもしれません。

終わりに

今回はPHPのバージョンを8系へアップデートする際にphp_codesniffer+php-compatibilityの組み合わせで静的解析を行う話をまとめさせていただきました。

実行結果が出るとどの程度コードに影響が出るかを知れるだけではなく、実際には修正して再実行してというサイクルをERROR/WARNが0件になるまで繰り返していくという流れになります。

適切なツールを使用することで雪かきのような作業の負荷を軽減し、本来注力したい作業にリソースを向けていけると良いですね。

インボイス制度による影響と対応について

今回は去年から開始されたインボイス制度による影響と対応について投稿しようと思います。
2023年10月1日から制度が開始されましたが、
弊社では保守を行わせていただいている案件のシステムが多数ありますが、一部システムでこの制度の影響を受けることがありました。

インボイス制度ってなんだっけ...影響がある部分ってどこなんだろう...
と度々思うこともあるので備忘録として記載しておきます。

インボイス制度とは?

国税局のHPインボイス制度についてまとめた記事などを参考にしてみたところ

今回の制度で正確な消費税率や消費税額などを明示したインボイス(適格請求書)が必要になりました。

具体的には現行の請求書に登録番号、複数税率に対応した消費税率、消費税額の記載が必要になります。

このインボイス(適格請求書)がないと仕入税額控除ができなくなります。

仕入税額控除とは?

仕入税額控除ってなんなんだ...

と思って調べてみると
消費税を算出する際に売上の消費税額から仕入の消費税額を差し引くことを言うそうです。

仕入税額控除 = 売上の消費税額 - 仕入の消費税額

文字だけだといまいちピンとこないので図で表してみました

仕入税額控除の図解

仕入の消費税額が控除されないのは厳しいのでインボイス制度への対応は必須と言えそうです。。。

今回の制度によるシステムの影響

弊社では受発注管理システムとしての機能を備えたクラウドERPパッケージ「アイカタ」を開発しております。
今回の制度によって「アイカタ」インボイス制度を満たすような改修を行なっております。
「アイカタ」を参考に影響の具体例をまとめてみました。

・売上請求画面
複数税率に対応した消費税率、消費税額の表示、システム構成の変更

・請求書
売上請求画面に準じた表示の変更、適格事業者番号の追加

上記対応で「アイカタ」インボイス制度の要件を満たすシステムとなりました。

下記リンクからお問い合わせ頂くことで
「アイカタ」機能をお見せしてのご説明、インボイス制度に対するシステムのお悩み相談が可能です。
よろしければお気軽にお問い合わせください。
ai-cata.com

ブリスウェルのAI技術・サービス紹介 〜 画像分類・物体検出・表情認識・生成AIなど

(ブリスウェルでは現時点では画像生成AIのサービス化はしていません・・・)

ブリスウェルでは、AIを活用して様々なサービスを提供しています。 AIのみでも利用できますし、業務システムやモバイルアプリなどと連携することも可能です。

それでは紹介していきましょう。

続きを読む

ローカル環境で画面にQuickSightを使用するダッシュボードを埋め込んでみる。

どうもこんにちは。ブリスウェルのSonです。
最近、ウェブサイトでダッシュボードを埋め込むために、QuickSightをちょっと調査してました。忘れないように、基本的な手順をメモしておきます。
この記事は技術的な内容なので、QuickSightサービスの料金に関する問題を無視します。

言語: PHP (v 7.4)
必要なライブラリ: AWS SDK for PHP (v 3.x)

準備:CSV内容

tenant city itemtype price
tenant1 city1 item1 100
tenant1 city1 item2 200
tenant1 city2 item1 400
tenant1 city2 item2 500
tenant1 city3 item1 700
tenant1 city3 item2 800
tenant2 city1 item1 100
tenant2 city1 item2 200
tenant2 city2 item1 400
tenant2 city2 item2 500
tenant2 city3 item1 700
tenant2 city3 item2 800

依頼内容は
ダッシュボードを画面に埋めこむ
・特定のユーザーはtenant=tenant2の行のみ閲覧できるように

datasetについて
上のCSVをインポートして、データセットを作成します。
データセットの作成をしたら、RLS モードを設定する必要があります (行レベルのセキュリティ)
特定のユーザーのみを表示するので、列を「tanent」に設定する必要があり、タグ列を「hogetalent」のような値を設定できます。

データセットからダッシュボードの作成してみます。ダッシュボードを作成したら、こんな状態になります。 RLSを設定しましたので、自分のアカウントでも細かいダッシュボードが見えないです。

IAMのroleの設定について
ダッシュボードを表示するには、SDKにある「GenerateEmbedUrlForAnonymousUser」を実装する必要があります。
ただし、「GenerateEmbedUrlForAnonymousUser」が実行できるために、次のようなユーザーにロールを与える必要があります。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "quicksight:GenerateEmbedUrlForAnonymousUser"
            ],
            "Resource": [
                "arn:aws:quicksight:{region}:{account_id}:dashboard/*"
            ],
            "Condition": {
                "ForAllValues:StringEquals": {
                    "quicksight:AllowedEmbeddingDomains": [
                        "http://localhost"
                    ]
                }
            }
        },
 ]
}

注意: 該当する「AllowedDomains」の値を変更する必要があります。
例: https://abc.com

Coding
まずは下記のようなコマンドでAWS SDK for PHP (v 3.x)導入するする必要があります。

composer require aws/aws-sdk-php

以下のように必要な情報を指定する必要があります。

define('AWS_REGION', 'XXXXXXXX');
define('AWS_ACCESS_KEY', 'XXXXXXXX');
define('AWS_SECRET_ACCESS_KEY', 'XXXXXXXX');
define('AWS_ACCOUNT_ID', 'XXXXXXXX');

次に、次の内容のphpクラスを作成します。

<?php
require __DIR__. '/vendor/autoload.php';

use Aws\QuickSight\QuickSightClient;
use Aws\Exception\AwsException; 

class QuickSight {
     $this->credentials_ = [
           'version' => 'latest',
           'region' => AWS_REGION,
           'credentials' => [
               'key'    => AWS_ACCESS_KEY,
               'secret' => AWS_SECRET_ACCESS_KEY
           ]
    ];
}

次にgetEmbedUrlの関数を作成します。「generateEmbedUrlForAnonymousUser」の関数を実行するために、$dashboardIdのパラメーターを渡す必要があります。

<?php

public function getEmbedUrl($dashboardId) {
   $params = [
      'AllowedDomains' => [
         'http://localhost'
      ],
      'AwsAccountId' => AWS_ACCOUNT_ID,
      'Namespace' => 'default',
      'SessionLifetimeInMinutes' => 600,
      'AuthorizedResourceArns' => 'arn:aws:quicksight:${AWS_REGION}:{AWS_ACCOUNT_ID}:dashboard/$dashboardId',
      'ExperienceConfiguration' => [
         'Dashboard' => [
            'InitialDashboardId'=> $dashboardId
         ]
      ],
      'SessionTags' => [
         [
            'Key' => 'hogetenant',
            'Value' => 'tenant2',
         ]
      ]
   ];

    try {
      $client = new QuickSightClient($this->credentials_);
      $result = $client->generateEmbedUrlForAnonymousUser($params);
      $embedUrl = $result['EmbedUrl'];
      
      return $embedUrl;
    } catch(Exception $e){
      print $e->getMessage();
    }
}

tenant=tenant2の行のみを表示するので、hogetenantのタグ値をtenant2に指定する必要があります。
注意: 該当する「AllowedDomains」の値を変更する必要があります。
例: https://abc.com

ウェブに表示するダッシュボードの埋め込みリンクを取得したい場合は、次の手順を実行します。

<?php
$quickSight = new QuickSight();
$embedUrl = $quickSight->getEmbedUrl($dashboardId);

実行が成功した場合の $embedUrl の値は次のようになります。
https://ap-northeast-1.quicksight.aws.amazon.com/embed/xxxxxx&amp;identityprovider=quicksight&amp;isauthcode=true

次のようにiframe$embedUrlを挿入する必要があります。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Quicksight Demo</title>
</head>
<body>
  <iframe src="<?php echo $embedUrl ?>"></iframe>
</body>
</html>


結果が来ました。

ローカル環境でダッシュボードが正常に表示されました。
もし何か問題がございましたら、お手数ですがコメント欄にご記入いただければ幸いです。

スポーツ動画のタグ付け

そろそろ年末が近づいてきました。気持ちの良い秋晴れ(もう冬ですかね)の空が広がっています。先日、数年ぶりに体育館で運動をしました。普段PCと向き合ってガチガチの身体がほぐれて良かったです。

今回は久しぶりにAI関連の記事です。CLIPモデルを利用して動画を解析してみます。

CLIPはOpenAIによって開発されたモデルで、画像とその説明(テキスト)の関係を検出します。このモデルは、インターネットから集めた大量の画像とテキストのペアで学習しています。特定のタスク用に追加の学習を必要とせず、多様なシーンで精度を出せるのが魅力ですね。

体育館での運動の合間の一コマです。謎の動きをしていますが、はたしてCLIPモデルを何をしているか理解できるでしょうか。

1. 検出する動き(テキスト)を日本語・英語で定義

{
  "投げる": "throw",
  "歩く": "walk",
  "走る": "run",
  "飛ぶ": "jump",
  "泳ぐ": "swim",
  "踊る": "dance",
  "歌う": "sing",
  "座る": "sit",
  "描く": "draw",
  "寝る": "sleep"
}

2. テキストの特徴量をpickleファイルへ保存

import torch
import clip
import pickle
import json

# CLIPモデルの初期化
device = "cuda" if torch.cuda.is_available() else "cpu"
model, transform = clip.load("ViT-B/32", device=device)

# 事前に準備した日本語と英語の辞書
with open('japanese_to_english_dict.json', 'r', encoding='utf-8') as file:
    japanese_to_english_dict = json.load(file)

# 英語に翻訳されたタグをCLIPモデル用にトークナイズ
translated_tags = list(japanese_to_english_dict.values())
text = clip.tokenize(translated_tags).to(device)

# テキストの特徴量を計算
with torch.no_grad():
    text_features = model.encode_text(text)

# pickleファイルとして保存
with open('text_features.pkl', 'wb') as f:
    pickle.dump(text_features, f)

print("テキスト特徴量を保存しました。")

3. 動画を読み込んで各フレームにタグ付け

import cv2
import torch
import clip
import pickle
from PIL import Image
from collections import Counter
import json
import os
import glob

# 指定されたフォルダ内の画像を削除する関数
def delete_images_in_folder(folder, file_extension="*.jpg"):
    files = glob.glob(os.path.join(folder, file_extension))
    for f in files:
        os.remove(f)

# フレームにテキストを描画する関数
def draw_text_on_frame(frame, text, position, font=cv2.FONT_HERSHEY_SIMPLEX, 
                       font_scale=0.7, font_color=(0, 255, 0), line_type=2):
    cv2.putText(frame, text, position, font, font_scale, font_color, line_type)

# バッチごとにフレームを処理する関数
def process_batch(frame_batch, start_frame_index, model, transform, text_features, 
                  japanese_tags, japanese_to_english_dict, all_tags_for_video, 
                  output_folder, fps):
    # バッチ内の各フレームをRGBに変換
    batch_rgb = [cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) for frame in frame_batch]

    # 変換されたフレームをPyTorchテンソルに変換
    batch_transformed = torch.stack([transform(Image.fromarray(img)) for img in batch_rgb]).to(device)

    # CLIPモデルを使用して画像の特徴量を抽出
    with torch.no_grad():
        image_features = model.encode_image(batch_transformed)
        logits_per_image = (image_features @ text_features.T)
        probs = logits_per_image.softmax(dim=1)
        top_tag_indices_list = probs.topk(N).indices

    # 各フレームごとに最も関連性の高いタグを選択
    for i, top_tag_indices in enumerate(top_tag_indices_list):
        valid_indices = [idx for idx in top_tag_indices if probs[i][idx] > SIMILARITY_THRESHOLD]
        top_tags_for_frame = [(japanese_tags[idx], probs[i][idx].item()) for idx in valid_indices]

        # 日本語タグを英語に変換
        top_tags_for_frame_english = [(japanese_to_english_dict[tag], score) for tag, score in top_tags_for_frame]

        # 現在のフレームのインデックスと時間を計算
        current_frame_index = start_frame_index + i
        current_frame_time = current_frame_index / fps

        # 処理中のフレームとそのタグをコンソールに出力
        print(f"Frame {current_frame_index} (Time: {current_frame_time:.2f} seconds): {top_tags_for_frame_english}")

        # フレームにタグを描画して保存
        for j, (eng_tag, score) in enumerate(top_tags_for_frame_english):
            text = f"{eng_tag}: {score:.2f}"
            draw_text_on_frame(frame_batch[i], text, (10, 30 + j*30))

        frame_filename = f"{output_folder}/frame_{current_frame_index}.jpg"
        cv2.imwrite(frame_filename, frame_batch[i])

        # 抽出されたタグを全タグのリストに追加
        for tag, _ in top_tags_for_frame:
            all_tags_for_video.append(tag)

# メインスクリプトの開始
device = "cuda" if torch.cuda.is_available() else "cpu"
model, transform = clip.load("ViT-B/32", device=device)

# pickleファイルからテキスト特徴量を読み込み
with open('text_features.pkl', 'rb') as f:
    text_features = pickle.load(f).to(device)

# 動画ファイルを読み込み
cap = cv2.VideoCapture('sports-movie.mp4')
fps = int(cap.get(cv2.CAP_PROP_FPS))

# 日本語と英語の辞書を読み込み
with open('japanese_to_english_dict.json', 'r', encoding='utf-8') as file:
    japanese_to_english_dict = json.load(file)

# 日本語のタグリストを作成
japanese_tags = list(japanese_to_english_dict.keys())

# 処理された全フレームのタグを保存するリストを初期化
all_tags_for_video = []

# バッチサイズ、上位N個のタグを選択するための数、類似度のしきい値を設定
BATCH_SIZE = 16
N = 3  # 上位N個のタグを選択
SIMILARITY_THRESHOLD = 0.2  # 類似度のしきい値

# バッチ処理用のフレームリストを初期化
frame_batch = []

# 出力されるフレームを保存するフォルダの設定
output_folder = 'output_frames'
if not os.path.exists(output_folder):
    os.makedirs(output_folder)  # フォルダが存在しない場合は作成
else:
    delete_images_in_folder(output_folder)  # フォルダが存在する場合は中の画像を全て削除

# 動画の各フレームを処理
frame_count = 0
while cap.isOpened():
    ret, frame = cap.read()  # フレームを読み込み
    if not ret:
        break  # フレームがない場合は終了

    frame_batch.append(frame)  # バッチリストにフレームを追加
    # バッチサイズに達したら処理を実行
    if len(frame_batch) == BATCH_SIZE:
        process_batch(frame_batch, frame_count - len(frame_batch) + 1, model, transform, 
                      text_features, japanese_tags, japanese_to_english_dict, 
                      all_tags_for_video, output_folder, fps)
        frame_batch = []  # 処理後はバッチリストをリセット
    frame_count += 1

# 残りのフレームを処理
if frame_batch:
    process_batch(frame_batch, frame_count - len(frame_batch) + 1, model, transform, 
                  text_features, japanese_tags, japanese_to_english_dict, 
                  all_tags_for_video, output_folder, fps)

cap.release()  # 動画の読み込みを終了

# タグの出現回数を集計し、ファイルに出力
tag_counts = Counter(all_tags_for_video)
with open('output_tags.txt', 'w', encoding='utf-8') as f:
    for tag, count in tag_counts.most_common():
        f.write(f"{tag}: {count}\n")  # タグとその出現回数をファイルに書き込み

print("動画の処理が完了しました。")

4. 実行結果と分析

タグとその出現回数は以下となります。

投げる: 832
踊る: 479
走る: 238

いいですね。多くは「投げる」と判断しています。

「踊る」「走る」はどのようなポイントで判断されているのが気になるところです。いくつかピックアップしてみます。

① 走る(run) 86%

まあ確かにこの画像だけを見ると走っているように見えますね。

② 投げる(throw) 50% & 走る(run) 41%

腕の部分は投げている雰囲気を出しています。

③ 投げる(throw) 75%

投げてます!

④ 踊る(dance) 74%

珍妙なダンスですが... 投げても走ってもいないですね。

5. 最後に

動画を読み込んで解析する場合、各フレームの静止画像に対して解析することになるので、上記のようにポイントでは誤った判断をすることがあります。そのため、全体を通してどのタグが一番多く検出されたのかを見ることで最終的な判断とすることがよさそうです。

Webのプッシュ通知を実装してみました。。。

iOS がよやくWebプッシュ通知を対応したので、PWAのPush通知を実装して色々なフィーチャーを使ってみました。

「Webプッシュ通知」とは、通知を許可したユーザーにWebブラウザ経由でプッシュ通知(受信操作をしなくてもメッセージが自動表示される通知方式)を送信する機能です。ユーザーは「Webプッシュ通知」を許可するだけで受信できるようになります。 なお「Webプッシュ通知」はPushAPINotificationAPIという2つの仕組みから成り立っています。

今回は vuejs のフロントエンドと nodejs バックエンドでWebプッシュ通知の機能を実装してみました。バックエンド側では web-push というパッケージをインストールする必要があります。

  1. フロントエンドでプッシュ通知の許可をリクエストするボタンを配置する必要があります。なければ iOS でプッシュ通知の許可をリクエストできません。
  2. フロントエンドで ServiceWorkerRegistration.pushManager.subscribe でサブスクリプションを登録します。登録結果はバックエンドのAPIを叩いて、保存します。

     serviceWorkerRegistration.pushManager.subscribe({
         userVisibleOnly: true,
         applicationServerKey,
     });
    
  3. バックエンドで WebPush.sendNotification を叩いたら フロントのサービスワーカー へ通知送信できます。

     WebPush.sendNotification(
         subscription,
         JSON.stringify({
             title: body.title,
             options: body.payload,
         }),
         options,
     );
    
  4. サービスワーカーで通知表示の処理を行います。(通知のボタンを押す時の処理もサービスワーカーで対応できます)

     self.addEventListener('push', (event) => {
         const data = event.data?.json();
         event.waitUntil(
             self.registration.showNotification(data.title, data.options),
         );
     });
    

Webプッシュ通知の表示は色んなオプションで調整できます。但し、 Windows/MacOS/iOS/Android では表示が異なるので、注意してください。仕様はここで参考してください: https://developer.mozilla.org/ja/docs/Web/API/ServiceWorkerRegistration/showNotification

  • badge:Androidのみ対応する

Androidで対応するbadgeのオプション

  • icon:iOSは未対応、MacOSではアプリのアイコンの代わり大きい画像として表示される
  • image:iOS/MacOSは未対応
  • actions:iOSは未対応、action.iconはWindowsのみ対応する

Windowsのwebプッシュ通知、icon + image + actions をセットしている

MacOSのプッシュ通知、icon + actions をセットしている

iOSのプッシュ通知

Androidのwebプッシュ通知、bade + icon + image + actionsをセットしている

皆さんもWebプッシュ通知を実装してみましたか?何かいい経験があれば コメント欄で共有してくれると幸いです。 では、今日の記事はここまでです。また後で。。

【AI展示会に出展します】 2023.10.25(水)〜27(金)@幕張メッセ JAPAN IT WEEK 【ブース番号:52-46】

開催概要

名称 Japan IT Week
会期 2023/10/25(水) ~ 27(金)
開場時間 10:00 ~ 18:00
会場 幕張メッセ
https://www.m-messe.co.jp/access/
ブースNo 52-46
招待状・入場券 会場に入場するためには、招待状・入場券が必要です 招待状はこちら
ブリスウェルご相談枠の事前予約 当日は混雑が予想されますので、予め枠をご予約ください
事前予約はこちら
公式サイト https://www.japan-it.jp/autumn/ja-jp.html



出展サービス

AI画像解析 ・お客様の活用シーンに応じたカスタマイズが可能
・AI画像解析エンジンを用途に応じたアプリケーションに組み込み可能
・少ない学習データからもモデル構築が可能
AIアナログメーター読み取り ・カメラ搭載のPC1台で撮影からデータ化まで対応可能
・デジタルやアナログのメーター両方に対応可能
クラウドシステムと連携することで複数機器のデータ分析が可能
AWS導入コンサルティング AWSテクノロジーパートナーとして、クラウド上でのシステム構築実績が多数あるため、様々なニーズに対応したAWS導入支援が可能です。
「アイカタ」 受発注管理クラウドサービス ・シンプルで拡張性の高いクラウドサービス
クラウドサービスなのでリモートワークにも最適
・shopifyやfreeeとの連携が可能
https://ai-cata.com/



お問い合わせ

株式会社ブリスウェル
TEL: 03-6450-4848
Mail: info@briswell.com



商談の事前予約

弊社営業担当者へご連絡頂くか、あるいは当ウェブサイトの問い合わせフォームよりご依頼ください。
お問い合わせ

VPC内のLambdaからインターネット接続する方法

今回もさくっとAWS関連です。

AWSVPC内でLambdaを動作させることは、RDSや他のプライベートリソースへの安全な接続に必要となります。

そのVPC内のLambdaからインターネット接続をするには、NAT Gatewayを利用することで実現できます。しかし、NAT Gatewayのコストが若干気になりますね。

NAT Gatewayの代替策を確認しました。

ENIにパブリックIPを付与

Lambda関数にアタッチされているElastic Network Interface(ENI)にパブリックIPを割り当ててみる。

最初はこの方法を試し、VPC内のLambdaからインターネット接続できることを確認できたのですが、LambdaのプライベートIPが変わると、パブリックIPの割り当てが解除されてしまいました。

安定した接続を維持するのが難しくなるので、現実的な方法ではないですね。

VPC外のLambdaをブリッジとして使用

VPC内のLambda:メインの処理&RDSとの通信を担当
VPC外のLambda:インターネット接続(メール送信)を担当
VPC内のLambda → VPC外のLambdaを呼び出す

# VPC内LambdaからVPC外Lambdaを呼び出すサンプル
def invoke_send_email_lambda(start_time, end_time, error_message):
    payload = {
        'start_time': start_time.strftime("%Y-%m-%dT%H:%M:%S"),
        'end_time': end_time.strftime("%Y-%m-%dT%H:%M:%S"),
        'error_message': error_message
    }

    response = lambda_client.invoke(
        FunctionName=LAMBDA_FUNCTION_NAME,  # ここにVPC外のLambdaの関数名を記載
        InvocationType='RequestResponse',  # 同期的に呼び出す
        Payload=json.dumps(payload)        
    )

    response_payload = json.loads(response['Payload'].read())
    print("VPC外のLambdaからのレスポンス:", response_payload)

この方法で実現できました。

VPC内のLambdaからVPC外のLambdaを呼び出すためには、VPCエンドポイントの設定が必要です。

Lambdaすごいですね。