縁で切れた絵に余白だけ描き足して継ぐ:元の画素を残す位置合わせの手順

頭や腕が写真の縁で切れていたので、余白だけを画像生成で描き足し、特徴点で位置合わせして継ぎました。元の範囲は元の画素のまま残す方法と、細長い帯を 2:3 に分けると合うようになった経緯の記録です。

目次
  1. 何を作りたかったか
  2. 最初のやり方:余白をつけて生成し、足した部分だけ継ぐ
  3. 継ぐ処理:特徴点で位置合わせして、足した部分だけ使う
  4. 失敗1:写真全体を渡すと、安全装置で止まった
  5. 失敗2:左右の細長い帯は、位置が合わない
  6. 罠:頭の上が増えると、位置の計算がずれる
  7. 測った結果と、残ったもの
  8. まとめ

写真の縁で頭の先や腕が切れていると、カードにした時に「欠けた」印象になります。そこで、足りない余白だけを画像生成で描き足し、元の写真に継ぎました。条件は1つ、元の写真が写っている範囲は元の画素のまま残すことです。顔や体は生成した画像に入れ替えません。

  • 描き足すのは余白だけ。生成画像は「足した部分」にしか使わない
  • 生成画像は特徴点(ORB)で、元の写真に位置合わせしてから使う
  • 画像生成の安全装置で止まる写真は、縁の帯だけを渡す
  • 縦に細長い帯は、構図を作り直されて位置が合わない。帯を分けて 2:3 にすると合った

何を作りたかったか

人物の写真をカードの主役にしているのですが、元の写真は撮った人が自由に構図を決めたもので、頭の上や腕が縁で切れているものがあります。カードでは人物を台紙に置き、枠の外へ飛び出す表現も使うので、切れた頭や腕はとくに目立ちます。ある写真は頭のリボンが、別の写真は帽子の先が上で切れていました。

決めた方針は次のとおりです。頭・帽子・腕などが写真の縁で切れていて見栄えが悪い写真は、余白だけ描き足して補う。ただし元の写真の範囲は元の画素のままにして、顔は生成しない。人物の写真は、そこに写っている本物のままにしておきたかったからです。

最初のやり方:余白をつけて生成し、足した部分だけ継ぐ

最初に試したのは、いちばん素直な手順です。

  1. 元の写真の上に余白を足した画像を作る(余白は縁の色をぼかして伸ばしたもの)
  2. その画像を参照にして、画像生成モデルに余白の部分だけ描き足させる
  3. 生成された画像の「足した部分」だけを、元の写真に継ぐ

最初の写真では、元の写真の上に22%の余白を足しました。指示の文は別ファイルにして、上の余白だけを描き足すように書いています。

余白つきの画像を作るスクリプト pad_for_outpaint.py の中心は、これだけです。

top, left, right = (float(v) for v in sys.argv[3:6])
im = Image.open(src).convert("RGB")
w, h = im.size
pt, pl, pr = round(h * top), round(w * left), round(w * right)
W, H = w + pl + pr, h + pt
canvas = im.resize((W, H), Image.LANCZOS).filter(ImageFilter.GaussianBlur(60))
if pt:
    canvas.paste(im.crop((0, 0, w, 24)).resize((w, pt))
                   .filter(ImageFilter.GaussianBlur(30)), (pl, 0))
# 左右も同じ。縁の幅24pxの帯を伸ばしてぼかし、余白に貼る
canvas.paste(im, (pl, pt))

余白の割合は、上は元の高さ、左右は元の幅に対する割合で渡します。写真全体を大きなキャンバスへ引き伸ばして強くぼかし、その上に、縁の細い帯(24px)を余白の大きさまで伸ばしたものを重ね、最後に元の写真を置きます。余白は「縁の色が続いている」だけの状態になり、そこへ描き足してもらう形です。

継ぐ処理:特徴点で位置合わせして、足した部分だけ使う

生成された画像は、渡した画像と完全に同じ位置・大きさで返ってくるとは限りません。少し拡大されていたり、わずかに回転していたりします。そのまま重ねると、継ぎ目で絵がずれます。そこで、元の写真が写っている範囲で特徴点を取り、位置合わせをしてから重ねます。

g0 = cv2.resize(gen, (W, H), interpolation=cv2.INTER_AREA)
inside = np.zeros((H, W), np.uint8)
inside[pt:pt + h, pl:pl + w] = 255   # 元の写真が写っている所だけで探す
orb = cv2.ORB_create(8000)
ka, da = orb.detectAndCompute(cv2.cvtColor(padded, cv2.COLOR_BGR2GRAY), inside)
kb, db = orb.detectAndCompute(cv2.cvtColor(g0, cv2.COLOR_BGR2GRAY), inside)
m = sorted(cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True).match(db, da),
           key=lambda x: x.distance)[:1200]
M, inl = cv2.estimateAffine2D(
    np.float32([kb[x.queryIdx].pt for x in m]),
    np.float32([ka[x.trainIdx].pt for x in m]),
    method=cv2.RANSAC, ransacReprojThreshold=2.5)
g = cv2.warpAffine(g0, M, (W, H), flags=cv2.INTER_LANCZOS4,
                   borderMode=cv2.BORDER_REPLICATE)

