読者です 読者をやめる 読者になる 読者になる

しろもじメモランダム

文字についてあれこれと。

てきとうに書いて作ったフォントができるまで(4)

書体 手書き

てきとうに書いて作ったフォントについて、(1), (2), (3) の続き。

PerlMagick/ImageMagick

手書きシートをスキャンしたら、そのスキャン画像を切り分けて1文字ずつにバラす必要がある。今回使ったのは PerlMagick というライブラリで、これは画像処理ツール ImageMagick の機能を Perl から扱えるようにしたもの。使い方などに関しては、以下のページがかなり参考になった。

位置の修正

画像を切り出すには、あらかじめマスの大きさと位置を指定しておけばよい。ただ、実際のスキャン画像はズレたり傾いたりしているので、単純に切り出すだけではうまくいかない。GIMPPhotoshop などを使って自分で修正するという手もあるが、ちょっと面倒。もちろんスキャンする時点で、細心の注意を払ってきっちり紙の位置を合わせておけば良いのだが、どう考えてもこれも面倒。それ以前に、印刷自体がズレていたりもする。

ということで、位置の修正は自動でやってもらいたい。そこで、手書きシートの左上・左下・右上の3箇所には黒四角のマーカーをつけておいた。画像を走査してそれぞれのピクセルが黒(=マーカーの中)か否かを判断し、黒と判断されたピクセルの重心を求めればそれがマーカーの中心、という安直な方法。改良の余地あり。

3つのマーカーの位置が求まれば、そこからシート自体の傾きやズレがわかるので、シートがまっすぐ真ん中にくるよう位置を修正する。

ImageMagick では回転の中心を指定することはできない(常に画像の中心が回転の中心となる)ようなので、まず中心を合わせるために、図の点線(body が内接するような長方形)に沿って周囲を切り落とす。その上で、角度θだけ時計回りに*1回転させて、位置を合わせる。回転の処理は比較的重いので、無視できるほどの傾きだったら傾き0とした。

画像の座標は、通常左上隅の点が原点 (0, 0) となっている。しかし Crop() で切り取ったあとの画像は、切り取ってできた画像の左上隅の点が原点となるのではなく、切り取る前の座標を受け継ぐようになっている様子。なのでそのまま回転させても、元の画像の中心を軸にして回ってしまい、意味がない。そこで、切り取った画像を一旦保存し、それを開き直すようにしている*2

が、よくよく考えてみると、中心を合わせずに回転させたとしても位置は計算で出せるはず。そうすればわざわざ保存して開きなおす必要もない。ここも改良の余地あり。

切り分けて保存

まっすぐになったら、1文字ずつ切り取っていく。切り取る位置は、単純な計算をこまごまとすれば出てくる。Unicode のリストを読み込み、uXXXX という名前をつけて保存する。ここで問題になるのが、保存するときの画像の形式。この次の工程で使う Potrace は、BMP か PNM (PBM, PGM, PPM) しか読み込めない。ということで、最初は BMP で保存してみた。

が、なぜか Potrace で読み込めない調べてみると、bmp3: という指定をする必要があるらしい。PerlMagick であれば、

$image->Write(filename=>"bmp3:hoge.bmp");

というようにすればうまくいった。

が、スキャン画像によっては Potrace の出力結果がおかしくなる(この図の右)。どうも BMP のカラーモードがインデックスになっている*3と、うまくいかない様子。一応、type=>"Grayscale" って指定してるんだけど……。これじゃないのか? よく分からなかったので、BMP は捨てて PGM で保存することにした。とりあえずこの PGM なら、今のところ問題なし。

PNM (PBM, PGM, PPM) という形式には馴染みがなかったが、UNIX では標準的な画像形式の一つとして使われているらしい。

実際のスクリプト

自動での位置修正は大体うまく動くものの、それでもまだ微妙にズレる。面倒だったので*4、自分で指定した量だけ追加でズラせるようにしておいた。

実際の動画でも、

$ perl glyphsplit.pl scan/hira.png code/_hira.txt 0 3

という感じで、実はこっそり調整している*5。まぁ自動修正の方を改良すべきなんですけどね。

一部表現を変えたが、使ったスクリプトを下に示す。初心者が作ったものなので、ご利用は計画的に。

#!/usr/bin/perl
#
# 手書きシートのスキャン画像をグリフごとに分割し、out/pgm/uxxxx.pgmとして保存
#
# usage: perl glyphsplit.pl [scanimage] [codelist]
#                             ([optionaloffsetx] [optionaloffsety])

use strict;
use warnings;
use Image::Magick;
use Math::Trig;

my $scanimage = $ARGV[0];     # 手書きシート画像
my $codelist = $ARGV[1];      # 対応するコードリスト
my ($oposx, $oposy) = ($ARGV[2] || 0, $ARGV[3] || 0);  # 任意に加えるオフセット

# 手書きシート画像を開く
my $img = Image::Magick->new;
$img->Read($scanimage);
my ($imgw, $imgh) = $img->Get('width', 'height');  # 手書きシート画像の大きさ
my ($cw, $ch) = ($imgw / 8, $imgh / 11);           # 隅を切り取る大きさ


# 隅を切り取ってそれぞれのマーカーの中心を求める
print "calibrate...\n";
#   左上
my $corner = $img->Clone();
$corner->Crop(width=>$cw, height=>$ch);
my ($ltx, $lty) = &getcenter($corner, $cw, $ch);
$corner->Write('out/temp/templt.bmp'); # 確認用
#   右上
$corner = $img->Clone();
$corner->Crop(width=>$cw, height=>$ch, x=>$imgw-$cw);
my ($rtx, $rty) = &getcenter($corner, $cw, $ch);
$rtx += $imgw - $cw;
$corner->Write('out/temp/temprt.bmp'); # 確認用
#   左下
$corner = $img->Clone();
$corner->Crop(width=>$cw, height=>$ch, y=>$imgh-$ch);
my ($lbx, $lby) = &getcenter($corner, $cw, $ch);
$lby += $imgh - $ch;
$corner->Write('out/temp/templb.bmp'); # 確認用
undef $corner;


