Practice of Programming

プログラム とか Linuxとかの話題

あまり頭を使わずに負けない三目並べを作る

4月書き忘れてることに気づいて、もう、5月31日ではないか....。

StreamDeckで遊ぶために、以前に三目並べを作ったのですが、その解説です。割と力技で作った感じなので、もっと頭のいい方法が知りたい方はぐぐると出てきます。

三目並べとは

説明するまでもないですが、9マスに先手後手で、◯×を埋めていって、3つ並べたら勝ちですね。 先手後手に関わらず最適な手を打てば必ず引き分けになります。

負けないためにはどうするか?

負けないためにはどうしたら良いのかというと、

  1. 負けそうなとき(相手の三目が成立する時)は妨害する
  2. 負ける配置(二目が同時に2つ成立する)にさせない
  3. 勝ち筋が多くなる(二目が同時に2つ成立する)ところに置く

の3点を守るのが基本です。

実装方法

マスをビットで表す

9マスをビットで表します。

# 三目並べのビット
1   2    4
8   16  32
64 128 256

見やすいようにテーブルにします。

a b c
A 1 2 4
B 8 16 32
C 64 128 246

勝利条件bit和で考える

勝利条件のbit和は下記のようになります(8列分)。

  • 84 ... 斜め(cA,bB,cC)
  • 7 ... A行
  • 56 ... B行
  • 448 ... C行
  • 273 ... 斜め(aC,bB,cA)
  • 73 ... a列
  • 146 ... b列
  • 292 ... c列

次で勝つときのbit和と、次どこを打てばよいかのbit は下記のようになります(8列x3=24)。

  • 84
    • 20: 64 ... cA(4)とbB(16)をとっていたら、 aC(64)が勝利手
    • 80: 4
    • 68: 16
  • 7
    • 3: 4
    • 6: 1
    • 5: 2,
  • 56
    • 24: 32
    • 48: 8
    • 40: 16
  • 448
    • 192: 256
    • 384: 64
    • 320: 128
  • 273
    • 17: 256
    • 272: 1
    • 257: 16
  • 73
    • 9: 64
    • 72: 1
    • 65: 8
  • 146
    • 18: 128
    • 134: 2
    • 130: 16
  • 292
    • 36: 256
    • 288: 4
    • 260: 32

負けないための戦略を考える

最初の3つの負けないためのルールを実行するためには、下記のようにする必要があります。

  1. 初手真ん中に置かれた場合は、端を取る(二目が2つ成立するのを防ぐ)
  2. ユーザーが対角、CPUが真ん中の場合は、辺の中を取る(二目が2つ成立するのを防ぐ)
  3. 真ん中が空いているなら、真ん中を取る(勝ちやすいだっけかな...。そうでもないかも)
  4. CPUの三目が成立するところに置く(勝利条件なので)
  5. 次にユーザーの三目が成立するところに置く(負けないために必要)
  6. 次にユーザーの二目が成立しそうなところに置く(ただし、ユーザーの二目が同時tに2つ成立する場合のみ)
  7. CPUが勝てそうなところ(次で勝つbit和となるところに置く、勝ち筋が同時に2つ発生する場所を優先)
  8. それでも決まらなかったらランダムで良い

だいたいこんな感じでやると負けません。 もし、人相手に勝ちたいなら、上を守っていれば、相手が油断してれば勝てるかと思います。

終わり

以上、あまり頭を使わずに負けない三目並べを作る方法でした。 ソースコードは下記にあります。

github.com

Google Photos APIを使うためのGoogle Developer Console での設定

こないだの続きです。 特に、Photos APIに限った話ではないですが。

OAuthアプリの設定を変更する

OAuth同意画面 から、「アプリを編集」します

OAuth同意画面の設定

「承認済みドメイン」が必須になります。

「保存して次へ」をクリック。

スコープの設定

スコープを追加または削除」で、スコープを設定します。

Google Photos APIの場合、どんなスコープがあるかは、下記に列挙されています。

developers.google.com

例えば、読み取りを行いたい場合は、https://www.googleapis.com/auth/photoslibrary.readonly が対象になります。

用意されている一覧には存在しないので、スコープの手動追加」で、スコープを追加します。

URLを入力して、「テーブルに追加」すると、一覧に追加されます。

「保存して次へ」行きます。

テストユーザーの設定

Photos APIは、「気密性の高いスコープ」です。そのため、Googleにアプリを認証してもらう必要がありますが、とりあえずテストするには、テストユーザーを登録しておけばよいです。

「ADD USERS」で、自分のGoogleアカウントのメールアドレスを登録しておきます。

アプリでスコープを指定する

先日のTauriのアプリでは、下記の場所にスコープに指定した値をエスケープして指定すればよいです。

auth.tsの11行目に、scopeがあるので、下記のように指定すればOKです。

    'scope=email%20profile%20openid%20' +
    'https:%2F%2Fwww.googleapis.com%2Fauth%2Fphotoslibrary.readonly%20' +

複数ある場合は、空白(%20)でつなげれば良いです。

ログインを試してみる

Google Developer Consoleの設定は、すぐには反映されないので、うまくいかなくても焦らずに、数分から数十分待ってみましょう。下記のようになればOKです。

「続行」を押せば、確認画面が出て処理が完了します。

APIの有効化

「有効なAPIとサービス」から、「APIとサービスの有効化」をクリックします。

photos で検索すると、Photos Library APIが見つかりますので、これを有効化しましょう。

これをしておかないと、実際にAPIを使うときにエラーになってしまいます。

おしまい

以上で、Google Phots APIを使えるようになりました。他のAPIも基本的には同じですね。 アプリを認証してもらう方法は下記に書いていますが、まだ、試していません。

support.google.com

Tauri で作ったアプリから Google にログインする

2月に記事を書けなかった...。まぁ日数少ないから、3/1なのでセーフということにしたい。

Tauri で Google等のOAuthでログインを行う方法の状況

さて、Tauri で 作ったアプリで Google のログインを行う方法があるのだろうかと、色々調べていたのですが、まだ、議論中の話題のようです。

  1. tauri の特定のrevisionを指定したらどうやらできるかも
  2. tauri_plugin_oauth を使えばできるっぽい

という感じでしたが、1番目はちょっとな...というのと、2番目はもうちょっと具体例がほしいな...と思って、最初にリンクしたスレッドを、不定期に追っていたのですが、3日前にtauri_plugin_oauth を使ったサンプルを作られた方の投稿がありました。

github.com

ありがたすぎる。

というわけで、この実装を参考に試してみましたが、Firebaseの設定等も必要になるので、そちらの情報をまとめながら、どこに何を記載したら良いかを書いていきます。

ただ、この方法、自分だけ使う分には良いですが、セキュリティ的に懸念があるんじゃないかと思われます。配布するような場合には、このまま使うのは避けたほうが良いでしょうね。

Firebaseの準備

Firebaseを使うので、Firewabaseにプロジェクトとアプリが必要です。

プロジェクトを作成します。

この後、Google Analyticsを有効にしますかとか聞かれます。好きにして下さい。

「アプリにFirebaseを追加して利用を開始しましょう」の下の、</> タグマークを選びます。

ウェブアプリにFirebaseを追加するページになります。 名前を適当に設定して、Firebase Hosting は使わなくて良いです。

「Firebase SDKの追加」で、npm install firebase の案内と、コードが表示されるので、コピーしておきましょう。コードは以下のような感じです。

// Import the functions you need from the SDKs you need
import { initializeApp } from "firebase/app";
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries

