mirror of
https://github.com/SickGear/SickGear.git
synced 2024-11-28 15:43:37 +00:00
469 lines
19 KiB
Python
469 lines
19 KiB
Python
"""
|
|
EXIF metadata parser; also parses TIFF file headers.
|
|
|
|
Author: Victor Stinner, Robert Xiao
|
|
|
|
References:
|
|
- Exif 2.2 Specification (JEITA CP-3451)
|
|
http://www.exif.org/Exif2-2.PDF
|
|
- TIFF 6.0 Specification
|
|
http://partners.adobe.com/public/developer/en/tiff/TIFF6.pdf
|
|
"""
|
|
|
|
from hachoir.field import (FieldSet, SeekableFieldSet, ParserError,
|
|
UInt8, UInt16, UInt32,
|
|
Int8, Int16, Int32,
|
|
Float32, Float64,
|
|
Enum, String, Bytes, SubFile,
|
|
NullBits, NullBytes)
|
|
from hachoir.core.endian import LITTLE_ENDIAN, BIG_ENDIAN
|
|
from hachoir.core.tools import createDict
|
|
|
|
|
|
def rationalFactory(class_name, size, field_class):
|
|
class Rational(FieldSet):
|
|
static_size = size
|
|
|
|
def createFields(self):
|
|
yield field_class(self, "numerator")
|
|
yield field_class(self, "denominator")
|
|
|
|
def createValue(self):
|
|
return float(self["numerator"].value) / self["denominator"].value
|
|
cls = Rational
|
|
cls.__name__ = class_name
|
|
return cls
|
|
|
|
|
|
RationalInt32 = rationalFactory("RationalInt32", 64, Int32)
|
|
RationalUInt32 = rationalFactory("RationalUInt32", 64, UInt32)
|
|
|
|
|
|
class ASCIIString(String):
|
|
|
|
def __init__(self, parent, name, nbytes, description=None, strip=' \0', charset='ISO-8859-1', *args, **kwargs):
|
|
String.__init__(self, parent, name, nbytes, description,
|
|
strip, charset, *args, **kwargs)
|
|
|
|
|
|
class IFDTag(UInt16):
|
|
|
|
def getTag(self):
|
|
return self.parent.TAG_INFO.get(self.value, (hex(self.value), hex(self.value)))
|
|
|
|
def createDisplay(self):
|
|
return self.getTag()[0]
|
|
|
|
|
|
class ValueArray(FieldSet):
|
|
|
|
def __init__(self, parent, name, type, count, description=None):
|
|
FieldSet.__init__(self, parent, name, size=type.static_size * count, description=description)
|
|
self.array_type = type
|
|
self.array_count = count
|
|
|
|
def createFields(self):
|
|
for i in range(self.array_count):
|
|
yield self.array_type(self, "data[]")
|
|
|
|
def createValue(self):
|
|
return [f.value for f in self]
|
|
|
|
def createDisplay(self):
|
|
if self.array_count > 16:
|
|
return '<Array of %d %s>' % (self.array_count, self.array_type.__name__)
|
|
else:
|
|
return '[' + ', '.join(f.display for f in self) + ']'
|
|
|
|
|
|
class BasicIFDEntry(FieldSet):
|
|
TYPE_BYTE = 0
|
|
TYPE_UNDEFINED = 7
|
|
TYPE_RATIONAL = 5
|
|
TYPE_SIGNED_RATIONAL = 10
|
|
TYPE_INFO = {
|
|
1: (UInt8, "BYTE (8 bits)"),
|
|
2: (ASCIIString, "ASCII (8 bits)"),
|
|
3: (UInt16, "SHORT (16 bits)"),
|
|
4: (UInt32, "LONG (32 bits)"),
|
|
5: (RationalUInt32, "RATIONAL (2x LONG, 64 bits)"),
|
|
6: (Int8, "SBYTE (8 bits)"),
|
|
7: (Bytes, "UNDEFINED (8 bits)"),
|
|
8: (Int16, "SSHORT (16 bits)"),
|
|
9: (Int32, "SLONG (32 bits)"),
|
|
10: (RationalInt32, "SRATIONAL (2x SLONG, 64 bits)"),
|
|
11: (Float32, "FLOAT (32 bits)"),
|
|
12: (Float64, "DOUBLE (64 bits)"),
|
|
}
|
|
ENTRY_FORMAT = createDict(TYPE_INFO, 0)
|
|
TYPE_NAME = createDict(TYPE_INFO, 1)
|
|
TAG_INFO = {}
|
|
|
|
def createFields(self):
|
|
yield IFDTag(self, "tag", "Tag")
|
|
yield Enum(UInt16(self, "type", "Type"), self.TYPE_NAME)
|
|
self.value_cls = self.ENTRY_FORMAT.get(self['type'].value, Bytes)
|
|
if issubclass(self.value_cls, Bytes):
|
|
self.value_size = 8
|
|
else:
|
|
self.value_size = self.value_cls.static_size
|
|
yield UInt32(self, "count", "Count")
|
|
|
|
count = self['count'].value
|
|
totalsize = self.value_size * count
|
|
if count == 0:
|
|
yield NullBytes(self, "padding", 4)
|
|
elif totalsize <= 32:
|
|
name = "value"
|
|
if issubclass(self.value_cls, Bytes):
|
|
yield self.value_cls(self, name, count)
|
|
elif count == 1:
|
|
yield self.value_cls(self, name)
|
|
else:
|
|
yield ValueArray(self, name, self.value_cls, count)
|
|
if totalsize < 32:
|
|
yield NullBits(self, "padding", 32 - totalsize)
|
|
else:
|
|
yield UInt32(self, "offset", "Value offset")
|
|
|
|
def createValue(self):
|
|
if "value" in self:
|
|
return self['value'].value
|
|
return None
|
|
|
|
def createDescription(self):
|
|
return "Entry: " + self["tag"].getTag()[1]
|
|
|
|
|
|
class IFDEntry(BasicIFDEntry):
|
|
EXIF_IFD_POINTER = 0x8769
|
|
GPS_IFD_POINTER = 0x8825
|
|
INTEROP_IFD_POINTER = 0xA005
|
|
SUBIFD_POINTERS = 0x014A
|
|
|
|
TAG_INFO = {
|
|
# image data structure
|
|
0x0100: ("ImageWidth", "Image width"),
|
|
0x0101: ("ImageLength", "Image height"),
|
|
0x0102: ("BitsPerSample", "Number of bits per component"),
|
|
0x0103: ("Compression", "Compression scheme"),
|
|
0x0106: ("PhotometricInterpretation", "Pixel composition"),
|
|
0x0112: ("Orientation", "Orientation of image"),
|
|
0x0115: ("SamplesPerPixel", "Number of components"),
|
|
0x011C: ("PlanarConfiguration", "Image data arrangement"),
|
|
0x0212: ("YCbCrSubSampling", "Subsampling ratio of Y to C"),
|
|
0x0213: ("YCbCrPositioning", "Y and C positioning"),
|
|
0x011A: ("XResolution", "Image resolution in width direction"),
|
|
0x011B: ("YResolution", "Image resolution in height direction"),
|
|
0x0128: ("ResolutionUnit", "Unit of X and Y resolution"),
|
|
# recording offset
|
|
0x0111: ("StripOffsets", "Image data location"),
|
|
0x0116: ("RowsPerStrip", "Number of rows per strip"),
|
|
0x0117: ("StripByteCounts", "Bytes per compressed strip"),
|
|
0x0201: ("JPEGInterchangeFormat", "Offset to JPEG SOI"),
|
|
0x0202: ("JPEGInterchangeFormatLength", "Bytes of JPEG data"),
|
|
# image data characteristics
|
|
0x012D: ("TransferFunction", "Transfer function"),
|
|
0x013E: ("WhitePoint", "White point chromaticity"),
|
|
0x013F: ("PrimaryChromaticities", "Chromaticities of primaries"),
|
|
0x0211: ("YCbCrCoefficients", "Color space transformation matrix coefficients"),
|
|
0x0214: ("ReferenceBlackWhite", "Pair of black and white reference values"),
|
|
# other tags
|
|
0x0132: ("DateTime", "File change date and time"),
|
|
0x010E: ("ImageDescription", "Image title"),
|
|
0x010F: ("Make", "Image input equipment manufacturer"),
|
|
0x0110: ("Model", "Image input equipment model"),
|
|
0x0131: ("Software", "Software used"),
|
|
0x013B: ("Artist", "Person who created the image"),
|
|
0x8298: ("Copyright", "Copyright holder"),
|
|
0x02bc: ("XMPPacket", "XMP Packet"),
|
|
# TIFF-specific tags
|
|
0x00FE: ("NewSubfileType", "NewSubfileType"),
|
|
0x00FF: ("SubfileType", "SubfileType"),
|
|
0x0107: ("Threshholding", "Threshholding"),
|
|
0x0108: ("CellWidth", "CellWidth"),
|
|
0x0109: ("CellLength", "CellLength"),
|
|
0x010A: ("FillOrder", "FillOrder"),
|
|
0x010D: ("DocumentName", "DocumentName"),
|
|
0x0118: ("MinSampleValue", "MinSampleValue"),
|
|
0x0119: ("MaxSampleValue", "MaxSampleValue"),
|
|
0x011D: ("PageName", "PageName"),
|
|
0x011E: ("XPosition", "XPosition"),
|
|
0x011F: ("YPosition", "YPosition"),
|
|
0x0120: ("FreeOffsets", "FreeOffsets"),
|
|
0x0121: ("FreeByteCounts", "FreeByteCounts"),
|
|
0x0122: ("GrayResponseUnit", "GrayResponseUnit"),
|
|
0x0123: ("GrayResponseCurve", "GrayResponseCurve"),
|
|
0x0124: ("T4Options", "T4Options"),
|
|
0x0125: ("T6Options", "T6Options"),
|
|
0x0129: ("PageNumber", "PageNumber"),
|
|
0x013C: ("HostComputer", "HostComputer"),
|
|
0x013D: ("Predictor", "Predictor"),
|
|
0x0140: ("ColorMap", "ColorMap"),
|
|
0x0141: ("HalftoneHints", "HalftoneHints"),
|
|
0x0142: ("TileWidth", "TileWidth"),
|
|
0x0143: ("TileLength", "TileLength"),
|
|
0x0144: ("TileOffsets", "TileOffsets"),
|
|
0x0145: ("TileByteCounts", "TileByteCounts"),
|
|
SUBIFD_POINTERS: ("SubIFDs", "SubIFDs"),
|
|
0x014C: ("InkSet", "InkSet"),
|
|
0x014D: ("InkNames", "InkNames"),
|
|
0x014E: ("NumberOfInks", "NumberOfInks"),
|
|
0x0150: ("DotRange", "DotRange"),
|
|
0x0151: ("TargetPrinter", "TargetPrinter"),
|
|
0x0152: ("ExtraSamples", "ExtraSamples"),
|
|
0x0153: ("SampleFormat", "SampleFormat"),
|
|
0x0154: ("SMinSampleValue", "SMinSampleValue"),
|
|
0x0155: ("SMaxSampleValue", "SMaxSampleValue"),
|
|
0x0156: ("TransferRange", "TransferRange"),
|
|
0x0200: ("JPEGProc", "JPEGProc"),
|
|
0x0203: ("JPEGRestartInterval", "JPEGRestartInterval"),
|
|
0x0205: ("JPEGLosslessPredictors", "JPEGLosslessPredictors"),
|
|
0x0206: ("JPEGPointTransforms", "JPEGPointTransforms"),
|
|
0x0207: ("JPEGQTables", "JPEGQTables"),
|
|
0x0208: ("JPEGDCTables", "JPEGDCTables"),
|
|
0x0209: ("JPEGACTables", "JPEGACTables"),
|
|
# IFD pointers
|
|
EXIF_IFD_POINTER: ("IFDExif", "Exif IFD Pointer"),
|
|
GPS_IFD_POINTER: ("IFDGPS", "GPS IFD Pointer"),
|
|
INTEROP_IFD_POINTER: ("IFDInterop", "Interoperability IFD Pointer"),
|
|
}
|
|
|
|
|
|
class ExifIFDEntry(BasicIFDEntry):
|
|
TAG_INFO = {
|
|
# version
|
|
0x9000: ("ExifVersion", "Exif version"),
|
|
0xA000: ("FlashpixVersion", "Supported Flashpix version"),
|
|
# image data characteristics
|
|
0xA001: ("ColorSpace", "Color space information"),
|
|
# image configuration
|
|
0x9101: ("ComponentsConfiguration", "Meaning of each component"),
|
|
0x9102: ("CompressedBitsPerPixel", "Image compression mode"),
|
|
0xA002: ("PixelXDimension", "Valid image width"),
|
|
0xA003: ("PixelYDimension", "Valid image height"),
|
|
# user information
|
|
0x927C: ("MakerNote", "Manufacturer notes"),
|
|
0x9286: ("UserComment", "User comments"),
|
|
# related file information
|
|
0xA004: ("RelatedSoundFile", "Related audio file"),
|
|
# date and time
|
|
0x9003: ("DateTimeOriginal", "Date and time of original data generation"),
|
|
0x9004: ("DateTimeDigitized", "Date and time of digital data generation"),
|
|
0x9290: ("SubSecTime", "DateTime subseconds"),
|
|
0x9291: ("SubSecTimeOriginal", "DateTimeOriginal subseconds"),
|
|
0x9292: ("SubSecTimeDigitized", "DateTimeDigitized subseconds"),
|
|
# picture-taking conditions
|
|
0x829A: ("ExposureTime", "Exposure time"),
|
|
0x829D: ("FNumber", "F number"),
|
|
0x8822: ("ExposureProgram", "Exposure program"),
|
|
0x8824: ("SpectralSensitivity", "Spectral sensitivity"),
|
|
0x8827: ("ISOSpeedRatings", "ISO speed rating"),
|
|
0x8828: ("OECF", "Optoelectric conversion factor"),
|
|
0x9201: ("ShutterSpeedValue", "Shutter speed"),
|
|
0x9202: ("ApertureValue", "Aperture"),
|
|
0x9203: ("BrightnessValue", "Brightness"),
|
|
0x9204: ("ExposureBiasValue", "Exposure bias"),
|
|
0x9205: ("MaxApertureValue", "Maximum lens aperture"),
|
|
0x9206: ("SubjectDistance", "Subject distance"),
|
|
0x9207: ("MeteringMode", "Metering mode"),
|
|
0x9208: ("LightSource", "Light source"),
|
|
0x9209: ("Flash", "Flash"),
|
|
0x920A: ("FocalLength", "Lens focal length"),
|
|
0x9214: ("SubjectArea", "Subject area"),
|
|
0xA20B: ("FlashEnergy", "Flash energy"),
|
|
0xA20C: ("SpatialFrequencyResponse", "Spatial frequency response"),
|
|
0xA20E: ("FocalPlaneXResolution", "Focal plane X resolution"),
|
|
0xA20F: ("FocalPlaneYResolution", "Focal plane Y resolution"),
|
|
0xA210: ("FocalPlaneResolutionUnit", "Focal plane resolution unit"),
|
|
0xA214: ("SubjectLocation", "Subject location"),
|
|
0xA215: ("ExposureIndex", "Exposure index"),
|
|
0xA217: ("SensingMethod", "Sensing method"),
|
|
0xA300: ("FileSource", "File source"),
|
|
0xA301: ("SceneType", "Scene type"),
|
|
0xA302: ("CFAPattern", "CFA pattern"),
|
|
0xA401: ("CustomRendered", "Custom image processing"),
|
|
0xA402: ("ExposureMode", "Exposure mode"),
|
|
0xA403: ("WhiteBalance", "White balance"),
|
|
0xA404: ("DigitalZoomRatio", "Digital zoom ratio"),
|
|
0xA405: ("FocalLengthIn35mmFilm", "Focal length in 35 mm film"),
|
|
0xA406: ("SceneCaptureType", "Scene capture type"),
|
|
0xA407: ("GainControl", "Gain control"),
|
|
0xA408: ("Contrast", "Contrast"),
|
|
0xA409: ("Saturation", "Saturation"),
|
|
0xA40A: ("Sharpness", "Sharpness"),
|
|
0xA40B: ("DeviceSettingDescription", "Device settings description"),
|
|
0xA40C: ("SubjectDistanceRange", "Subject distance range"),
|
|
# other tags
|
|
0xA420: ("ImageUniqueID", "Unique image ID"),
|
|
}
|
|
|
|
|
|
class GPSIFDEntry(BasicIFDEntry):
|
|
TAG_INFO = {
|
|
0x0000: ("GPSVersionID", "GPS tag version"),
|
|
0x0001: ("GPSLatitudeRef", "North or South Latitude"),
|
|
0x0002: ("GPSLatitude", "Latitude"),
|
|
0x0003: ("GPSLongitudeRef", "East or West Longitude"),
|
|
0x0004: ("GPSLongitude", "Longitude"),
|
|
0x0005: ("GPSAltitudeRef", "Altitude reference"),
|
|
0x0006: ("GPSAltitude", "Altitude"),
|
|
0x0007: ("GPSTimeStamp", "GPS time (atomic clock)"),
|
|
0x0008: ("GPSSatellites", "GPS satellites used for measurement"),
|
|
0x0009: ("GPSStatus", "GPS receiver status"),
|
|
0x000A: ("GPSMeasureMode", "GPS measurement mode"),
|
|
0x000B: ("GPSDOP", "Measurement precision"),
|
|
0x000C: ("GPSSpeedRef", "Speed unit"),
|
|
0x000D: ("GPSSpeed", "Speed of GPS receiver"),
|
|
0x000E: ("GPSTrackRef", "Reference for direction of movement"),
|
|
0x000F: ("GPSTrack", "Direction of movement"),
|
|
0x0010: ("GPSImgDirectionRef", "Reference for direction of image"),
|
|
0x0011: ("GPSImgDirection", "Direction of image"),
|
|
0x0012: ("GPSMapDatum", "Geodetic survey data used"),
|
|
0x0013: ("GPSDestLatitudeRef", "Reference for latitude of destination"),
|
|
0x0014: ("GPSDestLatitude", "Latitude of destination"),
|
|
0x0015: ("GPSDestLongitudeRef", "Reference for longitude of destination"),
|
|
0x0016: ("GPSDestLongitude", "Longitude of destination"),
|
|
0x0017: ("GPSDestBearingRef", "Reference for bearing of destination"),
|
|
0x0018: ("GPSDestBearing", "Bearing of destination"),
|
|
0x0019: ("GPSDestDistanceRef", "Reference for distance to destination"),
|
|
0x001A: ("GPSDestDistance", "Distance to destination"),
|
|
0x001B: ("GPSProcessingMethod", "Name of GPS processing method"),
|
|
0x001C: ("GPSAreaInformation", "Name of GPS area"),
|
|
0x001D: ("GPSDateStamp", "GPS date"),
|
|
0x001E: ("GPSDifferential", "GPS differential correction"),
|
|
}
|
|
|
|
|
|
class InteropIFDEntry(BasicIFDEntry):
|
|
TAG_INFO = {
|
|
0x0001: ("InteroperabilityIndex", "Interoperability Identification"),
|
|
}
|
|
|
|
|
|
class IFD(SeekableFieldSet):
|
|
EntryClass = IFDEntry
|
|
|
|
def __init__(self, parent, name, base_addr):
|
|
self.base_addr = base_addr
|
|
SeekableFieldSet.__init__(self, parent, name)
|
|
|
|
def createFields(self):
|
|
yield UInt16(self, "count", "Number of entries")
|
|
count = self["count"].value
|
|
if count == 0:
|
|
raise ParserError("IFDs cannot be empty.")
|
|
for i in range(count):
|
|
yield self.EntryClass(self, "entry[]")
|
|
yield UInt32(self, "next", "Offset to next IFD")
|
|
for i in range(count):
|
|
entry = self['entry[%d]' % i]
|
|
if 'offset' not in entry:
|
|
continue
|
|
self.seekByte(entry['offset'].value +
|
|
self.base_addr // 8, relative=False)
|
|
count = entry['count'].value
|
|
name = "value[%s]" % i
|
|
if issubclass(entry.value_cls, Bytes):
|
|
yield entry.value_cls(self, name, count)
|
|
elif entry['tag'].display == "XMPPacket":
|
|
yield String(self, name, count)
|
|
elif count == 1:
|
|
yield entry.value_cls(self, name)
|
|
else:
|
|
yield ValueArray(self, name, entry.value_cls, count)
|
|
|
|
def getEntryValues(self, entry):
|
|
n = int(entry.name.rsplit('[', 1)[1].strip(']'))
|
|
if 'offset' in entry:
|
|
field = 'value[%d]' % n
|
|
base = self
|
|
else:
|
|
field = 'value'
|
|
base = entry
|
|
if field not in base:
|
|
return None
|
|
elif isinstance(base[field], ValueArray):
|
|
return list(base[field])
|
|
else:
|
|
return [base[field]]
|
|
|
|
|
|
class ExifIFD(IFD):
|
|
EntryClass = ExifIFDEntry
|
|
|
|
|
|
class GPSIFD(IFD):
|
|
EntryClass = GPSIFDEntry
|
|
|
|
|
|
class InteropIFD(IFD):
|
|
EntryClass = InteropIFDEntry
|
|
|
|
|
|
IFD_TAGS = {
|
|
IFDEntry.EXIF_IFD_POINTER: ('exif', ExifIFD),
|
|
IFDEntry.GPS_IFD_POINTER: ('exif_gps', GPSIFD),
|
|
IFDEntry.INTEROP_IFD_POINTER: ('exif_interop', InteropIFD),
|
|
}
|
|
|
|
|
|
def TIFF(self):
|
|
iff_start = self.absolute_address + self.current_size
|
|
yield String(self, "endian", 2, "Endian ('II' or 'MM')", charset="ASCII")
|
|
if self["endian"].value not in ("II", "MM"):
|
|
raise ParserError("Invalid endian!")
|
|
if self["endian"].value == "II":
|
|
self.endian = LITTLE_ENDIAN
|
|
else:
|
|
self.endian = BIG_ENDIAN
|
|
|
|
yield UInt16(self, "version", "TIFF version number")
|
|
yield UInt32(self, "img_dir_ofs", "Next image directory offset")
|
|
offsets = [(self['img_dir_ofs'].value, 'ifd[]', IFD)]
|
|
while offsets:
|
|
offset, name, klass = offsets.pop(0)
|
|
self.seekByte(offset + iff_start // 8, relative=False)
|
|
ifd = klass(self, name, iff_start)
|
|
yield ifd
|
|
for entry in ifd.array('entry'):
|
|
tag = entry['tag'].value
|
|
if tag in IFD_TAGS:
|
|
name, klass = IFD_TAGS[tag]
|
|
offsets.append((ifd.getEntryValues(entry)[
|
|
0].value, name + '[]', klass))
|
|
elif tag == IFDEntry.SUBIFD_POINTERS:
|
|
for val in ifd.getEntryValues(entry):
|
|
offsets.append((val.value, ifd.name + '[]', IFD))
|
|
|
|
if ifd['next'].value != 0:
|
|
offsets.append((ifd['next'].value, 'ifd[]', IFD))
|
|
|
|
|
|
class Exif(SeekableFieldSet):
|
|
|
|
def createFields(self):
|
|
# Headers
|
|
yield String(self, "header", 6, "Header (Exif\\0\\0)", charset="ASCII")
|
|
if self["header"].value != "Exif\0\0":
|
|
raise ParserError("Invalid EXIF signature!")
|
|
iff_start = self.absolute_address + self.current_size
|
|
ifds = []
|
|
for field in TIFF(self):
|
|
yield field
|
|
if isinstance(field, IFD):
|
|
ifds.append(field)
|
|
|
|
for ifd in ifds:
|
|
data = {}
|
|
for i, entry in enumerate(ifd.array('entry')):
|
|
data[entry['tag'].display] = entry
|
|
if 'JPEGInterchangeFormat' in data and 'JPEGInterchangeFormatLength' in data:
|
|
offs = ifd.getEntryValues(
|
|
data['JPEGInterchangeFormat'])[0].value
|
|
size = ifd.getEntryValues(
|
|
data['JPEGInterchangeFormatLength'])[0].value
|
|
if size == 0:
|
|
continue
|
|
self.seekByte(offs + iff_start // 8, relative=False)
|
|
yield SubFile(self, "thumbnail[]", size, "Thumbnail (JPEG file)", mime_type="image/jpeg")
|