📸

Windows Hello 赤外線カメラの映像を取得する

に公開

動機

下記の記事を書いたときに Windows Hello の赤外線カメラがどのような映像を取得しているのか気になったので調べてみました。
https://zenn.dev/benki/articles/720801344912a4

成果物

↓赤外線は間欠的に照射されているようで明滅しています。 明滅していると見づらいので明るさが閾値より小さいときは描画しないようにしました。(2025-11-28 追記)

環境

Dynabook G83
Windows 25H2
rustc 1.91.1

参考

下記の記事を Rust に写経する簡単な作業です。

https://qiita.com/yokra9/items/d564d3c99393a0d04b9d
https://learn.microsoft.com/ja-jp/windows/apps/develop/camera/process-media-frames-with-mediaframereader

解説

メインウィンドウの生成

ウィンドウの生成はいつもの通りです。カメラのキャプチャは別スレッドで実行します。

fn run_capture(rx: Receiver<()>) -> Result<()> {
    // MediaFrameReader の初期化処理
    let reader = { /* 省略 */ };
 
    // フレームが到達したときに実行される処理を登録する
    let token = reader.FrameArrived(&frame_arrived)?;
    // キャプチャの開始
    reader.StartAsync()?.join()?;

    // メインウィンドウのスレッドが終了するまでここで待つ    
    rx.recv()?;

    // メインウィンドウが終了したら終了処理を実行
    reader.StopAsync()?.join()?;
    reader.RemoveFrameArrived(token)?;
    reader.Close()?;
    Ok(())
}

fn main() -> Result<()> {
    let wc = WNDCLASSW {
        lpfnWndProc: Some(wnd_proc),
        lpszClassName: CLASS_NAME,
        hCursor: unsafe { LoadCursorW(None, IDI_APPLICATION)? },
        ..Default::default()
    };

    unsafe { RegisterClassW(&wc) };

    let hwnd = unsafe {
        CreateWindowExW(
            WINDOW_EX_STYLE::default(),
            CLASS_NAME,
            w!("Infrared"),
            WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | WS_VISIBLE,
            CW_USEDEFAULT,
            CW_USEDEFAULT,
            656,
            399,
            None,
            None,
            None,
            None,
        )?
    };

    // メインウィンドウのウィンドウハンドルをグローバル変数に保存
    MAIN_WIN.set(hwnd);

    _ = unsafe { ShowWindow(hwnd, SW_SHOWNORMAL) };

    // 別スレッドでキャプチャを実行
    let (tx, rx) = mpsc::channel();
    let handle = thread::spawn(|| run_capture(rx));

    // メッセージループ
    let mut msg = MSG::default();
    loop {
        if unsafe { !GetMessageW(&mut msg, None, 0, 0).as_bool() } {
            break;
        }
        _ = unsafe { TranslateMessage(&msg) };
        unsafe { DispatchMessageW(&msg) };
    }

    // メインウィンドウの終了をキャプチャスレッドに通知
    tx.send(())?;
    // キャプチャスレッドの終了を待つ
    _ = handle.join();
    Ok(())
}

フレームが到達したときの処理

ビデオフレームが到達したらスレッドセーフなバッファArc<RwLock<Box<[u8]>>>にコピーして、メインウィンドウを再描画します。

let frame_arrived = TypedEventHandler::<MediaFrameReader, _>::new(|sender, _arg| {
    // sender から最新フレームへの参照を取得
    let reader = sender.ok()?;
    let last_frame = reader.TryAcquireLatestFrame()?;

    // 最新フレームのビットマップ
    let software_bitmap = last_frame.VideoMediaFrame()?.SoftwareBitmap()?;

    // BGRA8 のアルファ乗算済みに変換する
    let software_bitmap = if software_bitmap.BitmapPixelFormat()? != BitmapPixelFormat::Bgra8
        || software_bitmap.BitmapAlphaMode()? != BitmapAlphaMode::Premultiplied
    {
        SoftwareBitmap::ConvertWithAlpha(
            &software_bitmap,
            BitmapPixelFormat::Bgra8,
            BitmapAlphaMode::Premultiplied,
        )?
    } else {
        software_bitmap
    };

    // ここから SoftwareBitmap をバッファにコピーする処理
    {
        let bitmap_buffer = software_bitmap.LockBuffer(BitmapBufferAccessMode::Read)?;
        // windows::core::Interface トレイトの cast() 関数で QueryInterface する
        let memory_buffer: IMemoryBufferByteAccess = bitmap_buffer.CreateReference()?.cast()?;

        // BGRA データへのポインタを Rust のスライスにする
        let mut p: *mut u8 = ptr::null_mut();
        let mut capacity = 0;
        unsafe { memory_buffer.GetBuffer(&mut p, &mut capacity)? };
        let slice = unsafe { slice::from_raw_parts(p, capacity as _) };

        // キャプチャした画像中央の 120 x 120 px 範囲の明るさ平均値を求める
        let avg = (slice
            .chunks(WIDTH as usize * 4)
            .skip((HEIGHT - CROP as i32) as usize / 2)
            .take(CROP)
            .map(|r| {
                r.chunks(4)
                    .skip((WIDTH - CROP as i32) as usize / 2)
                    .take(CROP)
                    .map(|p| p[0] as u16) // BGR は同じ値なので B の値だけ使う
                    .sum::<u16>()
                    / CROP as u16
            })
            .sum::<u16>()
            / CROP as u16) as u8;

        if BUF_SIZE == capacity as _
            && avg > THRESHOLD // 明るさが閾値以上のときだけ処理する
            && let Ok(mut buf) = BUF.write()
        {
            // スレッドセーフなバッファへ BGRA データを全部コピー
            buf.copy_from_slice(slice);
            // バッファへのコピーが終了したらメインウィンドウを再描画する
            _ = unsafe { InvalidateRect(Some(MAIN_WIN.get()), None, true) };
        }
    }
    // 終了処理
    software_bitmap.Close()?;
    last_frame.Close()?;
    Ok(())
});