// Your web app's Firebase configuration
const firebaseConfig = {
  apiKey: "*************************",
  authDomain: "*************************.firebaseapp.com",
  projectId: "*************************",
  storageBucket: "*************************.appspot.com",
  messagingSenderId: "*************************",
  appId: "*************************"
};

// Initialize Firebase
const app = initializeApp(firebaseConfig);

この情報は、冒頭で紹介した、サンプルプログラムの、app.tsの5行目 に入れるものです。

次に、コンソールに戻って、「Authentication」を選びます。

その後、「始める」をクリックすると、下記のページに遷移します。

Sign-in method」を選んで、Google を選択します。

「有効にする」をクリックして、メールを選んで保存します。

Google Cloud Console での設定

次に、Google Cloud Console に移動します。

左上からプロジェクトが選べるので、未選択状態だったら、先程Firebaseで作ったプロジェクトを選びましょう。

APIとサービス」の「認証情報」を選びます。上記の手順でやると、OAuth2.0クライアントが2つできあがってるんですが、なんですかね...。 片方を選べば良いと思います。

右上の方に、クライアントIDがあるので、それをコピーします。 auth.tsの9行目<CLIEN_ID_FROM_FIREBASE>を置き換えるものです。

また、「承認済みのリダイレクトURI」に、http://localhost を追加しておきます。

APIキーに制限をつけましょう。何もしないと、下記のようになっています。こちらをクリックして、APIキーを制限します。

Webサイトを選んで、localhost を許可しておきます。

これで、ログインページが表示されるようになります。

なお、これらの設定が有効になるまでしばらくかかるようなので、数分から数十分待ってみましょう。

ただ、このままだと、下記のような感じで、怪しすぎるので、アプリ名を設定しましょう。

「OAuth同意画面」から「アプリを編集」します。

とりあえず、ここの「アプリ名」を変更すれば、同意画面に出てくる名前も変わります。 メールアドレスだけ設定する必要がありますが、それ以外は、特に設定しなくても先に進めます。

コードの編集と実行

既に書いたとおりですが、二箇所編集するだけです。

git clone https://github.com/igorjacauna/tauri-firebase-login.git
cd tauri-firebase-login

src/services/firebase/app.ts を編集します。

import { initializeApp } from 'firebase/app';

// Here goes the firebase project config
const firebaseConfig = {
  // Fill here
};

export const firebaseApp = initializeApp(firebaseConfig);

Fill here に、最初の方に取得した、firebaseConfig の内容をコピーします。

次に、src/services/firebase/auth.ts の <CLIENT_ID_FROM_FIREBASE> を置き換えます。

import { open } from '@tauri-apps/api/shell';
import { getAuth, GoogleAuthProvider, getRedirectResult, signInWithRedirect, signInWithCredential } from 'firebase/auth';

const openBrowserToConsent = (port: string) => {
  // Replace CLIEN_ID_FROM_FIREBASE
  // Must allow localhost as redirect_uri for CLIENT_ID on GCP: https://console.cloud.google.com/apis/credentials
  return open('https://accounts.google.com/o/oauth2/auth?' +
    'response_type=token&' +
    'client_id=<CLIEN_ID_FROM_FIREBASE>&' +
    `redirect_uri=http%3A//localhost:${port}&` +
    'scope=email%20profile%20openid&' +
    'prompt=consent'
  );
};

以上で、サンプルプログラムを動かしてみます。

cargo-tauri dev

超シンプルなログインの画面だけが表示されます。

クリックすると、下記のようにGoogleのログインが表示されます。

ログインすると、auth.tsの25行目 で、アクセストークンが取得できますので、これをどこかに保存しておけばよいでしょう。

おしまい

以上で、Google にログインして、アクセストークンを取得できるようになりました。 ですが、最初に書いたとおりセキュリティ上はあまりよろしくないかと思います。

APIキーの制限がlcalhostなので、誰でもそのキーを使って、APIを叩けます。 なので、本来は自分のWebサイト上で、OAuthの処理をすべきでしょう。

一旦テスト開発で...というのには十分役に立つかと思います。

RustでExifのMakerNoteからPanasonicのカメラのレンズ情報を雑に取得する

Pnaasonicのカメラを使ってるのですが、Exifからレンズ情報が取れない...のですよね。

問題

通常、ExifのLensModelにレンズ名が入ってくるのだと思うのですが、Panasonicのカメラでは、入ってなさそうです(フルサイズ系(S系)は入っているかも)。少なくとも、僕の使ったことのあるカメラ、GM5, GX8, G9等では入ってなかったです。

ですが、ExifのMakerNoteの中に、データはあるようなので、それを使えばいけそうです。

ExifをParseするのに、rexifを使う

ExifをRustでParseするのには、rexifというものがありますので、これを使いました。

docs.rs

rexifでparseすると、entriesというメソッドで、各エントリーが取得できるので、MakerNoteの場合に、別の関数でentryの、ifd.ext_dataをparseします。下記のような感じですね。

            struct Exif {
               lens_model: String,
               // 他にも適当に
            }

            let mut data = Exif{lens_model = "".to_string() };
            let exif_data = rexif::parse_file(file_path);
            for e in exif_data.unwrap().entries {
                match e.tag {
                    rexif::ExifTag::MakerNote => {
                        let d = get_lens_from_maker_note(e.ifd.ext_data);
                        if d != "" {
                            data.LensModel = d;
                        }
                    },
                    // 他にも適当に
                }
            }

get_lens_from_maker_note でやっていること

この関数では、rexifから取ってきたe.ifd_ext_data をparseします。この型はVec<u8> のバイト列です。Panasonicのカメラの場合、先頭の12バイトは、80, 97, 110, 97, 115, 111, 110, 105, 99, 0, 0, 0 となりますが、これはPanasonic\0\0\0になります。

先頭がPanasonic\0\0\0の場合に、レンズ情報がどこにあるかですが、固定長というわけではなさそうだったので、先頭から適当にそれっぽい文字がでるまで探すことにしました。

確認できたところでは、LUMIX, LEICA, OLYMPUS, SIGMA 等の文字の後に、レンズ情報が来ていました。LEICA は、LEICA認証の通ったPanasonicのレンズ(例: LEICA DG 100-400/F4.0-6.3)の場合になります。

なので、下記のような感じで取るようにしました。

  1. 最初にPansonic\0\0\0があるか判定
  2. 制御文字以外をstd::char::from_u32で変換して連結していき、最後の10文字だけ取っておく
  3. レンズのプレフィックス用の正規表現にあたるかチェック
  4. そこから制御文字の手前までをレンズ情報とする

という、とても雑な感じです(正規表現にマッチするレンズ情報がない場合、最後まで読んでしまうので、効率が悪い)。

コードは以下のようになります。

// currently only for Panasonic camera
fn get_lens_from_maker_note(data: Vec<u8>) -> String {
    if data.len() < 9 {
        return "".to_string();
    }

    // Panasonic
    let panasonic: [u8; 12] = [80, 97, 110, 97, 115, 111, 110, 105, 99, 0, 0, 0];
    let first12chars = &data[0..12];

    // return when first 12 char is not "Panasonic\0\0\0"
    if first12chars != &panasonic {
        return "".to_string();
    }

    // Lens name prefix regex(I only confirmed LUMIX, LEICA, OLYNMUS, SIGMA)
    let re = regex::Regex::new("(?i)(LUMIX|LEICA|OLYMPUS|SIGMA|TAMRON|KOWA|COSINA|VOIGT|VENUS)$")
        .unwrap();

    let mut i = 12;
    let mut str = "         ".to_string(); // dummy 9 chars
    while i < data.len() {
        let d = data[i];
        if d < 32 || 126 < d {
            i += 1;
            continue;
        }
        // enough length for regex
        str = str[str.len() - 9..str.len()].to_string();
        str.push(std::char::from_u32(data[i].into()).unwrap());

        let captures = re.captures(&str);
        if captures.is_some() {
            let cap = captures.unwrap();
            let mut lens = cap[0].to_string();
            let mut i2 = i;
            while i2 < data.len() {
                i2 += 1;
                if data[i2] < 32 || 126 < data[i2] {
                    return lens.to_string();
                }
                lens.push(std::char::from_u32(data[i2].into()).unwrap());
            }
        }
        i += 1;
    }
    return "".to_string();
}

