איך ממירים YUV ל-RGB ב-Media Foundation

· עודכן בתאריך: · · Media Foundation, C++, פיתוח Windows, עיבוד וידאו, YUV

היסטוריית עדכונים (גרסה ראשונה, פורסמה בתאריך 15 Mar 2026)
פרסום ראשון
לצטט את המאמר הזה(DOI: 10.5281/zenodo.22173475)

מאמר זה מאוחסן בארכיון Zenodo. להלן גם ה-DOI שתמיד מפנה לגרסה האחרונה וגם ה-DOI המקובע לגרסה שאתם קוראים.

Go Komura (2026). איך ממירים YUV ל-RGB ב-Media Foundation. KomuraSoft LLC. https://doi.org/10.5281/zenodo.22173475 https://comcomponent.com/he/blog/media-foundation-yuv-to-rgb-conversion-patterns/

DOI (הגרסה האחרונה)
10.5281/zenodo.22173475
DOI (הגרסה הזו)
10.5281/zenodo.22173476

רוצים לשלוף frame מווידאו ולשמור כ-PNG, להעביר ל-WIC או ל-GDI, או להציג ב-UI. במצבים כאלה צד האפליקציה רוצה רצף pixels ב-RGB.

אבל ה-frames שיוצאים מה-decoder של Media Foundation הם בדרך כלל בפורמט YUV כמו NV12 או YUY2. אם מתייחסים לרצף הבייטים הגולמי כאילו הוא תמונה כמו שהוא, מקבלים צבעים שבורים, פסים, או גוון ירוק מוזר.

במאמר מבוא ל-Media Foundation: להבין את ה-API דרך COM כיסינו את התמונה הכללית, ובמאמר איך לחלץ תמונת סטילס מ-MP4 בזמן נתון עם Media Foundation כיסינו חילוץ stills. הפעם נטפל בהמרת YUV -> RGB עצמה, שנמצאת בדרך שביניהם.

במאמר הזה נפריד בין שני ה-patterns הבאים:

  • Pattern A: לתת ל-IMFSourceReader להוביל אוטומטית עד RGB32
  • Pattern B: לקבל NV12 / YUY2 ולהמיר ל-RGB בעצמנו

המטרה אינה לשנן שמות API. המטרה היא לבנות בראש תמונה ברורה של איפה ב-Media Foundation מופיע YUV, ואיפה הוא הופך ל-RGB.

הקוד במאמר הזה זמין כסט דוגמאות מלא (C++ ל-Pattern A ול-Pattern B, CMake, ובדיקות המרת pixel) ב-GitHub.

media-foundation-yuv-to-rgb-conversion-patterns - komurasoft-blog-samples (GitHub)

סביבת ההרצה שמתחילים ממנה

אם רוצים לקחת את הקוד מהמאמר לפרויקט שלכם, זה כל מה שצריך:

פריט הנחה
OS Windows 10 ואילך
compiler MSVC של Visual Studio 2019 / 2022 (C++17)
SDK Windows SDK (headers וספריות ייבוא של Media Foundation). כלול ב-workload “Desktop development with C++” של Visual Studio
build הדוגמה בנויה ב-CMake 3.20 ואילך. אפשר גם ליצור פרויקט Visual Studio ידנית

יש לקשר ארבע ספריות. בקוד של המאמר זה כתוב עם #pragma comment(lib, ...), אבל אפשר גם לציין את זה בהגדרות הפרויקט.

  • mfplat.lib
  • mfreadwrite.lib
  • mfuuid.lib
  • ole32.lib

הקוד במאמר מניח ש-CoInitializeEx ו-MFStartup כבר רצו. נוסחת ההמרה של pixel בודד (5.6.) לבדה אינה תלויה ב-OS, ולכן בדוגמה ב-GitHub היא יושבת ב-header נפרד, ואפשר לבדוק אותה גם עם g++ ב-Linux.

1. קודם המסקנה

נסכם קודם רק את המסקנה:

  • לחילוץ כמה stills או יצירת thumbnail, הכי קל להפעיל את MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING ולבקש MFVideoFormat_RGB32
  • אבל ההמרה האוטומטית הזו היא software processing, ולא מותאמת ל-playback בזמן אמת
  • אם כותבים המרה עצמית, הדרך הקצרה ביותר היא קודם להבין היטב את NV12 ואת YUY2
  • YUV -> RGB זה לא “שלושה מקדמים וזהו” — בפועל מעורבים chroma subsampling, range, matrix ו-stride
  • התיעוד של Media Foundation משתמש באופן רחב במילה YUV, אבל בוידאו דיגיטלי היא מתייחסת בפועל ל-Y’CbCr, וכך קל יותר לקרוא
  • בפועל מה ששובר צבעים הוא לא לבדוק את MF_MT_YUV_MATRIX ואת MF_MT_VIDEO_NOMINAL_RANGE, וגם להניח ש-stride שווה ל-width * bytesPerPixel

בקצרה, אם רוצים נוחות — נותנים ל-Source Reader להוציא RGB32. אם רוצים עיבוד בנפח גדול ושליטה בצבע — מקבלים YUV וממירים בעצמכם. זו הבחירה.

שתי האפשרויות של המאמר הזהתרשים שמראה שאם רוצים נוחות, נותנים ל-Source Reader להוציא RGB32, ואם רוצים עיבוד בנפח גדול ושליטה בצבע, מקבלים YUV וממירים בעצמכם.רוצים נוחותעיבוד בנפח גדול / שליטה בצבערוצים frame ב-RGBPattern A: Source Reader מוציא RGB32Pattern B: מקבלים YUV וממירים בעצמנו

איור 1: אם בוחרים נוחות — Pattern A. אם לוקחים אחריות על נפח העיבוד ועל הצבע — Pattern B.

ב-diagram, solid line מציינת relation שתמיד מתקיים ו-dashed line מציינת relation מותנה (התנאים מופיעים בהסבר של כל relation ב-detail page). הרשימה המלאה של ה-relations (סה”כ 23, כולל evidence ו-certainty) וההגדרות של ה-concepts המרכזיים נמצאות ב-detail page של ה-knowledge map (ביפנית). Data: JSON-LD / Turtle

2. קודם רואים בציור

קודם כדאי לראות בציור מה קורה בתוך Media Foundation.

המסלול מ-YUV ל-RGBתרשים שמראה שקובץ מקור עובר דרך decoder ל-frame ב-YUV, ומשם מתפצל למסלול A דרך video processing של Source Reader ל-RGB32, או למסלול B דרך קוד המרה עצמי ל-BGRA/RGB.Pattern APattern BMP4 / H.264 / HEVCdecoderframe ב-YUV מסוג NV12 / YUY2 / YV12 וכדומהvideo processing של Source ReaderRGB32קוד המרה עצמיBGRA / RGB

איור 2: מה שה-decoder מוציא הוא frame ב-YUV, ומשם הדרך ל-RGB מתפצלת ל-Pattern A ול-Pattern B.

אם תוכן קובץ הווידאו הוא פורמט דחוס כמו H.264 או HEVC, ה-decoder מחזיר אותו קודם ל-frame לא-דחוס. ה-frame הלא-דחוס הזה אינו בהכרח RGB. להפך: בקטגוריית הווידאו של Windows, YUV הוא הנורמה.

לכן, כשהאפליקציה רוצה RGB, בוחרים באחת משתי האפשרויות:

  1. לתת לצד Media Foundation להוביל עד RGB32
  2. לקבל YUV ולהפוך אותו ל-RGB בקוד שלנו

המאמר הזה עוסק בדיוק בנקודת הפיצול הזו.

3. קודם מבהירים את היחס בין YUV ל-RGB

3.1. אומרים YUV, אבל בפועל זה Y’CbCr

שמות ה-API והתיעוד של Windows משתמשים באופן רחב במילה YUV. אבל בהקשר של וידאו דיגיטלי, אפשר כמעט תמיד לקרוא U כ-Cb ו-V כ-Cr.

בגדול:

  • Y הוא רכיב שקרוב יותר לבהירות
  • U / V הם רכיבי color difference
  • ב-RGB כל pixel מחזיק ישירות Red / Green / Blue

זה היחס.

העין רגישה יותר לפירוט של בהירות מאשר לפירוט של צבע. לכן בוידאו, תכנון של Y מדויק, ו-U/V גסים מעט עובד טוב. זו הסיבה שפורמטי YUV נפוצים כל כך.

למה משתמשים בפורמטי YUVתרשים שמראה שהעין רגישה יותר לפירוט בהירות מאשר לפירוט צבע, ולכן תכנון שבו Y מדויק ו-U/V גסים עובד טוב, וזו הסיבה שפורמטי YUV נפוצים.העין רגישה לבהירותY מדויק, U/V גסיםלמה משתמשים בפורמטי YUV

איור 3: בהתאם לכך שהעין רגישה לבהירות, התכנון של YUV מבוסס על Y מדויק ו-U/V גסים.

3.2. 4:4:4 / 4:2:2 / 4:2:0 זה “עד כמה מדללים את הצבע”

זו הנקודה המרכזית בקריאת YUV.

