画像の標本化
画像のデジタル化 では、連続的なアナログ画像は標本化・量子化・符号化という3つの工程でデジタル画像に変換されることを解説しました。
ここでは、その最初の工程である標本化を、もう一歩踏み込んで見ていきます。
標本化をどれだけ細かく行うかによって、もとの画像をどこまで正しく再現できるかが決まります。
標本化とは何か
連続的に濃淡が広がるアナログ画像を格子状に区切り、各格子点の上にある明るさを読み取る操作が標本化です。標本化は、サンプリングと呼ばれることもあります。
このとき、値を読み取る格子状の点を標本点といいます。標本点は、デジタル画像の画素の位置に対応しています。各標本点で読み取った明るさ(輝度などの値)が標本値であり、これがそのまま画素の値のもとになります。
そして、隣り合う標本点どうしの間隔を標本化間隔といいます。標本化間隔は画素のピッチ(画素どうしの間隔)に対応しており、この間隔が狭いほど、画像を細かく読み取っていることになります。
では、この間隔はどれくらい細かくすればよいのか?が次の主題です。
空間周波数と標本化間隔
標本化間隔をどこまで細かくすべきかは、もとの画像がどれだけ細かい模様を含んでいるかによって異なります。この細かさを表す尺度が、画像の中で明暗がどれだけ密に変化しているか?という空間周波数です。
多くの場合、周波数とは、時間が経つにつれて波が何回繰り返すか(1秒間の振動回数)を表します。
これに対して空間周波数は、位置をずらしていくにつれて波(明暗)が何回繰り返されるかを表します。波の繰り返しを数える基準が、時間の経過ではなく画像内の位置になっているわけです。この位置をずらす向きのことを空間方向と呼ぶため、空間周波数という名前が付いています。
たとえば、白と黒の縞模様を考えると、明暗の切り替わりが多い細かい縞ほど空間周波数が高く、太い縞ほど空間周波数が低いと表現できます。
そして、細かい模様、すなわち空間周波数の高い部分を正しく読み取るには、標本化間隔を十分に狭くする必要があります。標本化間隔が縞の細かさに対して粗すぎると、隣り合う標本点の間に縞が何本も入り込んでしまい、その明暗の変化を取りこぼしてしまうからです。
標本化定理
では、空間周波数に対して、標本化間隔はどれだけ細かくすれば十分なのでしょうか。
この問いに答えるのが標本化定理です。
標本化定理
もとの信号に含まれる空間周波数の最大値を とするとき、標本化周波数(標本化の細かさ) が次の条件を満たせば、標本値からもとの信号を完全に復元できる
つまり、画像に含まれる一番細かい模様に対して、その2倍以上の細かさで標本化しなければならない
境界値となる周波数 は、正しく標本化できる空間周波数の上限です。これをナイキスト周波数といいます。
1本の縞(明から暗へ、そして再び明へ戻る1周期分の波)を表現するには、少なくとも山の部分と谷の部分の2点を読み取らなければなりません。1周期あたり2点以上の標本点がないと、波の山と谷を区別できず、その縞の情報が失われてしまいます。
そのため、最も細かい縞に対しても、1周期につき最低2点の標本点を確保できる細かさ、すなわち最大空間周波数の2倍以上の細かさが必要になるのです。
標本値からもとの信号を実際に取り戻す手順については、標本化定理と信号の補間 で見ていきます。
エイリアシング
標本化定理の条件を満たさず、画像の細かさに対して標本化間隔が粗すぎると、もとの画像には存在しない模様が現れてしまうことが知られています。
このように、粗い標本化によって本来とは異なる信号が生じてしまう現象をエイリアシングといいます。
エイリアシングの身近な例として、モアレがあります。標本点が縞をところどころ飛ばして拾ってしまい、もとには無かった太い縞が浮かび上がってしまう現象です。
エイリアシングは、斜めの線や輪郭を画素に置き換えるときにも現れます。
滑らかな斜め線が標本化によって階段状にギザギザに見えるジャギーも、細かい形状を粗い標本点で表現しきれないために起こる、エイリアシングの一種です。
標本点の数が縞の細かさに足りないとき、標本化した結果に、もとの縞模様には無かった太い縞が浮かび上がるモアレを観察しよう
また、斜めの線はギザギザに見え、ジャギーが発生していることも確認しよう
Three.jsによる実装概要
// 縞の細かさ(画像の幅あたりに入る縞の本数)と傾き(度)、1 辺あたりの標本点の数
const FREQUENCY = 15
const ANGLE = 2
const SAMPLE_COUNT = 26
// もとの縞模様を焼く格子の細かさ。標本化の格子よりずっと細かくとり、連続的な明暗として見せる
const REFERENCE_RESOLUTION = 384
// 画像として扱う正方形の 1 辺と、左右に並べた 2 枚の間隔
const IMAGE_SIZE = 2
const IMAGE_GAP = 0.45
const HALF_IMAGE = IMAGE_SIZE / 2
const OFFSET_X = HALF_IMAGE + IMAGE_GAP / 2
// もとの連続的な縞模様。位置 (x, y)(ともに 0〜1)での明るさ(0〜1)を返す。
// 縞に垂直な向きへ進むほど明暗が繰り返すので、傾けた向きの成分で位相を決める
const patternAt = (x: number, y: number) => {
const radians = (ANGLE * Math.PI) / 180
const across = x * Math.cos(radians) + y * Math.sin(radians)
return 0.5 + 0.5 * Math.sin(2 * Math.PI * FREQUENCY * across)
}
// 指定した細かさの格子で明るさを読み取り、1 格子 1 テクセルとして焼く
const bakePattern = (resolution: number) => {
const data = new Uint8Array(resolution * resolution * 4)
for (let row = 0; row < resolution; row++) {
for (let column = 0; column < resolution; column++) {
// 格子 1 つ分の真ん中で明るさを読み取る
const x = (column + 0.5) / resolution
const y = (row + 0.5) / resolution
const level = Math.round(patternAt(x, y) * 255)
data.set([level, level, level, 255], (row * resolution + column) * 4)
}
}
const texture = new DataTexture(data, resolution, resolution)
texture.colorSpace = SRGBColorSpace
texture.needsUpdate = true
return texture
}
// 左:もとの連続的な縞模様。テクセルどうしを滑らかに繋いで、明暗の切れ目を見せない
const referenceTexture = bakePattern(REFERENCE_RESOLUTION)
referenceTexture.magFilter = LinearFilter
referenceTexture.minFilter = LinearFilter
// 右:標本化した結果。標本値を 1 画素 1 テクセルに焼き、
// 拡大しても画素どうしが混ざらないよう補間なし(NearestFilter)で貼る
const sampledTexture = bakePattern(SAMPLE_COUNT)
sampledTexture.magFilter = NearestFilter
sampledTexture.minFilter = NearestFilter
// 明るさをそのままの濃さで見せたいので、2 枚とも陰影の付かない材質で貼る
const imageGeometry = new PlaneGeometry(IMAGE_SIZE, IMAGE_SIZE)
const reference = new Mesh(
imageGeometry,
new MeshBasicMaterial({ map: referenceTexture, side: DoubleSide })
)
reference.position.x = -OFFSET_X
scene.add(reference)
const sampled = new Mesh(
imageGeometry,
new MeshBasicMaterial({ map: sampledTexture, side: DoubleSide })
)
sampled.position.x = OFFSET_X
scene.add(sampled)
// 画像の外周。背景と明るさが近い部分でも、画像の範囲が分かるようにする
const frameGeometry = new BufferGeometry().setFromPoints([
new Vector3(-HALF_IMAGE, -HALF_IMAGE, 0.002),
new Vector3(HALF_IMAGE, -HALF_IMAGE, 0.002),
new Vector3(HALF_IMAGE, -HALF_IMAGE, 0.002),
new Vector3(HALF_IMAGE, HALF_IMAGE, 0.002),
new Vector3(HALF_IMAGE, HALF_IMAGE, 0.002),
new Vector3(-HALF_IMAGE, HALF_IMAGE, 0.002),
new Vector3(-HALF_IMAGE, HALF_IMAGE, 0.002),
new Vector3(-HALF_IMAGE, -HALF_IMAGE, 0.002)
])
const frameMaterial = new LineBasicMaterial({ color: "#c8ccd4" })
for (const x of [-OFFSET_X, OFFSET_X]) {
const frame = new LineSegments(frameGeometry, frameMaterial)
frame.position.x = x
scene.add(frame)
} ダウンサンプリング
画像の縮小など、すでにデジタル化された画像の解像度を下げ、画素を間引いて粗くする操作をダウンサンプリングといいます。
ダウンサンプリングでは標本点を減らすため、結果として標本化間隔が広がります。これは標本化をやり直して、より粗い間隔で読み取り直すことと同じです。
そのため、もとの画像に細かい模様が含まれていると、間引きによって標本化定理の条件を満たせなくなり、ここでもエイリアシングが発生してしまいます。
この対策としては、間引く前にあらかじめ画像をぼかしておく方法がとられます。
ローパスフィルタ (プレフィルタリング)と呼ばれるぼかし処理で、間引いたあとの粗い標本化では拾いきれない高い空間周波数の成分をあらかじめ取り除いておくのです。細かい模様を先になくしておけば、その後で間引いてもエイリアシングは生じません。
このように、標本化の細かさと、画像に含まれる空間周波数の関係を意識することが、画像を正しく扱う上で欠かせません。
標本化に続くのは、標本点で読み取った標本値を、あらかじめ決められた段階の値に丸める量子化 の工程です。