VR 動画かどうかを画像から判定したくて、左右に並んだ 1 枚の画像を使ってスコアを出す処理を書きました。
最初は、画像を左右に分けて似ていれば VR だろう、くらいの気持ちで始めました。
でも実際にはそこまで単純ではありませんでした。
左右の画像は同じ景色を見ていることが多いですが、視点が少し違います。完全一致はしないし、少し横にずれます。場合によっては縦方向にもずれます。
そのため、単純な一致判定だけだと普通に外します。画像は思ったより素直ではありません。
そこで今回は、左右が似ているかだけではなく、ステレオ画像として自然かどうかをいくつかの観点から見て、最後に 1 つのスコアにまとめる方法にしました。
方針
前提はシンプルです。
左右に並んだ 1 枚の画像がすでにあるものとして、その画像を左右に分割し、左右の対応関係と構造の似方を見ます。
入口はこのくらいです。
fn compute_vr_score_from_image(thumbnail: &RgbImage) -> Result<f64, String> {
let grayscale = grayscale(thumbnail);
let (width, height) = grayscale.dimensions();
let half_width = width / 2;
if half_width == 0 || height == 0 {
return Err("Thumbnail image is too small to split into left/right halves".to_string());
}
let left = crop_imm(&grayscale, 0, 0, half_width, height).to_image();
let right = crop_imm(&grayscale, width - half_width, 0, half_width, height).to_image();
compute_feature_alignment_score(&left, &right)
}
やっていること自体は単純で、まずグレースケール化して左右半分に切り出しています。
本題はこのあとです。
まずは特徴点で左右の対応を見る
最初に使ったのは ORB の特徴点です。
左右の画像に対応する点がどれくらいあるかを見れば、ステレオ画像らしさの手がかりになります。
fn detect_orb_features(image: &GrayImage) -> Result<(Vec<core::KeyPoint>, Mat), String> {
let rows = i32::try_from(image.height())
.map_err(|_| "Image height exceeds OpenCV limits".to_string())?;
let cols = i32::try_from(image.width())
.map_err(|_| "Image width exceeds OpenCV limits".to_string())?;
let image_mat = Mat::new_rows_cols_with_data(rows, cols, image.as_raw())
.map_err(|error| format!("Failed to build OpenCV image matrix: {error}"))?;
let mut orb = features2d::ORB::create_def()
.map_err(|error| format!("Failed to create ORB detector: {error}"))?;
let mut keypoints = core::Vector::<core::KeyPoint>::new();
let mut descriptors = Mat::default();
orb.detect_and_compute_def(
&image_mat,
&Mat::default(),
&mut keypoints,
&mut descriptors,
)
.map_err(|error| format!("Failed to detect ORB features: {error}"))?;
Ok((keypoints.to_vec(), descriptors))
}
ただ、特徴点が取れたからそれで終わりではありません。
マッチした点をそのまま信じると、背景の模様や偶然似た場所にも引っ張られます。
そのため、距離でマッチを絞ったあとに、fundamental matrix を RANSAC で推定して inlier を残すようにしました。
ここを入れると、たまたま似ていた点ではなく、左右視点としてある程度筋の通った対応だけを使えます。
この段階で見ているのは、たとえば次のような値です。
inlier の割合
descriptor の質
対応点がどれだけ広く分布しているか
縦方向のずれがどれくらい小さいか
x 座標の順序がどれくらい保たれているか
つまり、対応点があるかではなく、その対応がそれっぽいかを見ています。
特徴点だけだと弱い場面がある
ここで一度うまくいった気になったのですが、まだ足りませんでした。
特徴点が取りづらい画像があります。
空が広い場面、壁が多い場面、全体的にのっぺりした場面です。
画像側にあまり情報がないと、こちらも急に無口になります。
そこで、特徴点とは別に画像全体の構造も見るようにしました。
左右の画像で、行ごとのエッジ量がどれくらい似ているか、グリッドごとのエッジ分布がどれくらい似ているかを使っています。
行方向の比較はこうです。
fn row_profile_similarity(left: &GrayImage, right: &GrayImage) -> f64 {
let left_profile = row_edge_profile(left);
let right_profile = row_edge_profile(right);
let max_shift = usize::try_from((left.height().min(right.height()) / 10).max(4)).unwrap_or(4);
shifted_cosine_similarity(&left_profile, &right_profile, max_shift.min(12))
}
ここでは単純なコサイン類似度ではなく、少し上下にずれていても拾えるようにしています。
実際の画像はそこまで行儀よく揃ってくれないので、このくらいのゆるさは必要でした。
最初は左右比較と聞くと横方向だけを意識しがちですが、実際には縦方向のずれもじわっと効きます。
画像処理は、だいたい想像より少しだけ性格が悪いです。
継ぎ目の不連続さも使う
少し面白かったのが、左右の継ぎ目を見る方法です。
普通の 1 枚絵を真ん中で分けただけなら、中央付近はある程度つながって見えることが多いです。
一方でステレオ画像は、左目用と右目用の画像が並んでいるので、真ん中で急に別視点へ切り替わります。
この違いを数値にしたのが seam_discontinuity です。
fn seam_discontinuity(left: &GrayImage, right: &GrayImage) -> f64 {
let width = left.width().min(right.width());
let height = left.height().min(right.height());
if width <= 2 || height == 0 {
return 0.0;
}
let band = (width / 16).clamp(2, 8);
let mut cross_total = 0.0f64;
let mut cross_count = 0usize;
let mut intra_total = 0.0f64;
let mut intra_count = 0usize;
for y in 0..height {
for offset in 0..band {
let left_edge = f64::from(left.get_pixel(width - 1 - offset, y)[0]);
let right_edge = f64::from(right.get_pixel(offset, y)[0]);
cross_total += (left_edge - right_edge).abs();
cross_count += 1;
if width > offset + 1 {
let left_inner = f64::from(left.get_pixel(width - 2 - offset, y)[0]);
intra_total += (left_edge - left_inner).abs();
intra_count += 1;
let right_inner = f64::from(right.get_pixel(offset + 1, y)[0]);
intra_total += (right_edge - right_inner).abs();
intra_count += 1;
}
}
}
if cross_count == 0 || intra_count == 0 {
return 0.0;
}
let cross_average = cross_total / cross_count as f64;
let intra_average = intra_total / intra_count as f64;
let ratio = cross_average / (intra_average + 1.0);
((ratio - 1.0) / 2.5).clamp(0.0, 1.0)
}
これだけで判定するのは危ないですが、他の特徴と組み合わせるとかなり効きました。
中央が不自然につながっていないか、という見方は思ったより役に立ちます。
最後に全部まとめてスコアにする
最終的には、特徴点ベースのスコアと構造ベースのスコアを重み付きでまとめています。
impl AlignmentMetrics {
fn feature_alignment_signal(&self) -> f64 {
0.28 * self.inlier_ratio
+ 0.16 * self.descriptor_quality
+ 0.12 * self.coverage
+ 0.14 * self.grid_similarity
+ 0.08 * self.x_order_consistency
+ 0.04 * self.seam_spread
+ 0.18 * self.seam_discontinuity
}
fn feature_score(&self) -> f64 {
(self.feature_alignment_signal() * self.support).clamp(0.0, 1.0)
}
fn structural_signal(&self) -> f64 {
row_similarity_confidence(self.row_similarity)
.max(self.vertical_alignment)
.max(seam_feature_rescue_confidence(
self.feature_alignment_signal(),
self.seam_discontinuity,
))
}
fn structural_score(&self) -> f64 {
self.structural_signal() * self.support.sqrt()
}
fn score(&self) -> f64 {
let feature_score = self.feature_score();
let structural_score = self.structural_score();
(STRUCTURAL_SCORE_WEIGHT * structural_score
+ (1.0 - STRUCTURAL_SCORE_WEIGHT) * feature_score)
.clamp(0.0, 1.0)
}
}
ここでやりたかったのは、1 個の指標で決めないことです。
特徴点が弱い画像でも、全体の構造がかなり似ていれば拾えることがあります。
逆に、一部の特徴点マッチが良くても、全体として不自然ならスコアは伸びません。
support を別で持っているのもそのためで、十分な証拠が取れていないときにスコアを出しすぎないようにしています。
自信がないときは、ちゃんと自信がない顔をするようにしました。
まとめ
この方法でやっていることを一言で言うと、左右が似ているかではなく、ステレオ画像として自然かを見る処理です。
左右を分割して、特徴点の対応を見る。
RANSAC で変な対応を落とす。
さらに行方向やグリッド単位の構造比較を足す。
最後に継ぎ目の不連続さも使ってスコアをまとめる。
最初は左右比較だけで済むと思っていたのですが、実際には少しずつ条件を足していく形になりました。
画像処理は、簡単そうに見える入口の先で急に仕事を増やしてきます。今回もだいたいその流れでした。
ただ、その分うまくまとまると、単純比較よりかなり安定して VR っぽさを見られるようになります。
メタデータに頼りきれない場面では、こういう判定を持っておくと便利でした。
実際に使っている物
vr上でiwaraの動画を見るために試行錯誤中
— ダーケン (@daken_dev) March 13, 2026
モザイクもVR上に直接入れてみた pic.twitter.com/5nX6LGf8iA