סימון משמעות דוגמה מייצגת
4:4:4 לכל pixel יש Y/U/V משלו AYUV, I444
4:2:2 שני pixels לרוחב חולקים U/V YUY2, UYVY, I422
4:2:0 2x2 pixels חולקים U/V NV12, YV12, I420

כדאי קודם לראות את הצורה של שני הפורמטים שנפוצים ביותר בפועל.

לפני זה נקבע מונח אחד. stride (בשם אחר, pitch) הוא מספר הבייטים לשורה אחת. זה לא רוחב התמונה עצמו, אלא “כמה בייטים מתקדמים עד תחילת השורה הבאה”, כולל padding בסוף השורה. במאמר הזה stride ו-pitch הם אותו דבר. גם ב-Microsoft Learn מופיעים שני הכינויים, ולכן אין צורך להבחין ביניהם.

בציור הבא, W הוא width, H הוא height, ו-S הוא stride. הנקודה: S >= W, אבל לא בהכרח S == W.

NV12 (4:2:0, planar) / width = W, height = H, stride = S

  <----------- S bytes ----------->
  <--- W --->
 +-----------+---------------------+  --+
 | Y Y Y Y Y | (padding)           |    |
 | Y Y Y Y Y | (padding)           |    | Y plane
 | Y Y Y Y Y | (padding)           |    | S * H bytes
 | Y Y Y Y Y | (padding)           |    |
 +-----------+---------------------+  --+  <- גבול ה-plane = S * H מתחילת ה-buffer
 | U V U V U | (padding)           |    |
 | U V U V U | (padding)           |    | UV plane
 +-----------+---------------------+  --+  הגובה הוא H / 2 שורות

  שורה y ב-Y     : yPlane  + S * y
  שורה y ב-UV    : uvPlane + S * (y / 2)
  תחילת UV plane : scanline0 + S * H

ב-NV12, ארבעת ה-pixels ב-block 2x2 חולקים זוג U/V אחד. ל-Y יש ערך לכל pixel. ה-UV plane משתמש באותו stride כמו ה-Y plane, אבל מספר השורות הוא מחצית. לכן גבול ה-plane הוא S * H, ולא W * H (נחזור לזה בפרק 7.5.).

YUY2 (4:2:2, packed) / width = W, height = H, stride = S

  <-------------- S bytes ---------------->
  <------- W * 2 bytes ------->
 +-----------------------------+----------+
 | Y0 U0 Y1 V0  Y2 U2 Y3 V2 …  | (padding)|   שורה 0
 | Y0 U0 Y1 V0  Y2 U2 Y3 V2 …  | (padding)|   שורה 1
 +-----------------------------+----------+

  תחילת שורה y   : scanline0 + S * y
  2 pixels = 4 bytes (Y, U, Y, V)
  plane אחד בלבד (packed, אין גבול)

ב-YUY2, שני pixels לרוחב חולקים זוג U/V אחד. Y0 ו-Y1 נפרדים, אבל U0 ו-V0 משותפים. כי זה packed, אין צורך לחשב גבול plane, אבל עדיין משתמשים ב-stride כדי לעבור בין שורות.

בשלב הזה כבר רואים שהמרת YUV -> RGB אינה החלפה פשוטה של pixel-אחר-pixel. קודם צריך לחשוב איזה U/V משותף שייך לאיזה pixel.

השוואת יחידת השיתוף בין NV12 ל-YUY2תרשים שמראה ש-NV12 מחלק זוג U/V אחד בין 4 pixels ב-block 2x2, ו-YUY2 מחלק זוג U/V בין 2 pixels לרוחב, ולכן צריך לקבוע מראש איזה pixel מקבל איזה U/V.NV12 (4:2:0)4 pixels ב-2x2 חולקים זוג U/VYUY2 (4:2:2)2 pixels לרוחב חולקים זוג U/Vקובעים איזה pixel מקבל איזה U/V

איור 4: בשני הפורמטים ה-U/V משותף, ולכן ההמרה לא מסתכמת בהחלפה per-pixel.

3.3. YUV -> RGB זה “המרת מרחב צבע + המרת sampling”

אם מסתכלים ב-Extended Color Information של Media Foundation, המרת הצבע המדויקת כוללת הרבה שלבים. inverse quantization, chroma upsampling, YUV -> RGB, transfer function, המרת primaries, ועד quantization.

עם זאת, כקוד מעשי ל-SDR ב-8-bit, נוח יותר לפצל לשלוש שכבות:

  1. לשחזר את ה-chroma subsampling להרחיב את ה-U/V המדולל של 4:2:0 או 4:2:2 לצורה שכל pixel יכול לגשת אליה
  2. לשחזר את ה-range ה-Y בוידאו בדרך כלל 16..235, וה-U/V בדרך כלל 16..240, ולכן צריך להחזיר את הסקאלה
  3. להפעיל את ה-matrix המרה ל-RGB עם מקדמים כמו BT.601 או BT.709

כלומר, המרת YUV -> RGB בפועל היא תהליך שקובע:

  • איזה U/V הוא הצבע של אותו pixel
  • באילו מקדמים מחזירים את אותו Y/U/V ל-RGB
שלוש השכבות שקוד מעשי צריך לתפוסתרשים שמראה שקוד מעשי ל-SDR ב-8-bit מבוסס על שלוש שכבות: שחזור chroma subsampling, שחזור range, והפעלת matrix, וכך קל יותר להבין את ההמרה מ-YUV ל-RGB.frame ב-YUVשחזור chroma subsamplingשחזור range (למשל 16..235)הפעלת matrix (601 / 709)RGB

איור 5: ההמרה לא מסתכמת בשלושה מקדמים — היא מורכבת משלוש שכבות: sampling, range ו-matrix.

3.4. טיפול רשלני ב-BT.601 וב-BT.709 מסיט את הצבע בהדרגה

בתיעוד של Media Foundation, BT.601 מוסבר כמתאים ל-SDTV ומטה, ו-BT.709 כמועדף בוידאו שמעל SD.

עם זאת, לא כדאי לנחש בשקט “הרזולוציה גדולה אז זה בטח 709”. סטיית צבע לא גורמת ל-crash, ולכן קל לא לשים לב ולהכניס אותה ל-production.

ב-Media Foundation אפשר להחזיק מידע על מרחב הצבע בתוך attributes של ה-media type. לפחות כדאי לבדוק את שני אלה:

  • MF_MT_YUV_MATRIX
  • MF_MT_VIDEO_NOMINAL_RANGE

אם בודקים את שני אלה, ומעבירים במפורש רק את השילובים שהקוד שלנו תומך בהם, פחות סיכוי לתקלה שקטה בהמשך.

הזרימה שנמנעת מניחוש מרחב הצבעתרשים שמראה שניחוש בשקט אם זה 601 או 709 לפי הרזולוציה מוביל לכניסת סטיית צבע ל-production בלי לשים לב, ולכן בודקים את ה-attributes MF_MT_YUV_MATRIX ו-MF_MT_VIDEO_NOMINAL_RANGE ומעבירים רק שילוב נתמך.ניחוש בשקט לפי הרזולוציהסטיית צבע לא עושה crashנכנסת ל-production בלי לשים לבבדיקת attributes של matrix ו-rangeמעבירים רק שילוב נתמךמונע תקלה שקטה

איור 6: לא מנחשים את מרחב הצבע — בודקים את ה-attributes, ומעבירים במפורש רק שילובים נתמכים.

3.5. הנוסחה הראשונה לזכור היא BT.601 ב-limited range

הנוסחה הייצוגית של BT.601 ב-8-bit נראית כך:

C = Y - 16
D = U - 128
E = V - 128

R = clip(1.164383 * C + 1.596027 * E)
G = clip(1.164383 * C - 0.391762 * D - 0.812968 * E)
B = clip(1.164383 * C + 2.017232 * D)

ב-BT.709 המקדמים שונים. נציג גם אותם בהמשך בקוד.

חשוב כאן לא “לשנן מקדמים” אלא את המבנה: מ-Y מפחיתים black level 16, ומ-U/V מסתכלים סביב מרכז 128.

המבנה של נוסחת ההמרהתרשים שמראה שמפחיתים 16 מ-Y (black level), מפחיתים 128 מ-U ו-V (מרכז), מכפילים במקדמי ה-matrix, ומגבילים את התוצאה — זה המבנה של נוסחת ההמרה.מפחיתים 16 מ-Y (black level)מכפילים במקדמי ה-matrixמפחיתים 128 מ-U / V (מרכז)clip ל-0..255

איור 7: מה שזוכרים זה לא המקדמים, אלא את מבנה הנוסחה: black level 16 ומרכז 128.

4. Pattern A: לתת ל-Media Foundation להמיר אוטומטית

4.1. מתי זה מתאים

השיטה הזו מתאימה למשל למצבים הבאים:

  • רוצים לשלוף still אחד מ-MP4
  • רוצים ליצור כמה thumbnails
  • רוצים להפוך לתמונת RGB ולהעביר ל-WIC
  • לא playback בזמן אמת, אלא שימוש ב-batch או בכלי

ל-Source Reader יש יכולת להריץ video processing מוגבל של YUV -> RGB32 באמצעות MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING.

עם זאת, כפי שכתוב גם ב-Microsoft Learn, זה software processing ולא מותאם ל-playback. אם רוצים לעבד מאות frames בשנייה, לא מומלץ להישען על זה.

