blob: d62287aeba9d2d26df80b0b3ac75fd1437073638 [file] [log] [blame] [edit]
#!/usr/bin/env python
# iccp
#
# International Color Consortium Profile
#
# Tools for manipulating ICC profiles.
#
# An ICC profile can be extracted from a PNG image (iCCP chunk).
#
#
# Non-standard ICCP tags.
#
# Apple use some (widespread but) non-standard tags. These can be
# displayed in Apple's ColorSync Utility.
# - 'vcgt' (Video Card Gamma Tag). Table to load into video
# card LUT to apply gamma.
# - 'ndin' Apple display native information.
# - 'dscm' Apple multi-localized description strings.
# - 'mmod' Apple display make and model information.
# References
#
# [ICC 2001] ICC Specification ICC.1:2001-04 (Profile version 2.4.0)
# [ICC 2004] ICC Specification ICC.1:2004-10 (Profile version 4.2.0.0)
import argparse
# https://docs.python.org/3.8/library/pprint.html
import pprint
import struct
import sys
import warnings
import zlib
import png
class FormatError(Exception):
pass
class EncodingError(Exception):
pass
class Profile:
"""An International Color Consortium Profile (ICC Profile)."""
def __init__(self):
self.rawtagtable = None
self.rawtagdict = {}
self.d = dict()
def from_file(self, inp, name="<unknown>"):
# See [ICC 2004]
profile = inp.read(128)
if len(profile) < 128:
raise FormatError("ICC Profile is too short.")
(size,) = struct.unpack_from(">L", profile)
profile += inp.read(size - len(profile))
return self.from_bytes(profile, name)
def from_bytes(self, profile, name="<unknown>"):
self.d = dict()
d = self.d
if len(profile) < 128:
raise FormatError("ICC Profile is too short.")
d.update(
zip(
[
"size",
"preferredCMM",
"version",
"profileclass",
"colourspace",
"pcs",
],
struct.unpack_from(">L4sL4s4s4s", profile),
)
)
if len(profile) < d["size"]:
warnings.warn(
"Profile size declared to be %d, but only got %d bytes"
% (d["size"], len(profile))
)
d["version"] = "%08x" % d["version"]
d["created"] = readICCdatetime(profile[24:36])
d.update(
zip(
["acsp", "platform", "flag", "manufacturer", "model"],
struct.unpack_from(">4s4sL4s4s", profile, 36),
)
)
if d["acsp"] != b"acsp":
warnings.warn("acsp field not present (not an ICC Profile?).")
d["deviceattributes"] = profile[56:64]
(d["intent"],) = struct.unpack_from(">L", profile, 64)
d["pcsilluminant"] = readICCXYZNumber(profile[68:80])
d["creator"] = profile[80:84]
d["id"] = profile[84:100]
(ntags,) = struct.unpack_from(">L", profile, 128)
d["ntags"] = ntags
fmt = "4s2L" * ntags
# tag table
tt = struct.unpack_from(">" + fmt, profile, 132)
tt = group(tt, 3)
# Could (should) detect 2 or more tags having the same sig. But
# we don't. Two or more tags with the same sig is illegal per
# the ICC spec.
# Convert (sig,offset,size) triples into (sig,value) pairs.
rawtag = [(sig, profile[offset : offset + size]) for sig, offset, size in tt]
self.rawtagtable = rawtag
self.rawtagdict = dict(rawtag)
tag = dict()
# Interpret the tags whose types we know about
for sig, v in rawtag:
if sig in tag:
warnings.warn("Duplicate tag %r found. Ignoring." % sig)
continue
v = ICCdecode(v)
if v is not None:
tag[sig] = v
self.tag = tag
return self
def greyInput(self):
"""Adjust ``self.d`` dictionary for greyscale input device.
``profileclass`` is 'scnr', ``colourspace`` is 'GRAY', ``pcs``
is 'XYZ '.
"""
self.d.update(dict(profileclass="scnr", colourspace="GRAY", pcs="XYZ "))
return self
def rgb(self):
"""Adjust ``self.d`` dictionary for rgb device.
``profileclass`` is `mntr`, ``colourspace`` is `RGB `,
``pcs`` is 'XYZ '.
"""
self.d.update(dict(profileclass="mntr", colourspace="RGB ", pcs="XYZ "))
return self
def maybeAddDefaults(self):
if self.rawtagdict:
return
self._addTags(
cprt=b"Copyright unknown.",
desc=b"created by PyPNG/iccp",
wtpt=D50(),
)
def addTags(self, **k):
self.maybeAddDefaults()
self._addTags(**k)
def _addTags(self, **k):
"""Helper for :meth:`addTags`."""
for tag, thing in k.items():
if not isinstance(thing, (tuple, list)):
thing = (thing,)
typetag = defaulttagtype[tag]
self.rawtagdict[tag] = encode(typetag, *thing)
return self
def write(self, out):
"""Write ICC Profile to the file."""
if not self.rawtagtable:
self.rawtagtable = self.rawtagdict.items()
tags = tagblock(self.rawtagtable)
self.writeHeader(out, 128 + len(tags))
out.write(tags)
out.flush()
return self
def writeHeader(self, out, size=999):
"""Add default values to the instance's `d` dictionary, then
write a header out onto the file stream. The size of the
profile must be specified using the `size` argument.
"""
def defaultkey(d, key, value):
"""Add ``[key]==value`` to the dictionary `d`, but only if
it does not have that key already.
"""
if key in d:
return
d[key] = value
z = "\x00" * 4
defaults = dict(
preferredCMM=z,
version="02000000",
profileclass=z,
colourspace=z,
pcs="XYZ ",
created=writeICCdatetime(),
acsp="acsp",
platform=z,
flag=0,
manufacturer=z,
model=0,
deviceattributes=0,
intent=0,
pcsilluminant=encodefuns()["XYZ"](*D50()),
creator=z,
)
for k, v in defaults.items():
defaultkey(self.d, k, v)
# helper functions to do datatype conversions of header items
def k(k):
"""Get item and use as is."""
return self.d[k]
def bs(k):
"""Get item and convert to bytes."""
return bytes(self.d[k], "ascii")
def x(k):
"""Get item and convert from hex to int."""
return int(self.d[k], 16)
header_items = [
bs("preferredCMM"),
x("version"),
bs("profileclass"),
bs("colourspace"),
bs("pcs"),
k("created"),
bs("acsp"),
bs("platform"),
k("flag"),
bs("manufacturer"),
k("model"),
k("deviceattributes"),
k("intent"),
k("pcsilluminant"),
bs("creator"),
]
out.write(struct.pack(">L4sL4s4s4s12s4s4sL4sLQL12s4s", size, *header_items))
out.write(b"\x00" * 44)
return self
def encodefuns():
"""Returns a dictionary mapping ICC type signature sig to encoding
function. Each function returns a string comprising the content of
the encoded value. To form the full value, the type sig and the 4
zero bytes should be prefixed (8 bytes).
"""
def desc(ascii):
"""Return textDescription type [ICC 2001] 6.5.17. The ASCII part is
filled in with the string `ascii`, the Unicode and ScriptCode parts
are empty."""
ascii += b"\x00"
n = len(ascii)
return struct.pack(">L%ds2LHB67s" % n, n, ascii, 0, 0, 0, 0, b"")
def text(ascii):
"""Return textType [ICC 2001] 6.5.18."""
return ascii + "\x00"
def curv(f=None, n=256):
"""Return a curveType, [ICC 2001] 6.5.3. If no arguments are
supplied then a TRC for a linear response is generated (no entries).
If an argument is supplied and it is a number (for *f* to be a
number it means that ``float(f)==f``) then a TRC for that
gamma value is generated.
Otherwise `f` is assumed to be a function that maps [0.0, 1.0] to
[0.0, 1.0]; an `n` element table is generated for it.
"""
if f is None:
return struct.pack(">L", 0)
try:
if float(f) == f:
return struct.pack(">LH", 1, int(round(f * 2 ** 8)))
except (TypeError, ValueError):
pass
assert n >= 2
table = []
M = float(n - 1)
for i in range(n):
x = i / M
table.append(int(round(f(x) * 65535)))
return struct.pack(">L%dH" % n, n, *table)
def XYZ(x, y, z):
"""
Encode an (X,Y,Z) colour.
"""
return struct.pack(">3l", *map(fs15f16, [x, y, z]))
return locals()
# Tag type defaults.
# Most tags can only have one or a few tag types.
# When encoding, we associate a default tag type with each tag so that
# the encoding is implicit.
defaulttagtype = dict(
A2B0="mft1",
A2B1="mft1",
A2B2="mft1",
bXYZ="XYZ",
bTRC="curv",
B2A0="mft1",
B2A1="mft1",
B2A2="mft1",
calt="dtim",
targ="text",
chad="sf32",
chrm="chrm",
cprt="desc",
crdi="crdi",
dmnd="desc",
dmdd="desc",
devs="",
gamt="mft1",
kTRC="curv",
gXYZ="XYZ",
gTRC="curv",
lumi="XYZ",
meas="",
bkpt="XYZ",
wtpt="XYZ",
ncol="",
ncl2="",
resp="",
pre0="mft1",
pre1="mft1",
pre2="mft1",
desc="desc",
pseq="",
psd0="data",
psd1="data",
psd2="data",
psd3="data",
ps2s="data",
ps2i="data",
rXYZ="XYZ",
rTRC="curv",
scrd="desc",
scrn="",
tech="sig",
bfd="",
vued="desc",
view="view",
)
def encode(tsig, *args):
"""Encode a Python value as an ICC type.
`tsig` is the type signature to encode, as a string
(the first 4 bytes of the encoded value, see [ICC 2004] section 10).
"""
fun = encodefuns()
if tsig not in fun:
raise EncodingError("No encoder for type %r." % tsig)
v = fun[tsig](*args)
# convert tsig to bytes and pad with spaces.
tsig = bytes((tsig + " ")[:4], "ascii")
return tsig + (b"\x00" * 4) + v
def tagblock(tag):
"""`tag` should be a list of (*signature*, *element*) pairs, where
*signature* (the key) is a length 4 string, and *element* is the
content of the tag element (a bytes instance).
The entire tag block (consisting of first a table and then the
element data) is constructed and returned as a bytes
instance.
"""
n = len(tag)
tablelen = 12 * n
# Build the tag table in two parts. A list of 12-byte tags, and a
# string of element data. Offset is the offset from the start of
# the profile to the start of the element data (so the offset for
# the next element is this offset plus the length of the element
# string so far).
offset = 128 + tablelen + 4
# The table. As a bytes instance.
table = b""
# The element data
element = b""
for k, v in tag:
k = bytes(k, "ascii")
table += struct.pack(">4s2L", k, offset + len(element), len(v))
element += v
return struct.pack(">L", n) + table + element
def print_iccp(out, inp):
"""Print to `out` a model for the icc profile in the (open)
file `inp`.
"""
profile = Profile().from_file(inp)
pprint.pprint(profile.d, stream=out)
print([x[0] for x in profile.rawtagtable], file=out)
pprint.pprint(profile.tag, stream=out, compact=True)
def profileFromPNG(inp):
"""
Extract profile from PNG file. Return (*profile*, *name*) pair.
Where `profile` is the sequence of bytes for the profile
actual, and `name` is the name in the PNG file.
"""
r = png.Reader(file=inp)
_, chunk = r.chunk_of_type("iCCP")
i = chunk.index(b"\x00")
name = chunk[:i]
compression = chunk[i + 1]
assert compression == 0
profile = zlib.decompress(chunk[i + 2 :])
return profile, name
def iccp_from_png(out, png):
"""Extract ICC Profile from (open) PNG file `inp` and
write it to the file `out`.
"""
out.write(profileFromPNG(png)[0])
def fs15f16(x):
"""Convert float to ICC s15Fixed16Number (as a Python ``int``)."""
return int(round(x * 2 ** 16))
def D50():
"""Return D50 illuminant as an (X,Y,Z) triple."""
# See [ICC 2001] A.1
return (0.9642, 1.0000, 0.8249)
def writeICCdatetime(t=None):
"""`t` should be a gmtime tuple (as returned from
``time.gmtime()``). If not supplied, the current time will be used.
Return an ICC dateTimeNumber in a 12 element bytes instance.
"""
import time
if t is None:
t = time.gmtime()
return struct.pack(">6H", *t[:6])
def readICCdatetime(s):
"""Convert from 12 byte ICC representation of dateTimeNumber to
ISO8601 string. See [ICC 2004] 5.1.1"""
return "%04d-%02d-%02dT%02d:%02d:%02dZ" % struct.unpack(">6H", s)
def readICCXYZNumber(s):
"""Convert from 12 byte ICC representation of XYZNumber to (x,y,z)
triple of floats. See [ICC 2004] 5.1.11"""
return s15f16l(s)
def s15f16l(s):
"""Convert sequence of ICC s15Fixed16 to list of float."""
# Note: As long as float has at least 32 bits of mantissa, all
# values are preserved.
n = len(s) // 4
t = struct.unpack(">%dl" % n, s)
return [(2 ** -16) * v for v in t]
# Several types and their byte encodings are defined by [ICC 2004]
# section 10. When encoded, a value begins with a 4 byte type
# signature. We use the same 4 byte type signature in the names of the
# Python functions that decode the type into a Pythonic representation.
def ICCdecode(s):
"""Take an ICC encoded tag, and dispatch on its type signature
(first 4 bytes) to decode it into a Python value. Pair (*sig*,
*value*) is returned, where *sig* is a 4 byte string, and *value* is
some Python value determined by the content and type.
"""
sig = s[0:4]
sig = str(sig, "ascii").strip()
f = dict(
text=RDtext,
XYZ=RDXYZ,
curv=RDcurv,
vcgt=RDvcgt,
sf32=RDsf32,
)
if sig not in f:
return None
return (sig, f[sig](s))
def RDXYZ(s):
"""Convert ICC XYZType to rank 1 array of trimulus values."""
# See [ICC 2001] 6.5.26
assert s[0:4] == b"XYZ "
return readICCXYZNumber(s[8:])
def RDsf32(s):
"""Convert ICC s15Fixed16ArrayType to list of float."""
# See [ICC 2004] 10.18
assert s[0:4] == b"sf32"
return s15f16l(s[8:])
def RDmluc(s):
"""Convert ICC multiLocalizedUnicodeType. This types encodes
several strings together with a language/country code for each
string. A list of (*lc*, *string*) pairs is returned where *lc* is
the 4 byte language/country code, and *string* is the string
corresponding to that code. It seems unlikely that the same
language/country code will appear more than once with different
strings, but the ICC standard does not prohibit it.
"""
# See [ICC 2004] 10.13
assert s[0:4] == b"mluc"
n, sz = struct.unpack_from(">2L", s, 8)
assert sz == 12
record = []
for i in range(n):
lc, l, o = struct.unpack_from("4s2L", s, 16 + 12 * n)
record.append(lc, s[o : o + l])
# How are strings encoded?
return record
def RDtext(s):
"""Convert ICC textType to Python string."""
# Note: type not specified or used in [ICC 2004], only in older
# [ICC 2001].
# See [ICC 2001] 6.5.18
assert s[0:4] == b"text"
return s[8:-1]
def RDcurv(s):
"""Convert ICC curveType."""
# See [ICC 2001] 6.5.3
assert s[0:4] == b"curv"
(count,) = struct.unpack_from(">L", s, 8)
if count == 0:
return dict(gamma=1)
table = struct.unpack_from(">%dH" % count, s, 12)
if count == 1:
return dict(gamma=table[0] * 2 ** -8)
return table
def RDvcgt(s):
"""Convert Apple CMVideoCardGammaType."""
# See
# http://developer.apple.com/documentation/GraphicsImaging/Reference/ColorSync_Manager/Reference/reference.html#//apple_ref/c/tdef/CMVideoCardGammaType
assert s[0:4] == b"vcgt"
(tagtype,) = struct.unpack_from(">L", s, 8)
if tagtype != 0:
return s[8:]
if tagtype == 0:
# Table.
channels, count, size = struct.unpack_from(">3H", s, 12)
if size == 1:
fmt = "B"
elif size == 2:
fmt = "H"
else:
return s[8:]
n = len(s[18:]) // size
t = struct.unpack_from(">%d%s" % (n, fmt), s, 18)
t = group(t, count)
return size, t
return s[8:]
def group(s, n):
return zip(*[iter(s)] * n)
def main(argv=None):
if argv is None:
argv = sys.argv
argv = argv[1:]
parser = argparse.ArgumentParser()
parser.add_argument("--png", type=png.cli_open)
parser.add_argument("icc", nargs="?", type=png.cli_open)
args = parser.parse_args(argv)
if args.png is not None:
return iccp_from_png(png.binary_stdout(), args.png)
if args.icc is None:
parser.print_help()
return 0
return print_iccp(sys.stdout, args.icc)
if __name__ == "__main__":
sys.exit(main())