From bdd2af39bb63f40ad0eb4e1fa873dbda9dcdfa15 Mon Sep 17 00:00:00 2001 From: jinniekim Date: Tue, 9 Jun 2026 00:15:40 -0700 Subject: [PATCH 1/8] feat(oiiotool): Add `--get-thumbnail` Assisted-by: Claude Code / opus-4.8 Signed-off-by: Jinnie Kim --- src/cmake/testing.cmake | 1 + src/doc/oiiotool.rst | 31 +++++++++++ src/oiiotool/oiiotool.cpp | 51 ++++++++++++++++++ testsuite/oiiotool-thumbnail/ref/out.txt | 28 ++++++++++ testsuite/oiiotool-thumbnail/ref/thumb.tif | Bin 0 -> 11757 bytes testsuite/oiiotool-thumbnail/run.py | 40 ++++++++++++++ .../oiiotool-thumbnail/src/with-thumbnail.psd | Bin 0 -> 29926 bytes 7 files changed, 151 insertions(+) create mode 100644 testsuite/oiiotool-thumbnail/ref/out.txt create mode 100644 testsuite/oiiotool-thumbnail/ref/thumb.tif create mode 100644 testsuite/oiiotool-thumbnail/run.py create mode 100644 testsuite/oiiotool-thumbnail/src/with-thumbnail.psd diff --git a/src/cmake/testing.cmake b/src/cmake/testing.cmake index 93aa7345e6..06190f5be6 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-thumbnail oiiotool-xform diff flip diff --git a/src/doc/oiiotool.rst b/src/doc/oiiotool.rst index ce6fd570c2..33e34e4af0 100644 --- a/src/doc/oiiotool.rst +++ b/src/doc/oiiotool.rst @@ -2339,6 +2339,37 @@ 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. + A thumbnail is a property of the whole file, not of an individual subimage. + 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.exr --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.exr --get-thumbnail:fail=0 --if "{TOP.width}" -o thumb.jpg --endif + .. option:: --sisplit Remove the top image from the stack, split it into its constituent diff --git a/src/oiiotool/oiiotool.cpp b/src/oiiotool/oiiotool.cpp index 51dbf281cd..dac859d53f 100644 --- a/src/oiiotool/oiiotool.cpp +++ b/src/oiiotool/oiiotool.cpp @@ -3214,6 +3214,54 @@ 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(); + ot.read(A); + + 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)); +} + + + // --colorcount static void action_colorcount(Oiiotool& ot, cspan argv) @@ -7393,6 +7441,9 @@ 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.separator("Image stack manipulation:"); ap.arg("--label %s") diff --git a/testsuite/oiiotool-thumbnail/ref/out.txt b/testsuite/oiiotool-thumbnail/ref/out.txt new file mode 100644 index 0000000000..904a4bdd67 --- /dev/null +++ b/testsuite/oiiotool-thumbnail/ref/out.txt @@ -0,0 +1,28 @@ +thumb.tif : 160 x 120, 3 channel, uint8 tiff +thumb0.tif : 160 x 120, 3 channel, uint8 tiff +thumb_f0.tif : 160 x 120, 3 channel, uint8 tiff +thumb_foo.tif : 160 x 120, 3 channel, uint8 tiff +thumb_dup.tif : 160 x 120, 3 channel, uint8 tiff +full.tif : 200 x 150, 3 channel, uint8 tiff +oiiotool ERROR: --get-thumbnail : Image "../common/tahoe-small.tif" has no thumbnail +Full command line was: +> oiiotool ../common/tahoe-small.tif --get-thumbnail +fail=0 path completed +oiiotool ERROR: -o : tiff image resolution must be at least 1x1, you asked for 0x0 +Full command line was: +> oiiotool ../common/tahoe-small.tif --get-thumbnail:fail=0 -o empty_thumb.tif +no thumbnail, skipped -o +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 ../common/tahoe-small.tif --get-thumbnail:index=1 +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 ../common/tahoe-small.tif --get-thumbnail:index=-1 +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 src/with-thumbnail.psd --get-thumbnail:index=1 +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 ../common/tahoe-small.tif --get-thumbnail:index=1:fail=0 +Comparing "thumb.tif" and "ref/thumb.tif" +PASS diff --git a/testsuite/oiiotool-thumbnail/ref/thumb.tif b/testsuite/oiiotool-thumbnail/ref/thumb.tif new file mode 100644 index 0000000000000000000000000000000000000000..2aad8fcb4ce981026b683ccca9bda42355698b36 GIT binary patch literal 11757 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 literal 0 HcmV?d00001 From 96a2532262510b0cafbb7c23e57da88cf01d9ef8 Mon Sep 17 00:00:00 2001 From: Jinnie Kim Date: Tue, 9 Jun 2026 23:13:48 -0700 Subject: [PATCH 2/8] feat(oiiotool): Add `-i:get_thumbnail` modifier Assisted-by: Claude Code / opus-4.8 Signed-off-by: Jinnie Kim --- src/doc/oiiotool.rst | 18 +++++++++++++++--- src/oiiotool/oiiotool.cpp | 24 ++++++++++++++++++++++-- testsuite/oiiotool-thumbnail/ref/out.txt | 13 +++++++++++++ testsuite/oiiotool-thumbnail/run.py | 20 ++++++++++++++++++++ 4 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/doc/oiiotool.rst b/src/doc/oiiotool.rst index 33e34e4af0..51e48b16b6 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 @@ -2342,7 +2354,7 @@ current top image. .. option:: --get-thumbnail Replace the top image on the stack with its embedded thumbnail. - A thumbnail is a property of the whole file, not of an individual subimage. + 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. @@ -2364,11 +2376,11 @@ current top image. Examples:: # Save the thumbnail - oiiotool input.exr --dup --get-thumbnail -o thumb.jpg + 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.exr --get-thumbnail:fail=0 --if "{TOP.width}" -o thumb.jpg --endif + oiiotool input.psd --get-thumbnail:fail=0 --if "{TOP.width}" -o thumb.jpg --endif .. option:: --sisplit diff --git a/src/oiiotool/oiiotool.cpp b/src/oiiotool/oiiotool.cpp index dac859d53f..196a97c19e 100644 --- a/src/oiiotool/oiiotool.cpp +++ b/src/oiiotool/oiiotool.cpp @@ -3245,7 +3245,11 @@ action_get_thumbnail(Oiiotool& ot, cspan argv) } ImageRecRef A = ot.pop(); - ot.read(A); + // 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()) { @@ -5543,6 +5547,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 @@ -5685,6 +5690,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" }; @@ -6990,7 +7009,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=...)") diff --git a/testsuite/oiiotool-thumbnail/ref/out.txt b/testsuite/oiiotool-thumbnail/ref/out.txt index 904a4bdd67..6c2149ee70 100644 --- a/testsuite/oiiotool-thumbnail/ref/out.txt +++ b/testsuite/oiiotool-thumbnail/ref/out.txt @@ -24,5 +24,18 @@ Full command line was: 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 ../common/tahoe-small.tif --get-thumbnail:index=1:fail=0 +thumb_i.tif : 160 x 120, 3 channel, uint8 tiff +Computing diff of "thumb_i.tif" vs "thumb.tif" +PASS +autoorient 160x120 +autocc 160x120 +oiiotool ERROR: --get-thumbnail : Image "../common/tahoe-small.tif" has no thumbnail +Full command line was: +> oiiotool -i:get_thumbnail=1 ../common/tahoe-small.tif +fail=0 path completed +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-thumbnail/run.py b/testsuite/oiiotool-thumbnail/run.py index 9f2863e7c9..1d01f865de 100644 --- a/testsuite/oiiotool-thumbnail/run.py +++ b/testsuite/oiiotool-thumbnail/run.py @@ -37,4 +37,24 @@ command += oiiotool (psd + " --get-thumbnail:index=1", failureok=True) command += oiiotool (no_thumb + " --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 += info_command ("thumb_i.tif", verbose=False, hash=False) +command += oiiotool ("--diff thumb_i.tif thumb.tif") + +# Test that autoorient/autocc act on the thumbnail not the main image +command += oiiotool ("--autoorient -i:get_thumbnail=1 " + psd + + " --echo \"autoorient {TOP.width}x{TOP.height}\"") +command += oiiotool ("--autocc -i:get_thumbnail=1 " + psd + + " --echo \"autocc {TOP.width}x{TOP.height}\"") + +# Test that -i:get_thumbnail=1 mirrors `--get-thumbnail` +command += oiiotool ("-i:get_thumbnail=1 " + no_thumb, failureok=True) +command += oiiotool ("-i:get_thumbnail=1:fail=0 " + no_thumb + " --echo \"fail=0 path completed\"") +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" ] From 9ace7dc56bc2c5fe62e172dbb52fae39194f737e Mon Sep 17 00:00:00 2001 From: Jinnie Kim Date: Sun, 14 Jun 2026 00:48:57 -0700 Subject: [PATCH 3/8] Rename folder Signed-off-by: Jinnie Kim --- .../ref/out.txt | 0 .../ref/thumb.tif | Bin .../run.py | 0 .../src/with-thumbnail.psd | Bin 4 files changed, 0 insertions(+), 0 deletions(-) rename testsuite/{oiiotool-thumbnail => oiiotool-get-thumbnail}/ref/out.txt (100%) rename testsuite/{oiiotool-thumbnail => oiiotool-get-thumbnail}/ref/thumb.tif (100%) rename testsuite/{oiiotool-thumbnail => oiiotool-get-thumbnail}/run.py (100%) rename testsuite/{oiiotool-thumbnail => oiiotool-get-thumbnail}/src/with-thumbnail.psd (100%) diff --git a/testsuite/oiiotool-thumbnail/ref/out.txt b/testsuite/oiiotool-get-thumbnail/ref/out.txt similarity index 100% rename from testsuite/oiiotool-thumbnail/ref/out.txt rename to testsuite/oiiotool-get-thumbnail/ref/out.txt diff --git a/testsuite/oiiotool-thumbnail/ref/thumb.tif b/testsuite/oiiotool-get-thumbnail/ref/thumb.tif similarity index 100% rename from testsuite/oiiotool-thumbnail/ref/thumb.tif rename to testsuite/oiiotool-get-thumbnail/ref/thumb.tif diff --git a/testsuite/oiiotool-thumbnail/run.py b/testsuite/oiiotool-get-thumbnail/run.py similarity index 100% rename from testsuite/oiiotool-thumbnail/run.py rename to testsuite/oiiotool-get-thumbnail/run.py diff --git a/testsuite/oiiotool-thumbnail/src/with-thumbnail.psd b/testsuite/oiiotool-get-thumbnail/src/with-thumbnail.psd similarity index 100% rename from testsuite/oiiotool-thumbnail/src/with-thumbnail.psd rename to testsuite/oiiotool-get-thumbnail/src/with-thumbnail.psd From 1dbf67aea4e00e74c678101b5b18a7b1ad1290b5 Mon Sep 17 00:00:00 2001 From: Jinnie Kim Date: Sun, 14 Jun 2026 19:52:35 -0700 Subject: [PATCH 4/8] oiiotool-get-thumbnail: Tighten the unittests Assisted-by: Claude Code / opus-4.8 Signed-off-by: Jinnie Kim --- testsuite/oiiotool-get-thumbnail/ref/out.txt | 35 +++--------- testsuite/oiiotool-get-thumbnail/run.py | 58 ++++++++------------ 2 files changed, 31 insertions(+), 62 deletions(-) diff --git a/testsuite/oiiotool-get-thumbnail/ref/out.txt b/testsuite/oiiotool-get-thumbnail/ref/out.txt index 6c2149ee70..2be87f841a 100644 --- a/testsuite/oiiotool-get-thumbnail/ref/out.txt +++ b/testsuite/oiiotool-get-thumbnail/ref/out.txt @@ -1,38 +1,17 @@ -thumb.tif : 160 x 120, 3 channel, uint8 tiff -thumb0.tif : 160 x 120, 3 channel, uint8 tiff -thumb_f0.tif : 160 x 120, 3 channel, uint8 tiff -thumb_foo.tif : 160 x 120, 3 channel, uint8 tiff -thumb_dup.tif : 160 x 120, 3 channel, uint8 tiff -full.tif : 200 x 150, 3 channel, uint8 tiff +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 -fail=0 path completed -oiiotool ERROR: -o : tiff image resolution must be at least 1x1, you asked for 0x0 -Full command line was: -> oiiotool ../common/tahoe-small.tif --get-thumbnail:fail=0 -o empty_thumb.tif -no thumbnail, skipped -o -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 ../common/tahoe-small.tif --get-thumbnail:index=1 -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 ../common/tahoe-small.tif --get-thumbnail:index=-1 -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 src/with-thumbnail.psd --get-thumbnail:index=1 +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 ../common/tahoe-small.tif --get-thumbnail:index=1:fail=0 -thumb_i.tif : 160 x 120, 3 channel, uint8 tiff +> oiiotool src/with-thumbnail.psd --get-thumbnail:index=1:fail=0 Computing diff of "thumb_i.tif" vs "thumb.tif" PASS -autoorient 160x120 -autocc 160x120 -oiiotool ERROR: --get-thumbnail : Image "../common/tahoe-small.tif" has no thumbnail -Full command line was: -> oiiotool -i:get_thumbnail=1 ../common/tahoe-small.tif -fail=0 path completed +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 diff --git a/testsuite/oiiotool-get-thumbnail/run.py b/testsuite/oiiotool-get-thumbnail/run.py index 1d01f865de..69d0441a9b 100644 --- a/testsuite/oiiotool-get-thumbnail/run.py +++ b/testsuite/oiiotool-get-thumbnail/run.py @@ -10,51 +10,41 @@ psd = "src/with-thumbnail.psd" no_thumb = "../common/tahoe-small.tif" -# Test extracting a present thumbnail +# Test extracting a present thumbnail. command += oiiotool (psd + " --get-thumbnail -o thumb.tif") -command += info_command ("thumb.tif", verbose=False, hash=False) -command += oiiotool (psd + " --get-thumbnail:index=0 -o thumb0.tif") -command += info_command ("thumb0.tif", verbose=False, hash=False) -command += oiiotool (psd + " --get-thumbnail:fail=0 -o thumb_f0.tif") -command += info_command ("thumb_f0.tif", verbose=False, hash=False) -command += oiiotool (psd + " --get-thumbnail:foo=1 -o thumb_foo.tif") -command += info_command ("thumb_foo.tif", verbose=False, hash=False) - -# Test stack integrity -command += oiiotool (psd + " --dup --get-thumbnail -o thumb_dup.tif --pop -o full.tif") -command += info_command ("thumb_dup.tif", verbose=False, hash=False) -command += info_command ("full.tif", verbose=False, hash=False) - -# Test missing thumbnail + +# 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 --echo \"fail=0 path completed\"") -command += oiiotool (no_thumb + " --get-thumbnail:fail=0 -o empty_thumb.tif", failureok=True) -command += oiiotool (no_thumb + " --get-thumbnail:fail=0 --if \"{TOP.width}\" -o guarded.tif --else --echo \"no thumbnail, skipped -o\" --endif") +command += oiiotool (no_thumb + " --get-thumbnail:fail=0" + + " --if \"{TOP.width}\" --echo unexpected" + + " --else --echo \"no thumbnail, skipped output\" --endif") -# Test index modifier -command += oiiotool (no_thumb + " --get-thumbnail:index=1", failureok=True) -command += oiiotool (no_thumb + " --get-thumbnail:index=-1", failureok=True) -command += oiiotool (psd + " --get-thumbnail:index=1", failureok=True) -command += oiiotool (no_thumb + " --get-thumbnail:index=1:fail=0", failureok=True) +# 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 +# as the equivalent --get-thumbnail command above. command += oiiotool ("-i:get_thumbnail=1 " + psd + " -o thumb_i.tif") -command += info_command ("thumb_i.tif", verbose=False, hash=False) command += oiiotool ("--diff thumb_i.tif thumb.tif") -# Test that autoorient/autocc act on the thumbnail not the main image -command += oiiotool ("--autoorient -i:get_thumbnail=1 " + psd - + " --echo \"autoorient {TOP.width}x{TOP.height}\"") -command += oiiotool ("--autocc -i:get_thumbnail=1 " + psd - + " --echo \"autocc {TOP.width}x{TOP.height}\"") +# 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=1 mirrors `--get-thumbnail` -command += oiiotool ("-i:get_thumbnail=1 " + no_thumb, failureok=True) -command += oiiotool ("-i:get_thumbnail=1:fail=0 " + no_thumb + " --echo \"fail=0 path completed\"") +# 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 +# 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" ] From b1149a68c9aeab211cd31041eb97e0b2fe96740d Mon Sep 17 00:00:00 2001 From: Jinnie Kim Date: Sun, 14 Jun 2026 19:55:13 -0700 Subject: [PATCH 5/8] fix(targa): Correct embedded thumbnail writing Encode the embedded thumbnail the same way as the main image: bottom-up scanlines, deassociated alpha, and BGR(A) channel order. The original implementation rather dumped the raw in-memory buffer, which left the thumbnail flipped, R/B-swapped, and premultiplied. Also clamp an oversized thumbnail to 255 rather than 256: each postage-stamp dimension is a single byte, so 256 wrapped to 0 and the thumbnail was silently dropped on read. Assisted-by: Claude Code / opus-4.8 Signed-off-by: Jinnie Kim --- src/targa.imageio/targaoutput.cpp | 32 ++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/src/targa.imageio/targaoutput.cpp b/src/targa.imageio/targaoutput.cpp index ef75e2c0bb..71b4107f48 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()); From 37d0a587f2b90819d61d657cb2d1ed47055cbc22 Mon Sep 17 00:00:00 2001 From: Jinnie Kim Date: Sun, 14 Jun 2026 19:56:48 -0700 Subject: [PATCH 6/8] fix(targa): Fix reading embedded thumbnails TGA stores unassociated alpha, but OIIO keeps it associated in memory. `readimg()` converts on read; `get_thumbnail()` did not, so RGBA thumbnails came back too bright. Apply the same `associateAlpha()` conversion to the thumbnail. get_thumbnail() is moved below associateAlpha() so it can call it without a forward declaration. Assisted-by: Claude Code / opus-4.8 Signed-off-by: Jinnie Kim --- src/targa.imageio/targainput.cpp | 160 +++++++++++++++++-------------- 1 file changed, 89 insertions(+), 71 deletions(-) diff --git a/src/targa.imageio/targainput.cpp b/src/targa.imageio/targainput.cpp index b168508374..06e8112c74 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() { From a9d61b0decce5c8f2aea02683878c8c75b64130c Mon Sep 17 00:00:00 2001 From: Jinnie Kim Date: Sun, 14 Jun 2026 19:57:42 -0700 Subject: [PATCH 7/8] feat(oiiotool): Add `--set-thumbnail` Assisted-by: Claude Code / opus-4.8 Signed-off-by: Jinnie Kim --- src/cmake/testing.cmake | 4 +- src/doc/oiiotool.rst | 13 ++ src/libOpenImageIO/imagebuf.cpp | 4 + src/libOpenImageIO/imagebuf_test.cpp | 158 +++++++++++++++++++ src/oiiotool/oiiotool.cpp | 57 ++++++- testsuite/oiiotool-set-thumbnail/ref/out.txt | 33 ++++ testsuite/oiiotool-set-thumbnail/run.py | 34 ++++ 7 files changed, 298 insertions(+), 5 deletions(-) create mode 100644 testsuite/oiiotool-set-thumbnail/ref/out.txt create mode 100644 testsuite/oiiotool-set-thumbnail/run.py diff --git a/src/cmake/testing.cmake b/src/cmake/testing.cmake index 06190f5be6..215040c0fb 100644 --- a/src/cmake/testing.cmake +++ b/src/cmake/testing.cmake @@ -183,7 +183,7 @@ macro (oiio_add_all_tests) oiiotool-readerror oiiotool-subimage oiiotool-text - oiiotool-thumbnail + oiiotool-get-thumbnail oiiotool-xform diff flip @@ -450,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 51e48b16b6..43304668fa 100644 --- a/src/doc/oiiotool.rst +++ b/src/doc/oiiotool.rst @@ -2382,6 +2382,19 @@ current top image. # 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..677ed7eec3 100644 --- a/src/libOpenImageIO/imagebuf_test.cpp +++ b/src/libOpenImageIO/imagebuf_test.cpp @@ -441,6 +441,161 @@ 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 +954,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 196a97c19e..da80d642e4 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"); + } } @@ -3266,6 +3277,39 @@ action_get_thumbnail(Oiiotool& ot, cspan argv) +// --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) @@ -5966,6 +6010,7 @@ output_file(Oiiotool& ot, cspan argv) bool supports_negativeorigin = out->supports("negativeorigin"); 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; } @@ -6145,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) @@ -6175,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"); @@ -6217,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)) { @@ -7464,6 +7510,9 @@ Oiiotool::getargs(int argc, char* argv[]) 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/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" ] From c589b11234615b3adad7fd4da5af94ca69e3884b Mon Sep 17 00:00:00 2001 From: Jinnie Kim Date: Sun, 14 Jun 2026 20:49:15 -0700 Subject: [PATCH 8/8] clang format Signed-off-by: Jinnie Kim --- src/libOpenImageIO/imagebuf_test.cpp | 3 +-- src/oiiotool/oiiotool.cpp | 4 ++-- src/targa.imageio/targainput.cpp | 2 +- src/targa.imageio/targaoutput.cpp | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/libOpenImageIO/imagebuf_test.cpp b/src/libOpenImageIO/imagebuf_test.cpp index 677ed7eec3..3ba32ef81c 100644 --- a/src/libOpenImageIO/imagebuf_test.cpp +++ b/src/libOpenImageIO/imagebuf_test.cpp @@ -583,8 +583,7 @@ test_thumbnail_tga() 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); + ImageBufAlgo::compare(*rt, rgba_thumb, 0.005f, 0.005f).nfail, 0); } for (const char* f : diff --git a/src/oiiotool/oiiotool.cpp b/src/oiiotool/oiiotool.cpp index da80d642e4..d44e718f92 100644 --- a/src/oiiotool/oiiotool.cpp +++ b/src/oiiotool/oiiotool.cpp @@ -6008,8 +6008,8 @@ 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; diff --git a/src/targa.imageio/targainput.cpp b/src/targa.imageio/targainput.cpp index 06e8112c74..81c0aacaa6 100644 --- a/src/targa.imageio/targainput.cpp +++ b/src/targa.imageio/targainput.cpp @@ -700,7 +700,7 @@ TGAInput::get_thumbnail(ImageBuf& thumb, int subimage) 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(); + 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]) { diff --git a/src/targa.imageio/targaoutput.cpp b/src/targa.imageio/targaoutput.cpp index 71b4107f48..a1af3a8921 100644 --- a/src/targa.imageio/targaoutput.cpp +++ b/src/targa.imageio/targaoutput.cpp @@ -688,7 +688,7 @@ TGAOutput::set_thumbnail(const ImageBuf& thumb) return false; } // 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 + // 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) { // Resize to fit within 255 while preserving aspect ratio.