Skip to content

Commit 540d855

Browse files
lukasstocknerlgritz
authored andcommitted
feat(jpeg): Support encoding/decoding arbitrary metadata as comments (#4430)
This is needed to port Blender's current JPEG IO code to using OIIO, but is also a useful feature to have in general. For reading, the code tries to parse comments as colon-separated key-value pairs and sets metadata accordingly. For writing, this needs to be explicitly enabled by setting jpeg:com_attributes to 1 in order to avoid accidentally bloating files for existing applications. Tests: I've added a small (~10KB) JPEG file containing Blender metadata and a basic test that parses it, checks that the metadata was read correctly, writes it twice (once with and once without `jpeg:com_attributes`), and then checks that those files are also parsed as expected. In case you're wondering why the info for "no-attribs.jpg" still contains one Blender attribute - that's because the first COM field is still put into `ImageDescription` just like before, so even without `jpeg:com_attributes` it ends up being written to the output file and recognized during parsing. --------- Signed-off-by: Lukas Stockner <[email protected]>
1 parent b3b7754 commit 540d855

File tree

10 files changed

+253
-4
lines changed

10 files changed

+253
-4
lines changed

src/cmake/testing.cmake

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ macro (oiio_add_all_tests)
144144
oiiotool-demosaic
145145
diff
146146
dither dup-channels
147-
jpeg-corrupt
147+
jpeg-corrupt jpeg-metadata
148148
maketx oiiotool-maketx
149149
misnamed-file
150150
missingcolor

src/doc/builtinplugins.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1037,6 +1037,10 @@ anywhere near the acceptance of the original JPEG/JFIF format.
10371037
reader/writer, and you should assume that nearly everything described
10381038
Appendix :ref:`chap-stdmetadata` is properly translated when using
10391039
JPEG files.
1040+
* - *other*
1041+
-
1042+
- Extra attributes will be read from comment blocks in the JPEG file,
1043+
and can optionally be written if ``jpeg:com_attributes`` is enabled.
10401044

10411045
**Configuration settings for JPEG input**
10421046

@@ -1084,6 +1088,10 @@ control aspects of the writing itself:
10841088
* - ``jpeg:progressive``
10851089
- int
10861090
- If nonzero, will write a progressive JPEG file.
1091+
* - ``jpeg:com_attributes``
1092+
- int
1093+
- If nonzero, extra attributes will be written into the file as comment
1094+
blocks.
10871095

10881096

10891097
**Custom I/O Overrides**

src/include/OpenImageIO/imageio.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2902,6 +2902,13 @@ OIIO_API std::string geterror(bool clear = true);
29022902
/// When nonzero, use the new "OpenEXR core C library" when available,
29032903
/// for OpenEXR >= 3.1. This is experimental, and currently defaults to 0.
29042904
///
2905+
/// - `int jpeg:com_attributes`
2906+
///
2907+
/// When nonzero, try to parse JPEG comment blocks as key-value attributes,
2908+
/// and only set ImageDescription if the parsing fails. Otherwise, always
2909+
/// set ImageDescription to the first comment block. Default is 1.
2910+
///
2911+
///
29052912
/// - `int limits:channels` (1024)
29062913
///
29072914
/// When nonzero, the maximum number of color channels in an image. Image

src/include/imageio_pvt.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ extern OIIO_UTIL_API int oiio_print_debug;
4242
extern OIIO_UTIL_API int oiio_print_uncaught_errors;
4343
extern int oiio_log_times;
4444
extern int openexr_core;
45+
extern int jpeg_com_attributes;
4546
extern int limit_channels;
4647
extern int limit_imagesize_MB;
4748
extern int imagebuf_print_uncaught_errors;

src/jpeg.imageio/jpeginput.cpp

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
#include <OpenImageIO/filesystem.h>
1010
#include <OpenImageIO/fmath.h>
1111
#include <OpenImageIO/imageio.h>
12+
#include <OpenImageIO/strutil.h>
1213
#include <OpenImageIO/tiffutils.h>
1314

1415
#include "jpeg_pvt.h"
@@ -287,10 +288,42 @@ JpgInput::open(const std::string& name, ImageSpec& newspec)
287288
&& !strcmp((const char*)m->data, "Photoshop 3.0"))
288289
jpeg_decode_iptc((unsigned char*)m->data);
289290
else if (m->marker == JPEG_COM) {
291+
std::string data((const char*)m->data, m->data_length);
292+
// Additional string metadata can be stored in JPEG files as
293+
// comment markers in the form "key:value" or "ident:key:value".
294+
// If the string contains a single colon, we assume key:value.
295+
// If there's multiple, we try splitting as ident:key:value and
296+
// check if ident and key are reasonable (in particular, whether
297+
// ident is a C-style identifier and key is not surrounded by
298+
// whitespace). If ident passes but key doesn't, assume key:value.
299+
auto separator = data.find(':');
300+
if (OIIO::get_int_attribute("jpeg:com_attributes")
301+
&& (separator != std::string::npos && separator > 0)) {
302+
std::string left = data.substr(0, separator);
303+
std::string right = data.substr(separator + 1);
304+
separator = right.find(':');
305+
if (separator != std::string::npos && separator > 0) {
306+
std::string mid = right.substr(0, separator);
307+
std::string value = right.substr(separator + 1);
308+
if (Strutil::string_is_identifier(left)
309+
&& (mid == Strutil::trimmed_whitespace(mid))) {
310+
// Valid parsing: left is ident, mid is key
311+
std::string attribute = left + ":" + mid;
312+
if (!m_spec.find_attribute(attribute, TypeDesc::STRING))
313+
m_spec.attribute(attribute, value);
314+
continue;
315+
}
316+
}
317+
if (left == Strutil::trimmed_whitespace(left)) {
318+
// Valid parsing: left is key, right is value
319+
if (!m_spec.find_attribute(left, TypeDesc::STRING))
320+
m_spec.attribute(left, right);
321+
continue;
322+
}
323+
}
324+
// If we made it this far, treat the comment as a description
290325
if (!m_spec.find_attribute("ImageDescription", TypeDesc::STRING))
291-
m_spec.attribute("ImageDescription",
292-
std::string((const char*)m->data,
293-
m->data_length));
326+
m_spec.attribute("ImageDescription", data);
294327
}
295328
}
296329