メインウィンドウの再描画

ウィンドウプロシージャにWM_PAINTメッセージが来たら、paint関数を呼び出してArc<RwLock<Box<[u8]>>>のデータをメインウィンドウに描画します。

fn paint(hwnd: HWND) -> Result<()> {
    // Arc<RwLock<Box<[u8]>>> データの読み込み
    if let Ok(buf) = BUF.read() {
        let mut ps = PAINTSTRUCT::default();
        let hdc = unsafe { BeginPaint(hwnd, &mut ps) };

        // WIDTH, HEIGHT, BUF_SIZE は私の環境に合うように決め打ちしていますが
        // 実際は各々の環境(カメラ)に合わせて変更が必要
        let bi = BITMAPINFO {
            bmiHeader: BITMAPINFOHEADER {
                biSize: size_of::<BITMAPINFOHEADER>() as _,
                biWidth: WIDTH,
                biHeight: -HEIGHT, // BOTTOM -> TOP なので負値
                biPlanes: 1,
                biCompression: BI_RGB.0, // BGRA データのときは BI_RGB で良いみたい
                biBitCount: 32,
                biSizeImage: BUF_SIZE as _,
                ..Default::default()
            },
            ..Default::default()
        };

        let mut h_bmp = unsafe { CreateCompatibleBitmap(hdc, WIDTH, HEIGHT) };

        unsafe {
            SetDIBits(
                Some(hdc),
                h_bmp,
                0,
                HEIGHT as _,
                buf.as_ptr() as _,
                &bi,
                DIB_RGB_COLORS,
            )
        };
        let h_mdc = unsafe { CreateCompatibleDC(Some(hdc)) };
        unsafe { SelectObject(h_mdc, h_bmp.into()) };

        unsafe { BitBlt(hdc, 0, 0, WIDTH, HEIGHT, Some(h_mdc), 0, 0, SRCCOPY)? };

        _ = unsafe { DeleteDC(h_mdc) };
        unsafe { h_bmp.free() }; // HBITMAP は windows::core::Free トレイトを実装しているのでこう書ける
        _ = unsafe { EndPaint(hwnd, &ps) };
    }
    Ok(())
}

まとめ

infraredって「インフラ・レド」って読むんですね。ずっと「イン・フレアド」と読んでました😅
大事なことは書いたのでコードを載せておきます。

コピペ用サンプルコード
Cargo.toml
[package]
name = "infrared"
version = "0.1.0"
edition = "2024"

[dependencies]
anyhow = "1.0"

[dependencies.windows]
version = "0.62"
features = [
  "Foundation_Collections",
  "Graphics_Imaging",
  "Media_Capture_Frames",  
  "Media_Capture",
  "Win32_UI_WindowsAndMessaging",
  "Win32_Graphics_Gdi",
  "Win32_System_WinRT",
]
src/main.rs
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

