この記事はOpenCV Advent Calendar 2023の21日目の記事です。
1. はじめに
今年はまさにHDR元年!
Adobe Lightroom、Google Pixel8、DJI Osmo Pocket 3等々、HDRの保存や出力に対応したものが一気に増えたので今年はHDR元年です。言ってた人は何年も前から言ってたんだろうと思います。おそらく来年もHDR元年です。BluetoothとかVRとかで見ました。
HDRの画像や動画が身近になった一方で、それを表示しようと思うとPCでもスマホでもChromeで開くのが一番簡単というのが現状です。OpenCVでHDR画像を扱うとして、その結果を確認したくても表示できず、imwriteがHDRに対応していないので書き出してChromeで開くこともできません。困りました。
HDR画像を読み込んだcv::Matをcv::imshowでHDR表示できる、そんなhighguiプラグインをQtで作りました。環境はWindows10以降でHDR対応のグラフィックボードとディスプレイがあることを想定しています。Qt専用の開発環境、qmake、mocといった類は使わずC++14に対応したコンパイラがあればOK。でもwindeployqtは欲しいので、Qtのオープンソース版6.6.0以降がインストールされているかvcpkgでqtdeclarativeとqttoolsの6.6.0以降がビルドしてあるのを前提とします。プラグインなのでOpenCV完全体をビルドしなおす必要も多分ありません。
この文章には以下の成分が含まれています。
- OpenCVのhighguiプラグインを作る
- Qt Quickのシーングラフを使ってHDR画像を表示する
- mocを使わずVerdigrisでカスタムQMLタイプを作ってQMLタイプシステムに登録する
今までもHDRってあったでしょ?って人もいるかと思います。例えばOpenCV 3.1.0からあるこのチュートリアル。
これはHDR合成を扱っていて、結果は最終的にSDRへトーンマッピングされています。
現在はイメージセンサーの性能が向上していて合成せずともRAWデータからHDR画像を現像することが出来ます。更にHDR対応の表示環境もスマホやPCモニターで簡単に手に入るようになりました。
つまりこの記事でやりたいことは、画素値が255や1.0より大きくて明るい、あるいは最大値が1.0でもそれが従来より明るく定義されている画像をSDRにせずそのまま表示しようという話なのです。
2. WindowsでHDRを扱う
WindowsではHDR10とscRGBの2つのHDRフォーマットがサポートされています。
HDR10はPQ方式のBT.2100で非線形のガンマカーブ(ST2084)、RGB各10bit(RGB10A2)、最大輝度10000nits。8bitのデータと10bitのデータを混在させると扱いが厄介になるので注意しましょう。もう一つのscRGBは線形ガンマの半精度浮動小数点数、1.0がsRGBの1.0と同じで最大輝度はFP16の限界までです。各アプリケーションはsRGB、HDR10、scRGBでオフスクリーンバッファに出力し、DWMが一度すべてscRGBに変換してから合成、大抵のHDRディスプレイがサポートしているHDR10で出力します。
何もしなければオフスクリーンバッファはsRGBになるので、HDRを表示するにはそのように伝えなければいけません。例えばHDR10なら色域がBT.2020で伝達関数がST2084で形式がRGB10A2で、といった具合です。WindowsだとDXGIのIDXGISwapChainやWinRTのVisual layerから、VulkanではVkColorSpaceKHRやVkFormatを使うみたいです。もちろんGDIは対応していません。OpenGLにもHDR用の拡張はなさそうです。
DXGIのサンプルはこの節の最初のリンクから、Visual Layerを使う場合のサンプルは多分↓
3. QtでHDRを扱う
Qtには各プラットフォームのグラフィックスAPIを抽象化した内部APIのQRhi(Qt Rendering Hardware Interface)があります。Qt Quickのレンダリングに使われていてD3D11、Vulkan、Metal、OpenGLの違いを吸収しています。Qt6.6.0で(互換性は保証できないとの警告付きですが)外部から使えるようになりました。バックエンドをD3D11にしてQQuickWindowのSwapChainにHDRフォーマットを指定すればWindowsでのHDR表示が可能になります。
highguiのQtバックエンドはQt QuickではなくQt Widgetというもう一つのGUIフレームワークを使っているので現状ではHDRに対応できません。1
注意点として、Qtは直接的には0~255や0.0~1.0の色しか扱うことができません。また、Qt QuickのUI記述言語QMLはCSS風ですが、CSSのrgba()がsRGBとして解釈される一方でQMLのQt.rgba()は色空間の情報を持ちません。なのでHDR10で1.0を指定すれば出力時には2^10-1=1023にしてくれる場合もあります。例えばQMLで四角形を描くとして
import QtQuick 2.0
Rectangle {
color: Qt.rgba(1, 1, 1, 1)
width: 300
height: 200
}
これはQRhiSwapChain::HDR10
を指定していれば猛烈に明るい白い四角形になります。しかしQRhiSwapChain::HDRExtendedSrgbLinear
の場合、scRGBの(1,1,1)はsRGBのそれと同じなのでマウスカーソル等と同じただの白になり、1以上の値を指定しても1にクランプされてしまいます。
ではQt QuickでQMLなUI上にcv::Matで画像を渡して表示するにはどうすればいいでしょうか。
import QtQuick 2.0
Image {
source: ""
function onSourceUpdate(text) {
source = text;
}
}
QMLのImage要素はsourceにurlしか受け付けず、バイナリの画像データを直接渡すことが出来ません。文字列でのリクエストに対して画像を返すImageProviderを実装する必要があります。この時返すのをQImageにしてしまうと、Qtがテクスチャを生成する際に各チャンネル8ビット整数で作ってしまう上にクランプされるため、HDR10でもscRGBでも都合がよくありません。したがってTextureFactoryを実装してそこからQRhiを使ってテスクチャを作成し返すことになります。
- QQuickImageProviderとQQuickTextureFactoryを継承して実装
- QMLのImageにC++側からユニークな文字列付きでsignalを送る
- QML側で受け取った文字列でsourceを更新
- C++に戻ってImageProviderからTextureFactoryを返す
- TextureFactoryからcv::Matを元にしてQSGTextureを返す
という流れになりそうです。画像1枚更新するのにC++とQMLを一往復、QMLに処理も書かないといけません。TextureFactoryから返したテクスチャの所有権はQt側に渡ってしまうので再利用することも出来なさそうです。動画の表示でテクスチャの再生成だらけになってしまうのでよろしくありませんね。
今回はQMLをシンプルに、ItemのC++側クラスQQuickItemを継承したCvItemが1つだけのQMLにして、表示はC++側でシーングラフを使って実装しましょう。
import CvQuick 0.1
CvItem {
}
QMLはこれだけです。これだけならQQmlComponent::loadFromModule
を使えばこのQML自体不要になります。
C++でCvItemを実装する際にmoc等を使いたくなかったので代わりにVerdigrisを使います。
Qt without moc、そのままですね!slotとsignalに代表されるQtのMeta Object SystemのためのコードをMeta-Object Compilerで生成する代わりに、マクロとconstexprを駆使してコンパイル時に同等のコードや構造体を用意してくれます。ちょっと記法がQtと違う部分もありますが、簡単なQtプログラムはQt Creatorやqmakeを使わずに書けます。
#include <wobjectdefs.h>
#include <QtQuick/qquickitem.h>
#include <QtGui/qimage.h>
class CvItem : public QQuickItem
{
W_OBJECT(CvItem)
W_CLASSINFO("QML.Element", "CvItem") // = QML_ELEMENT
public:
// called by QMetaObject::invokeMethod in imshow
void updateImage(QImage image); W_INVOKABLE(updateImage)
// resize window in the GUI thread
void imageSizeChanged() W_SIGNAL(imageSizeChanged)
private:
// connect the signal to resize win
void handleWindowChanged(QQuickWindow* win);
QMetaObject::Connection con;
// new image, isNull() == true if no updates
QImage nimage;
// new size
QSize nsize = {100, 100};
public:
// called from the render thread, while the GUI thread is blocked
QSGNode* updatePaintNode(QSGNode*, UpdatePaintNodeData*) override;
CvItem();
~CvItem();
};
#include <wobjectimpl.h>
#include "CvItem.h"
#include <QtCore/qobject.h>
#include <QtGui/rhi/qrhi.h>
#include <QtQuick/qquickitem.h>
#include <QtQuick/qsgimagenode.h>
#include <QtQml/qqmlmoduleregistration.h>
W_OBJECT_IMPL(CvItem)
void qml_register_types_CvQuick()
{
qmlRegisterTypesAndRevisions<CvItem>("CvQuick", 0);
qmlRegisterModule("CvQuick", 0, 1);
}
static const QQmlModuleRegistration registration("CvQuick", qml_register_types_CvQuick);
CvItem::CvItem()
: QQuickItem()
, con(connect(this, &QQuickItem::windowChanged, this, &CvItem::handleWindowChanged))
{
setFlag(ItemHasContents, true);
}
CvItem::~CvItem()
{
disconnect(con);
}
void CvItem::updateImage(QImage image)
{
nimage = image;
update();
}
void CvItem::handleWindowChanged(QQuickWindow* win)
{
if (win)
{
win->setColor(Qt::black);
connect(this, &CvItem::imageSizeChanged, win,
[this, win]
{
if (!nsize.isEmpty()) { win->resize(nsize); nsize = QSize{}; }
},
Qt::QueuedConnection
);
}
}
QSGNode* CvItem::updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*)
{
static QImage image(nsize, QImage::Format_RGBX16FPx4);
if (!nimage.isNull())
{
image = nimage;
}
QSGImageNode* node = oldNode ? static_cast<QSGImageNode*>(oldNode) : window()->createImageNode();
QSGTexture* texture = node->texture();
if(nimage.isNull() == false || texture == nullptr)
{
nimage = QImage{};
const QImage::Format qf = QImage::Format_RGBX16FPx4;
const QRhiTexture::Format tf = QRhiTexture::RGBA16F;
image.convertTo(qf, Qt::AutoColor);
QRhi* rhi = window()->rhi();
QRhiSwapChain* sc = window()->swapChain();
QRhiTexture* tex = nullptr;
if (texture != nullptr)
{
if (texture->textureSize() == image.size())
{
tex = texture->rhiTexture();
}
else
{
node->setOwnsTexture(false);
delete texture;
texture = nullptr;
}
}
if(texture == nullptr)
{
tex = rhi->newTexture(tf, image.size(), 1, QRhiTexture::MipMapped | QRhiTexture::UsedWithGenerateMips);
bool c = tex->create();
nsize = image.size();
emit imageSizeChanged();
}
QRhiResourceUpdateBatch* rub = rhi->nextResourceUpdateBatch();
rub->uploadTexture(tex, image);
rub->generateMips(tex);
QRhiCommandBuffer* cb = sc->currentFrameCommandBuffer();
cb->resourceUpdate(rub);
if (texture == nullptr)
{
texture = window()->createTextureFromRhiTexture(tex);
texture->setFiltering(QSGTexture::Linear);
texture->setMipmapFiltering(QSGTexture::Linear);
node->setTexture(texture);
node->setOwnsTexture(true);
node->setFiltering(QSGTexture::Linear);
node->setMipmapFiltering(QSGTexture::Linear);
}
node->setSourceRect(QRectF(QPointF(0.0, 0.0), texture->textureSize()));
}
node->setRect(boundingRect());
return node;
}
Q_OBJECT等がW_OBJECTになっていたりcpp側にW_OBJECT_IMPLが必要だったりがVerdigrisのお作法です。またVerdigrisはQML関連に対応していないため、カスタムQMLタイプとしてQMLから使えるようにW_CLASSINFOを使い、更にタイプとモジュールをQMLタイプシステムに登録するために必要な処理をstaticなグローバル変数の初期化で呼び出しています。本来のQtであればQML_ELEMENTと唱えるだけでこれらを生成してくれるらしいですよ。
ヘッダーも本来であれば#include <QQuickItem>
等とするのですが、インクルードパスが増えて面倒なので上のようにしています。バージョン間で互換性のないQRhi関連だけ別のところに入っているようなので、インクルードパスの指定はオープンソース版だと
-I"C:\Qt\6.6.0\msvc2019_64\include" -I"C:\Qt\6.6.0\msvc2019_64\include\QtGui\6.6.0" -I"C:\Qt\6.6.0\msvc2019_64\include\QtGui\6.6.0\QtGui\rhi"
こんな感じになります。
4. Highguiプラグインを作る
インストールされるヘッダーに加えてソースから以下3つのヘッダーが必要になります。
- modules/highgui/src/backend.hpp
- modules/highgui/src/plugin_api.hpp
- modules/core/include/opencv2/core/llapi/llapi.h
window_w32.cppとwindow_QT.cppをパクりつつ表示とキー入力を最低限実装します。先ほどのCvItemをQQmlComponentから生成し、QML UIを表示してくれるQQuickViewに渡します。
作成されるウィンドウはQQuickWindowになります。sceneGraphInitializedのシグナルで(必要ならバックエンドの選択と)HDRフォーマットの指定を行います。
QTバックエンドだとキー入力が常に大文字になるのが個人的に気に食わないので、WIN32UIに合わせます。本来は注意しないといけないスレッド周りや別のQtアプリケーションに組み込む場合等はあまり考慮していません。scRGBが優先されるのでHDR10を試したければ適宜書き換えてください。
#define BUILD_PLUGIN
#define ABI_VERSION 0
#define API_VERSION 0
#include <opencv2/opencv.hpp>
#include "plugin_api.hpp"
#include "backend.hpp"
#include <wobjectdefs.h>
#include <wobjectimpl.h>
#include <QtCore/qobject.h>
#include <QtGui/qguiapplication.h>
#include <QtGui/qevent.h>
#include <QtGui/rhi/qrhi.h>
#include <QtQuick/qquickview.h>
#include <QtQuick/qquickitem.h>
#include <QtQml/qqmlengine.h>
#include <QtTest/qtestcase.h>
#include "CvItem.h"
static bool isTranslatableKey(Qt::Key key)
{
// https://github.com/opencv/opencv/issues/21899
// https://doc.qt.io/qt-5/qt.html#Key-enum
// https://doc.qt.io/qt-6/qt.html#Key-enum
// https://github.com/qt/qtbase/blob/dev/src/testlib/qasciikey.cpp
bool ret = false;
switch (key)
{
// Special keys
case Qt::Key_Escape:
case Qt::Key_Tab:
case Qt::Key_Backtab:
case Qt::Key_Backspace:
case Qt::Key_Enter:
case Qt::Key_Return:
ret = true;
break;
// latin-1 keys.
default:
ret = (
((Qt::Key_Space <= key) && (key <= Qt::Key_AsciiTilde)) // 0x20--0x7e
||
((Qt::Key_nobreakspace <= key) && (key <= Qt::Key_ssharp)) // 0x0a0--0x0de
||
(key == Qt::Key_division) // 0x0f7
||
(key == Qt::Key_ydiaeresis) // 0x0ff
);
break;
}
return ret;
}
class CVQuickView : public QQuickView
{
W_OBJECT(CVQuickView)
public:
CVQuickView(QQmlEngine* engine, QWindow* parent = nullptr)
: QQuickView(engine, parent)
{
++num_window;
}
virtual void keyPressEvent(QKeyEvent* ke) override
{
const int key = ke->key();
const Qt::Key qtkey = static_cast<Qt::Key>(key);
auto vk = isTranslatableKey(qtkey) ? QTest::keyToAscii(qtkey) : ke->nativeVirtualKey();
if (vk >= 'A' && vk <= 'Z') {
if (ke->modifiers() & Qt::ShiftModifier) {
}
else {
vk += ('a' - 'A');
}
}
last_key = vk;
}
virtual void closeEvent(QCloseEvent* ev) override
{
--num_window;
if (num_window == 0)
{
last_key = -2;
}
}
public:
static int last_key;
static int num_window;
};
int CVQuickView::last_key = -1;
int CVQuickView::num_window = 0;
W_OBJECT_IMPL(CVQuickView)
namespace cv
{
namespace impl
{
using namespace cv::highgui_backend;
class QtQuickWindow : public UIWindow
{
public:
QtQuickWindow(const std::string& name_, int flags_, QQmlEngine &engine, QQmlComponent &component)
: name(name_)
, view(&engine, nullptr)
{
QObject::connect(&view, &QQuickWindow::sceneGraphInitialized,
[this] {
QRhi* rhi = view.rhi();
std::cerr << rhi->backendName() << std::endl;
QRhiSwapChain* sc = view.swapChain();
if (sc == nullptr)
{
std::cerr << "view.swapChain() == nullptr, skip" << std::endl;
return;
}
bool scrgb = sc->isFormatSupported(QRhiSwapChain::Format::HDRExtendedSrgbLinear);
bool hdr10 = sc->isFormatSupported(QRhiSwapChain::Format::HDR10);
std::cout << "HDRExtendedSrgbLinear " << (scrgb ? "" : "un") << "supported\n";
std::cout << "HDR10 " << (hdr10 ? "" : "un") << "supported\n";
if (scrgb)
{
sc->setFormat(QRhiSwapChain::Format::HDRExtendedSrgbLinear);
}
else if (hdr10)
{
sc->setFormat(QRhiSwapChain::Format::HDR10);
}
QRhiSwapChainHdrInfo hdrinfo = sc->hdrInfo();
std::cout << "hdrinfo.isHardCodedDefaults : " << (hdrinfo.isHardCodedDefaults ? "True" : "False") << std::endl;
if (hdrinfo.limitsType == QRhiSwapChainHdrInfo::LuminanceInNits)
{
std::cout << "hdrinfo.limitsType : LuminanceInNits\n\t"
"minLuminance : " << hdrinfo.limits.luminanceInNits.minLuminance << "\n\t"
"maxLuminance : " << hdrinfo.limits.luminanceInNits.maxLuminance << std::endl;
}
else
{
std::cout << "hdrinfo.limitsType : ColorComponentValue\n\t"
"maxColorComponentValue : " << hdrinfo.limits.colorComponentValue.maxColorComponentValue << "\n\t"
"maxPotentialColorComponentValue : " << hdrinfo.limits.colorComponentValue.maxPotentialColorComponentValue << std::endl;
}
}
);
view.setResizeMode(QQuickView::SizeRootObjectToView);
QObject *obj = component.create();
CvItem* item = qobject_cast<CvItem*>(obj);
view.setContent(QUrl{}, &component, item);
view.setTitle(QString::fromStdString(name));
view.show();
}
void imshow(InputArray input) CV_OVERRIDE
{
Mat mat = input.getMat();
Mat tmp;
QImage::Format format = QImage::Format_Invalid;
if (mat.channels() == 3)
{
cv::cvtColor(mat, tmp, cv::COLOR_BGR2RGBA);
swap(mat, tmp);
}
m = mat;
switch (mat.type())
{
case CV_16FC4:
format = QImage::Format_RGBX16FPx4;
break;
case CV_32FC4:
format = QImage::Format_RGBX32FPx4;
break;
case CV_8UC4:
format = QImage::Format_RGBX8888;
break;
case CV_16UC4:
format = QImage::Format_RGBX64;
break;
default:
break;
}
QImage img(mat.data, mat.cols, mat.rows, mat.step[0], format);
CvItem* item = qobject_cast<CvItem*>(view.rootObject());
QMetaObject::invokeMethod(
item,
[item, img = img.copy()]
{
item->updateImage(img);
}
);
}
const std::string& getID() const CV_OVERRIDE { return name; }
bool isActive() const CV_OVERRIDE { return view.isVisible(); }
void destroy() CV_OVERRIDE { view.close(); }
double getProperty(int prop) const CV_OVERRIDE { return std::numeric_limits<double>::quiet_NaN();; }
bool setProperty(int prop, double value) CV_OVERRIDE { return false; }
void resize(int width, int height) CV_OVERRIDE { view.resize(width, height); }
void move(int x, int y) CV_OVERRIDE { view.setPosition(x, y); }
Rect getImageRect() const CV_OVERRIDE { const QRect rect = view.geometry(); return Rect{ rect.x(), rect.y(), rect.width(), rect.height() }; }
void setTitle(const std::string& title) CV_OVERRIDE { view.setTitle(QString::fromStdString(title)); }
void setMouseCallback(MouseCallback onMouse, void* userdata /*= 0*/) CV_OVERRIDE {}
std::shared_ptr<UITrackbar> createTrackbar(
const std::string& name,
int count,
TrackbarCallback onChange /*= 0*/,
void* userdata /*= 0*/
) CV_OVERRIDE
{
return std::shared_ptr<UITrackbar>{};
}
std::shared_ptr<UITrackbar> findTrackbar(const std::string& name) CV_OVERRIDE { return std::shared_ptr<UITrackbar>{}; }
private:
const std::string name;
CVQuickView view;
};
class QtQuickBackend : public cv::highgui_backend::UIBackend
{
public:
QtQuickBackend()
: cv::highgui_backend::UIBackend()
, engine()
, component(&engine)
{
engine.addImportPath(qApp->applicationDirPath() + "\\qml");
component.loadFromModule("CvQuick", "CvItem");
}
~QtQuickBackend() CV_OVERRIDE
{
destroyAllWindows();
}
static bool isCVQuickView(QWindow* w)
{
return w->metaObject()->className() == QString("CVQuickView");
}
void destroyAllWindows() CV_OVERRIDE
{
bool isWidgetDeleted = true;
while (isWidgetDeleted)
{
isWidgetDeleted = false;
const QWindowList list = qApp->topLevelWindows();
for (auto const w : list)
{
if (isCVQuickView(w))
{
w->setVisibility(QWindow::Hidden);
w->close();
isWidgetDeleted = true;
break;
}
}
}
}
// namedWindow
virtual std::shared_ptr<UIWindow> createWindow(
const std::string& winname,
int flags
) CV_OVERRIDE
{
return std::make_shared<QtQuickWindow>(winname, flags, engine, component);
}
int waitKeyEx(int delay) CV_OVERRIDE
{
CVQuickView::last_key = -1;
if (delay == 0)
{
while (CVQuickView::last_key == -1)
{
QGuiApplication::processEvents(QEventLoop::WaitForMoreEvents);
}
if (CVQuickView::last_key == -2)
{
CVQuickView::last_key = -1;
}
}
else
{
QGuiApplication::processEvents();
}
return CVQuickView::last_key;
}
int pollKey() CV_OVERRIDE
{
return waitKeyEx(1);
}
private:
QQmlEngine engine;
QQmlComponent component;
};
static
std::shared_ptr<QtQuickBackend>& getInstance()
{
static int argc = 1;
static char arg0[] = "";
static char* argv[] = { arg0 };
static QGuiApplication app(argc, argv);
static std::shared_ptr<QtQuickBackend> g_instance = std::make_shared<QtQuickBackend>();
return g_instance;
}
}
}
static
CvResult cv_getInstance(CV_OUT CvPluginUIBackend* handle) CV_NOEXCEPT
{
try
{
if (!handle)
return CV_ERROR_FAIL;
*handle = cv::impl::getInstance().get();
return CV_ERROR_OK;
}
catch (...)
{
return CV_ERROR_FAIL;
}
}
static const OpenCV_UI_Plugin_API plugin_api =
{
{
sizeof(OpenCV_UI_Plugin_API), ABI_VERSION, API_VERSION,
CV_VERSION_MAJOR, CV_VERSION_MINOR, CV_VERSION_REVISION, CV_VERSION_STATUS,
"QtQuick OpenCV UI plugin"
},
{
/* 1*/cv_getInstance
}
};
const OpenCV_UI_Plugin_API* CV_API_CALL opencv_ui_plugin_init_v0(int requested_abi_version, int requested_api_version, void* /*reserved=NULL*/) CV_NOEXCEPT
{
if (requested_abi_version == ABI_VERSION && requested_api_version <= API_VERSION)
return &plugin_api;
return NULL;
}
こうしてできたdllをプラグインが有効なOpenCVに読み込ませるには環境変数を使います。
適当な方法でOPENCV_UI_PLUGIN_WIN32=C:\path\to\plugin\plugin.dll
としておけば本来のWIN32プラグインの代わりに読み込まれます。本来のQTバックエンドはプラグイン化されていないためOPENCV_UI_PLUGIN_QT
はNG。
Qtを使ったのでwindeployqtでdllもろもろを配置。
.\windeployqt.exe --release --dir C:\path\to\exe C:\path\to\exe\hdr.exe C:\path\to\plugin\plugin.dll
5. HDR画像の用意と読み込み
今回使うのはLightroomで書き出したゲインマップ付きHDR JPEG画像です。
HDR画像からSDRにトーンマッピングしたベース画像と、ベース画像から元のHDR画像を逆算するためのゲインマップがマルチピクチャフォーマットで1つのJPEGファイルに記録されています。対応環境では読み込み時に表示能力に合わせてHDRへのトーンマッピングを行いHDR画像を復元、あるいはゲインマップを認識できない環境ではSDR画像だけがデコードされ、互換性が保たれる仕組みになっています。
GoogleがUltra HDRと言っているものがほぼ同じもので、libultrahdrが公開されています。
これを使ってHDR画像を読み込めればよかったのですが、libultrahdrの実装はクロマサブサンプリングがYUV420限定、ゲインマップの解像度はベース画像に対して縦横それぞれ1/4と決め打ちになっており、YUV444で解像度が同じLightroomのHDR出力とは互換性がありません。仕方ないので自前で実装しましょう。sRGBのベース画像にゲインマップを適用してscRGBなcv::Matを返します。libultrahdrを部分的に活用しています。ベース画像とゲインマップを取り出す関数はprivateメンバ関数なので何とかして呼び出します。decompressImageは失敗しますがメタデータは取得できます。
// https://akinomyoga.hatenablog.com/entry/2019/12/07/171819
template<typename MemPtr, typename Tag>
struct accessor {
inline static MemPtr ptr;
template<MemPtr mp> struct init { inline static auto dummy = ptr = mp; };
};
using JpegR_extractPrimaryImageAndGainMap = accessor<ultrahdr::status_t(ultrahdr::JpegR::*)(ultrahdr::jr_compressed_ptr, ultrahdr::jr_compressed_ptr, ultrahdr::jr_compressed_ptr), struct tag_a>;
template struct JpegR_extractPrimaryImageAndGainMap::init<&ultrahdr::JpegR::extractPrimaryImageAndGainMap>;
template <typename T>
auto xmp_get_attr(boost::property_tree::ptree &pt, const std::string &attr, const T default_value)
{
return pt.get("x:xmpmeta.rdf:RDF.rdf:Description.<xmlattr>." + attr, default_value);
}
template <typename T>
auto xmp_get_data(boost::property_tree::ptree &pt, const std::string &name, const T default_value)
{
boost::property_tree::ptree empty;
std::array<T, 3> data;
int i = 0;
for (auto& c : pt.get_child("x:xmpmeta.rdf:RDF.rdf:Description." + name + ".rdf:Seq", empty))
{
data[i++] = c.second.get_value(default_value);
}
if (i == 0)
{
data[i++] = pt.get("x:xmpmeta.rdf:RDF.rdf:Description.<xmlattr>." + name, default_value);
}
if (i == 1)
{
data[2] = data[1] = data[0];
}
return std::initializer_list{ data[0], data[1], data[2] };
}
cv::Mat hdrread(const std::string fn)
{
using namespace ultrahdr;
JpegR jpegHdr;
jpegr_compressed_struct jpegImageR{};
std::ifstream ifs(fn, std::ios::binary | std::ios::ate);
std::streampos fileSize = ifs.tellg();
ifs.seekg(0, std::ios::beg);
std::vector<char> loadData(fileSize);
ifs.read(loadData.data(), fileSize);
jpegImageR.data = loadData.data();
jpegImageR.length = fileSize;
jpegImageR.maxLength = fileSize;
jpegImageR.colorGamut = ULTRAHDR_COLORGAMUT_BT709;
jpegr_compressed_struct primary_jpeg_image, gainmap_jpeg_image;
status_t status = (jpegHdr.*JpegR_extractPrimaryImageAndGainMap::ptr)(&jpegImageR, &primary_jpeg_image, &gainmap_jpeg_image);
if (status == JPEGR_NO_ERROR)
{
JpegDecoderHelper primary_decoder;
primary_decoder.decompressImage(primary_jpeg_image.data, primary_jpeg_image.length);
std::vector<uint8_t> buf;
ultrahdr_color_gamut primary_gamut;
if (const size_t size = primary_decoder.getICCSize(); size != 0)
{
primary_gamut = IccHelper::readIccColorGamut(primary_decoder.getICCPtr(), primary_decoder.getICCSize());
}
JpegDecoderHelper gainmap_decoder;
gainmap_decoder.decompressImage(gainmap_jpeg_image.data, gainmap_jpeg_image.length);
if (const size_t size = gainmap_decoder.getXMPSize(); size != 0)
{
buf.resize(size);
memcpy(&buf[0], gainmap_decoder.getXMPPtr(), size);
ultrahdr_metadata_struct uhdr_metadata;
std::string gmxmp(std::find(buf.begin(), buf.end(), '\0') + 1, buf.end());
using namespace boost::property_tree;
ptree pt;
std::istringstream iss(gmxmp);
read_xml(iss, pt, xml_parser::no_comments);
const std::string version = xmp_get_attr(pt, "hdrgm:Version", "1.0");
const bool baseRenditionIsHDR = xmp_get_attr(pt, "hdrgm:BaseRenditionIsHDR", false);
const cv::Vec3f gainMapMax = xmp_get_data(pt, "hdrgm:GainMapMax", 1.0f);
const cv::Vec3f gainMapMin = xmp_get_data(pt, "hdrgm:GainMapMin", 0.0f);
const cv::Vec3f gamma = xmp_get_data(pt, "hdrgm:Gamma", 1.0f);
const cv::Vec3f offsetSDR = xmp_get_data(pt, "hdrgm:OffsetSDR", 1.0f / 64.0f);
const cv::Vec3f offsetHDR = xmp_get_data(pt, "hdrgm:OffsetHDR", 1.0f / 64.0f);
const float HDRCapacityMin = xmp_get_attr(pt, "hdrgm:HDRCapacityMin", 0.0f);
const float HDRCapacityMax = xmp_get_attr(pt, "hdrgm:HDRCapacityMax", 1.0f);
const float H = 3.0f;
const float F = std::clamp((H - HDRCapacityMin) / (HDRCapacityMax - HDRCapacityMin), 0.0f, 1.0f);
const float W = baseRenditionIsHDR ? F - 1.0f : F;
cv::Mat pmat = cv::imdecode(cv::Mat_<uint8_t>(1, primary_jpeg_image.length, static_cast<uint8_t*>(primary_jpeg_image.data)), cv::IMREAD_UNCHANGED);
cv::Mat gmat = cv::imdecode(cv::Mat_<uint8_t>(1, gainmap_jpeg_image.length, static_cast<uint8_t*>(gainmap_jpeg_image.data)), cv::IMREAD_UNCHANGED);
cv::Mat3f pmatf, gmatf, dmatf(pmat.size());
pmat.convertTo(pmatf, CV_32FC3, 1.0 / 255.0);
gmat.convertTo(gmatf, CV_32FC3, 1.0 / 255.0);
if (gmatf.size() != pmatf.size())
{
cv::resize(gmatf, gmatf, pmatf.size(), 0.0, 0.0, cv::INTER_LANCZOS4);
}
std::transform(pmatf.begin(), pmatf.end(), gmatf.begin(), dmatf.begin(),
[&](const cv::Vec3f& p, const cv::Vec3f& g)
{
using namespace ultrahdr;
cv::Vec3f d;
for (int i = 0; i < 3; ++i)
{
const float lc = srgbInvOetf(p[2 - i]);
const float lg = std::pow(g[2 - i], 1.0f / gamma[i]);
const float glog2 = std::lerp(gainMapMin[i], gainMapMax[i], lg);
const float& offsetBase = baseRenditionIsHDR ? offsetHDR[i] : offsetSDR[i];
const float& offsetOther = baseRenditionIsHDR ? offsetSDR[i] : offsetHDR[i];
d[2 - i] = (lc + offsetBase) * exp2(glog2 * W) - offsetOther;
}
return d;
}
);
return dmatf;
}
}
return cv::Mat{};
}
const float H = 3.0f;
となっている箇所に注意してください。本来は表示環境の最大輝度とSDRの最大輝度の比のlog2を指定します。hdrinfo.limits.luminanceInNits.maxLuminance
を活用するところなのですが、getPropertyを実装するのが面倒だったので決め打ちになっています。例えばDisplayHDR400なら400nits強なのでlog2(400/100) = 2となります。
6. 完成
int main(int argc, char *argv[])
{
if(argc != 2)
{
return 1;
}
cv::Mat img = hdrread(argv[1]);
if(img.empty())
{
return 2;
}
cv::imshow("hdr", img);
cv::waitKey();
return 0;
}
明るい所がSDRの白より明るくてChromeと比べて大体同じならOKです。上の画像はSDR環境でも言ってることが伝わりやすいようにHDRでキャプチャしてSDRにトーンマッピングしてあります。
後は自分でHDR画像を現像するもよし、Adobeのゲインマップのページにあるサンプル画像をダウンロードして試すもよし。
追記:公開記事で画像が自動リサイズされるとゲインマップが消されてしまいSDR画像になってしまうようです。ChromeでHDR画像が表示できる環境であればクリックしてオリジナル画像を表示してみてください。
7. まとめ
とりあえずLightroomで出力したHDRなゲインマップ付きJPEGをOpenCVで弄って表示できるところまではたどり着けました。PNGやAVIFは別の方法2でHDRに対応しているため、そちらを使う場合はまたひと工夫必要になります。
実はQTバックエンドに使われているQOpenGLWidgetのQRhi版、QRhiWidgetがQt 6.7.0からTechnology Previewとして入り、Qt WidgetでもHDRを扱えるようになるみたいです。描画部分は全面的に書き換えが必要ですが、Qt QuickでQTバックエンドと同等に実装するよりは楽かもしれませんね。