├── .gitignore ├── release.bat ├── javascript └── ui.js ├── settings.json ├── README.md └── scripts └── main.py /.gitignore: -------------------------------------------------------------------------------- 1 | /modules/ 2 | /tempimages/ 3 | booru2prompt.zip -------------------------------------------------------------------------------- /release.bat: -------------------------------------------------------------------------------- 1 | 7z a booru2prompt.zip javascript\ scripts\ settings.json -------------------------------------------------------------------------------- /javascript/ui.js: -------------------------------------------------------------------------------- 1 | function switch_to_select() { 2 | //gradioApp().querySelector('#selectbox').querySelector('textarea').value = gradioApp().querySelector('.px-3.py-1').innerHTML; 3 | gradioApp().querySelectorAll('#tab_b2p_interface')[0].querySelectorAll('button')[0].click(); 4 | return gradioApp().querySelector('.px-3.py-1').innerHTML; 5 | } -------------------------------------------------------------------------------- /settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "active": "Danbooru", 3 | "negativeprompt": "lowres, bad anatomy, bad hands, text, error, missing fingers, extra digit, fewer digits, cropped, worst quality, low quality, normal quality, jpeg artifacts, signature, watermark, username, blurry, artist name", 4 | "boorus": [ 5 | { 6 | "name": "Danbooru", 7 | "host": "https://danbooru.donmai.us", 8 | "username": "", 9 | "apikey": "" 10 | }, 11 | { 12 | "name": "AIBooru", 13 | "host": "https://aibooru.space", 14 | "username": "", 15 | "apikey": "" 16 | } 17 | ] 18 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # booru2prompt // Turn booru posts into Stable Diffusion prompts! 2 | 3 | This is an extension for [stable-diffusion-webui](https://github.com/AUTOMATIC1111/stable-diffusion-webui). 4 | 5 | ### If you like this project, I encourage you to fork it and help me work on it! If you *really* like this project, please hire me to write more python for you. Just don't ask me to do any more javascript. 6 | 7 | This SD extension allows you to turn posts from various image boorus into stable diffusion prompts. It does so by pulling a list of tags down from their API. You can copy-paste in a link to the post you want yourself, or use the built-in search feature to do it all without leaving SD. 8 | 9 | To install this extension, navigate to your `extensions` directory and run `git clone https://github.com/Malisius/booru2prompt.git`. You can either restart SD completely or look at the bottom of SD's settings for `Restart Gradio and Refresh Components`. 10 | 11 | To start, visit the `API Keys` tab to put in your API keys. Most features should work without this, but some things like sort tags might not work depending on the restrictions of the booru. 12 | The included `settings.json` has configuration for danbooru.donmai.us and aibooru.space, but you can add your own by following the same format. Just add a new entry to the `boorus` list with the `name` and `host` keys. 13 | 14 | `{"name": "Danbooru", "host": "https://danbooru.donmai.us", "username": "", "apikey": ""}` 15 | 16 | Take note: calls to aibooru.space are returning `403: Forbidden` no matter what I try. Any help with that would be appreciated. 17 | 18 | ![image](https://user-images.githubusercontent.com/6227122/202934555-5eb73c22-aa8c-4757-b122-c47e6b7e7964.png) 19 | 20 | Once that's set, visit the `Select` tab pull down a post. You can paste in a link to the post in the `Link to image page` field, then hit `Select Image` at the bottom. 21 | 22 | ![image](https://user-images.githubusercontent.com/6227122/202934902-a990e190-cb51-451c-89ba-0c61c7ac3cf4.png) 23 | 24 | - Take note of the `Current Booru` at the top. The API call will be made with the credentials for that booru, so make sure it matches the link to the post you're selecting. 25 | - Don't worry about url parameters in your link. They'll be removed automatically by the extension. 26 | - As an alternative, you can select a post with the format `id:xxxxxx`. In the above axample, this would be `id:5298308`. This is the format used by the search feature. 27 | - You can select which extra tags to include in the final tag string with the checkboxes. If you change any of these, you'll have to hit `Select Image` again to change the final string. 28 | - There are options to modify the resulting prompt by adding commas and removing underscores. I'm not yet certain how much of an effect these have on generated images. I suspect it may have a lot to do with how your model was trained. Personally, I get different results by changing these, but it's hard to say which way is better. Use your discretion. 29 | 30 | Once your image is loaded and you're happy with the tag string, use one of the buttons at the bottom to send it where you want to go. 31 | 32 | ![image](https://user-images.githubusercontent.com/6227122/202936317-c1d6741a-d6e3-43de-8d83-c6ca78ea92f2.png) 33 | 34 | --- 35 | 36 | (txt2img results from the above prompt, with no cherry picking, no negative prompt, and no other modifications to the prompt) 37 | ![grid-0041](https://user-images.githubusercontent.com/6227122/202936978-4850e02c-cf41-4a23-a0ba-cf33fc78b0e8.png) 38 | 39 | --- 40 | 41 | You can also search for images right in the extension! Just visit the `Search` tab. 42 | Enter in your search exactly as you would on an image booru: a list of tags seperated by spaces. These are sent to the API the same way a normal search is, so qualifier tags like `order:` and `rating:` should all work, assuming the image booru you're searching supports them. 43 | By default, results with the `animated` tag will be automatically excluded. There's really no reason to turn that off right now, since I haven't yet figured out how to put anything other than a static image in a Gradio gallery. 44 | 45 | ![image](https://user-images.githubusercontent.com/6227122/202935945-73aee137-e788-4588-947a-96c84f76cd6e.png) 46 | 47 | Having done that, just hit `Send image to tag selection` to continue. 48 | 49 | --- 50 | This was a lot of fun to make, so if you have any feedback, please let me know! I plan on updating this frequently with some more ideas I have. What I really want is a browser extension to add a button directly to an image booru website to send a post right over to SD. Perhaps one day. 51 | -------------------------------------------------------------------------------- /scripts/main.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from urllib.request import urlopen, urlretrieve, Request 4 | from urllib import parse 5 | import inspect 6 | 7 | import gradio as gr 8 | 9 | import modules.ui 10 | from modules import script_callbacks, scripts 11 | 12 | #The auto1111 guide on developing extensions says to use scripts.basedir() to get the current directory 13 | #However, for some reason, this kept returning the stable diffusion root instead. 14 | #So this is my janky workaround to get this extensions directory. 15 | edirectory = inspect.getfile(lambda: None) 16 | edirectory = edirectory[:edirectory.find("scripts")] 17 | 18 | def loadsettings(): 19 | """Return a dictionary of settings read from settings.json in the extension directory 20 | 21 | Returns: 22 | dict: settings and api keys 23 | """ 24 | print("Loading booru2prompt settings") 25 | file = open(edirectory + "settings.json") 26 | settings = json.load(file) 27 | file.close() 28 | return settings 29 | 30 | def savesettings(active, username, apikey, negprompt): 31 | """Save the current username and api key to the active booru 32 | 33 | Args: 34 | active (str): The string identifier of the currently selected booru 35 | username (str): The username for that booru 36 | apikey (str): The user's api key 37 | negprompt (str): The negative prompt to be appended to each image selection 38 | """ 39 | settings["active"] = active 40 | settings["negativeprompt"] = negprompt 41 | 42 | #Stepping through all the boorus in the settings till we find the right one 43 | for booru in settings['boorus']: 44 | if booru['name'] == active: 45 | booru["username"] = username 46 | booru["apikey"] = apikey 47 | file = open(edirectory + "settings.json", "w") 48 | file.write(json.dumps(settings)) 49 | file.close() 50 | 51 | #We're loading the settings here since all the further functions depend on this existing already 52 | settings = loadsettings() 53 | 54 | def getauth(): 55 | """Get the username and api key for the currently selected booru 56 | 57 | Returns: 58 | tuple: (username, apikey) for whichever booru is selected in the dropdown 59 | """ 60 | for b in settings['boorus']: 61 | if b['name'] == settings['active']: 62 | return b['username'], b['apikey'] 63 | 64 | def gethost(): 65 | """Get the url for the currently selected booru. 66 | This url will get piped straight into every request, so https:// should be 67 | included in each in settings.json if you want to use ssl. 68 | Furthermore, you should include a trailing slash in these urls, since they're already 69 | added by every other function here that uses this function. 70 | 71 | Returns: 72 | str: The full url for the selected booru 73 | """ 74 | for booru in settings['boorus']: 75 | if booru['name'] == settings['active']: 76 | return booru['host'] 77 | 78 | def searchbooru(query, removeanimated, curpage, pagechange=0): 79 | """Search the currently selected booru, and return a list of images and the current page. 80 | 81 | Args: 82 | query (str): A list of tags to search for, delimited by spaces 83 | removeanimated (bool): True to append -animated to searches 84 | curpage (str or int): The current page to search 85 | pagechange (int, optional): How much to change the current page by before searching. Defaults to 0. 86 | 87 | Returns: 88 | tuple (list, str): The list in this tuple is a list of tuples, where [0] is 89 | a str filepath to a locally saved image, and [1] is a string representation 90 | of the id for that image on the searched booru. 91 | The string in this return is new current page number, which may or may not have been changed. 92 | """ 93 | host = gethost() 94 | u, a = getauth() 95 | 96 | #If the page isn't changing, then the user almost certainly is initiating a new 97 | #search, so we can set the page number back to 1. 98 | if pagechange == 0: 99 | curpage = 1 100 | else: 101 | curpage = int(curpage) + pagechange 102 | if curpage < 1: 103 | curpage = 1 104 | 105 | #We're about to use this in a url, so make it a string real quick 106 | curpage = str(curpage) 107 | 108 | url = host + f"/posts.json?" 109 | 110 | #Only append login parameters if we actually got some from the above getauth() 111 | #In the default settings.json in the repo, these are empty strings, so they'll 112 | #return false here. 113 | if u: 114 | url += f"login={u}&" 115 | if a: 116 | url += f"api_key={a}&" 117 | 118 | #Prepare the append some search tags 119 | #We can leave this here even if param:query is empty, since the api call still works apparently 120 | url += "tags=" 121 | 122 | #Add in the -animated tag if that checkbox was selected 123 | #I have no idea what happens if "animated" is searched for and that box is checked, 124 | #and I'm not testing that myself 125 | if removeanimated: 126 | url += "-animated+" 127 | 128 | #TODO: Add a settings option to change the images-per-page here 129 | url += f"{parse.quote_plus(query)}&limit=6" 130 | url += f"&page={curpage}" 131 | 132 | #I had this print here just to test my url building, but I kind of like it, so I'm leaving it 133 | print(url) 134 | 135 | #Normally it's fine to call urlopen() with just a string url, but some boorus get finicky about 136 | #setting a user-agent, so this builds a request with custom headers 137 | request = Request(url, data=None, headers = {'User-Agent': 'booru2prompt, a Stable Diffusion project (made by Borderless)'}) 138 | response = urlopen(request) 139 | data = json.loads(response.read()) 140 | 141 | localimages = [] 142 | 143 | #Creating the required directory for temporary images could be done in a preload.py, but I prefer to do this 144 | #check each time we go to save images, just in case 145 | if not os.path.exists(edirectory + "tempimages"): 146 | os.makedirs(edirectory + "tempimages") 147 | 148 | #The length of the returned json array might not actually be equal to what we reqeusted with limit=, 149 | #so we need to make sure to only step through what we got back 150 | for i in range(len(data)): 151 | #So I guess not every returned result has a 'file_url'. Could not tell you why that is. 152 | #Doesn't matter. If there's no file to grab, just skip the entry. 153 | if 'file_url' in data[i]: 154 | imageurl = data[i]['file_url'] 155 | #The format of this string is important. When we later go to query for specific posts, the user can use 156 | #"id:xxxxxx" instead of a full url to make that request 157 | id = "id:" + str(data[i]['id']) 158 | #I forget why I added this 159 | if "http" not in imageurl: 160 | imageurl = gethost() + imageurl 161 | #We're storing the images locally to be crammed into a Gradio gallery later. 162 | #This seemed simpler than using PIL images or whatever. 163 | savepath = edirectory + f"tempimages\\temp{i}.jpg" 164 | image = urlretrieve(imageurl, savepath) 165 | localimages.append((savepath, id)) 166 | 167 | #We're returning not just the images for the gallery, but the current page number 168 | #So that textbox in Gradio can be updated 169 | return localimages, curpage 170 | 171 | def gotonextpage(query, removeanimated, curpage): 172 | return searchbooru(query, removeanimated, curpage, pagechange=1) 173 | 174 | def gotoprevpage(query, removeanimated, curpage): 175 | return searchbooru(query, removeanimated, curpage, pagechange=-1) 176 | 177 | def updatesettings(active = settings['active']): 178 | """Update the relevant textboxes in Gradio with the appropriate data when 179 | the user selects a new booru in the dropdown 180 | 181 | Args: 182 | active (str, optional): The str name of the booru the user switched to. Defaults to settings['active']. 183 | 184 | Returns: 185 | (str, str, str, str): The username, apikey, name, and name again of the selected booru. 186 | We're only returning the name twice here since it needs to update two seperate Gradio components. 187 | """ 188 | settings['active'] = active 189 | for booru in settings['boorus']: 190 | if booru['name'] == active: 191 | username = booru['username'] 192 | apikey = booru['apikey'] 193 | return username, apikey, active, active 194 | 195 | def grabtags(url, negprompt, replacespaces, replaceunderscores, includeartist, includecharacter, includecopyright, includemeta): 196 | """Get the tags for the selected post and update all the relevant textboxes on the Select tab. 197 | 198 | Args: 199 | url (str): Either the full path to the post, or just the posts' id, formatted like "id:xxxxxx" 200 | negprompt (str): A negative prompt to paste into the relevant field. Setting to None will delete the existing negative prompt at the target 201 | replacespaces (bool): True to replace all the spaces in the tag list with ", " 202 | replaceunderscores (bool): True to replace the underscores in each tag with a space 203 | includeartist (bool): True to include the artist tags in the final tag string 204 | includecharacter (bool): True to include the character tags in the final tag string 205 | includecopyright (bool): True to include the copyright tags in the final tag string 206 | includemeta (bool): True to include the meta tags in the final tags string 207 | 208 | Returns: 209 | (str, str, str, str, str, str): A bunch of strings that will update some gradio components. 210 | In order, it's the final tag string, the local path to the saved image, the artist tags, the 211 | character tags, the copyright tags, and the meta tags. 212 | """ 213 | #This check may be uneccesary, but we should fail out immediately if the url isn't a string. 214 | #I struggle to remember what circumstance compelled me to add this. 215 | if not isinstance(url, str): 216 | return 217 | 218 | #Quick check to see if the user is selecting with the "id:xxxxxx" format. 219 | #If the are, we can all the extra stuff for them 220 | if url[0:2] == "id": 221 | url = gethost() + "/posts/" + url[3:] 222 | 223 | #Many times, copying a link right off the booru will result in a lot of extra 224 | #url parameters. We need to get rid of all those before we add our own. 225 | index = url.find("?") 226 | if index > -1: 227 | url = url[:index] 228 | 229 | #Check to make sure the request isn't already a .json api call before we add it ourselves 230 | if not url[-4:] == "json": 231 | url = url + ".json" 232 | 233 | #Add the question mark denoting url parameters back in 234 | url += "?" 235 | 236 | u, a = getauth() 237 | 238 | #Only append login parameters if we actually got some from the above getauth() 239 | #In the default settings.json in the repo, these are empty strings, so they'll 240 | #return false here. 241 | if u: 242 | url += f"login={u}&" 243 | if a: 244 | url += f"api_key={a}&" 245 | 246 | print(url) 247 | 248 | response = urlopen(url) 249 | data = json.loads(response.read()) 250 | 251 | tags = data['tag_string_general'] 252 | imageurl = data['file_url'] 253 | 254 | if "http" not in imageurl: 255 | imageurl = gethost() + imageurl 256 | 257 | artisttags = data["tag_string_artist"] 258 | charactertags = data["tag_string_character"] 259 | copyrighttags = data["tag_string_copyright"] 260 | metatags = data["tag_string_meta"] 261 | 262 | #We got all these extra tags, but we're only including them in the final string if the relevant 263 | #checkboxes have been checked 264 | if includeartist and artisttags: 265 | tags = artisttags + " " + tags 266 | if includecharacter and charactertags: 267 | tags = charactertags + " " + tags 268 | if includecopyright and copyrighttags: 269 | tags = copyrighttags + " " + tags 270 | if includemeta and metatags: 271 | tags = metatags + " " + tags 272 | 273 | #It would be a shame if someone got these backwards and couldn't figure out the issue for a whole day 274 | if replacespaces: 275 | tags = tags.replace(" ", ", ") 276 | if replaceunderscores: 277 | tags = tags.replace("_", " ") 278 | 279 | #Adding a line for the negative prompt if we receieved one 280 | #It's formatted this way very specifically. This is how the metadata looks on pngs coming out of SD 281 | if negprompt: 282 | tags += f"\nNegative prompt: {negprompt}" 283 | 284 | #Creating the temp directory if it doesn't already exist 285 | if not os.path.exists(edirectory + "tempimages"): 286 | os.makedirs(edirectory + "tempimages") 287 | urlretrieve(imageurl, edirectory + "tempimages\\temp.jpg") 288 | 289 | #My god look at that tuple 290 | return (tags, edirectory + "tempimages\\temp.jpg", artisttags, charactertags, copyrighttags, metatags) 291 | 292 | def on_ui_tabs(): 293 | #Just setting up some gradio components way early 294 | #For the most part, I've created each component at the place where it will be rendered 295 | #However, for these ones, I need to reference them before they would've otherwise been 296 | #initialized, so I put them up here instead. This is totally fine, since they can be 297 | #rendered in the appropirate place with .render() 298 | boorulist = [booru["name"] for booru in settings["boorus"]] 299 | selectimage = gr.Image(label="Image", type="filepath", interactive=False) 300 | searchimages = gr.Gallery(label="Search Results") 301 | searchimages.style(grid=3) 302 | activeboorutext1 = gr.Textbox(label="Current Booru", value=settings['active'], interactive=False) 303 | activeboorutext2 = gr.Textbox(label="Current Booru", value=settings['active'], interactive=False) 304 | curpage = gr.Textbox(value="1", label="Page Number", interactive=False, show_label=True) 305 | negprompt = gr.Textbox(label="Negative Prompt", value=settings['negativeprompt'], placeholder="Negative prompt to send with along with each prompt") 306 | 307 | with gr.Blocks() as interface: 308 | with gr.Tab("Select"): 309 | with gr.Row(equal_height=True): 310 | with gr.Column(): 311 | activeboorutext1.render() 312 | #Go to that link, I dare you 313 | imagelink = gr.Textbox(label="Link to image page", elem_id="selectbox", placeholder="https://danbooru.donmai.us/posts/4861569 or id:4861569") 314 | 315 | with gr.Row(): 316 | selectedtags_artist = gr.Textbox(label="Artist Tags", interactive=False) 317 | includeartist = gr.Checkbox(value=True, label="Include artist tags in tag string", interactive=True) 318 | with gr.Row(): 319 | selectedtags_character = gr.Textbox(label="Character Tags", interactive=False) 320 | includecharacter = gr.Checkbox(value=True, label="Include character tags in tag string", interactive=True) 321 | with gr.Row(): 322 | selectedtags_copyright = gr.Textbox(label="Copyright Tags", interactive=False) 323 | includecopyright = gr.Checkbox(value=True, label="Include copyright tags in tag string", interactive=True) 324 | with gr.Row(): 325 | selectedtags_meta = gr.Textbox(label="Meta Tags", interactive=False) 326 | includemeta = gr.Checkbox(value=False, label="Include meta tags in tag string", interactive=True) 327 | 328 | selectedtags = gr.Textbox(label="Image Tags", interactive=False, lines=3) 329 | 330 | replacespaces = gr.Checkbox(value=True, label="Replace spaces with a comma and a space", interactive=True) 331 | replaceunderscores = gr.Checkbox(value=False, label="Replace underscores with spaces") 332 | 333 | selectbutton = gr.Button(value="Select Image", variant="primary") 334 | selectbutton.click(fn=grabtags, 335 | inputs= 336 | [imagelink, 337 | negprompt, 338 | replacespaces, 339 | replaceunderscores, 340 | includeartist, 341 | includecharacter, 342 | includecopyright, 343 | includemeta], 344 | outputs= 345 | [selectedtags, 346 | selectimage, 347 | selectedtags_artist, 348 | selectedtags_character, 349 | selectedtags_copyright, 350 | selectedtags_meta]) 351 | 352 | clearselected = gr.Button(value="Clear") 353 | #This is just a cheeky way to clear out all the components in this tab. I'm sure this is not what you're meant to use lambda functions for. 354 | clearselected.click(fn=lambda: (None, None, None, None, None, None, None), outputs=[selectimage, selectedtags, selectedtags_artist, selectedtags_character, selectedtags_copyright, selectedtags_meta, imagelink]) 355 | with gr.Column(): 356 | selectimage.render() 357 | with gr.Row(equal_height=True): 358 | #Don't even ask me how this works. I spent like three days reading generation_parameters_copypaste.py 359 | #and I still don't quite know. Automatic1111 must've been high when he wrote that. 360 | sendselected = modules.generation_parameters_copypaste.create_buttons(["txt2img", "img2img", "inpaint", "extras"]) 361 | modules.generation_parameters_copypaste.bind_buttons(sendselected, selectimage, selectedtags) 362 | with gr.Tab("Search"): 363 | with gr.Row(equal_height=True): 364 | with gr.Column(): 365 | activeboorutext2.render() 366 | searchtext = gr.Textbox(label="Search string", placeholder="List of tags, delimited by spaces") 367 | removeanimated = gr.Checkbox(label="Remove results with the \"animated\" tag", value=True) 368 | searchbutton = gr.Button(value="Search Booru", variant="primary") 369 | searchtext.submit(fn=searchbooru, inputs=[searchtext, removeanimated, curpage], outputs=[searchimages, curpage]) 370 | searchbutton.click(fn=searchbooru, inputs=[searchtext, removeanimated, curpage], outputs=[searchimages, curpage]) 371 | with gr.Column(): 372 | with gr.Row(): 373 | prevpage = gr.Button(value="Previous Page") 374 | curpage.render() 375 | nextpage = gr.Button(value="Next Page") 376 | #The functions called here will then call searchbooru, just with a page in/decrement modifier 377 | prevpage.click(fn=gotoprevpage, inputs=[searchtext, removeanimated, curpage], outputs=[searchimages, curpage]) 378 | nextpage.click(fn=gotonextpage, inputs=[searchtext, removeanimated, curpage], outputs=[searchimages, curpage]) 379 | searchimages.render() 380 | with gr.Row(): 381 | sendsearched = gr.Button(value="Send image to tag selection", elem_id="sendselected") 382 | #In this particular instance, the javascript function will be used to read the page, find the selected image in 383 | #gallery, and send it back here to the imagelink output. I cannot fathom why Gradio galleries can't 384 | #be used as inputs, but so be it. 385 | sendsearched.click(fn = None, _js="switch_to_select", outputs = imagelink) 386 | with gr.Tab("Settings/API Keys"): 387 | settingshelptext = gr.HTML(interactive=False, show_label = False, value="API info may not be necessary for some boorus, but certain information or posts may fail to load without it. For example, Danbooru doesn't show certain posts in search results unless you auth as a Gold tier member.") 388 | settingshelptext2 = gr.HTML(interactive=False, show_label=False, value="Also, please set the booru selection here before using select or search.") 389 | booru = gr.Dropdown(label="Booru",value=settings['active'],choices=boorulist, interactive=True) 390 | u, a = getauth() 391 | username = gr.Textbox(label="Username", value=u) 392 | apikey = gr.Textbox(label="API Key", value=a) 393 | negprompt.render() 394 | savesettingsbutton = gr.Button(value="Save Settings", variant="primary") 395 | savesettingsbutton.click(fn=savesettings, inputs=[booru, username, apikey, negprompt]) 396 | booru.change(fn=updatesettings, inputs=booru, outputs=[username, apikey, activeboorutext1, activeboorutext2]) 397 | 398 | return (interface, "booru2prompt", "b2p_interface"), 399 | 400 | script_callbacks.on_ui_tabs(on_ui_tabs) 401 | --------------------------------------------------------------------------------