From db30a9a50ce25556034ab428c092c63eb4bbe693 Mon Sep 17 00:00:00 2001 From: Powei Feng Date: Thu, 27 Feb 2025 10:54:37 -0800 Subject: [PATCH] viewer: add option to export TIFF in automation (#8472) PPM does not store alpha channel, but TIFF does. We add a method to export RGBA to a TIFF file without compression. We add the corresponding options to gltf_viewer and AutomationEngine. The default export format for both gltf_viewer and AutomationEngine is now TIFF. --- libs/viewer/CMakeLists.txt | 2 + libs/viewer/include/viewer/AutomationEngine.h | 21 ++ libs/viewer/src/AutomationEngine.cpp | 50 +++- libs/viewer/src/TIFFExport.cpp | 262 ++++++++++++++++++ libs/viewer/src/TIFFExport.h | 24 ++ samples/gltf_viewer.cpp | 45 +-- test/renderdiff/src/run.py | 2 +- 7 files changed, 379 insertions(+), 27 deletions(-) create mode 100644 libs/viewer/src/TIFFExport.cpp create mode 100644 libs/viewer/src/TIFFExport.h diff --git a/libs/viewer/CMakeLists.txt b/libs/viewer/CMakeLists.txt index 58fe66c98f..3b67fde985 100644 --- a/libs/viewer/CMakeLists.txt +++ b/libs/viewer/CMakeLists.txt @@ -23,6 +23,8 @@ set(SRCS src/Settings.cpp src/Settings_generated.cpp src/Settings_generated.h + src/TIFFExport.cpp + src/TIFFExport.h src/ViewerGui.cpp ) diff --git a/libs/viewer/include/viewer/AutomationEngine.h b/libs/viewer/include/viewer/AutomationEngine.h index f0d907a0f7..3c7257f23c 100644 --- a/libs/viewer/include/viewer/AutomationEngine.h +++ b/libs/viewer/include/viewer/AutomationEngine.h @@ -56,6 +56,22 @@ public: * Allows users to toggle screenshots, change the sleep duration between tests, etc. */ struct Options { + + /** + * Formats that could be used for exporting the screenshots. + */ + enum class ExportFormat : uint8_t { + /** + * Tagged Image File Format (TIFF) + */ + TIFF = 0, + + /** + * Netpbm color image format (Portable Pixel Map) + */ + PPM = 1, + }; + /** * Minimum time that automation waits between applying a settings object and advancing * to the next test case. Specified in seconds. @@ -82,6 +98,11 @@ public: * If true, the tick function writes out a settings JSON file before advancing. */ bool exportSettings = false; + + /** + * Which image format will be used for exporting screenshots. + */ + ExportFormat exportFormat = ExportFormat::TIFF; }; /** diff --git a/libs/viewer/src/AutomationEngine.cpp b/libs/viewer/src/AutomationEngine.cpp index 810df99dbb..7e747e8001 100644 --- a/libs/viewer/src/AutomationEngine.cpp +++ b/libs/viewer/src/AutomationEngine.cpp @@ -16,6 +16,8 @@ #include +#include "TIFFExport.h" + #include #include #include @@ -44,7 +46,9 @@ struct ScreenshotState { AutomationEngine* engine; }; -static void convertRGBAtoRGB(void* buffer, uint32_t width, uint32_t height) { +namespace { + +void convertRGBAtoRGB(void* buffer, uint32_t width, uint32_t height) { uint8_t* writePtr = static_cast(buffer); uint8_t const* readPtr = static_cast(buffer); for (uint32_t i = 0, n = width * height; i < n; ++i) { @@ -56,6 +60,25 @@ static void convertRGBAtoRGB(void* buffer, uint32_t width, uint32_t height) { } } +void exportPPM(void* buffer, uint32_t width, uint32_t height, std::ofstream& outstream) { + // ReadPixels on Metal only supports RGBA, but the PPM format only supports RGB. + // So, manually perform a quick transformation here. + convertRGBAtoRGB(buffer, width, height); + + outstream << "P6 " << width << " " << height << " " << 255 << std::endl; + outstream.write(static_cast(buffer), width * height * 3); +} + +using ExportFormat = AutomationEngine::Options::ExportFormat; +constexpr char const* getExportFormatExtension(ExportFormat format) { + switch (format) { + case ExportFormat::PPM: return ".ppm"; + case ExportFormat::TIFF: return ".tif"; + } +} + +} // anonymous namespace + void AutomationEngine::exportScreenshot(View* view, Renderer* renderer, std::string filename, bool autoclose, AutomationEngine* automationEngine) { const Viewport& vp = view->getViewport(); @@ -75,14 +98,21 @@ void AutomationEngine::exportScreenshot(View* view, Renderer* renderer, std::str } const Viewport& vp = state->view->getViewport(); - // ReadPixels on Metal only supports RGBA, but the PPM format only supports RGB. - // So, manually perform a quick transformation here. - convertRGBAtoRGB(buffer, vp.width, vp.height); - Path out(state->filename); - std::ofstream ppmStream(out); - ppmStream << "P6 " << vp.width << " " << vp.height << " " << 255 << std::endl; - ppmStream.write(static_cast(buffer), vp.width * vp.height * 3); + std::ofstream outstream(out); + + auto extension = out.getExtension(); + if (extension == "ppm") { + exportPPM(buffer, vp.width, vp.height, outstream); + } else if (extension == "tif" || extension == "tiff") { + exportTIFF(buffer, vp.width, vp.height, outstream); + } else { + utils::slog.e << out.c_str() << " does not specify a supported file extension." + << utils::io::endl; + } + + outstream.close(); + delete[] static_cast(buffer); if (state->autoclose) { state->engine->requestClose(); @@ -244,8 +274,8 @@ void AutomationEngine::tick(Engine* engine, const ViewerContent& content, float } if (mOptions.exportScreenshots) { - AutomationEngine::exportScreenshot( - content.view, content.renderer, prefix + ".ppm", isLastTest, this); + AutomationEngine::exportScreenshot(content.view, content.renderer, + prefix + getExportFormatExtension(mOptions.exportFormat), isLastTest, this); } if (isLastTest) { diff --git a/libs/viewer/src/TIFFExport.cpp b/libs/viewer/src/TIFFExport.cpp new file mode 100644 index 0000000000..4c13c488a8 --- /dev/null +++ b/libs/viewer/src/TIFFExport.cpp @@ -0,0 +1,262 @@ +/* + * Copyright (C) 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "TIFFExport.h" + +#include +#include + +#include +#include +#include + +namespace { + +// TIFF Header Structure +struct TIFFHeader { + uint16_t byteOrder; + uint16_t magicNumber; + uint32_t firstIFDOffset; +}; + +static_assert(sizeof(TIFFHeader) == 8); + +// Image File Directory (IFD) Entry Structure +struct IFDEntry { + uint16_t tag; + uint16_t type; + uint32_t count; + uint32_t valueOffset; +}; + +static_assert(sizeof(IFDEntry) == 12); + +// TIFF Tag Definitions +constexpr uint16_t ImageWidth = 256; +constexpr uint16_t ImageLength = 257; +constexpr uint16_t BitsPerSample = 258; +constexpr uint16_t Compression = 259; +constexpr uint16_t PhotometricInterpretation = 262; +constexpr uint16_t StripOffsets = 273; +constexpr uint16_t SamplesPerPixel = 277; +constexpr uint16_t RowsPerStrip = 278; +constexpr uint16_t StripByteCounts = 279; +constexpr uint16_t XResolution = 282; +constexpr uint16_t YResolution = 283; +constexpr uint16_t ResolutionUnit = 296; +constexpr uint16_t PlanarConfiguration = 284; + +// TIFF Type Definitions +constexpr uint16_t SHORT = 3; +constexpr uint16_t LONG = 4; +constexpr uint16_t RATIONAL = 5; + +// TIFF resolution unit +constexpr uint16_t Inch = 2; + +constexpr uint16_t getType(uint16_t const tag) { + switch (tag) { + case ImageWidth: return LONG; + case ImageLength: return LONG; + case BitsPerSample: return SHORT; + case Compression: return SHORT; + case PhotometricInterpretation: return SHORT; + case StripOffsets: return LONG; + case SamplesPerPixel: return SHORT; + case RowsPerStrip: return LONG; + case StripByteCounts: return LONG; + case PlanarConfiguration: return SHORT; + case XResolution: return RATIONAL; + case YResolution: return RATIONAL; + case ResolutionUnit: return SHORT; + default: + return SHORT; + } +} + +// Photometric Interpretation +constexpr uint16_t RGBA = 2; + +// Compression +constexpr uint16_t NoCompression = 1; + +// Planar Configuration +constexpr uint16_t Chunky = 1; + +// According to spec, 8K is the recommended max strip size. +constexpr uint32_t MAX_STRIP_SIZE = 8192; + +#define ROWS_PER_STRIP(width) (MAX_STRIP_SIZE / (width * 4)) + +void buildStrips(uint32_t width, uint32_t height, + std::vector& stripSizes, std::vector& stripOffsets) { + uint32_t const totalSize = width * height * 4; + uint32_t const rowSize = width * 4; + uint32_t const rowsPerStrip = ROWS_PER_STRIP(width); + uint32_t const maxStripSize = rowSize * rowsPerStrip; + + uint32_t size = totalSize; + uint32_t offset = 0; + while (size > 0) { + uint32_t const stripSize = std::min(size, maxStripSize); + size -= stripSize; + + stripSizes.push_back(stripSize); + stripOffsets.push_back(offset); + offset += stripSize; + } +} + +using AddIFDEntryFunc = std::function; + +struct Offsets { + uint32_t bitsPerSample; + uint32_t xResolution; + uint32_t yResolution; + uint32_t stripByteCounts; + uint32_t strips; +}; + +void buildIFDEntries(uint32_t width, uint32_t height, Offsets offsets, uint32_t stripCount, + AddIFDEntryFunc addIFD, uint32_t* size) { + + auto addIFDEntry = [&](uint16_t tag, uint32_t count, uint32_t val) { + addIFD(tag, count, val); + if (size) { + (*size) += sizeof(IFDEntry); + } + }; + + addIFDEntry(ImageWidth, 1, width); + addIFDEntry(ImageLength, 1, height); + addIFDEntry(Compression, 1, NoCompression); + addIFDEntry(PhotometricInterpretation, 1, RGBA); + addIFDEntry(SamplesPerPixel, 1, 4); + addIFDEntry(RowsPerStrip, 1, ROWS_PER_STRIP(width)); + addIFDEntry(StripByteCounts, stripCount, offsets.stripByteCounts); + addIFDEntry(PlanarConfiguration, 1, Chunky); + addIFDEntry(BitsPerSample, 4, offsets.bitsPerSample); + addIFDEntry(StripOffsets, stripCount, offsets.strips); + addIFDEntry(XResolution, 1, offsets.xResolution); + addIFDEntry(YResolution, 1, offsets.yResolution); + addIFDEntry(ResolutionUnit, 1, Inch); +} + +} // anonymous + +void exportTIFF(void* rgbaData, uint32_t width, uint32_t height, std::ostream& file) { + FILAMENT_CHECK_PRECONDITION(width * 4 < MAX_STRIP_SIZE) + << "output image's width is too large. width=" << width + << ", max-width=" << (MAX_STRIP_SIZE / 4); + + uint32_t cursor = 0; + auto write = [&file, &cursor](auto const& obj) { + uint32_t const len = sizeof(obj); + file.write(reinterpret_cast(&obj), sizeof(obj)); + cursor += len; + }; + + auto writeBytes = [&file, &cursor](uint8_t* bytes, uint32_t size) { + file.write(reinterpret_cast(bytes), size); + cursor += size; + }; + + // TIFF Header + TIFFHeader header = { + .byteOrder = 0x4949, // Little-endian + .magicNumber = 42, + .firstIFDOffset = sizeof(TIFFHeader), + }; + write(header); + + auto noopIFD = [](uint16_t tag, uint32_t count, uint32_t val) {}; + + uint32_t ifdSize = 0; + // We do a no-op to gather the size of the IFD entries + buildIFDEntries(width, height, {}, 1, noopIFD, &ifdSize); + + uint16_t const ifdCount = ifdSize / sizeof(IFDEntry); + write(ifdCount); + + std::vector stripByteCounts; + std::vector stripOffsets; + + buildStrips(width, height, stripByteCounts, stripOffsets); + + // Next IFD Offset (0 for none) + uint32_t const nextIFDOffset = 0; + + // At this point, we've written the header plus the number of IFD entries (a uint16_t). + uint32_t offsetCursor = cursor; + + constexpr uint16_t bitsPerSample[4] = { 8, 8, 8, 8 }; + constexpr uint32_t xResolution[2] = { 1, 1 }; + constexpr uint32_t yResolution[2] = { 1, 1 }; + + Offsets const offsets = { + .bitsPerSample = (offsetCursor += (ifdSize + sizeof(nextIFDOffset))), + .xResolution = (offsetCursor += sizeof(bitsPerSample)), + .yResolution = (offsetCursor += sizeof(xResolution)), + .stripByteCounts = (offsetCursor += sizeof(yResolution)), + .strips = (offsetCursor += sizeof(uint32_t) * stripByteCounts.size()), + }; + + uint32_t const stripStart = offsets.strips + stripOffsets.size() * sizeof(uint32_t); + std::for_each(stripOffsets.begin(), stripOffsets.end(), + [stripStart](uint32_t& offset) { offset += stripStart; }); + + // Really build the IFD entries with the proper offsets and putting them into a vector. + std::vector ifdEntries; + auto addIFD = [&ifdEntries](uint16_t tag, uint32_t count, uint32_t val) { + ifdEntries.push_back({}); + auto& entry = ifdEntries.back(); + entry.tag = tag; + entry.type = getType(tag); + entry.count = count; + entry.valueOffset = val; + }; + buildIFDEntries(width, height, offsets, stripOffsets.size(), addIFD, nullptr); + // IFD entries must be sorted by tag. + std::sort(ifdEntries.begin(), ifdEntries.end(), [](auto const& a, auto const& b) { + return a.tag < b.tag; + }); + + // Begin writing IFD and all the other metadata arrays. + std::for_each(ifdEntries.begin(), ifdEntries.end(), write); + write(nextIFDOffset); + + assert_invariant(cursor == offsets.bitsPerSample); + write(bitsPerSample); + assert_invariant(cursor == offsets.xResolution); + write(xResolution); + assert_invariant(cursor == offsets.yResolution); + write(yResolution); + assert_invariant(cursor == offsets.stripByteCounts); + writeBytes((uint8_t*) stripByteCounts.data(), stripByteCounts.size() * sizeof(uint32_t)); + assert_invariant(cursor == offsets.strips); + writeBytes((uint8_t*) stripOffsets.data(), stripOffsets.size() * sizeof(uint32_t)); + + uint32_t const totalSize = width * height * 4; + uint32_t const maxStripSize = ROWS_PER_STRIP(width) * width * 4; + for (uint32_t i = 0, count = 0; i < totalSize;) { + uint32_t const stripSize = std::min(totalSize - i, maxStripSize); + + assert_invariant(cursor == stripOffsets[count++]); + writeBytes(((uint8_t*) rgbaData) + i, stripSize); + + i += stripSize; + }; +} diff --git a/libs/viewer/src/TIFFExport.h b/libs/viewer/src/TIFFExport.h new file mode 100644 index 0000000000..3aa13bdb16 --- /dev/null +++ b/libs/viewer/src/TIFFExport.h @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef VIEWER_TIFF_EXPORT_H +#define VIEWER_TIFF_EXPORT_H + +#include + +void exportTIFF(void* buffer, uint32_t width, uint32_t height, std::ostream& outstream); + +#endif // VIEWER_TIFF_EXPORT_H diff --git a/samples/gltf_viewer.cpp b/samples/gltf_viewer.cpp index bdde92b0f6..bb04d00afd 100644 --- a/samples/gltf_viewer.cpp +++ b/samples/gltf_viewer.cpp @@ -141,6 +141,7 @@ struct App { AutomationEngine* automationEngine = nullptr; bool screenshot = false; uint8_t screenshotSeq = 0; + bool screenshotAsPPM = false; }; static const char* DEFAULT_IBL = "assets/ibl/lightroom_14b"; @@ -202,6 +203,9 @@ static void printUsage(char* name) { " Vulkan backend allows user to choose their GPU.\n" " You can provide the index of the GPU or\n" " a substring to match against the device name\n\n" + " --screenshot-as-ppm, -d\n" + " export PPM as oppose to TIFF screenshots\n\n" + ); const std::string from("SHOWCASE"); for (size_t pos = usage.find(from); pos != std::string::npos; pos = usage.find(from, pos)) { @@ -216,22 +220,23 @@ static std::ifstream::pos_type getFileSize(const char* filename) { } static int handleCommandLineArguments(int argc, char* argv[], App* app) { - static constexpr const char* OPTSTR = "ha:f:i:usc:rt:b:evg:"; + static constexpr const char* OPTSTR = "ha:f:i:usc:rt:b:evg:d"; static const struct option OPTIONS[] = { - { "help", no_argument, nullptr, 'h' }, - { "api", required_argument, nullptr, 'a' }, - { "feature-level", required_argument, nullptr, 'f' }, - { "batch", required_argument, nullptr, 'b' }, - { "headless", no_argument, nullptr, 'e' }, - { "ibl", required_argument, nullptr, 'i' }, - { "ubershader", no_argument, nullptr, 'u' }, - { "actual-size", no_argument, nullptr, 's' }, - { "camera", required_argument, nullptr, 'c' }, - { "eyes", required_argument, nullptr, 'y' }, - { "recompute-aabb", no_argument, nullptr, 'r' }, - { "settings", required_argument, nullptr, 't' }, - { "split-view", no_argument, nullptr, 'v' }, - { "vulkan-gpu-hint", required_argument, nullptr, 'g' }, + { "help", no_argument, nullptr, 'h' }, + { "api", required_argument, nullptr, 'a' }, + { "feature-level", required_argument, nullptr, 'f' }, + { "batch", required_argument, nullptr, 'b' }, + { "headless", no_argument, nullptr, 'e' }, + { "ibl", required_argument, nullptr, 'i' }, + { "ubershader", no_argument, nullptr, 'u' }, + { "actual-size", no_argument, nullptr, 's' }, + { "camera", required_argument, nullptr, 'c' }, + { "eyes", required_argument, nullptr, 'y' }, + { "recompute-aabb", no_argument, nullptr, 'r' }, + { "settings", required_argument, nullptr, 't' }, + { "split-view", no_argument, nullptr, 'v' }, + { "vulkan-gpu-hint", required_argument, nullptr, 'g' }, + { "screenshot-as-ppm", no_argument, nullptr, 'd' }, { nullptr, 0, nullptr, 0 } }; int opt; @@ -317,6 +322,10 @@ static int handleCommandLineArguments(int argc, char* argv[], App* app) { app->config.vulkanGPUHint = arg; break; } + case 'd': { + app->screenshotAsPPM = true; + break; + } } } if (app->config.headless && app->batchFile.empty()) { @@ -767,6 +776,9 @@ int main(int argc, char** argv) { options.sleepDuration = 0.0; options.exportScreenshots = true; options.exportSettings = true; + options.exportFormat = app.screenshotAsPPM + ? AutomationEngine::Options::ExportFormat::PPM + : AutomationEngine::Options::ExportFormat::TIFF; app.automationEngine->setOptions(options); app.viewer->stopAnimation(); } @@ -1157,8 +1169,9 @@ int main(int argc, char** argv) { if (app.screenshot) { std::ostringstream stringStream; stringStream << "screenshot" << std::setfill('0') << std::setw(2) << +app.screenshotSeq; + std::string const ext = app.screenshotAsPPM ? ".ppm" : ".tif"; AutomationEngine::exportScreenshot( - view, renderer, stringStream.str() + ".ppm", false, app.automationEngine); + view, renderer, stringStream.str() + ext, false, app.automationEngine); ++app.screenshotSeq; app.screenshot = false; } diff --git a/test/renderdiff/src/run.py b/test/renderdiff/src/run.py index be90073709..9b63ebd4b3 100644 --- a/test/renderdiff/src/run.py +++ b/test/renderdiff/src/run.py @@ -59,7 +59,7 @@ def render_test(gltf_viewer, test_config, output_dir, env=env, capture_output=False) if res == 0: - execute(f'mv -f {test.name}0.ppm {named_output_dir}/{out_name}.ppm', capture_output=False) + execute(f'mv -f {test.name}0.tif {named_output_dir}/{out_name}.tif', capture_output=False) execute(f'mv -f {test.name}0.json {named_output_dir}/{test.name}.json', capture_output=False) else: important_print(f'{test_desc} failed with error={res}')