From 74273435915fc82ad483f2dd2ed8e6bbc8a17f5c Mon Sep 17 00:00:00 2001 From: Axxeman23 Date: Fri, 19 Aug 2022 17:00:28 -0400 Subject: [PATCH 1/5] adding star identifier --- plugins/starIdentifier/README.md | 83 +++++ plugins/starIdentifier/log.py | 52 +++ plugins/starIdentifier/star_identifier.py | 288 +++++++++++++++++ .../starIdentifier/star_identifier_config.py | 19 ++ .../star_identifier_interface.py | 296 ++++++++++++++++++ 5 files changed, 738 insertions(+) create mode 100644 plugins/starIdentifier/README.md create mode 100644 plugins/starIdentifier/log.py create mode 100644 plugins/starIdentifier/star_identifier.py create mode 100644 plugins/starIdentifier/star_identifier_config.py create mode 100644 plugins/starIdentifier/star_identifier_interface.py diff --git a/plugins/starIdentifier/README.md b/plugins/starIdentifier/README.md new file mode 100644 index 00000000..212ec616 --- /dev/null +++ b/plugins/starIdentifier/README.md @@ -0,0 +1,83 @@ +# Star Identifier + +https://github.com/axxeman23/star_identifier + +## Intro + +Star Identifier uses [facial recognition](https://github.com/ageitgey/face_recognition) to automatically identify who is in images or scene screenshots from the performers already in your [Stash](https://github.com/stashapp/stash) library. + +## Requirements + +### Python3 + +#### Installing Python + +1. Download Python [here](https://www.python.org/downloads/) +2. Install & add to your PATH +3. Configure Stash to use Python (if necessary. This can be set in the `System` tab of your `Settings` page) + +### Libs & Dependencies + +#### CMake + +For Windows: + +- You'll also need to install Microsoft Visual Studio 2015 (or newer) with C/C++ Compiler installed. [Link here](https://visualstudio.microsoft.com/downloads/) +- Install and add CMake to your PATH. [Link](https://cmake.org/download/) +- For more details, see [this issue](https://github.com/ageitgey/face_recognition/issues/175) + +For Mac & Linux: +`brew install cmake` + +#### Python Libraries + +1. numpy +2. dlib +3. face_recognition + +`pip install numpy dlib face_recognition` + +For more details, see the [Face Recognition installation instructions](https://github.com/ageitgey/face_recognition#installation). + +### Plugin Files + +You'll need the following in your `plugins` folder from this repo. Copy `star_identifier.yml` to the `plugins` folder, and the rest of the files to a folder called `py_plugins` inside the `plugins` folder. If you already have `log.py` in `py_plugins`, skip copying that one (it should be the same) + +``` +star_identifier.yml +py_plugins: + | log.py + | star_identifier_config.py + | star_identifier_interface.py + | star_identifier.py +``` + +## Config + +### Paths + +Running the plugin will create a folder. By default, this will be created in your `plugins` folder, but you can change that in the config. + +Face encodings will be saved to that new folder. The encodings file will be roughly 1MB per 1,000 performers. + +### Stash Settings + +Star Identifier uses a tag to find images or scenes you would like identified. By default, that tag is `star identifier`. + +Since the recognition is based on a single performer image, that image needs to have a pretty clear front-facing view of the performer's face. If face_recognition fails to find a performer's face, Star Identifier will tag that performer with `star identifier performer error` by default. + +## Running + +### Export Performers + +This is the first step. Star Identifier loads each performer's image, encodes their facial features into a numpy array, and saves those arrays. The clearer the face of the performer, the better identification results will be. Performers whose faces are not recognized by face_recognition will be tagged for you to update as desired. + +This only needs to be run once, or after new performers are added or have updated images. + +### Identify Images + +This loads all images in the stash database tagged with `star identifier` (by default), compares the recognized faces to the exported face database, and then adds all potential matches to those images as performers. + +### Identify Scene Screenshots + +This loads all scene screenshots in the stash database tagged with `star identifier` (by default), compares the recognized faces to the exported face database, and then adds all potential matches to those scenes as performers. diff --git a/plugins/starIdentifier/log.py b/plugins/starIdentifier/log.py new file mode 100644 index 00000000..7d142f8e --- /dev/null +++ b/plugins/starIdentifier/log.py @@ -0,0 +1,52 @@ +import sys + + +# Log messages sent from a plugin instance are transmitted via stderr and are +# encoded with a prefix consisting of special character SOH, then the log +# level (one of t, d, i, w, e, or p - corresponding to trace, debug, info, +# warning, error and progress levels respectively), then special character +# STX. +# +# The LogTrace, LogDebug, LogInfo, LogWarning, and LogError methods, and their equivalent +# formatted methods are intended for use by plugin instances to transmit log +# messages. The LogProgress method is also intended for sending progress data. +# + +def __prefix(level_char): + start_level_char = b'\x01' + end_level_char = b'\x02' + + ret = start_level_char + level_char + end_level_char + return ret.decode() + + +def __log(level_char, s): + if level_char == "": + return + + print(__prefix(level_char) + s + "\n", file=sys.stderr, flush=True) + + +def LogTrace(s): + __log(b't', s) + + +def LogDebug(s): + __log(b'd', s) + + +def LogInfo(s): + __log(b'i', s) + + +def LogWarning(s): + __log(b'w', s) + + +def LogError(s): + __log(b'e', s) + + +def LogProgress(p): + progress = min(max(0, p), 1) + __log(b'p', str(progress)) diff --git a/plugins/starIdentifier/star_identifier.py b/plugins/starIdentifier/star_identifier.py new file mode 100644 index 00000000..cab9d23b --- /dev/null +++ b/plugins/starIdentifier/star_identifier.py @@ -0,0 +1,288 @@ +# https://github.com/axxeman23/star_identifier + +# built-in +import json +import sys +import os +import pathlib + +# external +import urllib.request +import face_recognition +import numpy as np + +# local +import log +import star_identifier_config as config +from star_identifier_interface import IdentifierStashInterface + +# +# constants +# + +current_path = str(config.root_path or pathlib.Path(__file__).parent.absolute()) +encoding_export_folder = str(pathlib.Path(current_path + f'/../{config.encodings_folder}/').absolute()) + +encodings_path = os.path.join(encoding_export_folder, config.encodings_filename) +errors_path = os.path.join(encoding_export_folder, config.encodings_error_filename) + +# +# main +# + +def main(): + json_input = read_json_input() + + output = {} + + try: + run(json_input) + except Exception as error: + log.LogError(str(error)) + return + + out = json.dumps(output) + print(out + "\n") + +def run(json_input): + log.LogInfo('==> running') + mode_arg = json_input['args']['mode'] + client = IdentifierStashInterface(json_input["server_connection"]) + + match mode_arg: + case "export_known": + export_known(client) + case "identify_imgs": + identify_imgs(client, *load_encodings()) + case "identify_scene_screenshots": + identify_scene_screenshots(client, *load_encodings()) + case "debug": + debug_func(client) + case _: + export_known(client) + +# +# utils +# + +def read_json_input(): + json_input = sys.stdin.read() + return json.loads(json_input) + +def json_print(input, path): + os.makedirs(encoding_export_folder, exist_ok=True) + f = open(path, 'w') + json.dump(input, f) + f.close() + +def get_scrape_tag(client, tag_name): + tag_id = client.findTagIdWithName(tag_name) + if tag_id is not None: + return tag_id + else: + client.createTagWithName(tag_name) + tag_id = client.findTagIdWithName(tag_name) + return tag_id + +def get_scrape_tag_filter(client): + return { + "tags": { + "value": [get_scrape_tag(client, config.tag_name_identify)], + "modifier": "INCLUDES_ALL" + } + } + +def load_encodings(): + log.LogInfo("Loading exported face encodings...") + + e = Exception(f"Encoding database not found at {encodings_path}. Run Export Performers and try again.") + try: + ids = [] + known_face_encodings = [] + npz = np.load(encodings_path) + + if not len(npz): + raise e + + for id in npz: + ids.append(id) + known_face_encodings.append(npz[id]) + + return [ids, known_face_encodings] + except FileNotFoundError: + raise e + +# +# debug +# + +def debug_print(input): + f = open(os.path.join(current_path, 'debug.txt'), 'a') + f.write(str(input)) + f.close() + +def debug_func(client): + f = open(os.path.join(current_path, 'debug.txt'), 'w') + f.close() + +# +# export function +# + +def export_known(client): + # This would be faster multi-threaded, but that seems to break face_recognition + + log.LogInfo('Getting all performer images...') + + performers = client.getPerformerImages() + total = len(performers) + + log.LogInfo(f"Found {total} performers") + + if total == 0: + log.LogError('No performers found.') + return + + os.makedirs(encoding_export_folder, exist_ok=True) + + count = 0 + outputDict = {} + errorList = [] + + log.LogInfo('Starting performer image export (this might take a while)') + + for performer in performers: + log.LogProgress(count / total) + + image = face_recognition.load_image_file(urllib.request.urlopen(performer['image_path'])) + try: + encoding = face_recognition.face_encodings(image)[0] + outputDict[performer['id']] = encoding + except IndexError: + log.LogInfo(f"No face found for {performer['name']}") + errorList.append(performer) + + count += 1 + + np.savez(encodings_path, **outputDict) + json_print(errorList, errors_path) + + log.LogInfo(f'Finished exporting all {total} performer images. Failed recognitions saved to {str(errors_path)}.') + + error_tag = get_scrape_tag(client, config.tag_name_encoding_error) + error_ids = list(map(lambda entry: entry['id'], errorList)) + + log.LogInfo(f"Tagging failed performer exports with {config.tag_name_encoding_error}...") + client.bulkPerformerAddTags(error_ids, [error_tag]) + +# +# Facial recognition functions +# + + +def get_recognized_ids_from_path(image_path, known_face_encodings, ids): + return get_recognized_ids(face_recognition.load_image_file(image_path), known_face_encodings, ids) + +def get_recognized_ids_from_url(image_url, known_face_encodings, ids): + image = urllib.request.urlopen(image_url) + return get_recognized_ids(face_recognition.load_image_file(image), known_face_encodings, ids) + +def get_recognized_ids(image_file, known_face_encodings, ids): + unknown_face_encodings = face_recognition.face_encodings(image_file) + + recognized_ids = np.empty((0,0), int) + + for unknown_face in unknown_face_encodings: + results = face_recognition.compare_faces(known_face_encodings, unknown_face) + + recognized_ids = np.append(recognized_ids, [ids[i] for i in range(len(results)) if results[i] == True]) + + return np.unique(recognized_ids).tolist() + +# Imgs + +def identify_imgs(client, ids, known_face_encodings): + log.LogInfo(f"Getting images tagged with '{config.tag_name_identify}'...") + + images = client.findImages(get_scrape_tag_filter(client)) + count = 0 + total = len(images) + + if not total: + log.LogError(f"No tagged images found. Tag images with '{config.tag_name_identify}', then try again.") + return + + log.LogInfo(f"Found {total} tagged images. Starting identification...") + + for image in images: + log.LogProgress(count / total) + + try: + matching_performer_ids = get_recognized_ids_from_path(image['path'], known_face_encodings, ids) + except IndexError: + log.LogError(f"No face found in tagged image id {image['id']}. Moving on to next image...") + continue + except: + log.LogError(f"Unknown error comparing tagged image id {image['id']}. Moving on to next image...") + continue + + if not len(matching_performer_ids): + log.LogInfo(f"No matching performer found for image id {image['id']}. Moving on to next image...") + continue + + client.updateImage({ + 'id': image['id'], + 'performer_ids': matching_performer_ids + }) + + count += 1 + + log.LogInfo('Image identification complete!') + +# Scenes + +def identify_scene_screenshots(client, ids, known_face_encodings): + log.LogInfo(f"Getting scenes tagged with '{config.tag_name_identify}'...") + + scenes = client.getScenePaths(get_scrape_tag_filter(client)) + count = 0 + total = len(scenes) + + if not total: + log.LogError(f"No tagged scenes found. Tag scenes with '{config.tag_name_identify}', then try again.") + return + + log.LogInfo(f"Found {total} tagged scenes. Starting identification...") + + for scene in scenes: + log.LogProgress(count / total) + + matching_performer_ids = np.empty((0,0), int) + screenshot = scene['paths']['screenshot'] + + try: + matches = get_recognized_ids_from_url(screenshot, known_face_encodings, ids) + log.LogInfo(f"{len(matches)} performers identified in scene id {scene['id']}'s screenshot") + matching_performer_ids = np.append(matching_performer_ids, matches) + except IndexError: + log.LogError(f"No face found in screenshot for scene id {scene['id']}. Moving on to next image...") + continue + except Exception as error: + log.LogError(f"Error type = {type(error).__name__} comparing screenshot for scene id {scene['id']}. Moving on to next image...") + continue + + matching_performer_ids = np.unique(matching_performer_ids).tolist() + + log.LogDebug(f"Found performers in scene id {scene['id']} : {matching_performer_ids}") + + client.addPerformersToScene(scene['id'], matching_performer_ids) + + count += 1 + + log.LogInfo("Screenshot identification complete!") + +main() + + +# https://github.com/ageitgey/face_recognition +# https://github.com/ageitgey/face_recognition/issues/175 \ No newline at end of file diff --git a/plugins/starIdentifier/star_identifier_config.py b/plugins/starIdentifier/star_identifier_config.py new file mode 100644 index 00000000..32eb2979 --- /dev/null +++ b/plugins/starIdentifier/star_identifier_config.py @@ -0,0 +1,19 @@ +# +# Paths +# + +root_path = '' # defaults to plugins folder +encodings_folder = 'star-identifier-encodings' +encodings_filename = 'star-identifier-encodings.npz' +encodings_error_filename = 'errors.json' + +# +# Stash Settings +# + +# The identifier will run on images / scenes tagged with this +tag_name_identify = 'star identifier' + +# If the identifier can't find a face for a performer, +# it will add this tag to that performer +tag_name_encoding_error = 'star identifier performer error' \ No newline at end of file diff --git a/plugins/starIdentifier/star_identifier_interface.py b/plugins/starIdentifier/star_identifier_interface.py new file mode 100644 index 00000000..472151bc --- /dev/null +++ b/plugins/starIdentifier/star_identifier_interface.py @@ -0,0 +1,296 @@ +# most of this copied from https://github.com/niemands/StashPlugins + +import requests +import sys +import log + +class IdentifierStashInterface: + port = "" + url = "" + headers = { + "Accept-Encoding": "gzip, deflate, br", + "Content-Type": "application/json", + "Accept": "application/json", + "Connection": "keep-alive", + "DNT": "1" + } + cookies = {} + + # + # Init + # + + def __init__(self, conn): + self.port = conn['Port'] + scheme = conn['Scheme'] + + # Session cookie for authentication + self.cookies = { + 'session': conn.get('SessionCookie').get('Value') + } + + try: + # If stash does not accept connections from all interfaces use the host specified in the config + host = conn.get('Host') if '0.0.0.0' not in conn.get('Host') or '' else 'localhost' + except TypeError: + # Pre stable 0.8 + host = 'localhost' + + # Stash GraphQL endpoint + self.url = scheme + "://" + host + ":" + str(self.port) + "/graphql" + log.LogDebug(f"Using stash GraphQl endpoint at {self.url}") + + def __callGraphQL(self, query, variables=None): + json = {'query': query} + if variables is not None: + json['variables'] = variables + + response = requests.post(self.url, json=json, headers=self.headers, cookies=self.cookies) + + if response.status_code == 200: + result = response.json() + if result.get("error", None): + for error in result["error"]["errors"]: + raise Exception("GraphQL error: {}".format(error)) + if result.get("data", None): + return result.get("data") + elif response.status_code == 401: + sys.exit("HTTP Error 401, Unauthorised. Cookie authentication most likely failed") + else: + raise ConnectionError( + "GraphQL query failed:{} - {}. Query: {}. Variables: {}".format( + response.status_code, response.content, query, variables) + ) + + # + # Queries + # + + # Performers + + def getPerformerImages(self, performer_filter=None): + return self.__getPerformerImages(performer_filter) + + def __getPerformerImages(self, performer_filter=None, page=1): + per_page = 1000 + query = """ + query($per_page: Int, $page: Int, $performer_filter: PerformerFilterType) { + findPerformers( + performer_filter: $performer_filter + filter: { per_page: $per_page, page: $page } + ) { + count + performers { + id + name + image_path + } + } + } + """ + + variables = { + 'per_page': per_page, + 'page': page + } + if performer_filter: + variables['performer_filter'] = performer_filter + + result = self.__callGraphQL(query, variables) + + performers = result.get('findPerformers').get('performers') + + if len(performers) == per_page: + next_page = self.__getPerformerImages(performer_filter, page + 1) + for performer in next_page: + performers.append(performer) + + return performers + + # Tags + + def findTagIdWithName(self, name): + query = """ + query($name: String!) { + findTags( + tag_filter: { + name: {value: $name, modifier: EQUALS} + } + ){ + tags{ + id + name + } + } + } + """ + + variables = { + 'name': name, + } + + result = self.__callGraphQL(query, variables) + if result.get('findTags') is not None and result.get('findTags').get('tags') != []: + return result.get('findTags').get('tags')[0].get('id') + return None + + # Images + + def findImages(self, image_filter=None): + return self.__findImages(image_filter) + + def __findImages(self, image_filter=None, page=1): + per_page = 1000 + query = """ + query($per_page: Int, $page: Int, $image_filter: ImageFilterType) { + findImages( + image_filter: $image_filter, + filter: { per_page: $per_page, page: $page } + ) { + count + images { + id + path + performers { + id + } + } + } + } + """ + + variables = { + 'per_page': per_page, + 'page': page + } + if image_filter: + variables['image_filter'] = image_filter + + result = self.__callGraphQL(query, variables) + + images = result.get('findImages').get('images') + + if len(images) == per_page: + next_page = self.__findImages(image_filter, page + 1) + for image in next_page: + images.append(image) + + return images + + # Scenes + + def getScenePaths(self, scene_filter=None): + return self.__getScenePaths(scene_filter) + + def __getScenePaths(self, scene_filter=None, page=1): + per_page = 1000 + query = """ + query($per_page: Int, $page: Int, $scene_filter: SceneFilterType) { + findScenes( + scene_filter: $scene_filter, + filter: { per_page: $per_page, page: $page } + ) { + count + scenes { + id + paths { + screenshot + stream + } + } + } + } + """ + + variables = { + 'per_page': per_page, + 'page': page + } + if scene_filter: + variables['scene_filter'] = scene_filter + + result = self.__callGraphQL(query, variables) + scenes = result.get('findScenes').get('scenes') + + if len(scenes) == 1000: + next_page = self.__getScenePaths(scene_filter, page + 1) + for scene in next_page: + scenes.append(scene) + + return scenes + + + # + # Mutations + # + + def createTagWithName(self, name): + query = """ + mutation tagCreate($input:TagCreateInput!) { + tagCreate(input: $input){ + id + } + } + """ + variables = {'input': { + 'name': name + }} + + result = self.__callGraphQL(query, variables) + if result.get('tagCreate'): + log.LogDebug(f"Created tag: {name}") + return result.get('tagCreate').get("id") + else: + log.LogError(f"Could not create tag: {name}") + return None + + def updateImage(self, image_data): + query = """ + mutation($input: ImageUpdateInput!) { + imageUpdate(input: $input) { + id + } + } + """ + + variables = {'input': image_data} + + self.__callGraphQL(query, variables) + + def bulkPerformerAddTags(self, performer_ids, tag_ids): + query = """ + mutation($ids: [ID!], $tag_ids: BulkUpdateIds) { + bulkPerformerUpdate(input: { ids: $ids, tag_ids: $tag_ids }) { + id + } + } + """ + + variables = { + "ids": performer_ids, + "tag_ids": { + "ids": tag_ids, + "mode": 'ADD' + } + } + + self.__callGraphQL(query, variables) + + def addPerformersToScene(self, scene_id, performer_ids): + query = """ + mutation BulkSceneUpdate($ids: [ID!], $performer_ids: BulkUpdateIds) { + bulkSceneUpdate(input: { ids: $ids, performer_ids: $performer_ids}) { + id + } + } + """ + + variables = { + "ids": [scene_id], + "performer_ids": { + "ids": performer_ids, + "mode": "ADD" + } + } + + self.__callGraphQL(query, variables) \ No newline at end of file From b713afd8b3371cdc0c43c7e78743a7a7963e2b8d Mon Sep 17 00:00:00 2001 From: Axxeman23 Date: Fri, 19 Aug 2022 17:03:57 -0400 Subject: [PATCH 2/5] Adding Star Identifier to Readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 262c74ff..23df3230 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Scenes|SceneMarker.Create
SceneMarker.Update|[markerTagToScene](plugins/mar Scanning|Scene.Create
Gallery.Create
Image.Create|[defaultDataForPath](plugins/defaultDataForPath)|Adds configured Tags, Performers and/or Studio to all newly scanned Scenes, Images and Galleries..|v0.8 Scanning|Scene.Create
Gallery.Create|[filenameParser](plugins/filenameParser)|Tries to parse filenames, primarily in {studio}.{year}.{month}.{day}.{performer1firstname}.{performer1lastname}.{performer2}.{title} format, into the respective fields|v0.10 Reporting||[TagGraph](plugins/taggrap)|Creates a visual of the Tag relations.|v0.7 +Star Identifier|Task|[starIdentifier](plugins/starIdentifier/)|Automatically identify who is in images or scene screenshots from performers in your library.|v1.0 ## Themes From d5426c48b4ab246d9fbe8b50114af1ba2e9ee61c Mon Sep 17 00:00:00 2001 From: Axxeman23 Date: Fri, 19 Aug 2022 17:04:14 -0400 Subject: [PATCH 3/5] fixing tag graph typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 23df3230..7ad28c9e 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Maintenance|Set Scene Cover|[setSceneCoverFromFile](plugins/setSceneCoverFromFil Scenes|SceneMarker.Create
SceneMarker.Update|[markerTagToScene](plugins/markerTagToScene)|Adds primary tag of Scene Marker to the Scene on marker create/update.|v0.8 ([46bbede](https://github.com/stashapp/stash/commit/46bbede9a07144797d6f26cf414205b390ca88f9)) Scanning|Scene.Create
Gallery.Create
Image.Create|[defaultDataForPath](plugins/defaultDataForPath)|Adds configured Tags, Performers and/or Studio to all newly scanned Scenes, Images and Galleries..|v0.8 Scanning|Scene.Create
Gallery.Create|[filenameParser](plugins/filenameParser)|Tries to parse filenames, primarily in {studio}.{year}.{month}.{day}.{performer1firstname}.{performer1lastname}.{performer2}.{title} format, into the respective fields|v0.10 -Reporting||[TagGraph](plugins/taggrap)|Creates a visual of the Tag relations.|v0.7 +Reporting||[TagGraph](plugins/taggraph)|Creates a visual of the Tag relations.|v0.7 Star Identifier|Task|[starIdentifier](plugins/starIdentifier/)|Automatically identify who is in images or scene screenshots from performers in your library.|v1.0 ## Themes From af08bcfd201a6cbecb2bf03366973a6643a645c0 Mon Sep 17 00:00:00 2001 From: Axxeman23 Date: Tue, 23 Aug 2022 16:58:55 -0400 Subject: [PATCH 4/5] adding yml --- plugins/starIdentifier/star_identifier.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 plugins/starIdentifier/star_identifier.yml diff --git a/plugins/starIdentifier/star_identifier.yml b/plugins/starIdentifier/star_identifier.yml new file mode 100644 index 00000000..db0960e2 --- /dev/null +++ b/plugins/starIdentifier/star_identifier.yml @@ -0,0 +1,21 @@ +name: Star Identifier +description: Use facial recognition to automatically identify who is in images or scene screenshots from the performers already in your Stash library. +version: 1.0 +url: https://github.com/axxeman23/star_identifier +exec: + - python + - "{pluginDir}/py_plugins/star_identifier.py" +interface: raw +tasks: + - name: Export Performers + description: Run this first! Exports current performer images and adds them to an encoding file for recognition. + defaultArgs: + mode: export_known + - name: Identify Images + description: Compares images tagged with 'star identifier' (by default) to exported performers, and adds all possible matches to the images. + defaultArgs: + mode: identify_imgs + - name: Identify Scene Screenshots + description: Compares scene screenshots tagged with 'star identifier' (by default) to exported performers, and adds all possible matches to the scenes. + defaultArgs: + mode: identify_scene_screenshots From a1ff79552b4499747d95cce46f08054ec7375bf9 Mon Sep 17 00:00:00 2001 From: Axxeman23 Date: Wed, 31 Aug 2022 17:33:04 -0400 Subject: [PATCH 5/5] adding multi-threading, tolerance config --- plugins/starIdentifier/README.md | 11 +- plugins/starIdentifier/star_identifier.py | 158 ++++++++++-------- .../starIdentifier/star_identifier_config.py | 10 +- .../star_identifier_interface.py | 6 + 4 files changed, 117 insertions(+), 68 deletions(-) diff --git a/plugins/starIdentifier/README.md b/plugins/starIdentifier/README.md index 212ec616..6be0b379 100644 --- a/plugins/starIdentifier/README.md +++ b/plugins/starIdentifier/README.md @@ -9,6 +9,7 @@ Star Identifier uses [facial recognition](https://github.com/ageitgey/face_recog ## Requirements ### Python3 +__version: 3.10.x +__ #### Installing Python @@ -66,6 +67,10 @@ Star Identifier uses a tag to find images or scenes you would like identified. B Since the recognition is based on a single performer image, that image needs to have a pretty clear front-facing view of the performer's face. If face_recognition fails to find a performer's face, Star Identifier will tag that performer with `star identifier performer error` by default. +### Star Identifier Settings + +You can adjust the tolerance for identification here. `0.6` is default and typical, but I've found `0.5` to work well. Lower is more strict. + ## Running ### Export Performers @@ -80,4 +85,8 @@ This loads all images in the stash database tagged with `star identifier` (by de ### Identify Scene Screenshots -This loads all scene screenshots in the stash database tagged with `star identifier` (by default), compares the recognized faces to the exported face database, and then adds all potential matches to those scenes as performers. +This loads the screenshot for every scene in the stash database tagged with `star identifier` (by default), compares the recognized faces to the exported face database, and then adds all potential matches to those scenes as performers. + +## Upcoming roadmap + +See [issues](https://github.com/axxeman23/star_identifier/issues) diff --git a/plugins/starIdentifier/star_identifier.py b/plugins/starIdentifier/star_identifier.py index cab9d23b..9d97d075 100644 --- a/plugins/starIdentifier/star_identifier.py +++ b/plugins/starIdentifier/star_identifier.py @@ -5,6 +5,7 @@ import sys import os import pathlib +from concurrent.futures import ProcessPoolExecutor # external import urllib.request @@ -130,8 +131,6 @@ def debug_func(client): # def export_known(client): - # This would be faster multi-threaded, but that seems to break face_recognition - log.LogInfo('Getting all performer images...') performers = client.getPerformerImages() @@ -151,19 +150,24 @@ def export_known(client): log.LogInfo('Starting performer image export (this might take a while)') - for performer in performers: - log.LogProgress(count / total) + futures_list = [] + + with ProcessPoolExecutor(max_workers=10) as executor: + for performer in performers: + futures_list.append(executor.submit(encode_performer_from_url, performer)) - image = face_recognition.load_image_file(urllib.request.urlopen(performer['image_path'])) - try: - encoding = face_recognition.face_encodings(image)[0] - outputDict[performer['id']] = encoding - except IndexError: - log.LogInfo(f"No face found for {performer['name']}") - errorList.append(performer) + for future in futures_list: + log.LogProgress(count / total) - count += 1 + try: + result = future.result() + outputDict[result['id']] = result['encodings'] + except IndexError: + log.LogInfo(f"No face found for {result['name']}") + errorList.append({ 'id': result['id'], 'name': result['name'] }) + count += 1 + np.savez(encodings_path, **outputDict) json_print(errorList, errors_path) @@ -179,13 +183,26 @@ def export_known(client): # Facial recognition functions # +# Encoding + +def encode_performer_from_url(performer): + image = face_recognition.load_image_file(urllib.request.urlopen(performer['image_path'])) + performer['encodings'] = face_recognition.face_encodings(image)[0] + return performer + + +# Matching + +def get_recognized_ids_from_image(image, known_face_encodings, ids): + image['matched_ids'] = get_recognized_ids(face_recognition.load_image_file(image['path']), known_face_encodings, ids) + + return image -def get_recognized_ids_from_path(image_path, known_face_encodings, ids): - return get_recognized_ids(face_recognition.load_image_file(image_path), known_face_encodings, ids) +def get_recognized_ids_from_scene_screenshot(scene, known_face_encodings, ids): + image = urllib.request.urlopen(scene['paths']['screenshot']) + scene['matched_ids'] = get_recognized_ids(face_recognition.load_image_file(image), known_face_encodings, ids) -def get_recognized_ids_from_url(image_url, known_face_encodings, ids): - image = urllib.request.urlopen(image_url) - return get_recognized_ids(face_recognition.load_image_file(image), known_face_encodings, ids) + return scene def get_recognized_ids(image_file, known_face_encodings, ids): unknown_face_encodings = face_recognition.face_encodings(image_file) @@ -193,19 +210,48 @@ def get_recognized_ids(image_file, known_face_encodings, ids): recognized_ids = np.empty((0,0), int) for unknown_face in unknown_face_encodings: - results = face_recognition.compare_faces(known_face_encodings, unknown_face) + results = face_recognition.compare_faces(known_face_encodings, unknown_face, tolerance=config.tolerance) recognized_ids = np.append(recognized_ids, [ids[i] for i in range(len(results)) if results[i] == True]) return np.unique(recognized_ids).tolist() +# Execution + +def execute_identification_list(known_face_encodings, ids, args): + count = 0 + futures_list = [] + + with ProcessPoolExecutor(max_workers=10) as executor: + for item in args['items']: + futures_list.append(executor.submit(args['executor_func'], *[item, known_face_encodings, ids])) + + for future in futures_list: + log.LogProgress(count / args['total']) + + debug_print(future) + + try: + result = future.result() + + if not len(result['matched_ids']): + log.LogInfo(f"No matching performer found for {args['name']} id {result['id']}. Moving on to next {args['name']}...") + else: + log.LogDebug(f"updating {args['name']} {result['id']} with ") + args['submit_func'](result['id'], result['matched_ids']) + except IndexError: + log.LogError(f"No face found in tagged {args['name']} id {result['id']}. Moving on to next {args['name']}...") + except: + log.LogError(f"Unknown error comparing tagged {args['name']} id {result['id']}. Moving on to next {args['name']}...") + + count += 1 + # Imgs def identify_imgs(client, ids, known_face_encodings): log.LogInfo(f"Getting images tagged with '{config.tag_name_identify}'...") images = client.findImages(get_scrape_tag_filter(client)) - count = 0 total = len(images) if not total: @@ -214,28 +260,19 @@ def identify_imgs(client, ids, known_face_encodings): log.LogInfo(f"Found {total} tagged images. Starting identification...") - for image in images: - log.LogProgress(count / total) - - try: - matching_performer_ids = get_recognized_ids_from_path(image['path'], known_face_encodings, ids) - except IndexError: - log.LogError(f"No face found in tagged image id {image['id']}. Moving on to next image...") - continue - except: - log.LogError(f"Unknown error comparing tagged image id {image['id']}. Moving on to next image...") - continue - - if not len(matching_performer_ids): - log.LogInfo(f"No matching performer found for image id {image['id']}. Moving on to next image...") - continue - - client.updateImage({ - 'id': image['id'], - 'performer_ids': matching_performer_ids - }) + execution_args = { + 'name': 'image', + 'items': images, + 'total': total, + 'executor_func': get_recognized_ids_from_image, + 'submit_func': client.addPerformersToImage + } - count += 1 + execute_identification_list( + known_face_encodings, + ids, + execution_args + ) log.LogInfo('Image identification complete!') @@ -245,7 +282,6 @@ def identify_scene_screenshots(client, ids, known_face_encodings): log.LogInfo(f"Getting scenes tagged with '{config.tag_name_identify}'...") scenes = client.getScenePaths(get_scrape_tag_filter(client)) - count = 0 total = len(scenes) if not total: @@ -254,34 +290,24 @@ def identify_scene_screenshots(client, ids, known_face_encodings): log.LogInfo(f"Found {total} tagged scenes. Starting identification...") - for scene in scenes: - log.LogProgress(count / total) - - matching_performer_ids = np.empty((0,0), int) - screenshot = scene['paths']['screenshot'] - - try: - matches = get_recognized_ids_from_url(screenshot, known_face_encodings, ids) - log.LogInfo(f"{len(matches)} performers identified in scene id {scene['id']}'s screenshot") - matching_performer_ids = np.append(matching_performer_ids, matches) - except IndexError: - log.LogError(f"No face found in screenshot for scene id {scene['id']}. Moving on to next image...") - continue - except Exception as error: - log.LogError(f"Error type = {type(error).__name__} comparing screenshot for scene id {scene['id']}. Moving on to next image...") - continue - - matching_performer_ids = np.unique(matching_performer_ids).tolist() - - log.LogDebug(f"Found performers in scene id {scene['id']} : {matching_performer_ids}") + execution_args = { + 'name': 'scene', + 'items': scenes, + 'total': total, + 'executor_func': get_recognized_ids_from_scene_screenshot, + 'submit_func': client.addPerformersToScene + } - client.addPerformersToScene(scene['id'], matching_performer_ids) + execute_identification_list( + known_face_encodings, + ids, + execution_args + ) - count += 1 - - log.LogInfo("Screenshot identification complete!") + log.LogInfo("Scene screenshot identification complete!") -main() +if __name__ == "__main__": + main() # https://github.com/ageitgey/face_recognition diff --git a/plugins/starIdentifier/star_identifier_config.py b/plugins/starIdentifier/star_identifier_config.py index 32eb2979..24a83bdc 100644 --- a/plugins/starIdentifier/star_identifier_config.py +++ b/plugins/starIdentifier/star_identifier_config.py @@ -16,4 +16,12 @@ # If the identifier can't find a face for a performer, # it will add this tag to that performer -tag_name_encoding_error = 'star identifier performer error' \ No newline at end of file +tag_name_encoding_error = 'star identifier performer error' + +# +# Star Identifier Settings +# + +# Tolerance: How much distance between faces to consider it a match. +# Lower is more strict. 0.6 is typical best performance. +tolerance = 0.6 \ No newline at end of file diff --git a/plugins/starIdentifier/star_identifier_interface.py b/plugins/starIdentifier/star_identifier_interface.py index 472151bc..9708c991 100644 --- a/plugins/starIdentifier/star_identifier_interface.py +++ b/plugins/starIdentifier/star_identifier_interface.py @@ -257,6 +257,12 @@ def updateImage(self, image_data): self.__callGraphQL(query, variables) + def addPerformersToImage(self, image_id, performer_ids): + self.updateImage({ + 'id': image_id, + 'performer_ids': performer_ids + }) + def bulkPerformerAddTags(self, performer_ids, tag_ids): query = """ mutation($ids: [ID!], $tag_ids: BulkUpdateIds) {