2015/02/22

WPFでカラープロファイルを添付プロパティで取得する

2/21のめとべや東京#7での@veigrさんのセッション「カラーマネジメントシステムの概要とカラマネプログラミング初歩」はもやっと感じていたカラーマネジメントの基礎がようやく理解でき、かつ実践的なコードもあるという、自分的にとても有意義なものでした。
セッション中でも触れられていたWPFの関連APIの説明も参考になります。Microsoftからの説明があまりないので、こういう実際の動作まで調べたものは有り難いです。
WPFに関しては、モニターのカラープロファイルを取得する部分を除けば標準APIの枠内で対応でき、さほどハードルは高くないので(Windowsフォトビューアーのように同じ画像内で同時に違うカラープロファイルを適用するなどという変態的なことを目指さなければ)、画像の色を真面目に表示するアプリを作る場合は必見だと思います。

で、モニターのカラープロファイルについては、マルチモニター対応のためには各Windowが現在属しているモニターのものが必要になるので、これをWindowの添付プロパティで取得するものを書いてみました。
この添付プロパティを持つクラスはColorProfilePropertyですが、
  • Freezableを継承しているのは、バインディングターゲットとなれるようにするため。DependencyObjectだとバインディングソースにしかなれないので、バインディングを張るときの自由度が落ちる。
  • 添付プロパティの値を自分自身のインスタンスとしているのは、このクラス中の依存関係プロパティを複数、自由に参照できるようにするため。"AttachedProperty"という名前にとくに意味はない。
モニターのカラープロファイルのファイルパスを取得するメソッドは以下のとおりです。
private string GetColorProfilePath(Visual sourceVisual)
{
  var source = PresentationSource.FromVisual(sourceVisual) as HwndSource;
  if (source == null)
    return null;

  var monitorHandle = NativeMethod.MonitorFromWindow(
    source.Handle,
    NativeMethod.MONITOR_DEFAULTTO.MONITOR_DEFAULTTONEAREST);

  var monitorInfo = new NativeMethod.MONITORINFOEX
  {
    cbSize = (uint)Marshal.SizeOf(typeof(NativeMethod.MONITORINFOEX))
  };

  if (!NativeMethod.GetMonitorInfo(monitorHandle, ref monitorInfo))
    return null;

  IntPtr deviceContext = IntPtr.Zero;

  try
  {
    deviceContext = NativeMethod.CreateDC(
      monitorInfo.szDevice,
      monitorInfo.szDevice,
      null,
      IntPtr.Zero);

    if (deviceContext == IntPtr.Zero)
      return null;

    // First, get the length of file path.
    var lpcbName = 0U;
    NativeMethod.GetICMProfile(deviceContext, ref lpcbName, null);

    // Second, get the file path using StringBuilder which has the same length. 
    var sb = new StringBuilder((int)lpcbName);
    NativeMethod.GetICMProfile(deviceContext, ref lpcbName, sb);

    return sb.ToString();
  }
  finally
  {
    if (deviceContext != IntPtr.Zero)
      NativeMethod.DeleteDC(deviceContext);
  }
}
この中でもコアなのはGetICMProfile関数を使う部分ですが、ファイルパスを格納するlpszFilename(第3引数)のサイズを決めるのに少しややこしいことをしていて、初めにこれにnullを入れて実行するとlpcbName(第2引数)にlpszFilenameのサイズが入ってくるので、これを使ってStringBuilderを用意し、lpszFilenameに入れて再度実行することで取得しています(@veigrさんのサンプルのとおり)。

2回実行するのはどうも……という場合は、lpcbNameを通常あり得るパスの最大長である260に決め打ちしても動きます。
var lpcbName = 260U;
var sb = new StringBuilder((int)lpcbName);
NativeMethod.GetICMProfile(deviceContext, ref lpcbName, sb);
万一サイズが足りなかった場合を考えて戻り値を取って処理を重ねることもできます……細かい話ですが。

サンプルアプリを実行すると、カラープロファイルのファイルパスが表示されます。

モニター間をまたいでWindowを移動かリサイズするとファイルパスが変わり、画像がColorConvertedBitmapで再描画されます。ただ、この処理が結構重いので、滑らかに切り替わるというわけでもないです。まあ移動かリサイズを止めたタイミングで反映させるようにすれば、目立たないだろうと思いますが。