מתאים ולא מתאים להמרה האוטומטיתתרשים שמראה שההמרה האוטומטית של Source Reader היא software processing שלא מותאם ל-playback, ולכן מתאימה לשימושי batch כמו חילוץ still ו-thumbnail, אך לא מומלץ להישען עליה בעיבוד מאות frames בשנייה.מתאיםלא נשענים על זהההמרה האוטומטית של Source Readersoftware processingstill / thumbnail / batchעיבוד מאות frames בשנייה בזמן אמת

איור 8: ההמרה האוטומטית היא software processing, ולכן מגבילים אותה לשימושי כלי במספר קטן של frames.

4.2. מה מגדירים כדי לקבל RGB32

הזרימה טבעית למדי.

  1. ב-attributes שמעבירים ל-MFCreateSourceReaderFromURL, מגדירים MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING = TRUE
  2. בוחרים video stream
  3. ב-SetCurrentMediaType מבקשים MFMediaType_Video / MFVideoFormat_RGB32
  4. קוראים sample עם ReadSample

רק בזה, ה-video processing המוגבל שנכנס אחרי ה-decoder עושה עבורנו YUV -> RGB32.

ארבעת השלבים להפעלת ההמרה האוטומטיתתרשים שמראה שיוצרים Reader עם הפעלת video processing ב-attributes, בוחרים video stream, מבקשים RGB32, וקוראים עם ReadSample, ואז ההמרה מתבצעת אחרי ה-decoder.הפעלת video processing ב-attributesבחירת video streamבקשת RGB32קריאה עם ReadSampleההמרה ל-RGB32 מתבצעת אחרי ה-decoder

איור 9: רק אחרי ארבעת השלבים, ה-video processing שאחרי ה-decoder מוביל עד RGB32.

4.3. הקוד

הקוד הבא מניח ש-CoInitializeEx ו-MFStartup כבר רצו. בהרכב מינימלי, זה נראה בערך כך:

#include <windows.h>
#include <mfapi.h>
#include <mfidl.h>
#include <mfreadwrite.h>
#include <mferror.h>
#include <wrl/client.h>

#pragma comment(lib, "mfplat.lib")
#pragma comment(lib, "mfreadwrite.lib")
#pragma comment(lib, "mfuuid.lib")
#pragma comment(lib, "ole32.lib")

using Microsoft::WRL::ComPtr;

HRESULT CreateSourceReaderWithAutoRgb(
    const wchar_t* path,
    IMFSourceReader** ppReader)
{
    if (!path || !ppReader) return E_POINTER;
    *ppReader = nullptr;

    ComPtr<IMFAttributes> attrs;
    HRESULT hr = MFCreateAttributes(&attrs, 2);
    if (FAILED(hr)) return hr;

    hr = attrs->SetUINT32(MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING, TRUE);
    if (FAILED(hr)) return hr;

    hr = MFCreateSourceReaderFromURL(path, attrs.Get(), ppReader);
    if (FAILED(hr)) return hr;

    hr = (*ppReader)->SetStreamSelection(MF_SOURCE_READER_ALL_STREAMS, FALSE);
    if (FAILED(hr)) return hr;

    hr = (*ppReader)->SetStreamSelection(MF_SOURCE_READER_FIRST_VIDEO_STREAM, TRUE);
    if (FAILED(hr)) return hr;

    ComPtr<IMFMediaType> outType;
    hr = MFCreateMediaType(&outType);
    if (FAILED(hr)) return hr;

    hr = outType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
    if (FAILED(hr)) return hr;

    hr = outType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_RGB32);
    if (FAILED(hr)) return hr;

    hr = (*ppReader)->SetCurrentMediaType(
        MF_SOURCE_READER_FIRST_VIDEO_STREAM,
        nullptr,
        outType.Get());
    if (FAILED(hr)) return hr;

    return S_OK;
}

HRESULT ReadOneRgb32Sample(
    IMFSourceReader* reader,
    IMFSample** ppSample,
    LONGLONG* pTimestamp100ns)
{
    if (!reader || !ppSample) return E_POINTER;
    *ppSample = nullptr;
    if (pTimestamp100ns) *pTimestamp100ns = 0;

    DWORD streamIndex = 0;
    DWORD flags = 0;
    LONGLONG timestamp = 0;

    HRESULT hr = reader->ReadSample(
        MF_SOURCE_READER_FIRST_VIDEO_STREAM,
        0,
        &streamIndex,
        &flags,
        &timestamp,
        ppSample);

    if (FAILED(hr)) return hr;
    if (flags & MF_SOURCE_READERF_ENDOFSTREAM) return MF_E_END_OF_STREAM;
    if (*ppSample == nullptr) return MF_E_INVALID_STREAM_DATA;

    if (pTimestamp100ns) *pTimestamp100ns = timestamp;
    return S_OK;
}

אחרי זה, קריאה ל-GetCurrentMediaType מאפשרת לבדוק את ה-size ואת ה-stride בפועל של הפלט.

4.4. החוזק של השיטה הזו

היתרון כאן הוא בעיקר להגיע מהר לתמונה נכונה.

  • לא צריך לכתוב בעצמכם את פירוק ה-4:2:0 / 4:2:2
  • אפשר להסתיר במידה רבה את הטרחה של matrix / deinterlace
  • קל להעביר ל-WIC או ל-GDI
  • מספיק מעשי לעיבוד כמה frames

בכלים לחילוץ still, טבעי מאוד להתחיל מכאן.

4.5. יש גם pitfalls

להמרה האוטומטית הזו יש את התכונות הבאות:

פריט תוכן
יעד ההמרה בעיקרון RGB32
מימוש software processing
שימוש מתאים מספר קטן של frames, thumbnail, עיבוד offline
שימוש לא מתאים rendering בזמן אמת מבוסס D3D, עיבוד frames בנפח גדול
attributes שלא מסתדרים עם זה MF_SOURCE_READER_D3D_MANAGER, MF_READWRITE_DISABLE_CONVERTERS

ועוד דבר חשוב הוא הטיפול בבייט הרביעי של RGB32. RGB32 ב-Windows מסודר בזיכרון כ-Blue / Green / Red / Alpha או Don’t Care. זה לא ARGB32. אם מעבירים ל-WIC כ-32bppBGRA, בטוח יותר למלא את הבייט הרביעי ב-0xFF כדי לקבל אטימות.

הטיפול בבייט הרביעי של RGB32תרשים שמראה ש-RGB32 ב-Windows מסודר בזיכרון עם 3 בייטים של B, G, R ובייט רביעי שהוא alpha או don't care, ולכן בטוח יותר למלא 0xFF לפני העברה ל-WIC כ-32bppBGRA.מילוי ב-0xFFהעברה כמו שהואהסידור בזיכרון של RGB323 בייטים: B, G, Rהבייט הרביעי: alpha או don't careאפשר להעביר ל-WIC כ-32bppBGRAעלול לצאת שקוף

איור 10: הבייט הרביעי לא קבוע, ולכן ממלאים אותו ב-0xFF לפני העברה ל-WIC.

הנקודה הזו הוזכרה גם במאמר הקודם על חילוץ still, כאחד ה-pitfalls הנפוצים.

5. Pattern B: כותבים בעצמנו את עיבוד ההמרה

5.1. מתי זה מתאים

המרה עצמית מתאימה, למשל, למקרים הבאים:

  • מעבדים frames בנפח גדול, ורוצים לייעל את ההמרה בעצמנו
  • רוצים להזרים ישירות את NV12 ל-GPU או ל-SIMD
  • רוצים לטפל במפורש ב-BT.601 / BT.709 / ה-range
  • רוצים ליצור פורמט פלט שאינו RGB32
  • ההמרה האוטומטית המוגבלת של Source Reader לא מספיקה

אפשר לתאר את זה כ-pattern שבו לוקחים על עצמכם את האחריות על נפח העיבוד ועל הצבע, בתמורה לחופש פעולה.

ה-trade-off של ההמרה העצמיתתרשים שמראה שבחירת המרה עצמית פירושה לקחת אחריות על נפח העיבוד ועל הצבע, בתמורה לחופש בייעול, בחיבור ל-GPU/SIMD, בשליטה מפורשת ב-matrix וב-range ובפורמט הפלט.בחירת המרה עצמיתלקיחת אחריות על נפח העיבוד ועל הצבעחופש בייעול, GPU / SIMD, פורמט הפלטשליטה מפורשת ב-matrix / range

איור 11: המרה עצמית היא בחירה שלוקחת אחריות בתמורה לחופש בביצועים ובצבע.

5.2. הזרימה הכוללת של ההמרה העצמית

השלבים הם כדלהלן:

  1. הגדרת הפלט של Source Reader ל-NV12 או ל-YUY2
  2. קבלת ה-subtype וה-attributes בפועל עם GetCurrentMediaType
  3. בדיקת MF_MT_FRAME_SIZE, MF_MT_DEFAULT_STRIDE, MF_MT_YUV_MATRIX, MF_MT_VIDEO_NOMINAL_RANGE
  4. שליפת buffer מה-sample ו-lock
  5. חישוב ה-Y/U/V שכל pixel מצביע אליו
  6. הפעלת ה-matrix וכתיבה ל-BGRA

