#cohackpp ぱ陣営LT: Perlでエモい記念写真を撮ろうチャレンジ2024 (ディレクターズカットエディション)

connpass.com

"LTガチバトル 新郎新婦+3本勝負" 枠において、ワンオブ3本枠として発表した内容のディレクターズカット版です。スライドだけ公開してもなんのこっちゃとなりそうなので、時間があったらこういう内容を喋っていた…という内容を書いてみよう、という趣旨でやっていきます。


赤地に白文字で大きく おめでとうございます!! と書いたスライド

(ここで完成物を使ったデモとして記念写真を撮影した)

近年、オールドデジカメを使うとエモい写真が取れる、という流行というか風潮がありますね。

デジカメ専用機だけでなく、ニンテンドーDS/3DSの内蔵カメラや、PSPのカメラユニット、あるいは古めのスマホに対しても同様の目線が向けられているように思います。

で、オールドデジカメの写真のどこがエモ成分なのか、最新のカメラで取った写真をそれっぽくすることもできるんじゃないのかと思うわけですが、単に画像ファイルの解像度を揃えるとか、JPEGの圧縮率を上げてみるくらいだとそこまでそれっぽくはならないことが多いでしょう。

iPhone 15で撮影した新宿の夜景iPhone 15で撮影した新宿の夜景について、画素数を単純に下げるだけでは昔のカメラの写真みたいに見えないことを説明する写真iPhone 15で撮影した新宿の夜景について、画素数を下げ、JPEGの圧縮率を上げただけでは昔のカメラの写真みたいに見えないことを説明する写真
BeforeがiPhone 15の写真ママ、Afterその1が解像度だけVGAに下げた版、Afterその2が解像度をVGAに下げてJPEGの圧縮率を上げた版

自分が思うに、これはデジタルカメラの構成要素のすべてが進化し続けているためであり、画素数であるとかJPEGの圧縮ノイズの乗り具合ではない場所になんらかの味が存在しているということなのではないでしょうか。本気で再現するのであればレンズ・センサー・画像処理アルゴリズムすべてについて味を考慮する必要がありそうです。

デジタルカメラはレンズ・イメージセンサー・画像処理の組み合わせで構成されていることを説明するスライド

今回は、ホンモノを持ち出すことでその味を手っ取り早く獲得することを考えました。クローゼットで眠っていたオールドデジカメ…ではなく、オールドWebカメラを使ってみます。

Microsoft LifeCam StudioというWebカメラの紹介をするスライド。2011年発売、800万画素、レンズのスペックは35mm換算でF2/24mm

2011年発売のMicrosoft LifeCam Studioという機種になります。

Webカメラはパソコンで使うものなので、パソコンからいろいろ制御する仕組みが整っています。USBがまずあって、その上にUSB Video Device Class (通称UVC)という仕様が載っている、というような構成が一般的なのかなと思います。

パソコンがWebカメラを扱う部分のアーキテクチャの説明

OSの中でUSBとUVCがどのように扱われているかについてはそれぞれ異なると思いますが (上図の「何かしら」部分)、macOSであればUVC接続のカメラはAVFoundationというAPIを使って制御するのが一般的です。

macOSがWebカメラを扱う部分のアーキテクチャの説明

で、今回は ぱ —— Perl陣営を代表してのLTということなので、もちろんPerlからこのスタックのどこかしらにアクセスしていくことで写真を撮っていきたいと思います。

macOS上で、Perlを用いてWebカメラを操作する方法の案の一覧

一番最初に思いつく素朴なやり方は、system() などの外部コマンドを呼び出す関数からffmpegなどを呼び出し、そこでカメラデバイスからの画像の取得を完結させてしまうことではないでしょうか。しかしながら、これは素朴すぎであり、「Perlで写真を撮った」とは言いにくいと思います。

(本番ではこのへんまで喋って時間切れになった気がする)

先に説明したように、macOSにおける正攻法はAVFoundationを呼び出すことなのですが、これにはSwiftまたはObjective-Cのどちらかを使わないといけません。

PerlにはXSというネイティブ拡張の仕組みが用意されているので、そこからObjective-CのAPIを呼び出すことは理論上可能なはず…なのですが、自分はObjective-CもXSも不慣れなのでちょっとなかなかしんどそうです。

