import hashlib import io import numpy import numpy.core.multiarray # important this comes before cv! import struct import warnings try: # more hidden imports for pyinstaller import numpy.random.common # pylint: disable=E0401 import numpy.random.bounded_integers # pylint: disable=E0401 import numpy.random.entropy # pylint: disable=E0401 except: pass # old version of numpy, screw it from PIL import _imaging from PIL import ImageFile as PILImageFile from PIL import Image as PILImage from hydrus.core import HydrusConstants as HC from hydrus.core import HydrusData from hydrus.core import HydrusExceptions from hydrus.core import HydrusGlobals as HG from hydrus.core import HydrusPaths def EnableLoadTruncatedImages(): if hasattr( PILImageFile, 'LOAD_TRUNCATED_IMAGES' ): # this can now cause load hangs due to the trunc load code adding infinite fake EOFs to the file stream, wew lad # hence debug only PILImageFile.LOAD_TRUNCATED_IMAGES = True return True else: return False if not hasattr( PILImage, 'DecompressionBombError' ): # super old versions don't have this, so let's just make a stub, wew class DBE_stub( Exception ): pass PILImage.DecompressionBombError = DBE_stub if not hasattr( PILImage, 'DecompressionBombWarning' ): # super old versions don't have this, so let's just make a stub, wew class DBW_stub( Exception ): pass PILImage.DecompressionBombWarning = DBW_stub warnings.simplefilter( 'ignore', PILImage.DecompressionBombWarning ) warnings.simplefilter( 'ignore', PILImage.DecompressionBombError ) OLD_PIL_MAX_IMAGE_PIXELS = PILImage.MAX_IMAGE_PIXELS PILImage.MAX_IMAGE_PIXELS = None # this turns off decomp check entirely, wew PIL_ONLY_MIMETYPES = { HC.IMAGE_GIF, HC.IMAGE_ICON } try: import cv2 if cv2.__version__.startswith( '2' ): CV_IMREAD_FLAGS_SUPPORTS_ALPHA = cv2.CV_LOAD_IMAGE_UNCHANGED CV_IMREAD_FLAGS_SUPPORTS_EXIF_REORIENTATION = CV_IMREAD_FLAGS_SUPPORTS_ALPHA # there's something wrong with these, but I don't have an easy test env for it atm # CV_IMREAD_FLAGS_SUPPORTS_EXIF_REORIENTATION = cv2.CV_LOAD_IMAGE_ANYDEPTH | cv2.CV_LOAD_IMAGE_ANYCOLOR CV_JPEG_THUMBNAIL_ENCODE_PARAMS = [] CV_PNG_THUMBNAIL_ENCODE_PARAMS = [] else: CV_IMREAD_FLAGS_SUPPORTS_ALPHA = cv2.IMREAD_UNCHANGED CV_IMREAD_FLAGS_SUPPORTS_EXIF_REORIENTATION = cv2.IMREAD_ANYDEPTH | cv2.IMREAD_ANYCOLOR # this preserves colour info but does EXIF reorientation and flipping CV_JPEG_THUMBNAIL_ENCODE_PARAMS = [ cv2.IMWRITE_JPEG_QUALITY, 92 ] CV_PNG_THUMBNAIL_ENCODE_PARAMS = [ cv2.IMWRITE_PNG_COMPRESSION, 9 ] OPENCV_OK = True except: OPENCV_OK = False def ConvertToPNGIfBMP( path ): with open( path, 'rb' ) as f: header = f.read( 2 ) if header == b'BM': ( os_file_handle, temp_path ) = HydrusPaths.GetTempPath() try: with open( path, 'rb' ) as f_source: with open( temp_path, 'wb' ) as f_dest: HydrusPaths.CopyFileLikeToFileLike( f_source, f_dest ) pil_image = GeneratePILImage( temp_path ) pil_image.save( path, 'PNG' ) finally: HydrusPaths.CleanUpTempPath( os_file_handle, temp_path ) def Dequantize( pil_image ): if pil_image.mode not in ( 'RGBA', 'RGB' ): if pil_image.mode == 'LA' or ( pil_image.mode == 'P' and 'transparency' in pil_image.info ): pil_image = pil_image.convert( 'RGBA' ) else: pil_image = pil_image.convert( 'RGB' ) return pil_image def GenerateNumPyImage( path, mime, force_pil = False ): if HG.media_load_report_mode: HydrusData.ShowText( 'Loading media: ' + path ) if not OPENCV_OK: force_pil = True if mime in PIL_ONLY_MIMETYPES or force_pil: if HG.media_load_report_mode: HydrusData.ShowText( 'Loading with PIL' ) pil_image = GeneratePILImage( path ) numpy_image = GenerateNumPyImageFromPILImage( pil_image ) else: if HG.media_load_report_mode: HydrusData.ShowText( 'Loading with OpenCV' ) if mime == HC.IMAGE_JPEG: flags = CV_IMREAD_FLAGS_SUPPORTS_EXIF_REORIENTATION else: flags = CV_IMREAD_FLAGS_SUPPORTS_ALPHA numpy_image = cv2.imread( path, flags = flags ) if numpy_image is None: # doesn't support static gifs and some random other stuff if HG.media_load_report_mode: HydrusData.ShowText( 'OpenCV Failed, loading with PIL' ) pil_image = GeneratePILImage( path ) numpy_image = GenerateNumPyImageFromPILImage( pil_image ) else: if numpy_image.dtype == 'uint16': numpy_image //= 256 numpy_image = numpy.array( numpy_image, dtype = 'uint8' ) shape = numpy_image.shape if len( shape ) == 2: # monochrome image convert = cv2.COLOR_GRAY2RGB else: ( im_y, im_x, depth ) = shape if depth == 4: convert = cv2.COLOR_BGRA2RGBA else: convert = cv2.COLOR_BGR2RGB numpy_image = cv2.cvtColor( numpy_image, convert ) return numpy_image def GenerateNumPyImageFromPILImage( pil_image ): pil_image = Dequantize( pil_image ) ( w, h ) = pil_image.size s = pil_image.tobytes() return numpy.fromstring( s, dtype = 'uint8' ).reshape( ( h, w, len( s ) // ( w * h ) ) ) def GeneratePILImage( path ): try: pil_image = PILImage.open( path ) except Exception as e: raise HydrusExceptions.DamagedOrUnusualFileException( 'Could not load the image--it was likely malformed!' ) if pil_image.format == 'JPEG' and hasattr( pil_image, '_getexif' ): try: exif_dict = pil_image._getexif() except: exif_dict = None if exif_dict is not None: EXIF_ORIENTATION = 274 if EXIF_ORIENTATION in exif_dict: orientation = exif_dict[ EXIF_ORIENTATION ] if orientation == 1: pass # normal elif orientation == 2: # mirrored horizontal pil_image = pil_image.transpose( PILImage.FLIP_LEFT_RIGHT ) elif orientation == 3: # 180 pil_image = pil_image.transpose( PILImage.ROTATE_180 ) elif orientation == 4: # mirrored vertical pil_image = pil_image.transpose( PILImage.FLIP_TOP_BOTTOM ) elif orientation == 5: # seems like these 90 degree rotations are wrong, but fliping them works for my posh example images, so I guess the PIL constants are odd # mirrored horizontal, then 90 CCW pil_image = pil_image.transpose( PILImage.FLIP_LEFT_RIGHT ).transpose( PILImage.ROTATE_90 ) elif orientation == 6: # 90 CW pil_image = pil_image.transpose( PILImage.ROTATE_270 ) elif orientation == 7: # mirrored horizontal, then 90 CCW pil_image = pil_image.transpose( PILImage.FLIP_LEFT_RIGHT ).transpose( PILImage.ROTATE_270 ) elif orientation == 8: # 90 CCW pil_image = pil_image.transpose( PILImage.ROTATE_90 ) if pil_image is None: raise Exception( 'The file at ' + path + ' could not be rendered!' ) return pil_image def GeneratePILImageFromNumPyImage( numpy_image ): ( h, w, depth ) = numpy_image.shape if depth == 3: format = 'RGB' elif depth == 4: format = 'RGBA' pil_image = PILImage.frombytes( format, ( w, h ), numpy_image.data.tobytes() ) return pil_image def GenerateThumbnailBytesFromStaticImagePath( path, target_resolution, mime ): if OPENCV_OK: numpy_image = GenerateNumPyImage( path, mime ) thumbnail_numpy_image = ResizeNumPyImage( numpy_image, target_resolution ) try: thumbnail_bytes = GenerateThumbnailBytesNumPy( thumbnail_numpy_image, mime ) return thumbnail_bytes except HydrusExceptions.CantRenderWithCVException: pass # fall back to pil pil_image = GeneratePILImage( path ) pil_image = Dequantize( pil_image ) thumbnail_pil_image = pil_image.resize( target_resolution, PILImage.ANTIALIAS ) thumbnail_bytes = GenerateThumbnailBytesPIL( pil_image, mime ) return thumbnail_bytes def GenerateThumbnailBytesNumPy( numpy_image, mime ): if not OPENCV_OK: pil_image = GeneratePILImageFromNumPyImage( numpy_image ) return GenerateThumbnailBytesPIL( pil_image, mime ) ( im_y, im_x, depth ) = numpy_image.shape if depth == 4: convert = cv2.COLOR_RGBA2BGRA else: convert = cv2.COLOR_RGB2BGR numpy_image = cv2.cvtColor( numpy_image, convert ) if mime == HC.IMAGE_JPEG: ext = '.jpg' params = CV_JPEG_THUMBNAIL_ENCODE_PARAMS else: ext = '.png' params = CV_PNG_THUMBNAIL_ENCODE_PARAMS ( result_success, result_byte_array ) = cv2.imencode( ext, numpy_image, params ) if result_success: thumbnail_bytes = result_byte_array.tostring() return thumbnail_bytes else: raise HydrusExceptions.CantRenderWithCVException( 'Thumb failed to encode!' ) def GenerateThumbnailBytesPIL( pil_image, mime ): f = io.BytesIO() pil_image = Dequantize( pil_image ) if mime == HC.IMAGE_PNG or pil_image.mode == 'RGBA': pil_image.save( f, 'PNG' ) else: pil_image.save( f, 'JPEG', quality = 92 ) f.seek( 0 ) thumbnail_bytes = f.read() f.close() return thumbnail_bytes def GetGIFFrameDurations( path ): pil_image = GeneratePILImage( path ) times_to_play_gif = GetTimesToPlayGIFFromPIL( pil_image ) frame_durations = [] i = 0 while True: try: pil_image.seek( i ) except: break if 'duration' not in pil_image.info: duration = 83 # (83ms -- 1000 / 12) Set a 12 fps default when duration is missing or too funky to extract. most stuff looks ok at this. else: duration = pil_image.info[ 'duration' ] # In the gif frame header, 10 is stored as 1ms. This 1 is commonly as utterly wrong as 0. if duration in ( 0, 10 ): duration = 83 frame_durations.append( duration ) i += 1 return ( frame_durations, times_to_play_gif ) def GetImagePixelHash( path, mime ): numpy_image = GenerateNumPyImage( path, mime ) return hashlib.sha256( numpy_image.data.tobytes() ).digest() def GetImageProperties( path, mime ): if OPENCV_OK and mime not in PIL_ONLY_MIMETYPES: # webp here too maybe eventually, or offload it all to ffmpeg numpy_image = GenerateNumPyImage( path, mime ) ( width, height ) = GetResolutionNumPy( numpy_image ) duration = None num_frames = None else: ( ( width, height ), num_frames ) = GetResolutionAndNumFramesPIL( path, mime ) if num_frames > 1: ( durations, times_to_play_gif ) = GetGIFFrameDurations( path ) duration = sum( durations ) else: duration = None num_frames = None return ( ( width, height ), duration, num_frames ) # bigger number is worse quality # this is very rough and misses some finesse def GetJPEGQuantizationQualityEstimate( path ): pil_image = GeneratePILImage( path ) if hasattr( pil_image, 'quantization' ): table_arrays = list( pil_image.quantization.values() ) if len( table_arrays ) == 0: return ( 'unknown', None ) quality = sum( ( sum( table_array ) for table_array in table_arrays ) ) quality /= len( table_arrays ) if quality >= 3400: label = 'very low' elif quality >= 2000: label = 'low' elif quality >= 1400: label = 'medium low' elif quality >= 1000: label = 'medium' elif quality >= 700: label = 'medium high' elif quality >= 400: label = 'high' elif quality >= 200: label = 'very high' else: label = 'extremely high' return ( label, quality ) return ( 'unknown', None ) def GetPSDResolution( path ): with open( path, 'rb' ) as f: f.seek( 14 ) height_bytes = f.read( 4 ) width_bytes = f.read( 4 ) height = struct.unpack( '>L', height_bytes )[0] width = struct.unpack( '>L', width_bytes )[0] return ( width, height ) def GetResolutionNumPy( numpy_image ): ( image_height, image_width, depth ) = numpy_image.shape return ( image_width, image_height ) def GetResolutionAndNumFramesPIL( path, mime ): pil_image = GeneratePILImage( path ) ( x, y ) = pil_image.size if mime == HC.IMAGE_GIF: # some jpegs came up with 2 frames and 'duration' because of some embedded thumbnail in the metadata try: pil_image.seek( 1 ) pil_image.seek( 0 ) num_frames = 1 while True: try: pil_image.seek( pil_image.tell() + 1 ) num_frames += 1 except: break except: num_frames = 1 else: num_frames = 1 return ( ( x, y ), num_frames ) def GetThumbnailResolution( image_resolution, bounding_dimensions ): ( im_width, im_height ) = image_resolution ( bounding_width, bounding_height ) = bounding_dimensions if bounding_width >= im_width and bounding_height >= im_height: return ( im_width, im_height ) width_ratio = im_width / bounding_width height_ratio = im_height / bounding_height thumbnail_width = bounding_width thumbnail_height = bounding_height if width_ratio > height_ratio: thumbnail_height = im_height / width_ratio elif height_ratio > width_ratio: thumbnail_width = im_width / height_ratio thumbnail_width = max( int( thumbnail_width ), 1 ) thumbnail_height = max( int( thumbnail_height ), 1 ) return ( thumbnail_width, thumbnail_height ) def GetTimesToPlayGIF( path ): pil_image = GeneratePILImage( path ) return GetTimesToPlayGIFFromPIL( pil_image ) def GetTimesToPlayGIFFromPIL( pil_image ): if 'loop' in pil_image.info: times_to_play_gif = pil_image.info[ 'loop' ] else: times_to_play_gif = 1 return times_to_play_gif def IsDecompressionBomb( path ): # there are two errors here, the 'Warning' and the 'Error', which atm is just a test vs a test x 2 for number of pixels # 256MB bmp by default, ( 1024 ** 3 ) // 4 // 3 # we'll set it at 512MB, and now catching error should be about 1GB PILImage.MAX_IMAGE_PIXELS = ( 1024 ** 3 ) // 2 // 3 warnings.simplefilter( 'error', PILImage.DecompressionBombError ) try: GeneratePILImage( path ) except ( PILImage.DecompressionBombError ): return True except: # pil was unable to load it, which does not mean it was a decomp bomb return False finally: PILImage.MAX_IMAGE_PIXELS = None warnings.simplefilter( 'ignore', PILImage.DecompressionBombError ) return False def ResizeNumPyImage( numpy_image, target_resolution ): ( target_width, target_height ) = target_resolution ( image_width, image_height ) = GetResolutionNumPy( numpy_image ) if target_width == image_width and target_height == target_width: return numpy_image elif target_width > image_height or target_height > image_width: interpolation = cv2.INTER_LANCZOS4 else: interpolation = cv2.INTER_AREA return cv2.resize( numpy_image, ( target_width, target_height ), interpolation = interpolation )