実はこれまでセカンダリモニターのカラープロファイルは適当で済ませてましたが、きちんと設定して試してみるとプライマリモニターとはっきり分かる違いがあるのを発見して、意外と馬鹿にならないです。

2015/02/12

Exifの日付だけを修正する

先日、新しいカメラを持ってイベントに行って写真を撮ってきた。まではよかったが、PCに取り込む段になって写真の日付が1年前になっていることに気づいた。原因は容易に想像できるとおり、初めにカメラの時計を設定したときに年を間違えていた。

当然、画像ファイルのExifに記録された日付も1年前になっていたが、Exifの情報は色々な写真管理の基礎となるので、これはいかにもまずい。

そこで既存ツールを試してみたが、Exifのサムネイルが維持されなかったりして、ツールを確かめて回るのも手間。それなら自分でできないかと思い、ただし、真っ当にExifを編集しようとするとファイルサイズが変わるので、年を1年直すだけでそれは負けた気がする、ということで直接ファイルのバイナリを修正しようと考えた。

Exifの規格では日時は"YYYY:MM:DD HH:MM:SS"のフォーマットのASCII文字列と決まっているので、そんな無理な話でもないはず。実際、バイナリエディタで簡単に見ることができたので、ゴーアヘッド。

1. バイト配列用のIndexOfとReplaceメソッド

まずはバイト配列に含まれる他のバイト配列の位置を調べて置換するメソッドが必要なので(StringにおけるString.IndexOfとString.Replaceメソッドに相当)、書いてみた。

主眼は位置を調べるメソッドの方で、複数用(SequenceIndicesOf)と単数用(SequenceIndexOf)にそれぞれ引数がbyte[]とIEnumerable<byte>のオーバーロードを作った。
ルートとしては、
  1. 複数用byte[]メソッド+単数用byte[]メソッド
  2. 複数用byte[]メソッド+単数用IEnumerable<byte>メソッド
  3. 複数用IEnumerable<byte>メソッド
の3通りあるが、SequenceReplaceメソッド中の呼び出し元の引数がbyte[]なので、このままだと1のルートが走る。

折角なので引数にAsEnumerable<byte>()を付けてルートを変えつつ、最終的なコードで300のJPGファイル(計1.06GiB)を処理したところ、所要時間は以下のようになった。
  1. 21.6秒
  2. 46.7秒
  3. 36.1秒
byte[]で通した1のルートが基本にして最速という結果だが、2のルートが2倍以上遅いのはSkipが時間を食っているのではないかと思う(実行の度に先頭からループが回るわけで)。自分的に期待したのは3のルートだが、1のルートには全く及ばず。

なお、最終的なコードでは置換数=検索数の上限を指定するmaxCount引数を使ってないが、実はヒット回数は各ファイル3回でかつ位置は先頭部分と分かっているので、これに3を指定すれば画像データ部分を無駄に検索する必要がなくなって所要時間はたいして変わらなくなる、というオチ。

[修正]

複数用IEnumerable<byte>メソッドのmaxCountの処理にバグがあったので修正した。複数用byte[]メソッドは問題なかったが、比較のためこれに合わせた。なお、数字を挙げた所要時間を計った際にはmaxCountを使ってないので、影響はない。

2. Exifの日付部分を置換するメソッド

このSequenceReplace拡張メソッドを使ってJPGファイルのExifの日付部分を置換するメソッド(とそのコンソールアプリ)。

各ファイルについて、以下の処理をする。
  1. バイト配列として読み込む
  2. 一応、真っ当にExifの日時の文字列を読み出す(System.Photo.DateTakenにあるパスを使う)
  3. 元の日時をDateTimeに変換し、修正したDateTimeを生成して、これを規格に従った文字列にする
  4. 両方の文字列をASCIIの文字コードでバイト配列に変換する
  5. これらを置換したバイト配列を生成する
  6. バイト配列を別ファイルに書き込む
3. まとめ

以上、実用での使用時間は1分以内だったというコーディングだが、バイト配列の置換というのはユーティリティメソッドとしていかにもありそうなので、より効率的な方法があるような気がする。