הקוד במאמר הזה מוגבל ל-SDR ב-8-bit / progressive / NV12 או YUY2 / limited range. הגבלת ההנחות כאן היא לא קיצור דרך, אלא דבר חשוב. הסיבה היא שהמרת YUV, אם מממשים אותה כ”מקבל הכול בלי הבחנה”, נוטה לשבור צבעים בשקט.

הזרימה הכוללת של ההמרה העצמיתתרשים שמראה שמבקשים NV12 או YUY2, בודקים את ה-media type וה-attributes בפועל, עושים lock ל-buffer, מחשבים את ה-Y/U/V לכל pixel, ומפעילים matrix לכתיבה ל-BGRA, כאשר הגבלת ההנחות מקטינה סיכוי לשבירת צבע.בקשת NV12 / YUY2בדיקת ה-subtype וה-attributes בפועלlock ל-bufferחישוב ה-Y/U/V לכל pixelהפעלת matrix וכתיבה ל-BGRAהגבלת ההנחות מקטינה סיכוי לשבירת צבע

איור 12: ההמרה העצמית מתקדמת בסדר של בקשה, בדיקה, lock, הפניה והמרה, והסיכון יורד ככל שההנחות מוגבלות יותר.

5.3. תחילה מגדירים במפורש את ה-media type של הפלט

תחילה נמסור ל-Source Reader “תוציא לנו YUV כמו שהוא”. גם כאן מניחים ש-CoInitializeEx / MFStartup כבר רצו.

#include <windows.h>
#include <mfapi.h>
#include <mfidl.h>
#include <mfreadwrite.h>
#include <mferror.h>
#include <wrl/client.h>

using Microsoft::WRL::ComPtr;

HRESULT ConfigureSourceReaderForSubtype(
    IMFSourceReader* reader,
    REFGUID subtype)
{
    if (!reader) return E_POINTER;

    HRESULT hr = reader->SetStreamSelection(MF_SOURCE_READER_ALL_STREAMS, FALSE);
    if (FAILED(hr)) return hr;

    hr = reader->SetStreamSelection(MF_SOURCE_READER_FIRST_VIDEO_STREAM, TRUE);
    if (FAILED(hr)) return hr;

    ComPtr<IMFMediaType> outType;
    hr = MFCreateMediaType(&outType);
    if (FAILED(hr)) return hr;

    hr = outType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
    if (FAILED(hr)) return hr;

    hr = outType->SetGUID(MF_MT_SUBTYPE, subtype);
    if (FAILED(hr)) return hr;

    hr = reader->SetCurrentMediaType(
        MF_SOURCE_READER_FIRST_VIDEO_STREAM,
        nullptr,
        outType.Get());
    if (FAILED(hr)) return hr;

    return S_OK;
}

כאן מעבירים ל-subtype את MFVideoFormat_NV12 או את MFVideoFormat_YUY2.

חשוב לשים לב שה-subtype שביקשנו לא בהכרח יתקבל כמו שהוא. מה שבאמת יוצא, בודקים עם GetCurrentMediaType.

הפרדה בין הבקשה לפלט בפועלתרשים שמראה שה-subtype שביקשו ב-SetCurrentMediaType לא בהכרח מתקבל כמו שהוא, ולכן בודקים עם GetCurrentMediaType מה באמת יוצא.לא בהכרח מתקבל כמו שהואבקשת subtypeהפלט בפועלבדיקה עם GetCurrentMediaTypeכתיבת ההמשך לפי הערך שנבדק

איור 13: הבקשה היא בקשה, והפלט בפועל תמיד מאושר עם GetCurrentMediaType לפני שימוש.

5.4. לפני ההמרה, מקבלים רק מידע צבע נתמך

בהמרה עצמית, קודם כל מקבלים מה-media type את המידע המינימלי שנדרש. בדוגמה במאמר הזה, מקבלים רק NV12 / YUY2, ומעבירים רק matrix מסוג BT.601 או BT.709, ו-range מסוג MFNominalRange_16_235 בלבד.

#include <vector>

struct DecodedFrameInfo
{
    GUID subtype = GUID_NULL;
    UINT32 width = 0;
    UINT32 height = 0;
    LONG defaultStride = 0;
    MFVideoTransferMatrix matrix = MFVideoTransferMatrix_Unknown;
    MFNominalRange nominalRange = MFNominalRange_Unknown;
};

HRESULT GetDefaultStride(
    IMFMediaType* pType,
    LONG* plStride)
{
    if (!pType || !plStride) return E_POINTER;

    LONG stride = 0;
    HRESULT hr = pType->GetUINT32(
        MF_MT_DEFAULT_STRIDE,
        reinterpret_cast<UINT32*>(&stride));

    if (FAILED(hr))
    {
        GUID subtype = GUID_NULL;
        UINT32 width = 0;
        UINT32 height = 0;

        hr = pType->GetGUID(MF_MT_SUBTYPE, &subtype);
        if (FAILED(hr)) return hr;

        hr = MFGetAttributeSize(pType, MF_MT_FRAME_SIZE, &width, &height);
        if (FAILED(hr)) return hr;

        hr = MFGetStrideForBitmapInfoHeader(subtype.Data1, width, &stride);
        if (FAILED(hr)) return hr;

        hr = pType->SetUINT32(MF_MT_DEFAULT_STRIDE, static_cast<UINT32>(stride));
        if (FAILED(hr)) return hr;
    }

    *plStride = stride;
    return S_OK;
}

HRESULT GetStrictDecodedFrameInfo(
    IMFMediaType* pType,
    DecodedFrameInfo* pInfo)
{
    if (!pType || !pInfo) return E_POINTER;

    HRESULT hr = pType->GetGUID(MF_MT_SUBTYPE, &pInfo->subtype);
    if (FAILED(hr)) return hr;

    if (pInfo->subtype != MFVideoFormat_NV12 &&
        pInfo->subtype != MFVideoFormat_YUY2)
    {
        return MF_E_INVALIDMEDIATYPE;
    }

    hr = MFGetAttributeSize(pType, MF_MT_FRAME_SIZE, &pInfo->width, &pInfo->height);
    if (FAILED(hr)) return hr;

    hr = GetDefaultStride(pType, &pInfo->defaultStride);
    if (FAILED(hr)) return hr;

    UINT32 value = 0;

    hr = pType->GetUINT32(MF_MT_YUV_MATRIX, &value);
    if (FAILED(hr)) return hr;

    pInfo->matrix = static_cast<MFVideoTransferMatrix>(value);
    if (pInfo->matrix != MFVideoTransferMatrix_BT601 &&
        pInfo->matrix != MFVideoTransferMatrix_BT709)
    {
        return MF_E_INVALIDMEDIATYPE;
    }

    hr = pType->GetUINT32(MF_MT_VIDEO_NOMINAL_RANGE, &value);
    if (FAILED(hr)) return hr;

    pInfo->nominalRange = static_cast<MFNominalRange>(value);
    if (pInfo->nominalRange != MFNominalRange_16_235)
    {
        return MF_E_INVALIDMEDIATYPE;
    }

    return S_OK;
}

כאן במכוון מגדירים strict. בתיעוד ה-enum של Media Foundation יש רשום גם משהו כמו “Unknown מטופל כ-BT.709”, אבל בפועל, אם מעגלים את זה בשקט, קשה יותר לשים לב לסטיית צבע. לפחות במימוש הראשון, בטוח יותר להפוך שילוב לא נתמך לשגיאה.

מתי Unknown חוזר

יתכן שתחשבו “זה לא קפדני מדי?”, אז נסביר את המסלולים שבהם מתקבל Unknown. ברוב המקרים זה “הווידאו המקורי לא מחזיק מידע צבע”.

  • ל-VUI של H.264 / HEVC אין מידע צבע. לפי התקן, כש-colour_description_present_flag הוא 0, מטפלים ב-matrix_coefficients כ”לא צוין”. אם המידע הזה חסר כשעוברים דרך ה-decoder, גם ה-matrix שמגיע במורד הזרם לא צוין
  • YUV גולמי שהגיע מ-capture device או מ-container ישן. מסלול שאין בו תיאור של מרחב צבע
  • לפעמים אין בכלל attribute MF_MT_YUV_MATRIX. במקרה כזה GetUINT32 לא מחזיר ערך ונכשל עם MF_E_ATTRIBUTENOTFOUND (בקוד שלמעלה זה נדחה ישירות עם FAILED(hr))

חשוב כאן ש-Unknown פירושו “לא ידוע”, ולא “ידוע שזה BT.709”. אם מיישמים 709 על חומר ברזולוציית SD הצבע יסטה, וההפך גם נכון.

עם הבנה זו, יש שני כיווני מדיניות:

  • דחייה strict (המדיניות במאמר הזה): מחזירים שגיאה על מה שלא נתמך, ומאפשרים ברמה גבוהה יותר להחליט “החומר הזה לא נתמך”. בטוח יותר לומר “לא ניתן לטפל בזה” מאשר לתת לצבע לסטות בשקט
  • קביעת default והמשך: אם בהכרח צריך להמשיך, רושמים בלוג מה ההנחה שנעשתה כש-Unknown התקבל. וכך מבהירים במפורש ש”נקבע 601 / 709 לפי הרזולוציה”