雑すぎやしないか?

もっと頑張ってMakerNoteを解析するという手段もありなのですが、MakerNoteの仕様は、各社バラバラです。まともに対応すると、PerlのExifToolsのコードを見るとかする必要がありそうなのですが、そこまで時間を取れないので、一旦これで手を打ちました。

※ExifToolsは下記です。 metacpan.org

ですが、12バイト目がエントリー数で、そこから、12バイト単位でエントリーが並んでおり、そこにはレンズ情報はないということはわかったので、せめて下記のような追加を行いました。

下記の用に12から始めているところを

   let mut i = 12;

コメント通りですが、(data[12] のエントリ数 + 1 ("Panasonic\0\0\0"(12byte)) ) x 12byte分をスキップすることにしました。

    // // safely skip 12byte x (data[12](num of entries) + "Panasonic\0\0\0")
    let mut i: usize = usize::from(data[12] + 1) * 12;

まぁ、大した効果はないとは思います。

後は、正規表現でやるより、スライスでチェックしたほうが速いかも...とか思いましたが、正直速度はなんの問題もないレベルなので、一旦これで良しとします。

おわり

というわけでかなり雑にではありますが、レンズ情報を取得できるようになりました。

めでたし。

Pythonで目標をセンターに入れてCrop と 月を撮るカメラの設定 と ChatGPT

ちょっと前の話になりますが(2022/11/8)、皆既月食天王星食がありましたね。見られましたでしょうか?

僕は途中から、望遠レンズを持ち出して撮影していましたが、久しぶりに撮ると設定とかを忘れちゃっててだめですね。なかなか手こずりました。

で、何枚も撮ったわけですが、ご存知の通り月は動くので...常にセンターで撮れていたわけもなく...。写真を並べても月があっちこっちに行っていて、時系列に順番に見ようと思っても、ちょっと残念な感じです。

とはいえ、これをいい感じに手でトリミングするのも、めんどくさすぎる...ということで、OpenCVを使って、軽くやってみました。

なお、下記の記事が非常に参考になりました。

最初に結果

こんな感じのgifができあがります。animation pngも作りましたが、重いので。

目標をセンターに入れてCropする Python の script

大して長くも無いのでコメントに説明を書いています。 前提として、「写真内の月の大きさは同じ」です。

import cv2
import glob
import numpy as np
import os
import re
from PIL import Image

# 円の半径
min_radius = 520
# ~/Downloads/の下のdirectory名
path_prefix = 'eclipse'

# クロップするサイズ
crop_radius = min_radius + 150

# ファイルを取得(Lumixで撮ると、Pがファイルの最初に付く)
files: list = glob.glob("/home/ktat/Downloads/" + path_prefix + "/P*.JPG")
files = sorted(files, key = lambda item: int(re.sub('/.+/P(\d+)\.(?:jpg|JPG)$', r'\1', item, 1)) )

n = 0
for file in files:
    n += 1

    # tmp に cropしたファイルを保存します
    fname = '/tmp/{}-{}.png'.format(path_prefix,n)

    # 途中で設定変えて続きからやりたいときのために、ファイルがあったらskip 
    if os.path.isfile(fname) is True:
        continue

    img = cv2.imread(file)
    # ノイズ除去
    dst = cv2.GaussianBlur(img, (3,3), 0)
    # グレースケール化
    gray = cv2.cvtColor(dst, cv2.COLOR_BGR2GRAY)
    # 円の検出
    circles = cv2.HoughCircles(gray, cv2.HOUGH_GRADIENT, dp=1.1, minDist=100, param1=100, param2=30, minRadius=min_radius, maxRadius=min_radius + 50)

    if circles is None:
        print("skip {}".format(file))
        # 検出できなかったものがどんなものだったかわかるように保存しておく
        cv2.imwrite("sample-gray-{}.png".format(n), gray)
        cv2.imwrite("sample-noize-cancel-{}.png".format(n), dst)
        continue
    else:
        print("OK {}".format(file))

    circles = np.uint16(np.around(circles))

    x = 0
    y = 0
    i = 0
    for circle in circles[0, :]:
        i = i +1
        x += circle[0]
        y += circle[1]

    # 円が複数検出される可能性があるので(別の場所ではなくて、同じところに複数)
    # 中心座標(平均)を出す
    x = x / i
    y = y / i

    im = Image.open(file)
    left = x - crop_radius
    right = x + crop_radius
    up = y - crop_radius
    bottom = y + crop_radius

    # crop したファイルを保存
    im.crop((left,up,right,bottom)).save(fname, format='png')

以下、ポイントだけ。

ノイズ除去

下記の部分です。除去しないと円の検出がうまく行かないケースが多かったので、先にノイズ除去を行っています。 コントラストがはっきりしていればよいのでしょうが、月食が終わってくると、明るい部分はどうしても輪郭がわかりにくくなってしまうところがありますね。それに対して、多少の寄与はありそうです。

dst = cv2.GaussianBlur(img, (3,3), 0)

円の検出

円の検出は、下記の部分で、minRadius, maxRadius が、円の最小、最大直径です。 最初に書いたように、写真内の月の大きさは同じです(同じ焦点距離で撮っていたので)。

    circles = cv2.HoughCircles(gray, cv2.HOUGH_GRADIENT, dp=1.1, minDist=min_radius param1=100, param2=30, minRadius=min_radius, maxRadius=min_radius + 50)

dp は、0.8-1.2くらいが良いと

circlesに検出された円の情報が複数(同じ場所で複数)来る場合があるで、平均を取っています。

    for circle in circles[0, :]:
        i = i +1
        x += circle[0]
        y += circle[1]

    # 円が複数検出される可能性があるので(別の場所ではなくて、同じところに複数)
    # 中心座標(平均)を出す
    x = x / i
    y = y / i

crop して保存

中心座標(x,y)がわかれば、後は、crop して、保存しているだけです。

    im = Image.open(file)
    left = x - crop_radius
    right = x + crop_radius
    up = y - crop_radius
    bottom = y + crop_radius

    # tmp に cropしたファイルを保存します
    fname = '/tmp/{}-{}.png'.format(path_prefix,n)
    # crop したファイルを保存
    im.crop((left,up,right,bottom)).save(fname, format='png')

アニメーションgif/pngを作る Python の script

cropする処理が重いので、毎回やりたくはないので、別のプログラムとしました。 crop後に余計な画像は削除して連結をやり直しとかしたいからです。

単純に画像を取ってきて並べ替えて、連結してアニメーションにしているだけです。

import glob
import re
from PIL import Image

path_prefix = 'eclipse'
files: list = glob.glob('/tmp/' + path_prefix + "*.png") 
files = sorted(files, key = lambda item: int(re.sub('/tmp/' + path_prefix + '-(\d+)\.png', r'\1', item, 1)) )
cropped: list[Image] = []
cropped_mini: list[Image] = []

for item in files:
    im = Image.open(item)
    cropped.append(im)
    cropped_mini.append(im.resize((200,200)))

