├── .gitignore ├── LICENSE ├── README.md ├── _media ├── example.gif ├── face_scan_icon.png └── logo.png ├── logo.png ├── requirements.txt └── userscript └── visage.user.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # visage stuff we want to ignore 94 | matcher/performers.json 95 | matcher/face.db 96 | matcher/face.json 97 | hasher/weights/*.h5 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 cc1234475 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Deprecated 2 | 3 | This project has been deprecated favor of a plugin. 4 | 5 | You can find it here: 6 | 7 | https://github.com/stashapp/CommunityScripts/tree/main/plugins/visage 8 | 9 | # Visage 10 | 11 | ![](_media/logo.png) 12 | 13 | Visage is a userscript to do facial recognition on video's and images in [Stash](https://github.com/stashapp/stash). 14 | 15 | Once installed, A new icon will show on a scene's page next to the organized button. 16 | 17 | ![](_media/face_scan_icon.png) 18 | 19 | # How do I use it? 20 | 21 | ![](_media/example.gif) 22 | 23 | # Create your own database. 24 | 25 | Please check out https://github.com/cc1234475/visage-ml 26 | -------------------------------------------------------------------------------- /_media/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cc1234475/visage/2749b4c0da26ee7c518081778f986ad53dd15a05/_media/example.gif -------------------------------------------------------------------------------- /_media/face_scan_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cc1234475/visage/2749b4c0da26ee7c518081778f986ad53dd15a05/_media/face_scan_icon.png -------------------------------------------------------------------------------- /_media/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cc1234475/visage/2749b4c0da26ee7c518081778f986ad53dd15a05/_media/logo.png -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cc1234475/visage/2749b4c0da26ee7c518081778f986ad53dd15a05/logo.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi==0.85.0 2 | python-multipart==0.0.5 3 | pydantic==1.10.2 4 | annoy==1.17.1 5 | deepface==0.0.75 6 | uvicorn[standard]==0.18.3 -------------------------------------------------------------------------------- /userscript/visage.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name visage 3 | // @namespace https://github.com/cc1234475 4 | // @version 0.5.3 5 | // @description Match faces to performers 6 | // @author cc12344567 7 | // @match http://localhost:9999/* 8 | // @connect localhost 9 | // @connect hf.space 10 | // @grant GM_xmlhttpRequest 11 | // @grant unsafeWindow 12 | // @require https://code.jquery.com/jquery-2.0.3.min.js 13 | // @require https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js 14 | // @require https://raw.githubusercontent.com/7dJx1qP/stash-userscripts/master/src\StashUserscriptLibrary.js 15 | // ==/UserScript== 16 | 17 | var VISAGE_API_URL = "https://cc1234-stashface.hf.space/api/predict"; 18 | // var VISAGE_API_URL = "http://localhost:7860/api/predict"; 19 | var THRESHOLD = 20.0; // remove matches with a distance higher than this 20 | var MAX_RESULTS = 3; // number of results to show, don't change this for now 21 | 22 | (function () { 23 | "use strict"; 24 | 25 | const { 26 | stash, 27 | Stash, 28 | waitForElementId, 29 | waitForElementClass, 30 | waitForElementByXpath, 31 | getElementByXpath, 32 | insertAfter, 33 | createElementFromHTML, 34 | } = unsafeWindow.stash; 35 | 36 | function waitForElm(selector) { 37 | return new Promise((resolve) => { 38 | if (document.querySelector(selector)) { 39 | return resolve(document.querySelector(selector)); 40 | } 41 | 42 | const observer = new MutationObserver((mutations) => { 43 | if (document.querySelector(selector)) { 44 | resolve(document.querySelector(selector)); 45 | observer.disconnect(); 46 | } 47 | }); 48 | 49 | observer.observe(document.body, { 50 | childList: true, 51 | subtree: true, 52 | }); 53 | }); 54 | } 55 | 56 | var scanning = ``; 73 | 74 | var top = ``; 107 | 108 | async function add_performer(id_, name) { 109 | // find a performer with the same stash id in the user instance of stash 110 | var performers = await get_performers(id_); 111 | // if the users doesn't have a performer with the same stash id, get the data from stash box and create a new performer 112 | if (performers.length === 0) { 113 | var performer = await get_performer_data_based_on_name(id_); 114 | 115 | if (performer === undefined) { 116 | alert("Could not retrieve performer data from stash box"); 117 | return; 118 | } 119 | 120 | performer.image = performer.images[0]; 121 | var endpoint = await get_stashbox_endpoint(); 122 | 123 | // delete some fields that are not needed and will not be accepted by local stash instance 124 | delete performer.images; 125 | delete performer.remote_site_id; 126 | 127 | performer.stash_ids = [{ endpoint: endpoint, stash_id: id_ }]; 128 | 129 | id_ = await create_performer(performer); 130 | id_ = id_.data.performerCreate.id; 131 | } else { 132 | id_ = performers[0].id; 133 | } 134 | 135 | let [scenario, scenario_id] = get_scenario_and_id(); 136 | var perform_ids; 137 | 138 | if (scenario === "scenes") { 139 | perform_ids = await get_performers_for_scene(scenario_id); 140 | 141 | if (perform_ids.includes(id_)) { 142 | alert("Performer already assigned to scene"); 143 | return; 144 | } 145 | 146 | perform_ids.push(id_); 147 | 148 | await update_scene(scenario_id, perform_ids); 149 | } else if (scenario === "images") { 150 | perform_ids = await get_performers_for_image(scenario_id); 151 | 152 | if (perform_ids.includes(id_)) { 153 | alert("Performer already assigned to scene"); 154 | return; 155 | } 156 | 157 | perform_ids.push(id_); 158 | 159 | await update_image(scenario_id, perform_ids); 160 | } 161 | 162 | location.reload(); 163 | } 164 | 165 | function get_scenario_and_id() { 166 | var result = document.URL.match(/(scenes|images)\/(\d+)/); 167 | var scenario = result[1]; 168 | var scenario_id = result[2]; 169 | return [scenario, scenario_id]; 170 | } 171 | 172 | async function get_performers(performer_id) { 173 | const reqData = { 174 | query: `{ 175 | findPerformers( performer_filter: {stash_id: {value: "${performer_id}", modifier: EQUALS}}){ 176 | performers { 177 | name 178 | id 179 | } 180 | } 181 | }`, 182 | }; 183 | var results = await stash.callGQL(reqData); 184 | return results.data.findPerformers.performers; 185 | } 186 | 187 | async function get_performers_for_scene(scene_id) { 188 | const reqData = { 189 | query: `{ 190 | findScene(id: "${scene_id}") { 191 | performers { 192 | id 193 | } 194 | } 195 | }`, 196 | }; 197 | var result = await stash.callGQL(reqData); 198 | return result.data.findScene.performers.map((p) => p.id); 199 | } 200 | 201 | async function get_performers_for_image(image_id) { 202 | const reqData = { 203 | query: `{ 204 | findImage(id: "${image_id}") { 205 | performers { 206 | id 207 | } 208 | } 209 | }`, 210 | }; 211 | var result = await stash.callGQL(reqData); 212 | return result.data.findImage.performers.map((p) => p.id); 213 | } 214 | 215 | async function update_scene(scene_id, performer_ids) { 216 | const reqData = { 217 | variables: { input: { id: scene_id, performer_ids: performer_ids } }, 218 | query: `mutation sceneUpdate($input: SceneUpdateInput!){ 219 | sceneUpdate(input: $input) { 220 | id 221 | } 222 | }`, 223 | }; 224 | return stash.callGQL(reqData); 225 | } 226 | 227 | async function update_image(image_id, performer_ids) { 228 | const reqData = { 229 | variables: { input: { id: image_id, performer_ids: performer_ids } }, 230 | query: `mutation imageUpdate($input: ImageUpdateInput!){ 231 | imageUpdate(input: $input) { 232 | id 233 | } 234 | }`, 235 | }; 236 | return stash.callGQL(reqData); 237 | } 238 | 239 | async function get_stashbox_endpoint() { 240 | const reqData = { 241 | query: `{ 242 | configuration { 243 | general { 244 | stashBoxes { 245 | endpoint 246 | } 247 | } 248 | } 249 | }`, 250 | }; 251 | var result = await stash.callGQL(reqData); 252 | return result.data.configuration.general.stashBoxes[0].endpoint; 253 | } 254 | 255 | async function get_performer_data_based_on_name(stash_id) { 256 | const reqData = { 257 | variables: { 258 | source: { 259 | stash_box_index: 0, 260 | }, 261 | input: { 262 | query: stash_id, 263 | }, 264 | }, 265 | query: `query ScrapeSinglePerformer($source: ScraperSourceInput!, $input: ScrapeSinglePerformerInput!) { 266 | scrapeSinglePerformer(source: $source, input: $input) { 267 | name 268 | gender 269 | url 270 | twitter 271 | instagram 272 | birthdate 273 | ethnicity 274 | country 275 | eye_color 276 | height 277 | measurements 278 | fake_tits 279 | career_length 280 | tattoos 281 | piercings 282 | aliases 283 | images 284 | details 285 | death_date 286 | hair_color 287 | weight 288 | remote_site_id 289 | } 290 | }`, 291 | }; 292 | var result = await stash.callGQL(reqData); 293 | return result.data.scrapeSinglePerformer.filter( 294 | (p) => p.remote_site_id === stash_id 295 | )[0]; 296 | } 297 | 298 | async function create_performer(performer) { 299 | const reqData = { 300 | variables: { input: performer }, 301 | query: `mutation performerCreate($input: PerformerCreateInput!) { 302 | performerCreate(input: $input){ 303 | id 304 | } 305 | }`, 306 | }; 307 | return stash.callGQL(reqData); 308 | } 309 | 310 | function show_matches(matches) { 311 | var html = top; 312 | console.log(matches.length); 313 | for (var i = 0; i < matches.length; i++) { 314 | let per = matches[i]; 315 | html += match(i, per.name, per.image, round(per.confidence)); 316 | } 317 | html += bottom; 318 | $("body").append(html); 319 | 320 | $("#face_cancel").click(function () { 321 | close_modal(); 322 | }); 323 | 324 | $("#face_toggle").click(function () { 325 | var obj = $(".ModalComponent"); 326 | if (obj.css("opacity") == "0.1") { 327 | $(".ModalComponent").css("opacity", "1.0"); 328 | } else { 329 | $(".ModalComponent").css("opacity", "0.1"); 330 | } 331 | }); 332 | 333 | $("#face-0").click(function () { 334 | add_performer(matches[0].id, matches[0].name); 335 | close_modal(); 336 | }); 337 | 338 | $("#face-1").click(function () { 339 | add_performer(matches[1].id, matches[1].name); 340 | close_modal(); 341 | }); 342 | 343 | $("#face-2").click(function () { 344 | add_performer(matches[2].id, matches[2].name); 345 | close_modal(); 346 | }); 347 | } 348 | 349 | function recognize() { 350 | let [scenario, scenario_id] = get_scenario_and_id(); 351 | var selector; 352 | if (scenario === "scenes") { 353 | selector = "#VideoJsPlayer"; 354 | } else if (scenario === "images") { 355 | selector = ".image-image"; 356 | } 357 | 358 | html2canvas(document.querySelector(selector)).then((canvas) => { 359 | let image = canvas.toDataURL("image/jpg"); 360 | $("body").append(scanning); 361 | var data = {"data": [image, THRESHOLD, MAX_RESULTS]}; 362 | var requestDetails = { 363 | method: "POST", 364 | url: VISAGE_API_URL, 365 | data: JSON.stringify(data), 366 | headers: { 367 | "Content-Type": "application/json; charset=utf-8" 368 | }, 369 | onload: function (response) { 370 | var data = JSON.parse(response.responseText); 371 | close_modal(); 372 | if (data.data[0].length === 0) { 373 | alert("No matches found"); 374 | return; 375 | } 376 | show_matches(data.data[0]); 377 | }, 378 | onerror: function (response) { 379 | close_modal(); 380 | alert("Error: " + response.responseText); 381 | }, 382 | }; 383 | GM_xmlhttpRequest(requestDetails); 384 | }); 385 | } 386 | 387 | function close_modal() { 388 | $(".ModalComponent").remove(); 389 | } 390 | 391 | function round(value) { 392 | return +parseFloat(value).toFixed(2); 393 | } 394 | 395 | function create_button(action) { 396 | waitForElm(".ml-auto .btn-group").then(() => { 397 | const grp = document.querySelector(".ml-auto .btn-group"); 398 | const btn = document.createElement("button"); 399 | btn.setAttribute("id", "facescan"); 400 | btn.setAttribute("title", "Scan for performer"); 401 | btn.classList.add("btn", "btn-secondary", "minimal"); 402 | btn.innerHTML = 403 | ''; 404 | btn.onclick = action; 405 | grp.appendChild(btn); 406 | }); 407 | } 408 | 409 | stash.addEventListener("page:scene", function () { 410 | create_button(recognize); 411 | }); 412 | 413 | stash.addEventListener("page:image", function () { 414 | create_button(recognize); 415 | }); 416 | })(); 417 | --------------------------------------------------------------------------------