src/jpeg.imageio/jpegoutput.cpp

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
#include <cassert>
66
#include <cstdio>
7+
#include <set>
78
#include <vector>
89

910
#include <OpenImageIO/filesystem.h>
@@ -117,6 +118,14 @@ OIIO_PLUGIN_EXPORTS_END
117118

118119

119120

121+
static std::set<std::string> metadata_include { "oiio:ConstantColor",
122+
"oiio:AverageColor",
123+
"oiio:SHA-1" };
124+
static std::set<std::string> metadata_exclude {
125+
"XResolution", "YResolution", "PixelAspectRatio",
126+
"ResolutionUnit", "Orientation", "ImageDescription"
127+
};
128+
120129
bool
121130
JpgOutput::open(const std::string& name, const ImageSpec& newspec,
122131
OpenMode mode)
@@ -229,6 +238,36 @@ JpgOutput::open(const std::string& name, const ImageSpec& newspec,
229238
comment.size() + 1);
230239
}
231240

241+
// Write other metadata as JPEG comments if requested
242+
if (m_spec.get_int_attribute("jpeg:com_attributes")) {
243+
for (const auto& p : m_spec.extra_attribs) {
244+
std::string name = p.name().string();
245+
auto colon = name.find(':');
246+
if (metadata_include.count(name)) {
247+
// Allow explicitly included metadata
248+
} else if (metadata_exclude.count(name))
249+
continue; // Suppress metadata that is processed separately
250+
else if (Strutil::istarts_with(name, "ICCProfile"))
251+
continue; // Suppress ICC profile, gets written separately
252+
else if (colon != ustring::npos) {
253+
auto prefix = p.name().substr(0, colon);
254+
if (Strutil::iequals(prefix, "oiio"))
255+
continue; // Suppress internal metadata
256+
else if (Strutil::iequals(prefix, "exif")
257+
|| Strutil::iequals(prefix, "GPS")
258+
|| Strutil::iequals(prefix, "XMP"))
259+
continue; // Suppress EXIF metadata, gets written separately
260+
else if (Strutil::iequals(prefix, "iptc"))
261+
continue; // Suppress IPTC metadata
262+
else if (is_imageio_format_name(prefix))
263+
continue; // Suppress format-specific metadata
264+
}
265+
auto data = p.name().string() + ":" + p.get_string();
266+
jpeg_write_marker(&m_cinfo, JPEG_COM, (JOCTET*)data.c_str(),
267+
data.size());
268+
}
269+
}
270+
232271
if (equivalent_colorspace(m_spec.get_string_attribute("oiio:ColorSpace"),
233272
"sRGB"))
234273
m_spec.attribute("Exif:ColorSpace", 1);