macOS上でPerlを使ってWebカメラを操作するにあたり、libuvcを使う作戦を考えている説明

ここで、libuvcというライブラリの存在を思い出します。普通のC言語製ライブラリなので、AVFoundationよりは簡単にXSから呼び出せるのではないかという魂胆でのチョイスです。

というわけで、minil new -p XS Acme::SuperUVC::XS して開発開始です。

minil コマンドとは、MinillaというCPANモジュールのオーサリングツールです。minil new は、PHPでいうと composer create-project、Rubyでいうと bundle gem に相当する操作になります。-p XS オプションを付けることで、XSを使ったプロジェクトの雛形が生成されます。

(bundle gem のところを bundle init と誤って紹介していました、失礼しました。指摘いただいた id:onk さんありがとうございました :pray:)

で、自分はXSを書いたことがなかったので、ここからは緊急入門しながらの開発となります。

6時間ほどの苦労の結果、libuvcのサンプルコードの断片をPerlから発動させるような素朴なライブラリの作成に成功しました。正直思ったよりも時間がかかってしまったのですが、ここでハマったポイントについては最後に言及します。

で、libuvcのサンプルコードの断片をPerlから発動させることはできたのですが、UVCデバイスを実際に開くuvc_open() がなんかデバイスを開けない的なエラーで終了してしまいます。

uvc_open関数がAccess denied (-3) なるエラーを出力し失敗しているターミナルのログ

なぜこのようなエラーになっているのか見当がつかないので、ログを増やしたいのですが、libuvcにはそういう設定がなさそうでした。隠しオプションみたいなのがあったりしないだろうかと思ってlibuvcのソースコードを眺めてみたところ、ビルド時の設定として ENABLE_UVC_DEBUGGING というフラグを与えてやればよさそうなことがわかります。

github.com

CMakeLists.txt で定義されているフラグなので cmake -DENABLE_UVC_DEBUGGING=ON . みたく指定したらよさそうです。

よさそうだったのですが、ここでまず -DENABLE_UVC_DEBUGGING=ON を指定したときに、見慣れない名前のライブラリが存在しないというエラーでlibuvcのコンパイルが完走しなくなってしまいました。以下のpull requestで言及があるのですが、Android NDK由来のライブラリのようです。

brew install android-ndk してからしばらくゴニョゴニョ試行錯誤していたのですが、NDKのどの部分が要求されているのか見当がつけられませんでした……。コードをざっとgrepしてみて、CMakeLists.txtの当該の記述部分を削除してもコンパイルできて、動きもするんじゃないかという予感がしたので、実際にそうしてみたところ、少なくとも自分が使った部分は普通に動いてしまいました……。

(今気づいたのですが、Android NDKないならないでもコンパイルできるようにするpull requestがマージされずにcloseされていたので、これがマージされていたらうっかり悩まずに済んだのかもしれません)

libuvcのデバッグログ有効バージョンがコンパイルできた話に戻ると、以下のようなエラーメッセージが得られました!

not claiming interface 0: unable to detach kernel driver (Access denied)

わかりやすい!しかしながら、root権限が必要そうな操作をしているわけでもないはずなのにkernel driverがどうのでAccess denied、と言われているのはかなり不穏です。不穏ですが、そう言われてしまったときに考えることは一つ —— sudoつけたら動くってコト!?

sudoをつけたら動いてしまいました!下の方に callback! みたいなログが連なっていますが、これはlibuvcがカメラデバイスの初期化に成功し、実際に映像が得られたときのコールバック関数まで呼ばれていることを示しています。

さて困りました。カメラからの映像が得られたのは良いニュースですが、root権限でないと動かないのは、今回のようなめでたい席のLTにはあまり相応しくないように思います。なんでこんなことになってるんだ?と思って調べてみたところ、近年のmacOSのデバイスドライバ関連のAPIの変更によるものではないか、というissueがlibuvcやlibusbに複数立っていました!

先に調べとけという話なのですが、こういう行き当たりばったりを楽しむのも人生ということで………。

というわけで、先程の図に戻ります。近年のmacOSにおいては、Objective-C + XSを避けてPerlからWebカメラにアクセスするのはなかなか難しそうなことがわかりました。