ここで効いているのは、特徴点を探す範囲を inside で「元の写真が写っている所」に限っていることです。余白の部分は生成側が自由に描いた絵なので、そこで特徴点を取っても対応が取れません。元の写真と同じ内容が写っている範囲だけで対応を探し、RANSAC で外れ値を捨てて、拡大・回転・平行移動の変換(アフィン変換)を求めます。

最初の写真では、倍率 1.5719、回転 0.03 度の変換が求まりました。生成画像は、渡した画像と同じ大きさ・同じ位置では返ってきません。変換を数値で求めてから重ねる理由がここにあります。

位置合わせが良いかどうかの確認として、スクリプトは「元の写真との差」も出します。

diff = float(np.abs(g[pt:pt + h, pl:pl + w].astype(int)
                    - src.astype(int)).mean())

位置合わせをした生成画像のうち、元の写真に当たる部分と、本物の元の写真との画素の差の平均です。小さいほど合っています。後の節の「差 80」と「差 3.8〜8.8」は、この値です。

その後の仕上げは3段です。

  1. 色合わせ:元の写真の縁の帯(幅60px)で、生成画像の色の平均を元の写真に寄せる(gain)。
  2. 元の写真の範囲を、元の画素で上書きする(res[pt:pt + h, pl:pl + w] = src)。
  3. 継ぎ目の内側 28px だけ、生成から元の写真へなだらかに切り替える(B = 28 の線形の重み)。ここも、使うのは元の写真の縁だけです。

結果として、生成画像が最終画像に入るのは足した余白と、継ぎ目の内側の28pxだけです。顔は生成画像を使っていません。

失敗1:写真全体を渡すと、安全装置で止まった

ある写真では、写真全体を渡したところ、画像生成側の安全装置で止まりました。理由を詳しく調べる手段はなく、分かるのは「止まった」ことだけです。

直した方法は、渡す範囲を縁の帯だけにすることでした。渡したのは写真の上端の帯で、帽子と前髪だけが写っています。顔も体も入りません。描き足すのは余白だけなので、必要な情報は縁の帯で足りました。これで止まらずに通り、帽子の先が補えました。

同じ考え方で、頭の上が切れた写真に使うためのスクリプト outpaint_top.py も作りました。縁の帯を3:2の横長の画像の下に置き、上の42%に当たる部分を縁の色で伸ばした余白にします。

H = round(W * 2 / 3)          # 3:2 の横長
pad = round(H * 0.42)         # 上の余白の高さ
band = src.crop((0, 0, W, H - pad))
top = band.crop((0, 0, W, max(8, round(H * 0.04)))) \
          .resize((W, pad), Image.BICUBIC).filter(ImageFilter.GaussianBlur(40))
out = Image.new("RGB", (W, H))
out.paste(top, (0, 0)); out.paste(band, (0, pad))

失敗2:左右の細長い帯は、位置が合わない

左右の腕や袖が切れている写真では、左右の縁の帯を渡しました。ところが、縦に細長い帯のままだと、生成側が構図そのものを作り直してしまいました。位置合わせの特徴点は5〜6個しか合わず、元の写真との差は 80 でした。これでは継げません。

直した方法は、帯を縦に分けて、1つずつ 2:3 の画像にして渡すことでした。生成側は2:3のような普通の縦横比だと構図を保ちやすい、というのが私の見立てです(これは推測で、確かめた根拠はありません)。結果として、シスターの衣装の写真では9つの帯のすべてで位置合わせが通り、差は 3.8〜8.8 に収まりました。80 から一桁下がっています。

この「帯を分ける」版は、専用の小さなスクリプトにしてあります。他に、写真全体を渡す版と、縁の帯だけを渡す版があり、写真ごとに通る方法を選びました。

  • 写真全体を渡す版:2枚で通った
  • 縁の帯だけを渡す版:安全装置で止まった1枚の上側
  • 帯を分けて 2:3 にする版:左右が切れた1枚(上の9つの帯)

罠:頭の上が増えると、位置の計算がずれる

頭の上が増えると、カード側で顔の位置をそろえる計算(顔を台紙の決まった位置に置く計算)のままでは、高いヘッドドレスがカードの上端で切れました。これは補完した結果の副作用で、ある写真は顔の位置と倍率を手で下げて対処しました。補完は「欠けを直す」だけでは終わらず、置き方の再調整までが一式です。

測った結果と、残ったもの

ある人物の写真の組では、5枚に補完を行いました。そのうち継いだ4枚とカードの絵を目で見て、継ぎ目がなく頭が欠けていないことを確認しています。補完しなかった所もあります。カードの金の枠で切っている部分は、写真の縁ではないので、そのままにしました。

残る弱点は次のとおりです。

  • 生成が通るかどうかは写真ごとで、通るまで渡し方を変える作業が要る
  • 位置合わせの「差」は、元の写真と比べる指標で、足した部分の絵の自然さは測れない。そこは目視に頼った
  • 縁の帯だけを渡す方法は、帯の外側に何が写っているかを生成側が知らないまま描く

まとめ

縁で切れた絵は、余白だけ描き足して継ぐことで、元の画素を残したまま補えました。要点は、特徴点を元の写真の範囲だけで探すこと、安全装置で止まったら縁の帯だけを渡すこと、細長い帯は 2:3 に分けることです。

切り抜きの作り直しは 切り抜きマスクの記事 に書きました。