From 2c9a6b64d86e5608c6d92051fc269eceff0bf345 Mon Sep 17 00:00:00 2001 From: Zach Lewis Date: Mon, 3 Aug 2026 21:22:21 -0400 Subject: [PATCH 1/3] feat(apv): add APV (Advanced Professional Video) reader and writer Add an apv.imageio plugin using the OpenAPV library (https://github.com/openapv/openapv, BSD-3-Clause). APV is an all-intra professional video codec, so a raw .apv bitstream is naturally addressable as a sequence of individual images: the reader exposes one subimage per access unit (decoding its primary frame), and the writer emits one access unit per subimage, so appending subimages produces a valid multi-frame bitstream. - 10/12-bit YCbCr 4:2:2 / 4:4:4 / 4:4:4:4 (alpha) / 4:0:0 presented as full-range uint16 RGB(A)/grayscale, converted with the matrix coefficients signaled in the bitstream (709/601/2020). - CICP round trip: bitstream color descriptions surface as the CICP attribute (and oiio:ColorSpace interop ID when known); a CICP attribute on output is signaled in the bitstream. - Output controls: apv:profile (default 422-10 / 4444-10 / 400-10 by channel count), apv:qp, apv:bitrate, apv:preset, FramesPerSecond. - FindAPV.cmake module (OpenAPV installs into lib/oapv and needs OAPV_STATIC_DEFINE for static libs), build_APV.cmake local-build recipe with pinned tag+commit, testsuite/apv round-trip tests, and builtinplugins.rst documentation. Auxiliary frames within an access unit (preview, depth, alpha, non-primary) are not yet exposed; chroma resampling is simple co-sited linear. Assisted-by: Claude Code (Fable 5) Signed-off-by: Zach Lewis --- src/apv.imageio/CMakeLists.txt | 12 + src/apv.imageio/apv_pvt.h | 155 ++++++++++ src/apv.imageio/apvinput.cpp | 420 +++++++++++++++++++++++++++ src/apv.imageio/apvoutput.cpp | 398 +++++++++++++++++++++++++ src/cmake/build_APV.cmake | 45 +++ src/cmake/externalpackages.cmake | 5 + src/cmake/modules/FindAPV.cmake | 55 ++++ src/cmake/testing.cmake | 2 + src/doc/builtinplugins.rst | 69 +++++ src/libOpenImageIO/imageioplugin.cpp | 4 + testsuite/apv/ref/out.txt | 60 ++++ testsuite/apv/run.py | 41 +++ 12 files changed, 1266 insertions(+) create mode 100644 src/apv.imageio/CMakeLists.txt create mode 100644 src/apv.imageio/apv_pvt.h create mode 100644 src/apv.imageio/apvinput.cpp create mode 100644 src/apv.imageio/apvoutput.cpp create mode 100644 src/cmake/build_APV.cmake create mode 100644 src/cmake/modules/FindAPV.cmake create mode 100644 testsuite/apv/ref/out.txt create mode 100644 testsuite/apv/run.py diff --git a/src/apv.imageio/CMakeLists.txt b/src/apv.imageio/CMakeLists.txt new file mode 100644 index 0000000000..afd4eed5f0 --- /dev/null +++ b/src/apv.imageio/CMakeLists.txt @@ -0,0 +1,12 @@ +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +if (APV_FOUND) + add_oiio_plugin (apvinput.cpp apvoutput.cpp + INCLUDE_DIRS ${APV_INCLUDES} + LINK_LIBRARIES ${APV_LIBRARIES} + DEFINITIONS "USE_APV" ${APV_DEFINITIONS}) +else () + message (WARNING "APV plugin will not be built") +endif () diff --git a/src/apv.imageio/apv_pvt.h b/src/apv.imageio/apv_pvt.h new file mode 100644 index 0000000000..b81178c49f --- /dev/null +++ b/src/apv.imageio/apv_pvt.h @@ -0,0 +1,155 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +// Shared helpers for the APV (OpenAPV) reader and writer. + +#pragma once + +#include + +#include + +OIIO_PLUGIN_NAMESPACE_BEGIN + +namespace apv_pvt { + +// Raw APV bitstream files are a sequence of [4-byte big-endian AU size] +// [access unit]. Each AU begins with the 4-byte signature "aPv1". +inline constexpr unsigned char apv_signature[4] = { 0x61, 0x50, 0x76, 0x31 }; + +// Luma coefficients for the YCbCr matrices we support, by CICP +// MatrixCoefficients code (H.273). Returns false for codes we don't +// handle (callers should fall back to BT.709). +inline bool +matrix_luma_coefficients(int matrix_coefficients, float& kr, float& kb) +{ + switch (matrix_coefficients) { + case 1: // BT.709 + kr = 0.2126f; + kb = 0.0722f; + return true; + case 5: // BT.601 (625) + case 6: // BT.601 (525) + kr = 0.299f; + kb = 0.114f; + return true; + case 9: // BT.2020 non-constant luminance + case 10: // BT.2020 constant luminance (treated as NCL here) + kr = 0.2627f; + kb = 0.0593f; + return true; + default: return false; + } +} + + + +// Number of image channels our plugin exposes for an APV color format. +// Returns 0 for formats we do not support. +inline int +channels_for_format(int color_format) +{ + switch (color_format) { + case OAPV_CF_YCBCR400: return 1; + case OAPV_CF_YCBCR420: + case OAPV_CF_YCBCR422: + case OAPV_CF_YCBCR444: return 3; + case OAPV_CF_YCBCR4444: return 4; + default: return 0; + } +} + + + +// Scale an n-bit code value up to the full 16-bit range with bit +// replication (the same convention other OIIO readers use for 10/12 bit +// media). +inline uint16_t +scale_to_16bits(uint32_t v, int bits) +{ return uint16_t((v << (16 - bits)) | (v >> (2 * bits - 16))); } + + + +// Minimal oapv_imgb allocator, mirroring the reference application: the +// library does not export one, callers own the frame buffers. Plane +// dimensions are macroblock aligned as the library requires. + +inline int +imgb_addref(oapv_imgb_t* imgb) +{ return ++imgb->refcnt; } + +inline int +imgb_getref(oapv_imgb_t* imgb) +{ return imgb->refcnt; } + +inline int +imgb_release(oapv_imgb_t* imgb) +{ + int refcnt = --imgb->refcnt; + if (refcnt == 0) { + for (int i = 0; i < OAPV_MAX_CC; i++) + free(imgb->baddr[i]); + free(imgb); + } + return refcnt; +} + +inline oapv_imgb_t* +imgb_create(int w, int h, int cs) +{ + oapv_imgb_t* imgb = (oapv_imgb_t*)calloc(1, sizeof(oapv_imgb_t)); + if (!imgb) + return nullptr; + int bd = OAPV_CS_GET_BYTE_DEPTH(cs); + imgb->w[0] = w; + imgb->h[0] = h; + switch (OAPV_CS_GET_FORMAT(cs)) { + case OAPV_CF_YCBCR400: imgb->np = 1; break; + case OAPV_CF_YCBCR420: + imgb->w[1] = imgb->w[2] = (w + 1) >> 1; + imgb->h[1] = imgb->h[2] = (h + 1) >> 1; + imgb->np = 3; + break; + case OAPV_CF_YCBCR422: + imgb->w[1] = imgb->w[2] = (w + 1) >> 1; + imgb->h[1] = imgb->h[2] = h; + imgb->np = 3; + break; + case OAPV_CF_YCBCR444: + imgb->w[1] = imgb->w[2] = w; + imgb->h[1] = imgb->h[2] = h; + imgb->np = 3; + break; + case OAPV_CF_YCBCR4444: + imgb->w[1] = imgb->w[2] = imgb->w[3] = w; + imgb->h[1] = imgb->h[2] = imgb->h[3] = h; + imgb->np = 4; + break; + default: free(imgb); return nullptr; + } + for (int i = 0; i < imgb->np; i++) { + imgb->aw[i] = ((imgb->w[i] + OAPV_MB_W - 1) / OAPV_MB_W) * OAPV_MB_W; + imgb->ah[i] = ((imgb->h[i] + OAPV_MB_H - 1) / OAPV_MB_H) * OAPV_MB_H; + imgb->s[i] = imgb->aw[i] * bd; + imgb->e[i] = imgb->ah[i]; + imgb->bsize[i] = imgb->s[i] * imgb->e[i]; + imgb->a[i] = imgb->baddr[i] = calloc(1, imgb->bsize[i]); + if (!imgb->a[i]) { + for (int j = 0; j < i; j++) + free(imgb->baddr[j]); + free(imgb); + return nullptr; + } + } + imgb->cs = cs; + imgb->addref = imgb_addref; + imgb->getref = imgb_getref; + imgb->release = imgb_release; + imgb->addref(imgb); + return imgb; +} + +} // namespace apv_pvt + +OIIO_PLUGIN_NAMESPACE_END diff --git a/src/apv.imageio/apvinput.cpp b/src/apv.imageio/apvinput.cpp new file mode 100644 index 0000000000..7447bfbf61 --- /dev/null +++ b/src/apv.imageio/apvinput.cpp @@ -0,0 +1,420 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +// APV (Advanced Professional Video) reader, using the OpenAPV library. +// +// https://github.com/openapv/openapv +// +// APV is an all-intra professional video codec: every frame is coded +// independently, so a raw .apv bitstream is naturally addressable as a +// sequence of individual images. We expose one subimage per access unit +// (AU), decoding its primary frame. Auxiliary frames within an AU +// (preview, depth, alpha, non-primary) are not currently exposed. + +#include +#include +#include + +#include +#include +#include +#include + +#include "apv_pvt.h" + +OIIO_PLUGIN_NAMESPACE_BEGIN + +using namespace apv_pvt; + +class ApvInput final : public ImageInput { +public: + ApvInput() { init(); } + ~ApvInput() override { close(); } + const char* format_name(void) const override { return "apv"; } + int supports(string_view feature) const override + { return feature == "ioproxy"; } + bool valid_file(Filesystem::IOProxy* ioproxy) const override; + bool open(const std::string& name, ImageSpec& newspec) override; + bool open(const std::string& name, ImageSpec& newspec, + const ImageSpec& config) override; + int current_subimage(void) const override + { + lock_guard lock(*this); + return m_subimage; + } + bool seek_subimage(int subimage, int miplevel) override; + bool read_native_scanline(int subimage, int miplevel, int y, int z, + void* data) override; + bool close() override; + +private: + struct AuIndexEntry { + int64_t offset; // file offset of the AU payload (past the size field) + uint32_t size; // byte size of the AU payload + }; + + std::string m_filename; + std::vector m_au_index; + oapvd_t m_decoder; + int m_subimage; // Current subimage (== AU) index + std::vector m_pixels; // Decoded, converted RGB(A)/Y pixels + std::vector m_au; // Raw bytes of the current AU + + void init() + { + ioproxy_clear(); + m_filename.clear(); + m_au_index.clear(); + m_decoder = nullptr; + m_subimage = -1; + m_pixels.clear(); + m_au.clear(); + } + + // Read the 4-byte big-endian AU size fields to build the AU index. + bool index_aus(Filesystem::IOProxy* io); + // Decode AU number `subimage` and fill m_spec and m_pixels. + bool decode_au(int subimage); +}; + + + +// Obligatory material to make this a recognizable imageio plugin: +OIIO_PLUGIN_EXPORTS_BEGIN + +OIIO_EXPORT ImageInput* +apv_input_imageio_create() +{ return new ApvInput; } + +OIIO_EXPORT int apv_imageio_version = OIIO_PLUGIN_VERSION; + +OIIO_EXPORT const char* +apv_imageio_library_version() +{ + return "OpenAPV " OIIO_STRINGIZE(OAPV_VER_APISET) "." OIIO_STRINGIZE( + OAPV_VER_MAJOR) "." OIIO_STRINGIZE(OAPV_VER_MINOR) "." OIIO_STRINGIZE(OAPV_VER_PATCH); +} + +OIIO_EXPORT const char* apv_input_extensions[] = { "apv", nullptr }; + +OIIO_PLUGIN_EXPORTS_END + + + +bool +ApvInput::valid_file(Filesystem::IOProxy* ioproxy) const +{ + // A raw APV bitstream starts with a 4-byte AU size followed by the + // 4-byte AU signature "aPv1". + if (!ioproxy || ioproxy->mode() != Filesystem::IOProxy::Read) + return false; + unsigned char header[8]; + if (ioproxy->pread(header, sizeof(header), 0) != sizeof(header)) + return false; + return memcmp(header + 4, apv_signature, sizeof(apv_signature)) == 0; +} + + + +bool +ApvInput::open(const std::string& name, ImageSpec& newspec, + const ImageSpec& config) +{ + ioproxy_retrieve_from_config(config); + return open(name, newspec); +} + + + +bool +ApvInput::open(const std::string& name, ImageSpec& newspec) +{ + m_filename = name; + if (!ioproxy_use_or_open(name)) + return false; + Filesystem::IOProxy* io = ioproxy(); + if (!valid_file(io)) { + errorfmt("\"{}\" is not an APV bitstream", name); + close(); + return false; + } + if (!index_aus(io)) { + close(); + return false; + } + + oapvd_cdesc_t cdesc; + memset(&cdesc, 0, sizeof(cdesc)); + cdesc.threads = OAPV_CDESC_THREADS_AUTO; + int err = OAPV_OK; + m_decoder = oapvd_create(&cdesc, &err); + if (m_decoder == nullptr) { + errorfmt("Could not create APV decoder (error {})", err); + close(); + return false; + } + + if (!seek_subimage(0, 0)) { + close(); + return false; + } + newspec = m_spec; + return true; +} + + + +bool +ApvInput::index_aus(Filesystem::IOProxy* io) +{ + int64_t pos = 0; + const int64_t size = io->size(); + while (pos + 4 <= size) { + unsigned char szbuf[4]; + if (io->pread(szbuf, 4, pos) != 4) { + errorfmt("Truncated AU size field at byte {}", pos); + return false; + } + uint32_t au_size = (uint32_t(szbuf[0]) << 24) + | (uint32_t(szbuf[1]) << 16) + | (uint32_t(szbuf[2]) << 8) | uint32_t(szbuf[3]); + pos += 4; + if (au_size == 0 || pos + au_size > size) { + errorfmt("Corrupt AU size {} at byte {}", au_size, pos - 4); + return false; + } + m_au_index.push_back({ pos, au_size }); + pos += au_size; + } + if (m_au_index.empty()) { + errorfmt("No access units found"); + return false; + } + return true; +} + + + +bool +ApvInput::seek_subimage(int subimage, int miplevel) +{ + if (miplevel != 0 || subimage < 0 || subimage >= (int)m_au_index.size()) + return false; + if (subimage == m_subimage) + return true; + if (!decode_au(subimage)) + return false; + m_subimage = subimage; + return true; +} + + + +bool +ApvInput::decode_au(int subimage) +{ + Filesystem::IOProxy* io = ioproxy(); + const AuIndexEntry& au = m_au_index[subimage]; + m_au.resize(au.size); + if (io->pread(m_au.data(), au.size, au.offset) != au.size) { + errorfmt("Could not read AU {}", subimage); + return false; + } + + oapv_au_info_t aui; + if (oapvd_info(m_au.data(), (int)au.size, &aui) != OAPV_OK) { + errorfmt("Could not parse AU {}", subimage); + return false; + } + if (aui.num_frms <= 0 || aui.num_frms > OAPV_MAX_NUM_FRAMES) { + errorfmt("AU {} has invalid frame count {}", subimage, aui.num_frms); + return false; + } + + // Find the primary frame in this AU. + int primary = -1; + for (int i = 0; i < aui.num_frms; i++) { + if (aui.frm_info[i].pbu_type == OAPV_PBU_TYPE_PRIMARY_FRAME) { + primary = i; + break; + } + } + if (primary < 0) + primary = 0; // fall back to the first frame + const oapv_frm_info_t& finfo = aui.frm_info[primary]; + + const int fmt = OAPV_CS_GET_FORMAT(finfo.cs); + const int bits = OAPV_CS_GET_BIT_DEPTH(finfo.cs); + const int channels = channels_for_format(fmt); + if (channels == 0 || bits < 10 || bits > 16) { + errorfmt("Unsupported APV color format ({}) or bit depth ({})", fmt, + bits); + return false; + } + + // Decode the AU. The decoder requires caller-allocated buffers for + // every frame in the AU, not just the one we want. + oapv_frms_t ofrms; + memset(&ofrms, 0, sizeof(ofrms)); + ofrms.num_frms = aui.num_frms; + for (int i = 0; i < aui.num_frms; i++) { + ofrms.frm[i].imgb = imgb_create(aui.frm_info[i].w, aui.frm_info[i].h, + aui.frm_info[i].cs); + if (!ofrms.frm[i].imgb) { + for (int j = 0; j < i; j++) + ofrms.frm[j].imgb->release(ofrms.frm[j].imgb); + errorfmt("Could not allocate frame buffers for AU {}", subimage); + return false; + } + } + oapv_bitb_t bitb; + memset(&bitb, 0, sizeof(bitb)); + bitb.addr = m_au.data(); + bitb.bsize = (int)au.size; + bitb.ssize = (int)au.size; + oapvd_stat_t stat; + memset(&stat, 0, sizeof(stat)); + int ret = oapvd_decode(m_decoder, &bitb, &ofrms, nullptr, &stat); + if (ret != OAPV_OK) { + for (int i = 0; i < ofrms.num_frms; i++) + ofrms.frm[i].imgb->release(ofrms.frm[i].imgb); + errorfmt("APV decode failed for AU {} (error {})", subimage, ret); + return false; + } + + // Build the spec for this subimage. + m_spec = ImageSpec(finfo.w, finfo.h, channels, TypeDesc::UINT16); + m_spec.attribute("oiio:BitsPerSample", bits); + m_spec.attribute("apv:profile", finfo.profile_idc); + m_spec.attribute("apv:level", finfo.level_idc); + // N.B. Take the color description from the decode's frame info, not + // from oapvd_info(): OpenAPV's lightweight header probe misparses the + // color description fields (verified against its own reference + // encoder; the full decoder parses them correctly). + const oapv_frm_info_t& dinfo = stat.aui.frm_info[primary]; + float kr = 0.2126f, kb = 0.0722f; // default BT.709 + bool full_range = false; + if (dinfo.color_description_present_flag) { + matrix_luma_coefficients(dinfo.matrix_coefficients, kr, kb); + full_range = dinfo.full_range_flag; + // The pixels we return are RGB (matrix 0) and full range after + // conversion; the primaries and transfer carry over unchanged. + const int cicp[4] = { dinfo.color_primaries, + dinfo.transfer_characteristics, 0 /* RGB */, + 1 /* full range */ }; + m_spec.attribute("CICP", TypeDesc(TypeDesc::INT, 4), cicp); + const ColorConfig& colorconfig(ColorConfig::default_colorconfig()); + string_view interop_id = colorconfig.get_color_interop_id(cicp); + if (!interop_id.empty()) + m_spec.attribute("oiio:ColorSpace", interop_id); + m_spec.attribute("apv:matrix_coefficients", + (int)dinfo.matrix_coefficients); + m_spec.attribute("apv:full_range_flag", dinfo.full_range_flag); + } + + // Convert the primary frame to interleaved RGB(A)/Y uint16 pixels. + const oapv_imgb_t* imgb = ofrms.frm[primary].imgb; + const int w = finfo.w, h = finfo.h; + const float maxval = float((1 << bits) - 1); + const float lo = full_range ? 0.0f : float(16 << (bits - 8)); + const float yrange = full_range ? maxval : float(219 << (bits - 8)); + const float crange = full_range ? maxval : float(224 << (bits - 8)); + const float chalf = float(1 << (bits - 1)); + const float kg = 1.0f - kr - kb; + const int subx = (fmt == OAPV_CF_YCBCR420 || fmt == OAPV_CF_YCBCR422); + const int suby = (fmt == OAPV_CF_YCBCR420); + + m_pixels.resize(size_t(w) * h * channels); + auto plane = [&](int p) { + return reinterpret_cast(imgb->a[p]); + }; + auto pstride = [&](int p) { return imgb->s[p] / 2; }; + + for (int y = 0; y < h; y++) { + uint16_t* dst = m_pixels.data() + size_t(y) * w * channels; + const uint16_t* yrow = plane(0) + size_t(y) * pstride(0); + if (channels == 1) { + for (int x = 0; x < w; x++) { + float Y = (yrow[x] - lo) / yrange; + dst[x] = uint16_t(clamp(Y, 0.0f, 1.0f) * 65535.0f + 0.5f); + } + continue; + } + const int cy = suby ? (y >> 1) : y; + const uint16_t* cbrow = plane(1) + size_t(cy) * pstride(1); + const uint16_t* crrow = plane(2) + size_t(cy) * pstride(2); + const uint16_t* arow = channels == 4 ? plane(3) + size_t(y) * pstride(3) + : nullptr; + const int cw = imgb->w[1]; + for (int x = 0; x < w; x++) { + float cb, cr; + if (subx) { + // Simple co-sited linear interpolation of chroma. + // ponytail: nearest+average; proper resampling filters can + // come later if quality demands. + int cx = x >> 1; + if (x & 1) { + int cx1 = std::min(cx + 1, cw - 1); + cb = 0.5f * (cbrow[cx] + cbrow[cx1]); + cr = 0.5f * (crrow[cx] + crrow[cx1]); + } else { + cb = cbrow[cx]; + cr = crrow[cx]; + } + } else { + cb = cbrow[x]; + cr = crrow[x]; + } + float Y = (yrow[x] - lo) / yrange; + float pb = (cb - chalf) / crange; + float pr = (cr - chalf) / crange; + float r = Y + 2.0f * (1.0f - kr) * pr; + float b = Y + 2.0f * (1.0f - kb) * pb; + float g = (Y - kr * r - kb * b) / kg; + uint16_t* px = dst + size_t(x) * channels; + px[0] = uint16_t(clamp(r, 0.0f, 1.0f) * 65535.0f + 0.5f); + px[1] = uint16_t(clamp(g, 0.0f, 1.0f) * 65535.0f + 0.5f); + px[2] = uint16_t(clamp(b, 0.0f, 1.0f) * 65535.0f + 0.5f); + if (arow) // alpha is carried full range + px[3] = scale_to_16bits(arow[x], bits); + } + } + + for (int i = 0; i < ofrms.num_frms; i++) + ofrms.frm[i].imgb->release(ofrms.frm[i].imgb); + return true; +} + + + +bool +ApvInput::read_native_scanline(int subimage, int miplevel, int y, int /*z*/, + void* data) +{ + lock_guard lock(*this); + if (!seek_subimage(subimage, miplevel)) + return false; + if (y < 0 || y >= m_spec.height) + return false; + size_t scanline_bytes = size_t(m_spec.width) * m_spec.nchannels + * sizeof(uint16_t); + memcpy(data, m_pixels.data() + size_t(y) * m_spec.width * m_spec.nchannels, + scanline_bytes); + return true; +} + + + +bool +ApvInput::close() +{ + if (m_decoder) { + oapvd_delete(m_decoder); + m_decoder = nullptr; + } + init(); + return true; +} + +OIIO_PLUGIN_NAMESPACE_END diff --git a/src/apv.imageio/apvoutput.cpp b/src/apv.imageio/apvoutput.cpp new file mode 100644 index 0000000000..f3f78395f1 --- /dev/null +++ b/src/apv.imageio/apvoutput.cpp @@ -0,0 +1,398 @@ +// Copyright Contributors to the OpenImageIO project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/AcademySoftwareFoundation/OpenImageIO + +// APV (Advanced Professional Video) writer, using the OpenAPV library. +// +// Each subimage is encoded as one access unit (AU) containing a single +// primary frame, so appending subimages produces a valid multi-frame APV +// bitstream. + +#include +#include +#include + +#include +#include +#include + +#include "apv_pvt.h" + +OIIO_PLUGIN_NAMESPACE_BEGIN + +using namespace apv_pvt; + +class ApvOutput final : public ImageOutput { +public: + ApvOutput() { init(); } + ~ApvOutput() override { close(); } + const char* format_name(void) const override { return "apv"; } + int supports(string_view feature) const override + { + return (feature == "alpha" || feature == "multiimage" + || feature == "appendsubimage" || feature == "ioproxy"); + } + bool open(const std::string& name, const ImageSpec& spec, + OpenMode mode = Create) override; + bool write_scanline(int y, int z, TypeDesc format, const void* data, + stride_t xstride) override; + bool write_scanlines(int ybegin, int yend, int z, TypeDesc format, + const void* data, stride_t xstride, + stride_t ystride) override; + bool close() override; + +private: + std::string m_filename; + std::vector m_staging; // Interleaved float pixels for one frame + int m_color_format; // OAPV_CF_* for the frame being written + int m_bits; // encoded bit depth + int m_profile_idc; + bool m_dirty; // any scanlines written since open? + + void init() + { + ioproxy_clear(); + m_filename.clear(); + m_staging.clear(); + m_color_format = OAPV_CF_UNKNOWN; + m_bits = 0; + m_profile_idc = 0; + m_dirty = false; + } + + bool setup_profile(); + bool finalize_subimage(); +}; + + + +// Obligatory material to make this a recognizable imageio plugin: +OIIO_PLUGIN_EXPORTS_BEGIN + +OIIO_EXPORT ImageOutput* +apv_output_imageio_create() +{ return new ApvOutput; } + +OIIO_EXPORT const char* apv_output_extensions[] = { "apv", nullptr }; + +OIIO_PLUGIN_EXPORTS_END + + + +// Map a profile name to (profile_idc, color format, bit depth). +// The supported profile names match OpenAPV's own option strings. +struct ApvProfileDef { + const char* name; + int idc; + int color_format; + int bits; +}; +static const ApvProfileDef apv_profiles[] = { + { "422-10", OAPV_PROFILE_422_10, OAPV_CF_YCBCR422, 10 }, + { "422-12", OAPV_PROFILE_422_12, OAPV_CF_YCBCR422, 12 }, + { "444-10", OAPV_PROFILE_444_10, OAPV_CF_YCBCR444, 10 }, + { "444-12", OAPV_PROFILE_444_12, OAPV_CF_YCBCR444, 12 }, + { "4444-10", OAPV_PROFILE_4444_10, OAPV_CF_YCBCR4444, 10 }, + { "4444-12", OAPV_PROFILE_4444_12, OAPV_CF_YCBCR4444, 12 }, + { "400-10", OAPV_PROFILE_400_10, OAPV_CF_YCBCR400, 10 }, +}; + + + +bool +ApvOutput::setup_profile() +{ + string_view profile = m_spec.get_string_attribute("apv:profile", ""); + if (profile.empty()) { + if (m_spec.nchannels == 1) + profile = "400-10"; + else if (m_spec.nchannels == 4) + profile = "4444-10"; + else + profile = "422-10"; + } + for (const auto& p : apv_profiles) { + if (profile == p.name) { + m_profile_idc = p.idc; + m_color_format = p.color_format; + m_bits = p.bits; + const int want = channels_for_format(p.color_format); + if (m_spec.nchannels != want) { + errorfmt( + "APV profile \"{}\" requires {} channels, but image has {}", + profile, want, m_spec.nchannels); + return false; + } + return true; + } + } + errorfmt("Unknown APV profile \"{}\"", profile); + return false; +} + + + +bool +ApvOutput::open(const std::string& name, const ImageSpec& userspec, + OpenMode mode) +{ + if (mode == AppendMIPLevel) { + errorfmt("APV does not support MIP levels"); + return false; + } + if (mode == AppendSubimage) { + if (!finalize_subimage()) + return false; + m_spec = userspec; + if (!setup_profile()) + return false; + m_staging.assign(size_t(m_spec.width) * m_spec.height + * m_spec.nchannels, + 0.0f); + m_dirty = false; + return true; + } + + m_filename = name; + m_spec = userspec; + if (!check_open(mode, userspec, { 0, 1 << 20, 0, 1 << 20, 0, 1, 0, 4 })) + return false; + if (m_spec.depth > 1) { + errorfmt("APV does not support volume images"); + return false; + } + if (!setup_profile()) + return false; + if (!ioproxy_use_or_open(name)) + return false; + m_staging.assign(size_t(m_spec.width) * m_spec.height * m_spec.nchannels, + 0.0f); + m_dirty = false; + return true; +} + + + +bool +ApvOutput::write_scanline(int y, int z, TypeDesc format, const void* data, + stride_t xstride) +{ return write_scanlines(y, y + 1, z, format, data, xstride, AutoStride); } + + + +bool +ApvOutput::write_scanlines(int ybegin, int yend, int z, TypeDesc format, + const void* data, stride_t xstride, stride_t ystride) +{ + if (z != 0 || ybegin < 0 || yend > m_spec.height) { + errorfmt("Scanline range [{}, {}) out of bounds", ybegin, yend); + return false; + } + stride_t pixelsize = stride_t(m_spec.nchannels * format.size()); + if (xstride == AutoStride) + xstride = pixelsize; + if (ystride == AutoStride) + ystride = xstride * m_spec.width; + for (int y = ybegin; y < yend; y++) { + const char* src = (const char*)data + (y - ybegin) * ystride; + float* dst = m_staging.data() + + size_t(y) * m_spec.width * m_spec.nchannels; + OIIO::convert_image(m_spec.nchannels, m_spec.width, 1, 1, src, format, + xstride, AutoStride, AutoStride, dst, + TypeDesc::FLOAT, AutoStride, AutoStride, + AutoStride); + } + m_dirty = true; + return true; +} + + + +bool +ApvOutput::finalize_subimage() +{ + if (!m_dirty) + return true; + + const int w = m_spec.width; + const int h = m_spec.height; + const int channels = m_spec.nchannels; + const int bits = m_bits; + const int cs = OAPV_CS_SET(m_color_format, bits, 0); + + // Color handling: we always encode with BT.709 matrix coefficients. + // If the spec carries a CICP attribute, pass its primaries/transfer + // through to the bitstream's color description and honor its range + // flag; otherwise emit no color description and use limited range. + float kr = 0.2126f, kb = 0.0722f; + int color_present = 0, color_primaries = 2, color_transfer = 2; + int full_range = 0; + if (const ParamValue* p = m_spec.find_attribute("CICP")) { + if (p->type() == TypeDesc(TypeDesc::INT, 4)) { + const int* cicp = (const int*)p->data(); + color_present = 1; + color_primaries = cicp[0]; + color_transfer = cicp[1]; + full_range = cicp[3] ? 1 : 0; + } + } + const float kg = 1.0f - kr - kb; + const float maxval = float((1 << bits) - 1); + const float lo = full_range ? 0.0f : float(16 << (bits - 8)); + const float yrange = full_range ? maxval : float(219 << (bits - 8)); + const float crange = full_range ? maxval : float(224 << (bits - 8)); + const float chalf = float(1 << (bits - 1)); + + // Allocate and fill the input frame. + oapv_imgb_t* imgb = imgb_create(w, h, cs); + if (!imgb) { + errorfmt("Could not allocate APV frame buffer"); + return false; + } + auto plane = [&](int p) { return reinterpret_cast(imgb->a[p]); }; + auto pstride = [&](int p) { return imgb->s[p] / 2; }; + auto quant = [&](float v, float scale, float offset) -> uint16_t { + float q = offset + v * scale; + return uint16_t(clamp(q, 0.0f, maxval) + 0.5f); + }; + + const bool subx = (m_color_format == OAPV_CF_YCBCR422); + for (int y = 0; y < h; y++) { + const float* src = m_staging.data() + size_t(y) * w * channels; + uint16_t* yrow = plane(0) + size_t(y) * pstride(0); + if (channels == 1) { + for (int x = 0; x < w; x++) + yrow[x] = quant(clamp(src[x], 0.0f, 1.0f), yrange, lo); + continue; + } + uint16_t* cbrow = plane(1) + size_t(y) * pstride(1); + uint16_t* crrow = plane(2) + size_t(y) * pstride(2); + uint16_t* arow = channels == 4 ? plane(3) + size_t(y) * pstride(3) + : nullptr; + const int cw = imgb->w[1]; + for (int cx = 0; cx < cw; cx++) { + // For 4:2:2, box-filter each horizontal pair of pixels. + int x0 = subx ? std::min(2 * cx, w - 1) : cx; + int x1 = subx ? std::min(2 * cx + 1, w - 1) : cx; + float r = 0.5f * (src[x0 * channels + 0] + src[x1 * channels + 0]); + float g = 0.5f * (src[x0 * channels + 1] + src[x1 * channels + 1]); + float b = 0.5f * (src[x0 * channels + 2] + src[x1 * channels + 2]); + float Y = kr * r + kg * g + kb * b; + float pb = (b - Y) / (2.0f * (1.0f - kb)); + float pr = (r - Y) / (2.0f * (1.0f - kr)); + cbrow[cx] = quant(pb, crange, chalf); + crrow[cx] = quant(pr, crange, chalf); + } + for (int x = 0; x < w; x++) { + float r = src[x * channels + 0]; + float g = src[x * channels + 1]; + float b = src[x * channels + 2]; + float Y = kr * r + kg * g + kb * b; + yrow[x] = quant(clamp(Y, 0.0f, 1.0f), yrange, lo); + if (arow) + arow[x] = uint16_t( + clamp(src[x * channels + 3], 0.0f, 1.0f) * maxval + 0.5f); + } + } + + // Set up the encoder. + oapve_cdesc_t cdesc; + memset(&cdesc, 0, sizeof(cdesc)); + cdesc.max_bs_buf_size = w * h * channels * 4 + (1 << 20); + cdesc.max_num_frms = 1; + cdesc.threads = OAPV_CDESC_THREADS_AUTO; + oapve_param_t& param = cdesc.param[0]; + oapve_param_default(¶m); + param.profile_idc = m_profile_idc; + param.w = w; + param.h = h; + int fps[2] = { 30, 1 }; + if (const ParamValue* p = m_spec.find_attribute("FramesPerSecond", + TypeRational)) { + const int* r = (const int*)p->data(); + if (r[0] > 0 && r[1] > 0) { + fps[0] = r[0]; + fps[1] = r[1]; + } + } + param.fps_num = fps[0]; + param.fps_den = fps[1]; + int qp = m_spec.get_int_attribute("apv:qp", -1); + if (qp >= 0) + param.qp = (unsigned char)qp; + int bitrate = m_spec.get_int_attribute("apv:bitrate", 0); + if (bitrate > 0) { + param.bitrate = bitrate; + param.rc_type = OAPV_RC_ABR; + } + string_view preset = m_spec.get_string_attribute("apv:preset", ""); + if (!preset.empty()) { + for (const oapv_dict_str_int_t* d = oapv_param_opts_preset; d->key[0]; + d++) { + if (preset == d->key) { + param.preset = d->val; + break; + } + } + } + param.color_description_present_flag = color_present; + if (color_present) { + param.color_primaries = (unsigned char)color_primaries; + param.transfer_characteristics = (unsigned char)color_transfer; + param.matrix_coefficients = 1; // BT.709, what we encoded with + param.full_range_flag = full_range; + } + + int err = OAPV_OK; + oapve_t encoder = oapve_create(&cdesc, &err); + if (encoder == nullptr) { + imgb->release(imgb); + errorfmt("Could not create APV encoder (error {})", err); + return false; + } + + std::vector bsbuf(cdesc.max_bs_buf_size); + oapv_bitb_t bitb; + memset(&bitb, 0, sizeof(bitb)); + bitb.addr = bsbuf.data(); + bitb.bsize = (int)bsbuf.size(); + + oapv_frms_t ifrms, rfrms; + memset(&ifrms, 0, sizeof(ifrms)); + memset(&rfrms, 0, sizeof(rfrms)); + ifrms.num_frms = 1; + ifrms.frm[0].imgb = imgb; + ifrms.frm[0].pbu_type = OAPV_PBU_TYPE_PRIMARY_FRAME; + ifrms.frm[0].group_id = 1; + oapve_stat_t stat; + memset(&stat, 0, sizeof(stat)); + + int ret = oapve_encode(encoder, &ifrms, nullptr, &bitb, &stat, &rfrms); + oapve_delete(encoder); + imgb->release(imgb); + if (ret != OAPV_OK || stat.write <= 0) { + errorfmt("APV encode failed (error {})", ret); + return false; + } + + // The encoder's output already carries the raw bitstream framing + // ([4-byte big-endian AU size][AU]), so write it verbatim. + if (!iowrite(bsbuf.data(), stat.write)) + return false; + m_dirty = false; + return true; +} + + + +bool +ApvOutput::close() +{ + if (!ioproxy_opened()) // already closed + return true; + bool ok = finalize_subimage(); + init(); + return ok; +} + +OIIO_PLUGIN_NAMESPACE_END diff --git a/src/cmake/build_APV.cmake b/src/cmake/build_APV.cmake new file mode 100644 index 0000000000..d3221a5728 --- /dev/null +++ b/src/cmake/build_APV.cmake @@ -0,0 +1,45 @@ +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +###################################################################### +# APV (OpenAPV) by hand! +###################################################################### + +set_cache (APV_BUILD_VERSION 0.3.0.0 "OpenAPV version for local builds") +set (APV_GIT_REPOSITORY "https://github.com/openapv/openapv") +set_cache (APV_GIT_TAG "v${APV_BUILD_VERSION}" "Git branch or tag") +set_cache (APV_GIT_COMMIT "9f6fd2a7369db90acec67d99fc57724f1136fb84" + "commit hash to verify tag against") +set_cache (APV_BUILD_SHARED_LIBS ${LOCAL_BUILD_SHARED_LIBS_DEFAULT} + DOC "Should a local APV build, if necessary, build shared libraries" ADVANCED) + +if (APV_BUILD_SHARED_LIBS) + set (_apv_lib_args -D OAPV_BUILD_SHARED_LIB=ON -D OAPV_BUILD_STATIC_LIB=OFF) +else () + set (_apv_lib_args -D OAPV_BUILD_SHARED_LIB=OFF -D OAPV_BUILD_STATIC_LIB=ON) +endif () + +build_dependency_with_cmake(APV + VERSION ${APV_BUILD_VERSION} + GIT_REPOSITORY ${APV_GIT_REPOSITORY} + GIT_TAG ${APV_GIT_TAG} + GIT_COMMIT ${APV_GIT_COMMIT} + CMAKE_ARGS + -D CMAKE_POSITION_INDEPENDENT_CODE=ON + -D CMAKE_INSTALL_LIBDIR=lib + -D OAPV_BUILD_APPS=OFF + ${_apv_lib_args} + ) +unset (_apv_lib_args) + +set (APV_ROOT ${APV_LOCAL_INSTALL_DIR}) + +# Signal to caller that we need to find again at the installed location. +# N.B. the library reports its own apiset-based version from oapv.h, which +# does not match the release tag, so don't pass a REFIND_VERSION here. +set (APV_REFIND TRUE) + +if (APV_BUILD_SHARED_LIBS) + install_local_dependency_libs (APV oapv) +endif () diff --git a/src/cmake/externalpackages.cmake b/src/cmake/externalpackages.cmake index bcf0ca921c..9b4f1dfbb0 100644 --- a/src/cmake/externalpackages.cmake +++ b/src/cmake/externalpackages.cmake @@ -100,6 +100,11 @@ checked_find_package (JXL VERSION_MIN 0.10.1 DEFINITIONS USE_JXL=1) +# APV (Advanced Professional Video) via the OpenAPV library +checked_find_package (APV + VERSION_MIN 1.0 + DEFINITIONS USE_APV=1) + # Pugixml setup. Normally we just use the version bundled with oiio, but # some linux distros are quite particular about having separate packages so we # allow this to be overridden to use the distro-provided package if desired. diff --git a/src/cmake/modules/FindAPV.cmake b/src/cmake/modules/FindAPV.cmake new file mode 100644 index 0000000000..95895c65c3 --- /dev/null +++ b/src/cmake/modules/FindAPV.cmake @@ -0,0 +1,55 @@ +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO +# +# Module to find the OpenAPV library (APV codec) +# https://github.com/openapv/openapv +# +# Will define: +# - APV_FOUND +# - APV_INCLUDES directory to include for oapv headers +# - APV_LIBRARIES libraries to link to +# - APV_VERSION library version (apiset.major.minor.patch from oapv.h) + +include (FindPackageHandleStandardArgs) + +find_path(APV_INCLUDE_DIR + NAMES oapv/oapv.h) +mark_as_advanced(APV_INCLUDE_DIR) + +if (APV_INCLUDE_DIR) + file (STRINGS "${APV_INCLUDE_DIR}/oapv/oapv.h" TMP REGEX "^#define OAPV_VER_APISET .*$") + string (REGEX MATCHALL "[0-9]+" OAPV_VER_APISET ${TMP}) + file (STRINGS "${APV_INCLUDE_DIR}/oapv/oapv.h" TMP REGEX "^#define OAPV_VER_MAJOR .*$") + string (REGEX MATCHALL "[0-9]+" OAPV_VER_MAJOR ${TMP}) + file (STRINGS "${APV_INCLUDE_DIR}/oapv/oapv.h" TMP REGEX "^#define OAPV_VER_MINOR .*$") + string (REGEX MATCHALL "[0-9]+" OAPV_VER_MINOR ${TMP}) + file (STRINGS "${APV_INCLUDE_DIR}/oapv/oapv.h" TMP REGEX "^#define OAPV_VER_PATCH .*$") + string (REGEX MATCHALL "[0-9]+" OAPV_VER_PATCH ${TMP}) + set (APV_VERSION "${OAPV_VER_APISET}.${OAPV_VER_MAJOR}.${OAPV_VER_MINOR}.${OAPV_VER_PATCH}") +endif () + +# N.B. OpenAPV installs its libraries into a lib/oapv subdirectory. +find_library(APV_LIBRARY + NAMES oapv liboapv + PATH_SUFFIXES oapv) +mark_as_advanced ( + APV_LIBRARY + APV_VERSION + ) + +find_package_handle_standard_args(APV + REQUIRED_VARS APV_LIBRARY APV_INCLUDE_DIR + VERSION_VAR APV_VERSION) + +if (APV_FOUND) + set(APV_LIBRARIES ${APV_LIBRARY}) + set(APV_INCLUDES ${APV_INCLUDE_DIR}) + # A static liboapv has no generated oapv_exports.h; consumers must + # define OAPV_STATIC_DEFINE so oapv.h doesn't try to include it. + get_filename_component(_apv_lib_ext ${APV_LIBRARY} LAST_EXT) + if (_apv_lib_ext STREQUAL ".a") + set (APV_DEFINITIONS OAPV_STATIC_DEFINE) + endif () + unset (_apv_lib_ext) +endif () diff --git a/src/cmake/testing.cmake b/src/cmake/testing.cmake index bfa588ca7b..08e9a3cc9d 100644 --- a/src/cmake/testing.cmake +++ b/src/cmake/testing.cmake @@ -292,6 +292,8 @@ macro (oiio_add_all_tests) URL http://www.itu.int/net/ITU-T/sigdb/speimage/ImageForm-s.aspx?val=10100803) oiio_add_tests (jxl FOUNDVAR JXL_FOUND) + oiio_add_tests (apv + FOUNDVAR APV_FOUND) set (all_openexr_tests openexr-suite openexr-multires openexr-chroma openexr-decreasingy openexr-v2 openexr-window perchannel oiiotool-deep) diff --git a/src/doc/builtinplugins.rst b/src/doc/builtinplugins.rst index 92c8a5f6bf..43cb518a1f 100644 --- a/src/doc/builtinplugins.rst +++ b/src/doc/builtinplugins.rst @@ -13,6 +13,75 @@ attributes, etc. The plugins are listed alphabetically by format name. +| + +.. _sec-bundledplugins-apv: + +APV +=============================================== + +APV (Advanced Professional Video) is a royalty-free, all-intra +professional video codec. Because every frame is coded independently, +raw APV bitstreams (extension :file:`.apv`) are usable as sequences of +individually addressable images: OpenImageIO exposes one *subimage* per +access unit, decoding its primary frame, and writes one access unit per +subimage. This reader/writer uses the OpenAPV library +(https://github.com/openapv/openapv). + +Pixel data in the file are 10--16 bit YCbCr (4:2:2, 4:4:4, 4:4:4:4 with +alpha, or 4:0:0 grayscale); OpenImageIO presents them as full-range +``uint16`` RGB(A) or grayscale, converting with the matrix coefficients +signaled in the bitstream (BT.709 assumed if unspecified, BT.601 and +BT.2020 also honored). Writing converts RGB(A) input to YCbCr with +BT.709 coefficients. Auxiliary frames within an access unit (preview, +depth, alpha, non-primary) are not currently exposed. + +If the bitstream carries a color description, the reader sets the +``CICP`` attribute (with matrix and range reflecting the delivered RGB +pixels) and, when possible, an ``oiio:ColorSpace`` color interop ID +derived from it. The writer, symmetrically, passes the primaries and +transfer characteristics of a ``CICP`` attribute into the bitstream's +color description. + +**Configuration settings for APV output** + +When writing, the following special metadata tokens control aspects of +the encoding: + +.. list-table:: + :widths: 30 10 65 + :header-rows: 1 + + * - Output configuration Attribute + - Type + - Meaning + * - ``apv:profile`` + - string + - Encoding profile, one of ``"422-10"`` (default for 3-channel + images), ``"422-12"``, ``"444-10"``, ``"444-12"``, ``"4444-10"`` + (default for 4-channel images), ``"4444-12"``, ``"400-10"`` + (default for 1-channel images). + * - ``apv:qp`` + - int + - Constant quantization parameter (0--63 for 10-bit profiles). + * - ``apv:bitrate`` + - int + - Target bitrate in kbps; if set, rate control is used instead of + constant QP. + * - ``apv:preset`` + - string + - Encoder speed/quality trade-off: ``"fastest"``, ``"fast"``, + ``"medium"`` (default), ``"slow"``, or ``"placebo"``. + * - ``FramesPerSecond`` + - rational + - Frame rate metadata signaled in the bitstream (default 30/1). + +**Custom I/O Overrides** + +APV input and output both support the "custom I/O" feature via the +special ``"oiio:ioproxy"`` attributes (see Section +:ref:`sec-imageinput-ioproxy`) as well as the `set_ioproxy()` methods. + | .. _sec-bundledplugins-bmp: diff --git a/src/libOpenImageIO/imageioplugin.cpp b/src/libOpenImageIO/imageioplugin.cpp index 68462cebf1..ec72346ee2 100644 --- a/src/libOpenImageIO/imageioplugin.cpp +++ b/src/libOpenImageIO/imageioplugin.cpp @@ -273,6 +273,7 @@ catalog_plugin(const std::string& format_name, extern const char* name##_output_extensions[]; \ extern const char* name##_imageio_library_version(); +PLUGENTRY(apv); PLUGENTRY(bmp); PLUGENTRY(cineon); PLUGENTRY(dds); @@ -352,6 +353,9 @@ catalog_builtin_plugins() #endif // Now all the less common formats, in alphabetical order. +#if defined(USE_APV) && !defined(DISABLE_APV) + DECLAREPLUG (apv); +#endif #if !defined(DISABLE_BMP) DECLAREPLUG (bmp); #endif diff --git a/testsuite/apv/ref/out.txt b/testsuite/apv/ref/out.txt new file mode 100644 index 0000000000..8591de150e --- /dev/null +++ b/testsuite/apv/ref/out.txt @@ -0,0 +1,60 @@ +Reading out422.apv +out422.apv : 128 x 96, 3 channel, uint10 apv + SHA-1: 71086C86BB2C37BD60A6ACEC71937A4EF266A3F5 + channel list: R, G, B + apv:level: 30 + apv:profile: 33 + oiio:BitsPerSample: 10 +Computing diff of "pattern.tif" vs "out422.apv" +PASS +Computing diff of "pattern.tif" vs "out444.apv" +PASS +Reading out444-12.apv +out444-12.apv : 128 x 96, 3 channel, uint12 apv + SHA-1: 30A0FA5F39EFE7002C980B96924737E5F44AB7A7 + channel list: R, G, B + apv:level: 30 + apv:profile: 66 + oiio:BitsPerSample: 12 +Computing diff of "pattern.tif" vs "out444-12.apv" +PASS +Reading out4444.apv +out4444.apv : 64 x 64, 4 channel, uint10 apv + SHA-1: A7AE8C53CED3000E82DCACD31C3A1625C07DA677 + channel list: R, G, B, A + apv:level: 30 + apv:profile: 77 + oiio:BitsPerSample: 10 +Reading out400.apv +out400.apv : 64 x 64, 1 channel, uint10 apv + SHA-1: 0CFCAE621E0938136DD39CD856C73373CD6ADB17 + channel list: Y + apv:level: 30 + apv:profile: 99 + oiio:BitsPerSample: 10 +Reading multi.apv +multi.apv : 128 x 96, 3 channel, uint10 apv + 2 subimages: 128x96 [u10,u10,u10], 128x96 [u10,u10,u10] + subimage 0: 128 x 96, 3 channel, uint10 apv + SHA-1: 949B246E561511FF09A4376E437CEE9CFF13A87F + channel list: R, G, B + apv:level: 30 + apv:profile: 33 + oiio:BitsPerSample: 10 + subimage 1: 128 x 96, 3 channel, uint10 apv + SHA-1: F1C646340D636ACBCF0EA333D54EB14DCA1EEBE6 + channel list: R, G, B + apv:level: 30 + apv:profile: 33 + oiio:BitsPerSample: 10 +Reading cicp.apv +cicp.apv : 128 x 96, 3 channel, uint10 apv + SHA-1: 9DBB8C81C920E837B0D73007E376FD8C1A385FE1 + channel list: R, G, B + CICP: 9, 16, 0, 1 + apv:full_range_flag: 1 + apv:level: 30 + apv:matrix_coefficients: 1 + apv:profile: 33 + oiio:BitsPerSample: 10 + oiio:ColorSpace: "pq_rec2020_display" diff --git a/testsuite/apv/run.py b/testsuite/apv/run.py new file mode 100644 index 0000000000..76b2c9f535 --- /dev/null +++ b/testsuite/apv/run.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python + +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +# APV is lossy, so round trips are verified with oiiotool --diff and +# generous thresholds rather than exact reference images. + +# A smooth gradient survives 4:2:2 chroma subsampling well. +command += oiiotool ("--pattern fill:topleft=0.1,0.1,0.1:topright=0.9,0.2,0.1:bottomleft=0.1,0.7,0.2:bottomright=0.2,0.2,0.9 128x96 3 -d uint16 -o pattern.tif") + +# Default profile (422-10) write, then verify the metadata we expect. +command += oiiotool ("pattern.tif --attrib apv:qp 8 -o out422.apv") +command += info_command ("out422.apv", safematch=True) +command += oiiotool ("pattern.tif out422.apv --fail 0.05 --warn 0.02 --diff") + +# 4:4:4 write / read round trip. +command += oiiotool ("pattern.tif --attrib apv:profile 444-10 --attrib apv:qp 5 -o out444.apv") +command += oiiotool ("pattern.tif out444.apv --fail 0.05 --warn 0.02 --diff") + +# 12-bit 4:4:4. +command += oiiotool ("pattern.tif --attrib apv:profile 444-12 --attrib apv:qp 5 -o out444-12.apv") +command += info_command ("out444-12.apv", safematch=True) +command += oiiotool ("pattern.tif out444-12.apv --fail 0.05 --warn 0.02 --diff") + +# RGBA via the 4444 profile. +command += oiiotool ("--pattern fill:topleft=0.1,0.1,0.1,1:topright=0.9,0.2,0.1,0.8:bottomleft=0.1,0.7,0.2,0.5:bottomright=0.2,0.2,0.9,0.2 64x64 4 -d uint16 --attrib apv:qp 5 -o out4444.apv") +command += info_command ("out4444.apv", safematch=True) + +# Grayscale via the 400 profile. +command += oiiotool ("--pattern fill:topleft=0.05:topright=0.95:bottomleft=0.5:bottomright=0.5 64x64 1 -d uint16 -o out400.apv") +command += info_command ("out400.apv", safematch=True) + +# Multiple subimages become multiple access units (frames). +command += oiiotool ("pattern.tif pattern.tif --mulc 0.5,0.5,0.5 --siappendall -d uint16 -o multi.apv") +command += info_command ("multi.apv", safematch=True) + +# CICP carries through: tag as BT.2020/PQ, expect it back on read. +command += oiiotool ("pattern.tif --attrib:type=int[4] CICP 9,16,0,1 --attrib apv:qp 5 -o cicp.apv") +command += info_command ("cicp.apv", safematch=True) From 274867f61d4eb8c5ccbd2f9f600829d5f15e57fe Mon Sep 17 00:00:00 2001 From: Zach Lewis Date: Mon, 3 Aug 2026 21:22:59 -0400 Subject: [PATCH 2/3] style(apv): clang-format 17 pass over the new plugin sources Assisted-by: Claude Code (Fable 5) Signed-off-by: Zach Lewis --- src/apv.imageio/apv_pvt.h | 12 +++++++++--- src/apv.imageio/apvinput.cpp | 8 ++++++-- src/apv.imageio/apvoutput.cpp | 10 +++++++--- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/apv.imageio/apv_pvt.h b/src/apv.imageio/apv_pvt.h index b81178c49f..aa5dd0e237 100644 --- a/src/apv.imageio/apv_pvt.h +++ b/src/apv.imageio/apv_pvt.h @@ -67,7 +67,9 @@ channels_for_format(int color_format) // media). inline uint16_t scale_to_16bits(uint32_t v, int bits) -{ return uint16_t((v << (16 - bits)) | (v >> (2 * bits - 16))); } +{ + return uint16_t((v << (16 - bits)) | (v >> (2 * bits - 16))); +} @@ -77,11 +79,15 @@ scale_to_16bits(uint32_t v, int bits) inline int imgb_addref(oapv_imgb_t* imgb) -{ return ++imgb->refcnt; } +{ + return ++imgb->refcnt; +} inline int imgb_getref(oapv_imgb_t* imgb) -{ return imgb->refcnt; } +{ + return imgb->refcnt; +} inline int imgb_release(oapv_imgb_t* imgb) diff --git a/src/apv.imageio/apvinput.cpp b/src/apv.imageio/apvinput.cpp index 7447bfbf61..78e57538fc 100644 --- a/src/apv.imageio/apvinput.cpp +++ b/src/apv.imageio/apvinput.cpp @@ -33,7 +33,9 @@ class ApvInput final : public ImageInput { ~ApvInput() override { close(); } const char* format_name(void) const override { return "apv"; } int supports(string_view feature) const override - { return feature == "ioproxy"; } + { + return feature == "ioproxy"; + } bool valid_file(Filesystem::IOProxy* ioproxy) const override; bool open(const std::string& name, ImageSpec& newspec) override; bool open(const std::string& name, ImageSpec& newspec, @@ -85,7 +87,9 @@ OIIO_PLUGIN_EXPORTS_BEGIN OIIO_EXPORT ImageInput* apv_input_imageio_create() -{ return new ApvInput; } +{ + return new ApvInput; +} OIIO_EXPORT int apv_imageio_version = OIIO_PLUGIN_VERSION; diff --git a/src/apv.imageio/apvoutput.cpp b/src/apv.imageio/apvoutput.cpp index f3f78395f1..cc07c20f28 100644 --- a/src/apv.imageio/apvoutput.cpp +++ b/src/apv.imageio/apvoutput.cpp @@ -71,7 +71,9 @@ OIIO_PLUGIN_EXPORTS_BEGIN OIIO_EXPORT ImageOutput* apv_output_imageio_create() -{ return new ApvOutput; } +{ + return new ApvOutput; +} OIIO_EXPORT const char* apv_output_extensions[] = { "apv", nullptr }; @@ -176,7 +178,9 @@ ApvOutput::open(const std::string& name, const ImageSpec& userspec, bool ApvOutput::write_scanline(int y, int z, TypeDesc format, const void* data, stride_t xstride) -{ return write_scanlines(y, y + 1, z, format, data, xstride, AutoStride); } +{ + return write_scanlines(y, y + 1, z, format, data, xstride, AutoStride); +} @@ -196,7 +200,7 @@ ApvOutput::write_scanlines(int ybegin, int yend, int z, TypeDesc format, for (int y = ybegin; y < yend; y++) { const char* src = (const char*)data + (y - ybegin) * ystride; float* dst = m_staging.data() - + size_t(y) * m_spec.width * m_spec.nchannels; + + size_t(y) * m_spec.width * m_spec.nchannels; OIIO::convert_image(m_spec.nchannels, m_spec.width, 1, 1, src, format, xstride, AutoStride, AutoStride, dst, TypeDesc::FLOAT, AutoStride, AutoStride, From f5a8841ea3277034821b18025f7ecbd512b12d21 Mon Sep 17 00:00:00 2001 From: Zach Lewis Date: Mon, 3 Aug 2026 21:38:34 -0400 Subject: [PATCH 3/3] feat(apv): derive CICP from oiio:ColorSpace, match matrix to primaries Two Color Interop Forum alignment improvements to the writer: - If no explicit CICP attribute is present but oiio:ColorSpace names a color space the default ColorConfig can map to CICP code points (e.g. a color interop ID), derive the bitstream color description from it via ColorConfig::get_cicp(). Untagged input still writes no color description rather than guessing. - Choose the encode matrix coefficients to match the primaries (BT.2020 with the 2020 NCL matrix, BT.601 families with theirs, everything else including P3 with BT.709) instead of always BT.709, and signal the matrix actually used. Testsuite gains an interop-ID-only round trip. Assisted-by: Claude Code (Fable 5) Signed-off-by: Zach Lewis --- src/apv.imageio/apvoutput.cpp | 44 ++++++++++++++++++++++++++++++----- src/doc/builtinplugins.rst | 8 ++++++- testsuite/apv/ref/out.txt | 15 ++++++++++-- testsuite/apv/run.py | 5 ++++ 4 files changed, 63 insertions(+), 9 deletions(-) diff --git a/src/apv.imageio/apvoutput.cpp b/src/apv.imageio/apvoutput.cpp index cc07c20f28..f423b7c17c 100644 --- a/src/apv.imageio/apvoutput.cpp +++ b/src/apv.imageio/apvoutput.cpp @@ -12,6 +12,7 @@ #include #include +#include #include #include #include @@ -224,11 +225,12 @@ ApvOutput::finalize_subimage() const int bits = m_bits; const int cs = OAPV_CS_SET(m_color_format, bits, 0); - // Color handling: we always encode with BT.709 matrix coefficients. - // If the spec carries a CICP attribute, pass its primaries/transfer - // through to the bitstream's color description and honor its range - // flag; otherwise emit no color description and use limited range. - float kr = 0.2126f, kb = 0.0722f; + // Color handling, following Color Interop Forum conventions: an + // explicit CICP attribute wins; otherwise, if oiio:ColorSpace names + // a color space the default ColorConfig can map to CICP code points + // (e.g. a color interop ID), derive them from it. If neither yields + // anything, no color description is signaled ("don't guess") and + // BT.709 limited range is used for the YCbCr conversion. int color_present = 0, color_primaries = 2, color_transfer = 2; int full_range = 0; if (const ParamValue* p = m_spec.find_attribute("CICP")) { @@ -239,7 +241,37 @@ ApvOutput::finalize_subimage() color_transfer = cicp[1]; full_range = cicp[3] ? 1 : 0; } + } else { + string_view csname = m_spec.get_string_attribute("oiio:ColorSpace"); + if (!csname.empty()) { + const ColorConfig& colorconfig(ColorConfig::default_colorconfig()); + cspan cicp = colorconfig.get_cicp(csname); + if (cicp.size() == 4) { + color_present = 1; + color_primaries = cicp[0]; + color_transfer = cicp[1]; + // The registry tuple describes full-range RGB pixels; + // our YCbCr carrier follows video convention instead: + // limited range. + full_range = 0; + } + } } + // Choose matrix coefficients to match the primaries, per common + // practice: BT.2020 primaries pair with the 2020 non-constant + // luminance matrix, the BT.601 families with their own matrices, + // and everything else (including P3, conventionally) with BT.709. + int matrix = 1; + if (color_present) { + if (color_primaries == 9) + matrix = 9; + else if (color_primaries == 5) + matrix = 5; + else if (color_primaries == 6) + matrix = 6; + } + float kr = 0.2126f, kb = 0.0722f; + matrix_luma_coefficients(matrix, kr, kb); const float kg = 1.0f - kr - kb; const float maxval = float((1 << bits) - 1); const float lo = full_range ? 0.0f : float(16 << (bits - 8)); @@ -343,7 +375,7 @@ ApvOutput::finalize_subimage() if (color_present) { param.color_primaries = (unsigned char)color_primaries; param.transfer_characteristics = (unsigned char)color_transfer; - param.matrix_coefficients = 1; // BT.709, what we encoded with + param.matrix_coefficients = (unsigned char)matrix; param.full_range_flag = full_range; } diff --git a/src/doc/builtinplugins.rst b/src/doc/builtinplugins.rst index 43cb518a1f..d6bac65c06 100644 --- a/src/doc/builtinplugins.rst +++ b/src/doc/builtinplugins.rst @@ -41,7 +41,13 @@ If the bitstream carries a color description, the reader sets the pixels) and, when possible, an ``oiio:ColorSpace`` color interop ID derived from it. The writer, symmetrically, passes the primaries and transfer characteristics of a ``CICP`` attribute into the bitstream's -color description. +color description; lacking that attribute, an ``oiio:ColorSpace`` +naming a color space that OpenImageIO's default color configuration +can map to CICP code points (such as a color interop ID) is used +instead. Matrix coefficients for the encode are chosen to match the +primaries (BT.2020 primaries pair with the 2020 matrix, the BT.601 +families with theirs, everything else — including P3, conventionally — +with BT.709). **Configuration settings for APV output** diff --git a/testsuite/apv/ref/out.txt b/testsuite/apv/ref/out.txt index 8591de150e..b6792906ac 100644 --- a/testsuite/apv/ref/out.txt +++ b/testsuite/apv/ref/out.txt @@ -49,12 +49,23 @@ multi.apv : 128 x 96, 3 channel, uint10 apv oiio:BitsPerSample: 10 Reading cicp.apv cicp.apv : 128 x 96, 3 channel, uint10 apv - SHA-1: 9DBB8C81C920E837B0D73007E376FD8C1A385FE1 + SHA-1: 1561CF2F412992B44D793A9817CB6165728677AE channel list: R, G, B CICP: 9, 16, 0, 1 apv:full_range_flag: 1 apv:level: 30 - apv:matrix_coefficients: 1 + apv:matrix_coefficients: 9 apv:profile: 33 oiio:BitsPerSample: 10 oiio:ColorSpace: "pq_rec2020_display" +Reading ciid.apv +ciid.apv : 128 x 96, 3 channel, uint10 apv + SHA-1: 915AC0206C3C8C3AD46C595FB23CA9E581AE3D53 + channel list: R, G, B + CICP: 1, 13, 0, 1 + apv:full_range_flag: 0 + apv:level: 30 + apv:matrix_coefficients: 1 + apv:profile: 33 + oiio:BitsPerSample: 10 + oiio:ColorSpace: "srgb_rec709_scene" diff --git a/testsuite/apv/run.py b/testsuite/apv/run.py index 76b2c9f535..267fc6bed8 100644 --- a/testsuite/apv/run.py +++ b/testsuite/apv/run.py @@ -39,3 +39,8 @@ # CICP carries through: tag as BT.2020/PQ, expect it back on read. command += oiiotool ("pattern.tif --attrib:type=int[4] CICP 9,16,0,1 --attrib apv:qp 5 -o cicp.apv") command += info_command ("cicp.apv", safematch=True) + +# CICP derived from a color interop ID (no explicit CICP attribute): +# writing tags the bitstream, reading recovers both CICP and the ID. +command += oiiotool ("pattern.tif --attrib oiio:ColorSpace srgb_rec709_display --attrib apv:qp 5 -o ciid.apv") +command += info_command ("ciid.apv", safematch=True)