# マーカー間の距離を求める
my $bodyw_a = $rtx - $ltx;     # 左上・右上間のx座標の差
my $bodyh_a = $lby - $lty;     # 左上・左下間のy座標の差

# シートの角度を求める
my $theta = atan2($lbx - $ltx, $bodyh_a);


# bodyが内接するように切り抜き
print "crop...\n";
if ($theta >= 0) {
  $img->Crop(width=>$rtx+$lbx-2*$ltx, height=>$lby-$rty, x=>$ltx, y=>$rty);
} else {
  $img->Crop(width=>$rtx-$lbx, height=>$lby+$rty-2*$lty, x=>$lbx, y=>$lty);
}

# 座標を直すために、一旦bmpで保存して開き直す(bmp以外にもjpegやtiffでも可)
$img->Write('out/temp/temp.bmp');
@$img = ();
$img->Read('out/temp/temp.bmp');

# ある程度傾いていれば、シートを回転させて向きを合わせる
if (abs($theta) > 0.0022) {
  print "rotate...\n";
  $img->Rotate(degrees=>$theta*180/pi, color=>'#fff');
} else {
  $theta = 0;
}

# bodyの大きさ・オフセットを求める
my ($bodyw, $bodyh) = ($bodyw_a / cos($theta), $bodyh_a / cos($theta));
my ($bodyosx, $bodyosy) = (($img->Get('width') - $bodyw) / 2 + $oposx,
                           ($img->Get('height') - $bodyh) / 2 + $oposy);

# コードのリストを読み込む
open(CODE, $codelist) or die "$!";
my @code = ();
while (<CODE>) {
  @_ = split(/ /);
  push(@code, $_[0]);
}
close(CODE);

# オリジナルのsvgにおける距離・座標
my ($ltx_o, $lty_o, $markersize_o) = (49.227, 992.911, 20);
my ($rtx_o, $lby_o) = (674.867, 39.451);
my ($a1x_o, $a1y_o, $cellsize_o) = (132.343, 905.311, 28.701);
my ($f1x_o, $a2y_o, $b1x_o) = (313.335, 850.035, 163.524);

# 距離の換算
my ($xr, $yr) = ($bodyw / ($rtx_o - $ltx_o), $bodyh / ($lty_o - $lby_o));
                                           # 換算率
my ($cellw, $cellh) = ($cellsize_o * $xr, $cellsize_o * $yr);
                                           # セルの大きさ
my $a1osx = $bodyosx + ($a1x_o - ($ltx_o + $markersize_o / 2)) * $xr;
                                           # a1セルまでのオフセット(x)
my $a1osy = $bodyosy + (($lty_o + $markersize_o / 2) - ($a1y_o + $cellsize_o)) * $yr;
                                           # a1セルまでのオフセット(y)

my $colos = ($f1x_o - $a1x_o) * $xr;       # 列によるオフセット
my $rowos = ($a1y_o - $a2y_o) * $yr;       # 行によるオフセット
my $cellos = ($b1x_o - $a1x_o) * $xr;      # セルによるオフセット

# セルに切り分けpgmで保存
print "split:\n";
binmode(STDOUT, ":utf8");
foreach my $col (0..2) {
  my $offsetx = $a1osx + $colos * $col;
  print "\n     ".("a  b  c  d  e", "f  g  h  i  j", "k  l  m  n  o")[$col]."\n";
  foreach my $row (0..15) {
    my $offsety = $a1osy + $rowos * $row;
    printf "%2d ", $row + 1;
    foreach my $cell (0..4) {
      my $cellimg = $img->Clone();
      my $cellcode = shift(@code);
      unless ($cellcode eq "3000") { # 全角スペースはスキップ
        $cellimg->Crop(width=>$cellw, height=>$cellh,
                       x=>$offsetx + $cellos * $cell, y=>$offsety);
        $cellimg->Write(type=>"Grayscale", filename=>"out/pgm/u$cellcode.pgm");
      }
      print " " . pack("U", hex($cellcode));
    }
    print "\n";
  }
}

undef $img;

exit;


# ---------------------------------------------

# マーカーの中心を求める
sub getcenter {

  my ($corner, $cw, $ch) = @_;

  # 白黒二値化
  $corner->Quantize(colors=>2, dither=>'True', colorspace=>'Gray');

  # 中心を計算
  my $sumx = my $sumy = 0;
  my $countx = my $county = 0;
  for (my $y = 0; $y < $ch; $y += 2) {
    for (my $x = 0; $x < $cw; $x += 2) {
      if ($corner->Get("pixel[$x, $y]") =~ /^0/) {  # そのピクセルが黒ならば
        $sumx += $x;
        $sumy += $y;
        $countx++;
        $county++;
      }
    }
  }
  return($sumx / $countx, $sumy / $county);
}

(5)へ続く

*1:Rorate() は(反時計回りではなく)時計回り方向を正にとっている。

*2:もっとスマートなやり方がありそうなもんだけど。

*3:うちのスキャナでは少なくとも、グレースケールでスキャンしたものを PNG で保存すると、インデックスの画像になる。

*4:早く一通り作っちゃいたかったし。

*5:これはないしょだから誰にも言わないように!