cropped[0].save("result-{}.png".format(path_prefix), format="png", save_all=True, append_images=cropped[1:])
cropped_mini[0].save("result-{}-mini.gif".format(path_prefix), format="gif", save_all=True, append_images=cropped_mini[1:])

おしまい

月の大きさが同じだとかなり良い感じにcropできます。しかも割と簡単。

月の大きさが違う場合は、複数の半径の候補を持っておいて、大きいのから試していって、len(circles) == 1 を見つけるまでやると良いかもしれません。

以下は、2018/01/31の月食ですが、これはサイズがバラバラでした。色々試してみましたが、もう少し調整が必要そうです。 (これでも、うまく行かなかった画像は、目視で削除)

おまけ: 月を撮るときのカメラの設定

僕が使ったのは、下記の機材です。ちなみに普段は鳥を撮っているので、月をしょっちゅう撮ってるわけではないです。

普通の月を撮る

普通の月はかなり明るいので、手持ちでも大丈夫です。 iso700-800くらいで、シャッタースピードも1/300-400とかでも十分撮れると思います。

下記は、

皆既月食を撮る

対して、皆既月食は暗いので、三脚を使ったほうが良いです。また、安物の三脚の場合、シャッターを切る指の動きでブレる可能性もあるので、シャッターディレイ(シャッターボタンを押してから数秒後にシャッターが切れる設定)も行うと良いかもしれません。 後、三脚使う場合は、手ブレ補正はオフにしましょう。

下記は、

おまけ2: ChatGPTにお願いしてみた

コード書いてから、ChatGPTにお願いしたらどうなるだろうか、試してみました。

お願い(英語はだいぶ雑)。

Write code with python. The spcification is the following. 
1. several photos is given as commad line argument in which there is any shape of moon. 
2. sort them with file mtime
3. detect a moon center position in image. 
4. crop it from center of the moon to its radius with 200px margin. 
5. resize them as their radius are same. 
6. create animation gif with the results.

restriction:
1. consider memory usage. don't use huge memroy.. 
2. avoid misdetection of a moon as possible 
3. Please check your code with mypy and if error occurs, fix it.
4. implent whole code. avoid todo code.
5. you can assume the largest object in image as a moon.
6. the size of animation gif shoud be less than 20MB

ありがたいお言葉を若干修正したもの。

import os
import sys
from typing import List, Tuple

import cv2
import imageio

# Get the file paths of the images from the command line arguments
image_paths = sys.argv[1:]

# Sort the image paths by file modification time
image_paths.sort(key=lambda x: os.path.getmtime(x))

# Create a list to store the cropped and resized images
images = []

# Iterate over the image paths
for image_path in image_paths:
    # Load the image
    image = cv2.imread(image_path)

    # Convert the image to grayscale
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

    # Use Otsu's thresholding method to get a binary image
    _, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)

    # Find the contours in the binary image
    contours, _ = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_NONE)

    # Find the contour with the largest area
    cnt = max(contours, key=cv2.contourArea)

    # Compute the center of the contour
    M = cv2.moments(cnt)

    # 修正
    if M['m00'] == 0:
        continue

    cx = int(M['m10'] / M['m00'])
    cy = int(M['m01'] / M['m00'])

    # Compute the radius of the contour
    radius = int(cv2.minEnclosingCircle(cnt)[1])

    # 修正
    if radius == 0:
        continue

    # Crop the image from the center of the moon to its radius with a 200px margin
    cropped = image[cy - radius - 100:cy + radius + 100, cx - radius - 100:cx + radius + 100]

    # Get the width and height of the cropped image in pixels
    h, w = cropped.shape[:2]

    # 修正
    if w == 0 or h == 0:
        continue

    # Resize the cropped image so that the radii of the moons in all images are the same
    resized = cv2.resize(cropped, (w, h))

    # Add the resized image to the list of images
    images.append(resized)

# Set the frame rate of the gif to 24 frames per second
fps = 24

# Create a writer object that will write the gif to a file
writer = imageio.get_writer('animation2.gif', fps=fps)

# Write the frames to the gif file
for image in images:
    writer.append_data(image)

# Close the writer object
writer.close()

サイズ制限は無視されてしまったので、手で修正(画像の大きさで指定したほうが良かった)。 後、明らかに変なフレームは手で削除しました。

割と良い線いきますね...。

Product-Led Growthまとめ

この記事は Wano Group Advent Calendar 2022の4日目の記事となります。他にも、24日(STREAM DECK互換の仮想デバイスの紹介)と25日(Notionとtandemで作る相談しやすいリモートワーク開発のすすめ)に記事を書いているので、そちらもどうぞ。

グループ会社のEDOCODE(メインで仕事してます)のAdvent Calendar で、12/6にDockerの記事を書きますので、よろしければ、そちらもどうぞ。

この記事について

立場的に技術のこと書くべきなのかもしれませんが、リクエストがあったので、「Product-Led Growth」という本のまとめを書きます(要約と個人的な考えの追記となっています)。

なお、以下の目次を見てもおわかりと思いますが、とっても長いです。

Product-Led Growthとは?

ウェス・ブッシュによるプロダクト開発の本ですが、副題に「『セールスがプロダクトを売る時代』から『プロダクトがプロダクトを売る時代』へ」とあります。 プロダクトを成長させるための戦略や効果的なプロダクト開発、それにまつわる考え方について書かれています。

まじで良い本なので、こんなまとめを読んでる暇があったら、今すぐ、買って読んだほうが良いです。Kindleなら数秒で手に入りますし、英語なら、サイトで無料で読むことができます。

いや、それでもちょっと...という方は、時間が無駄になるかもしれませんが、以下の紹介を読んでから、本書を読む気になってください(ならなかったらごめんなさい)。

※文章内で、PLGと書いている場合は、Prodcut-Led Growthの書籍のことを指しています

ちなみに、時々「UXデザインの法則」の話が出てきますが、こちらも良書です。下記にまとめていますので、よろしければどうぞ。PLGの内容と非常に相性が良いのではないかと思います。

UXデザインの法則を読んで感銘を受けたのでまとめた

セールス主導型 vs プロダクト主導型

字句通りですが、セールス主導型のプロダクトというのは、セールスの人員が、見込み客リストにコンタクトを取って、顧客獲得するとか、オンボーディングに人手をかけるとか、広告を大々的に打って、顧客を獲得するとかそういうものです。セールスに重きを置いてプロダクトを成長させるということです。 プロダクト主導型というのは、見込み客に端的に言えばプロダクトを使わせる・体験させることによって、そのプロダクトの価値をユーザーにいち早く体験させることで、ターゲットになりうるユーザーを逃さず、成長につなげるというような感じです。

顧客を獲得するのに、セールス主導型は基本的にコストがかかります。比較して、プロダクト主導型ではコストは低く抑えられます。顧客獲得単価を低く抑えられるということは、その分サービスの利用金額も低く抑えることができ、競争優位性が高まります。

フリートライアル、フリーミアム

この2つのモデルのプロダクトがプロダクト主導型に向いています。この2種類の違いは何か、改めて書くと。

  • フリートライアル ... 一定期間は無料で使うことができる。無料期間が終わると有料プランに切り替わる
  • フリーミアム ... ずっと無料で使えるが、有料プランにすることで使える機能や回数とかが増えたりする

フリートライアルとフリーミアムの実際の例を出すと

みたいな感じですね。

自分のプロダクトが、フリートライアル、フリーミアム、どちらが向いているのかを判断するために、プロダクトの成長戦略について考える必要があります。

