diff --git a/src/cmake/testing.cmake b/src/cmake/testing.cmake index 93aa7345e6..215040c0fb 100644 --- a/src/cmake/testing.cmake +++ b/src/cmake/testing.cmake @@ -183,6 +183,7 @@ macro (oiio_add_all_tests) oiiotool-readerror oiiotool-subimage oiiotool-text + oiiotool-get-thumbnail oiiotool-xform diff flip @@ -449,6 +450,8 @@ macro (oiio_add_all_tests) ENABLEVAR ENABLE_TARGA IMAGEDIR oiio-images) endif() + oiio_add_tests (oiiotool-set-thumbnail + ENABLEVAR ENABLE_TARGA) if (WIN32) if (OIIO_BUILD_TOOLS) # Add test for long path handling if support is enabled at the system level. diff --git a/src/doc/oiiotool.rst b/src/doc/oiiotool.rst index ce6fd570c2..43304668fa 100644 --- a/src/doc/oiiotool.rst +++ b/src/doc/oiiotool.rst @@ -1232,6 +1232,18 @@ Reading images to following the input with a `--ch` command, except that by integrating into the `-i`, it potentially can avoid the I/O of the unneeded channels. + `:get_thumbnail=` *int* + If nonzero, read the file's embedded thumbnail instead of its main + image (equivalent to a following `--get-thumbnail`). The `:fail=` and + `:index=` modifiers are forwarded (e.g. `-i:get_thumbnail=1:fail=0`), + and any auto-orientation or color conversion applies to the thumbnail. + Since a thumbnail is display-referred (typically sRGB), `:autocc=` will + linearize it like any other input. + + Examples:: + + # Extract just the thumbnail from a large image + oiiotool -i:get_thumbnail=1 input.psd -o thumb.jpg .. option:: --iconfig @@ -2339,6 +2351,50 @@ current top image. Additionally, this command can be used to remove one subimage (leaving the others) by using the optional modifier `--subimage:delete=1`. +.. option:: --get-thumbnail + + Replace the top image on the stack with its embedded thumbnail. + The thumbnail associated with the first subimage (subimage 0) is used. + Because this replaces the top image, use ``--dup`` beforehand if you also + want to keep the original. + + Optional appended modifiers include: + + `:fail=` *int* (default: 1) + If 1, it is an error if the image has no embedded thumbnail. + If 0, an empty (0x0) image is pushed in its place instead, so a batch + script can continue; guard any subsequent output (see the example + below), since writing the empty image is itself an error. + + `:index=` *int* (default: 0) + Selects which embedded thumbnail to retrieve, for formats that can + store more than one (such as some camera raw formats). Currently only + the primary thumbnail (`index=0`, the default) is available; a nonzero + value is an error until multiple-thumbnail support is added (see issue + #4888). + + Examples:: + + # Save the thumbnail + oiiotool input.psd --dup --get-thumbnail -o thumb.jpg + + # Batch-safe: substitute an empty image for missing thumbnails, and + # guard the output so only real thumbnails are written + oiiotool input.psd --get-thumbnail:fail=0 --if "{TOP.width}" -o thumb.jpg --endif + +.. option:: --set-thumbnail + + Remove the top image from the stack and attach it as the thumbnail of the + image now on top (stored on the first subimage). The thumbnail may be + prepared beforehand with the usual image operations. It is written out only + if the output format supports embedded thumbnails, and may be resized or + otherwise adjusted to satisfy that format's restrictions. + + Examples:: + + # Attach a 128x128 box-filtered copy of the image as its thumbnail + oiiotool input.exr --dup --resize:filter=box 128x128 --set-thumbnail -o out_with_thumb.tga + .. option:: --sisplit Remove the top image from the stack, split it into its constituent diff --git a/src/libOpenImageIO/imagebuf.cpp b/src/libOpenImageIO/imagebuf.cpp index aa8bd9e8b8..75709038b6 100644 --- a/src/libOpenImageIO/imagebuf.cpp +++ b/src/libOpenImageIO/imagebuf.cpp @@ -1979,6 +1979,10 @@ ImageBufImpl::set_thumbnail(const ImageBuf& thumb, DoLock do_lock) clear_thumbnail(DoLock(false) /* we already hold the lock */); if (thumb.initialized()) { m_thumbnail.reset(new ImageBuf(thumb)); + m_spec.attribute("thumbnail_width", thumb.spec().width); + m_spec.attribute("thumbnail_height", thumb.spec().height); + m_spec.attribute("thumbnail_nchannels", thumb.spec().nchannels); + m_has_thumbnail = true; } } diff --git a/src/libOpenImageIO/imagebuf_test.cpp b/src/libOpenImageIO/imagebuf_test.cpp index f85b78934c..3ba32ef81c 100644 --- a/src/libOpenImageIO/imagebuf_test.cpp +++ b/src/libOpenImageIO/imagebuf_test.cpp @@ -441,6 +441,160 @@ time_get_pixels() +void +test_thumbnail() +{ + std::cout << "\nTesting set_thumbnail, get_thumbnail, clear_thumbnail:\n"; + ImageBuf A(ImageSpec(64, 48, 3, TypeUInt8)); + ImageBufAlgo::zero(A); + OIIO_CHECK_ASSERT(!A.has_thumbnail()); + + // Non-square asymmetric vertical gradient. The top/bottom colors are + // deliberately not R/B mirror images, so a flip, an R/B swap, and both + // together each alter the image. + auto gradient = [](int w, int h, int nchans) { + ImageBuf buf(ImageSpec(w, h, nchans, TypeUInt8)); + static const float top[4] = { 0.2f, 0.3f, 0.8f, 1.0f }; + static const float bottom[4] = { 0.7f, 0.9f, 0.4f, 1.0f }; + ImageBufAlgo::fill(buf, cspan(top), cspan(bottom)); + return buf; + }; + ImageBuf thumb = gradient(16, 12, 3); + + A.set_thumbnail(thumb); + OIIO_CHECK_ASSERT(A.has_thumbnail()); + auto t = A.get_thumbnail(); + OIIO_CHECK_ASSERT(t && t->initialized()); + OIIO_CHECK_EQUAL(t->spec().width, 16); + OIIO_CHECK_EQUAL(t->spec().height, 12); + OIIO_CHECK_EQUAL(A.spec().get_int_attribute("thumbnail_width"), 16); + OIIO_CHECK_EQUAL(A.spec().get_int_attribute("thumbnail_height"), 12); + OIIO_CHECK_EQUAL(A.spec().get_int_attribute("thumbnail_nchannels"), 3); + OIIO_CHECK_EQUAL(ImageBufAlgo::compare(*t, thumb, 0.0f, 0.0f).nfail, 0); + + // Test that `set_thumbnail` stores a deep copy. Mutating the source image + // afterward must not affect the stored thumbnail. + ImageBufAlgo::zero(thumb); + t = A.get_thumbnail(); + OIIO_CHECK_EQUAL( + ImageBufAlgo::compare(*t, gradient(16, 12, 3), 0.0f, 0.0f).nfail, 0); + + // Replace A's thumbnail with a new image. + A.set_thumbnail(gradient(8, 6, 3)); + t = A.get_thumbnail(); + OIIO_CHECK_EQUAL(t->spec().width, 8); + OIIO_CHECK_EQUAL(t->spec().height, 6); + OIIO_CHECK_EQUAL(A.spec().get_int_attribute("thumbnail_width"), 8); + OIIO_CHECK_EQUAL(A.spec().get_int_attribute("thumbnail_height"), 6); + + // Test that setting an uninitialized thumbnail clears it. + A.set_thumbnail(ImageBuf()); + OIIO_CHECK_ASSERT(!A.has_thumbnail()); + OIIO_CHECK_EQUAL(A.spec().get_int_attribute("thumbnail_width"), 0); + + // Test that `clear_thumbnail` removes the thumbnail and its metadata. + A.set_thumbnail(gradient(16, 12, 3)); + OIIO_CHECK_ASSERT(A.has_thumbnail()); + A.clear_thumbnail(); + OIIO_CHECK_ASSERT(!A.has_thumbnail()); + OIIO_CHECK_EQUAL(A.spec().get_int_attribute("thumbnail_width"), 0); +} + + + +void +test_thumbnail_tga() +{ + std::cout << "\nTesting thumbnail round trip through a TGA file:\n"; + ImageBuf A(ImageSpec(64, 48, 3, TypeUInt8)); + ImageBufAlgo::zero(A); + + // Non-square asymmetric vertical gradient. The top/bottom colors are + // deliberately not R/B mirror images, so a flip, an R/B swap, and both + // together each alter the image. + auto gradient = [](int w, int h, int nchans) { + ImageBuf buf(ImageSpec(w, h, nchans, TypeUInt8)); + static const float top[4] = { 0.2f, 0.3f, 0.8f, 1.0f }; + static const float bottom[4] = { 0.7f, 0.9f, 0.4f, 1.0f }; + ImageBufAlgo::fill(buf, cspan(top), cspan(bottom)); + return buf; + }; + + // Test that the thumbnail content survives a write/read round trip exactly. + A.set_thumbnail(gradient(16, 12, 3)); + OIIO_CHECK_ASSERT(A.write("imagebuf_test_thumb1.tga")); + { + ImageBuf in("imagebuf_test_thumb1.tga"); + OIIO_CHECK_ASSERT(in.has_thumbnail()); + auto rt = in.get_thumbnail(); + OIIO_CHECK_ASSERT(rt && rt->initialized()); + OIIO_CHECK_EQUAL(rt->spec().width, 16); + OIIO_CHECK_EQUAL(rt->spec().height, 12); + OIIO_CHECK_EQUAL(rt->spec().nchannels, 3); + OIIO_CHECK_EQUAL( + ImageBufAlgo::compare(*rt, gradient(16, 12, 3), 0.0f, 0.0f).nfail, + 0); + } + + // Test an oversized thumbnail is resized to fit the + // format's 255 pixel dimension limit, preserving aspect ratio. + A.set_thumbnail(gradient(512, 384, 3)); + OIIO_CHECK_ASSERT(A.write("imagebuf_test_thumb2.tga")); + { + ImageBuf in("imagebuf_test_thumb2.tga"); + OIIO_CHECK_ASSERT(in.has_thumbnail()); + auto rt = in.get_thumbnail(); + OIIO_CHECK_ASSERT(rt && rt->initialized()); + OIIO_CHECK_EQUAL(rt->spec().width, 255); + OIIO_CHECK_EQUAL(rt->spec().height, 191); + } + + A.set_thumbnail(gradient(384, 512, 3)); + OIIO_CHECK_ASSERT(A.write("imagebuf_test_thumb3.tga")); + { + ImageBuf in("imagebuf_test_thumb3.tga"); + OIIO_CHECK_ASSERT(in.has_thumbnail()); + auto rt = in.get_thumbnail(); + OIIO_CHECK_ASSERT(rt && rt->initialized()); + OIIO_CHECK_EQUAL(rt->spec().width, 191); + OIIO_CHECK_EQUAL(rt->spec().height, 255); + } + + // Test a thumbnail whose channel count doesn't match the image can't be + // stored in a TGA file; the image is written without one. + A.set_thumbnail(gradient(16, 12, 4)); + OIIO_CHECK_ASSERT(A.write("imagebuf_test_thumb4.tga")); + { + ImageBuf in("imagebuf_test_thumb4.tga"); + OIIO_CHECK_ASSERT(!in.has_thumbnail()); + } + + // Test conversion between associated and unassociated alpha. + ImageBuf rgba_image(ImageSpec(64, 48, 4, TypeUInt8)); + ImageBufAlgo::zero(rgba_image); + ImageBuf rgba_thumb(ImageSpec(16, 12, 4, TypeUInt8)); + const float premult_rgba[4] = { 0.4f, 0.3f, 0.2f, 0.5f }; + ImageBufAlgo::fill(rgba_thumb, cspan(premult_rgba)); + rgba_image.set_thumbnail(rgba_thumb); + OIIO_CHECK_ASSERT(rgba_image.write("imagebuf_test_thumb5.tga")); + { + ImageBuf in("imagebuf_test_thumb5.tga"); + OIIO_CHECK_ASSERT(in.has_thumbnail()); + auto rt = in.get_thumbnail(); + OIIO_CHECK_ASSERT(rt && rt->initialized()); + OIIO_CHECK_EQUAL( + ImageBufAlgo::compare(*rt, rgba_thumb, 0.005f, 0.005f).nfail, 0); + } + + for (const char* f : + { "imagebuf_test_thumb1.tga", "imagebuf_test_thumb2.tga", + "imagebuf_test_thumb3.tga", "imagebuf_test_thumb4.tga", + "imagebuf_test_thumb5.tga" }) + Filesystem::remove(f); +} + + + void test_read_channel_subset() { @@ -799,6 +953,9 @@ main(int argc, char* argv[]) test_set_get_pixels(); time_get_pixels(); + test_thumbnail(); + test_thumbnail_tga(); + test_write_over(); test_uncaught_error(); diff --git a/src/oiiotool/oiiotool.cpp b/src/oiiotool/oiiotool.cpp index 51dbf281cd..d44e718f92 100644 --- a/src/oiiotool/oiiotool.cpp +++ b/src/oiiotool/oiiotool.cpp @@ -887,6 +887,7 @@ adjust_output_options(string_view filename, ImageSpec& spec, const ImageSpec* nativespec, const Oiiotool& ot, int subimage_index, int nsubimages, bool format_supports_tiles, + bool format_supports_thumbnail, const ParamValueList& fileoptions, bool was_direct_read = false) { @@ -1059,6 +1060,16 @@ adjust_output_options(string_view filename, ImageSpec& spec, spec.erase_attribute("oiio:SHA-1"); spec.erase_attribute("oiio:ConstantColor"); spec.erase_attribute("oiio:AverageColor"); + + // If the output format can't embed a thumbnail, don't let the thumbnail + // bookkeeping attributes leak into the file as metadata describing a + // thumbnail that isn't actually there. + if (!format_supports_thumbnail) { + spec.erase_attribute("thumbnail_width"); + spec.erase_attribute("thumbnail_height"); + spec.erase_attribute("thumbnail_nchannels"); + spec.erase_attribute("thumbnail_image"); + } } @@ -3214,6 +3225,91 @@ action_subimage_append_all(Oiiotool& ot, cspan argv) +// --get-thumbnail +static void +action_get_thumbnail(Oiiotool& ot, cspan argv) +{ + if (ot.postpone_callback(1, action_get_thumbnail, argv)) + return; + string_view command = ot.express(argv[0]); + OTScopedTimer timer(ot, command); + + // Parse options from the command token, e.g. + // --get-thumbnail:fail=0:index=0 + auto options = ot.extract_options(command); + + bool fail_if_missing = options.get_int("fail", 1); + int index = options.get_int("index", 0); + + // The current ImageInput/ImageBuf API exposes only a single (primary) + // thumbnail per subimage. Formats that embed multiple thumbnails at + // different resolutions (such as some camera raw formats) cannot yet be + // distinguished, so for now only index 0 is valid. The `:index=` modifier + // reserves the syntax for when the API gains multi-thumbnail support. + // See https://github.com/AcademySoftwareFoundation/OpenImageIO/issues/4888 + if (index != 0) { + ot.errorfmt(command, + "Thumbnail index {} is not available; only the primary " + "thumbnail (index 0) is currently supported", + index); + return; + } + + ImageRecRef A = ot.pop(); + // Reading the spec loads the thumbnail, so avoid a full pixel read. + if (!ot.read_nativespec(A)) { + ot.push(A); + return; + } + + auto thumb = (*A)(0, 0).get_thumbnail(); + if (!thumb || !thumb->initialized()) { + if (fail_if_missing) { + ot.errorfmt(command, "Image \"{}\" has no thumbnail", A->name()); + ot.push(A); + return; + } + ot.push(new ImageRec(ImageBufRef(new ImageBuf()), false)); + return; + } + ot.push(new ImageRec(ImageBufRef(new ImageBuf(*thumb)), false)); +} + + + +// --set-thumbnail +static void +action_set_thumbnail(Oiiotool& ot, cspan argv) +{ + if (ot.postpone_callback(2, action_set_thumbnail, argv)) + return; + string_view command = ot.express(argv[0]); + OTScopedTimer timer(ot, command); + + // Top image is the thumbnail + ImageRecRef T = ot.pop(); + ImageRecRef A = ot.pop(); + if (!ot.read(T) || !ot.read(A)) { + ot.push(A); + ot.push(T); + return; + } + + const ImageBuf& thumb((*T)(0, 0)); + if (!thumb.initialized()) { + ot.errorfmt(command, "Thumbnail image \"{}\" is empty", T->name()); + ot.push(A); + ot.push(T); + return; + } + + (*A)(0, 0).set_thumbnail(thumb); + A->update_spec_from_imagebuf(0, 0); + ot.push(A); +} + + + // --colorcount static void action_colorcount(Oiiotool& ot, cspan argv) @@ -5495,6 +5591,7 @@ input_file(Oiiotool& ot, cspan argv) ot.printinfo_format); TypeDesc input_dataformat(fileoptions.get_string("type")); std::string channel_set = fileoptions["ch"]; + bool get_thumbnail = fileoptions.get_int("get_thumbnail", 0); for (int i = 0; i < std::ssize(argv); i++) { // FIXME: this loop is pointless, since there is ever only one arg @@ -5637,6 +5734,20 @@ input_file(Oiiotool& ot, cspan argv) // the input timer. timer.stop(); + if (get_thumbnail && !substitute) { + // Swap in the embedded thumbnail via the --get-thumbnail logic. + // Done before autoorient/autocc so they apply to the thumbnail. + std::string thumbcmd = "--get-thumbnail"; + if (fileoptions.contains("fail")) + thumbcmd += Strutil::fmt::format(":fail={}", + fileoptions.get_int("fail")); + if (fileoptions.contains("index")) + thumbcmd += Strutil::fmt::format(":index={}", + fileoptions.get_int("index")); + const char* argv[] = { thumbcmd.c_str() }; + action_get_thumbnail(ot, argv); + } + if (ot.autoorient) { void action_reorient(Oiiotool & ot, cspan argv); const char* argv[] = { "--reorient" }; @@ -5897,8 +6008,9 @@ output_file(Oiiotool& ot, cspan argv) } bool supports_displaywindow = out->supports("displaywindow"); bool supports_negativeorigin = out->supports("negativeorigin"); - bool supports_tiles = out->supports("tiles") || ot.output_force_tiles; - bool procedural = out->supports("procedural"); + bool supports_tiles = out->supports("tiles") || ot.output_force_tiles; + bool procedural = out->supports("procedural"); + bool supports_thumbnail = out->supports("thumbnail"); if (!ot.read()) { return; } @@ -6078,7 +6190,7 @@ output_file(Oiiotool& ot, cspan argv) if (do_tex || do_latlong || do_bumpslopes) { ImageSpec configspec; adjust_output_options(filename, configspec, nullptr, ot, 0, 1, - supports_tiles, fileoptions); + supports_tiles, supports_thumbnail, fileoptions); prep_texture_config(ot, configspec, fileoptions); ImageBufAlgo::MakeTextureMode mode = ImageBufAlgo::MakeTxTexture; if (do_shad) @@ -6108,8 +6220,8 @@ output_file(Oiiotool& ot, cspan argv) for (int s = 0, send = ir->subimages(); s < send; ++s) { ImageSpec spec = *ir->spec(s, 0); adjust_output_options(filename, spec, ir->nativespec(s), ot, s, - send, supports_tiles, fileoptions, - (*ir)[s].was_direct_read()); + send, supports_tiles, supports_thumbnail, + fileoptions, (*ir)[s].was_direct_read()); // If it's not tiled and MIP-mapped, remove any "textureformat" if (!spec.tile_pixels() || ir->miplevels(s) <= 1) spec.erase_attribute("textureformat"); @@ -6150,7 +6262,8 @@ output_file(Oiiotool& ot, cspan argv) for (int m = 0, mend = ir->miplevels(s); m < mend && ok; ++m) { ImageSpec spec = *ir->spec(s, m); adjust_output_options(filename, spec, ir->nativespec(s, m), ot, - s, send, supports_tiles, fileoptions, + s, send, supports_tiles, + supports_thumbnail, fileoptions, (*ir)[s].was_direct_read()); if (s > 0 || m > 0) { // already opened first subimage/level if (!out->open(tmpfilename, spec, mode)) { @@ -6942,7 +7055,8 @@ Oiiotool::getargs(int argc, char* argv[]) ap.separator("Commands that read images:"); ap.arg("-i %s:FILENAME") - .help("Input file (options: autocc=, ch=, info=, infoformat=, native=, now=, type=, unpremult=)") + .help("Input file (options: autocc=, ch=, get_thumbnail=, info=, " + "infoformat=, native=, now=, type=, unpremult=)") .OTACTION(input_file); ap.arg("--iconfig %s:NAME %s:VALUE") .help("Sets input config attribute (options: type=...)") @@ -7393,6 +7507,12 @@ Oiiotool::getargs(int argc, char* argv[]) ap.arg("--flatten") .help("Flatten deep image to non-deep") .OTACTION(action_flatten); + ap.arg("--get-thumbnail") + .help("Extract an embedded thumbnail (options: fail=, index=)") + .OTACTION(action_get_thumbnail); + ap.arg("--set-thumbnail") + .help("Attach the top image as the thumbnail of the image below it") + .OTACTION(action_set_thumbnail); ap.separator("Image stack manipulation:"); ap.arg("--label %s") diff --git a/src/targa.imageio/targainput.cpp b/src/targa.imageio/targainput.cpp index b168508374..81c0aacaa6 100644 --- a/src/targa.imageio/targainput.cpp +++ b/src/targa.imageio/targainput.cpp @@ -501,77 +501,6 @@ TGAInput::read_tga2_header() -bool -TGAInput::get_thumbnail(ImageBuf& thumb, int subimage) -{ - if (m_ofs_thumb <= 0) - return false; // no thumbnail info - - lock_guard lock(*this); - bool result = false; - int64_t save_offset = iotell(); - - if (!ioseek(m_ofs_thumb)) - return false; - - // Read the thumbnail dimensions -- sometimes it's 0x0 to indicate no - // thumbnail. - unsigned char res[2]; - if (!ioread(&res, 2, 1)) - return false; - if (res[0] > 0 && res[1] > 0) { - // Most of this code is a dupe of readimg(); according to the spec, - // the thumbnail is in the same format as the main image but - // uncompressed. - ImageSpec thumbspec(res[0], res[1], m_spec.nchannels, TypeUInt8); - thumbspec.set_colorspace("srgb_rec709_scene"); - thumb.reset(thumbspec); - int bytespp = (m_tga.bpp == 15) ? 2 : (m_tga.bpp / 8); - int palbytespp = (m_tga.cmap_size == 15) ? 2 : (m_tga.cmap_size / 8); - int alphabits = m_tga.attr & 0x0F; - if (alphabits == 0 && m_tga.bpp == 32) - alphabits = 8; - // read palette, if there is any - std::unique_ptr palette; - size_t palette_alloc_size = 0; - if (is_palette()) { - if (!ioseek(m_ofs_palette)) { - return false; - } - palette_alloc_size = palbytespp * m_tga.cmap_length; - palette.reset(new unsigned char[palette_alloc_size]); - if (!ioread(palette.get(), palbytespp, m_tga.cmap_length)) - return false; - if (!ioseek(m_ofs_thumb + 2)) { - return false; - } - } - // load pixel data - unsigned char pixel[4]; - unsigned char in[4]; - for (int64_t y = thumbspec.height - 1; y >= 0; y--) { - char* img = (char*)thumb.pixeladdr(0, y); - for (int64_t x = 0; x < thumbspec.width; - x++, img += m_spec.nchannels) { - if (!ioread(in, bytespp, 1)) - return false; - if (!decode_pixel(in, pixel, palette.get(), bytespp, palbytespp, - palette_alloc_size)) - return false; - memcpy(img, pixel, m_spec.nchannels); - } - } - result = true; - } - - if (!ioseek(save_offset)) { - return false; - } - return result; -} - - - inline bool TGAInput::decode_pixel(unsigned char* in, unsigned char* out, unsigned char* palette, int bytespp, int palbytespp, @@ -706,6 +635,95 @@ associateAlpha(T* data, int64_t size, int channels, int alpha_channel, +bool +TGAInput::get_thumbnail(ImageBuf& thumb, int subimage) +{ + if (m_ofs_thumb <= 0) + return false; // no thumbnail info + + lock_guard lock(*this); + bool result = false; + int64_t save_offset = iotell(); + + if (!ioseek(m_ofs_thumb)) + return false; + + // Read the thumbnail dimensions -- sometimes it's 0x0 to indicate no + // thumbnail. + unsigned char res[2]; + if (!ioread(&res, 2, 1)) + return false; + if (res[0] > 0 && res[1] > 0) { + // Most of this code is a dupe of readimg(); according to the spec, + // the thumbnail is in the same format as the main image but + // uncompressed. + ImageSpec thumbspec(res[0], res[1], m_spec.nchannels, TypeUInt8); + thumbspec.set_colorspace("srgb_rec709_scene"); + thumb.reset(thumbspec); + int bytespp = (m_tga.bpp == 15) ? 2 : (m_tga.bpp / 8); + int palbytespp = (m_tga.cmap_size == 15) ? 2 : (m_tga.cmap_size / 8); + int alphabits = m_tga.attr & 0x0F; + if (alphabits == 0 && m_tga.bpp == 32) + alphabits = 8; + // read palette, if there is any + std::unique_ptr palette; + size_t palette_alloc_size = 0; + if (is_palette()) { + if (!ioseek(m_ofs_palette)) { + return false; + } + palette_alloc_size = palbytespp * m_tga.cmap_length; + palette.reset(new unsigned char[palette_alloc_size]); + if (!ioread(palette.get(), palbytespp, m_tga.cmap_length)) + return false; + if (!ioseek(m_ofs_thumb + 2)) { + return false; + } + } + // load pixel data + unsigned char pixel[4]; + unsigned char in[4]; + for (int64_t y = thumbspec.height - 1; y >= 0; y--) { + char* img = (char*)thumb.pixeladdr(0, y); + for (int64_t x = 0; x < thumbspec.width; + x++, img += m_spec.nchannels) { + if (!ioread(in, bytespp, 1)) + return false; + if (!decode_pixel(in, pixel, palette.get(), bytespp, palbytespp, + palette_alloc_size)) + return false; + memcpy(img, pixel, m_spec.nchannels); + } + } + // Convert to associated alpha, matching readimg() and OIIO's in-memory + // convention; TGA stores unassociated (unpremultiplied) alpha. + if (m_spec.alpha_channel != -1 && !m_keep_unassociated_alpha + && m_alpha_type != TGA_ALPHA_PREMULTIPLIED) { + bool alpha0_everywhere = (m_tga_version == 1); + int64_t size = thumbspec.image_pixels(); + unsigned char* tpx = (unsigned char*)thumb.localpixels(); + for (int64_t i = 0; i < size; ++i) + if (tpx[i * thumbspec.nchannels + m_spec.alpha_channel]) { + alpha0_everywhere = false; + break; + } + if (!alpha0_everywhere) { + float gamma = m_spec.get_float_attribute("oiio:Gamma", 1.0f); + associateAlpha(tpx, size, thumbspec.nchannels, + m_spec.alpha_channel, gamma); + } + } + result = true; + } + + if (!ioseek(save_offset)) { + return false; + } + return result; +} + + + bool TGAInput::readimg() { diff --git a/src/targa.imageio/targaoutput.cpp b/src/targa.imageio/targaoutput.cpp index ef75e2c0bb..a1af3a8921 100644 --- a/src/targa.imageio/targaoutput.cpp +++ b/src/targa.imageio/targaoutput.cpp @@ -261,9 +261,25 @@ TGAOutput::write_tga20_data_fields() OIIO_DASSERT(tw && th && tc == m_spec.nchannels); ofs_thumb = (uint32_t)iotell(); // dump thumbnail size - if (!write(tw) || !write(th) - || !write(m_thumb.localpixels(), m_thumb.spec().image_bytes())) + if (!write(tw) || !write(th)) return false; + // Encode the thumbnail in TGA pixel order: write scanlines + // bottom-up, deassociate alpha, and convert RGB(A) to BGR(A). + // Similar to `TGAOutput::write_scanline`. + std::vector buf(m_thumb.spec().scanline_bytes()); + for (int y = th - 1; y >= 0; --y) { + if (!m_thumb.get_pixels(ROI(0, tw, y, y + 1, 0, 1, 0, tc), + make_span(buf))) + return false; + if (m_convert_alpha) + deassociateAlpha(buf.data(), tw, tc, m_spec.alpha_channel, + m_gamma); + if (tc >= 3) + for (int x = 0; x < tw; ++x) + std::swap(buf[x * tc], buf[x * tc + 2]); + if (!write(buf.data(), tc, tw)) + return false; + } } // prepare the footer @@ -671,15 +687,17 @@ TGAOutput::set_thumbnail(const ImageBuf& thumb) // Zero size thumbnail or channels don't match return false; } - // TARGA has a limitation of 256 res for thumbnail dimensions, and - // must be UINT8. + // TARGA thumbnails must be UINT8, and each dimension is stored in a single + // byte, so the maximum size is 255 (256 would truncate to 0 and the reader + // would treat the thumbnail as absent). if (thumb.spec().width >= 256 || thumb.spec().height >= 256) { - ROI roi(0, 256, 0, 256, 0, 1, 0, thumb.nchannels()); + // Resize to fit within 255 while preserving aspect ratio. + ROI roi(0, 255, 0, 255, 0, 1, 0, thumb.nchannels()); float ratio = float(thumb.spec().width) / float(thumb.spec().height); if (ratio >= 1.0f) { - roi.yend = (int)roundf(256.0f / ratio); + roi.yend = (int)roundf(255.0f / ratio); } else { - roi.xend = (int)roundf(256.0f * ratio); + roi.xend = (int)roundf(255.0f * ratio); } m_thumb = ImageBufAlgo::resize(thumb, ImageBufAlgo::KWArgs(), roi, this->threads()); diff --git a/testsuite/oiiotool-get-thumbnail/ref/out.txt b/testsuite/oiiotool-get-thumbnail/ref/out.txt new file mode 100644 index 0000000000..2be87f841a --- /dev/null +++ b/testsuite/oiiotool-get-thumbnail/ref/out.txt @@ -0,0 +1,20 @@ +modifiers 160x120 +thumbnail 160x120 +full 200x150 +oiiotool ERROR: --get-thumbnail : Image "../common/tahoe-small.tif" has no thumbnail +Full command line was: +> oiiotool ../common/tahoe-small.tif --get-thumbnail +no thumbnail, skipped output +oiiotool ERROR: --get-thumbnail:index=1:fail=0 : Thumbnail index 1 is not available; only the primary thumbnail (index 0) is currently supported +Full command line was: +> oiiotool src/with-thumbnail.psd --get-thumbnail:index=1:fail=0 +Computing diff of "thumb_i.tif" vs "thumb.tif" +PASS +autoorient/autocc 160x120 +input fail=0 returned empty +oiiotool ERROR: --get-thumbnail:index=1 : Thumbnail index 1 is not available; only the primary thumbnail (index 0) is currently supported +Full command line was: +> oiiotool -i:get_thumbnail=1:index=1 src/with-thumbnail.psd +get_thumbnail=0 200x150 +Comparing "thumb.tif" and "ref/thumb.tif" +PASS diff --git a/testsuite/oiiotool-get-thumbnail/ref/thumb.tif b/testsuite/oiiotool-get-thumbnail/ref/thumb.tif new file mode 100644 index 0000000000..2aad8fcb4c Binary files /dev/null and b/testsuite/oiiotool-get-thumbnail/ref/thumb.tif differ diff --git a/testsuite/oiiotool-get-thumbnail/run.py b/testsuite/oiiotool-get-thumbnail/run.py new file mode 100644 index 0000000000..69d0441a9b --- /dev/null +++ b/testsuite/oiiotool-get-thumbnail/run.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python + +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +# Capture error output +redirect = " >> out.txt 2>&1 " + +psd = "src/with-thumbnail.psd" +no_thumb = "../common/tahoe-small.tif" + +# Test extracting a present thumbnail. +command += oiiotool (psd + " --get-thumbnail -o thumb.tif") + +# Test valid modifiers and stack integrity. +command += oiiotool (psd + " --get-thumbnail:index=0:fail=0" + + " --echo \"modifiers {TOP.width}x{TOP.height}\"") +command += oiiotool (psd + " --dup --get-thumbnail" + + " --echo \"thumbnail {TOP.width}x{TOP.height}\"" + + " --pop --echo \"full {TOP.width}x{TOP.height}\"") + +# Test missing-thumbnail behavior. +command += oiiotool (no_thumb + " --get-thumbnail", failureok=True) +command += oiiotool (no_thumb + " --get-thumbnail:fail=0" + + " --if \"{TOP.width}\" --echo unexpected" + + " --else --echo \"no thumbnail, skipped output\" --endif") + +# Test that fail=0 does not suppress an invalid index. +command += oiiotool (psd + " --get-thumbnail:index=1:fail=0", failureok=True) + +# Test that the -i:get_thumbnail=1 read modifier produces the same thumbnail +# as the equivalent --get-thumbnail command above. +command += oiiotool ("-i:get_thumbnail=1 " + psd + " -o thumb_i.tif") +command += oiiotool ("--diff thumb_i.tif thumb.tif") + +# Test that autoorient/autocc act on the thumbnail, not the main image. +command += oiiotool ("--autoorient --autocc -i:get_thumbnail=1 " + psd + + " --echo \"autoorient/autocc {TOP.width}x{TOP.height}\"") + +# Test that -i:get_thumbnail forwards modifiers. +command += oiiotool ("-i:get_thumbnail=1:fail=0 " + no_thumb + + " --if \"{TOP.width}\" --echo unexpected" + + " --else --echo \"input fail=0 returned empty\" --endif") +command += oiiotool ("-i:get_thumbnail=1:index=1 " + psd, failureok=True) + +# Test that -i:get_thumbnail=0 returns the main image. +command += oiiotool ("-i:get_thumbnail=0 " + psd + " --echo \"get_thumbnail=0 {TOP.width}x{TOP.height}\"") + +outputs = [ "thumb.tif", "out.txt" ] diff --git a/testsuite/oiiotool-get-thumbnail/src/with-thumbnail.psd b/testsuite/oiiotool-get-thumbnail/src/with-thumbnail.psd new file mode 100644 index 0000000000..028e0cfa0e Binary files /dev/null and b/testsuite/oiiotool-get-thumbnail/src/with-thumbnail.psd differ diff --git a/testsuite/oiiotool-set-thumbnail/ref/out.txt b/testsuite/oiiotool-set-thumbnail/ref/out.txt new file mode 100644 index 0000000000..ba4c175ad2 --- /dev/null +++ b/testsuite/oiiotool-set-thumbnail/ref/out.txt @@ -0,0 +1,33 @@ +after set-thumbnail: 512x384 +Reading out.tga +out.tga : 512 x 384, 3 channel, uint8 targa + SHA-1: 55B55C41EB65BF7B741671DBFFA29CA970D4877C + channel list: R, G, B + compression: "rle" + thumbnail_height: 38 + thumbnail_nchannels: 3 + thumbnail_width: 50 + oiio:BitsPerSample: 8 + targa:version: 2 +Computing diff of "thumb.tif" vs "resize" +PASS +Reading no_thumb.tif +no_thumb.tif : 512 x 384, 3 channel, uint8 tiff + channel list: R, G, B + compression: "lzw" + Orientation: 1 (normal) + PixelAspectRatio: 1 + planarconfig: "contig" + ResolutionUnit: "in" + XResolution: 72 + YResolution: 72 + oiio:BitsPerSample: 8 + tiff:Compression: 5 + tiff:PhotometricInterpretation: 2 + tiff:PlanarConfiguration: 1 + tiff:RowsPerStrip: 32 +oiiotool ERROR: --set-thumbnail : Thumbnail image "" is empty +Full command line was: +> oiiotool ../common/tahoe-small.tif --dup --get-thumbnail:fail=0 --set-thumbnail +oiiotool WARNING: --set-thumbnail : pending command never executed +oiiotool WARNING : oiiotool produced no output. Did you forget -o? diff --git a/testsuite/oiiotool-set-thumbnail/run.py b/testsuite/oiiotool-set-thumbnail/run.py new file mode 100644 index 0000000000..02efe5c9ca --- /dev/null +++ b/testsuite/oiiotool-set-thumbnail/run.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python + +# Copyright Contributors to the OpenImageIO project. +# SPDX-License-Identifier: Apache-2.0 +# https://github.com/AcademySoftwareFoundation/OpenImageIO + +# Capture error output +redirect = " >> out.txt 2>&1 " + +src = "../common/tahoe-small.tif" + +# Test that a thumbnail embeds into TGA (currently the only format that can), +# is reported in the file's metadata, and leaves the main image on the stack. +command += oiiotool (src + " --dup --resize:filter=box 50x38 --set-thumbnail" + + " --echo \"after set-thumbnail: {TOP.width}x{TOP.height}\"" + + " -o out.tga") +command += info_command ("out.tga", safematch=True) + +# Test that an embed/extract round trip matches a directly resized image. +command += oiiotool ("out.tga --get-thumbnail -o thumb.tif") +command += oiiotool ("--warn 0.005 --fail 0.005 thumb.tif " + src + + " --resize:filter=box 50x38 --diff") + +# Test that thumbnail metadata is omitted from formats without thumbnail +# support. +command += oiiotool (src + " --dup --resize:filter=box 50x38 --set-thumbnail -o no_thumb.tif") +command += info_command ("no_thumb.tif", safematch=True, hash=False) + +# Test error cases: an empty thumbnail image, and too few images on the stack. +command += oiiotool (src + " --dup --get-thumbnail:fail=0 --set-thumbnail", + failureok=True) +command += oiiotool (src + " --set-thumbnail", failureok=True) + +outputs = [ "out.txt" ]