use anyhow::{Context, Result};
use std::cell::Cell;
use std::ptr;
use std::slice;
use std::sync::{
    Arc, LazyLock, RwLock,
    mpsc::{self, Receiver},
};
use std::thread;
use windows::{
    Foundation::TypedEventHandler,
    Graphics::Imaging::{
        BitmapAlphaMode, BitmapBufferAccessMode, BitmapPixelFormat, SoftwareBitmap,
    },
    Media::Capture::{
        Frames::{MediaFrameReader, MediaFrameSourceGroup, MediaFrameSourceKind},
        MediaCapture, MediaCaptureInitializationSettings, MediaCaptureMemoryPreference,
        MediaCaptureSharingMode, StreamingCaptureMode,
    },
    Win32::{
        Foundation::{HWND, LPARAM, LRESULT, WPARAM},
        Graphics::Gdi::{
            BI_RGB, BITMAPINFO, BITMAPINFOHEADER, BeginPaint, BitBlt, CreateCompatibleBitmap,
            CreateCompatibleDC, DIB_RGB_COLORS, DeleteDC, EndPaint, InvalidateRect, PAINTSTRUCT,
            SRCCOPY, SelectObject, SetDIBits,
        },
        System::WinRT::IMemoryBufferByteAccess,
        UI::WindowsAndMessaging::{
            CW_USEDEFAULT, CreateWindowExW, DefWindowProcW, DispatchMessageW, GetMessageW,
            IDI_APPLICATION, LoadCursorW, MSG, PostQuitMessage, RegisterClassW, SW_SHOWNORMAL,
            ShowWindow, TranslateMessage, WINDOW_EX_STYLE, WM_DESTROY, WM_PAINT, WNDCLASSW,
            WS_CAPTION, WS_MINIMIZEBOX, WS_OVERLAPPED, WS_SYSMENU, WS_VISIBLE,
        },
    },
    core::{Free, Interface, PCWSTR, w},
};

const CLASS_NAME: PCWSTR = w!("infrared_window_class_name");
const WIDTH: i32 = 640;
const HEIGHT: i32 = 360;
const CROP: usize = 120;
const THRESHOLD: u8 = 16;
const BUF_SIZE: usize = (WIDTH * HEIGHT) as usize * 4;

static BUF: LazyLock<Arc<RwLock<Box<[u8]>>>> =
    LazyLock::new(|| Arc::new(RwLock::new(vec![0u8; BUF_SIZE].into_boxed_slice())));

static MAIN_WIN: Hwnd = Hwnd::new();

#[derive(Debug)]
struct Hwnd(Cell<HWND>);

impl Hwnd {
    const fn new() -> Self {
        Self(Cell::new(HWND(ptr::null_mut())))
    }

    fn get(&self) -> HWND {
        self.0.get()
    }

    fn set(&self, hwnd: HWND) {
        self.0.set(hwnd);
    }
}

unsafe impl Send for Hwnd {}
unsafe impl Sync for Hwnd {}

unsafe extern "system" fn wnd_proc(
    hwnd: HWND,
    msg: u32,
    wparam: WPARAM,
    lparam: LPARAM,
) -> LRESULT {
    match msg {
        WM_PAINT => {
            paint(hwnd).ok();
        }
        WM_DESTROY => {
            unsafe { PostQuitMessage(0) };
        }
        _ => return unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) },
    }
    LRESULT::default()
}

fn paint(hwnd: HWND) -> Result<()> {
    if let Ok(buf) = BUF.read() {
        let mut ps = PAINTSTRUCT::default();
        let hdc = unsafe { BeginPaint(hwnd, &mut ps) };

        let bi = BITMAPINFO {
            bmiHeader: BITMAPINFOHEADER {
                biSize: size_of::<BITMAPINFOHEADER>() as _,
                biWidth: WIDTH,
                biHeight: -HEIGHT,
                biPlanes: 1,
                biCompression: BI_RGB.0,
                biBitCount: 32,
                biSizeImage: BUF_SIZE as _,
                ..Default::default()
            },
            ..Default::default()
        };

        let mut h_bmp = unsafe { CreateCompatibleBitmap(hdc, WIDTH, HEIGHT) };

        unsafe {
            SetDIBits(
                Some(hdc),
                h_bmp,
                0,
                HEIGHT as _,
                buf.as_ptr() as _,
                &bi,
                DIB_RGB_COLORS,
            )
        };
        let h_mdc = unsafe { CreateCompatibleDC(Some(hdc)) };
        unsafe { SelectObject(h_mdc, h_bmp.into()) };

        unsafe { BitBlt(hdc, 0, 0, WIDTH, HEIGHT, Some(h_mdc), 0, 0, SRCCOPY)? };

        _ = unsafe { DeleteDC(h_mdc) };
        unsafe { h_bmp.free() };
        _ = unsafe { EndPaint(hwnd, &ps) };
    }
    Ok(())
}