プロダクトの成長戦略

下記の3つの戦略のうち、自分のプロダクトにあったものを選ぶべきです。

ドミナント型 - 安価 & 大規模

全タイプの顧客を狙う差別化戦略です。低価格で機能を提供し、あらゆるタイプの顧客をターゲットとします。SpotifyNetflixのようなサービスですね。 「5000万ユーザーくらいを顧客にできないと成り立たない」との説もある(*)とのことですが、どこまで必要かは会社の目指す規模にもよりそうです。 ただ、安価なので、5000万はだいぶ言い過ぎなんじゃないかとは思いますが、それなりのユーザー数は必要かと思います。

ディスラプティブ型 - 既存より低価格 & 既存以下以上の規模

既存サービスを過剰と感じている顧客を狙う戦略。例えばタスク管理サービスのJiraに対する、Trelloみたいな感じですかね(買収されてますけど)。機能を落としても、よりユーザーが使いやすいものを作って、乗り換えさせる戦略です。

差別化型 - 既存より高価格 & 既存以下以上の規模

既存のサービスが不十分だと感じている顧客を狙う戦略。ディスラプティブ型とは逆に、既存サービスのできないところを補ったプロダクトを開発し、乗り換えさせる戦略です。 グループ会社のEDOCODEが開発しているPUSHCODEは、WebPushのサービスですが、他のサービスよりもAPIに力を入れているので、差別化型の戦略と言えるかと思います。

ブルーオーシャンレッドオーシャン

プロダクト主導が向いているかどうか判断するために、自分のプロダクトがすでに市場が成立しており競争過多な市場(レッドーオーシャン)にいるのか、今から市場を作り出す(ブルーオーシャン)ものなのかを把握する必要があります。

ブルー or レッド?という二者択一ではなく、セグメントによってはブルーにもなりうるし、レッドにもなりうるということもあります。 例えば、大企業向けに提供している同様のプロダクトは複数あるが、中小企業向けに提供しているプロダクトは少ない、または、無いということもありえます。

Slackに対するChatworkは、(当初の)Slackが英語インタフェースでAPIや連携サービスが充実していて開発者受けが良かったのに対して、別のセグメント(日本人向け、非開発者より)を狙っての戦略だったかもしれません。知らんけど。

ブルーオーシャン

ブルーオーシャンはこれから作り出す市場なので、競争相手はいないが、プロダクトをすぐに理解してくれる顧客も少ないということになります。 プロダクトの価値を顧客がすぐに理解できるようなものであればよい(Spotifyなど)ですが、そうでなければ、プロダクトを理解してもらうまでに手厚いサポートが必要になるかもしれませんので、コストをかける必要があります。 結果として、セールス主導型が合うようです。

レッドオーシャン

レッドオーシャンでは競争相手は多いですが、同じようなプロダクトがすでにあるので、顧客はプロダクトをすぐに理解することができます。 そのため、手厚いサポートはそこまで必要ないことが多く、プロダクトの力で顧客のオンボーディングを低コストに抑えられる可能性が高いので、プロダクト主導型にあっています。

ブルーオーシャンのほうが良いと思っていたが...

個人的に、プロダクトを新しく作るならブルーオーシャンのほうが良いよなーと思っていたのですが、プロダクト主導型のサービスにおいてはそうでもないということですね。

確かに、グループ会社のTuneCore Japanが始まった当初は市場はブルーオーシャンでしたが、ユーザー数の伸びも緩慢でした。 今は、TuneCore Japanが市場を開拓してユーザー数が増えました。後発のサービスが出ても、このタイプのプロダクトについてユーザーがすぐ理解できてしまいますね。先駆者にはメリットもありますが、当然ですが、メリットに甘えていられるというわけではないですね。

トップダウンボトムアップ型の販売戦略

販売戦略によってもプロダクト主導で行くべきかどうかが別れます。

トップダウン

トップダウン型の販売戦略とは、企業の意思決定者に導入を決定させるやり方です。大企業の全体に一つのシステムを導入させようというような場合は、トップダウン型になります。経営層やそこに近い人たちに売り込んで、全社的に採用してもらうようなやり方です。 プロダクトの導入までに数ヶ月から数年かかる場合もあります。

営業やサポートに人も時間もお金もかかるので、セールス主導型のプロダクトに向いています。

ボトムアップ

ボトムアップ型の販売戦略は、大きな決裁権のある人というよりは、実務者が実際に使ってみて、導入されていく形です。 実務者が簡単に使いはじめられ、理解しやすいものである必要があります。フリーミアム、フリートライアルが向いています。 消費者向けのプロダクトであれば、そもそもトップダウンということはないですね。

プロダクト主導型のプロダクトに向いています。

プロダクトの価値をいかに速く示せるか?

プロダクト主導型のプロダクトでは、プロダクトの価値をユーザーにいかに速く示す(体験させる)かが、重要です。フリーミアム、フリートライアルであれば、すぐにユーザーがプロダクトを体験することができます。

フリーミアムかフリートライアルか?

自分のプロダクトがプロダクト主導が向いているとわかった場合に、フリーミアムとフリートライアルのどちらが向いているのかを考える必要があります。

productled.com に、下記のクイズが用意されています。クイズに答えることで、どちらが向いているか判定することができます。 https://productled.com/quiz/

ただ、二択ではなく、フリーミアムとフリートライアルのハイブリッドという選択も可能です。

ハイブリッド型

既存のプロダクトとは別に新規事業としてたちあげて、いずれかのモデルを試してみるとか、フリーミアムだけど、ブロックされている機能をフリートライアルで提供できるとか、フリートライアルから有料版に移行しないユーザーに、フリーミアムの案内をだすとか、などがあるようです。

ユーザーに価値を示すと言っても?

本当に自分たちのプロダクトの価値を理解しているのが、正しく伝えられているのかを確認することが必要です。UCD Frameworkが紹介されています。

UCD Framework

UCDは、Understand、Communicate、Deliver で、このサイクルを回して、プロダクトの価値とユーザーがプロダクトから期待する価値をギャップがないものにしていくフレームワークです。

  1. Understand(理解する) ... 主観的分析、データドリブンアプローチ、ストレステスト
  2. Communicate(伝える) ... 経済的分析、マーケットリサーチ、適切な価格ページの開発
  3. Deliver(提供する) ... Valueのギャップの特定、定常的な最適化

プロダクトの価値の理解(Understand)

顧客はプロダクトを買っているわけではなく、プロダクトの生み出す「成果(=アウトカム)」を買っています。 Slackは「チャットツールを売っている」わけではなくて、「チームの生産性やコミュニケーションの向上を売って」いて、顧客はそれを望んでいます。

1. 機能的対価

プロダクトのメイン機能。顧客が実際に解決したい問題に対応するための機能に対する対価。

2. 感情的対価

使っていることで得られる感情的な対価。何らかの発見が得られるとか、喜びが得られるとか。 UXデザインの法則に「ピークエンドの法則」というものがあります。プロダクトの感情的対価のヒントになるかと思います。

3. 社会的対価

使っていることで得られる社会的な対価。使っていることによる他者(上司、部下、同僚、外部)からの評価など。「あのサービス使ってるんだ、イケてる」みたいに思われたりすることとかも、含まれますね。

プロダクトから得られる価値を測る

プロダクトの価値を測るためのバリューメトリクスには、2種類のものがあります。

  1. 機能的メトリクス ... 機能の使用頻度によって価格がスケールするようなケース
  2. 対価ベースのメトリクス ... 例えば、動画の視聴数が顧客に利益を生むようなケース

