bookscan

OpenCVで自炊本をモノクロ2値に変換してみた

自炊代行業者さん(Bookscan)に依頼した本のPDFがたくさんあるのだが,読みやすく美白化して白黒2値に変換したい。それを実現するためのプログラム。まだ試作段階だが,とりあえず動作している。以前,GIMP用のスクリプトやImageMagick用のPerlスクリプトで行っていたことを,OpenCVでやってみた。以前のバージョンに比べて,明らかにスピードが速い。OpenCVはもちろんC++でプログラム書いたことないので,無事にコンパイル出来たときは,ちょっと嬉しかったな。

参考にしたのは,次のページ。参考というよりは,ほとんどそのまま頂いている。有難きは先達なり。

橋本商会 scansnapで自炊した本をkindleで読めるように補正する(2)

このページに書かれているプログラムをひな形として,自分の目的に合うように処理内容をあちこち変えた。実は,boost::filesystemのバージョンが上記ページが書かれた頃から変わっていたため,そのままではコンパイル出来なかった。また,OpenCVのバージョンも上がっていて,ライブラリーの名前などが以前とは変わっているようなので,それに応じてMakefileも少し修正を必要とした。ちなみに,OpenCVもboostもHomebrewでインストールした。バージョンは brew info によれば,それぞれ opencv: stable 2.4.5, boost: stable 1.54.0 (bottled) となっている。

やっていることは,次の通り。

  • 赤チャンネルだけを抽出することでグレースケールに変換
  • 上下左右の余白を白で塗りつぶす
  • Lanczos法により2倍に拡大
  • ルックアップテーブルを作成することにより,レベル補正,ガンマ補正
  • 2値化
  • PNGで保存

レベル補正,ガンマ補正の式が多分正しくない。gamma=1のときは直線補間だから正しいと思うが,それ以外のときの式がわからない。ImageMagickの level high low gamma と同じにしたいのだが。これは今後の課題。

PNGで保存しているが,可能であれば,CCITT G4圧縮のTIFFで保存したい。これも今後の課題。

#include "cv.h"
#include "highgui.h"
#include <boost /program_options.hpp>
#include </boost><boost /filesystem/operations.hpp>
#include </boost><boost /filesystem/path.hpp>
#include </boost><boost /filesystem/fstream.hpp>
#include <iostream>
using namespace boost;
using namespace std;
namespace fs = boost::filesystem;

//---------------------------------------------------------------
// my_Level
// レベル補正・ガンマ補正 
// (ImageMagickの convert -level white_point black_point gamma と同じにしたいのだが)
// src    = 入力画像
// dst    = 出力画像
// high   = ホワイトポイント (0..255)
// low    = ブラックポイント (0..255)
// gamma  = ガンマ補正値
// 注意:暫定版。ガンマ補正の式はこれで正しいのか不明。
// 0<x &lt;1 と正規化したとき,f(x)={(x-low)/(high-low)}^{1/gamma} としてみた。
//---------------------------------------------------------------

// グローバル変数
uchar g_LUT&#91;256&#93;; // Lookup table用の配列
CvMat g_lut_mat;  // それをopencvの行列とみなしたもの