fn run_capture(rx: Receiver<()>) -> Result<()> {
    let group = MediaFrameSourceGroup::FindAllAsync()?
        .join()?
        .into_iter()
        .filter_map(|g| {
            if g.SourceInfos()
                .ok()?
                .into_iter()
                .filter_map(|i| i.SourceKind().ok())
                .all(|k| k.eq(&MediaFrameSourceKind::Infrared))
            {
                return Some(g);
            }
            None
        })
        .next()
        .context("no infrared device")?;

    let settings = MediaCaptureInitializationSettings::new()?;
    settings.SetSourceGroup(&group)?;
    settings.SetSharingMode(MediaCaptureSharingMode::ExclusiveControl)?;
    settings.SetMemoryPreference(MediaCaptureMemoryPreference::Cpu)?;
    settings.SetStreamingCaptureMode(StreamingCaptureMode::Video)?;

    let capture = MediaCapture::new()?;
    capture.InitializeWithSettingsAsync(&settings)?.join()?;

    let reader = capture
        .CreateFrameReaderAsync(
            &capture.FrameSources()?.Lookup(
                &group
                    .SourceInfos()?
                    .First()?
                    .next()
                    .context("no info")?
                    .Id()?,
            )?,
        )?
        .join()?;

    let frame_arrived = TypedEventHandler::<MediaFrameReader, _>::new(|sender, _arg| {
        let reader = sender.ok()?;
        let last_frame = reader.TryAcquireLatestFrame()?;
        let software_bitmap = last_frame.VideoMediaFrame()?.SoftwareBitmap()?;

        let software_bitmap = if software_bitmap.BitmapPixelFormat()? != BitmapPixelFormat::Bgra8
            || software_bitmap.BitmapAlphaMode()? != BitmapAlphaMode::Premultiplied
        {
            SoftwareBitmap::ConvertWithAlpha(
                &software_bitmap,
                BitmapPixelFormat::Bgra8,
                BitmapAlphaMode::Premultiplied,
            )?
        } else {
            software_bitmap
        };

        {
            let bitmap_buffer = software_bitmap.LockBuffer(BitmapBufferAccessMode::Read)?;
            let memory_buffer: IMemoryBufferByteAccess = bitmap_buffer.CreateReference()?.cast()?;

            let mut p: *mut u8 = ptr::null_mut();
            let mut capacity = 0;
            unsafe { memory_buffer.GetBuffer(&mut p, &mut capacity)? };
            let slice = unsafe { slice::from_raw_parts(p, capacity as _) };
            let avg = (slice
                .chunks(WIDTH as usize * 4)
                .skip((HEIGHT - CROP as i32) as usize / 2)
                .take(CROP)
                .map(|r| {
                    r.chunks(4)
                        .skip((WIDTH - CROP as i32) as usize / 2)
                        .take(CROP)
                        .map(|p| p[0] as u16)
                        .sum::<u16>()
                        / CROP as u16
                })
                .sum::<u16>()
                / CROP as u16) as u8;

            if BUF_SIZE == capacity as _
                && avg > THRESHOLD
                && let Ok(mut buf) = BUF.write()
            {
                buf.copy_from_slice(slice);
                _ = unsafe { InvalidateRect(Some(MAIN_WIN.get()), None, true) };
            }
        }
        software_bitmap.Close()?;
        last_frame.Close()?;
        Ok(())
    });

    let token = reader.FrameArrived(&frame_arrived)?;
    reader.StartAsync()?.join()?;

    rx.recv()?;

    reader.StopAsync()?.join()?;
    reader.RemoveFrameArrived(token)?;
    reader.Close()?;
    Ok(())
}

fn main() -> Result<()> {
    let wc = WNDCLASSW {
        lpfnWndProc: Some(wnd_proc),
        lpszClassName: CLASS_NAME,
        hCursor: unsafe { LoadCursorW(None, IDI_APPLICATION)? },
        ..Default::default()
    };

    unsafe { RegisterClassW(&wc) };

    let hwnd = unsafe {
        CreateWindowExW(
            WINDOW_EX_STYLE::default(),
            CLASS_NAME,
            w!("Infrared"),
            WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX | WS_VISIBLE,
            CW_USEDEFAULT,
            CW_USEDEFAULT,
            656,
            399,
            None,
            None,
            None,
            None,
        )?
    };

    MAIN_WIN.set(hwnd);
    _ = unsafe { ShowWindow(hwnd, SW_SHOWNORMAL) };

    let (tx, rx) = mpsc::channel();
    let handle = thread::spawn(|| run_capture(rx));

    let mut msg = MSG::default();
    loop {
        if unsafe { !GetMessageW(&mut msg, None, 0, 0).as_bool() } {
            break;
        }
        _ = unsafe { TranslateMessage(&msg) };
        unsafe { DispatchMessageW(&msg) };
    }

    tx.send(())?;
    _ = handle.join();
    Ok(())
}

以上。

Discussion