From 850a8c452ecaf5c633c07cf0b2ba7fd254dc7d63 Mon Sep 17 00:00:00 2001 From: Paul Friederichsen Date: Sat, 23 Sep 2023 14:13:21 -0500 Subject: [PATCH] Add blurhash (#1443) * Start on blurhash * More blurhash db stuff * Refactor GenerateThumbnailBytes to add GenerateThumbnailNumPy * Add blurhash gen to import * Add blurhashes to db * Add blurhash to file metadata api * Add API docs for blurhash * Make sure we regen blurhash after thumb regen --- docs/developer_api.md | 7 +- hydrus/client/ClientFiles.py | 54 +++- hydrus/client/ClientPDFHandling.py | 6 +- hydrus/client/ClientSVGHandling.py | 6 +- hydrus/client/db/ClientDB.py | 17 +- hydrus/client/db/ClientDBFilesMaintenance.py | 10 +- .../client/db/ClientDBFilesMetadataBasic.py | 31 ++- hydrus/client/importing/ClientImportFiles.py | 18 +- hydrus/client/media/ClientMediaManagers.py | 1 + .../networking/ClientLocalServerResources.py | 12 +- hydrus/core/HydrusFileHandling.py | 47 ++-- hydrus/core/HydrusImageHandling.py | 22 +- hydrus/core/HydrusPDFHandling.py | 4 +- hydrus/core/HydrusPSDHandling.py | 6 +- hydrus/core/HydrusSVGHandling.py | 4 +- hydrus/external/blurhash.py | 256 ++++++++++++++++++ 16 files changed, 436 insertions(+), 65 deletions(-) create mode 100644 hydrus/external/blurhash.py diff --git a/docs/developer_api.md b/docs/developer_api.md index 38edc114..0c687540 100644 --- a/docs/developer_api.md +++ b/docs/developer_api.md @@ -1486,6 +1486,7 @@ Arguments (in percent-encoded JSON): * `only_return_identifiers`: true or false (optional, defaulting to false) * `only_return_basic_information`: true or false (optional, defaulting to false) * `detailed_url_information`: true or false (optional, defaulting to false) + * `include_blurhash`: true or false (optional, defaulting to false. Only applies when `only_return_basic_information` is true) * `include_notes`: true or false (optional, defaulting to false) * `include_services_object`: true or false (optional, defaulting to true) * `hide_service_keys_tags`: **Deprecated, will be deleted soon!** true or false (optional, defaulting to true) @@ -1534,6 +1535,7 @@ Response: }, "ipfs_multihashes" : {}, "has_audio" : false, + "blurhash" : "U6PZfSi_.AyE_3t7t7R**0o#DgR4_3R*D%xt", "num_frames" : null, "num_words" : null, "is_inbox" : false, @@ -1605,6 +1607,7 @@ Response: "55af93e0deabd08ce15ffb2b164b06d1254daab5a18d145e56fa98f71ddb6f11" : "QmReHtaET3dsgh7ho5NVyHb5U13UgJoGipSWbZsnuuM8tb" }, "has_audio" : true, + "blurhash" : "UHF5?xYk^6#M@-5b,1J5@[or[k6.};FxngOZ", "num_frames" : 102, "num_words" : null, "is_inbox" : false, @@ -1725,6 +1728,8 @@ Size is in bytes. Duration is in milliseconds, and may be an int or a float. The `thumbnail_width` and `thumbnail_height` are a generally reliable prediction but aren't a promise. The actual thumbnail you get from [/get\_files/thumbnail](#get_files_thumbnail) will be different if the user hasn't looked at it since changing their thumbnail options. You only get these rows for files that hydrus actually generates an actual thumbnail for. Things like pdf won't have it. You can use your own thumb, or ask the api and it'll give you a fixed fallback; those are mostly 200x200, but you can and should size them to whatever you want. +`blurhash` gives a base 83 encoded string of a [blurhash](https://blurha.sh/) generated from the file's thumbnail if the file has a thumbnail. + #### tags The `tags` structure is similar to the [/add\_tags/add\_tags](#add_tags_add_tags) scheme, excepting that the status numbers are: @@ -1779,7 +1784,7 @@ You can change this behaviour with `create_new_file_ids=true`, but bear in mind If you ask about file_ids that do not exist, you'll get 404. -If you set `only_return_basic_information=true`, this will be much faster for first-time requests than the full metadata result, but it will be slower for repeat requests. The full metadata object is cached after first fetch, the limited file info object is not. +If you set `only_return_basic_information=true`, this will be much faster for first-time requests than the full metadata result, but it will be slower for repeat requests. The full metadata object is cached after first fetch, the limited file info object is not. You can optionally set `include_blurhash` when using this option to fetch blurhash strings for the files. If you add `detailed_url_information=true`, a new entry, `detailed_known_urls`, will be added for each file, with a list of the same structure as /`add_urls/get_url_info`. This may be an expensive request if you are querying thousands of files at once. diff --git a/hydrus/client/ClientFiles.py b/hydrus/client/ClientFiles.py index c3d5beac..e06aa8be 100644 --- a/hydrus/client/ClientFiles.py +++ b/hydrus/client/ClientFiles.py @@ -49,6 +49,7 @@ REGENERATE_FILE_DATA_JOB_FILE_INTEGRITY_PRESENCE_LOG_ONLY = 18 REGENERATE_FILE_DATA_JOB_FILE_HAS_HUMAN_READABLE_EMBEDDED_METADATA = 19 REGENERATE_FILE_DATA_JOB_FILE_HAS_EXIF = 20 REGENERATE_FILE_DATA_JOB_FILE_INTEGRITY_PRESENCE_DELETE_RECORD = 21 +REGENERATE_FILE_DATA_JOB_BLURHASH = 22 regen_file_enum_to_str_lookup = { REGENERATE_FILE_DATA_JOB_FILE_METADATA : 'regenerate file metadata', @@ -72,7 +73,8 @@ regen_file_enum_to_str_lookup = { REGENERATE_FILE_DATA_JOB_FILE_HAS_EXIF : 'determine if the file has EXIF metadata', REGENERATE_FILE_DATA_JOB_FILE_HAS_HUMAN_READABLE_EMBEDDED_METADATA : 'determine if the file has non-EXIF human-readable embedded metadata', REGENERATE_FILE_DATA_JOB_FILE_HAS_ICC_PROFILE : 'determine if the file has an icc profile', - REGENERATE_FILE_DATA_JOB_PIXEL_HASH : 'regenerate pixel duplicate data' + REGENERATE_FILE_DATA_JOB_PIXEL_HASH : 'regenerate pixel duplicate data', + REGENERATE_FILE_DATA_JOB_BLURHASH: 'regenerate blurhash' } regen_file_enum_to_description_lookup = { @@ -129,7 +131,8 @@ All missing/Incorrect files will also have their hashes, tags, and URLs exported REGENERATE_FILE_DATA_JOB_FILE_HAS_EXIF : 'This loads the file to see if it has EXIF metadata, which can be shown in the media viewer and searched with "system:image has exif".', REGENERATE_FILE_DATA_JOB_FILE_HAS_HUMAN_READABLE_EMBEDDED_METADATA : 'This loads the file to see if it has non-EXIF human-readable metadata, which can be shown in the media viewer and searched with "system:image has human-readable embedded metadata".', REGENERATE_FILE_DATA_JOB_FILE_HAS_ICC_PROFILE : 'This loads the file to see if it has an ICC profile, which is used in "system:has icc profile" search.', - REGENERATE_FILE_DATA_JOB_PIXEL_HASH : 'This generates a fast unique identifier for the pixels in a still image, which is used in duplicate pixel searches.' + REGENERATE_FILE_DATA_JOB_PIXEL_HASH : 'This generates a fast unique identifier for the pixels in a still image, which is used in duplicate pixel searches.', + REGENERATE_FILE_DATA_JOB_BLURHASH: 'This generates a very small version of the file\'s thumbnail that can be used as a placeholder while the thumbnail loads' } NORMALISED_BIG_JOB_WEIGHT = 100 @@ -156,7 +159,8 @@ regen_file_enum_to_job_weight_lookup = { REGENERATE_FILE_DATA_JOB_FILE_HAS_EXIF : 25, REGENERATE_FILE_DATA_JOB_FILE_HAS_HUMAN_READABLE_EMBEDDED_METADATA : 25, REGENERATE_FILE_DATA_JOB_FILE_HAS_ICC_PROFILE : 25, - REGENERATE_FILE_DATA_JOB_PIXEL_HASH : 100 + REGENERATE_FILE_DATA_JOB_PIXEL_HASH : 100, + REGENERATE_FILE_DATA_JOB_BLURHASH: 25 } regen_file_enum_to_overruled_jobs = { @@ -181,7 +185,8 @@ regen_file_enum_to_overruled_jobs = { REGENERATE_FILE_DATA_JOB_FILE_HAS_EXIF : [], REGENERATE_FILE_DATA_JOB_FILE_HAS_HUMAN_READABLE_EMBEDDED_METADATA : [], REGENERATE_FILE_DATA_JOB_FILE_HAS_ICC_PROFILE : [], - REGENERATE_FILE_DATA_JOB_PIXEL_HASH : [] + REGENERATE_FILE_DATA_JOB_PIXEL_HASH : [], + REGENERATE_FILE_DATA_JOB_BLURHASH: [] } ALL_REGEN_JOBS_IN_PREFERRED_ORDER = [ @@ -197,6 +202,7 @@ ALL_REGEN_JOBS_IN_PREFERRED_ORDER = [ REGENERATE_FILE_DATA_JOB_FILE_METADATA, REGENERATE_FILE_DATA_JOB_REFIT_THUMBNAIL, REGENERATE_FILE_DATA_JOB_FORCE_THUMBNAIL, + REGENERATE_FILE_DATA_JOB_BLURHASH, REGENERATE_FILE_DATA_JOB_SIMILAR_FILES_METADATA, REGENERATE_FILE_DATA_JOB_CHECK_SIMILAR_FILES_MEMBERSHIP, REGENERATE_FILE_DATA_JOB_FIX_PERMISSIONS, @@ -1677,7 +1683,8 @@ class ClientFilesManager( object ): self._AddThumbnailFromBytes( hash, thumbnail_bytes ) - + return True + def RegenerateThumbnailIfWrongSize( self, media ): @@ -1725,6 +1732,29 @@ class ClientFilesManager( object ): return do_it + + def RegenerateImageBlurHash( self, media ): + + hash = media.GetHash() + mime = media.GetMime() + + if mime not in HC.MIMES_WITH_THUMBNAILS: + + return None + + try: + + thumbnail_path = self._GenerateExpectedThumbnailPath( hash ) + + thumbnail_mime = HydrusFileHandling.GetThumbnailMime( thumbnail_path ) + + numpy_image = ClientImageHandling.GenerateNumPyImage( thumbnail_path, thumbnail_mime ) + + return HydrusImageHandling.GetImageBlurHashNumPy(numpy_image) + + except: + + return None def Reinit( self ): @@ -2371,7 +2401,8 @@ class FilesMaintenanceManager( object ): try: - self._controller.client_files_manager.RegenerateThumbnail( media_result ) + return self._controller.client_files_manager.RegenerateThumbnail( media_result ) + except HydrusExceptions.FileMissingException: @@ -2439,6 +2470,9 @@ class FilesMaintenanceManager( object ): return None + def _RegenBlurHash( self, media ): + + return self._controller.client_files_manager.RegenerateImageBlurHash( media ) def _RegenSimilarFilesMetadata( self, media_result ): @@ -2560,11 +2594,13 @@ class FilesMaintenanceManager( object ): elif job_type == REGENERATE_FILE_DATA_JOB_FORCE_THUMBNAIL: - self._RegenFileThumbnailForce( media_result ) + additional_data = self._RegenFileThumbnailForce( media_result ) elif job_type == REGENERATE_FILE_DATA_JOB_REFIT_THUMBNAIL: was_regenerated = self._RegenFileThumbnailRefit( media_result ) + + additional_data = was_regenerated if was_regenerated: @@ -2595,6 +2631,10 @@ class FilesMaintenanceManager( object ): elif job_type == REGENERATE_FILE_DATA_JOB_FIX_PERMISSIONS: self._FixFilePermissions( media_result ) + + elif job_type == REGENERATE_FILE_DATA_JOB_BLURHASH: + + additional_data = self._RegenBlurHash( media_result ) elif job_type in ( REGENERATE_FILE_DATA_JOB_FILE_INTEGRITY_PRESENCE_REMOVE_RECORD, diff --git a/hydrus/client/ClientPDFHandling.py b/hydrus/client/ClientPDFHandling.py index b68f2fa4..64fb8c05 100644 --- a/hydrus/client/ClientPDFHandling.py +++ b/hydrus/client/ClientPDFHandling.py @@ -75,7 +75,7 @@ def LoadPDF( path: str ): return document -def GenerateThumbnailBytesFromPDFPath( path: str, target_resolution: typing.Tuple[int, int], clip_rect = None ) -> bytes: +def GenerateThumbnailNumPyFromPDFPath( path: str, target_resolution: typing.Tuple[int, int], clip_rect = None ) -> bytes: try: @@ -114,7 +114,7 @@ def GenerateThumbnailBytesFromPDFPath( path: str, target_resolution: typing.Tupl thumbnail_numpy_image = HydrusImageHandling.ResizeNumPyImage( numpy_image, target_resolution ) - return HydrusImageHandling.GenerateThumbnailBytesNumPy( thumbnail_numpy_image ) + return thumbnail_numpy_image except Exception as e: @@ -126,7 +126,7 @@ def GenerateThumbnailBytesFromPDFPath( path: str, target_resolution: typing.Tupl -HydrusPDFHandling.GenerateThumbnailBytesFromPDFPath = GenerateThumbnailBytesFromPDFPath +HydrusPDFHandling.GenerateThumbnailNumPyFromPDFPath = GenerateThumbnailNumPyFromPDFPath PDF_ASSUMED_DPI = 300 diff --git a/hydrus/client/ClientSVGHandling.py b/hydrus/client/ClientSVGHandling.py index a8e3c7ba..e13667f2 100644 --- a/hydrus/client/ClientSVGHandling.py +++ b/hydrus/client/ClientSVGHandling.py @@ -31,7 +31,7 @@ def LoadSVGRenderer( path: str ): return renderer -def GenerateThumbnailBytesFromSVGPath( path: str, target_resolution: typing.Tuple[int, int], clip_rect = None ) -> bytes: +def GenerateThumbnailNumPyFromSVGPath( path: str, target_resolution: typing.Tuple[int, int], clip_rect = None ) -> bytes: # TODO: SVGs have no inherent resolution, so all this is pretty stupid. we should render to exactly the res we want and then clip the result, not beforehand @@ -74,7 +74,7 @@ def GenerateThumbnailBytesFromSVGPath( path: str, target_resolution: typing.Tupl thumbnail_numpy_image = HydrusImageHandling.ResizeNumPyImage( numpy_image, target_resolution ) - return HydrusImageHandling.GenerateThumbnailBytesNumPy( thumbnail_numpy_image ) + return thumbnail_numpy_image except: @@ -82,7 +82,7 @@ def GenerateThumbnailBytesFromSVGPath( path: str, target_resolution: typing.Tupl -HydrusSVGHandling.GenerateThumbnailBytesFromSVGPath = GenerateThumbnailBytesFromSVGPath +HydrusSVGHandling.GenerateThumbnailNumPyFromSVGPath = GenerateThumbnailNumPyFromSVGPath def GetSVGResolution( path: str ): diff --git a/hydrus/client/db/ClientDB.py b/hydrus/client/db/ClientDB.py index 13644e3b..fc2fa6c9 100644 --- a/hydrus/client/db/ClientDB.py +++ b/hydrus/client/db/ClientDB.py @@ -2775,7 +2775,7 @@ class DB( HydrusDB.HydrusDB ): return boned_stats - def _GetFileInfoManagers( self, hash_ids: typing.Collection[ int ], sorted = False ) -> typing.List[ ClientMediaManagers.FileInfoManager ]: + def _GetFileInfoManagers( self, hash_ids: typing.Collection[ int ], sorted = False, blurhash = False ) -> typing.List[ ClientMediaManagers.FileInfoManager ]: ( cached_media_results, missing_hash_ids ) = self._weakref_media_result_cache.GetMediaResultsAndMissing( hash_ids ) @@ -2790,6 +2790,9 @@ class DB( HydrusDB.HydrusDB ): # temp hashes to metadata hash_ids_to_info = { hash_id : ClientMediaManagers.FileInfoManager( hash_id, missing_hash_ids_to_hashes[ hash_id ], size, mime, width, height, duration, num_frames, has_audio, num_words ) for ( hash_id, size, mime, width, height, duration, num_frames, has_audio, num_words ) in self._Execute( 'SELECT * FROM {} CROSS JOIN files_info USING ( hash_id );'.format( temp_table_name ) ) } + if blurhash: + + hash_ids_to_blurhash = self.modules_files_metadata_basic.GetHashIdsToBlurHash( temp_table_name ) # build it @@ -2798,6 +2801,10 @@ class DB( HydrusDB.HydrusDB ): if hash_id in hash_ids_to_info: file_info_manager = hash_ids_to_info[ hash_id ] + + if blurhash and hash_id in hash_ids_to_blurhash: + + file_info_manager.blurhash = hash_ids_to_blurhash[hash_id] else: @@ -3295,6 +3302,8 @@ class DB( HydrusDB.HydrusDB ): hash_ids_to_tags_managers = self._GetForceRefreshTagsManagersWithTableHashIds( missing_hash_ids, temp_table_name, hash_ids_to_current_file_service_ids = hash_ids_to_current_file_service_ids ) + hash_ids_to_blurhash = self.modules_files_metadata_basic.GetHashIdsToBlurHash( temp_table_name ) + has_exif_hash_ids = self.modules_files_metadata_basic.GetHasEXIFHashIds( temp_table_name ) has_human_readable_embedded_metadata_hash_ids = self.modules_files_metadata_basic.GetHasHumanReadableEmbeddedMetadataHashIds( temp_table_name ) has_icc_profile_hash_ids = self.modules_files_metadata_basic.GetHasICCProfileHashIds( temp_table_name ) @@ -3412,6 +3421,10 @@ class DB( HydrusDB.HydrusDB ): file_info_manager.has_exif = hash_id in has_exif_hash_ids file_info_manager.has_human_readable_embedded_metadata = hash_id in has_human_readable_embedded_metadata_hash_ids file_info_manager.has_icc_profile = hash_id in has_icc_profile_hash_ids + + if hash_id in hash_ids_to_blurhash: + + file_info_manager.blurhash = hash_ids_to_blurhash[hash_id] missing_media_results.append( ClientMediaResult.MediaResult( file_info_manager, tags_manager, timestamps_manager, locations_manager, ratings_manager, notes_manager, file_viewing_stats_manager ) ) @@ -4614,6 +4627,8 @@ class DB( HydrusDB.HydrusDB ): self.modules_files_metadata_basic.SetHasHumanReadableEmbeddedMetadata( hash_id, file_import_job.HasHumanReadableEmbeddedMetadata() ) self.modules_files_metadata_basic.SetHasICCProfile( hash_id, file_import_job.HasICCProfile() ) + self.modules_files_metadata_basic.SetBlurHash( hash_id, file_import_job.GetBlurhash()) + # file_modified_timestamp = file_import_job.GetFileModifiedTimestamp() diff --git a/hydrus/client/db/ClientDBFilesMaintenance.py b/hydrus/client/db/ClientDBFilesMaintenance.py index 9fe98bf8..b8b53a88 100644 --- a/hydrus/client/db/ClientDBFilesMaintenance.py +++ b/hydrus/client/db/ClientDBFilesMaintenance.py @@ -180,8 +180,16 @@ class ClientDBFilesMaintenance( ClientDBModule.ClientDBModule ): if self.modules_similar_files.FileIsInSystem( hash_id ): self.modules_similar_files.StopSearchingFile( hash_id ) + + elif job_type == ClientFiles.REGENERATE_FILE_DATA_JOB_FORCE_THUMBNAIL or job_type == ClientFiles.REGENERATE_FILE_DATA_JOB_REFIT_THUMBNAIL: + + self.modules_files_maintenance_queue.AddJobs( ( hash_id, ), ClientFiles.REGENERATE_FILE_DATA_JOB_BLURHASH ) - + elif job_type == ClientFiles.REGENERATE_FILE_DATA_JOB_BLURHASH: + + blurhash: str = additional_data + + self.modules_files_metadata_basic.SetBlurHash( hash_id, blurhash ) diff --git a/hydrus/client/db/ClientDBFilesMetadataBasic.py b/hydrus/client/db/ClientDBFilesMetadataBasic.py index 4a5655a5..1841afb1 100644 --- a/hydrus/client/db/ClientDBFilesMetadataBasic.py +++ b/hydrus/client/db/ClientDBFilesMetadataBasic.py @@ -35,7 +35,8 @@ class ClientDBFilesMetadataBasic( ClientDBModule.ClientDBModule ): 'main.files_info' : ( 'CREATE TABLE IF NOT EXISTS {} ( hash_id INTEGER PRIMARY KEY, size INTEGER, mime INTEGER, width INTEGER, height INTEGER, duration INTEGER, num_frames INTEGER, has_audio INTEGER_BOOLEAN, num_words INTEGER );', 400 ), 'main.has_icc_profile' : ( 'CREATE TABLE IF NOT EXISTS {} ( hash_id INTEGER PRIMARY KEY );', 465 ), 'main.has_exif' : ( 'CREATE TABLE IF NOT EXISTS {} ( hash_id INTEGER PRIMARY KEY );', 505 ), - 'main.has_human_readable_embedded_metadata' : ( 'CREATE TABLE IF NOT EXISTS {} ( hash_id INTEGER PRIMARY KEY );', 505 ) + 'main.has_human_readable_embedded_metadata' : ( 'CREATE TABLE IF NOT EXISTS {} ( hash_id INTEGER PRIMARY KEY );', 505 ), + 'main.blurhash' : ( 'CREATE TABLE IF NOT EXISTS {} ( hash_id INTEGER PRIMARY KEY, blurhash TEXT );', 545 ), } @@ -107,7 +108,8 @@ class ClientDBFilesMetadataBasic( ClientDBModule.ClientDBModule ): ( 'files_info', 'hash_id' ), ( 'has_exif', 'hash_id' ), ( 'has_human_readable_embedded_metadata', 'hash_id' ), - ( 'has_icc_profile', 'hash_id' ) + ( 'has_icc_profile', 'hash_id' ), + ( 'blurhash', 'hash_id' ) ] @@ -215,6 +217,31 @@ class ClientDBFilesMetadataBasic( ClientDBModule.ClientDBModule ): else: self._Execute( 'DELETE FROM has_icc_profile WHERE hash_id = ?;', ( hash_id, ) ) + + def SetBlurHash( self, hash_id: int, blurhash: str ): + + # TODO blurhash db stuff + + self._Execute('INSERT OR REPLACE INTO blurhash ( hash_id, blurhash ) VALUES ( ?, ?);', (hash_id, blurhash)) + + + def GetBlurHash( self, hash_id: int ) -> str: + + result = self._Execute( 'SELECT blurhash FROM blurhash WHERE hash_id = ?;', ( hash_id, ) ).fetchone() + + # TODO blurhash db stuff + + if result is None: + + raise HydrusExceptions.DataMissing( 'Did not have blurhash information for that file!' ) + ( blurhash, ) = result + + return blurhash + + def GetHashIdsToBlurHash( self, hash_ids_table_name: str ): + + return dict( self._Execute( 'SELECT hash_id, blurhash FROM {} CROSS JOIN blurhash USING ( hash_id );'.format( hash_ids_table_name ) ) ) + diff --git a/hydrus/client/importing/ClientImportFiles.py b/hydrus/client/importing/ClientImportFiles.py index c9a4864e..39bc39b8 100644 --- a/hydrus/client/importing/ClientImportFiles.py +++ b/hydrus/client/importing/ClientImportFiles.py @@ -130,6 +130,7 @@ class FileImportJob( object ): self._has_icc_profile = None self._pixel_hash = None self._file_modified_timestamp = None + self._blurhash = None def CheckIsGoodToImport( self ): @@ -353,9 +354,20 @@ class FileImportJob( object ): percentage_in = HG.client_controller.new_options.GetInteger( 'video_thumbnail_percentage_in' ) + thumbnail_numpy = HydrusFileHandling.GenerateThumbnailNumPy(self._temp_path, target_resolution, mime, duration, num_frames, clip_rect = clip_rect, percentage_in = percentage_in) + # this guy handles almost all his own exceptions now, so no need for clever catching. if it fails, we are prob talking an I/O failure, which is not a 'thumbnail failed' error - self._thumbnail_bytes = HydrusFileHandling.GenerateThumbnailBytes( self._temp_path, target_resolution, mime, duration, num_frames, clip_rect = clip_rect, percentage_in = percentage_in ) + self._thumbnail_bytes = HydrusImageHandling.GenerateThumbnailBytesNumPy( thumbnail_numpy ) + try: + + self._blurhash = HydrusImageHandling.GetImageBlurHashNumPy( thumbnail_numpy ) + + except: + + pass + + if mime in HC.FILES_THAT_HAVE_PERCEPTUAL_HASH: @@ -503,6 +515,10 @@ class FileImportJob( object ): def HasICCProfile( self ) -> bool: return self._has_icc_profile + + def GetBlurhash( self ) -> str: + + return self._blurhash def PubsubContentUpdates( self ): diff --git a/hydrus/client/media/ClientMediaManagers.py b/hydrus/client/media/ClientMediaManagers.py index 3dd8774a..16a16e44 100644 --- a/hydrus/client/media/ClientMediaManagers.py +++ b/hydrus/client/media/ClientMediaManagers.py @@ -76,6 +76,7 @@ class FileInfoManager( object ): self.has_exif = False self.has_human_readable_embedded_metadata = False self.has_icc_profile = False + self.blurhash = None def Duplicate( self ): diff --git a/hydrus/client/networking/ClientLocalServerResources.py b/hydrus/client/networking/ClientLocalServerResources.py index 9c635166..cf3c63ec 100644 --- a/hydrus/client/networking/ClientLocalServerResources.py +++ b/hydrus/client/networking/ClientLocalServerResources.py @@ -66,7 +66,7 @@ LOCAL_BOORU_JSON_BYTE_LIST_PARAMS = set() CLIENT_API_INT_PARAMS = { 'file_id', 'file_sort_type', 'potentials_search_type', 'pixel_duplicates', 'max_hamming_distance', 'max_num_pairs' } CLIENT_API_BYTE_PARAMS = { 'hash', 'destination_page_key', 'page_key', 'service_key', 'Hydrus-Client-API-Access-Key', 'Hydrus-Client-API-Session-Key', 'file_service_key', 'deleted_file_service_key', 'tag_service_key', 'tag_service_key_1', 'tag_service_key_2', 'rating_service_key' } CLIENT_API_STRING_PARAMS = { 'name', 'url', 'domain', 'search', 'service_name', 'reason', 'tag_display_type', 'source_hash_type', 'desired_hash_type' } -CLIENT_API_JSON_PARAMS = { 'basic_permissions', 'tags', 'tags_1', 'tags_2', 'file_ids', 'download', 'only_return_identifiers', 'only_return_basic_information', 'create_new_file_ids', 'detailed_url_information', 'hide_service_keys_tags', 'simple', 'file_sort_asc', 'return_hashes', 'return_file_ids', 'include_notes', 'include_services_object', 'notes', 'note_names', 'doublecheck_file_system' } +CLIENT_API_JSON_PARAMS = { 'basic_permissions', 'tags', 'tags_1', 'tags_2', 'file_ids', 'download', 'only_return_identifiers', 'only_return_basic_information', 'include_blurhash', 'create_new_file_ids', 'detailed_url_information', 'hide_service_keys_tags', 'simple', 'file_sort_asc', 'return_hashes', 'return_file_ids', 'include_notes', 'include_services_object', 'notes', 'note_names', 'doublecheck_file_system' } CLIENT_API_JSON_BYTE_LIST_PARAMS = { 'file_service_keys', 'deleted_file_service_keys', 'hashes' } CLIENT_API_JSON_BYTE_DICT_PARAMS = { 'service_keys_to_tags', 'service_keys_to_actions_to_tags', 'service_keys_to_additional_tags' } @@ -2958,6 +2958,7 @@ class HydrusResourceClientAPIRestrictedGetFilesFileMetadata( HydrusResourceClien include_notes = request.parsed_request_args.GetValue( 'include_notes', bool, default_value = False ) include_services_object = request.parsed_request_args.GetValue( 'include_services_object', bool, default_value = True ) create_new_file_ids = request.parsed_request_args.GetValue( 'create_new_file_ids', bool, default_value = False ) + include_blurhash = request.parsed_request_args.GetValue( 'include_blurhash', bool, default_value = False ) hashes = ParseHashes( request ) @@ -2994,7 +2995,7 @@ class HydrusResourceClientAPIRestrictedGetFilesFileMetadata( HydrusResourceClien elif only_return_basic_information: - file_info_managers = HG.client_controller.Read( 'file_info_managers_from_ids', hash_ids ) + file_info_managers = HG.client_controller.Read( 'file_info_managers_from_ids', hash_ids, blurhash = include_blurhash ) hashes_to_file_info_managers = { file_info_manager.hash : file_info_manager for file_info_manager in file_info_managers } @@ -3019,6 +3020,10 @@ class HydrusResourceClientAPIRestrictedGetFilesFileMetadata( HydrusResourceClien 'num_words' : file_info_manager.num_words, 'has_audio' : file_info_manager.has_audio } + + if include_blurhash: + + metadata_row['blurhash'] = file_info_manager.blurhash metadata.append( metadata_row ) @@ -3072,7 +3077,8 @@ class HydrusResourceClientAPIRestrictedGetFilesFileMetadata( HydrusResourceClien 'duration' : file_info_manager.duration, 'num_frames' : file_info_manager.num_frames, 'num_words' : file_info_manager.num_words, - 'has_audio' : file_info_manager.has_audio + 'has_audio' : file_info_manager.has_audio, + 'blurhash' : file_info_manager.blurhash } if file_info_manager.mime in HC.MIMES_WITH_THUMBNAILS: diff --git a/hydrus/core/HydrusFileHandling.py b/hydrus/core/HydrusFileHandling.py index 89ee6345..25db2fef 100644 --- a/hydrus/core/HydrusFileHandling.py +++ b/hydrus/core/HydrusFileHandling.py @@ -116,6 +116,11 @@ headers_and_mime.extend( [ def GenerateThumbnailBytes( path, target_resolution, mime, duration, num_frames, clip_rect = None, percentage_in = 35 ): + thumbnail_numpy = GenerateThumbnailNumPy(path, target_resolution, mime, duration, num_frames, clip_rect, percentage_in ) + + return HydrusImageHandling.GenerateThumbnailBytesNumPy(thumbnail_numpy) + +def GenerateThumbnailNumPy( path, target_resolution, mime, duration, num_frames, clip_rect = None, percentage_in = 35 ): if target_resolution == ( 0, 0 ): target_resolution = ( 128, 128 ) @@ -125,7 +130,7 @@ def GenerateThumbnailBytes( path, target_resolution, mime, duration, num_frames, try: - thumbnail_bytes = HydrusPSDHandling.GenerateThumbnailBytesFromPSDPath( path, target_resolution, clip_rect = clip_rect ) + thumbnail_numpy = HydrusPSDHandling.GenerateThumbnailNumPyFromPSDPath( path, target_resolution, clip_rect = clip_rect ) except Exception as e: @@ -139,13 +144,13 @@ def GenerateThumbnailBytes( path, target_resolution, mime, duration, num_frames, HydrusVideoHandling.RenderImageToImagePath( path, temp_path ) - thumbnail_bytes = HydrusImageHandling.GenerateThumbnailBytesFromStaticImagePath( temp_path, target_resolution, HC.IMAGE_PNG, clip_rect = clip_rect ) + thumbnail_numpy = HydrusImageHandling.GenerateThumbnailNumPyFromStaticImagePath( temp_path, target_resolution, HC.IMAGE_PNG, clip_rect = clip_rect ) except Exception as e: thumb_path = os.path.join( HC.STATIC_DIR, 'psd.png' ) - thumbnail_bytes = HydrusImageHandling.GenerateThumbnailBytesFromStaticImagePath( thumb_path, target_resolution, HC.IMAGE_PNG, clip_rect = clip_rect ) + thumbnail_numpy = HydrusImageHandling.GenerateThumbnailNumPyFromStaticImagePath( thumb_path, target_resolution, HC.IMAGE_PNG, clip_rect = clip_rect ) finally: @@ -161,13 +166,13 @@ def GenerateThumbnailBytes( path, target_resolution, mime, duration, num_frames, HydrusClipHandling.ExtractDBPNGToPath( path, temp_path ) - thumbnail_bytes = HydrusImageHandling.GenerateThumbnailBytesFromStaticImagePath( temp_path, target_resolution, HC.IMAGE_PNG, clip_rect = clip_rect ) + thumbnail_numpy = HydrusImageHandling.GenerateThumbnailNumPyFromStaticImagePath( temp_path, target_resolution, HC.IMAGE_PNG, clip_rect = clip_rect ) except: thumb_path = os.path.join( HC.STATIC_DIR, 'clip.png' ) - thumbnail_bytes = HydrusImageHandling.GenerateThumbnailBytesFromStaticImagePath( thumb_path, target_resolution, HC.IMAGE_PNG, clip_rect = clip_rect ) + thumbnail_numpy = HydrusImageHandling.GenerateThumbnailNumPyFromStaticImagePath( thumb_path, target_resolution, HC.IMAGE_PNG, clip_rect = clip_rect ) finally: @@ -182,13 +187,13 @@ def GenerateThumbnailBytes( path, target_resolution, mime, duration, num_frames, HydrusKritaHandling.ExtractZippedImageToPath( path, temp_path ) - thumbnail_bytes = HydrusImageHandling.GenerateThumbnailBytesFromStaticImagePath( temp_path, target_resolution, HC.IMAGE_PNG, clip_rect = clip_rect ) + thumbnail_numpy = HydrusImageHandling.GenerateThumbnailNumPyFromStaticImagePath( temp_path, target_resolution, HC.IMAGE_PNG, clip_rect = clip_rect ) except Exception as e: thumb_path = os.path.join( HC.STATIC_DIR, 'krita.png' ) - thumbnail_bytes = HydrusImageHandling.GenerateThumbnailBytesFromStaticImagePath( thumb_path, target_resolution, HC.IMAGE_PNG, clip_rect = clip_rect ) + thumbnail_numpy = HydrusImageHandling.GenerateThumbnailNumPyFromStaticImagePath( thumb_path, target_resolution, HC.IMAGE_PNG, clip_rect = clip_rect ) finally: @@ -202,13 +207,13 @@ def GenerateThumbnailBytes( path, target_resolution, mime, duration, num_frames, HydrusProcreateHandling.ExtractZippedThumbnailToPath( path, temp_path ) - thumbnail_bytes = HydrusImageHandling.GenerateThumbnailBytesFromStaticImagePath( temp_path, target_resolution, HC.IMAGE_PNG, clip_rect = clip_rect ) + thumbnail_numpy = HydrusImageHandling.GenerateThumbnailNumPyFromStaticImagePath( temp_path, target_resolution, HC.IMAGE_PNG, clip_rect = clip_rect ) except Exception as e: thumb_path = os.path.join( HC.STATIC_DIR, 'procreate.png' ) - thumbnail_bytes = HydrusImageHandling.GenerateThumbnailBytesFromStaticImagePath( thumb_path, target_resolution, HC.IMAGE_PNG, clip_rect = clip_rect ) + thumbnail_numpy = HydrusImageHandling.GenerateThumbnailNumPyFromStaticImagePath( thumb_path, target_resolution, HC.IMAGE_PNG, clip_rect = clip_rect ) finally: @@ -219,7 +224,7 @@ def GenerateThumbnailBytes( path, target_resolution, mime, duration, num_frames, try: - thumbnail_bytes = HydrusSVGHandling.GenerateThumbnailBytesFromSVGPath( path, target_resolution, clip_rect = clip_rect ) + thumbnail_numpy = HydrusSVGHandling.GenerateThumbnailNumPyFromSVGPath( path, target_resolution, clip_rect = clip_rect ) except Exception as e: @@ -231,14 +236,14 @@ def GenerateThumbnailBytes( path, target_resolution, mime, duration, num_frames, thumb_path = os.path.join( HC.STATIC_DIR, 'svg.png' ) - thumbnail_bytes = HydrusImageHandling.GenerateThumbnailBytesFromStaticImagePath( thumb_path, target_resolution, HC.IMAGE_PNG, clip_rect = clip_rect ) + thumbnail_numpy = HydrusImageHandling.GenerateThumbnailNumPyFromStaticImagePath( thumb_path, target_resolution, HC.IMAGE_PNG, clip_rect = clip_rect ) elif mime == HC.APPLICATION_PDF: try: - thumbnail_bytes = HydrusPDFHandling.GenerateThumbnailBytesFromPDFPath( path, target_resolution, clip_rect = clip_rect ) + thumbnail_numpy = HydrusPDFHandling.GenerateThumbnailNumPyFromPDFPath( path, target_resolution, clip_rect = clip_rect ) except Exception as e: @@ -250,7 +255,7 @@ def GenerateThumbnailBytes( path, target_resolution, mime, duration, num_frames, thumb_path = os.path.join( HC.STATIC_DIR, 'pdf.png' ) - thumbnail_bytes = HydrusImageHandling.GenerateThumbnailBytesFromStaticImagePath( thumb_path, target_resolution, HC.IMAGE_PNG, clip_rect = clip_rect ) + thumbnail_numpy = HydrusImageHandling.GenerateThumbnailNumPyFromStaticImagePath( thumb_path, target_resolution, HC.IMAGE_PNG, clip_rect = clip_rect ) elif mime == HC.APPLICATION_FLASH: @@ -261,13 +266,13 @@ def GenerateThumbnailBytes( path, target_resolution, mime, duration, num_frames, HydrusFlashHandling.RenderPageToFile( path, temp_path, 1 ) - thumbnail_bytes = HydrusImageHandling.GenerateThumbnailBytesFromStaticImagePath( temp_path, target_resolution, HC.IMAGE_PNG, clip_rect = clip_rect ) + thumbnail_numpy = HydrusImageHandling.GenerateThumbnailNumPyFromStaticImagePath( temp_path, target_resolution, HC.IMAGE_PNG, clip_rect = clip_rect ) except: thumb_path = os.path.join( HC.STATIC_DIR, 'flash.png' ) - thumbnail_bytes = HydrusImageHandling.GenerateThumbnailBytesFromStaticImagePath( thumb_path, target_resolution, HC.IMAGE_PNG, clip_rect = clip_rect ) + thumbnail_numpy = HydrusImageHandling.GenerateThumbnailNumPyFromStaticImagePath( thumb_path, target_resolution, HC.IMAGE_PNG, clip_rect = clip_rect ) finally: @@ -280,7 +285,7 @@ def GenerateThumbnailBytes( path, target_resolution, mime, duration, num_frames, try: - thumbnail_bytes = HydrusImageHandling.GenerateThumbnailBytesFromStaticImagePath( path, target_resolution, mime, clip_rect = clip_rect ) + thumbnail_numpy = HydrusImageHandling.GenerateThumbnailNumPyFromStaticImagePath( path, target_resolution, mime, clip_rect = clip_rect ) except Exception as e: @@ -289,7 +294,7 @@ def GenerateThumbnailBytes( path, target_resolution, mime, duration, num_frames, thumb_path = os.path.join( HC.STATIC_DIR, 'hydrus.png' ) - thumbnail_bytes = HydrusImageHandling.GenerateThumbnailBytesFromStaticImagePath( thumb_path, target_resolution, HC.IMAGE_PNG, clip_rect = clip_rect ) + thumbnail_numpy = HydrusImageHandling.GenerateThumbnailNumPyFromStaticImagePath( thumb_path, target_resolution, HC.IMAGE_PNG, clip_rect = clip_rect ) else: @@ -340,13 +345,11 @@ def GenerateThumbnailBytes( path, target_resolution, mime, duration, num_frames, thumb_path = os.path.join( HC.STATIC_DIR, 'hydrus.png' ) - thumbnail_bytes = HydrusImageHandling.GenerateThumbnailBytesFromStaticImagePath( thumb_path, target_resolution, HC.IMAGE_PNG, clip_rect = clip_rect ) + thumbnail_numpy = HydrusImageHandling.GenerateThumbnailNumPyFromStaticImagePath( thumb_path, target_resolution, HC.IMAGE_PNG, clip_rect = clip_rect ) else: - numpy_image = HydrusImageHandling.ResizeNumPyImage( numpy_image, target_resolution ) # just in case ffmpeg doesn't deliver right - - thumbnail_bytes = HydrusImageHandling.GenerateThumbnailBytesNumPy( numpy_image ) + thumbnail_numpy = HydrusImageHandling.ResizeNumPyImage( numpy_image, target_resolution ) # just in case ffmpeg doesn't deliver right if renderer is not None: @@ -355,7 +358,7 @@ def GenerateThumbnailBytes( path, target_resolution, mime, duration, num_frames, - return thumbnail_bytes + return thumbnail_numpy def GetExtraHashesFromPath( path ): diff --git a/hydrus/core/HydrusImageHandling.py b/hydrus/core/HydrusImageHandling.py index daed03b6..11a018ce 100644 --- a/hydrus/core/HydrusImageHandling.py +++ b/hydrus/core/HydrusImageHandling.py @@ -47,6 +47,8 @@ from hydrus.core import HydrusPaths from hydrus.core import HydrusTemp from hydrus.core import HydrusPSDHandling +from hydrus.external import blurhash + PIL_SRGB_PROFILE = PILImageCms.createProfile( 'sRGB' ) def EnableLoadTruncatedImages(): @@ -436,7 +438,7 @@ def GeneratePILImageFromNumPyImage( numpy_image: numpy.array ) -> PILImage.Image return pil_image -def GenerateThumbnailBytesFromStaticImagePath( path, target_resolution, mime, clip_rect = None ) -> bytes: +def GenerateThumbnailNumPyFromStaticImagePath( path, target_resolution, mime, clip_rect = None ): if OPENCV_OK: @@ -449,18 +451,9 @@ def GenerateThumbnailBytesFromStaticImagePath( path, target_resolution, mime, cl thumbnail_numpy_image = ResizeNumPyImage( numpy_image, target_resolution ) - try: - - thumbnail_bytes = GenerateThumbnailBytesNumPy( thumbnail_numpy_image ) - - return thumbnail_bytes - - except HydrusExceptions.CantRenderWithCVException: - - pass # fallback to PIL + return thumbnail_numpy_image - pil_image = GeneratePILImage( path ) if clip_rect is not None: @@ -470,9 +463,9 @@ def GenerateThumbnailBytesFromStaticImagePath( path, target_resolution, mime, cl thumbnail_pil_image = pil_image.resize( target_resolution, PILImage.LANCZOS ) - thumbnail_bytes = GenerateThumbnailBytesPIL( thumbnail_pil_image ) + thumbnail_numpy_image = GenerateNumPyImageFromPILImage(thumbnail_pil_image) - return thumbnail_bytes + return thumbnail_numpy_image def GenerateThumbnailBytesNumPy( numpy_image ) -> bytes: @@ -1253,3 +1246,6 @@ def StripOutAnyUselessAlphaChannel( numpy_image: numpy.array ) -> numpy.array: return numpy_image +def GetImageBlurHashNumPy( numpy_image, components_x = 4, components_y = 4 ): + + return blurhash.blurhash_encode( numpy_image, components_x, components_y ) diff --git a/hydrus/core/HydrusPDFHandling.py b/hydrus/core/HydrusPDFHandling.py index e04abb81..237c5a69 100644 --- a/hydrus/core/HydrusPDFHandling.py +++ b/hydrus/core/HydrusPDFHandling.py @@ -2,7 +2,7 @@ import typing from hydrus.core import HydrusExceptions -def BaseGenerateThumbnailBytesFromPDFPath( path: str, target_resolution: typing.Tuple[int, int], clip_rect = None ) -> bytes: +def BaseGenerateThumbnailNumPyFromPDFPath( path: str, target_resolution: typing.Tuple[int, int], clip_rect = None ) -> bytes: raise HydrusExceptions.NoThumbnailFileException() @@ -12,5 +12,5 @@ def BaseGetPDFInfo( path: str ): raise HydrusExceptions.LimitedSupportFileException() -GenerateThumbnailBytesFromPDFPath = BaseGenerateThumbnailBytesFromPDFPath +GenerateThumbnailNumPyFromPDFPath = BaseGenerateThumbnailNumPyFromPDFPath GetPDFInfo = BaseGetPDFInfo diff --git a/hydrus/core/HydrusPSDHandling.py b/hydrus/core/HydrusPSDHandling.py index 2a099b4f..2bf84cb2 100644 --- a/hydrus/core/HydrusPSDHandling.py +++ b/hydrus/core/HydrusPSDHandling.py @@ -37,7 +37,7 @@ def MergedPILImageFromPSD( path: str ) -> PILImage: return HydrusPSDTools.MergedPILImageFromPSD( path ) -def GenerateThumbnailBytesFromPSDPath( path: str, target_resolution: typing.Tuple[int, int], clip_rect = None ) -> bytes: +def GenerateThumbnailNumPyFromPSDPath( path: str, target_resolution: typing.Tuple[int, int], clip_rect = None ) -> bytes: pil_image = MergedPILImageFromPSD( path ) @@ -48,9 +48,7 @@ def GenerateThumbnailBytesFromPSDPath( path: str, target_resolution: typing.Tupl thumbnail_pil_image = pil_image.resize( target_resolution, PILImage.LANCZOS ) - thumbnail_bytes = HydrusImageHandling.GenerateThumbnailBytesPIL( thumbnail_pil_image ) - - return thumbnail_bytes + return HydrusImageHandling.GenerateNumPyImageFromPILImage(thumbnail_pil_image) def GetPSDResolution( path: str ): diff --git a/hydrus/core/HydrusSVGHandling.py b/hydrus/core/HydrusSVGHandling.py index 91085a26..909ec028 100644 --- a/hydrus/core/HydrusSVGHandling.py +++ b/hydrus/core/HydrusSVGHandling.py @@ -2,7 +2,7 @@ import typing from hydrus.core import HydrusExceptions -def BaseGenerateThumbnailBytesFromSVGPath( path: str, target_resolution: typing.Tuple[int, int], clip_rect = None ) -> bytes: +def BaseGenerateThumbnailNumPyFromSVGPath( path: str, target_resolution: typing.Tuple[int, int], clip_rect = None ) -> bytes: raise HydrusExceptions.NoThumbnailFileException() @@ -12,5 +12,5 @@ def BaseGetSVGResolution( path: str ): raise HydrusExceptions.NoResolutionFileException() -GenerateThumbnailBytesFromSVGPath = BaseGenerateThumbnailBytesFromSVGPath +GenerateThumbnailNumPyFromSVGPath = BaseGenerateThumbnailNumPyFromSVGPath GetSVGResolution = BaseGetSVGResolution diff --git a/hydrus/external/blurhash.py b/hydrus/external/blurhash.py new file mode 100644 index 00000000..c445b744 --- /dev/null +++ b/hydrus/external/blurhash.py @@ -0,0 +1,256 @@ +""" +Pure python blurhash decoder with no additional dependencies, for +both de- and encoding. + +Very close port of the original Swift implementation by Dag Ă…gren. +""" + +""" +From https://github.com/halcy/blurhash-python + +MIT License + +Copyright (c) 2019 Lorenz Diener + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +import math + +# Alphabet for base 83 +alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" +alphabet_values = dict(zip(alphabet, range(len(alphabet)))) + +def base83_decode(base83_str): + """ + Decodes a base83 string, as used in blurhash, to an integer. + """ + value = 0 + for base83_char in base83_str: + value = value * 83 + alphabet_values[base83_char] + return value + +def base83_encode(value, length): + """ + Decodes an integer to a base83 string, as used in blurhash. + + Length is how long the resulting string should be. Will complain + if the specified length is too short. + """ + if int(value) // (83 ** (length)) != 0: + raise ValueError("Specified length is too short to encode given value.") + + result = "" + for i in range(1, length + 1): + digit = int(value) // (83 ** (length - i)) % 83 + result += alphabet[int(digit)] + return result + +def srgb_to_linear(value): + """ + srgb 0-255 integer to linear 0.0-1.0 floating point conversion. + """ + value = float(value) / 255.0 + if value <= 0.04045: + return value / 12.92 + return math.pow((value + 0.055) / 1.055, 2.4) + +def sign_pow(value, exp): + """ + Sign-preserving exponentiation. + """ + return math.copysign(math.pow(abs(value), exp), value) + +def linear_to_srgb(value): + """ + linear 0.0-1.0 floating point to srgb 0-255 integer conversion. + """ + value = max(0.0, min(1.0, value)) + if value <= 0.0031308: + return int(value * 12.92 * 255 + 0.5) + return int((1.055 * math.pow(value, 1 / 2.4) - 0.055) * 255 + 0.5) + +def blurhash_components(blurhash): + """ + Decodes and returns the number of x and y components in the given blurhash. + """ + if len(blurhash) < 6: + raise ValueError("BlurHash must be at least 6 characters long.") + + # Decode metadata + size_info = base83_decode(blurhash[0]) + size_y = int(size_info / 9) + 1 + size_x = (size_info % 9) + 1 + + return size_x, size_y + +def blurhash_decode(blurhash, width, height, punch = 1.0, linear = False): + """ + Decodes the given blurhash to an image of the specified size. + + Returns the resulting image a list of lists of 3-value sRGB 8 bit integer + lists. Set linear to True if you would prefer to get linear floating point + RGB back. + + The punch parameter can be used to de- or increase the contrast of the + resulting image. + + As per the original implementation it is suggested to only decode + to a relatively small size and then scale the result up, as it + basically looks the same anyways. + """ + if len(blurhash) < 6: + raise ValueError("BlurHash must be at least 6 characters long.") + + # Decode metadata + size_info = base83_decode(blurhash[0]) + size_y = int(size_info / 9) + 1 + size_x = (size_info % 9) + 1 + + quant_max_value = base83_decode(blurhash[1]) + real_max_value = (float(quant_max_value + 1) / 166.0) * punch + + # Make sure we at least have the right number of characters + if len(blurhash) != 4 + 2 * size_x * size_y: + raise ValueError("Invalid BlurHash length.") + + # Decode DC component + dc_value = base83_decode(blurhash[2:6]) + colours = [( + srgb_to_linear(dc_value >> 16), + srgb_to_linear((dc_value >> 8) & 255), + srgb_to_linear(dc_value & 255) + )] + + # Decode AC components + for component in range(1, size_x * size_y): + ac_value = base83_decode(blurhash[4+component*2:4+(component+1)*2]) + colours.append(( + sign_pow((float(int(ac_value / (19 * 19))) - 9.0) / 9.0, 2.0) * real_max_value, + sign_pow((float(int(ac_value / 19) % 19) - 9.0) / 9.0, 2.0) * real_max_value, + sign_pow((float(ac_value % 19) - 9.0) / 9.0, 2.0) * real_max_value + )) + + # Return image RGB values, as a list of lists of lists, + # consumable by something like numpy or PIL. + pixels = [] + for y in range(height): + pixel_row = [] + for x in range(width): + pixel = [0.0, 0.0, 0.0] + + for j in range(size_y): + for i in range(size_x): + basis = math.cos(math.pi * float(x) * float(i) / float(width)) * \ + math.cos(math.pi * float(y) * float(j) / float(height)) + colour = colours[i + j * size_x] + pixel[0] += colour[0] * basis + pixel[1] += colour[1] * basis + pixel[2] += colour[2] * basis + if linear == False: + pixel_row.append([ + linear_to_srgb(pixel[0]), + linear_to_srgb(pixel[1]), + linear_to_srgb(pixel[2]), + ]) + else: + pixel_row.append(pixel) + pixels.append(pixel_row) + return pixels + +def blurhash_encode(image, components_x = 4, components_y = 4, linear = False): + """ + Calculates the blurhash for an image using the given x and y component counts. + + Image should be a 3-dimensional array, with the first dimension being y, the second + being x, and the third being the three rgb components that are assumed to be 0-255 + srgb integers (incidentally, this is the format you will get from a PIL RGB image). + + You can also pass in already linear data - to do this, set linear to True. This is + useful if you want to encode a version of your image resized to a smaller size (which + you should ideally do in linear colour). + """ + if components_x < 1 or components_x > 9 or components_y < 1 or components_y > 9: + raise ValueError("x and y component counts must be between 1 and 9 inclusive.") + height = float(len(image)) + width = float(len(image[0])) + + # Convert to linear if neeeded + image_linear = [] + if linear == False: + for y in range(int(height)): + image_linear_line = [] + for x in range(int(width)): + image_linear_line.append([ + srgb_to_linear(image[y][x][0]), + srgb_to_linear(image[y][x][1]), + srgb_to_linear(image[y][x][2]) + ]) + image_linear.append(image_linear_line) + else: + image_linear = image + + # Calculate components + components = [] + max_ac_component = 0.0 + for j in range(components_y): + for i in range(components_x): + norm_factor = 1.0 if (i == 0 and j == 0) else 2.0 + component = [0.0, 0.0, 0.0] + for y in range(int(height)): + for x in range(int(width)): + basis = norm_factor * math.cos(math.pi * float(i) * float(x) / width) * \ + math.cos(math.pi * float(j) * float(y) / height) + component[0] += basis * image_linear[y][x][0] + component[1] += basis * image_linear[y][x][1] + component[2] += basis * image_linear[y][x][2] + + component[0] /= (width * height) + component[1] /= (width * height) + component[2] /= (width * height) + components.append(component) + + if not (i == 0 and j == 0): + max_ac_component = max(max_ac_component, abs(component[0]), abs(component[1]), abs(component[2])) + + # Encode components + dc_value = (linear_to_srgb(components[0][0]) << 16) + \ + (linear_to_srgb(components[0][1]) << 8) + \ + linear_to_srgb(components[0][2]) + + quant_max_ac_component = int(max(0, min(82, math.floor(max_ac_component * 166 - 0.5)))) + ac_component_norm_factor = float(quant_max_ac_component + 1) / 166.0 + + ac_values = [] + for r, g, b in components[1:]: + ac_values.append( + int(max(0.0, min(18.0, math.floor(sign_pow(r / ac_component_norm_factor, 0.5) * 9.0 + 9.5)))) * 19 * 19 + \ + int(max(0.0, min(18.0, math.floor(sign_pow(g / ac_component_norm_factor, 0.5) * 9.0 + 9.5)))) * 19 + \ + int(max(0.0, min(18.0, math.floor(sign_pow(b / ac_component_norm_factor, 0.5) * 9.0 + 9.5)))) + ) + + # Build final blurhash + blurhash = "" + blurhash += base83_encode((components_x - 1) + (components_y - 1) * 9, 1) + blurhash += base83_encode(quant_max_ac_component, 1) + blurhash += base83_encode(dc_value, 4) + for ac_value in ac_values: + blurhash += base83_encode(ac_value, 2) + + return blurhash