今度は本当に困りました。途方に暮れながら、ふとmetacpanで "avfoundation" と検索してみたら………

metacpan.org

なんかそれっぽいパッケージがヒットする!!!!!

metacpan.org

PDL::OpenCV::Videoio!!名前もそれっぽすぎる!!!マジで??

調べてみたところ、これはPDL: Perl Data Languageというフレームワーク、Pythonでいうところのnumpyみたいなやつ、の一部のようです。数値計算したいときって画像処理の話であることも多いよね、的な感じ (推測) でPDLにOpenCVのバインディングが存在しており (PDL::OpenCV)、そのOpenCVの一機能であるUVC撮影グッズに対応したPerlバインディングも用意されている (推測)、という状況のようです。

ウキウキで試そうと cpanm PDL::OpenCV してみたところ、執筆時点でmetacpanにアップロードされているバージョン0.001はコンパイルに失敗しインストールできませんでした…が、GitHub上のmainブランチ (執筆時点で b307d7b) の内容はインストールできました。

(人力bisectしてみたところ、おそらく以下の修正でコンパイルエラーの原因が修正されているみたいです)

github.com

さて、インストールできた PDL::OpenCVPDL::OpenCV::Videoio を使ってみます。アプリケーションがカメラの権限を要求するあの見慣れたダイアログが、iTermという見慣れないアプリケーションから出てきているのが愉快です。もちろんroot権限不要で実行できています。

で、写真が撮れたのですが……なんか色味が変!あとなんかひっくり返っている!

Perlで撮影できた写真。上下が反転しているほか、本来は肌色である人間の指や、木の色であるテーブルが真っ青に撮影されており、おかしい

最初は自分の顔とか手を適当に撮影していたのですが、フォトショのレタッチ機能では狙った色味に修正できないくらい変な色味だったので、テレビなどで用いるカラーバーを撮影してみています。自分の人生のなかでこれがガチの用途で役に立つことがあるなんて……。

映像テスト用のカラーバーを撮影した、色味がおかしい写真と、実際に表示されるべきカラーバーとの比較

見にくかったので上下反転してしまいました。下部の正解と見比べると……なんか右側がわかりやすいですね。赤と青がきれいに入れ替わっています。

映像テスト用のカラーバーを撮影した、色味がおかしい写真と、実際に表示されるべきカラーバーとの比較

ここで突然記憶が呼び起こされ、BGR形式の存在を思い出します。

qiita.com

教えに従い、適切に色変換をかますと…

Perlから適切な色味で撮影できた写真

正しい配色になりました!自分の部屋が暗くて色温度高めなのは実際こういう感じなので正解です。背景の生活感がヤバいのはすみません……。

ということで、冒頭に実施したPerl製記念撮影グッズはこうして完成したのだ、ということでした。

実装をベロっと貼ってしまうとこういう感じでした:

# Camera.pm

use PDL;
use PDL::OpenCV::Videoio;
use PDL::OpenCV;
use PDL::OpenCV::Imgproc qw(cvtColor);
use PDL::IO::Pic;
use PDL::Ufunc; # for max

use File::Temp qw/tempfile/;

use MIME::Base64;

