ページ

2015-12-20

PowerShellでスクレイピングする (AngleSharp編)

これは、PowerShell Advent Calendar 2015の12/20分の記事です。Invoke-WebRequestとIEエンジンによるDOM処理とは違うやり方について書きます。

---
PowerShellを使うと、Invoke-WebRequestを使うことで、特にライブラリ等を準備せずともWebページをスクレイピングすることができる(参考:PowerShellでスクレイピング 後編 HTMLをパースする) 。

これはこれで便利なのだが、少し複雑なHTMLになると、Where-ObjectやSelect-Objectがだんだん増えてきてしまう。こんな感じである。



やっぱりDOMツリーをたどるなら使い慣れたCSSセレクタの記法が使いたいよね?ということで、今回はCSSセレクタが使える.NET用HTMLパーサライブラリ「AngleSharp」をPowerShellから使ってみるお話。本当はC#で馴染みのあったCsQueryを使おうと思ったのだけど「Not Actively Maintained」ということでAngleSharpにしてみた。

インストール

例によって適当なフォルダに移動してnuget.exeを使ってダウンロードする。
> nuget install AngleSharp

使い方

初期化

まず、AngleSharp.dllをロードし、loaderが設定されたconfigを使って、BrowsingContextを作っておく。

このBrowsingContextを使っていく。

GETリクエスト

シンプルにGETリクエストを出すには、文字列のURLを使ってOpenAsyncを呼び出す。そして返ってきたものに対して、最初に一致したものだけでよければQuerySelector()を、全て欲しい場合はQuerySelectorAll()を呼び出す。

例として、このブログの右側の「ブログ アーカイブ」の一番上のタイトル(画像参照)を取得するにはこんな感じである。



~Asyncは、流儀通りTask<T>が返ってくる。PowerShell世界では扱いに困るのでResultでひたすらブロックしていく(ひどい)

ほぼ同じであるが、もう一つの例として@mutaguchiさんのブログPowerShell Scripting Weblogの右側にあるMVPのタイトルを取得するにはこんな感じである。


各ブラウザの開発者ツール(F12)のコンソールで、document.querySelector('#ArchiveList')などとして、先に試しておくとよい。

POSTリクエスト

POSTにするには、OpenAsync()の第2引数にリクエストオブジェクトを生成する。POSTリクエストを作る便利メソッドはいくつかあり、例えばPostAsUrlEncodedを使うとこんな感じである。

$docができたら、あとはGETと同様に、QuerySelector()やQuerySelectorAll()を呼び出せばOKである。

感想

これで一応、AngleSharpをPowerShellから呼ぶことはできた。Where-Objectを連発するよりは見通しのよいものが書けると思う。ただし、AngleSharpの呼び出す部分は拡張メソッドだらけになっており、PowerShellから大変に呼びにくいので、うーんという感じ。やはりInvoke-WebRequestが、querySelector / querySelectorAllできれば万事OKだったのに、という感じであった。

2015-12-13

Loqui 0.6.4 リリース

今回のリリースは主に、昨晩の「Ubuntu 15.10 リリース記念オフラインミーティング 15.12」のあとの飲み会で、@ut_maito氏が初めて使っているところを見て、使いにくそうに見えた部分のうち、さらっと改善できそうな部分についての対応です。バグを見つけたので併せて直しました。

主な変更点はこんなかんじ。

* Shift+Enterで送信する機能の追加
→複数行モードのときキーボードで送信する手段がなかったので送信できるようにした。すでにCtrl+Enterは1行モード時にNOTICEとして送信にしていたので、Shift+EnterでPRIVMSGで送信。1行の側もShift+Enterで送信できるようにして、どちらもCtrl+EnterでNOTICE / Shift+EnterでPRIVMSGになるよう統一した。

* 初回起動時にアカウント設定ダイアログを表示
→最初に何していいかわからない感じが強く出ていたので、手っ取り早くできそうなところで、最初に使うことになるアカウント設定ダイアログを出すことにした。

* アカウント削除時にクラッシュするバグの修正
→アカウント設定の検証をしていたら、アカウント削除で落ちることがあるバグを見つけたので修正。

* アカウント設定からプロトコル選択の削除
* 不要な「ツール」メニューの削除
→拡張用に若干複雑なUIになっていたのだけど、多分追加しないので削除。プロトコル選択画面で選べるIP Messenger機能は若干実装されていたのだけど、使っている人はいないよね。


初回起動時はチャンネルバッファの部分が余っているので、ここにガイド的なことを書いてあげたいなぁと思ったのだけど、だいぶ拡張がいりそうなので見送り。

キー(Ctrl+↑/Ctrl+↓)でチャンネルを選択すると、チャンネルツリーの選択部分がわかりにくいという話もあったのだけど、直そうとするとややこしそうなので見送り。手元のCinnamonだと起きないので、テーマに依ってしまう部分もある。

こんなんだけど、ちょっとでもわかりやすくなってるといいな。

2015-12-06

HDDが故障した話 (QNAP TS-219PにDebian jessieをインストールする)

多分5年以上使っていたNAS, TS-219PについているHDDの2台のうち片方が壊れた。Software RAID1構成なので、交換してリビルドすればいいという話だったはずなのだが、長期戦になってしまった。

今回は時系列に沿って。日数はフィクションです。仕事の話じゃなくてよかった(仕事だったらもっと慎重だったろうから、こんなんにならないと思うけど)。

1日目: 拡張できるはずだったのに

もともと2TB x 2本の構成だった。もちろん2TBを買ってきて取り替えればよい。だが、容量を拡張できる機能があることがわかったので、2015年現在のGB単価的(→サハロフの秋葉原レポート)に4TBにしようと思って4TBを2本買ってきた。

もちろん、時代的に4TB扱えなくてもそういうものかという感じな気がするので、compatibility listを見て4TBに対応していることを確認して買った。

ところが、4TBを差しても認識しない。ファームウェアがだいぶ古いので、アップグレードしてみることにする。

2日目: ファームウェアアップグレード

ファームウェアアップデートすると、今度は生きている2TBのディスクまでWeb管理画面から見えなくなってしまった。sshログインを使って、cat /proc/filesystemsを見ると、ext4がいない。ファームウェアの更新がうまくいかなかったのかなと思って再度更新をかけようとすると、バージョンを確認しろという旨のエラーが出て受け付けてもらえない。

バージョンがとんでもなく変わっていたので、変化に耐えられなかったのであろう。もうだめだ。ブラックボックスな独自OSで戦うのはここで限界。汎用OSで使えるものを探そう。

3日目: Debianインストール チャレンジ

Debianが使えないかなと調べてみると、Martin Michlmayr さんという方がまとめてくれている。手順まで詳しく載っており、これは戦えそうである。
Debian on QNAP TS-21x/TS-22x

インストール手順はInstalling Debian on QNAP TS-21x/TS-22xにある通り、

まずフラッシュ領域をバックアップする(後にわかることだが、これは大変に重要であり、絶対に避けてはいけない。)

vfatもマウントできない状態なので、別のマシンでUSBメモリをext2でフォーマットしておいたものを挿入し、catでコピーする。
mount /dev/sdX1 /tmp/usb
cd /tmp/usb
cat /dev/mtdblock0 > mtd0
cat /dev/mtdblock1 > mtd1
cat /dev/mtdblock2 > mtd2
cat /dev/mtdblock3 > mtd3
cat /dev/mtdblock4 > mtd4
cat /dev/mtdblock5 > mtd5
cd
umount /tmp/usb

そうしたら、カーネルやインストーラの入ったinitrdをダウンロードし、flash-debianスクリプトを起動する。
cd /tmp
busybox wget http://ftp.debian.org/debian/dists/stable/main/installer-armel/current/images/kirkwood/network-console/qnap/ts-219/initrd.gz
busybox wget http://ftp.debian.org/debian/dists/stable/main/installer-armel/current/images/kirkwood/network-console/qnap/ts-219/kernel
busybox wget http://ftp.debian.org/debian/dists/stable/main/installer-armel/current/images/kirkwood/network-console/qnap/ts-219/flash-debian
busybox wget http://ftp.debian.org/debian/dists/stable/main/installer-armel/current/images/kirkwood/network-console/qnap/ts-219/model
sh flash-debian

リブートするよう促されるので、rebootした。
reboot

DHCPでアドレスが払い出されるので、そのアドレスに対してinstallerユーザでsshログインする。
$ ssh installer@192.168.1.XX

cursorsのUIが起動し、インストーラが開始する。あとは選べばよいだけ……のように見えた。

4日目: インストーラが途中で止まる

普通のDebianインストーラだと安心し進んでいくと、パッケージのインストールのプログレスバーが途中で止まってしまう。最初からやり直しても同じ。

何度かディスクの構成を変えながら試してみても変わらないので、もう一つinstallerユーザでsshし、shellを選び/var/logのログを見ていると「UUID ~ doesn't exist in /dev/disk/by-uuid」なるログが。

そのキーワードを基に検索してみると、こんなバグ報告を発見。
#791794 - INSTALL REPORT (Jessie on QNAP TS-420U)

どうやらmdでミラーを組んでしまうと、/dev/disk/by-uuidのIDが更新されず、flash-kernelという裏で動くmtdデバイスにカーネルイメージを書き込むツールが書き込みに失敗するらしい。このツールがエラー終了するのではなく、Ctrl+C待ちで止まってしまっているので、フロントのインストーラのプログレスが止まったままとなっていたようだ。

書いてある感じに、ディスクの構成が終わったタイミングでもうひとつssh接続し、shellに降りて
udevadm tigger
とすることで、進むようになった。

このバグで触れられていたために、mdで作られたRAIDミラーがdegradedだと、起動時にemergency shellに落ちるという恐怖のバグを発見。通常コンソールのないこのNASでは、sshできない状態に落とされるのは致命的である。

#784070 - mdadm Software RAID1 with GPT on Debian 8.0.0 amd64 - Does not mount/boot on disk removal

おすすめされる行為ではないが、sidには修正済みのパッケージがあったため、バージョンの変化やdependencyなどを見てsidから持ってきて入れた。実際に1本外して試したところ、きちんと立ち上がるようになっていた。

LVMを構成したり、NFSやSambaなどなどNASっぽいデーモンをインストールしたりする。Ansibleで作ったのでやりなおせる。

5日目: rsyncデータコピー待ち

USB接続した元々の2TB HDDからrsyncでデータをコピーする。たいへんに時間がかかるが、待つしかない。

6日目: 再起動したら……

データのコピーも終わり、デーモンの動作もOK, 念のため再起動を試しておく。

……立ち上がらない。コンソールがないので状況もわからない。

11/30: フラッシュ・リカバリ

Martinさんはリカバリの方法も書いてくれていたので、QNAP NASのリカバリの手順でインストーラを起動して中の様子を見てみることにする。

Recovery mode of QNAP TS-21x/TS-22x

TS-219Pのリカバリモードは、ブート時にtftpでリカバリイメージを取得し、フラッシュ領域を書き直す。

debian-installerを動作させるイメージを作るには、まずカーネルイメージにpaddingを加えて所定のサイズにする。
dd if=kernel of=kernel.pad ibs=2097152 conv=sync

あとはinitrd.gzと、もともとのmtdblockイメージを連結して作る。取っててよかったバックアップ!
cat mtdblock0 mtdblock4 mtdblock5 kernel.pad initrd.gz mtdblock3 > F_TS-219

このファイル名は、u-boot パラメータが格納されているmtdblock4に入っているので念のため確認しておく。
strings mtdblock4 | grep bootp_vendor_class

次に、適当なノートPCを使ってDHCPサーバ + TFTPサーバを立てる。Martinさんは普通にdhcpdで立てているようだが、dnsmasqはそのどちらの機能も備えており、さくっと立てることができた。

あとは、NASをdnsmasqを立てたノートPCと直結し、背面にあるLANコネクタの隣あたりにあるピンで押すリセットボタンを押しながら、電源ボタンを押して起動する。すると、ピー、ピーと短く鳴って起動する。

その時のコマンド・ログはこんな感じだった。
$ sudo dnsmasq --no-daemon --port=0 --interface=eth0 --domain=example.com \
  --dhcp-range=192.168.0.3,192.168.0.253,255.255.255.0,1h --dhcp-boot=F_TS-219 \
  --enable-tftp --tftp-root=`pwd`
dnsmasq: started, version 2.75 DNS disabled
dnsmasq: compile time options: IPv6 GNU-getopt DBus i18n IDN DHCP DHCPv6 no-Lua TFTP conntrack ipset auth DNSSEC loop-detect inotify
dnsmasq-dhcp: DHCP, IP range 192.168.0.3 -- 192.168.0.253, lease time 1h
dnsmasq-tftp: TFTP root is /tmp/ts219
dnsmasq-dhcp: DHCPDISCOVER(eth0) 00:08:9b:XX:XX:XX
dnsmasq-dhcp: DHCPOFFER(eth0) 192.168.0.216 00:08:9b:XX:XX:XX
dnsmasq-dhcp: DHCPREQUEST(eth0) 192.168.0.216 00:08:9b:XX:XX:XX
dnsmasq-dhcp: DHCPACK(eth0) 192.168.0.216 00:08:9b:XX:XX:XX
dnsmasq-tftp: sent /tmp/ts219/F_TS-219 to 192.168.0.216
--no-daemonで起動すると、わざわざtail -fしなくてもログが追え便利であった。