בכל מקרה, הדבר היחיד שנמנעים ממנו הוא עיגול בשקט. סטיית צבע לא עושה crash, ולכן היא נכנסת ל-production בלי לשים לב.

הפיצול כש-matrix הוא Unknownתרשים שמראה ש-Unknown פירושו לא ידוע ולא BT.709 בוודאות, ולכן יש שתי מדיניות אפשריות: דחייה strict עם שגיאה, או המשך עם רישום ההנחה בלוג, ובכל מקרה נמנעים מעיגול בשקט.מדיניות המאמר הזהאם חייבים להמשיךמה שנמנעים ממנוmatrix הוא Unknown = לא ידועדחייה strict עם שגיאההמשך עם רישום ההנחה בלוגעיגול בשקט

איור 14: Unknown פירושו “לא ידוע”, ולכן בוחרים בין דחייה להמשך עם רישום, אבל לא מעגלים בשקט.

במצלמות או בפורמטים מסוג JPEG, לפעמים רוצים לטפל בנפרד במסלול שמחזיק full-range. כאן, במקום לשלב את זה בשקט, המדיניות היא להצר במפורש את ההנחות שהקוד הזה מקבל.

5.5. קוראים buffer לפי ה-stride

גם זה חשוב מאוד.

  • MF_MT_DEFAULT_STRIDE הוא ה-stride המינימלי
  • ל-sample buffer בפועל יכול להיות actual stride שכולל padding
  • אם אפשר להשתמש ב-IMF2DBuffer::Lock2D, מעדיפים אותו

אם לוקחים את דפוס ה-helper שב-Uncompressed Video Buffers של Microsoft Learn ומתאימים לשימוש נוח, זה נראה כך:

class BufferLock
{
public:
    explicit BufferLock(IMFMediaBuffer* buffer)
        : m_buffer(buffer),
          m_2dBuffer(nullptr),
          m_locked(false)
    {
        if (m_buffer)
        {
            m_buffer->AddRef();
            m_buffer->QueryInterface(IID_PPV_ARGS(&m_2dBuffer));
        }
    }

    ~BufferLock()
    {
        Unlock();

        if (m_2dBuffer)
        {
            m_2dBuffer->Release();
            m_2dBuffer = nullptr;
        }

        if (m_buffer)
        {
            m_buffer->Release();
            m_buffer = nullptr;
        }
    }

    HRESULT Lock(
        LONG defaultStride,
        DWORD heightInPixels,
        BYTE** ppScanline0,
        LONG* pActualStride)
    {
        if (!m_buffer || !ppScanline0 || !pActualStride) return E_POINTER;
        if (m_locked) return MF_E_INVALIDREQUEST;

        if (m_2dBuffer)
        {
            HRESULT hr = m_2dBuffer->Lock2D(ppScanline0, pActualStride);
            if (FAILED(hr)) return hr;

            m_locked = true;
            return S_OK;
        }

        BYTE* pData = nullptr;
        HRESULT hr = m_buffer->Lock(&pData, nullptr, nullptr);
        if (FAILED(hr)) return hr;

        *pActualStride = defaultStride;
        if (defaultStride < 0)
        {
            *ppScanline0 =
                pData + static_cast<size_t>(-defaultStride) * (heightInPixels - 1);
        }
        else
        {
            *ppScanline0 = pData;
        }

        m_locked = true;
        return S_OK;
    }

    void Unlock()
    {
        if (!m_locked) return;

        if (m_2dBuffer)
        {
            m_2dBuffer->Unlock2D();
        }
        else
        {
            m_buffer->Unlock();
        }

        m_locked = false;
    }

private:
    IMFMediaBuffer* m_buffer;
    IMF2DBuffer* m_2dBuffer;
    bool m_locked;
};

ההגדרה המומלצת של surface ל-YUV היא top-left / stride חיובי, אבל ל-buffer access בפועל בטוח יותר להשתמש ב-stride (=pitch) שה-API החזיר בפועל. אם קובעים לפי width, זה נשבר בשקט בהמשך.

סדר העדיפויות של ה-strideתרשים שמראה ש-MF_MT_DEFAULT_STRIDE הוא הערך המינימלי, ול-buffer בפועל עלול להיות stride שכולל padding, ולכן אם אפשר להשתמש ב-Lock2D מעדיפים את הערך שלו, ולא קובעים לפי width.אם אפשר — עדיפות ראשונהfallbackנשבר בשקטactual stride שמחזיר Lock2Dהערך שמשתמשים בו ל-buffer accessMF_MT_DEFAULT_STRIDE (ערך מינימלי)קביעה לפי width

איור 15: ה-stride שמשתמשים בו למעבר בין שורות הוא בעדיפות ראשונה הערך שנמדד עם Lock2D, ולא קביעה לפי width.

5.6. כותבים בקוד את נוסחת ההמרה של pixel בודד

כאן נעסוק רק ב-BT.601 וב-BT.709 ב-limited range. הפלט יהיה BGRA32, שקל להעביר ל-WIC או ל-GDI.

inline BYTE ClampToByte(double value)
{
    if (value <= 0.0) return 0;
    if (value >= 255.0) return 255;
    return static_cast<BYTE>(value + 0.5);
}

HRESULT ConvertLimitedYuvPixelToBgra(
    BYTE y,
    BYTE u,
    BYTE v,
    MFVideoTransferMatrix matrix,
    BYTE* dstPixel)
{
    if (!dstPixel) return E_POINTER;

    const double c = static_cast<double>(y) - 16.0;
    const double d = static_cast<double>(u) - 128.0;
    const double e = static_cast<double>(v) - 128.0;

    double r = 0.0;
    double g = 0.0;
    double b = 0.0;

    switch (matrix)
    {
    case MFVideoTransferMatrix_BT601:
        r = 1.164383 * c + 1.596027 * e;
        g = 1.164383 * c - 0.391762 * d - 0.812968 * e;
        b = 1.164383 * c + 2.017232 * d;
        break;

    case MFVideoTransferMatrix_BT709:
        r = 1.164383 * c + 1.792741 * e;
        g = 1.164383 * c - 0.213249 * d - 0.532909 * e;
        b = 1.164383 * c + 2.112402 * d;
        break;

    default:
        return MF_E_INVALIDMEDIATYPE;
    }

    dstPixel[0] = ClampToByte(b);
    dstPixel[1] = ClampToByte(g);
    dstPixel[2] = ClampToByte(r);
    dstPixel[3] = 255;

    return S_OK;
}

מה שנעשה כאן פשוט:

  • מפחיתים 16 מ-Y
  • מפחיתים 128 מ-U / V
  • מכפילים במקדמי ה-matrix המתאים
  • עושים clip לתוצאה ל-0..255
  • קובעים את הבייט הרביעי של BGRA ל-255

5.7. ממירים NV12 ל-BGRA32

מכיוון ש-NV12 הוא 4:2:0, ארבעת ה-pixels ב-block 2x2 חולקים אותו U/V. במימוש המינימלי ביותר, פשוט להשתמש באותו chroma משותף ישירות עבור 4 ה-pixels.

HRESULT ConvertNv12ToBgra32(
    IMFMediaBuffer* buffer,
    const DecodedFrameInfo& info,
    std::vector<BYTE>& dstBgra)
{
    if (!buffer) return E_POINTER;
    if (info.subtype != MFVideoFormat_NV12) return MF_E_INVALIDMEDIATYPE;
    if ((info.width & 1u) != 0 || (info.height & 1u) != 0)
    {
        return MF_E_INVALIDMEDIATYPE;
    }

    dstBgra.resize(static_cast<size_t>(info.width) * info.height * 4);

    BufferLock lock(buffer);

    BYTE* scanline0 = nullptr;
    LONG actualStride = 0;
    HRESULT hr = lock.Lock(
        info.defaultStride,
        info.height,
        &scanline0,
        &actualStride);
    if (FAILED(hr)) return hr;

    if (actualStride <= 0)
    {
        lock.Unlock();
        return MF_E_INVALIDMEDIATYPE;
    }

    const BYTE* yPlane = scanline0;

    // תחילת ה-UV plane נמצאת בעמדה שמתקדמת "stride × height".
    // שימו לב שזה לא width × height (ראו הציור בפרק 3.2.)
    const BYTE* uvPlane =
        scanline0 + static_cast<size_t>(actualStride) * info.height;

    for (UINT32 y = 0; y < info.height; ++y)
    {
        // המעבר בין שורות תמיד ביחידות של stride
        const BYTE* yRow = yPlane + static_cast<size_t>(actualStride) * y;

        // 4:2:0 אז ה-UV חולקים שורה אחת בין 2 שורות אנכיות -> y / 2
        // ה-UV plane משתמש באותו stride כמו ה-Y plane
        const BYTE* uvRow = uvPlane + static_cast<size_t>(actualStride) * (y / 2);

        // צד הפלט הוא BGRA packed בלי padding, לכן width * 4
        BYTE* dstRow =
            dstBgra.data() + static_cast<size_t>(info.width) * 4 * y;

        for (UINT32 x = 0; x < info.width; ++x)
        {
            const BYTE Y = yRow[x];

            // ב-UV plane, U ו-V מסודרים לסירוגין.
            // מכיוון ש-2 pixels לרוחב חולקים זוג אחד, קודם מוציאים "איזה זוג" עם (x / 2),
            // ומכיוון שזוג אחד = 2 bytes, מכפילים ב-2 כדי לקבל את מיקום הבייט. +0 הוא U, +1 הוא V.
            //   x = 0, 1 -> uvRow[0], uvRow[1]
            //   x = 2, 3 -> uvRow[2], uvRow[3]
            const BYTE U = uvRow[(x / 2) * 2 + 0];
            const BYTE V = uvRow[(x / 2) * 2 + 1];

            hr = ConvertLimitedYuvPixelToBgra(
                Y,
                U,
                V,
                info.matrix,
                dstRow + static_cast<size_t>(x) * 4);
            if (FAILED(hr))
            {
                lock.Unlock();
                return hr;
            }
        }
    }

    lock.Unlock();
    return S_OK;
}

