縁で切れた絵に余白だけ描き足して継ぐ:元の画素を残す位置合わせの手順
に公開
頭や腕が写真の縁で切れていたので、余白だけを画像生成で描き足し、特徴点で位置合わせして継ぎました。元の範囲は元の画素のまま残す方法と、細長い帯を 2:3 に分けると合うようになった経緯の記録です。
目次
写真の縁で頭の先や腕が切れていると、カードにした時に「欠けた」印象になります。そこで、足りない余白だけを画像生成で描き足し、元の写真に継ぎました。条件は1つ、元の写真が写っている範囲は元の画素のまま残すことです。顔や体は生成した画像に入れ替えません。
- 描き足すのは余白だけ。生成画像は「足した部分」にしか使わない
- 生成画像は特徴点(ORB)で、元の写真に位置合わせしてから使う
- 画像生成の安全装置で止まる写真は、縁の帯だけを渡す
- 縦に細長い帯は、構図を作り直されて位置が合わない。帯を分けて 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段です。
- 色合わせ:元の写真の縁の帯(幅60px)で、生成画像の色の平均を元の写真に寄せる(
gain)。 - 元の写真の範囲を、元の画素で上書きする(
res[pt:pt + h, pl:pl + w] = src)。 - 継ぎ目の内側 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 に分けることです。
切り抜きの作り直しは 切り抜きマスクの記事 に書きました。