機能を差別化することで価格をあげると、高い解約率を招く場合があり、その代わりに、バリューメトリクスを設定するほうが75%低い解約率になるとのことです。対価ベースのバリューメトリクスであれば、さらに40%低い解約率になるとのことです。

バリューメトリクスの例

  • Slack ... メッセージ数
  • PayPal ... レベニューの量
  • calendly ... スケジュールされたMeetingの数
  • Notion ... 招待されたプロジェクトメンバーの数
  • INTERCOM ... 開始されたチャットの会話の数
  • DropBox ... 共有フォルダの数

https://drive.google.com/file/d/1mK3I5lrsBSTJSi2yk2J69vX797A9dLLf/view page. 7

良いバリューメトリックスの3つの条件

  1. ユーザーにとって理解しやすいもの
  2. ユーザーがプロダクトから得られる価値と連動している
  3. ユーザーがプロダクトから価値を得れば得るほど、大きくなる
ユーザー数課金はたいてい間違っている

大抵のサービスがユーザー数で課金していますが、ユーザー数が増えたからといって、ユーザーが得られる価値が高まるかどうかは不明なので、ユーザー数に対して課金するのは良くないと書かれています(Slackのようなコミュニケーションツールは別)。

ユーザー課金チェックリストがあるので、これがすべてYesでなければ、ユーザー数課金にするべきではないとのことです。

バリューメトリクスを見つける方法
1. 条件に合致するメトリクスを探す

プロダクトのメトリクスになりそうなものをリストアップして、前述の3つの条件にあてはまるかどうか確認すると良いとのことです。

2. データドリブン・アプローチ

コアユーザーと解約ユーザーの行動の分析して、バリューメトリクスを作るアプローチです。

コアユーザーは...

  1. 普段どのように使っているか?
  2. プロダクトでやらないことは?
  3. オンボーディングの際に、最初にやること
  4. プロダクトから価値を多く得て成功している人の共通点

解約ユーザーは...

  1. コアユーザーとのユーザージャーニーの違いは?
  2. 具体的にどのようなクティビティが違うか?解約ユーザーが達成できたこと、達成できなかったこと。
  3. ターゲット市場内のユーザーだったか?
  4. 主な解約理由は何か?
バリューメトリクスの有効性判断(ストレステスト)

バリューメトリクスを作っても主観的に判断しているだけだと正確ではないです。 作ったバリューメトリクスを、ユーザーに価格面から「最も好ましいもの」「最も好ましくないもの」を選んでもらいます。

「ユーザーが好ましいと思っている」=「ユーザーが対価を受け取っているもの」なので、自分たちが設定したバリューメトリクスがユーザーの考えと合っているか確認できます。

プロダクトの価値を伝える(Communicate)

プロダクト主導型のビジネスでは売上モデルと顧客獲得モデルはは密接しており、連動しています。 プロダクト主導型のビジネスでは、プロダクトによって顧客を獲得するので、プロダクトがまずければ顧客は獲得できないですし、プライシングが複雑で理解しにくいなら、サインアップするユーザー数にも影響がでます。 この両輪がうまく回っていないと、成長もできません。

プライシングモデルと顧客獲得モデルを正しく設定する方法

1. 料金ページを複雑にしない

「UXデザインの法則」に、「ヒックの法則」というのがあります。「意思決定をさせたいなら選択肢は少ないほうが良い」というものです。 大抵のサービスで、プランの選択肢は3つ程度に抑えられています。 PLGでは、5秒テスト(5秒以内に選択できる)に合格できないなら、顧客獲得機会を失っているとあります。

2. 有料プランにアップグレードする必要がなくなるような無料プランは作らない

顧客が無料でい続けてしまってはビジネスにならないので、無料プランですべて済んでしまうようなプラン設計では困ります。

3. ダウングレードしやすい設計にしない

有料サービスをフリーミアムにしようとする場合に、有料ユーザーが無料プランに移ってしまう可能性がありますが、有料プランのユーザーを分析して、無料にする機能しか使っていないユーザーがどれくらいいるかとか、無料にすることで見込める新たな顧客はどれくらいか、とかを考えると良いようです。ユーザー数や配信数であれば、減ることは少ないですね。

プライシング戦略

需要供給に基づいて決める「適正判断型」や、サービスにかかっているコストに利益をプラスする「コスト・プラス型」や、競合の価格と比較して決める「競合ベース」。プロダクトが提供する価値をもとに決める「バリューベース」と、価格を決定する方法は複数ありますが、SaaSの価格を決定するならバリューベースでやるべき、とのことです。

プロダクトの価格の決め方

「経済的価値分析」と「市場調査とユーザー調査」の2つの方法が紹介されており、データやユーザー数が少なく、ユーザーと直接価格について話せる機会がない場合は、前者。逆であれば、後者が良く、後者のほうが正確とのこです。

経済的分析

経済的価値分析では、プロダクトの価値のところで上がっていた、3つの対価。 「機能的対価」「感情的対価」「社会的対価」を実際の金額にしてみて計算する(社会的対価は計算が難しいのでオプショナル)。 その価値の1/10の価格にすると良いとのことです。ユーザーが支払う価格の10倍の価値を提供するということですね。 例えば、あるサービスを使うことによって、今まで一日かかっていた作業が数分で済むようになるなら、1日分のその人のコストが機能的な対価といえますね。 感情的な対価は、この機能を使って得られらる機能的対価にたいして、いくら払ってもよいかということですが、「この仕事が自動化できるんだったら、毎月1万円くらい安いもんだ」みたいなことですね。社会的対価と同様で、ちょっと測りにくい気もしますけどね。

市場調査とユーザー調査

市場調査とユーザー調査では、価格をいくつか用意して、「高すぎる」「安くない」「高くない」「安すぎる」価格を選んでもらうという方法が書かれています。より単純化して、「納得できる」「高い」価格の二択でも良いとのことです。

分析方法は、この辺に書いています。

価値を提供する(Deliver)

プロダクトの説明を読んだり、説明動画を見たりして理解する「知覚価値」と実際使ってみたときの「体験価値」にギャップがある(バリュー・ギャップがある)とユーザーはがっかりします。この2つが一致するのが理想です。 バリュー・ギャップが大きいと、ユーザーはプロダクトを二度と使わなくなるので、バリュー・ギャップの解消に力を入れる必要があります。 「できると思って登録したのに、できないんかー(できるけど、えらい複雑だなーとか)」みたいな経験ありますよね。それがバリュー・ギャップです。

バリューギャップの解消

下記にあげるようなバリュー・ギャップを解消していく必要があります。

アビリティ・デッド

プロダクトが提供すべき価値をユーザーが受け取れていない状態です。別に機能がバグっているという話だけではなく、仮登録したけど、実際の機能は使わずに離脱してしまった、というのも含まれます。

(プロダクトの提供者が)顧客がプロダクトを購入する理由を理解していない

顧客がプロダクトに期待している価値を正しく理解して、その価値を可能な限り最短で体感できるようにする必要があります。

価値の提供に失敗している

ユーザーへの事前の認知(プロモーションやサイトの説明)と実際提供される価値が違っていれば、それは問題です。 単純に機能的なものもあれば、すぐに使えると書いているのに、実際は一週間経たないと使えない、みたいな時間的なものもあり得ます。

UCDフレームワークで考えてみる

Wanoのグループ会社の事業の例で考えてみました。D は実装部分なので、わからない場合は省略しますというのと、関わっていない事業も多いので、テキトーに想像しました。数分で考えたものなので、超、雑です。