הקוד הזה מפרש את chroma upsampling בסגנון nearest-neighbor. במראה זה בדרך כלל מספיק מעשי, אבל אם רוצים לשאוף לאיכות התמונה הגבוהה ביותר, תכנון שמבצע קודם upconversion מ-4:2:0 דרך 4:2:2 ל-4:4:4, כמו במאמרי ה-YUV של Microsoft Learn, נקי יותר תיאורטית.

שני התכנונים של chroma upsamplingתרשים שמראה שהמימוש המינימלי משתמש ב-chroma משותף ישירות עבור 4 ה-pixels בסגנון nearest-neighbor ומעשי מספיק ברוב המקרים, בעוד שהעדפת איכות דורשת upconversion מ-4:2:0 דרך 4:2:2 ל-4:4:4.מימוש מינימלי: שימוש ישיר ב-chroma המשותףמספיק מעשי ברוב המקריםהמרה אחרי upconversionנקי יותר תיאורטית, לטובת איכותבסדר 4:2:0 → 4:2:2 → 4:4:4

איור 16: ההבדל בין המימוש המינימלי שמשתמש ב-chroma המשותף לבין תכנון שמבצע upconversion בהדרגה.

5.8. ממירים YUY2 ל-BGRA32

YUY2 הוא 4:2:2 מסוג packed. מכיוון ששני pixels חולקים זוג U/V אחד, קל מעט יותר לקרוא מ-NV12.

#include <cstddef>

HRESULT ConvertYuy2ToBgra32(
    IMFMediaBuffer* buffer,
    const DecodedFrameInfo& info,
    std::vector<BYTE>& dstBgra)
{
    if (!buffer) return E_POINTER;
    if (info.subtype != MFVideoFormat_YUY2) return MF_E_INVALIDMEDIATYPE;
    if ((info.width & 1u) != 0) return MF_E_INVALIDMEDIATYPE;

    dstBgra.resize(static_cast<size_t>(info.width) * info.height * 4);

    BufferLock lock(buffer);

    BYTE* scanline0 = nullptr;
    LONG actualStride = 0;
    HRESULT hr = lock.Lock(
        info.defaultStride,
        info.height,
        &scanline0,
        &actualStride);
    if (FAILED(hr)) return hr;

    for (UINT32 y = 0; y < info.height; ++y)
    {
        const BYTE* src =
            scanline0 +
            static_cast<ptrdiff_t>(actualStride) * static_cast<ptrdiff_t>(y);

        BYTE* dstRow =
            dstBgra.data() + static_cast<size_t>(info.width) * 4 * y;

        for (UINT32 x = 0; x < info.width; x += 2)
        {
            const BYTE Y0 = src[0];
            const BYTE U  = src[1];
            const BYTE Y1 = src[2];
            const BYTE V  = src[3];

            hr = ConvertLimitedYuvPixelToBgra(
                Y0,
                U,
                V,
                info.matrix,
                dstRow + static_cast<size_t>(x) * 4);
            if (FAILED(hr))
            {
                lock.Unlock();
                return hr;
            }

            hr = ConvertLimitedYuvPixelToBgra(
                Y1,
                U,
                V,
                info.matrix,
                dstRow + static_cast<size_t>(x + 1) * 4);
            if (FAILED(hr))
            {
                lock.Unlock();
                return hr;
            }

            src += 4;
        }
    }

    lock.Unlock();
    return S_OK;
}

ב-YUY2 הבייטים מסודרים כ-Y0 U Y1 V, כך שהמבנה “משתמשים שוב ב-U/V כל 2 pixels” נראה ישירות. בזכות זה, קל יותר לבנות מודל מנטלי מ-NV12.

5.9. נקודת הכניסה לקריאה מ-sample

לבסוף, אם שולפים buffer רציף מ-IMFSample ומפצלים לפי subtype, נוח יותר להשתמש בזה.

HRESULT ConvertSampleToBgra32(
    IMFSample* sample,
    const DecodedFrameInfo& info,
    std::vector<BYTE>& dstBgra)
{
    if (!sample) return E_POINTER;

    ComPtr<IMFMediaBuffer> buffer;
    HRESULT hr = sample->ConvertToContiguousBuffer(&buffer);
    if (FAILED(hr)) return hr;

    if (info.subtype == MFVideoFormat_NV12)
    {
        return ConvertNv12ToBgra32(buffer.Get(), info, dstBgra);
    }

    if (info.subtype == MFVideoFormat_YUY2)
    {
        return ConvertYuy2ToBgra32(buffer.Get(), info, dstBgra);
    }

    return MF_E_INVALIDMEDIATYPE;
}

כך, השלב הקודם יכול להיות:

  • יצירת reader
  • בקשת NV12 או YUY2
  • בניית DecodedFrameInfo מ-GetCurrentMediaType
  • ReadSample
  • ConvertSampleToBgra32
הפיצול בפונקציית הכניסהתרשים שמראה ששולפים buffer רציף מ-sample, ומפצלים לפי ה-subtype - NV12 עובר להמרה ל-NV12, YUY2 עובר להמרה ל-YUY2, ואחר מקבל שגיאה.NV12YUY2אחרIMFSampleשליפת buffer רציףהמרה עבור NV12המרה עבור YUY2שגיאה

איור 17: אחרי המרה ל-buffer רציף בכניסה, מפצלים לפי ה-subtype, וכל מה שלא נתמך נופל בפשטות לשגיאה.

צד הקריאה בפועל יכול להיראות כך:

ComPtr<IMFMediaType> currentType;
HRESULT hr = reader->GetCurrentMediaType(
    MF_SOURCE_READER_FIRST_VIDEO_STREAM,
    &currentType);
if (FAILED(hr)) return hr;

DecodedFrameInfo info;
hr = GetStrictDecodedFrameInfo(currentType.Get(), &info);
if (FAILED(hr)) return hr;

DWORD flags = 0;
LONGLONG timestamp = 0;
ComPtr<IMFSample> sample;

hr = reader->ReadSample(
    MF_SOURCE_READER_FIRST_VIDEO_STREAM,
    0,
    nullptr,
    &flags,
    &timestamp,
    &sample);
if (FAILED(hr)) return hr;
if (flags & MF_SOURCE_READERF_ENDOFSTREAM) return MF_E_END_OF_STREAM;
if (!sample) return MF_E_INVALID_STREAM_DATA;

std::vector<BYTE> bgra;
hr = ConvertSampleToBgra32(sample.Get(), info, bgra);
if (FAILED(hr)) return hr;

// bgra ניתן לטיפול כ-top-down / 32bpp BGRA

5.10. איפה ממקמים “כתיבת המרה עצמית”

הקוד עד כאן הוא צורה שבה צד האפליקציה ממיר אחרי Source Reader. זו הצורה הכי ברורה.

עם זאת, אם רוצים להשתלב בתוך ה-pipeline של Media Foundation, יש גם תכנונים אחרים.

  • כתיבת MFT עצמאי
  • שימוש ב-Video Processor MFT / XVP
  • כתיבת shader מ-NV12 ל-RGB בצד ה-GPU

מכאן הנושא קצת משתנה, ולכן הפעם התמקדנו בקוד בצד האפליקציה. עם זאת, כדאי לדעת שיש נקודת ביניים בשם Video Processor MFT, בין “להשאיר ל-Media Foundation” לבין “לעשות הכול באפליקציה”.

5.11. מוודאים שההמרה נכונה

תקלות צבע קשות לזיהוי חזותי, ולכן מפרידים בין “זה רץ” ל”זה נכון” ומאמתים אותם. הסדר הוא שני שלבים.

שלב 1: מכניסים ערכים ידועים ומשווים לחישוב ידני

עדיף להעביר ל-ConvertLimitedYuvPixelToBgra ערכי Y/U/V ידועים, במקום להריץ ישר וידאו. לא צריך קובץ וידאו ולא Media Foundation.

ב-BT.601 ב-limited range, ה-Y/U/V של צבעים ייצוגיים והערך הצפוי מהצבתם בנוסחת 5.6. נראים כך:

צבע Y U V R הצפוי G B
שחור 16 128 128 0 0 0
לבן 235 128 128 255 255 255
אדום 81 90 240 254 0 0
כחול 41 240 110 0 0 255