sub launch_camera {
    my ($class, %args) = @_;

    my $wb_temperature = $args{wb_temperature} || 100;
    my $exposure = $args{exposure} || -8;
    my $device_index = $args{device_index} || 0;
    my $save_dir = $args{save_dir} || $ENV{HOME};

    my $obj = PDL::OpenCV::VideoCapture->new4($device_index);
    my $is_success = $obj->open3($device_index);
    die unless $is_success;

    # このあたりはOpenCVの設定そのまま
    $obj->set(PDL::OpenCV::Videoio::CAP_PROP_AUTO_WB, 1);
    $obj->set(PDL::OpenCV::Videoio::CAP_PROP_WB_TEMPERATURE, $wb_temperature);
    $obj->set(PDL::OpenCV::Videoio::CAP_PROP_AUTOFOCUS, 1);
    $obj->set(PDL::OpenCV::Videoio::CAP_PROP_AUTO_EXPOSURE, 0);
    $obj->set(PDL::OpenCV::Videoio::CAP_PROP_EXPOSURE, $exposure);

    while (1) {
        # reset terminal
        print "\33[H\33[2J";

        my ($image,$res) = $obj->read;

        # BGR / RGB 変換
        my $img2 = cvtColor($image, PDL::OpenCV::Imgproc::COLOR_BGR2RGB());

        # 上下反転
        my $img3 = flip($img2, 0);

        # ↓ の wim (write image) では拡張子からファイル形式を推定してくれるので jpg を指定しておく
        my $tmp = File::Temp->new(SUFFIX => '.jpg');

        $img3->wim($tmp->filename);

        my $data;
        {
            local $/;
            open my $fh, '<', $tmp->filename;
            $data = <$fh>;
            close $fh;
        }

        # 今撮れた写真を iTerm2 Inline Image Protocol で出力
        my $base64 = encode_base64($data, "");
        print "\033]1337;File=inline=1:$base64\a\n";

        my $in = <STDIN>;
        chomp $in;

        # `s` で始まる入力が得られたら画像を保存して終了
        if ($in =~ /^s/) {
            # write $data to home
            {
                my $epoch = time;
                my $dst_filename = "$save_dir/$epoch.jpg";
                open my $fh, '>', $dst_filename;
                print $fh $data;
                close $fh;
                warn "wrote to $dst_filename\n";
            }

            last;
        }
    }

    $obj->release;
}
# runcamera.pl

use Camera;

use Getopt::Long 'GetOptions';

my $wb_temperature = 100;
my $exposure = -8;
my $device_index = 0;
my $save_dir = $ENV{HOME};

my $show_help = 0;

GetOptions(
    'wb-temperature=i' => \$wb_temperature,
    'exposure=i' => \$exposure,
    'device-index=i' => \$device_index,
    'save-dir=s' => \$save_dir,
    'help' => \$show_help,
);

if ($show_help) {
    print <<HELP;
$0 [options]
    --device-index=0
        Index of device(s)
    --wb-temperature=100
        ref. CAP_PROP_WB_TEMPERATURE
    --exposure=-8
        ref. CAP_PROP_AUTO_EXPOSURE
    --save-dir=$ENV{HOME}
        Directory to save images
    --help
        show this message
HELP
    exit;
}

launch_camera(
    wb_temperature => $wb_temperature,
    exposure => $exposure,
    device_index => $device_index,
    save_dir => $save_dir,
);

実質OpenCV入門やんけ!

iTerm2の画像埋め込み機能を使ってプレビューを表示し、満足できる写真が撮れたら s と入力すると指定したディレクトリに保存できる、という、いろいろ割り切りつつ意外と普通に使えてしまう感じに作れたのは良かったかなという感じです。

iterm2.com

さて、XSの世界に飛び込むにあたり、豊富な日本語資料に大変助けられました。その一部を紹介します:

一つ気づいたことがあって、おそらくMinillaって今回でいうとlibuvcみたく外部のライブラリに依存する形のXSを雛形そのままには書き始められないのかな?ということです。今回は時間がなかったので、まずModule::Buildを用いる設定に変えて、minil build で生成されるBuild.PLに直接 extra_linker_flagsオプションを記述し、その後は perl Build.PL だけ実行する、という荒くれた手段をとってしまいました。これは追ってフィードバックできればと思っています。

おしまい!

小田原城でMicrosoft LifeCam Studioで撮影した夕焼け
これは件のカメラで小田原城から撮った写真


というわけで、今回はめでたい席だったので、どう写真を撮ってもあとから見返したときにエモくなるのは確実だろうとは思うのですが、変なカメラで変な写真を撮ること自体が思い出になればいいかなと思ってこういうトークにしてみ……たのですが、デモの写真撮影で想定の10倍くらい時間を使ってしまったのが反省です。楽しんでもらえていたら幸いです!

画像処理の話はほぼしませんでしたが、デジカメがイメージセンサーから上がってきた画像をどう画像ファイルにしてんの?っていうちゃんとした話が気になる方には、こちらのエントリから始まる一連の連載が非常にわかりやすくオススメです。

uzusayuu.hatenadiary.jp


ここまでで紹介した内容は、以下に示す環境で開発・検証を行ったものです:

  • macOS Sequoia 15.1.1
  • MacBook Pro (M1, 2020)
  • Perl 5.40.0