なお、下記のバリューメトリックスの中で「3つの条件」と書いているのは、先に紹介した、下記の3条件のことです。

  1. ユーザーにとって理解しやすいもの
  2. ユーザーがプロダクトから得られる価値と連動している
  3. ユーザーがプロダクトから価値を得れば得るほど、大きくなる
TuneCore Japan

チューンコアジャパンのサービスです。僕はほぼほぼ関わってないので、テキトーなことを書いています。

  1. Understand(プロダクトの価値)
    • 価値
      • 音楽を発表したい・販売したいという人が簡単に配信でき、売上を得ることができる。
    • 対価
      • 機能的対価
        • 自分の曲を様々なストアに配信できる
      • 感情的対価
        • ストアに楽曲が並ぶ
        • リリースして世間の人に聞いてもらえる
        • 売上が上がる
      • 社会的対価
        • 多くの人に聞いてもらうことでアーティストとしての認知度が上がる
    • バリューメトリクス
      • リリース数
        • リリース自体が目的の場合3つの条件にあてはまるが、売上が目的の場合は3つ目の条件(価値を獲れば得るほど大きくなる)は当てはまらない
      • 楽曲の売上
        • 3つの条件に当てはまりそうです
  2. Communicate(伝える)
    • 現在のプランはリリース数による課金です
      • そもそも、フリーミアムでもフリートライアルでもないですが、クーポンやキャンペーンででフリートライアル化している場合はあります
  3. Deliver(届ける)
    • 省略
ポイントモール

僕がメインで関わっているEDOCODEの事業ですが、そもそもフリーですね。 普段から使ったほうがお得ですが、ふるさと納税とか旅行とかで割と大きい金額使うときは、使わない手はないですよ!(「自分の使っているクレジットカード名 ポイントモール」とかで検索してみてください)

  1. Understand(プロダクトの価値)
    • 価値
      • ポイントモールを経由して買い物をすることで、通常より多くのクレジットカードのポイントを貯めることができる
    • 対価
      • 機能的対価
        • クレジットカードのポイントが貯められる
      • 感情的対価
        • ポイントが溜まって嬉しい
        • たまったポイントで買い物をお得にできる
      • 社会的価値
        • 特に無い気がしますが、余計にポイントが貯められていることを自慢できるかも?
    • バリューメトリクス
      • 獲得ポイント数
        • 3つの条件にあてはまりそうです
  2. Communicate(伝える)
    • そもそもフリーなので、料金設計はないです
    • サイトに来た人に価値を伝える工夫は必要そうです
  3. Deliver(届ける)
    • モールによりますが、ポイントを獲得した結果を見ることができます(ただし、仕組み上タイムラグが大きい)
    • モールによりますが、獲得できるであろうポイントの対象ショップを見ることができます(タイムラグが数日あります)
PUSHCODE

EDOCODEのもう一つの事業です。こちらにはそんなに関わってないので、テキトーなことを書いています。

  1. Understand(プロダクトの価値)
    • 価値
      • WebPushを通じて、サイトの運営者がユーザーとより良いコミュニケーションができるようになる
    • 対価
      • 機能的対価
        • ユーザーにWebPush通知を好きなタイミングで送れることで、タイミングを逃さずユーザーにアプローチできる
        • サイトのPVを増やすことができる
        • カゴ落ち防止に使うことができる
      • 感情的対価
        • 許諾数や通知に対する反応がわかることで、サイトに集まるユーザーが増えていることが実感できる
        • カゴ落ち防止からあがった売上を確認することで、売上が上がっていることが実感できる
      • 社会的価値
        • 効果的にWebPushを使うことで集客や売上を上げることができ、社内で評価される
    • バリューメトリクス
      • 配信数
        • 3つの条件にあてはまりそうですが、コミュニケーションが片思いに終わる場合もあるので、3つ目の(価値を得れば得るほど...)条件は使い方に依存しそうです
      • 配信から上がる成果(PVや売上等)
        • 3つの条件に当てはまりそうですが、1つめの条件(理解しやすいか)はちょっと考えどころというか見せ方に工夫がいるかもしれません
  2. Communicate(伝える)
    • 現状のプランは配信数です。一定配信数以下は無料のフリーミアムなプランです
    • 配信から上がる成果をプランに組み入れるのは、利用者にわかりにくいような気はしますが、ECサイトに特化したプランとかであれば、ありかも?
  3. Deliver(届ける)
    • 省略

トリプルAスプリント

プロダクトを改善するために、何が問題なのかを、迅速に特定し、解決し、効果を測定しなければなりません。 そのためのプロセスモデルです。

  1. Analyze(分析)
  2. Ask(質問)
  3. Act(実践)
Anayze(分析)

ビジネスのインプットとアウトプットを特定し、それぞれを分析します。

アウトプットは、例えば、以下のようなものです。

  • サインアップ数
  • 有料会員へのアップグレード者数
  • ARPU(顧客平均単価)
  • 顧客解約率
  • ARR(年間経常収益)
  • MRR(月間経常収益)

これらのアウトプットを生み出しているインプットが何なのかを特定する必要があります。

Ask(質問)
  1. 目的はどこか?
    • 売上とか、ビジネスがもっている目標
  2. 目的にたどり着くには何をすればよいか?
    • 解約率、ARPU、顧客数のいずれに注力すべきか?
    • 通常、解約率を下げればARPUは改善されるので、その後に顧客数を伸ばすことでARRが伸びる
    • それぞれがビジネスにどのような影響を与えているか把握する
  3. どのインプットに賭ければ良いか?
    • 注力すべきところがわかったら、それに一番効くインプットは何なのかを特定する。

改善すべき/導入すべきインプットが見つかったら、ICEフレームワークで、優先順位をつける。

ICEフレームワークは、

  1. Inpact(影響度) ... インプットがアウトプットに与える影響度
  2. Confidence(自信度) ... インプットがアウトプットを改善する自信がどれくらいあるか
  3. East(容易度) ... どれくらい容易に導入できるか

この3つを各5点ずつ配点して、合計点が高いものが優先度が高いとみなすという方法です。

Act(実践)

優先順位が付けられたら、後は実践するだけです。

ボウリングレーン・フレームワーク

穴を開ける方じゃなくて、ピンを倒す方のボウリングです。

「プロダクトの価値を顧客が正しく体験できている状態」を、

  • 「ボール」が「顧客の現在地」
  • 「ボウリングのピン」が「得られる成果」
  • 「ピンに届くまで」が成果が得られるまでの「時間経過」

を表しています。「ボール」が「ピン」に届いたら、「顧客」が「プロダクトからの成果」を得たということになります。

例えば、超単純に

  1. Web広告からプロダクトのサイトに遷移 => ボールの開始位置
  2. プランページを読む => ピンまでの途中(1)
  3. サインアップ => ピンまでの途中(2)
  4. 何かしらの成果を得る => ピンが倒れる

といった感じです。

この過程でプロダクトからユーザーが離れてしまうことを、ボールがガターに落ちることで表しています。

やることは、まず、

  1. ストレートラインを作成する
  2. プロダクトバンパーを設置する
  3. コミュニケーションバンパーを構築する

ストレートラインとは?

ユーザーが脇道にそれないように、プロダクトの価値を最短で感じ取ってもらえる最短距離をストレートラインと呼んでいます。

ユーザーがプロダクトの成果を得るために、それに「必須のもの」「オプショナルなもの」「不要なもの」のようなラベリングをして、ストレートラインは必須なもののみにして、最短で価値を提供できるようにすべきです。

バンパー

子供がボウリングで遊ぶときに、両脇にバンパーを設置しているの見たことありますかね?あれです。 プロダクトバンパーとコミュニケーションバンパーは、ユーザーがガターに落ちないように、防御するためのものです。