לדוגמה עבור אדום, C = 81 - 16 = 65, D = 90 - 128 = -38, E = 240 - 128 = 112, ואם מציבים בנוסחה:

R = 1.164383 * 65 + 1.596027 * 112       = 254.44  -> 254
G = 1.164383 * 65 - 0.391762 * (-38)
                  - 0.812968 * 112       =  -0.48  ->   0
B = 1.164383 * 65 + 2.017232 * (-38)     =  -0.97  ->   0

מתקבל. הפלט בסדר BGRA, ולכן רצף הבייטים הוא 00 00 FE FF.

חשוב כאן ש-האדום יוצא 254 ולא 255. הסיבה אינה דיוק המקדמים. הסיבה היא שה-Y/U/V בקלט כבר מספרים שלמים מעוגלים. אם ממירים את האדום התיאורטי (255, 0, 0) ל-limited range של BT.601, מתקבל Y = 16 + 219 × 0.299 = 81.481, U = 90.203, וה-V בדיוק 240. ברגע ששומרים כדגימה ב-8-bit, השבר הזה נמחק ומתקבל Y = 81. ה-0.481 שאבד, בזמן ההחזרה הופך לירידה של בערך 0.481 × 1.164383 ≒ 0.56. 255 − 0.56 = 254.44 — זה המקור למספר 254.44 שלמעלה. גם עם מקדמים בדיוק אינסופי מתקבל 254.44 — עיגול ל-6 ספרות משפיע רק מהספרה הרביעית אחרי הנקודה ואילך, ולא מופיע בפלט של 8-bit.

גם אופן ההפיכה הסופית למספר שלם משפיע על התוצאה. ClampToByte בפרק 5.6. מגביל ל-[0, 255] ואז חותך value + 0.5, כלומר עיגול. עם חיתוך פשוט (static_cast<BYTE>(value)), האדום הזה עדיין 254, אבל ערכים קרובים לגבול כמו B = 255.04 בכחול או R = 0.38 באדום ייסטו ב-1. לפני השוואה למימוש אחר, בדקו איזו שיטה הוא משתמש בה.

כלומר, הסיבה לסבילות של הפרש של 1-2 אינה “דיוק המקדמים שונה”, אלא שתי הסיבות: “השבר שנשמט בדגימה” ו”מדיניות ההפיכה למספר שלם שונה בין מימושים”. במילים אחרות, הפרש שלא מוסבר בשתי אלה הוא באג אמיתי. אדום שיוצא 250, אדום וכחול שמתחלפים, אזורי צל שדולקים לבד — הפרשים כאלה מצדיקים חשד בהנחות ההמרה (בלבול בין BT.601 ל-BT.709, בלבול בין full range ל-limited range, החלפה בין U ל-V, קריאה שגויה של ה-stride), ולא בדיוק המקדמים. אם מפטרים את זה בתור “בעיית דיוק”, מפספסים באג אמיתי שאפשר לתקן.

הגבול בין הפרש סביר לבאג אמיתיתרשים שמראה שהפרש של פלוס מינוס 1 עד 2 מוסבר על ידי שבר שנשמט בדגימה ומדיניות ההפיכה למספר שלם, וכל הפרש שלא מוסבר בכך הוא באג אמיתי שמצדיק חשד בהנחות ההמרה.הפרש של ±1〜2הפרש שלא מוסברבדיקת ההפרש מהערך הצפוימוסבר על ידי שבר בדגימה ומדיניות ההפיכהבאג אמיתיחושדים בהנחות matrix, range, U/V, stride

איור 18: הפרש קטן מוסבר על ידי הדגימה וההפיכה למספר שלם, וכל מה שלא מוסבר בכך מצדיק חשד בהנחות.

לצורך בדיקה, מספיקה הצורה הבאה:

#include <cstdlib>  // std::abs

// בודקים אם ההפרש מהערך הצפוי בתוך tolerance.
// בערך הצפוי כותבים את "הצבע התיאורטי" (אדום = 255, 0, 0). השבר שנשמט בדגימה,
// והשוני במדיניות ההפיכה למספר שלם, נספגים ב-tolerance. דיוק המקדמים אינו הסיבה
static bool CheckPixel(
    BYTE y, BYTE u, BYTE v,
    MFVideoTransferMatrix matrix,
    int expectedR, int expectedG, int expectedB,
    int tolerance = 2)
{
    BYTE bgra[4] = {};
    if (FAILED(ConvertLimitedYuvPixelToBgra(y, u, v, matrix, bgra)))
    {
        return false;
    }

    return std::abs(static_cast<int>(bgra[2]) - expectedR) <= tolerance
        && std::abs(static_cast<int>(bgra[1]) - expectedG) <= tolerance
        && std::abs(static_cast<int>(bgra[0]) - expectedB) <= tolerance
        && bgra[3] == 255;  // ה-alpha חייב להיות אטום
}

// אופן השימוש (BT.601 limited range)
// CheckPixel(16, 128, 128, MFVideoTransferMatrix_BT601, 0, 0, 0);      // שחור
// CheckPixel(235, 128, 128, MFVideoTransferMatrix_BT601, 255, 255, 255); // לבן
// CheckPixel(81, 90, 240, MFVideoTransferMatrix_BT601, 255, 0, 0);     // אדום (בנוסחה R=254)
// CheckPixel(41, 240, 110, MFVideoTransferMatrix_BT601, 0, 0, 255);    // כחול

אפשר לעשות את אותו הדבר גם עם BT.709. המקדמים שונים, ולכן גם הערכים של Y/U/V שונים. לדוגמה, האדום של BT.709 הוא Y=63, U=102, V=240. אם מזרימים את הערכים של 601 ישירות להסתעפות של 709, הצבע יסטה, ולכן שווה לבדוק זאת בשורה נפרדת כדי לתפוס בלבול matrix בו במקום.

בדוגמה ב-GitHub, ההמרה של pixel בודד הזו הוצאה ל-header נפרד שאינו תלוי ב-OS, כך שהבדיקה הזו רצה גם בלי Windows.

שלב 2: משווים בין הפלט של Pattern A ל-Pattern B

אחרי שה-pixel הבודד תואם, השלב הבא הוא כל ה-frame. מאותו זמן באותו וידאו, שולפים תמונה אחת בכל אחד משני המסלולים:

  1. Pattern A (MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING + RGB32)
  2. Pattern B (מקבלים NV12 / YUY2 וממירים בעצמנו)

ומשווים pixel-pixel.

איך קוראים את ההפרש:
  לוקחים |A.R - B.R|, |A.G - B.G|, |A.B - B.B| של כל pixel
  מוצאים את המקסימום, ואת אחוז ה-pixels שעברו את הסף

אל תצפו כאן להתאמה מלאה. יש לזה שתי סיבות.

  • ייתכן שה-video processing בצד Source Reader מבצע chroma upsampling בשיטה שאינה nearest-neighbor. המימוש העצמי בפרק 5.7. הוא מימוש מינימלי שמשתמש ב-chroma המשותף ישירות עבור 4 pixels, ולכן ההפרש בולט יותר באזורי גבול
  • הטיפול בעיגול ובדיוק ביניים שונה

לכן, מה שבודקים הוא לא “האם זה תואם”, אלא התבנית של הופעת ההפרש.

ההפרש שנצפה מה חושדים בו
האזורים השטוחים תואמים, ההפרש רק בגבולות הצבע ההבדל ב-chroma upsampling. צפוי
כל התמונה נסטה באופן אחיד בלבול ב-matrix (601 / 709) או ב-range (16..235 / 0..255)
פסים, סטייה אלכסונית קביעה שגויה של ה-stride. ראו 7.2. ו-7.5.
אדום וכחול מתחלפים בלבול בין BGRA ל-RGBA
הכול נראה שקוף / שחור לגמרי הבייט הרביעי לא מולא ב-0xFF. ראו 7.1.

אם רואים את “הצורה” של ההפרש, קל יותר לצמצם היכן לחשוד. אם ההפרש אחיד על כל התמונה, זה הנוסחה או מידע הצבע; אם הוא מקומי, זה אינדקס או stride.

שני שלבי האימותתרשים שמראה שקודם משווים pixel בודד עם ערכים ידועים לחישוב ידני, ואם זה תואם, משווים frames מלאים משני המסלולים באותו זמן באותו וידאו, וממקדים חשד לפי צורת ההפרש.שלב 1: השוואת pixel בודד לחישוב ידנישלב 2: השוואת frames משני המסלוליםממקדים חשד לפי צורת ההפרשלא נדרש וידאו ולא Media Foundation

איור 19: אחרי אימות pixel בודד, השוואת frames שלמים ממקדת את מקור ההפרש לפי צורתו.

6. באיזה pattern לבחור

בהיסוס, הטבלה הבאה מסייעת מאוד להתמקד.

היבט המרה אוטומטית (MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING) המרה עצמית
מהירות המימוש ◎ △
חילוץ כמה stills ◎ ○
עיבוד בנפח גדול / realtime △ ◎
שליטה מפורשת ב-matrix / range △ ◎
שילוב עם GPU / D3D △ ○〜◎
פלט שאינו RGB32 △ ◎
הבנת העקרונות ○ ◎

כמדד ראשון, נוח לחשוב כך:

  • רוצים שזה פשוט יעבוד ← המרה אוטומטית
  • רוצים לקחת אחריות על הצבע ועל הביצועים ← המרה עצמית