void make_lut(int high, int low, double gamma){
    int i;
    double x;
    const double p = 1.0 / gamma ;
    const double a = (double) low;
    const double b = (double) high;
  
    //補正用のルックアップテーブルの作成
    for (i=0; i<low; i++) { g_LUT&#91;i&#93; = 0; }
    for (i=high; i&lt;256; i++) { g_LUT&#91;i&#93; = 255; }
    for (i=low; i<high; i++) 
    {
      x = (double) i;
      // 16階調にする場合 16x16=256
      //g_LUT&#91;i&#93; = cvCeil( ((x-a)/(b-a)) * 15 ) * 16;
      g_LUT&#91;i&#93; = cvCeil( 255.0 * pow((x-a)/(b-a), p) );
    }
 
    //CvMatへ変換
    g_lut_mat = cvMat(1, 256, CV_8UC1, g_LUT);
}

void my_Level(IplImage* src, IplImage* dst) {
  //ルックアップテーブル変換
  cvLUT(src, dst, &g_lut_mat);
}

IplImage *adjust_image(IplImage *img, program_options::variables_map argmap){
  int cleft = argmap&#91;"cleft"&#93;.as<int>();
  int cright = argmap["cright"].as<int>();
  int ctop = argmap["ctop"].as</int><int>();
  int cbottom = argmap["cbottom"].as</int><int>();
  int threshold = argmap["threshold"].as</int><int>();

  const int w = img->width;
  const int h = img->height;
  const int w1 = 2*w;
  const int h1 = 2*h;
  const int x1 = cleft;
  const int x2 = w-cright;
  const int y1 = ctop;
  const int y2 = h-cbottom;

  IplImage *img_gray;

  //赤チャンネルを抽出してグレースケールの画像を得る
  //チャンネル毎に分割して,赤チャンネル以外は捨てる
  // IplImageはB,G,Rの順に格納されている
  // アルファチャンネルは用いないので,5番目の引数はNULLにしておく
  // cvSplit(img,channelB,channelG,channelR,NULL);
  img_gray = cvCreateImage(cvSize(w,h),IPL_DEPTH_8U,1);
  //cvCvtColor(img, img_gray, CV_BGR2GRAY);
  cvSplit(img,NULL,NULL,img_gray,NULL);

  //周囲を白で塗る
  cvRectangle(img_gray, cvPoint (0,0), cvPoint (x1,h), cvScalar (255), CV_FILLED, 8, 0);
  cvRectangle(img_gray, cvPoint (x2,0), cvPoint (w,h), cvScalar (255), CV_FILLED, 8, 0);
  cvRectangle(img_gray, cvPoint (0,0), cvPoint (w,y1), cvScalar (255), CV_FILLED, 8, 0);
  cvRectangle(img_gray, cvPoint (0,y2), cvPoint (w,h), cvScalar (255), CV_FILLED, 8, 0);

  //2倍に拡大
  IplImage *img_resize = cvCreateImage(cvSize(w1,h1), IPL_DEPTH_8U, 1);
  cvResize(img_gray, img_resize, CV_INTER_LANCZOS4);

  //レベル補正
  IplImage *img_level = cvCreateImage(cvSize(w1,h1), IPL_DEPTH_8U, 1);
  my_Level(img_resize, img_level);

  if (threshold > 0) {
    // 2値化する場合
    IplImage *img_bin = cvCreateImage(cvSize(w1,h1), IPL_DEPTH_8U, 1);
    // ガウシアンフィルタで平滑化を行う
    // cvSmooth (img_level, img_level, CV_GAUSSIAN, 5);
    // 2値化
    cvThreshold(img_level, img_bin, threshold, 255, CV_THRESH_BINARY);
    //cvThreshold(img_level, img_bin, 0, 255, CV_THRESH_BINARY | CV_THRESH_OTSU); // 大津の手法
    // メモリー開放
    cvReleaseImage(&img_gray);
    cvReleaseImage(&img_resize);
    cvReleaseImage(&img_level);
    // 2値化した画像へのポインターを返す
    return img_bin;
  } else {
    // グレースケールで出力する場合
    // メモリー開放
    cvReleaseImage(&img_gray);
    cvReleaseImage(&img_resize);
    // レベル補正したグレースケール画像へのポインターを返す
    return img_level;
  }
}

int main(int argc, char* argv[]) {
  program_options::options_description opts("options");
  opts.add_options()
    ("help", "ヘルプを表示")
    ("high", program_options::value</int><int>()->default_value(255), "level high")
    ("low", program_options::value</int><int>()->default_value(0), "level low")
    ("gamma", program_options::value<double>()->default_value(1.0), "level gamma")
    ("threshold,t", program_options::value<int>()->default_value(-1), "binarize threshold")
    ("input,i", program_options::value<string>(), "input directory name")
    ("output,o", program_options::value</string><string>(), "output directory name")
    ("cleft", program_options::value<int>()->default_value(0), "crop left (pixel)")
    ("cright", program_options::value</int><int>()->default_value(0), "crop right (pixel)")
    ("ctop", program_options::value</int><int>()->default_value(0), "crop top (pixel)")
    ("cbottom", program_options::value</int><int>()->default_value(0), "crop bottom (pixel)");
  program_options::variables_map argmap;
  program_options::store(parse_command_line(argc, argv, opts), argmap);
  program_options::notify(argmap);
  if (argmap.count("help") || !argmap.count("input") || !argmap.count("output") ||
      !argmap.count("high") || !argmap.count("low")) {
    cerr < < "&#91;input, output, high, low&#93; required" << endl;
    cerr << opts << endl;
    return 1;
  }

  int high = argmap&#91;"high"&#93;.as<int>();
  int low  = argmap["low"].as</int><int>();
  double gamma  = argmap["gamma"].as<double>();

  // ルックアップテーブルを作成する。ルックアップテーブルはグローバル変数 g_lut_mat である。
  make_lut(high,low,gamma);

  string in_dir = argmap["input"].as<string>();
  fs::path path = complete(fs::path(in_dir));
  fs::directory_iterator end;
  for (fs::directory_iterator i(path); i!=end; i++){
    string img_fullname = in_dir + i->path().filename().string();
    cout < < img_fullname << endl;
    IplImage *img, *img_result;
    img = cvLoadImage(img_fullname.c_str());
    if(!img){
      cerr << "image file load error" << endl;
    }
    else{
      img_result = adjust_image(img, argmap);
      // string out_filename = argmap&#91;"output"&#93;.as<string>() + "/" + i->path().filename().string();
      string out_filename = argmap["output"].as</string><string>() + "/" + i->path().stem().string() + ".png";
      cvSaveImage(out_filename.c_str(), img_result);
      cvReleaseImage(&img);
      cvReleaseImage(&img_result);
    }    
  }
}

スキャンしたPDFの後処理

以前から懸案ではあったのだが,Windowsマシンも買ったことだし,画像処理関係のツールをいろいろ集めて実験してみた。実は,Mac上でもImageMagickなどで実験していたのだが,Windowsだと専用のツールが豊富にあるようなので,それがWindowsマシン購入の理由の1つだったりするのだ。

自分でスキャンするものについては,スキャンの段階で白黒2値とか選べるが,Bookscanなどのスキャン代行業者に依頼したものは,そういうわけにもいかず,黄色く紙焼けした本など,そのまんまカラーでスキャンされているものが,けっこうある。それをきれいに脱色して,白黒2値あるいは16階調グレースケールあたりに変換するのが目的である。

いくつか試してみた結果,画像梱包,Ralpha Image Resizer, XnView の3つを使って,とりあえず目的を果たした。XnViewはMac版やLinux版もあるが,あとの2つはWindows専用のプログラムである。

まず,画像梱包(pic2pdf)を使って,PDFの画像を抽出する。ソフトの注意書きには,画像梱包でPDFにしたものに対してのみ抽出可能とあったが,スキャンした本のPDFでも大丈夫のようだった。要するに,複雑な構造のPDFもあるから,それは無理ということなのだろう。本をスキャンして作ったPDFは,たいていの場合,単にJPEGのデータにヘッダー(XMLとか)を付加しただけであるから,問題ないのだと思う。抽出のスピードも非常に速い。AcrobatでJPEG出力するよりもずっと速いのだ。しかもJPEGのヘッダーを外すだけなので,基本的に無劣化で画像が取り出せるらしい。

次は,Ralpha Image Resizer を使っての画像処理。紙焼けは,周辺部がとくにひどいので,文字がないことを確認してから,上下左右をトリミングする。そして,同じ分量だけ付け加える。こうすると,紙の周囲が真っ白になる。次に,2倍に拡大する。これで,600dpi相当にオーバーサンプリングしたことになる。最後に白黒2値化するので,解像度を上げておきたいから。続いて,トーンカーブを調節する。ここのところは,ImageMagickのlevel処理のように,high, low, gamma の3つで簡単に処理しても良いと思うのだが,まあ,似たようなもの。プレビュー出来るので,結果を見ながら調節できる。最後に,赤チャンネルのみを抽出してグレースケールにする。

このあと,白黒2値でCCITT FAX G4圧縮のTIFFにしたいのだが,Ralphaでは白黒2値というメニューがない。グレースケールで色数を2色にすれば,白黒2値と同じなのかもだが,それでもJPEGになってしまうのは嫌なので,Ralphaだけで済ますのはあきらめた。結局,Ralphaでは,グレースケールにしてPNGとして出力させて終了。

Ralphaで出力したPNGファイルをXnViewで一括変換する。一気に白黒2値とどれくらい違うが疑問だが,段階を踏むことにして,グレースケールの色数を4bit, 3bit, 2bitと下げていき,最後に1bitつまり白黒2値にする。白黒2値にする際には,閾値(しきいち,threshold)というパラメーターがあるはずなのだが。何故か見当たらない。ここが謎なのだが,ともかく白黒2値にする。出力はCCITT FAX G4圧縮のTIFFファイルとする。

最後に,再び画像梱包を使う。解像度を600dpiと指定して,PDFに固める。解像度の指定をしておくと,Acrobatで見るとき,100%でもそれほど巨大にならないし,それに,表紙と裏表紙(これはPDFから抽出したままで,2倍にしていない)を後からAcrobatで加えるとき,同じ大きさで表示されるので,ちょうど良い。

以上で作業は終了。これをバッチ処理できるスクリプトがあればよいのだが。書式は,
mypdf2pdf hoge.pdf -level high low gamma -color 1-2 143-144 -resize 2 hoge2.pdf
とかいった感じで。

自炊事始め2

裁断の要領や注意点(紙がくっついてないかを、くどいくらいにチェックすること)も分かってきたので、いよいよハードカバーの書籍の解体・裁断・スキャンをやってみた。

少々ためらったが、シュヴァルツの解析学(全7巻)を遡上に。まずは、第7巻から。魚をさばくのに比べればずっと楽。(というか、やったことないから、想像だが。) 本体と表紙をくっつけているガーゼ(?)のような部分があるので、カッターで真っ直ぐに切ると、繋ぎ目がきれいに外れるようだ。上手くやると背表紙に傷をつけることなく、ハードカバーだけがパカっと取れる。まあ、どのみち廃棄するのではあるが、中には裁断後も残す本もあるだろうから。

裁断機Durodex 200DX, OFLAカッターハイパーH型, PLUS カッティングマット, アルミ直尺アル助

道具は、カッター、カッティングマット、定規、そして裁断機。

ハードカバーを解体・裁断 (シュヴァルツ 解析学7)

上で書いたように、脇の部分(?)というか、繋ぎ目を切断してハードバウンドを外す。読み取りはまだ試行錯誤中だが、網掛けの挿絵のない数学書の場合、白黒が一番読みやすいと思う。ScanSnapではPDFかJPEGしか選べないのだが、白黒だとJPEGも選べずにPDFの一択になってしまう。内部フォーマットはJPEGなのか、それともTIFFなのか、それも分からない。まあ、でも、これでまずまず満足の画質ではある。

白黒で読み取ったページ

念のため、カラーのJPEGでも読み取っておくことにした。圧縮もレベル2と少なめにした。ホントはRAWというかTIFFかPNGで読み取ったものを原本にしたいところだが、まあ、その代わりといったところ。

カラーで読み取ったページ

白黒での読み込みも、実はカラーで読み取ってから、画像処理用のCPUか何かでソフトウェア的に白黒にしているのだと思う。だとすれば、カラーで読み取ってから、ImageMagickなどで変換するのと、(手間を除けば)変わらないのはず。

しかし、本を解体するのは、心理的な抵抗感があるなあ。そのうち慣れちゃうのかもだけど。

ドキュメントスキャナーと裁断機を購入

ついにスキャナーと裁断機を購入した。

ScanSnap IX500 と 裁断機 Durodex 200DX

スキャナーは定番のScanSnap IX500という機種。TWAINに対応していないとか,気に入らないところもあるのだが,総合的にはまあこれだろうな,ということで。裁断機は縦置きで保管できて場所を取らない(比較の問題ですが)ということで,Durodexの200DXにした。

来週になったら少し暇になるから,ホントの自炊(今までは業者に依頼していたので)を始めてみようかな。

Hardy全集をお出かけ自炊してきた

自転車圏内に裁断機・スキャナ完備のレンタル自炊スペースが開店していることを知ったので、先日試しに行ってみた。開放的でゆったりとしていて、お店の人も親切かつ知識豊富。料金はちょっと高めだが、業務用の高性能スキャナで取り込めることなどを考慮すれば、個人的には許容範囲。書籍以外でも、つまりコピー用紙や資料のシートなどでも取り込んで良いという話だったので、大昔にコピーしておいたハーディー全集(第1巻)を自転車に積んで再訪した。

デフォルトは品質80%のjpegなのだが、自分で設定を変えて良いので、可逆圧縮のPNGで取り込んでみた。解像度は300dpi。さすがに600dpiで取り込む勇気はなかった 😉 もっとも、600dpiでモノクロ2値での取り込みなら、ファイルサイズも大したことはないので、こちらは検討の余地あったかも。

JPEGに比べるとPNGの方がサイズ大きいので、若干スピードが遅くなったが、この程度であれば全然問題ない。取り込んだ画像をPCで確認する。

Hardy全集1のプレビュー中

ものの数分で取り込み作業は終了する。むしろ確認に時間が掛かる。まあ、サムネイルでチェックしても大丈夫そうだが。このあとPDFに変換することも出来るようだが、それは自宅でも出来るから、PNG画像のまま、持参したUSBメモリーにコピーして終了。

最初のページは、こんな感じ。読むだけであれば、このままで十分かな。

ハーディー全集第1巻

電子書籍の自炊関係では、著作権がらみで、もろもろあるようだが、裁断機や業務用スキャナをレンタルで使えるお店は個人的には非常に助かるので、長続きして欲しいなあと思う。

スキャンしたPDFの最適化メモ

ScanSnapでPDFにしただけのものは特に問題ないが,AcrobatでOCR処理したものは,設定にもよるが,Mac OS Xのプレビューでの表示が遅いというか重たい。Adobe Readerならそれほどでもないが。ということで,その対策メモ。

Previewから別名保存でも良いが,サイズが倍近くなったりする。Acrobat 9で別名保存して,その際,PDF, optimized というのを選ぶ方が良さそう。標準だと150dpiにダウンサンプリングするが,これをダウンサンプリングなしにしてみた。結果は,サイズがやや増加(20パーセントくらいか?)するものの,Preview.appでもスムーズにスクロールして,極めて快適。

もう少し実験が必要だが,良さそうだったら,夜中にでもすべてのファイルにバッチ処理でこの最適化を施してみよう。

MacOSX上のAcrobatによるOCRでの文字化け対策

諸般の事情で、MacのPreviewで快適に見るには、やはり自前でOCR処理できなくちゃなあということで、ちょっこし実験してみた。ところが、のっけから文字化けで、参ったのなんのって。

文字認識はしているようなのであった。検索しまくった結果、次のページを発見。

MacOSX 上の Acrobat の OCR の奇妙な文字化けに対処する

正にワタシと同じ状況!そうなのだ。UTF-8にされちゃっているのだ。ということで、上記記事に従って、~/.MacOSX/environment.plist を見てみると、案の定 LANG変数は UTF-8 に設定してある。しかし、これを自分で設定した記憶がない。デフォルトでこうなってるんじゃなかったのかなあ。ううむ。

ともあれ、これを削除してみた。再起動させてから、おもむろに Acrobat 9 を起動し、OCR処理させてみたところ、文字化けせずにちゃんと認識できた。

ということで、一件落着。2時間無駄にしたけどな 😉 それにしても、LANG変数、いつ設定したのだろうか。というか、削除して他のアプリに影響ないのかなあ。ちょっと心配。

おお、これが画像処理の威力なのか!

昔の本の場合、紙焼けしてたりするので、スキャンしたままだとかなり目立つ。まあ、これはこれで味があって良いかもだし、読むのにそんなに支障があるかと言えば、大したことはないようにも思う。しかし、調べてみると、割と簡単な画像処理で補正できるらしいのである。試しに実験したので、メモ。

残念なことに、Mac OS X で適当なプログラムを見つけることが出来なかった。もちろん、GIMP(無料)とかPhotoShop(有料)とか使えば出来るに違いないのだが、カラーでスキャンした紙焼けの書籍を一括して(つまりバッチ処理)読みやすくモノクロに変換するという今の目的をお手軽にこなすという意味では、Windowsに軍配を上げざるを得ない状況のようだ。

ということで、使用したのは「藤 -Resizer-」というプログラム。ファイルを選択してドロップすれば、一括処理してくれる。

設定パラメーターは「漫画をスキャナで電子書籍化する初心者向け自炊解説サイト」の中の「スキャンした画像の加工」という記事に全面的に依存した。

サンプルとして選んだのは、大学初年級の参考書「詳解・微積分演習 I」のとあるページ。ちなみにこの第2巻、誰かに貸したままで戻ってきてないのである。返してくれ〜(笑)。

さて、処理前の画像がこれ。

補正前の画像

これを白色化閾値=225、正規化範囲=70/255、輝度範囲=-100/300、コントラスト補正値=1.1、相対ガンマ値=1.4、白黒化手法2、にて処理したものが次の画像。

補正後の画像

おお、と思わず声を上げてしまった。す、素晴しいじゃないですか!とりあえず、というか、ワタシの場合、これで満足です。

あとは、すべてのページをこれで処理して、再びPDFにするか、あるいはJPGのままZIPでアーカイブするか。まあ、この本は読まないだろうが(笑)、Mumford先生の謄写版刷りの本 (裁断する勇気があるか? 😯 ) などでやれば効果抜群かもしれない。

PDFから画像ファイルを取り出す

[ 備忘録 ] 書籍をスキャンしたPDFの画像補正のためのメモ。

PDFといっても書籍をスキャンしてできるファイルの場合、中身は画像ファイルであり、PDFは単なるコンテナーでしかない。それを取り出すのが目的。MacのPreviewなどから取り出すことも可能だが、できれば劣化させないで、そのままを取り出したい。あれこれ調べた結果、pdfimages で出来るようだ。それには xpdf というパッケージ(?)をインストールしなくてはならい。

以下は、Mac OS X の MacPorts でインストールする場合の手順。

sudo port install xpdf

とすればオッケー。hoge.pdf からJPGファイル群を取り出すには、

pdfimages -j hoge.pdf foo

とかすれば、foo-000.jpg, foo-001.jpg, 等々と連番でファイルが生成される。