プロダクトバンパー

プロダクトバンパーは下記のようなもので、必要なものを取り入れたほうが良いです。

  • ウェルカムメッセージ
    • サインアップしたタイミングでユーザーに送るメッセージ
  • プロダクトツアー
    • 最短でユーザーに価値を届けられるように、ユーザーに何をするべきなのかを伝える。余計な選択肢は隠す。
  • プログレスバー
    • 価値が体感できるまで、現時点で何%まで来ているのかを視覚的にわかるようにする
  • オンボーディング・チェックリスト
    • プログレスバーが100%まで終わった後に、やるべき細かいタスクをチェックリストとして提示する
  • オンボーディング・ツールチップ
  • エンプティステート
    • プロダクトに最初にログインしたときに、ユーザーがこれから何をしないといけないかを見せる。
コニュニケーションバンパー

コミュニケーションバンパーは以下のようなものです。

  • ユーザーオンボーディング・メール
    • ウェルカムメール
    • 利用ガイドメール
    • セールスタッチメール
    • ...
  • プッシュ通知
  • 説明動画
  • ダイレクトメール

省略しますが、このあたりに、どういう観点で作るべきか、文例まで含めて載ってますのでかなり参考になると思います。

ユーザーごとの平均収益(ARPU)をあげる

ユーザーは誰か?というのは、疑問を挟む余地がないようにも思えますが、単にアカウントを作った人ではなくて、利用料を払っているユーザーとみなしたほうが良いとのことです。 ユーザーの定義が決まったら、ARPU(ユーザーごとの平均収益)の計算は以下のとおりです。

ARPU = MRR(月間経常収益) ÷ ユーザー数

ARPUを上げることがプロダクトの成長につながります。

チャーンビーストの退治

チャーン(解約)は低いに越したことはありません。PLGでは、「顧客維持率を5%あげるだけで、売上を25-95%改善できる」と書いています。

チャーンは単にユーザー数をみるだけでは不十分で、下記の3種類を見たほうが良いとのことです。

  1. カスタマーチャーン ... 一定期間に失ったユーザー数
  2. レベニューチャーン ... 一定期間に失った売上額
  3. アクティビティチャーン ... 解約リスクがあるとされるユーザー数

おしまい

以上、Product-Led Growthのまとめでした。書籍(Webでも)の方では、実例も豊富ですし、実際どうやったら良いか、まで書かれているので、ぜひそちらを読まれるのをお勧めします。

この記事の内容はほぼ書籍の内容ですが、https://productled.com/ にも関連情報は結構あり、参考になりました。

きっとこんなまとめ読んでる暇があったら、本買えば良かったと思うことでしょう!(その前に、長いからここまでたどり着く前に本を買ってるかもしれませんが)

人材募集

現在、Wanoグループでは人材募集をしています。興味のある方は下記を参照してください。 group.wano.co.jp

EDOCODEについては、以下をご参照ください。 go.edocode.co.jp

PulseAudioの入出力やボリューム設定をコマンドで行う

AfterShokz(現:Shokz)のOpenCommを使っていますが、Ubuntu で利用していると、プロファイルがA2DPを選択されてしまい、毎朝仕事をする前に、Handsfree Head Unit(HFP)に選択し直さないといけないという作業があったんですが、いい加減ちゃんと調べようと思って調べました。

pactl list cards を使って利用可能なプロファイルを検索する

下記のコマンドを実行すると、

LANG=C pactl list cards |less |grep -E 'Name|profile'

下記のようになります(他にも色々でますが)。

        Name: bluez_card.**_**_**_**_**_**
                        Part of profile(s): a2dp_sink, handsfree_head_unit
                        Part of profile(s): handsfree_head_unit

上記の通り、下記の2つのプロファイルがあります。

  • a2dp_sink
  • handsfree_head_unit

pacmd set-card-profile でプロファイルを指定する

下記のようにpacmdコマンドに、カード名とprofileを渡して実行すると、プロファイルが切り替わります。

pacmd set-card-profile bulez_card.**_**_**_**_**_** a2dp_sink
pacmd set-card-profile bulez_card.**_**_**_**_**_** handsfree_head_unit

pacmd set-default-source で入力(マイク)も選択する

a2dp_sink を選んでいると、マイクをShokzにできないので、会議とかに使う場合は、HFPの方を選択します。その場合には、マイクも設定したいですね。

ちなみに、PulsAudio の sinks というのは、出力(スピーカー)のことで、sources というのは、入力(マイク)のことになります。

pactl list sources

で、検索できるんですけども、A2DPを選んでいると、出てきませんので、サウンド設定で変更してください。

pactl list sources |grep bluez_source 

下記のようでてきます。

        Name: bluez_source.**_**_**_**_**_**.handsfree_head_unit

pacmd set-default-source で入力を選択する。

pacmd set-default-source bluez_source.**_**_**_**_**_**.handsfree_head_unit

音量も設定する

A2DPHFPの切り替えはこれで大丈夫なんですが、mixerで同じ音量にあわせているとA2DPHFPで音量が微妙に異なって聞こえます。 骨伝導イヤホン使ってると、声質によりますが、音量が大きいと、すごいこそばゆい感覚になる時があり、大きい音量だと微妙な場合があります。

こちらもコマンドで設定できます。

※ちなみに、A2DPの出力ボリュームが0になってることに気づかず、なんで聞こえないんやろって、なってました...ので、聞こえない人は、pavucontrol --tab=4 とかで調節しましょう(以下に紹介するコマンド使ったら良いんですが)

pactl set-sink-volume で、出力の音量を設定する

下記のコマンドでsinksを特定します。これも、A2DP選んでいると、HFPの設定は出てこないので、設定で選び直してから、それぞれ特定してください。

LANG=C pactl list sinks| grep Name | grep bluez_sink

下記のように出てきます。

        Name: bluez_sink.**_**_**_**_**_**.a2dp_sink

サブコマンドのset-sink-volume で設定できます。

pactl  set-sink-volume bluez_sink.*_*_*_*_*_*.handsfree_head_unit 75%
pactl  set-sink-volume bluez_sink.*_*_*_*_*_*.a2dp_sink 90%

pactl set-source-volume で、入力の音量を設定する

下記のコマンドでsource を特定します。

LANG=C pactl list sources| grep Name |grep bluez

下記のようにでてきます。

        Name: bluez_sink.**_**_**_**_**_**.a2dp_sink.monitor

サブコマンドのset-source-volume で設定できます。

pactl set-source-volume bluez_sink.**_**_**_**_**_**.a2dp_sink.monitor 135%

マイク音量が小さいとよく言われるので、大きめにしてみました。

終わり

このコマンドを、こないだ作ったSTREAM DECKをコントロールするプログラムに組み込んで、仕事用のページとか、ZoomやMeetが、Active windowになったら、コマンドを実行するみたいにしてみました。後は、PodCastYoutubeとかのbookmark的なページにいったら、A2DPに変えるようにしてみました。

ちなみに、OpenCommについて

仕事で一日中つけっぱなしで使っていますが、かなり気に入ってます。

朝から晩まで10時間以上仕事でつけっぱなしでも(ずっと会議してるわけではないですが、僕の仕事上、会議はかなり多い)、充電切れない。 充電忘れたときでも、5分の充電で2時間通話くらいの充電してくれるので、昼休みとかに充電しとけば基本問題ないかなと思います。

欠点としては、頭痛のときには、つけっぱなしはちょっとしんどいかも、くらいです(僕は頭痛もちではないので、そんなことはあんまりない)。