10分程待つと再びピー、ピーと音がなり、再起動される。すると、無事に同じようにinstaller@で接続できるようになった。

インストーラが起動したら、ディスクの設定画面まで進めたあと、別のssh接続を行い、shellに降りる。shellが起動したら、以下のようにchrootを実行して、ディスク上の環境を得る。

mount /dev/md0 /mnt/md0
mount --bind /dev /mnt/md0/dev
mount --bind /proc /mnt/md0/proc
mount --bind /sys /mnt/md0/sys
chroot . bin/bash

fstabがおかしいかなと新しく作ったエントリをコメントアウトしたりした。終わったら、元のカーネルに戻すべくフラッシュする。
flash-kernel

rebootすると、無事起動した。原因についてははっきりわかっていないが、おそらく、fstabうんぬんではなく、USB接続したディスクをmdデバイスとして見せた状態でinitrdを更新してしまったために、変な情報を覚えられてしまったのかなと思っている。ディスクが見えなくなるから起動しなくなるかもよみたいなことを書いてあった記憶。

7日目: USBシリアルからコンソール出せれば便利じゃね?

ブートオプション変更すればUSBシリアルにコンソール出せるんじゃね?このデバイスはU-Boot使ってるからfw_setenvでブートオプション変更できるんじゃね?と思ったらやっぱりあった。

U-Boot tools for Debian ARM Linux in QNAP Server | Black God

ダメでもどうせフラッシュすればいいやーと思って書き換えてみることにする。

まずは設定。
$ cat /proc/mtd
dev:    size   erasesize  name
mtd0: 00080000 00040000 "U-Boot"
mtd1: 00200000 00040000 "Kernel"
mtd2: 00900000 00040000 "RootFS1"
mtd3: 00300000 00040000 "RootFS2"
mtd4: 00040000 00040000 "U-Boot Config"
mtd5: 00140000 00040000 "NAS Config"
# vi /etc/fw_env.config
(書き込み)
# Configuration file for fw_(printenv/saveenv) utility for
# QNAP TS-119, TS-219 and TS-219P.

# MTD device name Device offset Env. size Flash sector size
/dev/mtd4 0x0000 0x1000 0x40000

確認。
# fw_printenv

ttyUSB0を使うように書き換え(コマンドはイメージです:注:このデバイスでのコンソールアクセスがない状態での書き換えはやってはいけません)
# sudo fw_setenv bootargs 'console=ttyS0,115200 root=/dev/ram initrd=0xa00000,0x900000 ramdisk=32768'

再びfw_printenvを実行してみると、書き換わっている。

いざ再起動……立ち上がらない。まぁいいや、リカバリならもう慣れた……リカバリ完了。立ち上がらない!?

8日目: カーネルビルドという選択肢

試しにPCでUSBシリアルを接続し、grubからconsole=ttyUSB0として起動しようとしてみると、起動しない。先にやっておくべきだった!

でもU-Bootのコンフィグが入っているのは先に見たようにmtd4。フラッシュイメージにはmtd4が入っている。直るはずだ!

とMartinさんのページをよく見てみると、こんな記述が。
During recovery mode, mtd0 (the boot loader), mtd4 (the boot loader configuration) and on some devices mtd5 (device configuration) are ignored
……なぜここを読み飛ばしてしまっていたのか。mtd4はリカバリでは戻らない。やってしまった……

U-Bootのコンソールを叩きにいけば直るはずなので、シリアルコンソールに接続してコマンドを叩けば直る。でも現状その接続はない。そこでもう一つの選択肢。設定されたbootargsで立ち上がるカーネルを作ればよい。

ということで、活躍していないsqueezeのarmelデバイスがあったので、これを使ってlinux-sourceをダウンロード。中に入っているはずのカーネルパッケージに含まれるconfigを見ると、どうもCONFIG_USB_SERIAL=mであるのが影響していそうだ。

ということで、=yになるようmake menuconfigでちょいちょいといじって、カーネルを作り直す。起動しない。linux-sourceからのカーネルビルドの手順にまったく沿っていないためか、そもそも元のconfigで作り直しても全然違うサイズになる。やり方が雑過ぎたようだ。

9日目: クロスビルド環境でのカーネルコンパイル(失敗)

make menuconfigを眺めていると、ブートオプションを強制するオプションがあった。.config的にはこんな感じである。
CONFIG_CMDLINE="console=ttyS0,115200 root=/dev/ram initrd=0xa00000,0x900000 ramdisk=32768"
CONFIG_CMDLINE_FORCE=y

なおビルドについては、armelデバイスが古すぎるためか、一晩寝てもコンパイルが完了しない。これでは時間がかかりすぎるので、クロスビルドするしかないか、と思って適当なページを見ながら、適当なUbuntu 15.10のマシンを使ってコンパイルするも、コンパイルエラー。まともに環境を作ろうとすると大変そうなのでこのビルド方式はあきらめ。
How do I cross-compile the kernel on a Ubuntu host? - Raspberry Pi Stack Exchange

$ sudo apt-get install git ncurses-dev make gcc-arm-linux-gnueabi
(カーネルとconfigを配置/展開)
make ARCH=arm CROSS_COMPILE=/usr/bin/arm-linux-gnueabi- oldconfig
make ARCH=arm CROSS_COMPILE=/usr/bin/arm-linux-gnueabi- menuconfig
make ARCH=arm CROSS_COMPILE=/usr/bin/arm-linux-gnueabi- zImage

10日目: そもそもarmelをエミュレーションした環境でビルドする(途中)

そういえばx86なマシンでもqemu使えばarmel環境をエミュレーションできるんじゃね?ということで環境を作る。
qemu-debootstrapを使ってユーザモードQEMUで動くDockerイメージを作ってみる - ももいろテクノロジー

$ sudo apt-get install debootstrap qemu-user-static
$ sudo qemu-debootstrap --verbose --arch=armel --variant=buildd \
  --include=vim-tiny,less jessie rootfs-debian-armel http://http.debian.net/debian/
$ sudo chroot rootfs-debian-armel

今度こそjessieになったのでHowToRebuildAnOfficialDebianKernelPackageの手順を使ってビルドしてみる。armel指定の部分はこんな感じ。

# fakeroot make -f debian/rules.gen setup_armel_none_kirkwood
# fakeroot make -f debian/rules.gen binary-arch_armel_none_kirkwood binary-indep \
DEBIAN_KERNEL_JOBS=4

普通に進んでいたので、このままほっとけばできたのかもしれないが、4つコアを使っている感じがなく時間がかかりすぎて作業時間オーバー。とりあえず断念。

11日目: そしてシリアルコンソールへ

カーネルビルド方式では、リカバリまで含めての試行錯誤の1ターンがかかりすぎるので、ついにシリアルコンソールに手を出すことにした。ここで失敗するとハードウェア破壊を意味するので、あまりやりたくなかったのだ。

やりかたはMartinさんがピン配置を書いてくれているので、この通り接続する。
Serial console for QNAP TS-21x/TS-22x

コネクタはPHR-4というもので千石電商の2Fにあったので買ってケーブルとピンヘッダに差すコネクタをハンダ付けしてケーブルを作った。

RS-232Cレベルではなく、3.3Vなので昔買ったFT232RLの変換アダプタにつなげばよさそうだなーと思ったが、Raspberry Piのシリアルピン使えるんじゃね?とRasPiの存在を思いだし、RasPiが登場。TS-219P - RasPiをTx - Rx, Rx - Tx, GND - GNDで接続した。