src/libOpenImageIO/imageio.cpp

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ atomic_int oiio_try_all_readers(1);
4848
#endif
4949
// Should we use "Exr core C library"?
5050
int openexr_core(OIIO_OPENEXR_CORE_DEFAULT);
51+
int jpeg_com_attributes(1);
5152
int tiff_half(0);
5253
int tiff_multithread(1);
5354
int dds_bc5normal(0);
@@ -366,6 +367,10 @@ attribute(string_view name, TypeDesc type, const void* val)
366367
openexr_core = *(const int*)val;
367368
return true;
368369
}
370+
if (name == "jpeg:com_attributes" && type == TypeInt) {
371+
jpeg_com_attributes = *(const int*)val;
372+
return true;
373+
}
369374
if (name == "tiff:half" && type == TypeInt) {
370375
tiff_half = *(const int*)val;
371376
return true;
@@ -537,6 +542,10 @@ getattribute(string_view name, TypeDesc type, void* val)
537542
*(int*)val = openexr_core;
538543
return true;
539544
}
545+
if (name == "jpeg:com_attributes" && type == TypeInt) {
546+
*(int*)val = jpeg_com_attributes;
547+
return true;
548+
}
540549
if (name == "tiff:half" && type == TypeInt) {
541550
*(int*)val = tiff_half;
542551
return true;
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
Reading src/blender-render.jpg
2+
src/blender-render.jpg : 640 x 480, 3 channel, uint8 jpeg
3+
SHA-1: A60D05FC42FDEE2FC8907531E3641C17D7C1E3AB
4+
channel list: R, G, B
5+
Blender:Camera: "Camera"
6+
Blender:Date: "2024/09/17 15:50:17"
7+
Blender:File: "<untitled>"
8+
Blender:Frame: "001"
9+
Blender:RenderTime: "00:03.49"
10+
Blender:Scene: "Scene"
11+
Blender:Time: "00:00:00:01"
12+
jpeg:subsampling: "4:2:0"
13+
oiio:ColorSpace: "sRGB"
14+
Comparing "src/blender-render.jpg" and "no-attribs.jpg"
15+
PASS
16+
Reading no-attribs.jpg
17+
no-attribs.jpg : 640 x 480, 3 channel, uint8 jpeg
18+
SHA-1: 329B449C07E6649023504E2C8E5130B41985CF7F
19+
channel list: R, G, B
20+
Exif:ColorSpace: 1
21+
Exif:ExifVersion: "0230"
22+
Exif:FlashPixVersion: "0100"
23+
jpeg:subsampling: "4:2:0"
24+
oiio:ColorSpace: "sRGB"
25+
Reading src/blender-render.jpg
26+
src/blender-render.jpg : 640 x 480, 3 channel, uint8 jpeg
27+
SHA-1: A60D05FC42FDEE2FC8907531E3641C17D7C1E3AB
28+
channel list: R, G, B
29+
Blender:Camera: "Camera"
30+
Blender:Date: "2024/09/17 15:50:17"
31+
Blender:File: "<untitled>"
32+
Blender:Frame: "001"
33+
Blender:RenderTime: "00:03.49"
34+
Blender:Scene: "Scene"
35+
Blender:Time: "00:00:00:01"
36+
jpeg:subsampling: "4:2:0"
37+
oiio:ColorSpace: "sRGB"
38+
Comparing "src/blender-render.jpg" and "with-attribs.jpg"
39+
PASS
40+
Reading with-attribs.jpg
41+
with-attribs.jpg : 640 x 480, 3 channel, uint8 jpeg
42+
SHA-1: 329B449C07E6649023504E2C8E5130B41985CF7F
43+
channel list: R, G, B
44+
Blender:Camera: "Camera"
45+
Blender:Date: "2024/09/17 15:50:17"
46+
Blender:File: "<untitled>"
47+
Blender:Frame: "001"
48+
Blender:RenderTime: "00:03.49"
49+
Blender:Scene: "Scene"
50+
Blender:Time: "00:00:00:01"
51+
Exif:ColorSpace: 1
52+
Exif:ExifVersion: "0230"
53+
Exif:FlashPixVersion: "0100"
54+
jpeg:subsampling: "4:2:0"
55+
oiio:ColorSpace: "sRGB"
56+
Reading src/blender-render.jpg
57+
src/blender-render.jpg : 640 x 480, 3 channel, uint8 jpeg
58+
SHA-1: A60D05FC42FDEE2FC8907531E3641C17D7C1E3AB
59+
channel list: R, G, B
60+
Blender:Camera: "Camera"
61+
Blender:Date: "2024/09/17 15:50:17"
62+
Blender:File: "<untitled>"
63+
Blender:Frame: "001"
64+
Blender:RenderTime: "00:03.49"
65+
Blender:Scene: "Scene"
66+
Blender:Time: "00:00:00:01"
67+
jpeg:subsampling: "4:2:0"
68+
oiio:ColorSpace: "sRGB"
69+
Comparing "src/blender-render.jpg" and "with-attribs-and-desc.jpg"
70+
PASS
71+
Reading with-attribs-and-desc.jpg
72+
with-attribs-and-desc.jpg : 640 x 480, 3 channel, uint8 jpeg
73+
SHA-1: 329B449C07E6649023504E2C8E5130B41985CF7F
74+
channel list: R, G, B
75+
ImageDescription: "A photo"
76+
Blender:Camera: "Camera"
77+
Blender:Date: "2024/09/17 15:50:17"
78+
Blender:File: "<untitled>"
79+
Blender:Frame: "001"
80+
Blender:RenderTime: "00:03.49"
81+
Blender:Scene: "Scene"
82+
Blender:Time: "00:00:00:01"
83+
Exif:ColorSpace: 1
84+
Exif:ExifVersion: "0230"
85+
Exif:FlashPixVersion: "0100"
86+
IPTC:Caption: "A photo"
87+
jpeg:subsampling: "4:2:0"
88+
oiio:ColorSpace: "sRGB"
89+
Reading src/blender-render.jpg
90+
src/blender-render.jpg : 640 x 480, 3 channel, uint8 jpeg
91+
SHA-1: A60D05FC42FDEE2FC8907531E3641C17D7C1E3AB
92+
channel list: R, G, B
93+
Blender:Camera: "Camera"
94+
Blender:Date: "2024/09/17 15:50:17"
95+
Blender:File: "<untitled>"
96+
Blender:Frame: "001"
97+
Blender:RenderTime: "00:03.49"
98+
Blender:Scene: "Scene"
99+
Blender:Time: "00:00:00:01"
100+
jpeg:subsampling: "4:2:0"
101+
oiio:ColorSpace: "sRGB"
102+
Comparing "src/blender-render.jpg" and "with-colon-desc.jpg"
103+
PASS
104+
Reading with-colon-desc.jpg
105+
with-colon-desc.jpg : 640 x 480, 3 channel, uint8 jpeg
106+
SHA-1: 329B449C07E6649023504E2C8E5130B41985CF7F
107+
channel list: R, G, B
108+
ImageDescription: "Example:Text"
109+
Exif:ColorSpace: 1
110+
Exif:ExifVersion: "0230"
111+
Exif:FlashPixVersion: "0100"
112+
IPTC:Caption: "Example:Text"
113+
jpeg:subsampling: "4:2:0"
114+
oiio:ColorSpace: "sRGB"

testsuite/jpeg-metadata/run.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#!/usr/bin/env python
2+
3+
# Copyright Contributors to the OpenImageIO project.
4+
# SPDX-License-Identifier: Apache-2.0
5+
# https://github.com/AcademySoftwareFoundation/OpenImageIO
6+
7+
8+
redirect = ' >> out.txt 2>&1 '
9+
10+
# This file was rendered and saved in Blender, and therefore contains metadata
11+
# in the form of comments.
12+
13+
# Check if the comments are correctly decoded as attributes, and that writing
14+
# to a new JPEG does not include them by default.
15+
command += rw_command ("src", "blender-render.jpg", use_oiiotool=1,
16+
output_filename="no-attribs.jpg")
17+
command += info_command ("no-attribs.jpg", safematch=True)
18+
19+
# Check that, when jpeg:com_attributes is set, the attributes are preserved.
20+
command += rw_command ("src", "blender-render.jpg", use_oiiotool=1,
21+
output_filename="with-attribs.jpg",
22+
extraargs="--attrib:type=int jpeg:com_attributes 1")
23+
command += info_command ("with-attribs.jpg", safematch=True)
24+
25+
# Check that JPEG comments that don't match an attribute will be read as ImageDescription.
26+
command += rw_command ("src", "blender-render.jpg", use_oiiotool=1,
27+
output_filename="with-attribs-and-desc.jpg",
28+
extraargs="--attrib:type=int jpeg:com_attributes 1 "
29+
"--attrib:type=string ImageDescription \"A photo\"")
30+
command += info_command ("with-attribs-and-desc.jpg", safematch=True)
31+
32+
# Check that JPEG comments that would match an attribute will be read as ImageDescription
33+
# if jpeg:com_attributes is 0.
34+
command += rw_command ("src", "blender-render.jpg", use_oiiotool=1,
35+
output_filename="with-colon-desc.jpg",
36+
extraargs="--attrib:type=string ImageDescription \"Example:Text\"")
37+
command += info_command ("with-colon-desc.jpg", safematch=True,
38+
extraargs="--oiioattrib:type=int jpeg:com_attributes 0")
10.5 KB
Loading

0 commit comments

Comments
 (0)