בפועל, גם הסדר “קודם לוודא תמונה נכונה עם המרה אוטומטית, ואחר כך להחליף ל-manual path” יעיל למדי. אם לוקחים על עצמכם הכול מההתחלה, קשה יותר לדעת היכן התמונה נשברה.

הסדר המעשי היעילתרשים שמראה שאם קודם מוודאים תמונה נכונה עם ההמרה האוטומטית ורק אז עוברים ל-manual path, קל יותר לבודד היכן התמונה נשברה, בעוד שנטילת הכול מההתחלה מקשה על כך.קודם מוודאים תמונה נכונה עם המרה אוטומטיתאחר כך עוברים ל-manual pathקל יותר לבודד היכן נשברנטילת הכול מההתחלהקשה לדעת היכן נשבר

איור 20: אם קודם מבטיחים תמונה נכונה ואז עוברים למימוש עצמי, קל יותר לבודד את מקום התקלה.

7. pitfalls נפוצים בפועל

7.1. הנחה מוטעית ש-RGB32 הוא RGBA עם alpha

RGB32 בזיכרון הוא B, G, R, Alpha או Don't Care. אם שומרים אותו ישירות כ-BGRA ל-PNG, לפעמים הבייט הרביעי הוא 0 והתמונה שקופה. בטוח יותר למלא 0xFF לפני השמירה.

7.2. קביעת ה-stride לפי width * bytesPerPixel

זו תקלה נפוצה למדי. מכיוון של-sample buffer בפועל יכול להיות padding, העיקרון הוא להשתמש ב-actual stride למעבר בין שורות.

7.3. בלבול בין MF_MT_DEFAULT_STRIDE ל-actual pitch

MF_MT_DEFAULT_STRIDE הוא “ה-stride המינימלי כשמייצגים את הפורמט הזה בזיכרון רציף”. ל-actual pitch של ה-sample buffer מעדיפים את הערך שמחזיר IMF2DBuffer::Lock2D. (pitch הוא שם אחר ל-stride. כפי שהוזכר בפרק 3.2., במאמר הזה משתמשים בהם באותה משמעות.)

7.4. ניחוש בשקט של 601 / 709 בלי לבדוק color metadata

תקלות צבע קשה לראות. הן גם לא גורמות ל-crash. לכן הן מטרידות.

  • MF_MT_YUV_MATRIX
  • MF_MT_VIDEO_NOMINAL_RANGE

לפחות אלה, כדאי לבדוק. ובנוסף, נכון להתייחס בגישה של להפוך ערכים שהקוד שלכם לא תומך בהם לשגיאה.

7.5. חיתוך UV plane של NV12 לפי width * height

היסט ה-plane נקבע לפי ה-stride וה-height בפועל. לא לפי width * height. אם עושים את זה ברשלנות, הצבע נסטה או שהתמונה נשברת.

איך קובעים את גבול ה-plane ב-NV12תרשים שמראה שתחילת ה-UV plane ב-NV12 נקבעת לפי מכפלת ה-stride וה-height בפועל, וחיתוך לפי מכפלת width ו-height מוביל לסטיית צבע ולתמונה שבורה.מתקדמים stride × heightתחילת ה-buffer (Y plane)תחילת ה-UV planeחיתוך לפי width × heightסטיית צבע / תמונה שבורה

איור 21: גבול ה-UV plane נקבע לפי stride × height, לא לפי width × height.

7.6. טיפול ב-interlaced video בהנחת progressive

הדוגמה הידנית במאמר הזה מניחה progressive. אם קוראים interlaced ישירות כ-field בודד, לפעמים מופיע combing. אם דרוש deinterlace, טבעי יותר לשקול את ה-video processing האוטומטי של Source Reader או את Video Processor MFT.

7.7. התעלמות מאיכות ה-chroma upsampling ב-4:2:0

ההמרה של NV12 במאמר הזה, לטובת בהירות, היא בצורה של שימוש ישיר ב-chroma המשותף עבור כל pixel. לפי צורך זה מספיק, אבל אם רוצים איכות תמונה, כדאי לעקוב אחר תפיסת ה-upconversion שבחומרי הפורמט המומלץ של YUV.

8. סיכום

כשממירים YUV ל-RGB עם Media Foundation, אם מחזיקים את הסידור הבא מראש, קל הרבה יותר לא להתבלבל:

  • מאחורי ה-decoder, בדרך כלל יוצא לא RGB אלא NV12 או YUY2
  • אם רוצים נוחות, מבקשים RGB32 עם MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING
  • אם רוצים לשלוט, מקבלים NV12 / YUY2 וממירים בעצמכם ל-BGRA
  • ב-manual path, לפני הנוסחה עצמה, תופסים sampling / range / matrix / stride
  • אם BT.601 / BT.709, 16..235, 4:2:0 / 4:2:2 נשארים מעורפלים, מתקבלת סטיית צבע או תמונה שבורה

היחס בין YUV ל-RGB קצת קשה להיאחז בו בהתחלה. אבל ברגע שהתמונה הבאה נכנסת לראש:

  • NV12 חולק U/V ב-2x2
  • YUY2 חולק U/V בשני pixels לרוחב
  • מפעילים matrix על אותו U/V ועל Y

זה נעשה הרבה יותר טבעי. רצף הבייטים המסתורי מתחיל להיראות כ-pixels בעלי משמעות אמיתית.

התמונה שכדאי להחזיק בראשתרשים שמראה ש-NV12 חולק U/V ב-2x2, YUY2 חולק U/V בשני pixels לרוחב, ומפעילים matrix על אותו U/V ועל Y, וכך רצף הבייטים המסתורי נראה כ-pixels בעלי משמעות.NV12: חלוקת U/V ב-2x2הפעלת matrix על אותו U/V ועל YYUY2: חלוקת U/V בשני pixels לרוחברצף הבייטים נראה כ-pixels בעלי משמעות

איור 22: עם תמונה של יחידת השיתוף ומקדם ה-matrix, רצף הבייטים של ה-YUV נקרא בטבעיות.

9. מקורות

קוד הדוגמה של המאמר הזה

מאמרים קשורים של KomuraSoft

Microsoft Learn

מאמרים עדכניים עם אותן תגיות, להעמקה בנושאים קרובים.

העמודים האלה ממקמים את הנושא בהקשר רחב יותר של שירותים והחלטות.

המאמר קשור ישירות לשירותים הבאים.

שאלות נפוצות

שאלות נפוצות בפניות בנושא המאמר.

למה ה-decoder של Media Foundation מוציא YUV ולא RGB?
כי העין רגישה לפירוט של בהירות יותר מאשר לפירוט של צבע, ולכן בוידאו משתלם לתכנן Y (רכיב שקרוב לבהירות) ברזולוציה גבוהה, ו-U/V (רכיבי color difference) גסים יותר. בקטגוריית הווידאו של Windows, ה-frames הלא-דחוסים שיוצאים מה-decoder הם בדרך כלל פורמט YUV כמו NV12 או YUY2. בהקשר של וידאו דיגיטלי, YUV מתייחס בפועל ל-Y'CbCr, וכך קל יותר לקרוא את התיעוד.
מה הדרך הפשוטה ביותר לקבל frame ב-RGB?
להפעיל MF_SOURCE_READER_ENABLE_VIDEO_PROCESSING ב-IMFSourceReader ולבקש MFVideoFormat_RGB32. זו הדרך הקלה ביותר לחילוץ כמה stills או ליצירת thumbnail. אבל ההמרה האוטומטית הזו היא software processing שלא מותאם ל-playback בזמן אמת, ולכן בעיבוד בנפח גדול או כשרוצים שליטה בצבע, מקבלים את ה-YUV כמו שהוא וממירים בעצמכם.
על מה צריך להיזהר כשממירים YUV ל-RGB בעצמכם?
זה לא נגמר בשלושה מקדמים. מעורבים כאן chroma subsampling (4:2:0 / 4:2:2), range, matrix ו-stride. בפועל מה ששובר צבעים הוא לא לבדוק את MF_MT_YUV_MATRIX ואת MF_MT_VIDEO_NOMINAL_RANGE, ולהניח ש-stride שווה ל-width × bytesPerPixel. הדרך הקצרה ביותר היא קודם להבין את המבנה של NV12 ושל YUY2.
מה ההבדל בין NV12 ל-YUY2?
NV12 הוא פורמט 4:2:0: אחרי Y plane מגיע UV plane שבו U ו-V יושבים לסירוגין, וארבעה pixels ב-block 2x2 חולקים זוג U/V אחד. YUY2 הוא פורמט 4:2:2, שבו שני pixels לרוחב חולקים U/V. שני הפורמטים נפוצים מאוד בפועל, וההבדל הוא איך מדללים את הצבע (chroma subsampling).

פרופיל הכותב

עמוד היכרות עם כותב המאמר.

Go Komura

מנהל KomuraSoft LLC

מתמחה בפיתוח תוכנה עבור Windows, ייעוץ טכני וחקירת תקלות, בעיקר בפרויקטים עם מערכות קיימות ובאגים שקשה לשחזר.

קישורים ציבוריים

חזרה לבלוג