RasPi側ピン
(https://www.raspberrypi.org/documentation/usage/gpio/)より

TS-219P側ピン

黄: Tx
黒: Vcc (接続しない)
赤: Rx
青: GND


シリアルポートの場所はここ。ケースは固いので開けたくなかったが、開けないと手が届かない。

RasPiは、ブートオプションconsole=に当該ポートが指定されていて使えないので、/boot/cmdline.txtを編集してconsole=ttyAMAの記述をトル。
http://elinux.org/RPi_Serial_Connection
console=パラメータの変更はまさに悩んでいる部分なので、こうさらっと変更できると複雑な気分。

あとは起動して、
$ cu -s 11200 -l /dev/ttyAMA0
として待つ。あとはTS-219Pをブートすればコンソールが現れるのでauto-bootを止める。あとはsetenv, saveしてブート。
         __  __                      _ _
        |  \/  | __ _ _ ____   _____| | |
        | |\/| |/ _` | '__\ \ / / _ \ | |
        | |  | | (_| | |   \ V /  __/ | |
        |_|  |_|\__,_|_|    \_/ \___|_|_|
 _   _     ____              _
| | | |   | __ )  ___   ___ | |_ 
| | | |___|  _ \ / _ \ / _ \| __| 
| |_| |___| |_) | (_) | (_) | |_ 
 \___/    |____/ \___/ \___/ \__|  ** LOADER **
 ** MARVELL BOARD: DB-88F6281A-BP LE 

U-Boot 1.1.4 (Jan  5 2009 - 12:58:51) Marvell version: 3.4.4

U-Boot code: 00600000 -> 0067FFF0  BSS: -> 00690DCC

Soc: MV88F6281 Rev 3 (DDR2)
CPU running @ 1200Mhz L2 running @ 400Mhz
SysClock = 400Mhz , TClock = 200Mhz 
(略)
Hit any key to stop autoboot:  0 
QNAP: Recovery Button pressed: 0
Marvell>> printenv
(略)
Marvell>> setenv bootargs console=ttyS0,115200 root=/dev/ram initrd=0xa00000,0x900000 ramdisk=32768
Marvell>> saveenv
Saving Environment to Flash...
................................................................
.
Un-Protected 1 sectors
Erasing Flash...
.
Erased 1 sectors
Writing to Flash... done
................................................................
.
Protected 1 sectors
Marvell>> printenv
baudrate=115200
(略)
bootargs=console=ttyS0,115200 root=/dev/ram initrd=0xa00000,0x900000 ramdisk=32768

Environment size: 1339/4092 bytes
Marvell>> boot
Unknown command 'uart1' - try 'help'
## Booting image at 00800000 ...
   Image Name:   
   Created:      2015-12-02  15:12:54 UTC
   Image Type:   ARM Linux Kernel Image (uncompressed)
   Data Size:    1968432 Bytes =  1.9 MB
   Load Address: 00008000
   Entry Point:  00008000
   Verifying Checksum ... OK
OK

Starting kernel ...

Uncompressing Linux... done, booting the kernel.

無事起動。これでちゃんと起動するDebian環境が得られた。

おわりに

USBシリアルでコンソールを見ようというのは本当に余計だった。

自分の環境ではこんな目にあってしまったが、今まで見てきたARMデバイスの中ではDebian officialで済む範囲が多いデバイスなので、Debianを入れてみるというのはアリだと思う。カーネルイメージは公式のものを使えなかったり、ファンコントロールは外部だったりといったことが多いが、これはどちらもofficialに含まれている。

もちろんメーカーの力は得られなくなるので、いわゆる自己責任で。新しく買ってきてというよりは、昔の保証切れてそうな箱でやってみるという感じがよいかとは思う。

2015-11-24

Linuxを使ってUEFIシステムのWindowsをセーフモードで起動させる

しばらくセーフモードのお世話になっていないうちに、セーフモードはF8で起動するものではなくなっていたらしい。セーフモードを構成してからシャットダウンしてセーフモードとか。回復ディスクがなく、Windowsが起動しない場合で、明示的にセーフモードで起動したいときはどうしたらいいんだろう?ということで、Linuxを使ってセーフモードを明示的に構成する方法について今回はまとめた。

注意:PCメーカーやMicrosoftの推奨する方法ではありません。壊れにくいような手順は取っていますが、重要なシステムに適用することはおすすめしません。普通に回復ディスクを使うのが楽だと思います。どちらかというと、仕組みを読み取ってもらえれば幸いです。

手順の概要

最近のWindowsの起動時の設定はBoot Configuration Data (BCD) というところに格納されており、ここにセーフモードで起動するかどうかの設定も入っている。そこで、このBCDをUSBメモリからブートしたUbuntuを使って書き換えることで、Windowsをセーフモードで起動させる。

なおBCD書き換えの際、標準外のツールを使うため、ファイルが壊される可能性を考慮して、セカンダリBCDストアを構成し、そちらの設定を変更することにする。

図にすると以下の通り。

環境/必要なもの

対象の環境は、UEFIで稼動するWindows。Windows 10で検証したが、Vista以降ならいけると思う。

また、必要なものはUbuntu 14.04 (or later) のブータブルUSBメモリ や DVD。すでにデュアルブート環境なら、それでも問題ない。

一応、検証した環境(VirtualBox)でのbcdeditコマンドの出力結果はこんな感じ。
Windows ブート マネージャー
--------------------------------
identifier              {bootmgr}
device                  partition=\Device\HarddiskVolume2
path                    \EFI\Microsoft\Boot\bootmgfw.efi
description             Windows Boot Manager
locale                  ja-JP
inherit                 {globalsettings}
default                 {current}
resumeobject            {43ea4f06-91dc-11e5-8faa-ac88953b08c0}
displayorder            {current}
toolsdisplayorder       {memdiag}
timeout                 30

Windows ブート ローダー
--------------------------------
identifier              {current}
device                  partition=C:
path                    \Windows\system32\winload.efi
description             Windows 10
locale                  ja-JP
inherit                 {bootloadersettings}
recoverysequence        {43ea4f08-91dc-11e5-8faa-ac88953b08c0}
recoveryenabled         Yes
isolatedcontext         Yes
allowedinmemorysettings 0x15000075
osdevice                partition=C:
systemroot              \Windows
resumeobject            {43ea4f06-91dc-11e5-8faa-ac88953b08c0}
nx                      OptIn
bootmenupolicy          Standard

手順

1) セカンダリブートパス・セカンダリBCDストアの構成

1-1) Ubuntuの起動

ブータブルUSBメモリなどでUbuntuを起動する。

1-2) EFI システムパーティション (ESP) のマウント

Ubuntuが起動したら、EFI システムパーティションをどこか適当な場所にマウントする。マウントしたら、EFI/Microsoft/Boot/BCDが存在することを確認しておく。
$ sudo parted /dev/sda print
モデル: ATA VBOX HARDDISK (scsi)
ディスク /dev/sda: 34.4GB
セクタサイズ (論理/物理): 512B/512B
パーティションテーブル: gpt

番号  開始    終了    サイズ  ファイルシステム  名前                          フラグ
 1    1049kB  473MB   472MB   ntfs              Basic data partition          hidden, diag
 2    473MB   578MB   105MB   fat32             EFI system partition          boot
 3    578MB   595MB   16.8MB                    Microsoft reserved partition  msftres
 4    595MB   34.4GB  33.8GB  ntfs              Basic data partition          msftdata

$ sudo mkdir /mnt/efi
$ sudo mount /dev/sda2 /mnt/efi
$ find /mnt/efi -name BCD
/mnt/efi/EFI/Microsoft/Boot/BCD     ←Microsoft/Boot/BCDが存在することを確認
/mnt/efi/EFI/Microsoft/Recovery/BCD

1-3) Microsoftディレクトリのコピー

EFIアプリケーションであるWindows Boot Manager (bootmgfw.efi)及びその設定であるBoot Configuration Data (BCD)が含まれるMicrosoftディレクトリをコピーする。
$ cd /mnt/efi/EFI
$ sudo cp -pr Microsoft MicrosoftSafeMode

手順参考:Windows RE を起動するためのハードウェア回復ボタンの追加

2) セカンダリBCDストアのBCDの書き換え

2-1) hivexregeditツールのインストール

設定が格納されているBCDは、レジストリハイブの形式をとっている。そこで、レジストリハイブを編集できる「hivexregedit」ツールを使えるようにする。

「hivexregedit」ツールは、libwin-hivex-perlパッケージに入っているので、これをインストールすればOK。
$ sudo apt-get install libwin-hivex-perl
なお、他にもレジストリハイブを扱うツールがlibhivex-binパッケージにいくつか入っているので、必要があれば入れる。

2-2) BCDファイルをレジストリエディタ形式にエクスポート

hivexregeditの--exportオプションを使って、BCDの内容を*.regファイルでおなじみの形式にエクスポートする。
$ hivexregedit --unsafe-printable-strings --export /mnt/efi/EFI/MicrosoftSafeMode/Boot/BCD '\' > /tmp/bcd.reg
これにより、テキストエディタやビューワで表示が可能になった。--unsafe-printable-stringsをつけないと、文字列が軒並みhex表記になって見れたもんじゃない。

2-3) Windows Boot Managerのobjectを探す

@junichia氏のブログエントリ「ブート構成データ(BCD)ストアを理解すれば VHDブート は簡単」に図があるので見て欲しいのだけど、BCDにはひとつのWindows Boot Managerのobjectと、複数のWindows Boot Loaderのobjectがある。Boot Loader側にsafe modeの設定もあるのだけど、まずはどれがdefaultで利用されているBoot Loaderか確認するため、Boot Manager側のobjectを探し出す。

boot managerのオブジェクトを探し出すには「bootmgfw.efi」を文字列検索するのが簡単。
$ less /tmp/bcd.reg
(/で検索)
[\Objects\{9dea862c-5cdd-4e70-acc1-f32b344d4795}\Elements\12000002]
"Element"=str(1):"\EFI\Microsoft\Boot\bootmgfw.efi^@"

[\Objects\{9dea862c-5cdd-4e70-acc1-f32b344d4795}\Elements\12000004]
"Element"=str(1):"Windows Boot Manager"

これで、boot managerのオブジェクトのGUIDが「{9dea862c-5cdd-4e70-acc1-f32b344d4795}」であることがわかった。

なお、各項目はElements\以下の数値で何か判断することが可能である。上記における「12000002」は「BcdLibraryString_ApplicationPath」でbcdeditコマンドでは「path」として表示される。同様に「12000004」は「BcdLibraryString_Description」でbceditコマンドでは「description」である。それぞれの数値が何を意味するかは、MSDNにも記述があるのだが、Geoff Chappell氏の「BCD Elements」が見やすくておすすめである。

2-4) Widnows Boot Managerのobjectから「default」を探し出す

Boot Managerがわかったので、そのGUIDを利用して、デフォルトのBoot Loaderの指定を探し出す。デフォルトのBoot Loaderが記述されているのはBcdBootMgrObject_DefaultObject (default)で「23000003」である。

$ less /tmp/bcd.reg
(/で検索)
[\Objects\{9dea862c-5cdd-4e70-acc1-f32b344d4795}\Elements\23000003]
"Element"=str(1):"{43ea4f07-91dc-11e5-8faa-ac88953b08c0}"

ここでデフォルトのBoot Loaderが{43ea4f07-91dc-11e5-8faa-ac88953b08c0}であることがわかった。一応、{43ea4f07-91dc-11e5-8faa-ac88953b08c0} のBcdLibraryString_ApplicationPath (path) 「12000002」及び BcdLibraryString_Description (description) 「12000004」を確認しておく。
$ less /tmp/bcd.reg
(/で検索)
[\Objects\{43ea4f07-91dc-11e5-8faa-ac88953b08c0}\Elements\12000002]
"Element"=str(1):"\Windows\system32\winload.efi"

[\Objects\{43ea4f07-91dc-11e5-8faa-ac88953b08c0}\Elements\12000004]
"Element"=str(1):"Windows 10"
これでいいようだ。

2-5) Boot Loader objectにセーフモード設定を組み込む

Boot Loaderのobjectにセーフモード関連のElementを追加する*.regファイルを作り、それをhivexregeditの--mergeオプションを使って組み込む。

項目がいくつかあるので後の表を見て欲しいが、例えばmsconfigで言うところのセーフモード: 代替シェルで起動する記述は以下の通りである。生成したbcd.regをコピーして作るのが楽。
$ cp /tmp/bcd.reg /tmp/bcd-safe.reg
$ vi /tmp/bcd-safe.reg
~~~
Windows Registry Editor Version 5.00

[\Objects\{43ea4f07-91dc-11e5-8faa-ac88953b08c0}\Elements\25000080]
"Element"=hex(3):00,00,00,00,00,00,00,00

[\Objects\{43ea4f07-91dc-11e5-8faa-ac88953b08c0}\Elements\26000081]
"Element"=hex(3):01
~~~
上記の例ではBcdOSLoaderInteger_SafeBoot (safeboot) 「25000080」を「Minimal」にし、BcdOSLoaderBoolean_SafeBootAlternateShell (やsafebootalternateshell) 「26000081」をtrueにしている。

以下は検証したわけではないが、msconfig.exeの「ブート」タブの表示(以下参考)とbcdeditの出力を見ながら、「BCD Elements」とつきあわせて表にまとめたものが以下となる。


msconfigの項目bcdeditの項目名称Element備考
pathBcdLibraryString_ApplicationPath 12000002string
descriptionBcdLibraryString_Description 12000004string
defaultBcdBootMgrObject_DefaultObject23000003GUID
セーフブートsafebootBcdOSLoaderInteger_SafeBoot 250000800: 最小(Minimal) /
1: ネットワーク(Network) /
2: Active Directory修復(DsRepair)
(代替シェルのときのみ)safebootalternateshellBcdOSLoaderBoolean_SafeBootAlternateShell26000081boolean
GUI ブートなしquietbootBcdOSLoaderBoolean_DisableBootDisplay26000041boolean
ブートログbootlogBcdOSLoaderBoolean_BootLogInitialization 26000090boolean
基本ビデオvgaBcdOSLoaderBoolean_UseVgaDriver26000040boolean
OS ブート情報sosBcdOSLoaderBoolean_VerboseObjectLoadMode26000091boolean

regファイルを作ったら、--mergeでmergeする。
$ sudo hivexregedit --merge /mnt/efi/EFI/MicrosoftSafeMode/Boot/BCD /tmp/bcd-safe.reg

再度hivexregedit --exportしてみて、組み込まれていればOK。

2-6) umount

言うまでもないかもしれないが、umountしておく。
$ sudo umount /mnt/efi

3) grubから新しいBCDを使って起動

UbuntuをEFIで起動しているということは、EFIで動作するgrubが使えるということである。そこで、そのgrubを利用して上記で作ったセカンダリブートパス・BCDストアを選択して起動する。

再びUbuntu ブータブルUSBメモリで再起動し、grubメニューが現れたら、「c」キーを押しコンソールモードに切り替える。切り替わったら、以下のように入力して、新しいbootmgfw.efiを使って起動する。

grub> set root=(hd0,gpt2)
grub> chainloader /efi/MicrosoftSafeMode/Boot/bootmgfw.efi
grub> boot

hdのパスはPCによって異なるはずなので、以下の図のようにTAB補完を活用しながら入力するとよい。


これでセーフモードで立ち上がってくれば成功。とくにEFI firmware側の設定は変更していないので、ブータブルUSBメモリを外して普通に起動すれば元通り。気になるようであれば、EFIシステムパーティションから今回作ったディレクトリを削除しておけばOKである。


2015-11-15

Loqui 0.6.3 リリース(クラッシュバグの修正)/Git移行

Loqui 0.6.3をリリースしました。一部環境でチャンネルツリーを(ダブル)クリックしたとき、segfaultして落ちることがあるようです。ここでは再現しないのですが、影響する環境では発生頻度は高そうなので、基本的にバージョンアップをおすすめします。
https://launchpad.net/loqui

Gitの特殊オペレーションに慣れすぎてBazzarがしんどくなってきたので、メインのブランチをGitに移行しました。レポジトリはLaunchpadのままです。基本 https://code.launchpad.net/loqui にある通りですが、sshを使う場合はlaunchpad login名をgit+ssh://hoge@ というふうにつけてあげる必要があるのでご注意ください。


---
落ちていた場所は、Gtk側からのツリーノードの件数の取得要求に答えるコードで、GtkTreeModelの規約にあっていなかった。呼ばれてなくても呼ばれていてもまぁそういうメソッドかな、数数えるならこっち呼んだほうがよくねって呼ぶようになったのかなという感じなんだけど、環境によって呼ばれたり呼ばれなかったりするのは謎。

2015-11-08

失われたUEFIブートエントリを取り戻す(Ubuntu インストールメディアから)

ある夕暮れ。VT-xを有効にすべくBIOS設定画面をいじっていて、再起動しようと思って「Reset」ボタンを押したら、再起動ボタンではなくFactory Resetボタンだった。設定が工場出荷時に戻されるのはまぁ戻せばいいのだけど、問題はブートエントリまで消されてしまったために、Ubuntuが起動できなくなってしまったということだ。

危険なResetボタン

update-grubすれば万事OKよね、と思っていたがそうでなかったので、UbuntuのUSBメモリを使って復旧する手順を記録しておく。

復旧手順

「Rescue a broken system」でレスキューモード起動

 Ubuntu 14.04 Server のUSBメモリをUEFIで起動すると、grubメニューが現れる。その中の「Rescue a broken system」を選んで起動する。

Serverなのは手元にあっただけで、Desktopなら普通のliveブートできるからもうちょっと楽かもね。

rootをインストール先にしてshell起動

言語だとかを選びウィザードを進めていくと、ルートファイルシステムを選ぶ画面が現れるので、Ubuntuをインストールしたパーティション、例えば/dev/sda6を選択後、「Execute a shell in /dev/sda6」を選んで、シェルを起動する。

efibootmgrでエントリが存在しないことを確認

efibootmgr あるいは efibootmgr -v(-vは詳細情報を表示)と叩くと、記録されたブートエントリの一覧が確認できる。

以下は例。長いID他が出ているが本筋ではないので省略した。
# efibootmgr -v
BootCurrent: 0015
Timeout: 0 seconds
BootOrder: 0000,0012,0013,0014
Boot0000* Windows Boot Manager HD(2,fa800,82000,(略))File(\EFI\Microsoft\Boot\bootmgfw.efi)WIND
OWS.........x...B.C.D.O.B.J.E.C.T.=.(略)
Boot0010  Setup FvFile((略))
Boot0011  Boot Menu FvFile((略))
Boot0012* Built-in Storage Device VenMsg((略))
Boot0013* USB Device VenMsg((略))
Boot0014* Network VenMsg((略))
Boot0015* Onetime Boot Device VenMsg((略))
Boot0016* VaioNextBoot Device VenMsg((略))
UbuntuやLinuxらしき項目は見つけられない。

/boot/efiにEFIパーティションをマウント

ブートローダが格納されているパーティションを/boot/efiにマウントする。今回は/dev/sda2なので以下の通り。
# mount /dev/sda2 /boot/efi
パーティションがどこなのかは、fdisk -lやparted /dev/sdX print、efibootmgr -vの出力、/etc/fstabの記述などから判断する。

grub-installによる復旧

grub-installでブートローダをインストールする。
# grub-install --bootloader-id ubuntu /dev/sda
もっとローレベルにefibootmgr -cでもできると思うが、試していない。

efibootmgrによる確認

efibootmgr -vを実行し、ubuntuの行があること、その行がBootOrderの先頭にあることを確認する。
# efibootmgr -v
BootCurrent: 0015
Timeout: 0 seconds
BootOrder: 0001,0000,0012,0013,0014
Boot0000* Windows Boot Manager HD(2,fa800,82000,(略))File(\EFI\Microsoft\Boot\bootmgfw.efi)WIND
OWS.........x...B.C.D.O.B.J.E.C.T.=.(略)
Boot0001* ubuntu HD(2,fa800,82000,(略))File(\EFI\ubuntu\shimx64.efi)
Boot0010  Setup FvFile((略))
Boot0011  Boot Menu FvFile((略))
Boot0012* Built-in Storage Device VenMsg((略))
Boot0013* USB Device VenMsg((略))
Boot0014* Network VenMsg((略))
Boot0015* Onetime Boot Device VenMsg((略))
Boot0016* VaioNextBoot Device VenMsg((略))

これで再起動して、Grubメニューが出てきたら成功。

参考:Restore Ubuntu UEFI Boot Entries after BIOS Update
---

このブートエントリ飛ばしちゃった問題は某co-edoで起きていたのだけど、うっかり復旧しようとしてたら確実にこれで時間を使いきっていた。あきらめて正解であった。

2015-11-03

図解 Amazon EC2 Container Service (ECS)

本当にざっと使ってみたので、説明用に絵にしてみた、という話。説明書きはつけたけど、図解と言うほどには、説明していないかも。



触った感じ、動き的にはこうじゃないかと思うのだけど、違っているところがあるかもしれない(のでRev0.1)。

並べるものではないのだけど、Beanstalkを使ったときは、Beanstalkが広く面倒を見るせいで、便利そうな反面意識するところが増えてかえってしんどいなと思ったのだけど、ECSは層の分割がしっかりしているおかげで、だいぶ扱いやすそうだなと思う。「Agent入りのインスタンスくれたら、そこのDockerコンテナを管理するよ。そこで何動かすかは知らないよ」という感じで、役割分担がはっきりしている。

Agent入れればdistributionはなんでもOKということだけど、こだわりがなければECS-Optimized Amazon Linuxを使っておいて、なるべくホスト側には手を入れないというのが、らしい使い方だろうか。

ECS-Optimized Amazon Linuxがあるとはいえ、VMが利用者管理なのはAWSらしいなと思う。もうちょっと基礎側がmanagedなやつがあればいいなぁと思うけど、そういった向きはAPI Gateway + Lambdaとかになるのかねえ。

MSI N3150I ECO と Ubuntu 14.04.3 の罠

以下の構成でPCを組んだ。用途によっては良い構成だと思うので、後々の参考のためメモ。

M/B(CPU)MSI N3150I ECO (Celeron N3150 Braswell)
メモリCrucial 4GB DDR3-1600 SO-DIMM 1.35V CL11 (CT51264BF160B)
HDDHGST 2.5" 500GB/5400rpm (0S03794)
ケースIN WIN IW-BM639余ってたやつ
ケースファンAinex OMEGA TYPHOON 8cm/15mm厚 (2000rpm/25.26cfm/20.4db)

第385回 Celeron N3150で省エネPC生活:Ubuntu Weekly Recipe」では、いくや先生が「IW-BP671/300B」で組んでいるのだけど、うちの「IN WIN IW-BM639」はそれのずっと昔のモデルだと思う。多分「Wavy II(IW-BMR651)」が現行モデルで、ちょっとずつデザインを変えながら生き続けている模様。このサイズで3.5"ドライブを2つ格納する場所がある上、ロープロファイルの拡張カードが挿せるのはオンリーワンだと思うのでIN WINには引き続き作って頂きたい。(拡張スロットが2つついていても、Micro-ATXが入るわけではないだろうし、Mini-ITXだとつけようがないと思うのだけど、一体何がささるのだろうというのはあるが。)

メモリは別の構成用に検討したので1.35Vになってるだけで1.5V使えるらしいし、HDDも3.5インチ入るのに2.5インチなのは単に静かそうだからってだけ。

しかし、ここまで到達するのに紆余曲折あったので、順番がおかしいのだけど、はまりそうな順に。

罠1: インストーラがインストールメディアを認識しない

Ubuntu 14.04 (14.04.3) をインストールしたのだけど、ちょっとだけコツがある。インストール中にCD-ROM(USBメモリ)の認識に失敗する(「cd-rom couldn't be mounted」というメッセージが表示される)ので、Alt+F2でコンソールに降りて以下のコマンドを叩いた後、インストーラ画面に戻ってボタンを押すとなぜか進む。Braswellのせい?USBメモリの作り方が悪い?

# df
→/mediaがマウントされている場所を確認。ここではsdb2とする。
# umount /media/
# mount /dev/sdb2 /cdrom

参考:debian - Unattended installation of Ubuntu from USB drive -- Not mounted correctly - Server Fault

罠2: ケースファンとメモリモジュールが当たる

写真の通り、メモリスロット1の位置とケースファンを設置する位置が近いので、もともとつけていたよくある25mm厚のファンだと干渉して配置することができなかったので、15mm厚のファンに取り替えた。ケースファンなんてなくてもいけそうな構成だけど一応つけてみた。スロット2があるからそっち使えば?と思うかもしれないが、1枚しか挿さない場合は1のほうに挿すことになっているらしいのだ。
写真はうまく入っている15mm厚のファンをつけた場合。マザーボードとケースの板までの間隔は20mm程だったので、それ以下でないとあやしい。

罠3: ASRock N3150-ITX には ATX12V がない(拡張カードを使うには電力不足)

最初は、Weekly RecipeにあわせてASRockで組んでいたのだけど、PCI-E x1スロットはあるものの、拡張カードによってはドライブできない(できなかった)のでやめた。N3150-ITXにはATX12V (「田」の形をしているコネクタ)がないので、PCI-Expressへの電力が足りないのだと思う。拡張カードを挿す予定ならこのMSI N3xxI ECOが良いと思う。

パフォーマンス

別に期待してないからどうでもいいのだけど、CPU性能はどんなもんかなと思ってうちの子達とsysbenchを使ってざっくり測ってみた。

「sysbench --test=cpu --cpu-max-prime=20000 --num-threads=$I」のtotal time。数値が小さいほど処理にかかった時間が短い。

CPU / --num-threads124
Atom CPU N270 @ 1.60GHz (2008, Diamondville, 1C/2T, 2.5W) 341.3614207.2697207.3816
Celeron N3150 @ 1.60GHz (2015, Braswell, 4C/4P, 6W) 52.550225.218112.5651
Core2 Duo P8600 @ 2.40GHz (2008, Penryn, 2C/2T, 25W)32.207717.155417.1533
Core i7-5500U CPU @ 2.40GHz (2015, Broadwell, 2C/4T, 15W)25.021812.93997.7414
Core i5-3570S CPU @ 3.10GHz (2012, Ivy Bridge, 4C/4T, 65W)22.27211.44816.2018
Core i7-4790S CPU @ 3.20GHz (2014, Haswell Refresh, 4C/8T, 65W)20.589310.54065.5582

整数演算だけ見てもなぁというところだけど、ざっくり。カテゴリ的には上の表でめちゃくちゃ遅いN270と似たような立ち位置になると思うのだけど、さすがに年代が進んでかなり速くなっているといえる。

ちなみに計測はこんな感じでやった。1回の結果しか使わない雑っぷり。


[2016/5/10 追記]
別の環境でも試してみた。
CPU / --num-threads124
Core i7-2700K @ 3.50GHz (2011, Sandy Bridge, 4C/8T, 95W)22.533811.50435.9518
Core i7-6700K @ 4.00GHz (2015, Skylake, 4C/8T, 91W)17.67738.84584.4978

消費電力

大事な消費電力はどうかと思ってワットチェッカーで計測してみると、起動時19W, 通常時15Wだった。起動時高いのは、HDDを使っているせいだろう。sensors読みで温度的にも40度いくかどうかって感じ。非常に省エネで、こっちのほうが重要なので満足。

2015-10-26

Loqui 0.6.2 リリース

Loqui 0.6.2 をリリースしました。
https://launchpad.net/loqui

内容は、共通バッファ(見ているチャンネル以外が全て流れてくる左下のペイン)のメッセージの行のどこかをダブルクリックすると、そのメッセージのチャンネルを選択する機能の追加。

実用的になったタイミングから、あったほうが便利だよなーと思いつつ、これ作るのいろいろ手加えなきゃで大変だよなーと思ってたんだけど、当初想定よりもっとざっくりした方法でだいたい動く作り方を思いついたからそれで作った。あればいいよなーと思ってから実装されるまで10年以上かかってる機能になるね...

2015-10-22

UbuntuからローカルのLinux KVM上のWindowsにリモートデスクトップする環境を作る

今回は、Ubuntu DesktopにLinux KVMを構成し、その上にWindowsをインストールし、Desktopの側からリモートデスクトップ(RDP)で接続する環境を作る方法について。絵にするとこういう環境。



「普通Windows上にLinuxじゃないの?」と思うかもしれないが、 Windowsのリモートデスクトップは優秀なので、Windows上にVirtualBoxでLinux desktopを上げるよりは快適に使うことができるので、どちらも使いたい環境では個人的には気に入っている使い方である。

前提として、Ubuntu Desktop 14.04がセットアップされているものとする。なお、今となっては同じことGUIでもできるじゃん?と思う手順もあるが、動くなら全然そっちで構わないと思う。断片的に作業してる部分があるので、ちょっと足りてない話があるかも。

こだわらない手順ざっくり版

長くなってしまったので、なくてもいい部分を省いたざっくり手順を先に記載。ピンとこなければ、詳しくを参照。

  • Intel VT/AMD-VをBIOSで有効化 (詳しく)
  • sudo apt-get install qemu-system qemu-kvm libvirt-bin virt-manager (詳しく)
  • sudo virt-managerを実行し、localhostを右クリック→「新規」からVMをウィザードで追加 (詳しく:特にWindows10の場合)
  • VMにWindowsをインストールし、リモートデスクトップを構成。振られるIPを確認 (詳しく)
  • sudo apt-get install remmina-plugin-rdpし、remminaで「TLS」に設定してWindowsに接続 (詳しく)

ここからは、もう少し詳しい+やったら良いかもしれない手順。

初期設定

VMを作成する前に一度だけ必要になる作業。

BIOSの設定

KVMを使うので、Intel VT (Intel Virtualization Technology) のようなCPUの仮想化支援機能をBIOS上で有効にしておく。

ブリッジを構成する(必要であれば)

デフォルトのNATでは都合が悪い場合、VM用のネットワークを設定する。自分の環境ではホストNICを含むブリッジネットワークを作るのが便利なことが多いので、ホスト側にブリッジを構成してしまう。…と思っていたが、デフォルトで作成されるvirbr0 (192.168.122.0/24) のネットワークで十分かも。なお、後述の仮想マシンマネージャー(virt-manager)のGUIでも作れそうだが、やったことはない。

bridge-utilsパッケージをインストール。
sudo apt-get install bridge-utils

/etc/network/interfacesを例えば以下のように設定する。

auto lo
iface lo inet loopback

auto eth0
iface eth0 inet manual

auto br0
iface br0 inet static
        bridge_ports eth0
        address 192.168.1.X
        netmask 255.255.255.0
        network 192.168.1.0
        broadcast 192.168.1.255
        gateway 192.168.1.254
 dns-nameservers 192.168.1.254

VMイメージ用のLVMの構成(お好みで)

VMのイメージはデフォルトでは/var/lib/libvirt/imagesに普通のファイルとして作られるので、ここの容量が十分にあれば別に使えるのだが、ファイルで取るよりLVMでブロックデバイスとして切り出してあげたほうが効率的と思うので、この構成を取るときはLVM用のパーティションを切ってLVM Volume Group vg0を作ることにしている。

ここでのパーティション構成はこういう形と想定する(本当は別ディスクにしたい)。


上記の前提のため、sda3をPV (Physical Volume)とし、それだけを含むvg0を作る。

$ sudo apt-get install lvm2
$ sudo pvcreate /dev/sda3
$ sudo vgcreate vg0 /dev/sda3

KVM関連ソフトウェアのインストール

qemu-kvm単体で使うのはさすがにしんどいので、libvirtを使って管理する。その管理用GUIであるところの仮想マシンマネージャー(virt-manager)も入れる。
$ sudo apt-get install qemu-system qemu-kvm libvirt-bin virt-manager

なお、Webで管理したい場合は「KVMの仮想マシンをWebブラウザから管理する」の記事を参照。

virt-managerを起動するときは
$ sudo virt-manager
とする。

仮想マシンマネージャー(virt-manager)からのストレージ・ネットワークの設定(お好みで)

virt-managerを起動し「localhost」をダブルクリックすると、ホストの設定画面がでてくる。ここで「仮想ネットワーク」「ストレージ」「ネットワークインタフェース」タブを選ぶと、それぞれの設定ができる。私は使っていないが、うまく使えば便利かもしれない。

VMの作成

VMを作成するごとに必要になる作業。基本的にはひとつだけだと思うが、複数使う場合はここを繰り返す。

VMイメージの作成(LVMを使ったとき)

LVM(かつvirt-managerでストレージの構成を行っていないとき)は、事前にストレージを作っておく。
$ sudo lvcreate -L 48G -n win1 vg0
win1の部分はVM名あるいはVM名-rootなどを入れることが多いが、名前なのでわかればなんでもいい。

仮想マシンの追加

ISOイメージを準備した後、virt-managerを起動し、localhostを右クリックして「新規」を選択。ウィザード画面が表示されるので聞かれる情報を入れていくだけ。

入力の注意点は以下の通り。

・OSはWindows, Windows 7を選ぶ(7なのはそれ以降の項目がないので。できたら選ぶ。Vista以前は使わないですよね?)

・メモリ容量、ディスク容量ともに適切に増やす。

・LVM(略)を使った場合、ストレージで既存のストレージを選択するを選び、先ほど作ったLVのパスを入力する。



・ウィザードの最後の画面で、詳細オプションを表示し、必要なネットワーク、例えば「ホストデバイス br0」を選択する。

なお、Windows 10のときは、ウィザードの最後で「インストール前に設定をカスタマイズする」を選択し、CPUを適切に選ばないと(例えばCore2 Duo)エラーで立ち上がらないみたい。


参考:仮想環境(KVM)でのwindows10が、一度起動した後リブートすると起動しなくなる (症状は違うが、この対処と同様にCPUを変更すると動作が変わる)

Windowsのインストール

virt-manager上から淡々とインストールする。リモートデスクトップを有効にする。必要であれば普通にIPアドレスを設定する(デフォルトのNATだと192.168.122.0/24のIPが振られていると思う)。このマシンにIPアドレスで接続しにいくので、設定しなくても確認をしておく。

VMの起動・停止

もちろんGUIからもOn/Offできるのだが、普段リモートデスクトップからの接続となるので、ターミナルからの起動・停止も覚えておくと便利。

確認
$ sudo virsh list --all

起動
$ sudo virsh start win1

停止
$ sudo virsh destroy win1


リモートデスクトップ接続

上記でVMができたら、ホスト側から接続する。

Remminaを使う方法

普通にRemminaを使うのであれば、apt-get installして起動して設定画面で設定すればOK。

$ sudo apt-get install remmina remmina-plugin-rdp

ただし「リモートデスクトップの設定」の「高度な設定」にある「セキュリティ」の項目がデフォルトの「ネゴシエーション」だと、最近のWindowsには接続できないので、他の項目、例えばTLSに変更する。


なお、RemoteFX を選ぶと、RDPの品質の設定でフォントスムージングが有効でも有効にならないので、それ以外、例えばTrue Color 32bitを選ぶこと。また、品質は最高(最低速)を選ぶこと。

xfreerdpを使う方法

Remminaを使うのはお手軽で良いのだが、アンチエイリアスが効かないのでガタガタで悲しい。しかし、xfreerdpを使うと有効にできる。
追記: と思っていたが、上述の通り品質でフォントスムージングを選んでおき、RemminaでもRemoteFXを選ばなければ有効になる模様。xfreerdpの手順も一応残しておくが、Remminaでいいと思う。

$ sudo apt-get install xfreerdp-x11

毎回コマンドを打つのはだるいので、以下のようなシェルスクリプトを置いて起動することにしている。パスワード入力をssh-askpassを使うのはちょっとチート気味。



---

これで設定方法と使い方はおしまい。両方使う方は試してみてはいかがか。

2015-10-14

MSBuildでRAW画像ファイルを移動する

正直PowerShellでOK。普通こんな使い方してないけど、なんというかItemGroupの使い方の例ということで。

デジカメで写真を撮るときは、RAW画像も一緒に撮るのだけど、オンラインなところに一緒に置いておくと容量を食うので、オフラインなところに退避したい。しかし、こんな構造で保存しているので、そのままだと散らかっていて移動させにくい。

  camera\20151014-自宅\DSC00274.JPG →これはとっておきたい
  camera\20151014-自宅\DSC00274.RAW →これは避けたい

なので、ARWだけこんな感じの場所にムーブしたい(そしたらまるっと移動させればいいので)

  camera\ARW\20151014-自宅\DSC00274.RAW

もちろんスクリプトを書けば全く問題ないのだけど、MSBuildで書いたらこんな感じになったのでメモ。camera\に配置して動かす。


2015-10-10

Visual Studioソリューションの配置方針とMSBuildの活用

Visual Studioで開発をしていると、ひとつソリューションを作ってひとつのVisual Studioインスタンスでビルドしたり発行している間は良いが、そのうちそれ以上のことをしたくなってくる。例えば、複数のソリューションを複数のVisual Studioインスタンスで開いたり、CIにリリース用のzipファイルを作らせたりといったことである。

そのような状況を踏まえながら、自分がソリューション/プロジェクトを作るときに考える点について、文書としてまとめたことはなかったと思い、以下に記述する。

Visual Studio全般に通じる部分も多いと思うが、基本的にC# プロジェクト(.csproj)を想定する。

前提: ソリューション/プロジェクトとは何か?

Visual Studioでは、アプリケーションをビルド(及び実行)するために必要なものをまとめたかたまりを「プロジェクト」と呼んでおり(*1)、「プロジェクトファイル」(*.なんとかproj)には、ファイル一覧や参照ライブラリ情報が記載されている。

*1: プロジェクトは、アプリケーションをビルドするのに必要なものすべての論理的なコンテナーです。 - ソリューションとプロジェクトの作成

その「プロジェクト」を複数まとめたものが「ソリューション」となっており、プロジェクトの紐付けの情報が記録されているのが「ソリューションファイル」(*.sln)である。Visual Studioの1インスタンスは通常ひとつの「ソリューション」を開いている。

「プロジェクトファイル」(*.なんとかproj)/「ソリューションファイル」(*.sln)は、Visual StudioのGUIによって作成/編集される。

また、この「プロジェクトファイル」(*.なんとかproj) ファイルはMSBuildというビルドツールでビルドできる形式となっている。

MSBuildから実行できるプロジェクトファイルはVisual Studioに頼らずに一から作成することもでき、MSDNのドキュメント的にはそのファイルもプロジェクトファイルと呼ぶように見えるが、本記事では便宜上そのような1から書いたファイルは区別して「MSBuildファイル」と呼ぶことにする。

単一ソリューションの場合

まずはシンプルなひとつのソリューションに複数のプロジェクトという構成。Visual Studioからソリューションを作成することのほかに、やっていることがあるので、まずはその方針について記載する。

ざっくり図で表すと、以下の通りである。


詳しく説明していくと、以下の通り。

コード記述時以外のビルドの窓口となるMSBuildファイルを用意する

コードを書いているときはVisual Studioでコードを書き、ビルドし、デバッグ実行する。それ以外の作業、例えばzipへのアーカイブであったり、サーバへのデプロイといった作業についてはVisual Studioで完結させるのではなく、Visual StudioでのプロジェクトをラップしたMSBuildファイルをMSBuild.exeなどでビルドし作業する。手続きを*.〜projに記述することもできるが、それは基本的にしない。

これをする理由の一つ目は、機械的に生成される部分と人が意図を持って記述している部分を分けることにより、人が記述している部分の書きやすさ/読みやすさを向上させるためである。ツール(Visual Studioやその拡張)が生成/編集する*.〜projファイルに記述するとなると、生成/編集するツールの都合に合わせて記述しなければならない。ただの一覧のような設定ファイルであればそれほど問題ではないが、もっと自由に記述できるMSBuildでは非常にストレスフルな作業になる。また、Visual StudioのGUIから生成したわけではない追加の記述があることがGUIから分かり辛く、気づきにくい。別に記述してあれば、これらの問題はない。

二つ目は、CIシステムからのエントリポイントとして便利なためである。.csprojは先の通りMSBuild.exeでビルド可能なのだが、複数のcsprojを順にビルドするとか、渡すパラメータがいくつもあったりすると、そのMSBuild.exeの呼び出し自体がスクリプトのようになってきて、CIシステムのジョブの見通しが悪くなったり、変更管理がしにくくなったりする。しかし、CIシステム側からはこのMSBuildファイルを呼ぶだけと決めておけば、CIシステムでのジョブの記述内容が容易に想像できるようになり、またCIシステムに設定する前のタスクの事前確認がしやすくなったりする。

なお、このプロジェクトをラップしたMSBuildファイルは、私は「{{Name}}.msbuild」というファイル名をつけることが多いが、「{{Name}}.proj」でも別にかまわないし、そっちのほうがプロジェクトファイルとしての本筋っぽくはある。.msbuildで慣れてしまったのもあるが、Visual Studioで開くファイルではないよーということがわかっていいかなと思う。(この.msbuild拡張子を使っているプロジェクトもgithubで見かけるので、それほど好き勝手ではないと思っている。例えば、xunit

MSBuildファイルは基本的にプロジェクトファイル(*.~proj)をビルドする

MSBuildは、MSBuildタスクで他のプロジェクトファイルをビルドすることができる。MSBuildファイルは、これを利用してVisual Studioで作ったプロジェクトをビルドする。

なお、ソリューションをビルドすることもできるが、余分なビルドを減らすためにも、できる限りプロジェクトファイルを指定するほうが良い。

出力用フォルダを用意して後処理を可能にする

成果物出力フォルダはbin\Debug\のような各プロジェクトのデフォルトのパスではなく、「output\」のようなMSBuildファイル処理用の出力用フォルダを設定してそこに出力するようにする。

MSBuildファイル上の記述としては、以下のようになる。これは、KiritoriMage.csprojを「Release」でoutput\KiritoriMageに出力する指示である。(OutputDirプロパティは事前に「output」と定義、MSBuildProjectDirectoryはMSBuildファイルが配置されているディレクトリ(自動定義))


これをすることで、出力用フォルダ以下は書き換え(削除)して良いことが明確になるため、様々な後処理が行いやすくなる。もちろん、bin\Debugの状態でもできなくはないのだが、「どこ?」となりにくいので処理しやすい。

後処理とは例えば、以下のようなことである。
  • MSBuildで指定した変数に応じた環境情報を設定ファイルに設定する(*2)。
  • NuGetで入れたパッケージが不要なファイルを成果物にしてくるので、削除する。
  • 動的に生成するコンテンツを成果物に含める。
*2: おすすめはしない。なぜかというと、ビルド成果物が環境に縛られるので、各環境ごとに成果物が必要になってしまうため。可能な限りデプロイ時に構成するか、環境側に設定したい。

引き続きKiritoriMageのBuildターゲットで説明するとこんな感じである。ffmpegは使わないので消している。
やりなおしたいときもこのフォルダを削除すればよいだけというのは、いろいろな意味で楽である(この削除をする「Clean」ターゲットは用意しておく)。 ただし、この出力先フォルダの差し替えをするのが面倒なプロジェクトタイプがあった覚えがあるので、労力との見合いで実施する。

csprojには、可能な限りビルド構成は増やさない(Debug, Releaseだけで行けるならそうする)

ビルドのプロパティに関しては、MSBuildファイル側からできるだけ「Release」構成にプロパティで渡して変更することとして、可能な限りcsproj側のビルド構成(Configuration)は増やさない。

理由は、ビルド構成はGUIでの設定となる都合上、どうしても設定内容をフラットに見せざるを得なくなるため、各構成間での差分に対する見通しが悪かったり(良く見せることが難しい、と言うべきか)、一部のみ違う構成の変更作業に手間がかかったりするためである。MSBuildファイル中にコードで指示していれば「このプロパティだけ違う」というのが意図として伝わりやすい(伝えるコードに書きやすい)。

プロパティで変更できなくても、XMLな設定ファイルがちょっと違うレベルなら、ビルド後にMSBuild Community TasksのXmlUpdateで更新してしまうという手もある。

この方針も、構成間の差分次第で考える。がっつり違うのであれば、別物として用意しておくのもひとつ。

ツール群は窓口MSBuildファイルから呼べるようにする

そのプロジェクトの便利ツールがある、といった場合、窓口となるMSBuildファイルにタスクを記述しておくと、そこに存在があること、及びどんなパラメータが必要なのか明確になって便利である。CIからも呼びやすい。

正直これが便利だといえるのは、MSBuild LauncherのUIがこうだからというのも大きいが、この話だけ切り取って説明したのが「PowerShellにMSBuildLauncherで簡易GUIをつける」で、詳しい話はこちらを参照されたい。

複数のソリューションを使う場合とは

さて、複数のソリューションの方針を説明する前に、どんなときに複数になるのかという話。

Visual Studioは、基本的に一つのソリューションに対して同時に一つのデバッグ実行を行うことが想定されている(ように私には見える)。そのため、複数の実行が同時に走るようなものを一つのソリューションに押し込むと、扱いにくい(一応実行はできなくはない)。例えば、Webサーバーをデバッグ実行しながらクライアントアプリをデバッグ実行するといったケースでは、サーバー側、クライアント側でそれぞれソリューションを作成すると、二つのVisual Studioを同時に起動し、それぞれでデバッグ実行を行えて便利である。

そのため、同時に利用する複数のエントリポイントがある場合、複数のソリューションを使う。

他にも、ビルドの粒度のコントロールのために分割するという話もあるかもしれない。

複数ソリューションの場合

上記の状況となる典型的なケース、ServerとClientが及びその共通部分であるCommonというソリューションを構成する場合を例に複数ソリューションの場合の方針について記載する。 まずは図で表すと、以下の通りとなる。


なお、後の説明が通じやすいようにCommon, Server, Clientという名前をつけただけで、名前のつけかたはここでは触れない。Sharedといった他の汎用的な名前であったり、あるいはコードネームだったりとか、そういったものでも別に構わない。

各ソリューションごと、及びソリューションのまとまりごとにMSBuildファイルを用意する

先ほどのMSBuildファイルは、各ソリューションごと(子)、及びそのまとまりごと(親、通常トップにひとつ)に定義する。

子のMSBuildファイルには、各ソリューションごとの作業を記述する。親のMSBuildファイルには、MSBuildタスクによる子のターゲットの呼び出しと、組み合わせたときに必要になることを記述する。(とくになければ親側は単なる窓口になる。)

別のソリューションのプロジェクトを参照してもよい

例えば、CommonのプロジェクトはServerとClientが参照してもOK。すべては追加せず、必要な分だけ参照する。

ただし、基本的にはCommonへのメソッドの追加などはCommonのソリューションを開いて実施する。read-onlyとは言わないが、あくまで使っている側は参照用というスタイルで使う。 というのも、Common側でビルドが完結することがはっきりしないといけないし、ユニットテストが実施できないからである(ServerやClientからCommonのユニットテスト プロジェクトへの参照があるのはおかしい)。

とはいえ名前変更のようなリファクタのときは入っているほうがよかったりするので、どちらで直していいかはケースバイケースではあるが。

(大規模な変更をするときは、merge-solutionsで全部入りのAll.slnというのを作ったりするがこれは完全にバッドノウハウ。)

共通の定義を記述するMSBuildファイルを用意する

複数のプロジェクトにまたがって利用できる共通のターゲットがあれば、~.targetsという形で共有する形で追い出す。

ディレクトリ階層は揃える

これはバッドノウハウではあるのだが、csproj内には相対パスで参照される項目があるので、csprojがあるフォルダ階層は可能な限り揃えるようにする。 例えば、こんな感じ。

  • XYZ.Server.Web\XYZ.Server.Web.csproj
  • XYZ.Client.App\XYZ.Client.App.csproj

内部であってもNuGetパッケージを作成して利用する

上述のCommonは、共通に利用するアセンブリがあるということしか意味しないので、便利コードのようなものは、内部であってもNuGetパッケージにしてプライベートリポジトリで配布するとよい。

標準のファイルシステムベースのプライベートリポジトリという方法(社内の開発環境の改善&効率化のためにNuGetを活用しよう - Build Insider)もあるが、ProGetのようにこのようなパッケージを管理できる製品もある。また、使ったことはないがMyGetのようなSaaSも利用できるだろう。

なお、チームが分割されている場合、例えば「共通基盤チーム」が存在する場合、NuGet配布にするとリリースサイクルなどがコントロールしやすくなる場合もある。配布元チームは利用側チームに適切なリリースとして見せられるし、利用側チームは適切なタイミングでバージョンアップできるからである。

その他のTips

MSBuild Community Tasksの活用

MSBuild Community Tasksというものがあり、Zipを作ったり、Version情報を生成したりするTaskなどがあるので、NuGetでインストールして活用する。

csprojから参照されるtargetsファイルの構造に深入りしない

前述の通り、csprojファイルはMSBuildのプロジェクトファイルである。そのため、ビルド方法を記述したファイルをImportしており、追おうと思えばビルド作業を追うことができる。

しかし、決して処理順や参照変数などを追いやすい構造ではなく、これをやり始めるとそればっかりになってしまって、割に合わない。そして頑張って調べても、Visual Studioなどのバージョンによって変わってしまう可能性がある。

そもそも動かないといったトラブルシューティング的な部分であれば仕方ないが、~~というオプションがあれば後処理がいらなくて済むのになぁといった話は、時間を区切ってわかるレベルに留めるのが無難と思う。

MSBuildは、便利に使える範囲で使って、そこから出たらPowerShellなどのプログラムに任せる。

MSBuild Launcherの活用

上述のMSBuildファイルはVisual Studioで開くためのファイルではないので、Visual StudioをGUIとしては使えないか、あるいは無理に使えるように作ってもVisual Studioがいろいろなターゲットを実行するために作られたUIではないので不便である。

普通に考えるとMSBuild.exeで実行することになるが、そうなると「そうは言っても毎回コマンドを実行するのは辛い」という流れになり、GUIが欲しくなる。でもいいのがないね、という話で作ったのがMSBuild Launcherなので、そういう感じになったら使ってみてほしい。

例としてKiritoriMageのMSBuildファイルを開いた様子はこんな感じである。


なお、今よりもずっとノウハウがないときのプロジェクトなので全体のサンプルとしておすすめできるプロジェクトではないが、一例としてKiritoriMageのMSBuildファイルとしてはだいたいこんな感じである。

おわりに

自分でも、最初からこういうふうに作るわけではなく、ソリューションとプロジェクトファイルしかない場合や、ソリューション側のmsbuildがなかったりと、実際のところはもう少しいろいろ違っている。

いろいろ作ってきていないもの(Portableライブラリ, ストアアプリなど)もあるので、配慮が欠けている部分もあるかもしれない。とはいえ、少しでも設計の参考になれば幸いである。

なお、最新にはついていっていない部分があるはずなので、変わっている部分があればぜひ教えて欲しい(DNXの影響はかなりありそう)。

2015-09-06

ZXing(.Net)のバーコード認識率を上げる方法

ZXing.Netは、1次元/2次元バーコードを認識するためのライブラリで、よく見るJAN-13(EAN-13)コードや、QRコードを認識させることができる。使い方は一見簡単で、NuGetでぽいっと入れて、数行書けば動かすことができる。

しかし、バーコードが含まれる静止画を認識させようとするとデフォルトだと認識率がだいぶ低い。恐らくカメラを動かしながら認識させていくために速度重視なのであろう。この記事では、そんなZXing.Netの認識率を上げる方法について書く。コードは流用できずとも、JavaのZXingも含めた他の言語実装でも同じようなテクニックが利用可能と思う。

ZXing.Netの単純な使い方

BarcodeReaderを作って、Decode/DecodeMultipleメソッドにbitmapを流し込むだけ。コードをざっと見、プラットフォームごと、例えばXamarin用のコードだと受け取る画像オブジェクトが違うように見えるので、使う環境によって見てみるといいと思う。System.Drawingなんてヤダヤダ、BitmapSourceがいいという場合は、zxing.presentationアセンブリにそれを受け取るやつが居るっぽいが、この記事を書いているときに気づいたので試してない。

using (var bitmap = (System.Drawing.Bitmap)System.Drawing.Bitmap.FromFile(path))
{
    var reader = new BarcodeReader();
    var resultArray = reader.DecodeMultiple(bitmap);
    return resultArray == null ? new string[0] : resultArray.Select(x => x.Text).OrderBy(x => x).ToArray();
}

気をつけなきゃいけないのはDecodeMultiple()はnullを返すことがあるということ。なぜ空配列にしなかったのか。…などというのはこの後説明する内容に比べれば些細なのでしれっとnullチェックする。

1. TryHarder他オプションを設定する

デフォルトだと回転すらしてくれないので、ほぼバーコード自体のみの画像でない限り認識した結果を見るほうが珍しいという事態になると思う。回転なんかはさすがにこのTryHarderオプションが面倒を見てくれるので、オプションを設定する。
using (var bitmap = (System.Drawing.Bitmap)System.Drawing.Bitmap.FromFile(path))
{
    var reader = new BarcodeReader();
    reader.Options = new ZXing.Common.DecodingOptions
    {
        TryHarder = true,
        PossibleFormats = new[] { BarcodeFormat.EAN_13 }.ToList()
    };

    var resultArray = reader.DecodeMultiple(bitmap);
    return resultArray == null ? new string[0] : resultArray.Select(x => x.Text).OrderBy(x => x).ToArray();
}
今回のケースではJAN-13だということがはっきりしているので、TryHarderのほかにPossibleFormatsを指示する。

これでデフォルトからするとだいぶ改善する。

2. 事前に2値化する

だがまだ認識してくれないものがだいぶある。輝度の処理がイマイチという話をどこかで読んだので、大津の2値化をしてから投げるだけで改善した。後述の理由により、認識させるものによってはこれでいいケースがかなりあると思う。

例によってOpenCVSharpを使ったコード。
using (var mat = new Mat(path))
using (var matG = mat.CvtColor(ColorConversion.BgrToGray))
using (var matB = matG.Threshold(0, 255, ThresholdType.Otsu))
using (var bitmap = matB.ToBitmap())
{
    var reader = new BarcodeReader();

    reader.Options = new ZXing.Common.DecodingOptions
    {
        TryHarder = true,
        PossibleFormats = new[] { BarcodeFormat.EAN_13 }.ToList()
    };

    var resultArray = reader.DecodeMultiple(bitmap);
    return resultArray == null ? new string[0] : resultArray.Select(x => x.Text).OrderBy(x => x).ToArray();
}
using OpenCvSharp.Extensions; しておくとMatはToBitmap()でSystem.Drawing.Bitmapにできるのがポイント。

AdaptiveThreshold
でもいいかなと思ったが自分の見てるソースだといつもうまくいくパラメータを持ってくるのが逆にしんどかった。カメラで撮っている画像だと頑張ってAdaptiveThresholdのパラメータ合わせた方がいいことがあるかもしれない。

あるいは、バーコードが黒だとはっきりしているのなら、色ありの状態で黒部分を抜き出して処理するという戦略もあると思う。そこまでじゃないかなと思ったのでやっていない。

3. さらに事前に領域分割する

それでも認識してくれない画像があるのだが、その画像もサイズを半分にしたら認識されたりする。ざっと見では追いかけられなかったのでコードは追っていないのだが、画像全体のサイズから一定の比率以下のサイズだと認識されない感じがする。そのため、事前に分割してしまうと、予想通りうまくいった。

具体的には、Erodeで収縮させバーコード領域のあたりを潰し、そこをFindContoursで輪郭検出させる。詳しい処理はZXing.NETが面倒を見てくれるので、ざっくり分かれればよしとしている。処理イメージとしてはこんな感じ。



コードはこんな感じ。


変わったこととしては、サイズ下限でのフィルタ。どう頑張ってもバーコードが入らない小さい領域を食べさせてもしょうがないので。JAN-13は横は縦棒が30本あるので×2で60px, 縦も数字の「8」が最低5px必要なので×2はあるだろうということで10pxとし、回転も考えて長いほう/短いほうでフィルタしてみた。あと、領域ぎりぎりで切り取ると認識しないことがあったので、白枠を付与することにした。

なお、領域分割を行っているのだから、バーコードは分割されると割り切って、画像にバーコードが複数含まれる場合でもDecodeMultiple()でなくDecode()を呼び出すと、速度が向上する。しかし、背景色の関係で領域がくっつくことがまれにあるので、Decode()を狙うならもう少し領域分割をまじめにやったほうがいいかもしれない。

認識率向上の効果

手元の画像200枚(画像サイズ5000x2000程度、各バーコード2つずつ)に対して、1., 2., 3.を実行してみて認識した数がこちら(グラフには個数と書いてしまったが枚数である)。MultipleはDecodeMultiple()を呼んだ版、MultipleなしはDecode()を呼んだ版。


デフォルト状態での認識数はまさかのゼロ。むしろ貼ってあったQRコードを認識してて笑った。

この「事前領域分割(Multiple)」(3.)のほうの残り3枚は、2つ存在するバーコードの他に一つ追加で何かを誤認識してしまったもの。どうせ二つあるうちの一つを先頭の数字(978)でフィルタしようと思っていたので、自分の使い方的には100%使えるという結果なので満足。「事前領域分割(Multipleなし)」のほうも197枚なのだが、「事前領域分割(Multiple)」と同じなわけではなく、誤認識が一つ減っている代わりに、一つの画像で2つのバーコードの領域が合体し、認識が一つ漏れてしまっていた。理由は前述の背景色の関係なのだが、1/200が他にもあってもおかしくないので、自分的にはDecodeMultipleを呼んで使おうと思う。

速度の違い

処理方式の違いによる速度の違いは以下の通り。


認識率を最も重視しているので、方法が複雑になるほど処理速度は下がっている。また領域分割に関しては、ばらつきが大きいが、これはどのように分割されるかによってトータルでの領域サイズが変わるからであろう。

また、DecodeMultipleを使わない(Decodeを使う)版だと領域分割したとしても単にTryHarderにしたDecodeMultipleより速いので、速度重視であれば自前で分割してでもDecodeMultiple()は使わないほうがよさそうである。ここはOpenCVのほうが速いからという話はありそうだが。

今後の展開

自分で使う分には満足行くレベルになったので、もうおしまいにするが、以下のような点を行うと、より改善されるように思う。必要であればお試しされたし。
  • 黒付近の色での領域抜き出しによる、認識率向上/領域サイズ削減による高速化
  • 自前回転
  • PureFormatsオプションの利用
ここまでやってしまったら、JAN-13程度であれば自前で認識してしまうというのも視野に入ってきてしまうと思うので、要求に応じてという感じで。

2015-08-23

PdfSharpでPDFファイルからJPEG画像を抽出する

.NET用のPDF操作ライブラリPdfSharpを使って、PDFファイルに含まれるJPEG画像のバイト列を抽出する。iTextではない。

「PdfSharp」NuGetパッケージをインストールして、以下の感じで一応OK。



StreamはStreamって言ってるけどSystem.IO.Streamではないのでバイト列をファイルごとに返すことにした。

参考:PDFsharp Sample: Export Images

2015-08-10

PowerShell と ImageMagick (Magick.NET)で画像を加工する

WindowsでImageMagickを使おうとすると、cmdの引数の扱いがだるいと思っていたのだけど、ImageMagickライブラリの.NETラッパーであるところのMagick.NETをPowerShellから呼んでしまえば、便利に呼べることに気づいた。convertコマンドのパラメータの順序とか考えなくていいので、正直convert使うより楽かもしれない。

基本的にMagick.NETを呼んでいけばOK(気づいたPowerShell固有の罠は以下に書いておく)。本格的に処理するのであればC#でコード書いたほうがいいと思うけど、そこまででもないワンタイムバッチ処理にどうぞ。ただし「Verb-Noun -Arg abc」styleでない、.NETクラス直呼び出しの「なんちゃってPowerShell」なので、がっかりされる前に宣言しておく。コンソールで打って問題ない量だと思うけど。

なお動かしてる環境は、PowerShell v4 / x64。

準備

インストール

適当なフォルダを作り、NuGetでMagick.NETのx64 or x86を環境にあわせてもってくる。
PS C:\> mkdir c:\opt\magick
PS C:\> cd c:\opt\magick
PS C:\opt\magick> Invoke-WebRequest https://nuget.org/nuget.exe -OutFile nuget.exe
PS C:\opt\magick> .\nuget install Magick.NET-Q16-x64
NuGet的にはAnyCPUがあるので、何も考えずにAnyCPUを選びたくなるところではあるが、それはNG。というのも、AnyCPU版はx86とx64のDLLをEmbedded Resourceでかかえる形になっていて、必要に応じてロードしているっぽいのだが、これがAdd-Typeでうまくロードできない。x64やx86はプラットフォーム依存DLLがそのまま置いてあるのでOK。

アセンブリのロード

Add-Typeするだけ。これはさすがにスクリプト化しておいたほうが便利かもね。
PS C:\opt\magick> Add-Type -Path .\Magick.NET-*\lib\net40-client\Magick.NET-x64.dll

イメージの読み書き

イメージの読み込み

とにかくここから始まる。これはMagickImageインスタンスを作ればOK。
PS c:\temp> $image = New-Object ImageMagick.MagickImage c:\temp\sample.jpg
ただし、ワーキングディレクトリはPowerShellのものではないので、フルパスで指定するのが無難。クラス名はNew-O[TAB] MagickI[TAB]で補完できるのでおすすめ。

イメージの書き込み

後述の加工作業を行ってから、.Write(path)で書く。

PS C:\temp> $image.Write('c:\temp\sample-out.jpg')

基本的に「New-Object」「何かして」「Write」の組み合わせ。

フォーマット指示が必要な場合、Writeの前に入れる。
PS C:\temp> $image.Format = 'png'
PS C:\temp> $image.Write('c:\temp\sample-out.png')
とか、
PS C:\temp> $image.Format = 'jpeg'
PS C:\temp> $image.Quality = 99
PS C:\temp> $image.Write('c:\temp\sample-out-q99.jpg')
とか。

あとは
PS c:\temp> $image.Dispose()
で後片付け。インタラクティブ操作時はpowershellごと消してもいいと思うけど、ループ時とかは注意。

情報の表示

属性の表示

$imageと打てば、それだけで情報がでてくる。Width, Heightなど。これだけでもわりと便利。ただ、計算するプロパティが含まれているせいか、全部出すと重いのでループするのはおすすめしない。表示はこんな感じ(全部ではない):


EXIF情報の表示

$image.GetExifProfile().Values でOK。
PS C:\temp> $image.GetExifProfile().Values
 DataType                       IsArray                           Tag Value
 --------                       -------                           --- -----
    Ascii                         False              ImageDescription
    Ascii                         False                          Make SONY
    Ascii                         False                         Model NEX-6
    Short                         False                   Orientation 1
 Rational                         False                   XResolution 350
 Rational                         False                   YResolution 350
    Short                         False                ResolutionUnit 2
    Ascii                         False                      Software NEX-6 v1.01
    Ascii                         False                      DateTime 2015:05:24 10:46:23
    Short                         False              YCbCrPositioning 2
Undefined                          True                       Unknown {80, 114, 105, 110...}
 Rational                         False                  ExposureTime 0.01
 Rational                         False                       FNumber 13
    Short                         False               ExposureProgram 2
    Short                         False               ISOSpeedRatings 100
(以下略)
PS C:\temp> $image.GetExifProfile().Values.Where({$_.Tag -eq 'FocalLengthIn35mmFilm'}).Value
24

1ファイル系の処理

1ファイルに対する処理をこの画像を使って行っていく。


回転(変換と保存)

回転はRotate()。

PS c:\temp> $image = New-Object ImageMagick.MagickImage c:\temp\sample.jpg
PS C:\temp> $image.Rotate(90)
PS C:\temp> $image.Write('c:\temp\sample-out.jpg')


(多分)EXIFにしたがって回転してくれるAutoOrient()ってのもある。便利ね。

切り出し

切り出しはCrop()。Gravityはどこからひっぱってくるかの指示。
PS c:\temp> $image = New-Object ImageMagick.MagickImage c:\temp\sample.jpg
PS c:\temp> $image.Crop(200, 200, [ImageMagick.Gravity]::Center)
PS C:\temp> $image.Write('c:\temp\sample-out.jpg')

リサイズ

Resize()かResample()かScale()かThumbnail()。この使い分けは難しいのだけど、とりあえず無難そうなResizeだけ説明。

そのResize()もパラメータをいくつか選べる。
PS C:\temp> $image.Resize

OverloadDefinitions
-------------------
void Resize(int width, int height)
void Resize(ImageMagick.MagickGeometry geometry)
void Resize(ImageMagick.Percentage percentage)
void Resize(ImageMagick.Percentage percentageWidth, ImageMagick.Percentage percentageHeight)

一番簡単なやつがwidth, heightを指定するやつ(New-ObjectとWriteはここの説明では省略)
PS C:\temp> $image.Resize(200, 100)
ただし、アスペクト比を維持するので、(200, 100)にはならない。ここに入る大きさになる。



パーセンテージ指定が次に簡単(書き方がイマイチ...)
PS C:\temp> image.Resize((New-Object ImageMagick.Percentage 50))


で、もっともいろいろできるのが、MagickGeometry指定パターン。ImageMagickをコマンドで使っている人ならおなじみの、例の記法が使える。例えば、アスペクト比を無視する!を付与したスタイル。
PS C:\temp> $image.Resize((New-Object ImageMagick.MagickGeometry '200x200!'))


このあたりの挙動はImageMagickそのものの話なので、詳しくは公式ドキュメント(ImageMagick v6 Examples --
Resize or Scaling (General Techniques)
)を参照。なお、日本語でまとめてくれてる人もいる(ImageMagickでリサイズする方法

複数ファイル系の処理

ここからはこの2つの画像を例に説明。

結合

結合は、MagickImageCollectionを作ってimageを追加し、AppendHorizontally()かAppendVertically()を呼ぶ。
PS C:\temp> $image1 = New-Object ImageMagick.MagickImage c:\temp\sample.jpg
PS C:\temp> $image2 = New-Object ImageMagick.MagickImage c:\temp\sample2.jpg

PS C:\temp> $col = New-Object ImageMagick.MagickImageCollection
PS C:\temp> $col.Add($image1)
PS C:\temp> $col.Add($image2)

PS C:\temp> $image = $col.AppendVertically() # あるいは $col.AppendHorizontally()  
PS C:\temp> $image.Write('c:\temp\sample-out.jpg')

重ねる

結合とだいたい同じノリだけども、重ねることも可能。
PS C:\temp> $image1 = New-Object ImageMagick.MagickImage c:\temp\sample.jpg
PS C:\temp> $image2 = New-Object ImageMagick.MagickImage c:\temp\sample2.jpg

PS C:\temp> $col = New-Object ImageMagick.MagickImageCollection
PS C:\temp> $col.Add($image1)
PS C:\temp> $col.Add($image2)

PS C:\temp> $image = $col.Evaluate([ImageMagick.EvaluateOperator]::Add)
PS C:\temp> $image.Write('c:\temp\sample-out.jpg')

ここまで書いておいて例が悪いな。

etc, etc...

あとはBlurとか、さまざまなフィルタがあるのだけど、きりないからこのへんでやめ。ImageMagickは加工は得意なので、できることはたくさんある。

他のものを知りたかったら、$image.で[TAB]してみたり、MagickNetのドキュメントとか、ImageMagickのコマンドラインオプション(ImageMagick: Command-line Options)を参照。

おまけ: 統計情報の表示

最後に一応、需要はなさそうだけど、これをやるために使い始めたので説明。平均とかの取り方。

Statistics()を呼んで、Composite()あるいはGetChannel(チャンネル)を呼ぶ。
先に
$image.Grayscale([ImageMagick.PixelIntensityMethod]::Lightness)
を呼んでおくのも便利かも。
PS C:\temp> $stat = $image.Statistics()
PS C:\temp> $stat.Composite()

Channel           : Composite
Depth             : 1
Entropy           : 0.966856332282486
Kurtosis          : -1.25816857805541
Maximum           : 65535
Mean              : 27115.482213115
Minimum           : 0
Skewness          : 0.0779930273889325
StandardDeviation : 17685.6919467067
Sum               : 27115.482213115
SumCubed          : 45922010656938.6
SumFourthPower    : 2.14361048522477E+18
SumSquared        : 1049386363.99743
Variance          : 312783699.633806

PS C:\temp> $stat.GetChannel([ImageMagick.PixelChannel]::Blue)

Channel           : Blue
Depth             : 8
Entropy           : 0.967346949241304
Kurtosis          : -1.26933689965117
Maximum           : 65535
Mean              : 28404.2418122061
Minimum           : 0
Skewness          : 0.0995661483530123
StandardDeviation : 18559.3929293196
Sum               : 28404.2418122061
SumCubed          : 52904690206274
SumFourthPower    : 2.59600361552516E+18
SumSquared        : 1151252018.83115
Variance          : 1151252018.83115

Springありで@TheoryしたいときはSpringClassRule/SpringMethodRuleを使う

タイトルの通り。

@RunWith(SpringJUnit4ClassRunner.class) を指定していると JUnit4 で @Theory とか @Parameterized したいときに困るわけだけども、SpringClassRule と SpringMethodRule を使えば、RunWithにTheories.classとかを指定できる。

サンプル的なものはこちら。

参考:Spring Framework Reference Documentation 14.5.8 TestContext Framework support classes

比較的新しいらしい@FromDataPointsも使ってみたよ。

なお、Spring 4.2からのようなので注意。
testCompile 'org.springframework:spring-test:4.2.0.RELEASE'

2015-08-08

Caliburn.Micro + AutofacでWPFプロジェクトを開始する方法のメモ

ひとつひとつ解説すると長くなるので、使い慣れた構成で淡々とプロジェクトを開始できるようにというメモにする(更新するかも)

概要

以下の構成でプロジェクトを開始する。
  • Caliburn.Micro (MVVMライブラリ) → ガイド
  • Autofac (DIコンテナ) → ガイド
  • Autofac.AttributedComponent (Autofacへのコンポーネント収集)
  • ReactiveProperty + Rx(Model/ViewModel間の連携)

プロジェクトの作成とパッケージインストール

  • WPFプロジェクトを作る
  • 以下をInstall-Package
    • Caliburn.Micro
    • Autofac.AttributedComponent
    • ReactiveProperty

初期View / ViewModelの作成

ViewModels/ShellViewModel.csにViewModelを作成、Views/ShellView.xamlにWindowを作成。
(フォルダは切らなくてもいい: View / View Model Naming Conventionsを参照)

ShellViewModelはCaliburn.Micro.PropertyChangedBaseを継承し、Component attributeを付与する(Scope=ComponentScope.SingleInstanceにするかは好みで)


ShellView.csのクラスにも[Component]をつける。
(つけなくてもいいが、[Resource]によるフィールド・インジェクションが可能になるので、これによってビュー向けのシングルトンインスタンスを受け取ると便利。)

AppBootstrapperの作成

Caliburn.Micro初期化用クラスをBootstrapperBaseを継承して作る。

Customizing The Bootstrapper を参照。Caliburn.Micro.AutofacBootstrap がそこそこやってくれるのだけど、ちょっとばかり微妙なので、Autofacへのつなぎは以下で済ませる。



App.xamlの設定

以下のようにする。StartupUriは削除する。


MainWindow.xaml(.cs)の削除

いらないので削除。

おまけ: お試し

試したかったら、まぁこんな感じで(Basic Configuration, Actions and Conventionsより)

ShellView.xaml追記


ShellViewModel.cs


2015-08-04

groovyshでJavaのクラス情報を参照して適当にコード片を生成する

IDEがやってくれるような一般的なコード生成ではないが、規則のあるコードをちょちょいと生成したりしたいときがある。Annotation Processingでアノテーション見ながらというのが王道だろうが、そこまででもない使い捨てな感じにコードをGroovyを使って生成する方法。

groovyshの起動

GroovyのREPLであるところのgroovyshをまず起動する。追記: なお、IntelliJ IDEAであればTools > Groovy Consoleで起動できるGroovyで処理できるので、いきなり次の作業に飛んで問題ない。

単純なケースであれば、クラスパスを指定して、groovyshを起動する。
$ groovysh -cp out/production/javaclasstest

普通はさらさらっと指定できるほど単純ではないと思うが、Gradleからgradle-groovysh-pluginを使ってgroovyshを起動すれば、必要なものをロードしておいてくれる。

基本的には、以下の変更をbuild.gradleに加えた後「./gradlew -q --no-daemon shell」で起動すればOK。
  • buildscriptのdependenciesにcom.tkruse.gradle:gradle-groovysh-pluginを追加する。
  • 'java'と'com.github.tkruse.groovysh'をapply pluginする。
例としてはこんな感じ。

apply plugin: 'java'
apply plugin: 'com.github.tkruse.groovysh'

buildscript {
  repositories {
      jcenter()
  }

  dependencies {
      classpath 'com.tkruse.gradle:gradle-groovysh-plugin:1.0.7'
  }
}


ただし、gradle.propertiesに「org.gradle.daemon=true」が書いてあると「Do not run with gradle daemon (use --no-daemon)」と言われて起動できない。--no-daemonをつけろと言うのだが、つけてもダメ。多分チェック方法が間違っている。Issueに登録しておいたが、直るまでは実行するときはファイルにtrueを記述していない状態にするしかない。

[2015/8/26追記] warningが出つつも起動するようにしてもらえたので、ちゃんと-q --no-daemonで起動すればOK。

リフレクションを使って適当に作る

あとはjava.lang.Classjava.lang.reflect.Methodを見ながら、適当にコードを生成する。GroovyなのでgetHogeはhogeでいいし、Rubyちっくに適当につなげれば生成できるのでラク。

たとえば、こんな感じ。
$ ./gradlew shell -q
(略)
groovy:000> cls = com.example.HelloJavaBuilder
===> class com.example.HelloJavaBuilder
groovy:000> cls.methods.findAll { it.declaringClass == cls }.collect { ".${it.name}()" }.sort().join("\n")
===> .build()
.fuga()
.hige()
.hoge()
groovy:000> 

ポイントとしては、import なんちゃらと打つとパッケージ名の補完が効くので、いったんimportしちゃうなり、補完までしてimportを書き換えるなどするとパッケージ/クラス名の入力が楽。

Tips: GroovyのMetaClassを活用する

setterのsetを外したところを取りたい、みたいな話だと、普通にやるとこんな感じだと思う。
groovy:000> cls.methods.findAll { it.declaringClass == cls && it.name =~ /set/ }.sort().
collect { it.name.replaceAll(/set/, '').replaceAll(/^./) { it.toLowerCase() } }

しかし、GroovyのmetaClassを見ると、setter/getterはset/getを外した形でpropertiesに入っているので、こっちを使うとかなり楽になる(classもあるけど、そこはうまいこと外す)
groovy:000> cls.metaClass.properties.collect{ it.name }

もし役に立つ機会があればどうぞ。

2015-07-21

OpenCvSharpで表紙の折り目を検知してみた

いわゆる本の自炊をする際、表紙は切らず長尺読みをし、それを分割して使う。その分割作業をサポートするために作ったのがKiritoriMageなのだけど、この切り取る座標を決定するのを自動化できるんじゃね?ってことでやってみた。



詳しくは以下に記述するが、要は折られている以上折り目が存在するので、それを検知すれば、どこで切ればいいかわかるという作戦。その折り目を見つけるにも、印刷されているほうを使うと大変なので、一緒にスキャンされる裏側を使うことにした。

それを実装したのがKiritoriMage 0.0.2で、切りたい画像とセットになる裏側を一緒にドラッグアンドドロップしてあげればこの機能が動作する。試してみたい方はこちらからどうぞ。
https://github.com/sunnyone/kiritorimage/releases/tag/v0.0.2


処理の詳細

OpenCvSharpの使い方例として、処理内容を解説していく。わかりやすさのために、順番を差し替えたり、定数をリテラルにしたりしている。また、OpenCvSharpなので実際の呼び出しとは異なるが、解説が詳しいので、opencv.jpのC/C++のコードの説明をリンクしている。

今よりずっとWPFを使えないときに書いたために、ソースはだいぶきれいではないのでKiritoriMageを読むのはお勧めしないが、この処理自体はFoldDetectUtil.csにある。

今回の例に使う画像を縮小(+α)したものがこちら。実際の表紙を使うと面倒なので、適当な表紙を作った。
この偽表紙は実際の表紙と異なり普通紙でガサガサなので、裏にはちょっとNRをかけてある。


Phase1 折り目候補の洗い出し

画像に手を加えることで、折り目らしき部分を抽出する。

1-1. 画像の読み込み

簡単。Matクラスのコンストラクタにファイル名を渡すだけ。
var matSrc = new Mat(backFilename);

1-2. AdaptiveThresholdによる2値化

これが全体の中で一番肝だと言っていい。最初使ったときは感動した。

ただの2値化は固定値か全体の値を見て白か黒かにするという感じだけど、AdaptiveThresholdを使うとピクセルの周りを見て白か黒か選んでくれる(参考: OpenCvSharpをつかう その15(適応的閾値処理))。全体を見てしまうとこの紙の裏の画像はほとんど差がないので、自動的にやろうとするとどうしても「全部白!」or「全部黒!」となってしまうのだけど、折り目境界部分は比較的違いがはっきりしているので折り目をはっきりと浮かび上がらせてくれる。

コード的には、CvtColorでグレースケールに変換した後、AdaptiveThresholdを呼べばOK。KiritoriMageでは、blockSizeは幅の1/200を奇数にしたもの(4000なら21)としている。
var mat = matSrc.CvtColor(ColorConversion.BgrToGray)
    .AdaptiveThreshold(255, AdaptiveThresholdType.MeanC, ThresholdType.Binary, blockSize, 1);

後述の輪郭検出の都合が良いので、反転しておく。
mat = ~mat;

1-3. 高さ1にリサイズ後、平滑化をかけて2値化

折り目は縦一線にできるので、Resizeで思い切って高さ1にする。この処理ではInterpolation.Areaが一番よかった。
Cv2.Resize(mat, mat, new Size(matSrc.Width, 1), interpolation: Interpolation.Area);
(高さ1ピクセルでは見えないので、この状態で高さを戻すリサイズをかけた状態はこんな感じ)

そのままでは黒と白が細かく散っているので、平滑化する。輪郭を残してくれそうなので、MedianBlurをチョイス。KiritoriMageではksizeはさっきのblockSizeと一緒にしている(動かしてて困らない数値だっただけで、一緒にしたいという意図はない)。
mat = mat.MedianBlur(ksize)

(高さ1は見えないので、高さを戻した状態の画像)

終わったら再度2値化する。ここでは大津の2値化を使った。
mat = mat.Threshold(0, 255, OpenCvSharp.ThresholdType.Otsu);

(再び2値化後の高さを戻した状態の画像)

1-4. 高さを作り輪郭検出

1次元のデータなのでこのまま回して分割していってもいいのだけど、だるいので高さを作ってOpenCVの輪郭検出FindContoursにまかせて、その輪郭を囲む矩形の座標をもらう。…なんて無駄なのでしょう!遅くて使えなかったら本気出す。まぁここでFindCoutoursの例を出すために使ったと言っても過言ではない。なお、FindContoursはmatを破壊するので注意。
Cv2.Resize(mat, mat, new Size(matSrc.Width, 3), interpolation: Interpolation.Cubic);
var rects = mat
    .FindContoursAsArray(ContourRetrieval.External, ContourChain.ApproxSimple)
    .Select(x => Cv2.BoundingRect(x))
    .OrderBy(x => x.X)
    .ToArray();

(選択できたものをCv2.Rectangleで書いた画像)

ここで折り目候補ができたが、ここまでで90%、成否が決まっているといっても過言ではない。候補が大量にできていてはうまくいかないし、そもそも各折り目がどれか検出されなかった時点でアウト。

本当はここで処理を完了したかったのだけど、そんな調子なので、折り目だけを検出するようにパラメータを構成するのは難しく、どれかの折り目が検出されなくなってしまうことがほとんどなので、候補から適切なものを選択することにした。

Phase2 折り目候補から折り目4つを選択

「表紙の紙の折り目」である、という特徴を使って、一番選択肢として妥当なものを選択する。

2-1. 折り目候補の一覧から4つを選ぶ全ての組み合わせの算出

いわゆる順列/組み合わせ。候補から4つを選ぶ組み合わせを列挙する。コードは省略。そこまで長くはないし、実際自前で実装しているんだけど、NuGetでパッとインストールできるこの手の処理が集まってるライブラリの都合のいいやつがないのよね。LINQに入っていてもおかしくない汎用さなんだけど。

2-2. 座標、幅をピクセル数から全体幅との比率に変換

まず各組み合わせについて、画像のサイズは毎回違っていて扱いにくいので、全体の幅で割って割合にする。書いていて思ったが先にやっておいたほうが計算量は少ないね。
var widthRatios = rectCands.Select(r => new { 
    Left = r.X / imageWidth, 
    Right = (r.X + r.Width) / imageWidth,
    Width = r.Width / imageWidth }).ToArray();

2-3. 幅が3%以上なら組み合わせを却下

組み合わせのひとつひとつを見て、折り目が太すぎるもの、具体的には全体幅の3%越えが存在するものはおかしいのでその組み合わせは却下する。過去データを見る限り、1%くらいだったので、余裕を持って3%。
rectSets = rectSets.Where(r => r.WidthRatios.All(x => x.Width < 0.03)).ToArray();

2-4. 細い同士、太い同士の割合(thinRatio, fatRatio)を計算

表紙の紙の特徴として、基本的に表紙と裏表紙(「太いほう」)は同じ幅だし、折り込まれた表紙の内側と裏表紙の内側(「細いほう」)も同じ幅のはず。そのため、表紙の幅/(表紙の幅+裏表紙の幅)という感じで太いほう、細いほうそれぞれの片側が占める割合を計算して(thinRatio, fatRatio)とする。
double leftThin = widthRatios[0].Left;
double rightThin = 1 - widthRatios[3].Right;
double thinRatio = leftThin / (leftThin + rightThin);

double leftFat = widthRatios[1].Left - widthRatios[0].Right;
double rightFat = widthRatios[3].Left - widthRatios[2].Right;
double fatRatio = leftFat / (leftFat + rightFat);
その(thinRatio, fatRatio)と(0.5, 0.5)の距離を計算して、一番近かったものを選んで、これを答えとする。
double thinFatDistance = Math.Sqrt(Math.Pow(fatRatio - 0.5, 2) + Math.Pow(thinRatio - 0.5, 2));

var bestMatchRectSet = rectSets.OrderBy(x => x.ThinFatDistance).First();

これで選ばれたものがこちら。


あとは切るX座標として0, 1, 3番目の右側を選んで終了。

いくつか手法を試して、結果的にわりとシンプルなやりかたに落ち着いた。太いほう:細いほうの比はあまり極端ではないはずなので、そこを基準にフィルタしてもいいかなと思っているけども、使い込んでいないのでまぁ様子を見てという感じ。一晩でやれたらいいかなくらいだったものを、だいぶ頑張って実装してしまった感があるので、ここで一区切りかな。