1.はじめに
Elixir(Phoenix LiveView)を用いて、IIIF対応の画像アーカイブシステムを開発しています
考古学の発掘調査報告書は限られた予算もあり、ページ数が限られています
このため1ページ中に複数の図版がジグソーパズルのように紙面ギリギリに密着して組版されるのは珍しくありません
このような複雑な組版に対応するため、個々の図版を切り離して保存するのは容易ではありません
従来の「四角形(バウンディングボックス)」でのクロップでは、どうしても隣接する別の遺物の一部が入り込んでしまいます
これを解決するため、画像処理ライブラリ vix (裏側で動くC言語の libvips) を駆使して「多角形(ポリゴン)クロップ」を実装しました
本記事では、フロントエンドでの多角形UIの構築から、バックエンドでの高度なマスク合成、そして「JPEG配信における透過の罠」を突破した手法を共有します
2.課題:IIIF配信における「JPEGの罠」
多角形の外側を「透明(アルファチャンネル)」にするだけなら簡単に見えますが、IIIF規格の標準的な配信フォーマットは JPEG です。
JPEGは物理的に透過を保持できないため、せっかく透過させた余白が黒く潰れたり、元の画像が復活したりしてしまいます。
そこで、透過させるのではなく 「多角形の外側を物理的に『純白(RGB: 255, 255, 255)』で塗りつぶす」 というアプローチをとる必要がありました
3. フロントエンド:LiveViewとSVGによるプレビュー
まずはUI側です。JavaScriptのフックを用いて、ユーザーがクリックした頂点座標の配列(points)をLiveViewに送信します。
画面上でのプレビュー表示には、SVGの <clipPath> を使用します。多角形の外側が透明になってUIの暗い背景色が透けるのを防ぐため、背面に純白の <rect> を敷き詰めるのがポイントです。
<svg viewBox={"#{min_x} #{min_y} #{width} #{height}"} class="w-full h-48 object-contain">
<rect x={min_x} y={min_y} width={width} height={height} fill="white" />
<defs>
<clipPath id="polygon-clip-#{item.id}">
<polygon points={Enum.map(item.points, fn p -> "#{p["x"]},#{p["y"]}" end) |> Enum.join(" ")} />
</clipPath>
</defs>
<image href={item.image_path} clip-path="url(#polygon-clip-#{item.id})" />
</svg>
これにより、ブラウザ上ではすでに完璧に切り抜かれたかのような美しいサムネイルが表示されます
4. バックエンド:vixによる物理的な白塗り合成
ここからが本題
フロントエンドから送られてきた points を元にvix で実際の画像ファイルを加工します
ここで活躍するのが、Vix.Vips.Operation.ifthenelse という強力な関数です。
名前からElixirの標準的な if / else 構文(プログラムの条件分岐)を連想するかもしれませんが、全く異なります
これは、libvipsが提供する 「画像の全ピクセルを同時に判定して混ぜ合わせる、ピクセル合成専用の画像処理コマンド」 です
メモリを節約するため、まずは最小の四角形でクロップ(extract_area)し、その後、SVG文字列から動的にマスクを生成してこの ifthenelse 関数で白背景と合成します
# 1. 画像の寸法取得とポイントのオフセット(切り抜き位置に合わせる)
width = Vix.Vips.Image.width(cropped_img)
height = Vix.Vips.Image.height(cropped_img)
offset_points = Enum.map(points, fn p -> "#{p["x"] - min_x},#{p["y"] - min_y}" end) |> Enum.join(" ")
# 2. SVGマスクの動的生成(背景黒、多角形部分が白)
svg_mask = """
<svg width="#{width}" height="#{height}">
<rect width="100%" height="100%" fill="black" />
<polygon points="#{offset_points}" fill="white" />
</svg>
"""
# マスクを1バンド(グレースケール)として読み込む
{:ok, {svg_img, _}} = Vix.Vips.Operation.svgload_buffer(svg_mask)
{:ok, mask} = Vix.Vips.Operation.extract_band(svg_img, 0)
# 3. 合成用の純白の背景画像を作成する
{:ok, black} = Vix.Vips.Operation.black(width, height)
{:ok, white} = Vix.Vips.Operation.invert(black)
{:ok, white_bg} = Vix.Vips.Operation.bandjoin([white, white, white])
# 4. 元画像が確実に3バンド(RGB)であることを保証する
{:ok, rgb_img} = Vix.Vips.Operation.extract_band(cropped_img, 0, n: 3)
# 5. ifthenelseによるピクセル合成処理
# mask画像のピクセルが白(>0)の座標は rgb_img の色を採用し、
# 黒(0)の座標は white_bg (純白) の色を採用する
{:ok, final_img} = Vix.Vips.Operation.ifthenelse(mask, rgb_img, white_bg)
5.なぜ単なるアルファチャンネル追加ではなく、ifthenelse関数なのか?
単純なアルファチャンネルの追加(bandjoin)では、JPEG配信時に前述の透過問題が発生します。Vix.Vips.Operation.ifthenelse を使用することで、「対象物は元の色、それ以外の領域は真っ白」という物理的なピクセルレベルの書き換えを、極めて高速かつ安全に行うことができます。
6.まとめ
Elixirと vix の強力な画像処理能力、そしてPhoenix LiveViewのリアクティブなUIを組み合わせることで、専門領域における複雑なクロップ要件をクリアすることができました
同じように「画像の任意の多角形部分だけを抽出し、それ以外を特定の色で塗りつぶしたい」というケースにおいて、SVGマスクとVix.Vips.Operation.ifthenelse の組み合わせは非常に有効なアプローチになるかと思います
詳しい実装はGitHubを見てください