Upload 42 files
Browse files- .gitignore +8 -0
- CHANGELOG.md +62 -0
- LICENSE +21 -0
- README.md +138 -0
- backend/__init__.py +3 -0
- backend/app.py +386 -0
- backend/config.py +154 -0
- backend/script_hack.py +161 -0
- backend/structs.py +85 -0
- backend/utils.py +329 -0
- frontends/krita/krita_diff.desktop +8 -0
- frontends/krita/krita_diff/__init__.py +73 -0
- frontends/krita/krita_diff/client.py +402 -0
- frontends/krita/krita_diff/config.py +87 -0
- frontends/krita/krita_diff/defaults.py +127 -0
- frontends/krita/krita_diff/docker.py +34 -0
- frontends/krita/krita_diff/extension.py +63 -0
- frontends/krita/krita_diff/krita_diff.action +69 -0
- frontends/krita/krita_diff/manual.html +15 -0
- frontends/krita/krita_diff/pages/__init__.py +6 -0
- frontends/krita/krita_diff/pages/common.py +123 -0
- frontends/krita/krita_diff/pages/config.py +182 -0
- frontends/krita/krita_diff/pages/extension.py +135 -0
- frontends/krita/krita_diff/pages/img2img.py +31 -0
- frontends/krita/krita_diff/pages/img_base.py +90 -0
- frontends/krita/krita_diff/pages/inpaint.py +97 -0
- frontends/krita/krita_diff/pages/txt2img.py +49 -0
- frontends/krita/krita_diff/pages/upscale.py +60 -0
- frontends/krita/krita_diff/script.py +425 -0
- frontends/krita/krita_diff/style.py +8 -0
- frontends/krita/krita_diff/utils.py +237 -0
- frontends/krita/krita_diff/widgets/__init__.py +8 -0
- frontends/krita/krita_diff/widgets/checkbox.py +81 -0
- frontends/krita/krita_diff/widgets/combo_box.py +98 -0
- frontends/krita/krita_diff/widgets/line_edit.py +44 -0
- frontends/krita/krita_diff/widgets/misc.py +13 -0
- frontends/krita/krita_diff/widgets/prompt.py +58 -0
- frontends/krita/krita_diff/widgets/spin_box.py +64 -0
- frontends/krita/krita_diff/widgets/status_bar.py +22 -0
- frontends/krita/krita_diff/widgets/tips.py +18 -0
- install.py +26 -0
- scripts/main.py +146 -0
.gitignore
ADDED
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
__pycache__
|
2 |
+
/venv
|
3 |
+
/tmp
|
4 |
+
/outputs
|
5 |
+
/log
|
6 |
+
/.idea
|
7 |
+
/.vscode
|
8 |
+
/krita_config.yaml
|
CHANGELOG.md
ADDED
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# UI Changelog
|
2 |
+
|
3 |
+
## 2022-12-28
|
4 |
+
|
5 |
+
- Added "Alt Dock Behaviour" under "SD Plugin Config".
|
6 |
+
- _Modifies default Krita dock behaviour!_
|
7 |
+
- Dragging title bar of docker now drags all stacked/tabbed dockers out instead of just one docker.
|
8 |
+
- Dragging the tab now drags the specific docker out instead of only re-arranging the tab.
|
9 |
+
- Enables floating stacked/tabbed dockers.
|
10 |
+
- Enables subdividing dock areas further.
|
11 |
+
- See: <https://doc.qt.io/qt-6/qmainwindow.html#DockOption-enum>
|
12 |
+
- All generations are added to group layer per batch with generation info.
|
13 |
+
- For batches of generations, all but the last image generated is hidden by default.
|
14 |
+
|
15 |
+
## 2022-12-20
|
16 |
+
|
17 |
+
- **UI Overhaul**: A few miscellaneous changes with some big ones:
|
18 |
+
- All tabs are now their own dockers to allow more flexibility in arranging.
|
19 |
+
- "Restore Defaults" will make all dockers re-appear and arrange themselves.
|
20 |
+
- Progress & number of pending requests now shown.
|
21 |
+
- All dropdowns now support searching, useful if your model checkpoint list is really long.
|
22 |
+
|
23 |
+
## 2022-12-04
|
24 |
+
|
25 |
+
- Add Interrupt button.
|
26 |
+
|
27 |
+
## 2022-11-15
|
28 |
+
|
29 |
+
- Scripts/features that increase the image size (Simple upscaling, SD upscaling, Outpaint Mk 2, etc) will now expand the canvas when image generation is complete **only if** _there is no active selection_.
|
30 |
+
- If there is a selection, the image will be scaled to fit the selection region.
|
31 |
+
- Using Ctrl+A to select the entire image is considered an active selection!
|
32 |
+
|
33 |
+
## 2022-11-08
|
34 |
+
|
35 |
+
- Inpainting is finally 100% fixed! No more weird borders. Blur works properly.
|
36 |
+
- Inpainting Full Resolution and Mask Blur were deemed obsolete and removed.
|
37 |
+
- See <https://github.com/Interpause/auto-sd-paint-ext/wiki/Usage-Guide#inpainting> on better ways to do so.
|
38 |
+
|
39 |
+
## 2022-10-31
|
40 |
+
|
41 |
+
- Moved base size/max size & some other quick config options based on user feedback.
|
42 |
+
|
43 |
+
## 2022-10-25
|
44 |
+
|
45 |
+
- Will now save previous tab user was on.
|
46 |
+
- Fixed seed being truncated to 32-bit int.
|
47 |
+
- Prevent sending image generation request when cannot connect to backend.
|
48 |
+
|
49 |
+
## 2022-10-24
|
50 |
+
|
51 |
+
- UI no longer freezes when generating images or network activity like getting backend config
|
52 |
+
- Pressing "start xxx" multiple times will queue generation requests on the backend
|
53 |
+
- Will not mess with the current selection region or layer when inserting images once done
|
54 |
+
|
55 |
+
## 2022-10-21
|
56 |
+
|
57 |
+
- No need to manually hide inpainting layer anymore; It will be auto-hidden.
|
58 |
+
- Color correction can be toggled separately for img2img/inpainting.
|
59 |
+
- Status bar:
|
60 |
+
- In middle of page to be more visible even when scrolling.
|
61 |
+
- Warning when using features with no document open.
|
62 |
+
- Inpaint is now the default tab.
|
LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
MIT License
|
2 |
+
|
3 |
+
Copyright (c) 2022 John-Henry Lim
|
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.
|
README.md
ADDED
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# auto-sd-paint-ext
|
2 |
+
|
3 |
+
Formerly known as `auto-sd-krita`.
|
4 |
+
|
5 |
+
> Extension for AUTOMATIC1111's webUI with Krita Plugin (other drawing studios soon?)
|
6 |
+
|
7 |
+
Outdated demo | New UI (TODO: demo image)
|
8 |
+
--- | ---
|
9 |
+
 | 
|
10 |
+
|
11 |
+
Why use this?
|
12 |
+
|
13 |
+
- Optimized workflow (txt2img, img2img, inpaint, outpaint, upscale) & UI design.
|
14 |
+
- Only drawing studio plugin that exposes the Script API.
|
15 |
+
- Easily create/save profiles (prompts, samplers, model, etc used).
|
16 |
+
- Some of the above isn't actually implemented yet.
|
17 |
+
|
18 |
+
## Quick Jump
|
19 |
+
|
20 |
+
- Full Installation & Workflow Tutorial Video! (Coming Soon...)
|
21 |
+
- [Installation Guide](https://github.com/Interpause/auto-sd-paint-ext/wiki/Install-Guide)
|
22 |
+
- [Usage Guide](https://github.com/Interpause/auto-sd-paint-ext/wiki/Usage-Guide)
|
23 |
+
- [Step by Step Guide to Better Inpainting](https://github.com/Interpause/auto-sd-paint-ext/wiki/Usage-Guide#inpainting-step-by-step)
|
24 |
+
- [Update Guide](https://github.com/Interpause/auto-sd-paint-ext/wiki/Update-Guide)
|
25 |
+
- [Features](https://github.com/Interpause/auto-sd-paint-ext/wiki/Features)
|
26 |
+
- [TODO](https://github.com/Interpause/auto-sd-paint-ext/wiki/TODO)
|
27 |
+
- [Contribution Guide](https://github.com/Interpause/auto-sd-paint-ext/wiki/Contribution-Guide)
|
28 |
+
|
29 |
+
(Outdated) Usage & Workflow Demo:
|
30 |
+
|
31 |
+
[](https://youtu.be/nP8MuRwcDN8 "Inpaint like a pro with Stable Diffusion! auto-sd-krita workflow guide")
|
32 |
+
|
33 |
+
### Differences from Video
|
34 |
+
|
35 |
+
- All webUI scripts have been tested to work!
|
36 |
+
- SD Upscale, Outpainting Mk 2, Img2Img Alt, etc
|
37 |
+
- Inpainting experience is better
|
38 |
+
- Inpaint mask is auto-hidden
|
39 |
+
- Better mask blur & inpaint full resolution technique than webUI
|
40 |
+
- UI no longer freezes during image update
|
41 |
+
- UI has been improved, takes up less space
|
42 |
+
- Error messages have been improved
|
43 |
+
|
44 |
+
## Breaking Changes
|
45 |
+
|
46 |
+
- The URL is different now, so reset "Backend URL" to default under the Config tab.
|
47 |
+
- It is now an AUTOMATIC1111 extension.
|
48 |
+
- Do <https://github.com/Interpause/auto-sd-krita/wiki/Quick-Switch-Using-Existing-AUTOMATIC1111-Install> in reverse for a quick fix.
|
49 |
+
- `krita_config.yaml` was renamed to `auto-sd-paint-ext-backend.yaml`.
|
50 |
+
|
51 |
+
## FAQ
|
52 |
+
|
53 |
+
Q: How does the base_size, max_size system work?
|
54 |
+
|
55 |
+
A:
|
56 |
+
|
57 |
+
It is an alternative to AUTO's highres fix that works for all modes, not just txt2img.
|
58 |
+
|
59 |
+
The selection will be resized such that the shorter dimension is base_size. However, if the aforementioned resize causes the longer dimension to exceed max_size, the shorter dimension will be resized to less than base_size. Setting base_size and max_size higher can be used to generate higher resolution images (along with their issues), essentially **disabling the system**, _though it might make sense for img2img mode_.
|
60 |
+
|
61 |
+
This is actually smarter than the builtin highres fix + firstphase width/height system. Thank the original plugin writer, @sddebz, for writing this.
|
62 |
+
|
63 |
+
<hr/>
|
64 |
+
|
65 |
+
Q: Outpainting tab?
|
66 |
+
|
67 |
+
A:
|
68 |
+
While the outpainting tab is still WIP, the outpainting scripts (under img2img tab) works perfectly fine! Alternatively, if you want more control over outpainting, you can:
|
69 |
+
|
70 |
+
1. Expand the canvas
|
71 |
+
2. Scribble in the newly added blank area
|
72 |
+
3. img2img on the blank area + some of the image
|
73 |
+
|
74 |
+
<hr/>
|
75 |
+
|
76 |
+
Q: Is the model loaded into memory twice?
|
77 |
+
|
78 |
+
A: No, it shares the same backend. Both the Krita plugin and webUI can be used concurrently.
|
79 |
+
|
80 |
+
<hr/>
|
81 |
+
|
82 |
+
Q: How can you commit to updating regularly?
|
83 |
+
|
84 |
+
A: It is easy for me.
|
85 |
+
|
86 |
+
<hr/>
|
87 |
+
|
88 |
+
Q: Will it work with other Krita plugin backends?
|
89 |
+
|
90 |
+
A: Unfortunately no, all plugins so far have different APIs. The official API is coming soon though...
|
91 |
+
|
92 |
+
## UI Changelog
|
93 |
+
|
94 |
+
See [CHANGELOG.md](./CHANGELOG.md) for the full changelog.
|
95 |
+
|
96 |
+
### 2022-12-28
|
97 |
+
|
98 |
+
- Added "Alt Dock Behaviour" under "SD Plugin Config".
|
99 |
+
- _Modifies default Krita dock behaviour!_
|
100 |
+
- Dragging title bar of docker now drags all stacked/tabbed dockers out instead of just one docker.
|
101 |
+
- Dragging the tab now drags the specific docker out instead of only re-arranging the tab.
|
102 |
+
- Enables floating stacked/tabbed dockers.
|
103 |
+
- Enables subdividing dock areas further.
|
104 |
+
- See: <https://doc.qt.io/qt-6/qmainwindow.html#DockOption-enum>
|
105 |
+
- All generations are added to group layer per batch with generation info.
|
106 |
+
- For batches of generations, all but the last image generated is hidden by default.
|
107 |
+
|
108 |
+
### 2022-12-20
|
109 |
+
|
110 |
+
- **UI Overhaul**: A few miscellaneous changes with some big ones:
|
111 |
+
- All tabs are now their own dockers to allow more flexibility in arranging.
|
112 |
+
- "Restore Defaults" will make all dockers re-appear and arrange themselves.
|
113 |
+
- Progress & number of pending requests now shown.
|
114 |
+
- All dropdowns now support searching, useful if your model checkpoint list is really long.
|
115 |
+
|
116 |
+
### 2022-12-04
|
117 |
+
|
118 |
+
- Add Interrupt button.
|
119 |
+
|
120 |
+
### 2022-11-15
|
121 |
+
|
122 |
+
- Scripts/features that increase the image size (Simple upscaling, SD upscaling, Outpaint Mk 2, etc) will now expand the canvas when image generation is complete **only if** _there is no active selection_.
|
123 |
+
- If there is a selection, the image will be scaled to fit the selection region.
|
124 |
+
- Using Ctrl+A to select the entire image is considered an active selection!
|
125 |
+
|
126 |
+
### 2022-11-08
|
127 |
+
|
128 |
+
- Inpainting is finally 100% fixed! No more weird borders. Blur works properly.
|
129 |
+
- Inpainting Full Resolution and Mask Blur were deemed obsolete and removed.
|
130 |
+
- See <https://github.com/Interpause/auto-sd-paint-ext/wiki/Usage-Guide#inpainting> on better ways to do so.
|
131 |
+
|
132 |
+
## Credits
|
133 |
+
|
134 |
+
- [@sddebz](https://github.com/sddebz) for writing the original backend API and Krita plugin while keeping the Gradio webUI functionality intact.
|
135 |
+
|
136 |
+
## License
|
137 |
+
|
138 |
+
MIT for the Krita Plugin backend server & frontend plugin. Code has been nearly completely rewritten compared to original plugin by now.
|
backend/__init__.py
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
from .app import router
|
2 |
+
|
3 |
+
__all__ = ["router"]
|
backend/app.py
ADDED
@@ -0,0 +1,386 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from __future__ import annotations
|
2 |
+
|
3 |
+
import logging
|
4 |
+
import os
|
5 |
+
import time
|
6 |
+
|
7 |
+
import modules
|
8 |
+
from fastapi import APIRouter, Request
|
9 |
+
from fastapi.responses import StreamingResponse
|
10 |
+
from modules import shared
|
11 |
+
from modules.call_queue import wrap_gradio_gpu_call
|
12 |
+
from PIL import Image, ImageOps
|
13 |
+
from starlette.concurrency import iterate_in_threadpool
|
14 |
+
|
15 |
+
from .config import LOGGER_NAME, NAME_SCRIPT_LOOPBACK, NAME_SCRIPT_UPSCALE
|
16 |
+
from .script_hack import get_script_info, get_scripts_metadata, process_script_args
|
17 |
+
from .structs import (
|
18 |
+
ConfigResponse,
|
19 |
+
ImageResponse,
|
20 |
+
Img2ImgRequest,
|
21 |
+
Txt2ImgRequest,
|
22 |
+
UpscaleRequest,
|
23 |
+
UpscaleResponse,
|
24 |
+
)
|
25 |
+
from .utils import (
|
26 |
+
b64_to_img,
|
27 |
+
bytewise_xor,
|
28 |
+
get_encrypt_key,
|
29 |
+
get_sampler_index,
|
30 |
+
get_upscaler_index,
|
31 |
+
img_to_b64,
|
32 |
+
load_config,
|
33 |
+
merge_default_config,
|
34 |
+
parse_prompt,
|
35 |
+
prepare_backend,
|
36 |
+
prepare_mask,
|
37 |
+
save_img,
|
38 |
+
sddebz_highres_fix,
|
39 |
+
)
|
40 |
+
|
41 |
+
router = APIRouter()
|
42 |
+
|
43 |
+
log = logging.getLogger(LOGGER_NAME)
|
44 |
+
|
45 |
+
# NOTE: how to run a script
|
46 |
+
# - get scripts_txt2img/scripts_img2img from modules.scripts
|
47 |
+
# - construct array args, where 0th element is selected script
|
48 |
+
# - refer to script.args_from & script.args_to to figure out which elements in
|
49 |
+
# array args to populate
|
50 |
+
#
|
51 |
+
# The way scripts are handled is they are loaded one by one, append to a list of
|
52 |
+
# scripts, which each script taking up "slots" in the input args array.
|
53 |
+
# So the more scripts, the longer array args would be for the last script.
|
54 |
+
|
55 |
+
# NOTE: where to draw the line on what is done by the backend vs the frontend?
|
56 |
+
# TODO: Create separate Outpainting route, add img2img structs to Upscale route
|
57 |
+
# - yes I know its highly inconsistent what should be a route or not, but to prevent
|
58 |
+
# incredibly hacky workarounds on the frontend for script calling, it should be
|
59 |
+
# done by the backend, which has better access to the script information.
|
60 |
+
# - Upscale tab UI:
|
61 |
+
# - Upscaler dropdown + 0.5x downscale checkbox + SD upscale checkbox
|
62 |
+
# - SD upscale checkbox hides 0.5x downscale checkbox, renames upscaler dropdown
|
63 |
+
# - to prescaler, and shows modified img2img UI (ofc uses its own cfg namespace)
|
64 |
+
# - Outpaint tab UI:
|
65 |
+
# - modified img2img UI with own cfg namespace
|
66 |
+
# - try and hijack more control (Pixel to expand per direction instead of all directions)
|
67 |
+
# - self-sketch mode: basically sketch + inpaint but the inpaint mask is auto-calculated
|
68 |
+
# - option to select poor man, mk 2 or self-sketch
|
69 |
+
# TODO: Consider using pipeline directly instead of Gradio API for less surprises & better control
|
70 |
+
|
71 |
+
|
72 |
+
@router.get("/config", response_model=ConfigResponse)
|
73 |
+
async def get_state():
|
74 |
+
"""Get information about backend API.
|
75 |
+
|
76 |
+
Returns config from `krita_config.yaml`, other metadata,
|
77 |
+
the path to the rendered image and image mask, etc.
|
78 |
+
|
79 |
+
Returns:
|
80 |
+
Dict: information.
|
81 |
+
"""
|
82 |
+
opt = load_config().plugin
|
83 |
+
prepare_backend(opt)
|
84 |
+
|
85 |
+
sample_path = os.path.abspath(opt.sample_path)
|
86 |
+
return {
|
87 |
+
**opt.dict(),
|
88 |
+
"sample_path": sample_path,
|
89 |
+
"upscalers": [upscaler.name for upscaler in shared.sd_upscalers],
|
90 |
+
"samplers": [sampler.name for sampler in modules.sd_samplers.samplers],
|
91 |
+
"samplers_img2img": [
|
92 |
+
sampler.name for sampler in modules.sd_samplers.samplers_for_img2img
|
93 |
+
],
|
94 |
+
"scripts_txt2img": get_scripts_metadata(False),
|
95 |
+
"scripts_img2img": get_scripts_metadata(True),
|
96 |
+
"face_restorers": [model.name() for model in shared.face_restorers],
|
97 |
+
"sd_models": modules.sd_models.checkpoint_tiles(), # yes internal API has spelling error
|
98 |
+
}
|
99 |
+
|
100 |
+
|
101 |
+
@router.post("/txt2img", response_model=ImageResponse)
|
102 |
+
def f_txt2img(req: Txt2ImgRequest):
|
103 |
+
"""Post request for Txt2Img.
|
104 |
+
|
105 |
+
Args:
|
106 |
+
req (Txt2ImgRequest): Request.
|
107 |
+
|
108 |
+
Returns:
|
109 |
+
Dict: Outputs and info.
|
110 |
+
"""
|
111 |
+
log.info(f"txt2img:\n{req}")
|
112 |
+
|
113 |
+
opt = load_config().txt2img
|
114 |
+
req = merge_default_config(req, opt)
|
115 |
+
prepare_backend(req)
|
116 |
+
|
117 |
+
script_ind, script, meta = get_script_info(req.script, False)
|
118 |
+
args = process_script_args(script_ind, script, meta, req.script_args)
|
119 |
+
|
120 |
+
width, height = sddebz_highres_fix(
|
121 |
+
req.base_size, req.max_size, req.orig_width, req.orig_height
|
122 |
+
)
|
123 |
+
|
124 |
+
output = wrap_gradio_gpu_call(modules.txt2img.txt2img)(
|
125 |
+
parse_prompt(req.prompt), # prompt
|
126 |
+
parse_prompt(req.negative_prompt), # negative_prompt
|
127 |
+
"None", # prompt_style: saved prompt styles (unsupported)
|
128 |
+
"None", # prompt_style2: saved prompt styles (unsupported)
|
129 |
+
req.steps, # steps
|
130 |
+
get_sampler_index(req.sampler_name), # sampler_index
|
131 |
+
req.restore_faces, # restore_faces
|
132 |
+
req.tiling, # tiling
|
133 |
+
req.batch_count, # n_iter
|
134 |
+
req.batch_size, # batch_size
|
135 |
+
req.cfg_scale, # cfg_scale
|
136 |
+
req.seed, # seed
|
137 |
+
req.subseed, # subseed
|
138 |
+
req.subseed_strength, # subseed_strength
|
139 |
+
req.seed_resize_from_h, # seed_resize_from_h
|
140 |
+
req.seed_resize_from_w, # seed_resize_from_w
|
141 |
+
req.seed_enable_extras, # seed_enable_extras
|
142 |
+
height, # height
|
143 |
+
width, # width
|
144 |
+
req.highres_fix, # enable_hr: high res fix
|
145 |
+
req.denoising_strength, # denoising_strength: only applicable if high res fix in use
|
146 |
+
0, # hr_scale (overrided by hr_resize_x/y)
|
147 |
+
req.upscaler_name, # hr_upscaler: upscaler to use for highres fix
|
148 |
+
0, # hr_second_pass_steps: 0 uses same num of steps as generation to refine details
|
149 |
+
req.orig_width, # hr_resize_x
|
150 |
+
req.orig_height, # hr_resize_y
|
151 |
+
*args,
|
152 |
+
)
|
153 |
+
images = output[0]
|
154 |
+
info = output[1]
|
155 |
+
|
156 |
+
if images is None or len(images) < 1:
|
157 |
+
log.warning("Interrupted!")
|
158 |
+
return {"outputs": [], "info": info}
|
159 |
+
|
160 |
+
if shared.opts.return_grid:
|
161 |
+
if not req.include_grid and len(images) > 1 and script_ind == 0:
|
162 |
+
images = images[1:]
|
163 |
+
|
164 |
+
if not script or (width == images[0].width and height == images[0].height):
|
165 |
+
log.info(
|
166 |
+
f"img size: {images[0].width}x{images[0].height}, target: {req.orig_width}x{req.orig_height}"
|
167 |
+
)
|
168 |
+
images = [
|
169 |
+
modules.images.resize_image(0, image, req.orig_width, req.orig_height)
|
170 |
+
for image in images
|
171 |
+
]
|
172 |
+
|
173 |
+
# save images for debugging/logging purposes
|
174 |
+
if req.save_samples:
|
175 |
+
output_paths = [
|
176 |
+
save_img(image, opt.sample_path, filename=f"{int(time.time())}_{i}.png")
|
177 |
+
for i, image in enumerate(images)
|
178 |
+
]
|
179 |
+
log.info(f"saved: {output_paths}")
|
180 |
+
|
181 |
+
images = [img_to_b64(image) for image in images]
|
182 |
+
|
183 |
+
log.info(f"output sizes: {[len(i) for i in images]}")
|
184 |
+
log.info(f"finished txt2img!")
|
185 |
+
return {"outputs": images, "info": info}
|
186 |
+
|
187 |
+
|
188 |
+
@router.post("/img2img", response_model=ImageResponse)
|
189 |
+
def f_img2img(req: Img2ImgRequest):
|
190 |
+
"""Post request for Img2Img.
|
191 |
+
|
192 |
+
Args:
|
193 |
+
req (Img2ImgRequest): Request.
|
194 |
+
|
195 |
+
Returns:
|
196 |
+
Dict: Outputs and info.
|
197 |
+
"""
|
198 |
+
log.info(f"img2img:\n{req.dict(exclude={'src_img', 'mask_img'})}")
|
199 |
+
|
200 |
+
opt = load_config().img2img
|
201 |
+
req = merge_default_config(req, opt)
|
202 |
+
prepare_backend(req)
|
203 |
+
|
204 |
+
script_ind, script, meta = get_script_info(req.script, True)
|
205 |
+
args = process_script_args(script_ind, script, meta, req.script_args)
|
206 |
+
|
207 |
+
image = b64_to_img(req.src_img)
|
208 |
+
mask = (
|
209 |
+
prepare_mask(b64_to_img(req.mask_img))
|
210 |
+
if req.mode == 1 and req.mask_img is not None
|
211 |
+
else None
|
212 |
+
)
|
213 |
+
|
214 |
+
orig_width, orig_height = image.size
|
215 |
+
|
216 |
+
if script and script.title() == NAME_SCRIPT_UPSCALE:
|
217 |
+
# in SD upscale mode, width & height determines tile size
|
218 |
+
width = height = req.base_size
|
219 |
+
else:
|
220 |
+
width, height = sddebz_highres_fix(
|
221 |
+
req.base_size, req.max_size, orig_width, orig_height
|
222 |
+
)
|
223 |
+
|
224 |
+
# NOTE:
|
225 |
+
# - image & mask repeated due to Gradio API have separate tabs for each mode...
|
226 |
+
# - mask is used only in inpaint mode
|
227 |
+
# - mask_mode determines whethere init_img_with_mask or init_img_inpaint is used,
|
228 |
+
# I dont know why
|
229 |
+
# - new color sketch functionality in webUI is irrelevant so None is used for their options.
|
230 |
+
# - the internal code for img2img is confusing and duplicative...
|
231 |
+
|
232 |
+
output = wrap_gradio_gpu_call(modules.img2img.img2img)(
|
233 |
+
req.mode, # mode
|
234 |
+
parse_prompt(req.prompt), # prompt
|
235 |
+
parse_prompt(req.negative_prompt), # negative_prompt
|
236 |
+
"None", # prompt_style: saved prompt styles (unsupported)
|
237 |
+
"None", # prompt_style2: saved prompt styles (unsupported)
|
238 |
+
image, # init_img
|
239 |
+
{"image": image, "mask": mask}, # init_img_with_mask
|
240 |
+
None, # init_img_with_mask_orig # only used by webUI color sketch if init_img_with_mask isn't dict
|
241 |
+
image, # init_img_inpaint
|
242 |
+
mask, # init_mask_inpaint
|
243 |
+
# using 1 for uploaded mask mode; processing done by prepare_mask to ensure its correct
|
244 |
+
1, # mask_mode: internally checks if equal 0. 1 enables alpha mask (remove erased parts)
|
245 |
+
req.steps, # steps
|
246 |
+
get_sampler_index(req.sampler_name), # sampler_index
|
247 |
+
0, # req.mask_blur, # mask_blur
|
248 |
+
None, # mask_alpha # only used by webUI color sketch if init_img_with_mask isn't dict
|
249 |
+
req.inpainting_fill, # inpainting_fill
|
250 |
+
req.restore_faces, # restore_faces
|
251 |
+
req.tiling, # tiling
|
252 |
+
req.batch_count, # n_iter
|
253 |
+
req.batch_size, # batch_size
|
254 |
+
req.cfg_scale, # cfg_scale
|
255 |
+
req.denoising_strength, # denoising_strength
|
256 |
+
req.seed, # seed
|
257 |
+
req.subseed, # subseed
|
258 |
+
req.subseed_strength, # subseed_strength
|
259 |
+
req.seed_resize_from_h, # seed_resize_from_h
|
260 |
+
req.seed_resize_from_w, # seed_resize_from_w
|
261 |
+
req.seed_enable_extras, # seed_enable_extras
|
262 |
+
height, # height
|
263 |
+
width, # width
|
264 |
+
req.resize_mode, # resize_mode
|
265 |
+
False, # req.inpaint_full_res, # inpaint_full_res
|
266 |
+
0, # req.inpaint_full_res_padding, # inpaint_full_res_padding
|
267 |
+
req.invert_mask, # inpainting_mask_invert
|
268 |
+
"", # img2img_batch_input_dir (unspported)
|
269 |
+
"", # img2img_batch_output_dir (unspported)
|
270 |
+
*args,
|
271 |
+
)
|
272 |
+
images = output[0]
|
273 |
+
info = output[1]
|
274 |
+
|
275 |
+
if images is None or len(images) < 1:
|
276 |
+
log.warning("Interrupted!")
|
277 |
+
return {"outputs": [], "info": info}
|
278 |
+
|
279 |
+
if shared.opts.return_grid:
|
280 |
+
if not req.include_grid and len(images) > 1 and script_ind == 0:
|
281 |
+
images = images[1:]
|
282 |
+
# This is a workaround.
|
283 |
+
if script and script.title() == NAME_SCRIPT_LOOPBACK and len(images) > 1:
|
284 |
+
images = images[1:]
|
285 |
+
|
286 |
+
# NOTE: this is a dumb assumption:
|
287 |
+
# if size of image is different from size given to pipeline (after sbbedz fix)
|
288 |
+
# then it must be intentional (i.e. SD Upscale/outpaint) so dont scale back
|
289 |
+
if not script or (width == images[0].width and height == images[0].height):
|
290 |
+
log.info(
|
291 |
+
f"img Size: {images[0].width}x{images[0].height}, target: {orig_width}x{orig_height}"
|
292 |
+
)
|
293 |
+
images = [
|
294 |
+
modules.images.resize_image(0, image, orig_width, orig_height)
|
295 |
+
for image in images
|
296 |
+
]
|
297 |
+
|
298 |
+
if req.mode == 1:
|
299 |
+
|
300 |
+
def apply_mask(img):
|
301 |
+
"""Mask inpaint using original mask, including alpha."""
|
302 |
+
r, g, b = img.split() # img2img/inpaint gives rgb image
|
303 |
+
a = ImageOps.invert(mask) if req.invert_mask else mask
|
304 |
+
return Image.merge("RGBA", (r, g, b, a))
|
305 |
+
|
306 |
+
images = [apply_mask(x) for x in images]
|
307 |
+
|
308 |
+
# save images for debugging/logging purposes
|
309 |
+
if req.save_samples:
|
310 |
+
output_paths = [
|
311 |
+
save_img(image, opt.sample_path, filename=f"{int(time.time())}_{i}.png")
|
312 |
+
for i, image in enumerate(images)
|
313 |
+
]
|
314 |
+
log.info(f"saved: {output_paths}")
|
315 |
+
|
316 |
+
images = [img_to_b64(image) for image in images]
|
317 |
+
|
318 |
+
log.info(f"output sizes: {[len(i) for i in images]}")
|
319 |
+
log.info(f"finished img2img!")
|
320 |
+
return {"outputs": images, "info": info}
|
321 |
+
|
322 |
+
|
323 |
+
@router.post("/upscale", response_model=UpscaleResponse)
|
324 |
+
def f_upscale(req: UpscaleRequest):
|
325 |
+
"""Post request for upscaling.
|
326 |
+
|
327 |
+
Args:
|
328 |
+
req (UpscaleRequest): Request.
|
329 |
+
|
330 |
+
Returns:
|
331 |
+
Dict: Output.
|
332 |
+
"""
|
333 |
+
log.info(f"upscale:\n{req.dict(exclude={'src_img'})}")
|
334 |
+
|
335 |
+
opt = load_config().upscale
|
336 |
+
req = merge_default_config(req, opt)
|
337 |
+
prepare_backend(req)
|
338 |
+
|
339 |
+
image = b64_to_img(req.src_img).convert("RGB")
|
340 |
+
orig_width, orig_height = image.size
|
341 |
+
|
342 |
+
upscaler_index = get_upscaler_index(req.upscaler_name)
|
343 |
+
upscaler = shared.sd_upscalers[upscaler_index]
|
344 |
+
|
345 |
+
if upscaler.name == "None":
|
346 |
+
log.info(f"No upscaler selected, will do nothing")
|
347 |
+
return
|
348 |
+
|
349 |
+
if req.downscale_first:
|
350 |
+
image = modules.images.resize_image(0, image, orig_width // 2, orig_height // 2)
|
351 |
+
|
352 |
+
image = upscaler.scaler.upscale(image, upscaler.scale, upscaler.data_path)
|
353 |
+
if req.save_samples:
|
354 |
+
output_path = save_img(
|
355 |
+
image, opt.sample_path, filename=f"{int(time.time())}.png"
|
356 |
+
)
|
357 |
+
log.info(f"saved: {output_path}")
|
358 |
+
|
359 |
+
output = img_to_b64(image)
|
360 |
+
log.info(f"output size: {len(output)}")
|
361 |
+
log.info("finished upscale!")
|
362 |
+
return {"output": output}
|
363 |
+
|
364 |
+
|
365 |
+
async def app_encryption_middleware(req: Request, call_next):
|
366 |
+
"""Used to decrypt/encrypt HTTP request body."""
|
367 |
+
is_encrypted = "X-Encrypted-Body" in req.headers
|
368 |
+
# only supported method now is XOR
|
369 |
+
assert not is_encrypted or req.headers["X-Encrypted-Body"] == "XOR"
|
370 |
+
if is_encrypted:
|
371 |
+
key = get_encrypt_key()
|
372 |
+
assert key is not None, "Unable to decrypt request without key."
|
373 |
+
body = await req.body()
|
374 |
+
body = bytewise_xor(body, key)
|
375 |
+
# NOTE: FastAPI refuses to work with requests that have already been consumed idk why
|
376 |
+
async def receive():
|
377 |
+
return dict(type="http.request", body=body, more_body=False)
|
378 |
+
|
379 |
+
req = Request(req.scope, receive, req._send)
|
380 |
+
|
381 |
+
res: StreamingResponse = await call_next(req)
|
382 |
+
if is_encrypted:
|
383 |
+
res.headers["X-Encrypted-Body"] = req.headers["X-Encrypted-Body"]
|
384 |
+
body = [bytewise_xor(chunk, key) async for chunk in res.body_iterator]
|
385 |
+
res.body_iterator = iterate_in_threadpool(iter(body))
|
386 |
+
return res
|
backend/config.py
ADDED
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from __future__ import annotations
|
2 |
+
|
3 |
+
from typing import Any
|
4 |
+
|
5 |
+
from pydantic import BaseModel, Field
|
6 |
+
|
7 |
+
SCRIPT_NAME = "Interpause Backend API"
|
8 |
+
SCRIPT_ID = "interpause_backend_api"
|
9 |
+
ROUTE_PREFIX = "/sdapi/interpause"
|
10 |
+
CONFIG_PATH = "auto-sd-paint-ext-backend.yaml"
|
11 |
+
LOGGER_NAME = "auto-sd-paint-ext"
|
12 |
+
ENCRYPT_FILE = "xor_pass.txt"
|
13 |
+
|
14 |
+
# names of scripts to apply workarounds for
|
15 |
+
NAME_SCRIPT_LOOPBACK = "Loopback"
|
16 |
+
NAME_SCRIPT_UPSCALE = "SD upscale"
|
17 |
+
|
18 |
+
|
19 |
+
class BaseOptions(BaseModel):
|
20 |
+
sample_path: str = "outputs/krita-out"
|
21 |
+
"""Where to save generated images to."""
|
22 |
+
save_samples: bool = False
|
23 |
+
"""Whether to save temporary images (useful for debugging)."""
|
24 |
+
|
25 |
+
|
26 |
+
class GenerationOptions(BaseModel):
|
27 |
+
sd_model: str = "model.ckpt"
|
28 |
+
"""Model to use for generation."""
|
29 |
+
script: str = "None"
|
30 |
+
"""Which script to use."""
|
31 |
+
script_args: list = Field(default_factory=list)
|
32 |
+
"""List of args for script."""
|
33 |
+
|
34 |
+
prompt: Any = "dog"
|
35 |
+
"""Requested prompt."""
|
36 |
+
negative_prompt: Any = ""
|
37 |
+
"""Requested negative prompt."""
|
38 |
+
seed: int = -1
|
39 |
+
"""Seed used for noise generation. Incremented by 1 for each image rendered."""
|
40 |
+
|
41 |
+
seed_enable_extras: bool = False
|
42 |
+
"""Enable subseed variation."""
|
43 |
+
subseed: int = -1
|
44 |
+
"""Subseed to use for subseed variation. Incremented by 1 for each image rendered."""
|
45 |
+
subseed_strength: float = 0.0
|
46 |
+
"""Strength of subseed compared to seed. 0.0 will be completely original seed, 1.0 will be completely subseed."""
|
47 |
+
seed_resize_from_h: int = 0
|
48 |
+
"""Original resolution seed was used at. Used to resize latent noise to attempt to generate same image with a different resolution."""
|
49 |
+
seed_resize_from_w: int = 0
|
50 |
+
"""Original resolution seed was used at. Used to resize latent noise to attempt to generate same image with a different resolution."""
|
51 |
+
|
52 |
+
sampler_name: str = "Euler a"
|
53 |
+
"""Exact name of sampler to use. Name should follow exact spelling and capitalization as in the WebUI."""
|
54 |
+
steps: int = 30
|
55 |
+
"""Number of steps for diffusion."""
|
56 |
+
cfg_scale: float = 7.5
|
57 |
+
"""Guidance scale for diffusion."""
|
58 |
+
denoising_strength: float = 0.35
|
59 |
+
"""Strength of denoising from 0.0 to 1.0."""
|
60 |
+
|
61 |
+
batch_count: int = 1
|
62 |
+
"""Number of batches to render."""
|
63 |
+
batch_size: int = 1
|
64 |
+
"""Number of images per batch to render."""
|
65 |
+
|
66 |
+
base_size: int = 512
|
67 |
+
"""Native/base resolution of model used."""
|
68 |
+
max_size: int = 768
|
69 |
+
"""Max input resolution allowed to prevent image artifacts."""
|
70 |
+
tiling: bool = False
|
71 |
+
"""Whether to generate a tileable image."""
|
72 |
+
highres_fix: bool = False
|
73 |
+
"""Whether to enable workaround for higher resolution at cost of time."""
|
74 |
+
firstphase_height: int = 512
|
75 |
+
"""Max height for first phase of highres fix (before upscaling to requested resolution)."""
|
76 |
+
firstphase_width: int = 512
|
77 |
+
"""Max width for first phase of highres fix (before upscaling to requested resolution)."""
|
78 |
+
|
79 |
+
# upscale_overlap: int = 64
|
80 |
+
# """Size of overlap in pixels for upscaling.""" Configure this in WebUI
|
81 |
+
upscaler_name: str = "None"
|
82 |
+
"""Exact name of upscaler to use."""
|
83 |
+
filter_nsfw: bool = False
|
84 |
+
"""filter NSFW content."""
|
85 |
+
|
86 |
+
include_grid: bool = False
|
87 |
+
"""Whether to include the image grid in the results sent to Krita"""
|
88 |
+
|
89 |
+
|
90 |
+
class SamplerParamOptions(BaseModel):
|
91 |
+
# TODO: More conveniently expose config options for samplers/explain them.
|
92 |
+
pass
|
93 |
+
|
94 |
+
|
95 |
+
class FaceRestorationOptions(BaseModel):
|
96 |
+
restore_faces: bool = False
|
97 |
+
"""Whether to use GFPGAN for face restoration."""
|
98 |
+
face_restorer: str = "CodeFormer"
|
99 |
+
"""Exact name of face restorer to use."""
|
100 |
+
codeformer_weight: float = 0.5
|
101 |
+
"""Strength of face restoration if using CodeFormer. 0.0 is the strongest and 1.0 is the weakest."""
|
102 |
+
|
103 |
+
|
104 |
+
class InpaintingOptions(BaseModel):
|
105 |
+
inpainting_fill: int = 1
|
106 |
+
"""What to fill inpainted region with. 0 is blur/fill, 1 is original, 2 is latent noise, and 3 is latent empty."""
|
107 |
+
inpaint_full_res: bool = False
|
108 |
+
"""(DISABLED) Whether to use the full resolution for inpainting."""
|
109 |
+
inpaint_full_res_padding: int = 0
|
110 |
+
"""(DISABLED) Padding when using full resolution for inpainting."""
|
111 |
+
mask_blur: int = 0
|
112 |
+
"""(DISABLED) Size of blur at boundaries of mask."""
|
113 |
+
invert_mask: bool = False
|
114 |
+
"""Whether to invert the mask."""
|
115 |
+
inpaint_mask_weight: float = 1.0
|
116 |
+
"""Mask weight for specialized inpainting models."""
|
117 |
+
|
118 |
+
|
119 |
+
class Txt2ImgOptions(BaseOptions, GenerationOptions, FaceRestorationOptions):
|
120 |
+
pass
|
121 |
+
|
122 |
+
|
123 |
+
class Img2ImgOptions(
|
124 |
+
BaseOptions, GenerationOptions, InpaintingOptions, FaceRestorationOptions
|
125 |
+
):
|
126 |
+
mode: int = 0
|
127 |
+
"""Img2Img mode. 0 is normal img2img on the selected region, 1 is inpainting, and 2 (unsupported) is batch processing."""
|
128 |
+
resize_mode: int = 1
|
129 |
+
"""Unused by Krita plugin since rescaling is done by us. 0 is stretch to fit, 1 is cover, 2 is contain."""
|
130 |
+
|
131 |
+
steps: int = 50
|
132 |
+
|
133 |
+
color_correct: bool = True
|
134 |
+
"""Apply color correction after img2img/inpaint to match original & blend better."""
|
135 |
+
do_exact_steps: bool = True
|
136 |
+
"""Do exactly the number of steps specified by the slider instead of less during img2img/inpaint."""
|
137 |
+
|
138 |
+
|
139 |
+
class UpscaleOptions(BaseOptions):
|
140 |
+
upscaler_name: str = "None"
|
141 |
+
"""Exact name of upscaler to use."""
|
142 |
+
downscale_first: bool = False
|
143 |
+
"""Whether to downscale the image by x0.5 first."""
|
144 |
+
|
145 |
+
|
146 |
+
class PluginOptions(BaseOptions):
|
147 |
+
sample_path: str = "outputs/krita-in"
|
148 |
+
|
149 |
+
|
150 |
+
class MainConfig(BaseModel):
|
151 |
+
txt2img: Txt2ImgOptions = Txt2ImgOptions()
|
152 |
+
img2img: Img2ImgOptions = Img2ImgOptions()
|
153 |
+
upscale: UpscaleOptions = UpscaleOptions()
|
154 |
+
plugin: PluginOptions = PluginOptions()
|
backend/script_hack.py
ADDED
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
"""
|
2 |
+
Attempting to map Gradio UI elements present in scripts to allow
|
3 |
+
converting to pyQt elements on the plugin side.
|
4 |
+
"""
|
5 |
+
|
6 |
+
import logging
|
7 |
+
from typing import List, Sequence, Tuple
|
8 |
+
|
9 |
+
import gradio as gr
|
10 |
+
import modules
|
11 |
+
|
12 |
+
from .config import LOGGER_NAME
|
13 |
+
|
14 |
+
log = logging.getLogger(LOGGER_NAME)
|
15 |
+
|
16 |
+
|
17 |
+
def inspect_ui(script: modules.scripts.Script, is_img2img: bool):
|
18 |
+
"""Get metadata about accepted arguments by inspecting GUI. Needs Gradio Blocks context."""
|
19 |
+
elems = script.ui(is_img2img)
|
20 |
+
|
21 |
+
metadata = []
|
22 |
+
if not isinstance(elems, Sequence):
|
23 |
+
return metadata
|
24 |
+
|
25 |
+
for elem in elems:
|
26 |
+
data = {
|
27 |
+
"type": "None",
|
28 |
+
"label": elem.label,
|
29 |
+
"val": elem.value,
|
30 |
+
"is_index": False,
|
31 |
+
}
|
32 |
+
if isinstance(elem, gr.HTML):
|
33 |
+
data.update(val="")
|
34 |
+
elif isinstance(elem, gr.Markdown):
|
35 |
+
data.update(val="")
|
36 |
+
elif isinstance(elem, gr.Slider):
|
37 |
+
data.update(
|
38 |
+
type="range",
|
39 |
+
min=elem.minimum,
|
40 |
+
max=elem.maximum,
|
41 |
+
step=elem.step,
|
42 |
+
)
|
43 |
+
elif isinstance(elem, gr.Radio):
|
44 |
+
data.update(
|
45 |
+
type="combo",
|
46 |
+
is_index=elem.type == "index",
|
47 |
+
opts=elem.choices,
|
48 |
+
)
|
49 |
+
elif isinstance(elem, gr.Dropdown):
|
50 |
+
data.update(
|
51 |
+
type="combo",
|
52 |
+
is_index=elem.type == "index",
|
53 |
+
opts=elem.choices,
|
54 |
+
)
|
55 |
+
elif isinstance(elem, gr.Textbox):
|
56 |
+
data.update(
|
57 |
+
type="text",
|
58 |
+
)
|
59 |
+
elif isinstance(elem, gr.Checkbox):
|
60 |
+
data.update(
|
61 |
+
type="checkbox",
|
62 |
+
)
|
63 |
+
elif isinstance(elem, gr.CheckboxGroup):
|
64 |
+
data.update(
|
65 |
+
type="multiselect",
|
66 |
+
is_index=elem.type == "index",
|
67 |
+
opts=elem.choices,
|
68 |
+
)
|
69 |
+
elif isinstance(elem, gr.File):
|
70 |
+
data.update(val="") # unsupported
|
71 |
+
else:
|
72 |
+
data.update(val="") # unsupported
|
73 |
+
metadata.append(data)
|
74 |
+
|
75 |
+
return metadata
|
76 |
+
|
77 |
+
|
78 |
+
img2img_script_meta = None
|
79 |
+
txt2img_script_meta = None
|
80 |
+
|
81 |
+
|
82 |
+
def get_scripts_metadata(is_img2img: bool):
|
83 |
+
"""Get metadata about accepted arguments for scripts."""
|
84 |
+
# NOTE: inspect_ui is quite slow, so cache this
|
85 |
+
global txt2img_script_meta, img2img_script_meta
|
86 |
+
if is_img2img:
|
87 |
+
runner = modules.scripts.scripts_img2img
|
88 |
+
else:
|
89 |
+
runner = modules.scripts.scripts_txt2img
|
90 |
+
metadata = {"None": []}
|
91 |
+
if (
|
92 |
+
is_img2img
|
93 |
+
and img2img_script_meta
|
94 |
+
and len(img2img_script_meta) - 1 == len(runner.titles)
|
95 |
+
):
|
96 |
+
return img2img_script_meta
|
97 |
+
elif txt2img_script_meta and len(txt2img_script_meta) - 1 == len(runner.titles):
|
98 |
+
return txt2img_script_meta
|
99 |
+
|
100 |
+
with gr.Blocks(visible=False, analytics_enabled=False):
|
101 |
+
for name, script in zip(runner.titles, runner.selectable_scripts):
|
102 |
+
metadata[name] = inspect_ui(script, is_img2img)
|
103 |
+
if is_img2img:
|
104 |
+
img2img_script_meta = metadata
|
105 |
+
else:
|
106 |
+
txt2img_script_meta = metadata
|
107 |
+
return metadata
|
108 |
+
|
109 |
+
|
110 |
+
def get_script_info(
|
111 |
+
script_name: str, is_img2img: bool
|
112 |
+
) -> Tuple[int, modules.scripts.Script, List[dict]]:
|
113 |
+
"""Get index of script, script instance and argument metadata by name.
|
114 |
+
|
115 |
+
Args:
|
116 |
+
script_name (str): Exact name of script.
|
117 |
+
is_img2img (bool): Whether the script is for img2img or txt2img.
|
118 |
+
|
119 |
+
Raises:
|
120 |
+
KeyError: Script cannot be found.
|
121 |
+
|
122 |
+
Returns:
|
123 |
+
Tuple[int, Script, List[dict]]: Index of script, script itself and arguments metadata.
|
124 |
+
"""
|
125 |
+
if is_img2img:
|
126 |
+
runner = modules.scripts.scripts_img2img
|
127 |
+
else:
|
128 |
+
runner = modules.scripts.scripts_txt2img
|
129 |
+
# in API, index 0 means no script, scripts are indexed from 1 onwards
|
130 |
+
names = ["None"] + runner.titles
|
131 |
+
if script_name == "None":
|
132 |
+
return 0, None, []
|
133 |
+
for i, n in enumerate(names):
|
134 |
+
if n == script_name:
|
135 |
+
script = runner.selectable_scripts[i - 1]
|
136 |
+
return i, script, get_scripts_metadata(is_img2img)[n]
|
137 |
+
raise KeyError(f"script not found for type {type}: {script_name}")
|
138 |
+
|
139 |
+
|
140 |
+
def process_script_args(
|
141 |
+
script_ind: int, script: modules.scripts.Script, meta: List[dict], args: list
|
142 |
+
) -> list:
|
143 |
+
"""Get the position arguments required."""
|
144 |
+
if script is None:
|
145 |
+
return [0] # 0th element selects which script to use. 0 is None.
|
146 |
+
|
147 |
+
# convert strings back to indexes
|
148 |
+
for i, (o, arg) in enumerate(zip(meta, args)):
|
149 |
+
if o["is_index"]:
|
150 |
+
if isinstance(arg, list):
|
151 |
+
args[i] = [o["opts"].index(v) for v in arg]
|
152 |
+
else:
|
153 |
+
args[i] = o["opts"].index(arg)
|
154 |
+
|
155 |
+
log.info(
|
156 |
+
f"Script selected: {script.filename}, Args Range: [{script.args_from}:{script.args_to}]"
|
157 |
+
)
|
158 |
+
# pad the args like the internal API requires...
|
159 |
+
args = [script_ind] + [0] * (script.args_from - 1) + args
|
160 |
+
log.info(f"Script args:\n{args}")
|
161 |
+
return args
|
backend/structs.py
ADDED
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from __future__ import annotations
|
2 |
+
|
3 |
+
from typing import Dict, List, Optional
|
4 |
+
|
5 |
+
from pydantic import BaseModel
|
6 |
+
|
7 |
+
from .config import Img2ImgOptions, PluginOptions, Txt2ImgOptions, UpscaleOptions
|
8 |
+
from .utils import optional
|
9 |
+
|
10 |
+
|
11 |
+
@optional
|
12 |
+
class DefaultTxt2ImgOptions(Txt2ImgOptions):
|
13 |
+
pass
|
14 |
+
|
15 |
+
|
16 |
+
class Txt2ImgRequest(DefaultTxt2ImgOptions):
|
17 |
+
"""Text2Img API request. If optional attributes aren't set, the defaults
|
18 |
+
from `krita_config.yaml` will be used.
|
19 |
+
"""
|
20 |
+
|
21 |
+
orig_width: int
|
22 |
+
"""Requested image width."""
|
23 |
+
orig_height: int
|
24 |
+
"""Requested image height."""
|
25 |
+
|
26 |
+
|
27 |
+
@optional
|
28 |
+
class DefaultImg2ImgOptions(Img2ImgOptions):
|
29 |
+
pass
|
30 |
+
|
31 |
+
|
32 |
+
class Img2ImgRequest(DefaultImg2ImgOptions):
|
33 |
+
"""Img2Img API request. If optional attributes aren't set, the defaults from
|
34 |
+
`krita_config.yaml` will be used.
|
35 |
+
"""
|
36 |
+
|
37 |
+
src_img: str
|
38 |
+
"""Image being used."""
|
39 |
+
mask_img: Optional[str] = None
|
40 |
+
"""Image mask being used."""
|
41 |
+
|
42 |
+
|
43 |
+
@optional
|
44 |
+
class DefaultUpscaleOptions(UpscaleOptions):
|
45 |
+
pass
|
46 |
+
|
47 |
+
|
48 |
+
class UpscaleRequest(DefaultUpscaleOptions):
|
49 |
+
"""Upscale API request. If optional attributes aren't set, the defaults from
|
50 |
+
`krita_config.yaml` will be used.
|
51 |
+
"""
|
52 |
+
|
53 |
+
src_img: str
|
54 |
+
"""Image being used."""
|
55 |
+
|
56 |
+
|
57 |
+
class ConfigResponse(PluginOptions):
|
58 |
+
sample_path: str
|
59 |
+
"""Where the Krita plugin should save the selected region and mask."""
|
60 |
+
upscalers: List[str]
|
61 |
+
"""List of available upscalers."""
|
62 |
+
samplers: List[str]
|
63 |
+
"""List of available samplers."""
|
64 |
+
samplers_img2img: List[str]
|
65 |
+
"""List of available samplers specifically for img2img (upstream separated them for a reason)."""
|
66 |
+
scripts_txt2img: Dict[str, List[Dict]]
|
67 |
+
"""List of available txt2img scripts."""
|
68 |
+
scripts_img2img: Dict[str, List[Dict]]
|
69 |
+
"""List of available img2img scripts."""
|
70 |
+
face_restorers: List[str]
|
71 |
+
"""List of available face restorers."""
|
72 |
+
sd_models: List[str]
|
73 |
+
"""List of available models."""
|
74 |
+
|
75 |
+
|
76 |
+
class ImageResponse(BaseModel):
|
77 |
+
outputs: List[str]
|
78 |
+
"""List of generated images encoded in base64."""
|
79 |
+
info: str
|
80 |
+
"""Generation info already jsonified."""
|
81 |
+
|
82 |
+
|
83 |
+
class UpscaleResponse(BaseModel):
|
84 |
+
output: str
|
85 |
+
"""Upscaled image in base64."""
|
backend/utils.py
ADDED
@@ -0,0 +1,329 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from __future__ import annotations
|
2 |
+
|
3 |
+
import inspect
|
4 |
+
import logging
|
5 |
+
import os
|
6 |
+
import secrets
|
7 |
+
from base64 import b64decode, b64encode
|
8 |
+
from io import BytesIO
|
9 |
+
from itertools import cycle
|
10 |
+
from math import ceil
|
11 |
+
|
12 |
+
import modules
|
13 |
+
import yaml
|
14 |
+
from modules import shared
|
15 |
+
from PIL import Image
|
16 |
+
from pydantic import BaseModel
|
17 |
+
|
18 |
+
from .config import CONFIG_PATH, ENCRYPT_FILE, LOGGER_NAME, MainConfig
|
19 |
+
|
20 |
+
log = logging.getLogger(LOGGER_NAME)
|
21 |
+
|
22 |
+
|
23 |
+
def load_config():
|
24 |
+
"""Load default config (including those not exposed in the API yet) from
|
25 |
+
`CONFIG_PATH` in the current working directory.
|
26 |
+
|
27 |
+
Will create `CONFIG_PATH` if it has yet to exist using `MainConfig` from
|
28 |
+
`config.py`.
|
29 |
+
|
30 |
+
Returns:
|
31 |
+
MainConfig: config
|
32 |
+
"""
|
33 |
+
if not os.path.isfile(CONFIG_PATH):
|
34 |
+
cfg = MainConfig()
|
35 |
+
with open(CONFIG_PATH, "w") as f:
|
36 |
+
yaml.safe_dump(cfg.dict(), f)
|
37 |
+
|
38 |
+
with open(CONFIG_PATH) as file:
|
39 |
+
obj = yaml.safe_load(file)
|
40 |
+
return MainConfig.parse_obj(obj)
|
41 |
+
|
42 |
+
|
43 |
+
def merge_default_config(config: BaseModel, default: BaseModel):
|
44 |
+
"""Replace unset and None fields in opt with values from default with the
|
45 |
+
same field name in place.
|
46 |
+
|
47 |
+
Unset fields does not include fields that are explicitly set to None but
|
48 |
+
includes fields with a default value due to being unset.
|
49 |
+
|
50 |
+
Args:
|
51 |
+
config (BaseModel): Config object.
|
52 |
+
default (BaseModel): Default to merge from.
|
53 |
+
|
54 |
+
Returns:
|
55 |
+
BaseModel: Modified config.
|
56 |
+
"""
|
57 |
+
|
58 |
+
for field in config.__fields__:
|
59 |
+
if not field in config.__fields_set__ or field is None:
|
60 |
+
setattr(config, field, getattr(default, field, None))
|
61 |
+
|
62 |
+
return config
|
63 |
+
|
64 |
+
|
65 |
+
def prepare_backend(opt: BaseModel):
|
66 |
+
"""Misc configuration and preparation tasks before calling internal API.
|
67 |
+
|
68 |
+
Currently includes:
|
69 |
+
- Ensuring the output/input folders exist
|
70 |
+
- Set the global face restorer model to the selected one
|
71 |
+
- Set the global SD model to the selected one
|
72 |
+
- Set the global upscaler to the selected one
|
73 |
+
- Set other misc global webUI/backend settings
|
74 |
+
|
75 |
+
Args:
|
76 |
+
opt (BaseModel): Option/Request object
|
77 |
+
"""
|
78 |
+
# the `shared` module handles app state for the underlying codebase
|
79 |
+
|
80 |
+
if hasattr(opt, "face_restorer"):
|
81 |
+
shared.opts.face_restoration_model = opt.face_restorer
|
82 |
+
shared.opts.code_former_weight = opt.codeformer_weight
|
83 |
+
|
84 |
+
if hasattr(opt, "sd_model"):
|
85 |
+
shared.opts.sd_model_checkpoint = opt.sd_model
|
86 |
+
modules.sd_models.reload_model_weights(shared.sd_model)
|
87 |
+
|
88 |
+
if hasattr(opt, "upscaler_name"):
|
89 |
+
shared.opts.upscaler_for_img2img = opt.upscaler_name
|
90 |
+
|
91 |
+
if hasattr(opt, "color_correct"):
|
92 |
+
shared.opts.img2img_color_correction = opt.color_correct
|
93 |
+
shared.opts.img2img_fix_steps = opt.do_exact_steps
|
94 |
+
|
95 |
+
if hasattr(opt, "filter_nsfw"):
|
96 |
+
shared.opts.filter_nsfw = opt.filter_nsfw
|
97 |
+
|
98 |
+
if hasattr(opt, "inpaint_mask_weight"):
|
99 |
+
shared.opts.inpainting_mask_weight = opt.inpaint_mask_weight
|
100 |
+
|
101 |
+
# Ensure the output/input folders exist
|
102 |
+
if hasattr(opt, "sample_path"):
|
103 |
+
os.makedirs(opt.sample_path, exist_ok=True)
|
104 |
+
|
105 |
+
|
106 |
+
def optional(*fields):
|
107 |
+
"""Decorator function used to modify a pydantic model's fields to all be optional.
|
108 |
+
Alternatively, you can also pass the field names that should be made optional as arguments
|
109 |
+
to the decorator.
|
110 |
+
Taken from https://github.com/samuelcolvin/pydantic/issues/1223#issuecomment-775363074
|
111 |
+
"""
|
112 |
+
|
113 |
+
def dec(_cls):
|
114 |
+
for field in fields:
|
115 |
+
_cls.__fields__[field].required = False
|
116 |
+
return _cls
|
117 |
+
|
118 |
+
if fields and inspect.isclass(fields[0]) and issubclass(fields[0], BaseModel):
|
119 |
+
cls = fields[0]
|
120 |
+
fields = cls.__fields__
|
121 |
+
return dec(cls)
|
122 |
+
|
123 |
+
return dec
|
124 |
+
|
125 |
+
|
126 |
+
def save_img(image: Image.Image, sample_path: str, filename: str):
|
127 |
+
"""Saves an image.
|
128 |
+
|
129 |
+
Args:
|
130 |
+
image (Image): Image to save.
|
131 |
+
sample_path (str): Folder to save the image in.
|
132 |
+
filename (str): Name to save the image as.
|
133 |
+
|
134 |
+
Returns:
|
135 |
+
str: Absolute path where the image was saved.
|
136 |
+
"""
|
137 |
+
path = os.path.join(sample_path, filename)
|
138 |
+
image.save(path)
|
139 |
+
return os.path.abspath(path)
|
140 |
+
|
141 |
+
|
142 |
+
def img_to_b64(image: Image.Image):
|
143 |
+
"""Convert an image to base64-encoded string.
|
144 |
+
|
145 |
+
Args:
|
146 |
+
image (Image): Image to encode.
|
147 |
+
|
148 |
+
Returns:
|
149 |
+
str: Base64-encoded image.
|
150 |
+
"""
|
151 |
+
buf = BytesIO()
|
152 |
+
image.save(buf, format="png")
|
153 |
+
return b64encode(buf.getvalue()).decode("utf-8")
|
154 |
+
|
155 |
+
|
156 |
+
def b64_to_img(enc: str):
|
157 |
+
"""Convert base64-encoded string to image.
|
158 |
+
|
159 |
+
Args:
|
160 |
+
enc (str): Base64-encoded image.
|
161 |
+
|
162 |
+
Returns:
|
163 |
+
Image: Image.
|
164 |
+
"""
|
165 |
+
return Image.open(BytesIO(b64decode(enc)))
|
166 |
+
|
167 |
+
|
168 |
+
def sddebz_highres_fix(
|
169 |
+
base_size: int, max_size: int, orig_width: int, orig_height: int
|
170 |
+
):
|
171 |
+
"""Calculate an appropiate image resolution given the base input size of the
|
172 |
+
model and max input size allowed.
|
173 |
+
|
174 |
+
The max input size is due to how Stable Diffusion currently handles resolutions
|
175 |
+
larger than its base/native input size of 512, which can cause weird issues
|
176 |
+
such as duplicated features in the image. Hence, it is typically better to
|
177 |
+
render at a smaller appropiate resolution before using other methods to upscale
|
178 |
+
to the original resolution. Setting max_size to 512, matching the base_size,
|
179 |
+
imitates how the highres fix works.
|
180 |
+
|
181 |
+
Stable Diffusion also messes up for resolutions smaller than 512. In which case,
|
182 |
+
it is better to render at the base resolution before downscaling to the original.
|
183 |
+
|
184 |
+
This method requires less user input than the builtin highres fix, which uses
|
185 |
+
firstphase_width and firstphase_height.
|
186 |
+
|
187 |
+
The original plugin writer, @sddebz, wrote this. I modified it to `ceil`
|
188 |
+
instead of `round` to make selected region resizing easier in the plugin, and
|
189 |
+
to avoid rounding to 0.
|
190 |
+
|
191 |
+
Args:
|
192 |
+
base_size (int): Native/base input size of the model.
|
193 |
+
max_size (int): Max input size to accept.
|
194 |
+
orig_width (int): Original width requested.
|
195 |
+
orig_height (int): Original height requested.
|
196 |
+
|
197 |
+
Returns:
|
198 |
+
Tuple[int, int]: Appropiate (width, height) to use for the model.
|
199 |
+
"""
|
200 |
+
|
201 |
+
def rnd(r, x, z=64):
|
202 |
+
"""Scale dimension x with stride z while attempting to preserve aspect ratio r."""
|
203 |
+
return z * ceil(r * x / z)
|
204 |
+
|
205 |
+
ratio = orig_width / orig_height
|
206 |
+
|
207 |
+
# height is smaller dimension
|
208 |
+
if orig_width > orig_height:
|
209 |
+
width, height = rnd(ratio, base_size), base_size
|
210 |
+
if width > max_size:
|
211 |
+
width, height = max_size, rnd(1 / ratio, max_size)
|
212 |
+
# width is smaller dimension
|
213 |
+
else:
|
214 |
+
width, height = base_size, rnd(1 / ratio, base_size)
|
215 |
+
if height > max_size:
|
216 |
+
width, height = rnd(ratio, max_size), max_size
|
217 |
+
|
218 |
+
new_ratio = width / height
|
219 |
+
|
220 |
+
log.info(
|
221 |
+
f"img size: {orig_width}x{orig_height} -> {width}x{height}, "
|
222 |
+
f"aspect ratio: {ratio:.2f} -> {new_ratio:.2f}, {100 * (new_ratio - ratio) / ratio :.2f}% change"
|
223 |
+
)
|
224 |
+
return width, height
|
225 |
+
|
226 |
+
|
227 |
+
def parse_prompt(val):
|
228 |
+
"""Parse different representations of prompt/negative prompt.
|
229 |
+
|
230 |
+
Args:
|
231 |
+
val (Any): Prompt to parse.
|
232 |
+
|
233 |
+
Raises:
|
234 |
+
SyntaxError: Value of the prompt key cannot be parsed.
|
235 |
+
|
236 |
+
Returns:
|
237 |
+
str: Correctly formatted prompt.
|
238 |
+
"""
|
239 |
+
if val is None:
|
240 |
+
return ""
|
241 |
+
# Below cases are meant for prompts read from the yaml config
|
242 |
+
if isinstance(val, str):
|
243 |
+
return val
|
244 |
+
if isinstance(val, list):
|
245 |
+
return ", ".join(val)
|
246 |
+
if isinstance(val, dict):
|
247 |
+
prompt = ""
|
248 |
+
for item, weight in val.items():
|
249 |
+
if not prompt == "":
|
250 |
+
prompt += " "
|
251 |
+
if weight is None:
|
252 |
+
prompt += f"{item}"
|
253 |
+
else:
|
254 |
+
prompt += f"({item}:{weight})"
|
255 |
+
return prompt
|
256 |
+
raise SyntaxError(f"prompt field in {CONFIG_PATH} is invalid")
|
257 |
+
|
258 |
+
|
259 |
+
def get_sampler_index(sampler_name: str):
|
260 |
+
"""Get index of sampler by name.
|
261 |
+
|
262 |
+
Args:
|
263 |
+
sampler_name (str): Exact name of sampler.
|
264 |
+
|
265 |
+
Raises:
|
266 |
+
KeyError: Sampler cannot be found.
|
267 |
+
|
268 |
+
Returns:
|
269 |
+
int: Index of sampler.
|
270 |
+
"""
|
271 |
+
for index, sampler in enumerate(modules.sd_samplers.samplers):
|
272 |
+
if sampler_name == sampler.name or sampler_name in sampler.aliases:
|
273 |
+
return index
|
274 |
+
raise KeyError(f"sampler not found: {sampler_name}")
|
275 |
+
|
276 |
+
|
277 |
+
def get_upscaler_index(upscaler_name: str):
|
278 |
+
"""Get index of upscaler by name.
|
279 |
+
|
280 |
+
Args:
|
281 |
+
upscaler_name (str): Exact name of upscaler.
|
282 |
+
|
283 |
+
Raises:
|
284 |
+
KeyError: Upscaler cannot be found.
|
285 |
+
|
286 |
+
Returns:
|
287 |
+
int: Index of sampler.
|
288 |
+
"""
|
289 |
+
for index, upscaler in enumerate(shared.sd_upscalers):
|
290 |
+
if upscaler.name == upscaler_name:
|
291 |
+
return index
|
292 |
+
raise KeyError(f"upscaler not found: {upscaler_name}")
|
293 |
+
|
294 |
+
|
295 |
+
def prepare_mask(mask: Image.Image):
|
296 |
+
"""Prepare mask for usage.
|
297 |
+
|
298 |
+
Args:
|
299 |
+
mask (Image): mask.
|
300 |
+
|
301 |
+
Returns:
|
302 |
+
Image: The luminance mask.
|
303 |
+
"""
|
304 |
+
return mask.getchannel("A")
|
305 |
+
|
306 |
+
|
307 |
+
def bytewise_xor(msg: bytes, key: bytes):
|
308 |
+
"""Used for decrypting/encrypting request/response bodies."""
|
309 |
+
return bytes(v ^ k for v, k in zip(msg, cycle(key)))
|
310 |
+
|
311 |
+
|
312 |
+
def get_encrypt_key():
|
313 |
+
"""Read encryption key from file."""
|
314 |
+
try:
|
315 |
+
with open(ENCRYPT_FILE) as f:
|
316 |
+
return f.read().strip().encode("utf-8")
|
317 |
+
except:
|
318 |
+
if not os.path.exists(ENCRYPT_FILE):
|
319 |
+
log.warning(
|
320 |
+
f"Encryption key file doesn't exist at {os.path.abspath(ENCRYPT_FILE)}."
|
321 |
+
)
|
322 |
+
log.warning(f"Creating random encryption key.")
|
323 |
+
with open(ENCRYPT_FILE, "w") as f:
|
324 |
+
f.write(secrets.token_hex(16))
|
325 |
+
log.warning(
|
326 |
+
f"Key in {ENCRYPT_FILE} is completely optional. It can be used to encrypt messages between backend & Krita and is editable."
|
327 |
+
)
|
328 |
+
return get_encrypt_key()
|
329 |
+
return None
|
frontends/krita/krita_diff.desktop
ADDED
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
[Desktop Entry]
|
2 |
+
Type=Service
|
3 |
+
ServiceTypes=Krita/PythonPlugin
|
4 |
+
X-KDE-Library=krita_diff
|
5 |
+
X-Python-2-Compatible=false
|
6 |
+
X-Krita-Manual=manual.html
|
7 |
+
Name=Stable Diffusion Plugin
|
8 |
+
Comment=Expose the power of AUTOMATIC1111's Stable Diffusion fork for creating to your heart's desire.
|
frontends/krita/krita_diff/__init__.py
ADDED
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from krita import DockWidgetFactory, DockWidgetFactoryBase, Krita
|
2 |
+
|
3 |
+
from .defaults import (
|
4 |
+
TAB_CONFIG,
|
5 |
+
TAB_IMG2IMG,
|
6 |
+
TAB_INPAINT,
|
7 |
+
TAB_SDCOMMON,
|
8 |
+
TAB_TXT2IMG,
|
9 |
+
TAB_UPSCALE,
|
10 |
+
)
|
11 |
+
from .docker import create_docker
|
12 |
+
from .extension import SDPluginExtension
|
13 |
+
from .pages import (
|
14 |
+
ConfigPage,
|
15 |
+
Img2ImgPage,
|
16 |
+
InpaintPage,
|
17 |
+
SDCommonPage,
|
18 |
+
Txt2ImgPage,
|
19 |
+
UpscalePage,
|
20 |
+
)
|
21 |
+
from .script import script
|
22 |
+
from .utils import reset_docker_layout
|
23 |
+
|
24 |
+
instance = Krita.instance()
|
25 |
+
instance.addExtension(SDPluginExtension(instance))
|
26 |
+
instance.addDockWidgetFactory(
|
27 |
+
DockWidgetFactory(
|
28 |
+
TAB_SDCOMMON,
|
29 |
+
DockWidgetFactoryBase.DockLeft,
|
30 |
+
create_docker(SDCommonPage),
|
31 |
+
)
|
32 |
+
)
|
33 |
+
instance.addDockWidgetFactory(
|
34 |
+
DockWidgetFactory(
|
35 |
+
TAB_TXT2IMG,
|
36 |
+
DockWidgetFactoryBase.DockLeft,
|
37 |
+
create_docker(Txt2ImgPage),
|
38 |
+
)
|
39 |
+
)
|
40 |
+
instance.addDockWidgetFactory(
|
41 |
+
DockWidgetFactory(
|
42 |
+
TAB_IMG2IMG,
|
43 |
+
DockWidgetFactoryBase.DockLeft,
|
44 |
+
create_docker(Img2ImgPage),
|
45 |
+
)
|
46 |
+
)
|
47 |
+
instance.addDockWidgetFactory(
|
48 |
+
DockWidgetFactory(
|
49 |
+
TAB_INPAINT,
|
50 |
+
DockWidgetFactoryBase.DockLeft,
|
51 |
+
create_docker(InpaintPage),
|
52 |
+
)
|
53 |
+
)
|
54 |
+
instance.addDockWidgetFactory(
|
55 |
+
DockWidgetFactory(
|
56 |
+
TAB_UPSCALE,
|
57 |
+
DockWidgetFactoryBase.DockLeft,
|
58 |
+
create_docker(UpscalePage),
|
59 |
+
)
|
60 |
+
)
|
61 |
+
instance.addDockWidgetFactory(
|
62 |
+
DockWidgetFactory(
|
63 |
+
TAB_CONFIG,
|
64 |
+
DockWidgetFactoryBase.DockLeft,
|
65 |
+
create_docker(ConfigPage),
|
66 |
+
)
|
67 |
+
)
|
68 |
+
|
69 |
+
|
70 |
+
# dumb workaround to ensure its only created once
|
71 |
+
if script.cfg("first_setup", bool):
|
72 |
+
instance.notifier().windowCreated.connect(reset_docker_layout)
|
73 |
+
script.cfg.set("first_setup", False)
|
frontends/krita/krita_diff/client.py
ADDED
@@ -0,0 +1,402 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import json
|
2 |
+
import socket
|
3 |
+
from typing import Any, Dict, List
|
4 |
+
from urllib.error import URLError
|
5 |
+
from urllib.parse import urljoin, urlparse
|
6 |
+
from urllib.request import Request, urlopen
|
7 |
+
|
8 |
+
from krita import QObject, QThread, pyqtSignal
|
9 |
+
|
10 |
+
from .config import Config
|
11 |
+
from .defaults import (
|
12 |
+
ERR_BAD_URL,
|
13 |
+
ERR_NO_CONNECTION,
|
14 |
+
LONG_TIMEOUT,
|
15 |
+
OFFICIAL_ROUTE_PREFIX,
|
16 |
+
ROUTE_PREFIX,
|
17 |
+
SHORT_TIMEOUT,
|
18 |
+
STATE_DONE,
|
19 |
+
STATE_READY,
|
20 |
+
STATE_URLERROR,
|
21 |
+
THREADED,
|
22 |
+
)
|
23 |
+
from .utils import bytewise_xor, fix_prompt, get_ext_args, get_ext_key, img_to_b64
|
24 |
+
|
25 |
+
# NOTE: backend queues up responses, so no explicit need to block multiple requests
|
26 |
+
# except to prevent user from spamming themselves
|
27 |
+
|
28 |
+
# TODO: tab showing all queued up requests (local plugin instance only)
|
29 |
+
|
30 |
+
|
31 |
+
def get_url(cfg: Config, route: str = ..., prefix: str = ROUTE_PREFIX):
|
32 |
+
base = cfg("base_url", str)
|
33 |
+
if not urlparse(base).scheme in {"http", "https"}:
|
34 |
+
return None
|
35 |
+
url = urljoin(base, prefix)
|
36 |
+
if route is not ...:
|
37 |
+
url = urljoin(url, route)
|
38 |
+
# print("url:", url)
|
39 |
+
return url
|
40 |
+
|
41 |
+
|
42 |
+
# krita doesn't reexport QtNetwork
|
43 |
+
class AsyncRequest(QObject):
|
44 |
+
timeout = None
|
45 |
+
finished = pyqtSignal()
|
46 |
+
result = pyqtSignal(object)
|
47 |
+
error = pyqtSignal(Exception)
|
48 |
+
|
49 |
+
def __init__(
|
50 |
+
self,
|
51 |
+
url: str,
|
52 |
+
data: Any = None,
|
53 |
+
timeout: int = ...,
|
54 |
+
method: str = ...,
|
55 |
+
headers: dict = ...,
|
56 |
+
key: str = None,
|
57 |
+
):
|
58 |
+
"""Create an AsyncRequest object.
|
59 |
+
|
60 |
+
By default, AsyncRequest has no timeout, will infer whether it is "POST"
|
61 |
+
or "GET" based on the presence of `data` and uses JSON to transmit. It
|
62 |
+
also assumes the response is JSON.
|
63 |
+
|
64 |
+
Args:
|
65 |
+
url (str): URL to request from.
|
66 |
+
data (Any, optional): Payload to send. Defaults to None.
|
67 |
+
timeout (int, optional): Timeout for request. Defaults to `...`.
|
68 |
+
method (str, optional): Which HTTP method to use. Defaults to `...`.
|
69 |
+
key (Union[str, None], Optional): Key to use for encryption/decryption. Defaults to None.
|
70 |
+
"""
|
71 |
+
super(AsyncRequest, self).__init__()
|
72 |
+
self.url = url
|
73 |
+
self.data = None if data is None else json.dumps(data).encode("utf-8")
|
74 |
+
self.headers = {} if headers is ... else headers
|
75 |
+
|
76 |
+
self.key = None
|
77 |
+
if isinstance(key, str) and key.strip() != "":
|
78 |
+
self.key = key.strip().encode("utf-8")
|
79 |
+
|
80 |
+
if self.key is not None:
|
81 |
+
self.headers["X-Encrypted-Body"] = "XOR"
|
82 |
+
if timeout is not ...:
|
83 |
+
self.timeout = timeout
|
84 |
+
if method is ...:
|
85 |
+
self.method = "GET" if data is None else "POST"
|
86 |
+
else:
|
87 |
+
self.method = method
|
88 |
+
if self.data is not None:
|
89 |
+
if self.key is not None:
|
90 |
+
# print(f"Encrypting with ${self.key}:\n{self.data}")
|
91 |
+
self.data = bytewise_xor(self.data, self.key)
|
92 |
+
# print(f"Encrypt Result:\n{self.data}")
|
93 |
+
self.headers["Content-Type"] = "application/json"
|
94 |
+
self.headers["Content-Length"] = str(len(self.data))
|
95 |
+
|
96 |
+
def run(self):
|
97 |
+
req = Request(self.url, headers=self.headers, method=self.method)
|
98 |
+
try:
|
99 |
+
with urlopen(req, self.data, self.timeout) as res:
|
100 |
+
data = res.read()
|
101 |
+
enc_type = res.getheader("X-Encrypted-Body", None)
|
102 |
+
assert enc_type in {"XOR", None}, "Unknown server encryption!"
|
103 |
+
if enc_type == "XOR":
|
104 |
+
assert self.key, f"Key needed to decrypt server response!"
|
105 |
+
# print(f"Decrypting with ${self.key}:\n{data}")
|
106 |
+
data = bytewise_xor(data, self.key)
|
107 |
+
# print(f"Decrypt Result:\n{data}")
|
108 |
+
self.result.emit(json.loads(data))
|
109 |
+
except Exception as e:
|
110 |
+
self.error.emit(e)
|
111 |
+
finally:
|
112 |
+
self.finished.emit()
|
113 |
+
|
114 |
+
@classmethod
|
115 |
+
def request(cls, *args, **kwargs):
|
116 |
+
req = cls(*args, **kwargs)
|
117 |
+
if THREADED:
|
118 |
+
thread = QThread()
|
119 |
+
# NOTE: need to keep reference to thread or it gets destroyed
|
120 |
+
req.thread = thread
|
121 |
+
req.moveToThread(thread)
|
122 |
+
thread.started.connect(req.run)
|
123 |
+
req.finished.connect(thread.quit)
|
124 |
+
# NOTE: is this a memory leak?
|
125 |
+
# For some reason, deleteLater occurs while thread is still running, resulting in crash
|
126 |
+
# req.finished.connect(req.deleteLater)
|
127 |
+
# thread.finished.connect(thread.deleteLater)
|
128 |
+
return req, lambda: thread.start()
|
129 |
+
else:
|
130 |
+
return req, lambda: req.run()
|
131 |
+
|
132 |
+
|
133 |
+
class Client(QObject):
|
134 |
+
status = pyqtSignal(str)
|
135 |
+
config_updated = pyqtSignal()
|
136 |
+
|
137 |
+
def __init__(self, cfg: Config, ext_cfg: Config):
|
138 |
+
"""It is highly dependent on config's structure to the point it writes directly to it. :/"""
|
139 |
+
super(Client, self).__init__()
|
140 |
+
self.cfg = cfg
|
141 |
+
self.ext_cfg = ext_cfg
|
142 |
+
self.short_reqs = set()
|
143 |
+
self.long_reqs = set()
|
144 |
+
# NOTE: this is a hacky workaround for detecting if backend is reachable
|
145 |
+
self.is_connected = False
|
146 |
+
|
147 |
+
def handle_api_error(self, exc: Exception):
|
148 |
+
"""Handle exceptions that can occur while interacting with the backend."""
|
149 |
+
self.is_connected = False
|
150 |
+
try:
|
151 |
+
# wtf python? socket raises an error that isnt an Exception??
|
152 |
+
if isinstance(exc, socket.timeout):
|
153 |
+
raise TimeoutError
|
154 |
+
else:
|
155 |
+
raise exc
|
156 |
+
except URLError as e:
|
157 |
+
self.status.emit(f"{STATE_URLERROR}: {e.reason}")
|
158 |
+
except TimeoutError as e:
|
159 |
+
self.status.emit(f"{STATE_URLERROR}: response timed out")
|
160 |
+
except json.JSONDecodeError as e:
|
161 |
+
self.status.emit(f"{STATE_URLERROR}: invalid JSON response")
|
162 |
+
except ValueError as e:
|
163 |
+
self.status.emit(f"{STATE_URLERROR}: Invalid backend URL")
|
164 |
+
except ConnectionError as e:
|
165 |
+
self.status.emit(f"{STATE_URLERROR}: connection error during request")
|
166 |
+
except Exception as e:
|
167 |
+
# self.status.emit(f"{STATE_URLERROR}: Unexpected Error")
|
168 |
+
# self.status.emit(str(e))
|
169 |
+
assert False, e
|
170 |
+
|
171 |
+
def post(
|
172 |
+
self, route, body, cb, base_url=..., is_long=True, ignore_no_connection=False
|
173 |
+
):
|
174 |
+
if not ignore_no_connection and not self.is_connected:
|
175 |
+
self.status.emit(ERR_NO_CONNECTION)
|
176 |
+
return
|
177 |
+
url = get_url(self.cfg, route) if base_url is ... else urljoin(base_url, route)
|
178 |
+
if not url:
|
179 |
+
self.status.emit(ERR_BAD_URL)
|
180 |
+
return
|
181 |
+
# TODO: how to cancel this? destroy the thread after sending API interrupt request?
|
182 |
+
req, start = AsyncRequest.request(
|
183 |
+
url,
|
184 |
+
body,
|
185 |
+
LONG_TIMEOUT if is_long else SHORT_TIMEOUT,
|
186 |
+
key=self.cfg("encryption_key"),
|
187 |
+
)
|
188 |
+
|
189 |
+
if is_long:
|
190 |
+
self.long_reqs.add(req)
|
191 |
+
else:
|
192 |
+
self.short_reqs.add(req)
|
193 |
+
|
194 |
+
def handler():
|
195 |
+
self.long_reqs.discard(req)
|
196 |
+
self.short_reqs.discard(req)
|
197 |
+
if is_long and len(self.long_reqs) == 0:
|
198 |
+
self.status.emit(STATE_DONE)
|
199 |
+
|
200 |
+
req.result.connect(cb)
|
201 |
+
req.error.connect(lambda e: self.handle_api_error(e))
|
202 |
+
req.finished.connect(handler)
|
203 |
+
start()
|
204 |
+
|
205 |
+
def get(self, route, cb, base_url=..., is_long=False, ignore_no_connection=False):
|
206 |
+
self.post(
|
207 |
+
route,
|
208 |
+
None,
|
209 |
+
cb,
|
210 |
+
base_url=base_url,
|
211 |
+
is_long=is_long,
|
212 |
+
ignore_no_connection=ignore_no_connection,
|
213 |
+
)
|
214 |
+
|
215 |
+
def common_params(self, has_selection):
|
216 |
+
"""Parameters nearly all the post routes share."""
|
217 |
+
tiling = self.cfg("sd_tiling", bool) and not (
|
218 |
+
self.cfg("only_full_img_tiling", bool) and has_selection
|
219 |
+
)
|
220 |
+
|
221 |
+
# its fine to stuff extra stuff here; pydantic will shave off irrelevant params
|
222 |
+
params = dict(
|
223 |
+
sd_model=self.cfg("sd_model", str),
|
224 |
+
batch_count=self.cfg("sd_batch_count", int),
|
225 |
+
batch_size=self.cfg("sd_batch_size", int),
|
226 |
+
base_size=self.cfg("sd_base_size", int),
|
227 |
+
max_size=self.cfg("sd_max_size", int),
|
228 |
+
tiling=tiling,
|
229 |
+
upscaler_name=self.cfg("upscaler_name", str),
|
230 |
+
restore_faces=self.cfg("face_restorer_model", str) != "None",
|
231 |
+
face_restorer=self.cfg("face_restorer_model", str),
|
232 |
+
codeformer_weight=self.cfg("codeformer_weight", float),
|
233 |
+
filter_nsfw=self.cfg("filter_nsfw", bool),
|
234 |
+
do_exact_steps=self.cfg("do_exact_steps", bool),
|
235 |
+
include_grid=self.cfg("include_grid", bool),
|
236 |
+
save_samples=self.cfg("save_temp_images", bool),
|
237 |
+
)
|
238 |
+
return params
|
239 |
+
|
240 |
+
def get_config(self):
|
241 |
+
def cb(obj):
|
242 |
+
try:
|
243 |
+
assert "sample_path" in obj
|
244 |
+
assert len(obj["upscalers"]) > 0
|
245 |
+
assert len(obj["samplers"]) > 0
|
246 |
+
assert len(obj["samplers_img2img"]) > 0
|
247 |
+
assert len(obj["face_restorers"]) > 0
|
248 |
+
assert len(obj["sd_models"]) > 0
|
249 |
+
assert len(obj["scripts_txt2img"]) > 0
|
250 |
+
assert len(obj["scripts_img2img"]) > 0
|
251 |
+
except:
|
252 |
+
self.status.emit(
|
253 |
+
f"{STATE_URLERROR}: incompatible response, are you running the right API?"
|
254 |
+
)
|
255 |
+
print("Invalid Response:\n", obj)
|
256 |
+
return
|
257 |
+
|
258 |
+
# replace only after verifying
|
259 |
+
self.cfg.set("sample_path", obj["sample_path"])
|
260 |
+
# NOTE: sorting these lists is risky; ivent 100% verified that I removed all reliance on indexes
|
261 |
+
self.cfg.set("upscaler_list", obj["upscalers"])
|
262 |
+
self.cfg.set("txt2img_sampler_list", obj["samplers"])
|
263 |
+
self.cfg.set("img2img_sampler_list", obj["samplers_img2img"])
|
264 |
+
self.cfg.set("inpaint_sampler_list", obj["samplers_img2img"])
|
265 |
+
self.cfg.set("txt2img_script_list", list(obj["scripts_txt2img"].keys()))
|
266 |
+
self.cfg.set("img2img_script_list", list(obj["scripts_img2img"].keys()))
|
267 |
+
self.cfg.set("inpaint_script_list", list(obj["scripts_img2img"].keys()))
|
268 |
+
self.cfg.set("face_restorer_model_list", obj["face_restorers"])
|
269 |
+
self.cfg.set("sd_model_list", obj["sd_models"])
|
270 |
+
|
271 |
+
# extension script cfg
|
272 |
+
obj["scripts_inpaint"] = obj["scripts_img2img"]
|
273 |
+
for ext_type in {"scripts_txt2img", "scripts_img2img", "scripts_inpaint"}:
|
274 |
+
metadata: Dict[str, List[dict]] = obj[ext_type]
|
275 |
+
self.ext_cfg.set(f"{ext_type}_len", len(metadata))
|
276 |
+
for ext_name, ext_meta in metadata.items():
|
277 |
+
old_val = self.ext_cfg(get_ext_key(ext_type, ext_name))
|
278 |
+
new_val = json.dumps(ext_meta)
|
279 |
+
if new_val != old_val:
|
280 |
+
self.ext_cfg.set(get_ext_key(ext_type, ext_name), new_val)
|
281 |
+
for i, opt in enumerate(ext_meta):
|
282 |
+
key = get_ext_key(ext_type, ext_name, i)
|
283 |
+
self.ext_cfg.set(key, opt["val"])
|
284 |
+
|
285 |
+
self.is_connected = True
|
286 |
+
self.status.emit(STATE_READY)
|
287 |
+
self.config_updated.emit()
|
288 |
+
|
289 |
+
self.get("config", cb, ignore_no_connection=True)
|
290 |
+
|
291 |
+
def post_txt2img(self, cb, width, height, has_selection):
|
292 |
+
params = dict(orig_width=width, orig_height=height)
|
293 |
+
if not self.cfg("just_use_yaml", bool):
|
294 |
+
seed = (
|
295 |
+
int(self.cfg("txt2img_seed", str)) # Qt casts int as 32-bit int
|
296 |
+
if not self.cfg("txt2img_seed", str).strip() == ""
|
297 |
+
else -1
|
298 |
+
)
|
299 |
+
ext_name = self.cfg("txt2img_script", str)
|
300 |
+
ext_args = get_ext_args(self.ext_cfg, "scripts_txt2img", ext_name)
|
301 |
+
params.update(self.common_params(has_selection))
|
302 |
+
params.update(
|
303 |
+
prompt=fix_prompt(self.cfg("txt2img_prompt", str)),
|
304 |
+
negative_prompt=fix_prompt(self.cfg("txt2img_negative_prompt", str)),
|
305 |
+
sampler_name=self.cfg("txt2img_sampler", str),
|
306 |
+
steps=self.cfg("txt2img_steps", int),
|
307 |
+
cfg_scale=self.cfg("txt2img_cfg_scale", float),
|
308 |
+
seed=seed,
|
309 |
+
highres_fix=self.cfg("txt2img_highres", bool),
|
310 |
+
denoising_strength=self.cfg("txt2img_denoising_strength", float),
|
311 |
+
script=ext_name,
|
312 |
+
script_args=ext_args,
|
313 |
+
)
|
314 |
+
|
315 |
+
self.post("txt2img", params, cb)
|
316 |
+
|
317 |
+
def post_img2img(self, cb, src_img, mask_img, has_selection):
|
318 |
+
params = dict(mode=0, src_img=img_to_b64(src_img))
|
319 |
+
if not self.cfg("just_use_yaml", bool):
|
320 |
+
seed = (
|
321 |
+
int(self.cfg("img2img_seed", str)) # Qt casts int as 32-bit int
|
322 |
+
if not self.cfg("img2img_seed", str).strip() == ""
|
323 |
+
else -1
|
324 |
+
)
|
325 |
+
ext_name = self.cfg("img2img_script", str)
|
326 |
+
ext_args = get_ext_args(self.ext_cfg, "scripts_img2img", ext_name)
|
327 |
+
params.update(self.common_params(has_selection))
|
328 |
+
params.update(
|
329 |
+
prompt=fix_prompt(self.cfg("img2img_prompt", str)),
|
330 |
+
negative_prompt=fix_prompt(self.cfg("img2img_negative_prompt", str)),
|
331 |
+
sampler_name=self.cfg("img2img_sampler", str),
|
332 |
+
steps=self.cfg("img2img_steps", int),
|
333 |
+
cfg_scale=self.cfg("img2img_cfg_scale", float),
|
334 |
+
denoising_strength=self.cfg("img2img_denoising_strength", float),
|
335 |
+
color_correct=self.cfg("img2img_color_correct", bool),
|
336 |
+
script=ext_name,
|
337 |
+
script_args=ext_args,
|
338 |
+
seed=seed,
|
339 |
+
)
|
340 |
+
|
341 |
+
self.post("img2img", params, cb)
|
342 |
+
|
343 |
+
def post_inpaint(self, cb, src_img, mask_img, has_selection):
|
344 |
+
assert mask_img, "Inpaint layer is needed for inpainting!"
|
345 |
+
params = dict(
|
346 |
+
mode=1, src_img=img_to_b64(src_img), mask_img=img_to_b64(mask_img)
|
347 |
+
)
|
348 |
+
if not self.cfg("just_use_yaml", bool):
|
349 |
+
seed = (
|
350 |
+
int(self.cfg("inpaint_seed", str)) # Qt casts int as 32-bit int
|
351 |
+
if not self.cfg("inpaint_seed", str).strip() == ""
|
352 |
+
else -1
|
353 |
+
)
|
354 |
+
fill = self.cfg("inpaint_fill_list", "QStringList").index(
|
355 |
+
self.cfg("inpaint_fill", str)
|
356 |
+
)
|
357 |
+
ext_name = self.cfg("inpaint_script", str)
|
358 |
+
ext_args = get_ext_args(self.ext_cfg, "scripts_inpaint", ext_name)
|
359 |
+
params.update(self.common_params(has_selection))
|
360 |
+
params.update(
|
361 |
+
prompt=fix_prompt(self.cfg("inpaint_prompt", str)),
|
362 |
+
negative_prompt=fix_prompt(self.cfg("inpaint_negative_prompt", str)),
|
363 |
+
sampler_name=self.cfg("inpaint_sampler", str),
|
364 |
+
steps=self.cfg("inpaint_steps", int),
|
365 |
+
cfg_scale=self.cfg("inpaint_cfg_scale", float),
|
366 |
+
denoising_strength=self.cfg("inpaint_denoising_strength", float),
|
367 |
+
color_correct=self.cfg("inpaint_color_correct", bool),
|
368 |
+
script=ext_name,
|
369 |
+
script_args=ext_args,
|
370 |
+
seed=seed,
|
371 |
+
invert_mask=self.cfg("inpaint_invert_mask", bool),
|
372 |
+
# mask_blur=self.cfg("inpaint_mask_blur", int),
|
373 |
+
inpainting_fill=fill,
|
374 |
+
# inpaint_full_res=self.cfg("inpaint_full_res", bool),
|
375 |
+
# inpaint_full_res_padding=self.cfg("inpaint_full_res_padding", int),
|
376 |
+
inpaint_mask_weight=self.cfg("inpaint_mask_weight", float),
|
377 |
+
include_grid=False, # it is never useful for inpaint mode
|
378 |
+
)
|
379 |
+
|
380 |
+
self.post("img2img", params, cb)
|
381 |
+
|
382 |
+
def post_upscale(self, cb, src_img):
|
383 |
+
params = (
|
384 |
+
{
|
385 |
+
"src_img": img_to_b64(src_img),
|
386 |
+
"upscaler_name": self.cfg("upscale_upscaler_name", str),
|
387 |
+
"downscale_first": self.cfg("upscale_downscale_first", bool),
|
388 |
+
}
|
389 |
+
if not self.cfg("just_use_yaml", bool)
|
390 |
+
else {"src_img": img_to_b64(src_img)}
|
391 |
+
)
|
392 |
+
self.post("upscale", params, cb)
|
393 |
+
|
394 |
+
def post_interrupt(self, cb):
|
395 |
+
# get official API url
|
396 |
+
url = get_url(self.cfg, prefix=OFFICIAL_ROUTE_PREFIX)
|
397 |
+
self.post("interrupt", {}, cb, base_url=url)
|
398 |
+
|
399 |
+
def get_progress(self, cb):
|
400 |
+
# get official API url
|
401 |
+
url = get_url(self.cfg, prefix=OFFICIAL_ROUTE_PREFIX)
|
402 |
+
self.get("progress", cb, base_url=url)
|
frontends/krita/krita_diff/config.py
ADDED
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from dataclasses import asdict
|
2 |
+
from typing import Any
|
3 |
+
|
4 |
+
from krita import QObject, QReadWriteLock, QSettings
|
5 |
+
|
6 |
+
from .defaults import CFG_FOLDER, CFG_NAME, DEFAULTS, ERR_MISSING_CONFIG
|
7 |
+
|
8 |
+
|
9 |
+
class Config(QObject):
|
10 |
+
def __init__(self, folder=CFG_FOLDER, name=CFG_NAME, model=DEFAULTS):
|
11 |
+
"""Sorta like a controller for QSettings.
|
12 |
+
|
13 |
+
I'm going to treat this as a singleton global app state, but implemented
|
14 |
+
correctly such that it should be theoretically possible to have multiple
|
15 |
+
instances (maybe multiple dockers controlling multiple remotes?)
|
16 |
+
|
17 |
+
If model is None, Config will not check if keys exist.
|
18 |
+
|
19 |
+
Args:
|
20 |
+
folder (str, optional): Which folder to store settings in. Defaults to CFG_FOLDER.
|
21 |
+
name (str, optional): Name of settings file. Defaults to CFG_NAME.
|
22 |
+
model (Any, optional): Data model representing config & defaults. Defaults to DEFAULTS.
|
23 |
+
"""
|
24 |
+
# See: https://doc.qt.io/qt-6/qsettings.html#accessing-settings-from-multiple-threads-or-processes-simultaneously
|
25 |
+
# but im too lazy to figure out creating separate QSettings per worker, so we will just lock
|
26 |
+
super(Config, self).__init__()
|
27 |
+
self.model = model # is immutable
|
28 |
+
self.lock = QReadWriteLock()
|
29 |
+
self.config = QSettings(QSettings.IniFormat, QSettings.UserScope, folder, name)
|
30 |
+
|
31 |
+
# add in new config settings
|
32 |
+
self.restore_defaults(overwrite=False)
|
33 |
+
|
34 |
+
def __call__(self, key: str, type: type = str):
|
35 |
+
"""Shorthand for Config.get()"""
|
36 |
+
return self.get(key, type)
|
37 |
+
|
38 |
+
def get(self, key: str, type: type = str):
|
39 |
+
"""Get config value by key & cast to type.
|
40 |
+
|
41 |
+
Args:
|
42 |
+
key (str): Name of config option.
|
43 |
+
type (type, optional): Type to cast config value to. Defaults to str.
|
44 |
+
|
45 |
+
Returns:
|
46 |
+
Any: Config value.
|
47 |
+
"""
|
48 |
+
self.lock.lockForRead()
|
49 |
+
try:
|
50 |
+
# notably QSettings assume strings too unless specified
|
51 |
+
if self.model is not None:
|
52 |
+
assert self.config.contains(key) and hasattr(
|
53 |
+
self.model, key
|
54 |
+
), ERR_MISSING_CONFIG
|
55 |
+
val = self.config.value(key, type=type)
|
56 |
+
return val
|
57 |
+
finally:
|
58 |
+
self.lock.unlock()
|
59 |
+
|
60 |
+
def set(self, key: str, val: Any, overwrite: bool = True):
|
61 |
+
"""Set config value by key.
|
62 |
+
|
63 |
+
Args:
|
64 |
+
key (str): Name of config option.
|
65 |
+
val (Any): Config value.
|
66 |
+
overwrite (bool, optional): Whether to overwrite an existing value. Defaults to False.
|
67 |
+
"""
|
68 |
+
self.lock.lockForWrite()
|
69 |
+
try:
|
70 |
+
if self.model is not None:
|
71 |
+
assert hasattr(self.model, key), ERR_MISSING_CONFIG
|
72 |
+
if overwrite or not self.config.contains(key):
|
73 |
+
self.config.setValue(key, val)
|
74 |
+
finally:
|
75 |
+
self.lock.unlock()
|
76 |
+
|
77 |
+
def restore_defaults(self, overwrite: bool = True):
|
78 |
+
"""Reset settings to default.
|
79 |
+
|
80 |
+
Args:
|
81 |
+
overwrite (bool, optional): Whether to overwrite existing settings, else add only new ones. Defaults to True.
|
82 |
+
"""
|
83 |
+
if self.model is None:
|
84 |
+
return
|
85 |
+
defaults = asdict(self.model)
|
86 |
+
for k, v in defaults.items():
|
87 |
+
self.set(k, v, overwrite)
|
frontends/krita/krita_diff/defaults.py
ADDED
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from dataclasses import dataclass, field
|
2 |
+
from typing import List
|
3 |
+
|
4 |
+
# set combo box to error msg instead of blank when cannot retrieve options from backend
|
5 |
+
ERROR_MSG = "Retrieval Failed"
|
6 |
+
|
7 |
+
# Used for status bar
|
8 |
+
STATE_READY = "Ready"
|
9 |
+
STATE_INIT = "Errors will be shown here"
|
10 |
+
STATE_URLERROR = "Network error"
|
11 |
+
STATE_RESET_DEFAULT = "All settings reset"
|
12 |
+
STATE_WAIT = "Please wait..."
|
13 |
+
STATE_DONE = "Done!"
|
14 |
+
STATE_INTERRUPT = "Interrupted!"
|
15 |
+
|
16 |
+
# Other currently hardcoded stuff
|
17 |
+
SHORT_TIMEOUT = 10
|
18 |
+
LONG_TIMEOUT = None # requests that might take "forever", i.e., image generation with high batch count
|
19 |
+
REFRESH_INTERVAL = 3000 # 3 seconds between auto-config refresh
|
20 |
+
ETA_REFRESH_INTERVAL = 1000 # 1 second between eta refresh
|
21 |
+
CFG_FOLDER = "krita" # which folder in ~/.config to store config
|
22 |
+
CFG_NAME = "krita_diff_plugin" # name of config file
|
23 |
+
EXT_CFG_NAME = "krita_diff_plugin_scripts" # name of config file
|
24 |
+
# selection mask can only be added after image is added, so timeout is needed
|
25 |
+
ADD_MASK_TIMEOUT = 200
|
26 |
+
THREADED = True
|
27 |
+
ROUTE_PREFIX = "/sdapi/interpause/"
|
28 |
+
OFFICIAL_ROUTE_PREFIX = "/sdapi/v1/"
|
29 |
+
|
30 |
+
# error messages
|
31 |
+
ERR_MISSING_CONFIG = "Report this bug, developer missed out a config key somewhere."
|
32 |
+
ERR_NO_DOCUMENT = "No document open yet!"
|
33 |
+
ERR_NO_CONNECTION = "Cannot reach backend!"
|
34 |
+
ERR_BAD_URL = "Invalid backend URL!"
|
35 |
+
|
36 |
+
# tab IDs
|
37 |
+
TAB_SDCOMMON = "krita_diff_sdcommon"
|
38 |
+
TAB_CONFIG = "krita_diff_config"
|
39 |
+
TAB_TXT2IMG = "krita_diff_txt2img"
|
40 |
+
TAB_IMG2IMG = "krita_diff_img2img"
|
41 |
+
TAB_INPAINT = "krita_diff_inpaint"
|
42 |
+
TAB_UPSCALE = "krita_diff_upscale"
|
43 |
+
|
44 |
+
|
45 |
+
@dataclass(frozen=True)
|
46 |
+
class Defaults:
|
47 |
+
base_url: str = "http://127.0.0.1:7860"
|
48 |
+
encryption_key: str = ""
|
49 |
+
just_use_yaml: bool = False
|
50 |
+
create_mask_layer: bool = True
|
51 |
+
save_temp_images: bool = False
|
52 |
+
fix_aspect_ratio: bool = True
|
53 |
+
only_full_img_tiling: bool = True
|
54 |
+
filter_nsfw: bool = False
|
55 |
+
do_exact_steps: bool = True
|
56 |
+
sample_path: str = "."
|
57 |
+
minimize_ui: bool = False
|
58 |
+
first_setup: bool = True # only used for the initial docker layout
|
59 |
+
alt_dock_behavior: bool = False
|
60 |
+
hide_layers: bool = True
|
61 |
+
|
62 |
+
sd_model_list: List[str] = field(default_factory=lambda: [ERROR_MSG])
|
63 |
+
sd_model: str = "model.ckpt"
|
64 |
+
sd_batch_size: int = 1
|
65 |
+
sd_batch_count: int = 1
|
66 |
+
sd_base_size: int = 512
|
67 |
+
sd_max_size: int = 768
|
68 |
+
sd_tiling: bool = False
|
69 |
+
upscaler_list: List[str] = field(default_factory=lambda: [ERROR_MSG])
|
70 |
+
upscaler_name: str = "None"
|
71 |
+
face_restorer_model_list: List[str] = field(default_factory=lambda: [ERROR_MSG])
|
72 |
+
face_restorer_model: str = "None"
|
73 |
+
codeformer_weight: float = 0.5
|
74 |
+
include_grid: bool = False
|
75 |
+
|
76 |
+
txt2img_prompt: str = ""
|
77 |
+
txt2img_negative_prompt: str = ""
|
78 |
+
txt2img_sampler_list: List[str] = field(default_factory=lambda: [ERROR_MSG])
|
79 |
+
txt2img_sampler: str = "Euler a"
|
80 |
+
txt2img_steps: int = 20
|
81 |
+
txt2img_cfg_scale: float = 7.0
|
82 |
+
txt2img_denoising_strength: float = 0.7
|
83 |
+
txt2img_seed: str = ""
|
84 |
+
txt2img_highres: bool = False
|
85 |
+
txt2img_script: str = "None"
|
86 |
+
txt2img_script_list: List[str] = field(default_factory=lambda: [ERROR_MSG])
|
87 |
+
# TODO: Seed variation
|
88 |
+
|
89 |
+
img2img_prompt: str = ""
|
90 |
+
img2img_negative_prompt: str = ""
|
91 |
+
img2img_sampler_list: List[str] = field(default_factory=lambda: [ERROR_MSG])
|
92 |
+
img2img_sampler: str = "Euler a"
|
93 |
+
img2img_steps: int = 40
|
94 |
+
img2img_cfg_scale: float = 12.0
|
95 |
+
img2img_denoising_strength: float = 0.8
|
96 |
+
img2img_seed: str = ""
|
97 |
+
img2img_color_correct: bool = False
|
98 |
+
img2img_script: str = "None"
|
99 |
+
img2img_script_list: List[str] = field(default_factory=lambda: [ERROR_MSG])
|
100 |
+
|
101 |
+
inpaint_prompt: str = ""
|
102 |
+
inpaint_negative_prompt: str = ""
|
103 |
+
inpaint_sampler_list: List[str] = field(default_factory=lambda: [ERROR_MSG])
|
104 |
+
inpaint_sampler: str = "LMS"
|
105 |
+
inpaint_steps: int = 100
|
106 |
+
inpaint_cfg_scale: float = 5.0
|
107 |
+
inpaint_denoising_strength: float = 0.40
|
108 |
+
inpaint_seed: str = ""
|
109 |
+
inpaint_invert_mask: bool = False
|
110 |
+
# inpaint_mask_blur: int = 4
|
111 |
+
inpaint_fill_list: List[str] = field(
|
112 |
+
# NOTE: list order corresponds to number to use in internal API!!!
|
113 |
+
default_factory=lambda: ["blur", "preserve", "latent noise", "latent empty"]
|
114 |
+
)
|
115 |
+
inpaint_fill: str = "preserve"
|
116 |
+
# inpaint_full_res: bool = False
|
117 |
+
# inpaint_full_res_padding: int = 32
|
118 |
+
inpaint_color_correct: bool = False
|
119 |
+
inpaint_script: str = "None"
|
120 |
+
inpaint_script_list: List[str] = field(default_factory=lambda: [ERROR_MSG])
|
121 |
+
inpaint_mask_weight: float = 1.0
|
122 |
+
|
123 |
+
upscale_upscaler_name: str = "None"
|
124 |
+
upscale_downscale_first: bool = False
|
125 |
+
|
126 |
+
|
127 |
+
DEFAULTS = Defaults()
|
frontends/krita/krita_diff/docker.py
ADDED
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from krita import DockWidget, QScrollArea
|
2 |
+
|
3 |
+
from .script import script
|
4 |
+
from .style import style
|
5 |
+
|
6 |
+
|
7 |
+
def create_docker(page):
|
8 |
+
class Docker(DockWidget):
|
9 |
+
def __init__(self, *args, **kwargs):
|
10 |
+
super(Docker, self).__init__(*args, **kwargs)
|
11 |
+
self.setWindowTitle(page.name)
|
12 |
+
self.create_interface()
|
13 |
+
self.update_interface()
|
14 |
+
self.connect_interface()
|
15 |
+
self.setWidget(self.widget)
|
16 |
+
|
17 |
+
def create_interface(self):
|
18 |
+
self.page_widget = page()
|
19 |
+
self.widget = QScrollArea()
|
20 |
+
self.widget.setStyleSheet(style)
|
21 |
+
self.widget.setWidget(self.page_widget)
|
22 |
+
self.widget.setWidgetResizable(True)
|
23 |
+
|
24 |
+
def update_interface(self):
|
25 |
+
self.page_widget.cfg_init()
|
26 |
+
|
27 |
+
def connect_interface(self):
|
28 |
+
self.page_widget.cfg_connect()
|
29 |
+
script.config_updated.connect(lambda: self.update_interface())
|
30 |
+
|
31 |
+
def canvasChanged(self, canvas):
|
32 |
+
pass
|
33 |
+
|
34 |
+
return Docker
|
frontends/krita/krita_diff/extension.py
ADDED
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from krita import Extension, QMainWindow, QTimer
|
2 |
+
|
3 |
+
from .defaults import REFRESH_INTERVAL
|
4 |
+
from .script import script
|
5 |
+
|
6 |
+
|
7 |
+
class SDPluginExtension(Extension):
|
8 |
+
def __init__(self, instance):
|
9 |
+
super().__init__(instance)
|
10 |
+
|
11 |
+
self.instance = instance
|
12 |
+
# store original window docker config
|
13 |
+
self.dock_opts = None
|
14 |
+
|
15 |
+
def setup(self):
|
16 |
+
self.update_timer = QTimer()
|
17 |
+
self.update_timer.timeout.connect(lambda: script.action_update_config())
|
18 |
+
self.update_timer.start(REFRESH_INTERVAL)
|
19 |
+
script.config_updated.connect(lambda: self.update_global())
|
20 |
+
self.instance.notifier().windowCreated.connect(lambda: self.update_global())
|
21 |
+
script.action_update_config()
|
22 |
+
|
23 |
+
def update_global(self):
|
24 |
+
window = self.instance.activeWindow()
|
25 |
+
if not window:
|
26 |
+
return
|
27 |
+
qwin = window.qwindow()
|
28 |
+
if not self.dock_opts:
|
29 |
+
self.dock_opts = qwin.dockOptions()
|
30 |
+
|
31 |
+
# NOTE: This changes the default behaviour of Krita for all dockers!
|
32 |
+
if script.cfg("alt_dock_behavior", bool):
|
33 |
+
qwin.setDockOptions(
|
34 |
+
QMainWindow.AnimatedDocks
|
35 |
+
| QMainWindow.AllowTabbedDocks
|
36 |
+
| QMainWindow.GroupedDragging
|
37 |
+
| QMainWindow.AllowNestedDocks
|
38 |
+
# | QMainWindow.VerticalTabs
|
39 |
+
)
|
40 |
+
else:
|
41 |
+
qwin.setDockOptions(self.dock_opts)
|
42 |
+
|
43 |
+
def createActions(self, window):
|
44 |
+
txt2img_action = window.createAction(
|
45 |
+
"txt2img", "Apply txt2img", "tools/scripts"
|
46 |
+
)
|
47 |
+
txt2img_action.triggered.connect(lambda: script.action_txt2img())
|
48 |
+
img2img_action = window.createAction(
|
49 |
+
"img2img", "Apply img2img", "tools/scripts"
|
50 |
+
)
|
51 |
+
img2img_action.triggered.connect(lambda: script.action_img2img())
|
52 |
+
upscale_x_action = window.createAction(
|
53 |
+
"img2img_upscale", "Apply img2img upscale", "tools/scripts"
|
54 |
+
)
|
55 |
+
upscale_x_action.triggered.connect(lambda: script.action_sd_upscale())
|
56 |
+
upscale_x_action = window.createAction(
|
57 |
+
"img2img_inpaint", "Apply img2img inpaint", "tools/scripts"
|
58 |
+
)
|
59 |
+
upscale_x_action.triggered.connect(lambda: script.action_inpaint())
|
60 |
+
simple_upscale_action = window.createAction(
|
61 |
+
"simple_upscale", "Apply simple upscaler", "tools/scripts"
|
62 |
+
)
|
63 |
+
simple_upscale_action.triggered.connect(lambda: script.action_simple_upscale())
|
frontends/krita/krita_diff/krita_diff.action
ADDED
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
2 |
+
<ActionCollection version="2" name="Scripts">
|
3 |
+
<Actions category="Scripts">
|
4 |
+
<text>Stable Diffusion Plugin</text>
|
5 |
+
|
6 |
+
<Action name="txt2img">
|
7 |
+
<icon/>
|
8 |
+
<text>Apply txt2img transform</text>
|
9 |
+
<whatsThis/>
|
10 |
+
<toolTip/>
|
11 |
+
<iconText/>
|
12 |
+
<activationFlags>10000</activationFlags>
|
13 |
+
<activationConditions>0</activationConditions>
|
14 |
+
<shortcut>ctrl+alt+q</shortcut>
|
15 |
+
<isCheckable>false</isCheckable>
|
16 |
+
<statusTip/>
|
17 |
+
</Action>
|
18 |
+
<Action name="img2img">
|
19 |
+
<icon/>
|
20 |
+
<text>Apply img2img transform</text>
|
21 |
+
<whatsThis/>
|
22 |
+
<toolTip/>
|
23 |
+
<iconText/>
|
24 |
+
<activationFlags>10000</activationFlags>
|
25 |
+
<activationConditions>0</activationConditions>
|
26 |
+
<shortcut>ctrl+alt+w</shortcut>
|
27 |
+
<isCheckable>false</isCheckable>
|
28 |
+
<statusTip/>
|
29 |
+
</Action>
|
30 |
+
<!--
|
31 |
+
<Action name="img2img_upscale">
|
32 |
+
<icon/>
|
33 |
+
<text>Apply SD upscale transform</text>
|
34 |
+
<whatsThis/>
|
35 |
+
<toolTip/>
|
36 |
+
<iconText/>
|
37 |
+
<activationFlags>10000</activationFlags>
|
38 |
+
<activationConditions>0</activationConditions>
|
39 |
+
<shortcut>ctrl+alt+e</shortcut>
|
40 |
+
<isCheckable>false</isCheckable>
|
41 |
+
<statusTip/>
|
42 |
+
</Action>
|
43 |
+
-->
|
44 |
+
<Action name="img2img_inpaint">
|
45 |
+
<icon/>
|
46 |
+
<text>Apply inpaint transform</text>
|
47 |
+
<whatsThis/>
|
48 |
+
<toolTip/>
|
49 |
+
<iconText/>
|
50 |
+
<activationFlags>10000</activationFlags>
|
51 |
+
<activationConditions>0</activationConditions>
|
52 |
+
<shortcut>ctrl+alt+r</shortcut>
|
53 |
+
<isCheckable>false</isCheckable>
|
54 |
+
<statusTip/>
|
55 |
+
</Action>
|
56 |
+
<Action name="simple_upscale">
|
57 |
+
<icon/>
|
58 |
+
<text>Apply ESRGAN upscaler</text>
|
59 |
+
<whatsThis/>
|
60 |
+
<toolTip/>
|
61 |
+
<iconText/>
|
62 |
+
<activationFlags>10000</activationFlags>
|
63 |
+
<activationConditions>0</activationConditions>
|
64 |
+
<shortcut>ctrl+alt+t</shortcut>
|
65 |
+
<isCheckable>false</isCheckable>
|
66 |
+
<statusTip/>
|
67 |
+
</Action>
|
68 |
+
</Actions>
|
69 |
+
</ActionCollection>
|
frontends/krita/krita_diff/manual.html
ADDED
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<h1>Stable Diffusion Plugin</h1>
|
2 |
+
|
3 |
+
<p>Expose the power of AUTOMATIC1111's Stable Diffusion fork for creating to your heart's desire.</p>
|
4 |
+
|
5 |
+
<p>Forked from <a href="https://github.com/sddebz/stable-diffusion-krita-plugin"
|
6 |
+
target="_blank">https://github.com/sddebz/stable-diffusion-krita-plugin</a>,
|
7 |
+
which is based on the feature-rich and performant <a href="https://github.com/AUTOMATIC1111/stable-diffusion-webui"
|
8 |
+
target="_blank">AUTOMATIC1111's Fork</a>.
|
9 |
+
</p>
|
10 |
+
|
11 |
+
<p>Usage guide at <a href="https://github.com/Interpause/auto-sd-paint-ext/wiki"
|
12 |
+
target="_blank">https://github.com/Interpause/auto-sd-paint-ext/wiki</a>.</p>
|
13 |
+
<p>Report bugs & suggest features at <a href="https://github.com/Interpause/auto-sd-paint-ext/issues"
|
14 |
+
target="_blank">https://github.com/Interpause/auto-sd-paint-ext/issues</a>.
|
15 |
+
</p>
|
frontends/krita/krita_diff/pages/__init__.py
ADDED
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from .common import SDCommonPage
|
2 |
+
from .config import ConfigPage
|
3 |
+
from .img2img import Img2ImgPage
|
4 |
+
from .inpaint import InpaintPage
|
5 |
+
from .txt2img import Txt2ImgPage
|
6 |
+
from .upscale import UpscalePage
|
frontends/krita/krita_diff/pages/common.py
ADDED
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from krita import QHBoxLayout, QPushButton, QVBoxLayout, QWidget
|
2 |
+
|
3 |
+
from ..script import script
|
4 |
+
from ..widgets import QCheckBox, QComboBoxLayout, QLabel, QSpinBoxLayout
|
5 |
+
|
6 |
+
# Notes:
|
7 |
+
# - move tiling mode to config?
|
8 |
+
# - move upscaler/face restorer to config?
|
9 |
+
|
10 |
+
|
11 |
+
class SDCommonPage(QWidget):
|
12 |
+
name = "SD Common Options"
|
13 |
+
|
14 |
+
def __init__(self, *args, **kwargs):
|
15 |
+
super(SDCommonPage, self).__init__(*args, **kwargs)
|
16 |
+
|
17 |
+
self.title = QLabel("<em>Quick Config</em>")
|
18 |
+
|
19 |
+
# Model list
|
20 |
+
self.sd_model_layout = QComboBoxLayout(
|
21 |
+
script.cfg, "sd_model_list", "sd_model", label="SD model:"
|
22 |
+
)
|
23 |
+
|
24 |
+
# batch size & count
|
25 |
+
self.batch_count_layout = QSpinBoxLayout(
|
26 |
+
script.cfg, "sd_batch_count", label="Batch count:", min=1, max=9999, step=1
|
27 |
+
)
|
28 |
+
self.batch_size_layout = QSpinBoxLayout(
|
29 |
+
script.cfg, "sd_batch_size", label="Batch size:", min=1, max=9999, step=1
|
30 |
+
)
|
31 |
+
batch_layout = QHBoxLayout()
|
32 |
+
batch_layout.addLayout(self.batch_count_layout)
|
33 |
+
batch_layout.addLayout(self.batch_size_layout)
|
34 |
+
|
35 |
+
# base/max size adjustment
|
36 |
+
self.base_size_layout = QSpinBoxLayout(
|
37 |
+
script.cfg, "sd_base_size", label="Base size:", min=64, max=8192, step=64
|
38 |
+
)
|
39 |
+
self.max_size_layout = QSpinBoxLayout(
|
40 |
+
script.cfg, "sd_max_size", label="Max size:", min=64, max=8192, step=64
|
41 |
+
)
|
42 |
+
size_layout = QHBoxLayout()
|
43 |
+
size_layout.addLayout(self.base_size_layout)
|
44 |
+
size_layout.addLayout(self.max_size_layout)
|
45 |
+
|
46 |
+
# global upscaler
|
47 |
+
self.upscaler_layout = QComboBoxLayout(
|
48 |
+
script.cfg, "upscaler_list", "upscaler_name", label="Upscaler:"
|
49 |
+
)
|
50 |
+
|
51 |
+
# Restore faces
|
52 |
+
self.face_restorer_layout = QComboBoxLayout(
|
53 |
+
script.cfg,
|
54 |
+
"face_restorer_model_list",
|
55 |
+
"face_restorer_model",
|
56 |
+
label="Face restorer:",
|
57 |
+
)
|
58 |
+
self.codeformer_weight_layout = QSpinBoxLayout(
|
59 |
+
script.cfg,
|
60 |
+
"codeformer_weight",
|
61 |
+
label="CodeFormer weight (max 0, min 1):",
|
62 |
+
step=0.01,
|
63 |
+
)
|
64 |
+
|
65 |
+
# Tiling mode
|
66 |
+
self.tiling = QCheckBox(script.cfg, "sd_tiling", "Tiling mode")
|
67 |
+
|
68 |
+
# Interrupt button
|
69 |
+
self.interrupt_btn = QPushButton("Interrupt")
|
70 |
+
|
71 |
+
layout = QVBoxLayout()
|
72 |
+
layout.setContentsMargins(0, 0, 0, 0)
|
73 |
+
|
74 |
+
layout.addWidget(self.title)
|
75 |
+
layout.addLayout(self.upscaler_layout)
|
76 |
+
layout.addLayout(self.face_restorer_layout)
|
77 |
+
layout.addLayout(self.codeformer_weight_layout)
|
78 |
+
layout.addWidget(self.tiling)
|
79 |
+
layout.addLayout(self.sd_model_layout)
|
80 |
+
layout.addLayout(batch_layout)
|
81 |
+
layout.addLayout(size_layout)
|
82 |
+
layout.addWidget(self.interrupt_btn)
|
83 |
+
layout.addStretch()
|
84 |
+
|
85 |
+
self.setLayout(layout)
|
86 |
+
|
87 |
+
def cfg_init(self):
|
88 |
+
self.sd_model_layout.cfg_init()
|
89 |
+
self.batch_count_layout.cfg_init()
|
90 |
+
self.batch_size_layout.cfg_init()
|
91 |
+
self.base_size_layout.cfg_init()
|
92 |
+
self.max_size_layout.cfg_init()
|
93 |
+
self.upscaler_layout.cfg_init()
|
94 |
+
self.face_restorer_layout.cfg_init()
|
95 |
+
self.codeformer_weight_layout.cfg_init()
|
96 |
+
self.tiling.cfg_init()
|
97 |
+
|
98 |
+
self.title.setVisible(not script.cfg("minimize_ui", bool))
|
99 |
+
|
100 |
+
def cfg_connect(self):
|
101 |
+
self.sd_model_layout.cfg_connect()
|
102 |
+
self.batch_count_layout.cfg_connect()
|
103 |
+
self.batch_size_layout.cfg_connect()
|
104 |
+
self.base_size_layout.cfg_connect()
|
105 |
+
self.max_size_layout.cfg_connect()
|
106 |
+
self.upscaler_layout.cfg_connect()
|
107 |
+
self.face_restorer_layout.cfg_connect()
|
108 |
+
self.codeformer_weight_layout.cfg_connect()
|
109 |
+
self.tiling.cfg_connect()
|
110 |
+
|
111 |
+
# Hide codeformer_weight when model isnt codeformer
|
112 |
+
def toggle_codeformer_weights(visible):
|
113 |
+
self.codeformer_weight_layout.qspin.setVisible(visible)
|
114 |
+
self.codeformer_weight_layout.qlabel.setVisible(visible)
|
115 |
+
|
116 |
+
self.face_restorer_layout.qcombo.currentTextChanged.connect(
|
117 |
+
lambda t: toggle_codeformer_weights(t == "CodeFormer")
|
118 |
+
)
|
119 |
+
toggle_codeformer_weights(
|
120 |
+
self.face_restorer_layout.qcombo.currentText() == "CodeFormer"
|
121 |
+
)
|
122 |
+
|
123 |
+
self.interrupt_btn.released.connect(lambda: script.action_interrupt())
|
frontends/krita/krita_diff/pages/config.py
ADDED
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from functools import partial
|
2 |
+
|
3 |
+
from krita import QHBoxLayout, QLineEdit, QPushButton, QVBoxLayout, QWidget
|
4 |
+
|
5 |
+
from ..defaults import DEFAULTS
|
6 |
+
from ..script import script
|
7 |
+
from ..utils import reset_docker_layout
|
8 |
+
from ..widgets import QCheckBox, QLabel, QLineEditLayout, StatusBar
|
9 |
+
|
10 |
+
|
11 |
+
class ConfigPage(QWidget):
|
12 |
+
name = "SD Plugin Config"
|
13 |
+
|
14 |
+
def __init__(self, *args, **kwargs):
|
15 |
+
super(ConfigPage, self).__init__(*args, **kwargs)
|
16 |
+
|
17 |
+
self.status_bar = StatusBar()
|
18 |
+
|
19 |
+
self.base_url = QLineEdit()
|
20 |
+
self.base_url_reset = QPushButton("Default")
|
21 |
+
inline1 = QHBoxLayout()
|
22 |
+
inline1.addWidget(self.base_url)
|
23 |
+
inline1.addWidget(self.base_url_reset)
|
24 |
+
|
25 |
+
self.enc_key = QLineEditLayout(
|
26 |
+
script.cfg, "encryption_key", "Optional Encryption Key"
|
27 |
+
)
|
28 |
+
|
29 |
+
# Plugin settings
|
30 |
+
self.just_use_yaml = QCheckBox(
|
31 |
+
script.cfg, "just_use_yaml", "(unrecommended) Ignore settings"
|
32 |
+
)
|
33 |
+
self.create_mask_layer = QCheckBox(
|
34 |
+
script.cfg, "create_mask_layer", "Use selection as mask"
|
35 |
+
)
|
36 |
+
self.save_temp_images = QCheckBox(
|
37 |
+
script.cfg, "save_temp_images", "Save images for debug"
|
38 |
+
)
|
39 |
+
self.fix_aspect_ratio = QCheckBox(
|
40 |
+
script.cfg, "fix_aspect_ratio", "Adjust selection aspect ratio"
|
41 |
+
)
|
42 |
+
self.only_full_img_tiling = QCheckBox(
|
43 |
+
script.cfg, "only_full_img_tiling", "Disallow tiling with selection"
|
44 |
+
)
|
45 |
+
self.include_grid = QCheckBox(
|
46 |
+
script.cfg, "include_grid", "Include txt2img/img2img grid"
|
47 |
+
)
|
48 |
+
self.minimize_ui = QCheckBox(script.cfg, "minimize_ui", "Squeeze the UI")
|
49 |
+
self.alt_docker = QCheckBox(
|
50 |
+
script.cfg, "alt_dock_behavior", "Alt Docker Behaviour"
|
51 |
+
)
|
52 |
+
self.hide_layers = QCheckBox(script.cfg, "hide_layers", "Auto hide layers")
|
53 |
+
|
54 |
+
# webUI/backend settings
|
55 |
+
self.filter_nsfw = QCheckBox(script.cfg, "filter_nsfw", "Filter NSFW")
|
56 |
+
self.img2img_color_correct = QCheckBox(
|
57 |
+
script.cfg, "img2img_color_correct", "Color correct img2img"
|
58 |
+
)
|
59 |
+
self.inpaint_color_correct = QCheckBox(
|
60 |
+
script.cfg, "inpaint_color_correct", "Color correct inpaint"
|
61 |
+
)
|
62 |
+
self.do_exact_steps = QCheckBox(
|
63 |
+
script.cfg,
|
64 |
+
"do_exact_steps",
|
65 |
+
"Exact number of steps for denoising",
|
66 |
+
)
|
67 |
+
|
68 |
+
self.refresh_btn = QPushButton("Auto-Refresh Options Now")
|
69 |
+
self.restore_defaults = QPushButton("Restore Defaults")
|
70 |
+
|
71 |
+
self.info_label = QLabel()
|
72 |
+
self.info_label.setOpenExternalLinks(True)
|
73 |
+
self.info_label.setWordWrap(True)
|
74 |
+
|
75 |
+
# scroll_area = QScrollArea()
|
76 |
+
|
77 |
+
layout = QVBoxLayout()
|
78 |
+
layout.setContentsMargins(0, 0, 0, 0)
|
79 |
+
layout_inner = QVBoxLayout()
|
80 |
+
layout_inner.setContentsMargins(0, 0, 0, 0)
|
81 |
+
|
82 |
+
layout_inner.addWidget(QLabel("<em>Plugin settings:</em>"))
|
83 |
+
layout_inner.addWidget(self.minimize_ui)
|
84 |
+
layout_inner.addWidget(self.alt_docker)
|
85 |
+
layout_inner.addWidget(self.hide_layers)
|
86 |
+
layout_inner.addWidget(self.create_mask_layer)
|
87 |
+
layout_inner.addWidget(self.fix_aspect_ratio)
|
88 |
+
layout_inner.addWidget(self.only_full_img_tiling)
|
89 |
+
layout_inner.addWidget(self.include_grid)
|
90 |
+
layout_inner.addWidget(self.save_temp_images)
|
91 |
+
# layout_inner.addWidget(self.just_use_yaml)
|
92 |
+
|
93 |
+
layout_inner.addWidget(QLabel("<em>Backend/webUI settings:</em>"))
|
94 |
+
layout_inner.addWidget(self.filter_nsfw)
|
95 |
+
layout_inner.addWidget(self.img2img_color_correct)
|
96 |
+
layout_inner.addWidget(self.inpaint_color_correct)
|
97 |
+
layout_inner.addWidget(self.do_exact_steps)
|
98 |
+
|
99 |
+
# TODO: figure out how to set height of scroll area when there are too many options
|
100 |
+
# or maybe an option search bar
|
101 |
+
# scroll_area.setLayout(layout_inner)
|
102 |
+
# scroll_area.setWidgetResizable(True)
|
103 |
+
# layout.addWidget(scroll_area)
|
104 |
+
layout.addWidget(self.status_bar)
|
105 |
+
layout.addWidget(QLabel("<em>Backend url:</em>"))
|
106 |
+
layout.addLayout(inline1)
|
107 |
+
layout.addLayout(self.enc_key)
|
108 |
+
layout.addLayout(layout_inner)
|
109 |
+
layout.addWidget(self.refresh_btn)
|
110 |
+
layout.addWidget(self.restore_defaults)
|
111 |
+
layout.addWidget(self.info_label)
|
112 |
+
layout.addStretch()
|
113 |
+
|
114 |
+
self.setLayout(layout)
|
115 |
+
|
116 |
+
def cfg_init(self):
|
117 |
+
# NOTE: update timer -> cfg_init, setText seems to reset cursor position so we prevent it
|
118 |
+
base_url = script.cfg("base_url", str)
|
119 |
+
if self.base_url.text() != base_url:
|
120 |
+
self.base_url.setText(base_url)
|
121 |
+
|
122 |
+
self.enc_key.cfg_init()
|
123 |
+
self.just_use_yaml.cfg_init()
|
124 |
+
self.create_mask_layer.cfg_init()
|
125 |
+
self.save_temp_images.cfg_init()
|
126 |
+
self.fix_aspect_ratio.cfg_init()
|
127 |
+
self.only_full_img_tiling.cfg_init()
|
128 |
+
self.include_grid.cfg_init()
|
129 |
+
self.filter_nsfw.cfg_init()
|
130 |
+
self.img2img_color_correct.cfg_init()
|
131 |
+
self.inpaint_color_correct.cfg_init()
|
132 |
+
self.do_exact_steps.cfg_init()
|
133 |
+
self.minimize_ui.cfg_init()
|
134 |
+
self.alt_docker.cfg_init()
|
135 |
+
self.hide_layers.cfg_init()
|
136 |
+
|
137 |
+
info_text = """
|
138 |
+
<em>Tip:</em> Only a selected few backend/webUI settings are exposed above.<br/>
|
139 |
+
<em>Tip:</em> You should look through & configure all the backend/webUI settings at least once.
|
140 |
+
<br/><br/>
|
141 |
+
<a href="http://127.0.0.1:7860/" target="_blank">Configure all settings in webUI</a><br/>
|
142 |
+
<a href="https://github.com/Interpause/auto-sd-paint-ext/wiki" target="_blank">Read the guide</a><br/>
|
143 |
+
<a href="https://github.com/Interpause/auto-sd-paint-ext/issues" target="_blank">Report bugs or suggest features</a>
|
144 |
+
"""
|
145 |
+
if script.cfg("minimize_ui", bool):
|
146 |
+
info_text = "\n".join(info_text.split("\n")[-4:-1])
|
147 |
+
self.info_label.setText(info_text)
|
148 |
+
|
149 |
+
def cfg_connect(self):
|
150 |
+
self.base_url.textChanged.connect(partial(script.cfg.set, "base_url"))
|
151 |
+
# NOTE: this triggers on every keystroke; theres no focus lost signal...
|
152 |
+
self.base_url.textChanged.connect(lambda: script.action_update_config())
|
153 |
+
self.base_url_reset.released.connect(
|
154 |
+
lambda: self.base_url.setText(DEFAULTS.base_url)
|
155 |
+
)
|
156 |
+
self.enc_key.cfg_connect()
|
157 |
+
self.just_use_yaml.cfg_connect()
|
158 |
+
self.create_mask_layer.cfg_connect()
|
159 |
+
self.save_temp_images.cfg_connect()
|
160 |
+
self.fix_aspect_ratio.cfg_connect()
|
161 |
+
self.only_full_img_tiling.cfg_connect()
|
162 |
+
self.include_grid.cfg_connect()
|
163 |
+
self.filter_nsfw.cfg_connect()
|
164 |
+
self.img2img_color_correct.cfg_connect()
|
165 |
+
self.inpaint_color_correct.cfg_connect()
|
166 |
+
self.do_exact_steps.cfg_connect()
|
167 |
+
self.minimize_ui.cfg_connect()
|
168 |
+
self.alt_docker.cfg_connect()
|
169 |
+
self.hide_layers.cfg_connect()
|
170 |
+
|
171 |
+
def restore_defaults():
|
172 |
+
script.restore_defaults()
|
173 |
+
reset_docker_layout()
|
174 |
+
script.cfg.set("first_setup", False)
|
175 |
+
# retrieve list of available stuff again
|
176 |
+
script.action_update_config()
|
177 |
+
|
178 |
+
self.refresh_btn.released.connect(lambda: script.action_update_config())
|
179 |
+
self.restore_defaults.released.connect(restore_defaults)
|
180 |
+
self.minimize_ui.toggled.connect(lambda _: script.config_updated.emit())
|
181 |
+
self.alt_docker.toggled.connect(lambda _: script.config_updated.emit())
|
182 |
+
script.status_changed.connect(lambda s: self.status_bar.set_status(s))
|
frontends/krita/krita_diff/pages/extension.py
ADDED
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import json
|
2 |
+
from functools import partial
|
3 |
+
from typing import List
|
4 |
+
|
5 |
+
from krita import QVBoxLayout, QWidget
|
6 |
+
|
7 |
+
from ..config import Config
|
8 |
+
from ..script import script
|
9 |
+
from ..utils import get_ext_key
|
10 |
+
from ..widgets import (
|
11 |
+
QCheckBox,
|
12 |
+
QComboBoxLayout,
|
13 |
+
QLineEditLayout,
|
14 |
+
QMultiCheckBoxLayout,
|
15 |
+
QSpinBoxLayout,
|
16 |
+
)
|
17 |
+
|
18 |
+
|
19 |
+
# TODO: dynamically adjust script options available without needing to restart plugin
|
20 |
+
class ExtWidget(QWidget):
|
21 |
+
def __init__(self, ext_cfg: Config, ext_type: str, ext_name: str, *args, **kwargs):
|
22 |
+
"""Dynamically create form for script based on metadata.
|
23 |
+
|
24 |
+
Args:
|
25 |
+
ext_cfg (Config): Config object to get metadata from.
|
26 |
+
ext_type (str): Whether metadata is in "scripts_txt2img", "scripts_img2img" or "scripts_inpaint"
|
27 |
+
ext_name (str): Name of script.
|
28 |
+
"""
|
29 |
+
super(ExtWidget, self).__init__(*args, **kwargs)
|
30 |
+
|
31 |
+
get_key = partial(get_ext_key, ext_type, ext_name)
|
32 |
+
|
33 |
+
try:
|
34 |
+
meta: List[dict] = json.loads(ext_cfg(get_key()))
|
35 |
+
except json.JSONDecodeError:
|
36 |
+
meta = []
|
37 |
+
print(f"Script metadata is invalid: {ext_cfg(get_key())}")
|
38 |
+
|
39 |
+
layout = QVBoxLayout()
|
40 |
+
self.widgets = []
|
41 |
+
for i, o in enumerate(meta):
|
42 |
+
w = None
|
43 |
+
k = get_key(i)
|
44 |
+
if o["type"] == "range":
|
45 |
+
w = QSpinBoxLayout(
|
46 |
+
ext_cfg,
|
47 |
+
k,
|
48 |
+
label=o["label"],
|
49 |
+
min=o["min"],
|
50 |
+
max=o["max"],
|
51 |
+
step=o["step"],
|
52 |
+
)
|
53 |
+
elif o["type"] == "combo":
|
54 |
+
w = QComboBoxLayout(ext_cfg, o["opts"], k, label=o["label"])
|
55 |
+
elif o["type"] == "text":
|
56 |
+
w = QLineEditLayout(ext_cfg, k, o["label"])
|
57 |
+
elif o["type"] == "checkbox":
|
58 |
+
w = QCheckBox(ext_cfg, k, o["label"])
|
59 |
+
elif o["type"] == "multiselect":
|
60 |
+
w = QMultiCheckBoxLayout(ext_cfg, o["opts"], k, o["label"])
|
61 |
+
else:
|
62 |
+
continue
|
63 |
+
self.widgets.append(w)
|
64 |
+
if isinstance(w, QWidget):
|
65 |
+
layout.addWidget(w)
|
66 |
+
else:
|
67 |
+
layout.addLayout(w)
|
68 |
+
self.setLayout(layout)
|
69 |
+
|
70 |
+
def cfg_init(self):
|
71 |
+
for w in self.widgets:
|
72 |
+
w.cfg_init()
|
73 |
+
|
74 |
+
def cfg_connect(self):
|
75 |
+
for w in self.widgets:
|
76 |
+
w.cfg_connect()
|
77 |
+
|
78 |
+
|
79 |
+
class ExtSectionLayout(QVBoxLayout):
|
80 |
+
def __init__(self, cfg_prefix: str, *args, **kwargs):
|
81 |
+
super(ExtSectionLayout, self).__init__(*args, **kwargs)
|
82 |
+
|
83 |
+
# NOTE: backend will send empty scripts followed by the real one, have to
|
84 |
+
# detect for that
|
85 |
+
self.is_init = False
|
86 |
+
|
87 |
+
self.dropdown = QComboBoxLayout(
|
88 |
+
script.cfg,
|
89 |
+
f"{cfg_prefix}_script_list",
|
90 |
+
f"{cfg_prefix}_script",
|
91 |
+
label="Scripts:",
|
92 |
+
)
|
93 |
+
self.addLayout(self.dropdown)
|
94 |
+
|
95 |
+
self.ext_type = f"scripts_{cfg_prefix}"
|
96 |
+
self.ext_names = partial(script.cfg, f"{cfg_prefix}_script_list", "QStringList")
|
97 |
+
self.ext_widgets = {}
|
98 |
+
|
99 |
+
def init_ui_once_if_ready(self):
|
100 |
+
"""Init UI only once, and only when its ready (aka metadata is present)."""
|
101 |
+
if self.is_init:
|
102 |
+
return
|
103 |
+
if len(self.ext_names()) != script.ext_cfg(f"{self.ext_type}_len", int):
|
104 |
+
return
|
105 |
+
|
106 |
+
self.is_init = True
|
107 |
+
for ext_name in self.ext_names():
|
108 |
+
ext_widget = ExtWidget(script.ext_cfg, self.ext_type, ext_name)
|
109 |
+
ext_widget.setVisible(False)
|
110 |
+
self.addWidget(ext_widget)
|
111 |
+
self.ext_widgets[ext_name] = ext_widget
|
112 |
+
self._cfg_connect()
|
113 |
+
|
114 |
+
def cfg_init(self):
|
115 |
+
self.dropdown.cfg_init()
|
116 |
+
self.init_ui_once_if_ready()
|
117 |
+
for widget in self.ext_widgets.values():
|
118 |
+
widget.cfg_init()
|
119 |
+
|
120 |
+
def cfg_connect(self):
|
121 |
+
self.dropdown.cfg_connect()
|
122 |
+
self.init_ui_once_if_ready()
|
123 |
+
self.dropdown.qcombo.currentTextChanged.connect(lambda s: self._update(s))
|
124 |
+
|
125 |
+
def _update(self, selected):
|
126 |
+
for w in self.ext_widgets.values():
|
127 |
+
w.setVisible(False)
|
128 |
+
widget = self.ext_widgets.get(selected, None)
|
129 |
+
if widget and selected != "None":
|
130 |
+
widget.setVisible(True)
|
131 |
+
|
132 |
+
def _cfg_connect(self):
|
133 |
+
for widget in self.ext_widgets.values():
|
134 |
+
widget.cfg_connect()
|
135 |
+
self._update(self.dropdown.qcombo.currentText())
|
frontends/krita/krita_diff/pages/img2img.py
ADDED
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from krita import QPushButton
|
2 |
+
|
3 |
+
from ..script import script
|
4 |
+
from ..widgets import TipsLayout
|
5 |
+
from .img_base import SDImgPageBase
|
6 |
+
|
7 |
+
|
8 |
+
class Img2ImgPage(SDImgPageBase):
|
9 |
+
name = "Img2Img"
|
10 |
+
|
11 |
+
def __init__(self, *args, **kwargs):
|
12 |
+
super(Img2ImgPage, self).__init__(cfg_prefix="img2img", *args, **kwargs)
|
13 |
+
|
14 |
+
self.btn = QPushButton("Start img2img")
|
15 |
+
self.tips = TipsLayout(
|
16 |
+
["Select what you want the model to perform img2img on."]
|
17 |
+
)
|
18 |
+
|
19 |
+
self.layout.addLayout(self.denoising_strength_layout)
|
20 |
+
self.layout.addWidget(self.btn)
|
21 |
+
self.layout.addLayout(self.tips)
|
22 |
+
self.layout.addStretch()
|
23 |
+
|
24 |
+
def cfg_init(self):
|
25 |
+
super(Img2ImgPage, self).cfg_init()
|
26 |
+
|
27 |
+
self.tips.setVisible(not script.cfg("minimize_ui", bool))
|
28 |
+
|
29 |
+
def cfg_connect(self):
|
30 |
+
super(Img2ImgPage, self).cfg_connect()
|
31 |
+
self.btn.released.connect(lambda: script.action_img2img())
|
frontends/krita/krita_diff/pages/img_base.py
ADDED
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from krita import QHBoxLayout, QVBoxLayout, QWidget
|
2 |
+
|
3 |
+
from ..script import script
|
4 |
+
from ..widgets import (
|
5 |
+
QComboBoxLayout,
|
6 |
+
QLineEditLayout,
|
7 |
+
QPromptLayout,
|
8 |
+
QSpinBoxLayout,
|
9 |
+
StatusBar,
|
10 |
+
)
|
11 |
+
from .extension import ExtSectionLayout
|
12 |
+
|
13 |
+
|
14 |
+
class SDImgPageBase(QWidget):
|
15 |
+
def __init__(self, cfg_prefix: str, *args, **kwargs):
|
16 |
+
super(SDImgPageBase, self).__init__(*args, **kwargs)
|
17 |
+
|
18 |
+
self.status_bar = StatusBar()
|
19 |
+
|
20 |
+
self.prompt_layout = QPromptLayout(
|
21 |
+
script.cfg, f"{cfg_prefix}_prompt", f"{cfg_prefix}_negative_prompt"
|
22 |
+
)
|
23 |
+
|
24 |
+
self.seed_layout = QLineEditLayout(
|
25 |
+
script.cfg, f"{cfg_prefix}_seed", label="Seed:", placeholder="Random"
|
26 |
+
)
|
27 |
+
|
28 |
+
self.sampler_layout = QComboBoxLayout(
|
29 |
+
script.cfg,
|
30 |
+
f"{cfg_prefix}_sampler_list",
|
31 |
+
f"{cfg_prefix}_sampler",
|
32 |
+
label="Sampler:",
|
33 |
+
)
|
34 |
+
|
35 |
+
self.steps_layout = QSpinBoxLayout(
|
36 |
+
script.cfg, f"{cfg_prefix}_steps", label="Steps:", min=1, max=9999, step=1
|
37 |
+
)
|
38 |
+
self.cfg_scale_layout = QSpinBoxLayout(
|
39 |
+
script.cfg,
|
40 |
+
f"{cfg_prefix}_cfg_scale",
|
41 |
+
label="CFG scale:",
|
42 |
+
min=1.0,
|
43 |
+
max=9999.0,
|
44 |
+
)
|
45 |
+
|
46 |
+
self.ext_layout = ExtSectionLayout(cfg_prefix)
|
47 |
+
|
48 |
+
inline_layout = QHBoxLayout()
|
49 |
+
inline_layout.addLayout(self.steps_layout)
|
50 |
+
inline_layout.addLayout(self.cfg_scale_layout)
|
51 |
+
|
52 |
+
self.layout = layout = QVBoxLayout()
|
53 |
+
layout.setContentsMargins(0, 0, 0, 0)
|
54 |
+
|
55 |
+
layout.addWidget(self.status_bar)
|
56 |
+
layout.addLayout(self.ext_layout)
|
57 |
+
layout.addLayout(self.prompt_layout)
|
58 |
+
layout.addLayout(self.seed_layout)
|
59 |
+
layout.addLayout(self.sampler_layout)
|
60 |
+
layout.addLayout(inline_layout)
|
61 |
+
|
62 |
+
self.setLayout(layout)
|
63 |
+
|
64 |
+
# not added so inheritants can place it wherever they want
|
65 |
+
self.denoising_strength_layout = QSpinBoxLayout(
|
66 |
+
script.cfg,
|
67 |
+
f"{cfg_prefix}_denoising_strength",
|
68 |
+
label="Denoising strength:",
|
69 |
+
step=0.01,
|
70 |
+
)
|
71 |
+
|
72 |
+
def cfg_init(self):
|
73 |
+
self.ext_layout.cfg_init()
|
74 |
+
self.prompt_layout.cfg_init()
|
75 |
+
self.seed_layout.cfg_init()
|
76 |
+
self.sampler_layout.cfg_init()
|
77 |
+
self.steps_layout.cfg_init()
|
78 |
+
self.cfg_scale_layout.cfg_init()
|
79 |
+
self.denoising_strength_layout.cfg_init()
|
80 |
+
|
81 |
+
def cfg_connect(self):
|
82 |
+
self.ext_layout.cfg_connect()
|
83 |
+
self.prompt_layout.cfg_connect()
|
84 |
+
self.seed_layout.cfg_connect()
|
85 |
+
self.sampler_layout.cfg_connect()
|
86 |
+
self.steps_layout.cfg_connect()
|
87 |
+
self.cfg_scale_layout.cfg_connect()
|
88 |
+
self.denoising_strength_layout.cfg_connect()
|
89 |
+
|
90 |
+
script.status_changed.connect(lambda s: self.status_bar.set_status(s))
|
frontends/krita/krita_diff/pages/inpaint.py
ADDED
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from krita import QHBoxLayout, QPushButton
|
2 |
+
|
3 |
+
from ..script import script
|
4 |
+
from ..widgets import QCheckBox, QComboBoxLayout, QSpinBoxLayout, TipsLayout
|
5 |
+
from .img_base import SDImgPageBase
|
6 |
+
|
7 |
+
|
8 |
+
class InpaintPage(SDImgPageBase):
|
9 |
+
name = "Inpaint"
|
10 |
+
|
11 |
+
def __init__(self, *args, **kwargs):
|
12 |
+
super(InpaintPage, self).__init__(cfg_prefix="inpaint", *args, **kwargs)
|
13 |
+
self.layout.addLayout(self.denoising_strength_layout)
|
14 |
+
|
15 |
+
self.invert_mask = QCheckBox(script.cfg, "inpaint_invert_mask", "Invert mask")
|
16 |
+
# self.mask_blur_layout = QSpinBoxLayout(
|
17 |
+
# script.cfg, "inpaint_mask_blur", "Mask blur (px):", min=0, max=9999, step=1
|
18 |
+
# )
|
19 |
+
self.inpaint_mask_weight = QSpinBoxLayout(
|
20 |
+
script.cfg, "inpaint_mask_weight", "Mask weight:", step=0.01
|
21 |
+
)
|
22 |
+
|
23 |
+
inline1 = QHBoxLayout()
|
24 |
+
inline1.addWidget(self.invert_mask)
|
25 |
+
inline1.addLayout(self.inpaint_mask_weight)
|
26 |
+
# inline1.addLayout(self.mask_blur_layout)
|
27 |
+
|
28 |
+
self.fill_layout = QComboBoxLayout(
|
29 |
+
script.cfg, "inpaint_fill_list", "inpaint_fill", label="Inpaint fill:"
|
30 |
+
)
|
31 |
+
|
32 |
+
# self.full_res = QCheckBox(script.cfg, "inpaint_full_res", "Inpaint full res")
|
33 |
+
# self.full_res_padding_layout = QSpinBoxLayout(
|
34 |
+
# script.cfg,
|
35 |
+
# "inpaint_full_res_padding",
|
36 |
+
# "Padding (px):",
|
37 |
+
# min=0,
|
38 |
+
# max=9999,
|
39 |
+
# step=1,
|
40 |
+
# )
|
41 |
+
|
42 |
+
inline2 = QHBoxLayout()
|
43 |
+
# inline2.addWidget(self.full_res)
|
44 |
+
# inline2.addLayout(self.full_res_padding_layout)
|
45 |
+
|
46 |
+
self.tips = TipsLayout(
|
47 |
+
[
|
48 |
+
"Ensure the inpaint layer is selected.",
|
49 |
+
"Select what the model will see when inpainting. <em>Inpaint full res</em> is unnecessary.",
|
50 |
+
]
|
51 |
+
)
|
52 |
+
self.tips2 = TipsLayout(
|
53 |
+
[
|
54 |
+
'<a href="https://github.com/Interpause/auto-sd-paint-ext/wiki/Usage-Guide#inpainting" target="_blank">Inpaint Full Res & Mask Blur is obsolete; Click for new method.</a>'
|
55 |
+
],
|
56 |
+
prefix="",
|
57 |
+
)
|
58 |
+
self.btn = QPushButton("Start inpaint")
|
59 |
+
|
60 |
+
self.layout.addLayout(self.fill_layout)
|
61 |
+
self.layout.addLayout(inline1)
|
62 |
+
self.layout.addLayout(inline2)
|
63 |
+
self.layout.addWidget(self.btn)
|
64 |
+
self.layout.addLayout(self.tips2)
|
65 |
+
self.layout.addLayout(self.tips)
|
66 |
+
self.layout.addStretch()
|
67 |
+
|
68 |
+
def cfg_init(self):
|
69 |
+
super(InpaintPage, self).cfg_init()
|
70 |
+
# self.mask_blur_layout.cfg_init()
|
71 |
+
self.fill_layout.cfg_init()
|
72 |
+
self.inpaint_mask_weight.cfg_init()
|
73 |
+
# self.full_res_padding_layout.cfg_init()
|
74 |
+
self.invert_mask.cfg_init()
|
75 |
+
# self.full_res.cfg_init()
|
76 |
+
|
77 |
+
self.tips.setVisible(not script.cfg("minimize_ui", bool))
|
78 |
+
|
79 |
+
def cfg_connect(self):
|
80 |
+
super(InpaintPage, self).cfg_connect()
|
81 |
+
# self.mask_blur_layout.cfg_connect()
|
82 |
+
self.fill_layout.cfg_connect()
|
83 |
+
self.inpaint_mask_weight.cfg_connect()
|
84 |
+
# self.full_res_padding_layout.cfg_connect()
|
85 |
+
|
86 |
+
self.invert_mask.cfg_connect()
|
87 |
+
|
88 |
+
# def toggle_fullres(enabled):
|
89 |
+
# # hide/show fullres padding
|
90 |
+
# self.full_res_padding_layout.qlabel.setVisible(enabled)
|
91 |
+
# self.full_res_padding_layout.qspin.setVisible(enabled)
|
92 |
+
|
93 |
+
# self.full_res.cfg_connect()
|
94 |
+
# self.full_res.toggled.connect(toggle_fullres)
|
95 |
+
# toggle_fullres(self.full_res.isChecked())
|
96 |
+
|
97 |
+
self.btn.released.connect(lambda: script.action_inpaint())
|
frontends/krita/krita_diff/pages/txt2img.py
ADDED
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from krita import QHBoxLayout, QPushButton
|
2 |
+
|
3 |
+
from ..script import script
|
4 |
+
from ..widgets import QCheckBox, TipsLayout
|
5 |
+
from .img_base import SDImgPageBase
|
6 |
+
|
7 |
+
|
8 |
+
class Txt2ImgPage(SDImgPageBase):
|
9 |
+
name = "Txt2Img"
|
10 |
+
|
11 |
+
def __init__(self, *args, **kwargs):
|
12 |
+
super(Txt2ImgPage, self).__init__(cfg_prefix="txt2img", *args, **kwargs)
|
13 |
+
|
14 |
+
self.highres = QCheckBox(script.cfg, "txt2img_highres", "Highres fix")
|
15 |
+
|
16 |
+
inline_layout = QHBoxLayout()
|
17 |
+
inline_layout.addWidget(self.highres)
|
18 |
+
inline_layout.addLayout(self.denoising_strength_layout)
|
19 |
+
|
20 |
+
self.tips = TipsLayout(
|
21 |
+
["Set base_size & max_size higher for AUTO's txt2img highres fix to work."]
|
22 |
+
)
|
23 |
+
|
24 |
+
self.btn = QPushButton("Start txt2img")
|
25 |
+
|
26 |
+
self.layout.addLayout(inline_layout)
|
27 |
+
self.layout.addWidget(self.btn)
|
28 |
+
self.layout.addLayout(self.tips)
|
29 |
+
self.layout.addStretch()
|
30 |
+
|
31 |
+
def cfg_init(self):
|
32 |
+
super(Txt2ImgPage, self).cfg_init()
|
33 |
+
self.highres.cfg_init()
|
34 |
+
|
35 |
+
self.tips.setVisible(not script.cfg("minimize_ui", bool))
|
36 |
+
|
37 |
+
def cfg_connect(self):
|
38 |
+
super(Txt2ImgPage, self).cfg_connect()
|
39 |
+
|
40 |
+
def toggle_highres(enabled):
|
41 |
+
# hide/show denoising strength
|
42 |
+
self.denoising_strength_layout.qlabel.setVisible(enabled)
|
43 |
+
self.denoising_strength_layout.qspin.setVisible(enabled)
|
44 |
+
|
45 |
+
self.highres.cfg_connect()
|
46 |
+
self.highres.toggled.connect(toggle_highres)
|
47 |
+
toggle_highres(self.highres.isChecked())
|
48 |
+
|
49 |
+
self.btn.released.connect(lambda: script.action_txt2img())
|
frontends/krita/krita_diff/pages/upscale.py
ADDED
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from krita import QPushButton, QVBoxLayout, QWidget
|
2 |
+
|
3 |
+
from ..script import script
|
4 |
+
from ..widgets import QCheckBox, QComboBoxLayout, QLabel, StatusBar
|
5 |
+
|
6 |
+
|
7 |
+
# TODO: Become SD Upscale tab.
|
8 |
+
class UpscalePage(QWidget):
|
9 |
+
name = "Upscale"
|
10 |
+
|
11 |
+
def __init__(self, *args, **kwargs):
|
12 |
+
super(UpscalePage, self).__init__(*args, **kwargs)
|
13 |
+
|
14 |
+
self.status_bar = StatusBar()
|
15 |
+
|
16 |
+
self.upscaler_layout = QComboBoxLayout(
|
17 |
+
script.cfg, "upscaler_list", "upscale_upscaler_name", label="Upscaler:"
|
18 |
+
)
|
19 |
+
|
20 |
+
self.downscale_first = QCheckBox(
|
21 |
+
script.cfg,
|
22 |
+
"upscale_downscale_first",
|
23 |
+
"Downscale image x0.5 before upscaling",
|
24 |
+
)
|
25 |
+
|
26 |
+
self.note = QLabel(
|
27 |
+
"""
|
28 |
+
NOTE:<br/>
|
29 |
+
- txt2img & img2img will use the <em>Quick Config</em> Upscaler when needing to scale up.<br/>
|
30 |
+
- Upscaling manually is only useful if the image was resized via Krita.<br/>
|
31 |
+
- In the future, SD Upscaling will replace this tab! For now, use the WebUI.
|
32 |
+
"""
|
33 |
+
)
|
34 |
+
self.note.setWordWrap(True)
|
35 |
+
|
36 |
+
self.btn = QPushButton("Start upscaling")
|
37 |
+
|
38 |
+
layout = QVBoxLayout()
|
39 |
+
layout.setContentsMargins(0, 0, 0, 0)
|
40 |
+
|
41 |
+
layout.addWidget(self.status_bar)
|
42 |
+
layout.addWidget(self.note)
|
43 |
+
layout.addLayout(self.upscaler_layout)
|
44 |
+
layout.addWidget(self.downscale_first)
|
45 |
+
layout.addWidget(self.btn)
|
46 |
+
layout.addStretch()
|
47 |
+
|
48 |
+
self.setLayout(layout)
|
49 |
+
|
50 |
+
def cfg_init(self):
|
51 |
+
self.upscaler_layout.cfg_init()
|
52 |
+
self.downscale_first.cfg_init()
|
53 |
+
|
54 |
+
self.note.setVisible(not script.cfg("minimize_ui", bool))
|
55 |
+
|
56 |
+
def cfg_connect(self):
|
57 |
+
self.upscaler_layout.cfg_connect()
|
58 |
+
self.downscale_first.cfg_connect()
|
59 |
+
self.btn.released.connect(lambda: script.action_simple_upscale())
|
60 |
+
script.status_changed.connect(lambda s: self.status_bar.set_status(s))
|
frontends/krita/krita_diff/script.py
ADDED
@@ -0,0 +1,425 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import itertools
|
2 |
+
import os
|
3 |
+
import time
|
4 |
+
from typing import Union
|
5 |
+
|
6 |
+
from krita import (
|
7 |
+
Document,
|
8 |
+
Krita,
|
9 |
+
Node,
|
10 |
+
QImage,
|
11 |
+
QObject,
|
12 |
+
Qt,
|
13 |
+
QTimer,
|
14 |
+
Selection,
|
15 |
+
pyqtSignal,
|
16 |
+
)
|
17 |
+
|
18 |
+
from .client import Client
|
19 |
+
from .config import Config
|
20 |
+
from .defaults import (
|
21 |
+
ADD_MASK_TIMEOUT,
|
22 |
+
ERR_NO_DOCUMENT,
|
23 |
+
ETA_REFRESH_INTERVAL,
|
24 |
+
EXT_CFG_NAME,
|
25 |
+
STATE_INTERRUPT,
|
26 |
+
STATE_RESET_DEFAULT,
|
27 |
+
STATE_WAIT,
|
28 |
+
)
|
29 |
+
from .utils import (
|
30 |
+
b64_to_img,
|
31 |
+
find_optimal_selection_region,
|
32 |
+
get_desc_from_resp,
|
33 |
+
img_to_ba,
|
34 |
+
save_img,
|
35 |
+
)
|
36 |
+
|
37 |
+
|
38 |
+
# Does it actually have to be a QObject?
|
39 |
+
# The only possible use I see is for event emitting
|
40 |
+
class Script(QObject):
|
41 |
+
cfg: Config
|
42 |
+
"""config singleton"""
|
43 |
+
client: Client
|
44 |
+
"""API client singleton"""
|
45 |
+
status: str
|
46 |
+
"""Current status (shown in status bar)"""
|
47 |
+
app: Krita
|
48 |
+
"""Krita's Application instance (KDE Application)"""
|
49 |
+
doc: Document
|
50 |
+
"""Currently opened document if any"""
|
51 |
+
node: Node
|
52 |
+
"""Currently selected layer in Krita"""
|
53 |
+
selection: Selection
|
54 |
+
"""Selection region in Krita"""
|
55 |
+
x: int
|
56 |
+
"""Left position of selection"""
|
57 |
+
y: int
|
58 |
+
"""Top position of selection"""
|
59 |
+
width: int
|
60 |
+
"""Width of selection"""
|
61 |
+
height: int
|
62 |
+
"""Height of selection"""
|
63 |
+
status_changed = pyqtSignal(str)
|
64 |
+
config_updated = pyqtSignal()
|
65 |
+
|
66 |
+
def __init__(self):
|
67 |
+
super(Script, self).__init__()
|
68 |
+
# Persistent settings (should reload between Krita sessions)
|
69 |
+
self.cfg = Config()
|
70 |
+
# used for webUI scripts aka extensions not to be confused with their extensions
|
71 |
+
self.ext_cfg = Config(name=EXT_CFG_NAME, model=None)
|
72 |
+
self.client = Client(self.cfg, self.ext_cfg)
|
73 |
+
self.client.status.connect(self.status_changed.emit)
|
74 |
+
self.client.config_updated.connect(self.config_updated.emit)
|
75 |
+
self.eta_timer = QTimer()
|
76 |
+
self.eta_timer.setInterval(ETA_REFRESH_INTERVAL)
|
77 |
+
self.eta_timer.timeout.connect(lambda: self.action_update_eta())
|
78 |
+
|
79 |
+
def restore_defaults(self, if_empty=False):
|
80 |
+
"""Restore to default config."""
|
81 |
+
self.cfg.restore_defaults(not if_empty)
|
82 |
+
self.ext_cfg.config.remove("")
|
83 |
+
|
84 |
+
if not if_empty:
|
85 |
+
self.status_changed.emit(STATE_RESET_DEFAULT)
|
86 |
+
|
87 |
+
def update_selection(self):
|
88 |
+
"""Update references to key Krita objects as well as selection information."""
|
89 |
+
self.app = Krita.instance()
|
90 |
+
self.doc = self.app.activeDocument()
|
91 |
+
|
92 |
+
# self.doc doesnt exist at app startup
|
93 |
+
if not self.doc:
|
94 |
+
self.status_changed.emit(ERR_NO_DOCUMENT)
|
95 |
+
return
|
96 |
+
|
97 |
+
self.node = self.doc.activeNode()
|
98 |
+
self.selection = self.doc.selection()
|
99 |
+
|
100 |
+
is_not_selected = (
|
101 |
+
self.selection is None
|
102 |
+
or self.selection.width() < 1
|
103 |
+
or self.selection.height() < 1
|
104 |
+
)
|
105 |
+
if is_not_selected:
|
106 |
+
self.x = 0
|
107 |
+
self.y = 0
|
108 |
+
self.width = self.doc.width()
|
109 |
+
self.height = self.doc.height()
|
110 |
+
self.selection = None # for the two other cases of invalid selection
|
111 |
+
else:
|
112 |
+
self.x = self.selection.x()
|
113 |
+
self.y = self.selection.y()
|
114 |
+
self.width = self.selection.width()
|
115 |
+
self.height = self.selection.height()
|
116 |
+
|
117 |
+
assert (
|
118 |
+
self.doc.colorDepth() == "U8"
|
119 |
+
), f'Only "8-bit integer/channel" supported, Document Color Depth: {self.doc.colorDepth()}'
|
120 |
+
assert (
|
121 |
+
self.doc.colorModel() == "RGBA"
|
122 |
+
), f'Only "RGB/Alpha" supported, Document Color Model: {self.doc.colorModel()}'
|
123 |
+
|
124 |
+
def adjust_selection(self):
|
125 |
+
"""Adjust selection region to account for scaling and striding to prevent image stretch."""
|
126 |
+
if self.selection is not None and self.cfg("fix_aspect_ratio", bool):
|
127 |
+
x, y, width, height = find_optimal_selection_region(
|
128 |
+
self.cfg("sd_base_size", int),
|
129 |
+
self.cfg("sd_max_size", int),
|
130 |
+
self.x,
|
131 |
+
self.y,
|
132 |
+
self.width,
|
133 |
+
self.height,
|
134 |
+
self.doc.width(),
|
135 |
+
self.doc.height(),
|
136 |
+
)
|
137 |
+
|
138 |
+
self.x = x
|
139 |
+
self.y = y
|
140 |
+
self.width = width
|
141 |
+
self.height = height
|
142 |
+
|
143 |
+
def get_selection_image(self) -> QImage:
|
144 |
+
"""QImage of selection"""
|
145 |
+
return QImage(
|
146 |
+
self.doc.pixelData(self.x, self.y, self.width, self.height),
|
147 |
+
self.width,
|
148 |
+
self.height,
|
149 |
+
QImage.Format_RGBA8888,
|
150 |
+
).rgbSwapped()
|
151 |
+
|
152 |
+
def get_mask_image(self) -> Union[QImage, None]:
|
153 |
+
"""QImage of mask layer for inpainting"""
|
154 |
+
if self.node.type() not in {"paintlayer", "filelayer"}:
|
155 |
+
return None
|
156 |
+
|
157 |
+
return QImage(
|
158 |
+
self.node.pixelData(self.x, self.y, self.width, self.height),
|
159 |
+
self.width,
|
160 |
+
self.height,
|
161 |
+
QImage.Format_RGBA8888,
|
162 |
+
).rgbSwapped()
|
163 |
+
|
164 |
+
def img_inserter(self, x, y, width, height, group: str = None):
|
165 |
+
"""Return frozen image inserter to insert images as new layer."""
|
166 |
+
# Selection may change before callback, so freeze selection region
|
167 |
+
has_selection = self.selection is not None
|
168 |
+
glayer = self.doc.createGroupLayer(group) if group else None
|
169 |
+
|
170 |
+
def create_layer(name: str):
|
171 |
+
"""Create new layer in document or group"""
|
172 |
+
layer = self.doc.createNode(name, "paintLayer")
|
173 |
+
parent = self.doc.rootNode()
|
174 |
+
if glayer:
|
175 |
+
glayer.addChildNode(layer, None)
|
176 |
+
parent.addChildNode(glayer, None)
|
177 |
+
else:
|
178 |
+
parent.addChildNode(layer, None)
|
179 |
+
return layer
|
180 |
+
|
181 |
+
# TODO: Insert images inside a group layer for better organization
|
182 |
+
# Group layer name can contain model name, prompt, etc
|
183 |
+
def insert(layer_name, enc):
|
184 |
+
nonlocal x, y, width, height, has_selection
|
185 |
+
print(f"inserting layer {layer_name}")
|
186 |
+
print(f"data size: {len(enc)}")
|
187 |
+
|
188 |
+
# QImage.Format_RGB32 (4) is default format after decoding image
|
189 |
+
# QImage.Format_RGBA8888 (17) is format used in Krita tutorial
|
190 |
+
# both are compatible, & converting from 4 to 17 required a RGB swap
|
191 |
+
# Likewise for 5 & 18 (their RGBA counterparts)
|
192 |
+
image = b64_to_img(enc)
|
193 |
+
print(
|
194 |
+
f"image created: {image}, {image.width()}x{image.height()}, depth: {image.depth()}, format: {image.format()}"
|
195 |
+
)
|
196 |
+
|
197 |
+
# NOTE: Scaling is usually done by backend (although I am reconsidering this)
|
198 |
+
# The scaling here is for SD Upscale or Upscale on a selection region rather than whole image
|
199 |
+
# Image won't be scaled down ONLY if there is no selection; i.e. selecting whole image will scale down,
|
200 |
+
# not selecting anything won't scale down, leading to the canvas being resized afterwards
|
201 |
+
if has_selection and (image.width() != width or image.height() != height):
|
202 |
+
print(f"Rescaling image to selection: {width}x{height}")
|
203 |
+
image = image.scaled(
|
204 |
+
width, height, transformMode=Qt.SmoothTransformation
|
205 |
+
)
|
206 |
+
|
207 |
+
# Resize (not scale!) canvas if image is larger (i.e. outpainting or Upscale was used)
|
208 |
+
if image.width() > self.doc.width() or image.height() > self.doc.height():
|
209 |
+
# NOTE:
|
210 |
+
# - user's selection will be partially ignored if image is larger than canvas
|
211 |
+
# - it is complex to scale/resize the image such that image fits in the newly scaled selection
|
212 |
+
# - the canvas will still be resized even if the image fits after transparency masking
|
213 |
+
print("Image is larger than canvas! Resizing...")
|
214 |
+
new_width, new_height = self.doc.width(), self.doc.height()
|
215 |
+
if image.width() > self.doc.width():
|
216 |
+
x, width, new_width = 0, image.width(), image.width()
|
217 |
+
if image.height() > self.doc.height():
|
218 |
+
y, height, new_height = 0, image.height(), image.height()
|
219 |
+
self.doc.resizeImage(0, 0, new_width, new_height)
|
220 |
+
|
221 |
+
ba = img_to_ba(image)
|
222 |
+
layer = create_layer(layer_name)
|
223 |
+
# layer.setColorSpace() doesn't pernamently convert layer depth etc...
|
224 |
+
|
225 |
+
# Don't fail silently for setPixelData(); fails if bit depth or number of channels mismatch
|
226 |
+
size = ba.size()
|
227 |
+
expected = layer.pixelData(x, y, width, height).size()
|
228 |
+
assert expected == size, f"Raw data size: {size}, Expected size: {expected}"
|
229 |
+
|
230 |
+
print(f"inserting at x: {x}, y: {y}, w: {width}, h: {height}")
|
231 |
+
layer.setPixelData(ba, x, y, width, height)
|
232 |
+
return layer
|
233 |
+
|
234 |
+
if glayer:
|
235 |
+
return insert, glayer
|
236 |
+
return insert
|
237 |
+
|
238 |
+
def apply_txt2img(self):
|
239 |
+
# freeze selection region
|
240 |
+
insert, glayer = self.img_inserter(
|
241 |
+
self.x, self.y, self.width, self.height, group="a"
|
242 |
+
)
|
243 |
+
mask_trigger = self.transparency_mask_inserter()
|
244 |
+
|
245 |
+
def cb(response):
|
246 |
+
if len(self.client.long_reqs) == 1: # last request
|
247 |
+
self.eta_timer.stop()
|
248 |
+
assert response is not None, "Backend Error, check terminal"
|
249 |
+
outputs = response["outputs"]
|
250 |
+
glayer_name, layer_names = get_desc_from_resp(response, "txt2img")
|
251 |
+
layers = [
|
252 |
+
insert(name if name else f"txt2img {i + 1}", output)
|
253 |
+
for output, name, i in zip(outputs, layer_names, itertools.count())
|
254 |
+
]
|
255 |
+
if self.cfg("hide_layers", bool):
|
256 |
+
for layer in layers[:-1]:
|
257 |
+
layer.setVisible(False)
|
258 |
+
glayer.setName(glayer_name)
|
259 |
+
self.doc.refreshProjection()
|
260 |
+
mask_trigger(layers)
|
261 |
+
|
262 |
+
self.eta_timer.start(ETA_REFRESH_INTERVAL)
|
263 |
+
self.client.post_txt2img(
|
264 |
+
cb, self.width, self.height, self.selection is not None
|
265 |
+
)
|
266 |
+
|
267 |
+
def apply_img2img(self, mode):
|
268 |
+
insert, glayer = self.img_inserter(
|
269 |
+
self.x, self.y, self.width, self.height, group="a"
|
270 |
+
)
|
271 |
+
mask_trigger = self.transparency_mask_inserter()
|
272 |
+
mask_image = self.get_mask_image()
|
273 |
+
|
274 |
+
path = os.path.join(self.cfg("sample_path", str), f"{int(time.time())}.png")
|
275 |
+
mask_path = os.path.join(
|
276 |
+
self.cfg("sample_path", str), f"{int(time.time())}_mask.png"
|
277 |
+
)
|
278 |
+
if mode == 1 and mask_image is not None:
|
279 |
+
if self.cfg("save_temp_images", bool):
|
280 |
+
save_img(mask_image, mask_path)
|
281 |
+
# auto-hide mask layer before getting selection image
|
282 |
+
self.node.setVisible(False)
|
283 |
+
self.doc.refreshProjection()
|
284 |
+
|
285 |
+
sel_image = self.get_selection_image()
|
286 |
+
if self.cfg("save_temp_images", bool):
|
287 |
+
save_img(sel_image, path)
|
288 |
+
|
289 |
+
def cb(response):
|
290 |
+
if len(self.client.long_reqs) == 1: # last request
|
291 |
+
self.eta_timer.stop()
|
292 |
+
assert response is not None, "Backend Error, check terminal"
|
293 |
+
|
294 |
+
outputs = response["outputs"]
|
295 |
+
layer_name_prefix = (
|
296 |
+
"inpaint" if mode == 1 else "sd upscale" if mode == 2 else "img2img"
|
297 |
+
)
|
298 |
+
glayer_name, layer_names = get_desc_from_resp(response, layer_name_prefix)
|
299 |
+
layers = [
|
300 |
+
insert(name if name else f"{layer_name_prefix} {i + 1}", output)
|
301 |
+
for output, name, i in zip(outputs, layer_names, itertools.count())
|
302 |
+
]
|
303 |
+
if self.cfg("hide_layers", bool):
|
304 |
+
for layer in layers[:-1]:
|
305 |
+
layer.setVisible(False)
|
306 |
+
glayer.setName(glayer_name)
|
307 |
+
self.doc.refreshProjection()
|
308 |
+
# dont need transparency mask for inpaint mode
|
309 |
+
if mode == 0:
|
310 |
+
mask_trigger(layers)
|
311 |
+
|
312 |
+
method = self.client.post_inpaint if mode == 1 else self.client.post_img2img
|
313 |
+
self.eta_timer.start()
|
314 |
+
method(
|
315 |
+
cb,
|
316 |
+
sel_image,
|
317 |
+
mask_image, # is unused by backend in img2img mode
|
318 |
+
self.selection is not None,
|
319 |
+
)
|
320 |
+
|
321 |
+
def apply_simple_upscale(self):
|
322 |
+
insert = self.img_inserter(self.x, self.y, self.width, self.height)
|
323 |
+
sel_image = self.get_selection_image()
|
324 |
+
|
325 |
+
path = os.path.join(self.cfg("sample_path", str), f"{int(time.time())}.png")
|
326 |
+
if self.cfg("save_temp_images", bool):
|
327 |
+
save_img(sel_image, path)
|
328 |
+
|
329 |
+
def cb(response):
|
330 |
+
assert response is not None, "Backend Error, check terminal"
|
331 |
+
output = response["output"]
|
332 |
+
insert(f"upscale", output)
|
333 |
+
self.doc.refreshProjection()
|
334 |
+
|
335 |
+
self.client.post_upscale(cb, sel_image)
|
336 |
+
|
337 |
+
def transparency_mask_inserter(self):
|
338 |
+
"""Mask out extra regions due to adjust_selection()."""
|
339 |
+
orig_selection = self.selection.duplicate() if self.selection else None
|
340 |
+
|
341 |
+
def add_mask(layers):
|
342 |
+
self.doc.waitForDone()
|
343 |
+
cur_selection = self.selection
|
344 |
+
cur_layer = self.doc.activeNode()
|
345 |
+
for layer in layers:
|
346 |
+
self.doc.setActiveNode(layer)
|
347 |
+
self.doc.setSelection(orig_selection)
|
348 |
+
self.app.action("add_new_transparency_mask").trigger()
|
349 |
+
self.doc.setSelection(cur_selection) # reset to current selection
|
350 |
+
self.doc.setActiveNode(cur_layer) # reset to current layer
|
351 |
+
|
352 |
+
def trigger_mask_adding(layers):
|
353 |
+
if self.cfg("create_mask_layer", bool):
|
354 |
+
# need timeout to ensure layer exists first else crash
|
355 |
+
QTimer.singleShot(ADD_MASK_TIMEOUT, lambda: add_mask(layers))
|
356 |
+
|
357 |
+
return trigger_mask_adding
|
358 |
+
|
359 |
+
# Actions
|
360 |
+
def action_txt2img(self):
|
361 |
+
self.status_changed.emit(STATE_WAIT)
|
362 |
+
self.update_selection()
|
363 |
+
if not self.doc:
|
364 |
+
return
|
365 |
+
self.adjust_selection()
|
366 |
+
self.apply_txt2img()
|
367 |
+
|
368 |
+
def action_img2img(self):
|
369 |
+
self.status_changed.emit(STATE_WAIT)
|
370 |
+
self.update_selection()
|
371 |
+
if not self.doc:
|
372 |
+
return
|
373 |
+
self.adjust_selection()
|
374 |
+
self.apply_img2img(mode=0)
|
375 |
+
|
376 |
+
def action_sd_upscale(self):
|
377 |
+
assert False, "disabled"
|
378 |
+
self.status_changed.emit(STATE_WAIT)
|
379 |
+
self.update_selection()
|
380 |
+
self.apply_img2img(mode=2)
|
381 |
+
|
382 |
+
def action_inpaint(self):
|
383 |
+
self.status_changed.emit(STATE_WAIT)
|
384 |
+
self.update_selection()
|
385 |
+
if not self.doc:
|
386 |
+
return
|
387 |
+
self.adjust_selection()
|
388 |
+
self.apply_img2img(mode=1)
|
389 |
+
|
390 |
+
def action_simple_upscale(self):
|
391 |
+
self.status_changed.emit(STATE_WAIT)
|
392 |
+
self.update_selection()
|
393 |
+
if not self.doc:
|
394 |
+
return
|
395 |
+
self.apply_simple_upscale()
|
396 |
+
|
397 |
+
def action_update_config(self):
|
398 |
+
"""Update certain config/state from the backend."""
|
399 |
+
self.client.get_config()
|
400 |
+
|
401 |
+
def action_interrupt(self):
|
402 |
+
def cb(resp=None):
|
403 |
+
self.status_changed.emit(STATE_INTERRUPT)
|
404 |
+
|
405 |
+
self.client.post_interrupt(cb)
|
406 |
+
|
407 |
+
def action_update_eta(self):
|
408 |
+
def cb(resp=None):
|
409 |
+
# print(resp)
|
410 |
+
# NOTE: progress & eta_relative is bugged upstream when there is multiple jobs
|
411 |
+
# so we use a substitute that seems to work
|
412 |
+
state = resp["state"]
|
413 |
+
cur_step = state["sampling_step"]
|
414 |
+
total_steps = state["sampling_steps"]
|
415 |
+
# doesnt take into account batch count
|
416 |
+
num_jobs = len(self.client.long_reqs) - 1
|
417 |
+
|
418 |
+
self.status_changed.emit(
|
419 |
+
f"Step {cur_step}/{total_steps} ({num_jobs} in queue)"
|
420 |
+
)
|
421 |
+
|
422 |
+
self.client.get_progress(cb)
|
423 |
+
|
424 |
+
|
425 |
+
script = Script()
|
frontends/krita/krita_diff/style.py
ADDED
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# NOTE: see https://bugreports.qt.io/browse/QTBUG-22862
|
2 |
+
# We cant set layout padding via stylesheet
|
3 |
+
|
4 |
+
style = """
|
5 |
+
QTabBar::tab {
|
6 |
+
padding: 4px 2px 4px 2px;
|
7 |
+
}
|
8 |
+
"""
|
frontends/krita/krita_diff/utils.py
ADDED
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import json
|
2 |
+
import re
|
3 |
+
from itertools import cycle
|
4 |
+
from math import ceil
|
5 |
+
|
6 |
+
from krita import Krita, QBuffer, QByteArray, QImage, QIODevice, Qt
|
7 |
+
|
8 |
+
from .config import Config
|
9 |
+
from .defaults import (
|
10 |
+
TAB_CONFIG,
|
11 |
+
TAB_IMG2IMG,
|
12 |
+
TAB_INPAINT,
|
13 |
+
TAB_SDCOMMON,
|
14 |
+
TAB_TXT2IMG,
|
15 |
+
TAB_UPSCALE,
|
16 |
+
)
|
17 |
+
|
18 |
+
|
19 |
+
def fix_prompt(prompt: str):
|
20 |
+
"""Replace empty prompts with None."""
|
21 |
+
return prompt if prompt != "" else None
|
22 |
+
|
23 |
+
|
24 |
+
def get_ext_key(ext_type: str, ext_name: str, index: int = None):
|
25 |
+
"""Get name of config key where the ext values would be stored."""
|
26 |
+
return "_".join(
|
27 |
+
[
|
28 |
+
ext_type,
|
29 |
+
re.sub(r"\W+", "", ext_name.lower()),
|
30 |
+
"meta" if index is None else str(index),
|
31 |
+
]
|
32 |
+
)
|
33 |
+
|
34 |
+
|
35 |
+
def get_ext_args(ext_cfg: Config, ext_type: str, ext_name: str):
|
36 |
+
"""Get args for script in positional list form."""
|
37 |
+
raw = ext_cfg(get_ext_key(ext_type, ext_name))
|
38 |
+
meta = []
|
39 |
+
try:
|
40 |
+
meta = json.loads(raw)
|
41 |
+
except json.JSONDecodeError:
|
42 |
+
print(f"Invalid metadata: {raw}")
|
43 |
+
args = []
|
44 |
+
for i, o in enumerate(meta):
|
45 |
+
typ = type(o["val"])
|
46 |
+
if issubclass(typ, list):
|
47 |
+
typ = "QStringList"
|
48 |
+
val = ext_cfg(get_ext_key(ext_type, ext_name, i), typ)
|
49 |
+
args.append(val)
|
50 |
+
return args
|
51 |
+
|
52 |
+
|
53 |
+
def find_fixed_aspect_ratio(
|
54 |
+
base_size: int, max_size: int, orig_width: int, orig_height: int
|
55 |
+
):
|
56 |
+
"""Copy of `krita_server.utils.sddebz_highres_fix()`.
|
57 |
+
|
58 |
+
This is used by `find_optimal_selection_region()` below to adjust the selected region.
|
59 |
+
"""
|
60 |
+
|
61 |
+
def rnd(r, x, z=64):
|
62 |
+
"""Scale dimension x with stride z while attempting to preserve aspect ratio r."""
|
63 |
+
return z * ceil(r * x / z)
|
64 |
+
|
65 |
+
ratio = orig_width / orig_height
|
66 |
+
|
67 |
+
# height is smaller dimension
|
68 |
+
if orig_width > orig_height:
|
69 |
+
width, height = rnd(ratio, base_size), base_size
|
70 |
+
if width > max_size:
|
71 |
+
width, height = max_size, rnd(1 / ratio, max_size)
|
72 |
+
# width is smaller dimension
|
73 |
+
else:
|
74 |
+
width, height = base_size, rnd(1 / ratio, base_size)
|
75 |
+
if height > max_size:
|
76 |
+
width, height = rnd(ratio, max_size), max_size
|
77 |
+
|
78 |
+
return width / height
|
79 |
+
|
80 |
+
|
81 |
+
def find_optimal_selection_region(
|
82 |
+
base_size: int,
|
83 |
+
max_size: int,
|
84 |
+
orig_x: int,
|
85 |
+
orig_y: int,
|
86 |
+
orig_width: int,
|
87 |
+
orig_height: int,
|
88 |
+
canvas_width: int,
|
89 |
+
canvas_height: int,
|
90 |
+
):
|
91 |
+
"""Adjusts the selected region in order to attempt to preserve the original
|
92 |
+
aspect ratio of the selection. This prevents the image from being stretched
|
93 |
+
after being scaled and strided.
|
94 |
+
|
95 |
+
After grasping what @sddebz intended to do, I fixed some logical errors &
|
96 |
+
made it clearer.
|
97 |
+
|
98 |
+
Iterating the padding is naive, but easier to understand & verify then figuring
|
99 |
+
out how to grow the rectangle using the fixed aspect ratio alone while accounting
|
100 |
+
for the canvas boundary. Also, it only grows the selection, not shrink, to
|
101 |
+
prevent clipping what the user selected.
|
102 |
+
|
103 |
+
Args:
|
104 |
+
base_size (int): Native/base input size of the model.
|
105 |
+
max_size (int): Max input size to accept.
|
106 |
+
orig_x (int): Original left position of selection.
|
107 |
+
orig_y (int): Original top position of selection.
|
108 |
+
orig_width (int): Original width of selection.
|
109 |
+
orig_height (int): Original height of selection.
|
110 |
+
canvas_width (int): Canvas width.
|
111 |
+
canvas_height (int): Canvas height.
|
112 |
+
|
113 |
+
Returns:
|
114 |
+
Tuple[int, int, int, int]: Best x, y, width, height to use.
|
115 |
+
"""
|
116 |
+
orig_ratio = orig_width / orig_height
|
117 |
+
fix_ratio = find_fixed_aspect_ratio(base_size, max_size, orig_width, orig_height)
|
118 |
+
|
119 |
+
# h * (w/h - w/h) = w
|
120 |
+
xpad_limit = ceil(abs(fix_ratio - orig_ratio) * orig_height) * 2
|
121 |
+
# w * (h/w - h/w) = h
|
122 |
+
ypad_limit = ceil(abs(1 / fix_ratio - 1 / orig_ratio) * orig_width) * 2
|
123 |
+
|
124 |
+
best_x = orig_x
|
125 |
+
best_y = orig_y
|
126 |
+
best_width = orig_width
|
127 |
+
best_height = orig_height
|
128 |
+
best_delta = abs(fix_ratio - orig_ratio)
|
129 |
+
for x in range(1, xpad_limit + 1):
|
130 |
+
for y in range(1, ypad_limit + 1):
|
131 |
+
# account for boundary of canvas
|
132 |
+
# padding is on both sides i.e the selection grows while center anchored
|
133 |
+
x1 = max(0, orig_x - x // 2)
|
134 |
+
x2 = min(canvas_width, x1 + orig_width + x)
|
135 |
+
y1 = max(0, orig_y - y // 2)
|
136 |
+
y2 = min(canvas_height, y1 + orig_height + y)
|
137 |
+
|
138 |
+
new_width = x2 - x1
|
139 |
+
new_height = y2 - y1
|
140 |
+
new_ratio = new_width / new_height
|
141 |
+
new_delta = abs(fix_ratio - new_ratio)
|
142 |
+
if new_delta < best_delta:
|
143 |
+
best_delta = new_delta
|
144 |
+
best_x = x1
|
145 |
+
best_y = y1
|
146 |
+
best_width = new_width
|
147 |
+
best_height = new_height
|
148 |
+
|
149 |
+
return best_x, best_y, best_width, best_height
|
150 |
+
|
151 |
+
|
152 |
+
def save_img(img: QImage, path: str):
|
153 |
+
"""Expects QImage"""
|
154 |
+
# png is lossless; setting compression to max (0) won't affect quality
|
155 |
+
# NOTE: save_img WILL FAIL when using remote backend
|
156 |
+
try:
|
157 |
+
img.save(path, "PNG", 0)
|
158 |
+
except:
|
159 |
+
pass
|
160 |
+
|
161 |
+
|
162 |
+
def img_to_ba(img: QImage):
|
163 |
+
"""Converts QImage to QByteArray"""
|
164 |
+
ptr = img.bits()
|
165 |
+
ptr.setsize(img.byteCount())
|
166 |
+
return QByteArray(ptr.asstring())
|
167 |
+
|
168 |
+
|
169 |
+
def img_to_b64(img: QImage):
|
170 |
+
"""Converts QImage to base64-encoded string"""
|
171 |
+
ba = QByteArray()
|
172 |
+
buffer = QBuffer(ba)
|
173 |
+
buffer.open(QIODevice.WriteOnly)
|
174 |
+
img.save(buffer, "PNG", 0)
|
175 |
+
return ba.toBase64().data().decode("utf-8")
|
176 |
+
|
177 |
+
|
178 |
+
def b64_to_img(enc: str):
|
179 |
+
"""Converts base64-encoded string to QImage"""
|
180 |
+
ba = QByteArray.fromBase64(enc.encode("utf-8"))
|
181 |
+
return QImage.fromData(ba, "PNG")
|
182 |
+
|
183 |
+
|
184 |
+
def bytewise_xor(msg: bytes, key: bytes):
|
185 |
+
"""Used for decrypting/encrypting request/response bodies."""
|
186 |
+
return bytes(v ^ k for v, k in zip(msg, cycle(key)))
|
187 |
+
|
188 |
+
|
189 |
+
def get_desc_from_resp(resp: dict, type: str = ""):
|
190 |
+
"""Get description of image generation from backend response."""
|
191 |
+
try:
|
192 |
+
info = json.loads(resp["info"])
|
193 |
+
seeds = info["all_seeds"]
|
194 |
+
glayer_desc = f"""[{type}]
|
195 |
+
Prompt: {info['prompt']},
|
196 |
+
Negative Prompt: {info['negative_prompt']},
|
197 |
+
Model: {info['sd_model_hash']},
|
198 |
+
Sampler: {info['sampler_name']},
|
199 |
+
Scale: {info['cfg_scale']},
|
200 |
+
Steps: {info['steps']}"""
|
201 |
+
layers_desc = []
|
202 |
+
for (seed,) in zip(seeds):
|
203 |
+
layers_desc.append(f"Seed: {seed}")
|
204 |
+
return glayer_desc, layers_desc
|
205 |
+
except:
|
206 |
+
return f"[{type}]", cycle([None])
|
207 |
+
|
208 |
+
|
209 |
+
def reset_docker_layout():
|
210 |
+
"""NOTE: Default stacking of dockers hardcoded here."""
|
211 |
+
docker_ids = {
|
212 |
+
TAB_SDCOMMON,
|
213 |
+
TAB_CONFIG,
|
214 |
+
TAB_IMG2IMG,
|
215 |
+
TAB_TXT2IMG,
|
216 |
+
TAB_UPSCALE,
|
217 |
+
TAB_INPAINT,
|
218 |
+
}
|
219 |
+
instance = Krita.instance()
|
220 |
+
# Assumption that currently active window is the main window
|
221 |
+
window = instance.activeWindow()
|
222 |
+
dockers = {
|
223 |
+
d.objectName(): d for d in instance.dockers() if d.objectName() in docker_ids
|
224 |
+
}
|
225 |
+
qmainwindow = window.qwindow()
|
226 |
+
# Reset all dockers
|
227 |
+
for d in dockers.values():
|
228 |
+
d.setFloating(False)
|
229 |
+
d.setVisible(True)
|
230 |
+
qmainwindow.addDockWidget(Qt.LeftDockWidgetArea, d)
|
231 |
+
|
232 |
+
qmainwindow.tabifyDockWidget(dockers[TAB_SDCOMMON], dockers[TAB_CONFIG])
|
233 |
+
qmainwindow.tabifyDockWidget(dockers[TAB_TXT2IMG], dockers[TAB_IMG2IMG])
|
234 |
+
qmainwindow.tabifyDockWidget(dockers[TAB_TXT2IMG], dockers[TAB_INPAINT])
|
235 |
+
qmainwindow.tabifyDockWidget(dockers[TAB_TXT2IMG], dockers[TAB_UPSCALE])
|
236 |
+
dockers[TAB_SDCOMMON].raise_()
|
237 |
+
dockers[TAB_INPAINT].raise_()
|
frontends/krita/krita_diff/widgets/__init__.py
ADDED
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from .checkbox import QCheckBox, QMultiCheckBoxLayout
|
2 |
+
from .combo_box import QComboBoxLayout
|
3 |
+
from .line_edit import QLineEditLayout
|
4 |
+
from .misc import QLabel
|
5 |
+
from .prompt import QPromptLayout
|
6 |
+
from .spin_box import QSpinBoxLayout
|
7 |
+
from .status_bar import StatusBar
|
8 |
+
from .tips import TipsLayout
|
frontends/krita/krita_diff/widgets/checkbox.py
ADDED
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from functools import partial
|
2 |
+
|
3 |
+
from krita import QCheckBox as _QCheckBox
|
4 |
+
from krita import QHBoxLayout, QVBoxLayout
|
5 |
+
|
6 |
+
from ..config import Config
|
7 |
+
from .misc import QLabel
|
8 |
+
|
9 |
+
|
10 |
+
class QCheckBox(_QCheckBox):
|
11 |
+
def __init__(self, cfg: Config, field_cfg: str, label: str = None, *args, **kwargs):
|
12 |
+
"""QCheckBox compatible with the config system.
|
13 |
+
|
14 |
+
Args:
|
15 |
+
cfg (Config): Config to connect to.
|
16 |
+
field_cfg (str): Config key to read/write value to.
|
17 |
+
label (str): Label, uses `field_cfg` if None. Defaults to None.
|
18 |
+
"""
|
19 |
+
label = field_cfg if label is None else label
|
20 |
+
super(QCheckBox, self).__init__(label, *args, **kwargs)
|
21 |
+
|
22 |
+
self.cfg = cfg
|
23 |
+
self.field_cfg = field_cfg
|
24 |
+
|
25 |
+
def cfg_init(self):
|
26 |
+
self.setChecked(self.cfg(self.field_cfg, bool))
|
27 |
+
|
28 |
+
def cfg_connect(self):
|
29 |
+
self.toggled.connect(partial(self.cfg.set, self.field_cfg))
|
30 |
+
|
31 |
+
|
32 |
+
# TODO: adjust number of checkboxes based on options without needing restart
|
33 |
+
class QMultiCheckBoxLayout(QVBoxLayout):
|
34 |
+
def __init__(
|
35 |
+
self,
|
36 |
+
cfg: Config,
|
37 |
+
options_cfg: list,
|
38 |
+
selected_cfg: str,
|
39 |
+
label: str = None,
|
40 |
+
*args,
|
41 |
+
**kwargs
|
42 |
+
):
|
43 |
+
"""Layout for labelled multi-select CheckBox.
|
44 |
+
|
45 |
+
Args:
|
46 |
+
cfg (Config): Config to connect to.
|
47 |
+
options_cfg (list): List of options.
|
48 |
+
selected_cfg (str): Config key to read/write selected options to.
|
49 |
+
label (str, optional): Label, uses `selected_cfg` if None. Defaults to None.
|
50 |
+
"""
|
51 |
+
super(QMultiCheckBoxLayout, self).__init__(*args, **kwargs)
|
52 |
+
|
53 |
+
self.cfg = cfg
|
54 |
+
self.options_cfg = options_cfg
|
55 |
+
self.selected_cfg = selected_cfg
|
56 |
+
|
57 |
+
self.qlabel = QLabel(self.selected_cfg if label is None else label)
|
58 |
+
|
59 |
+
# TODO: flexbox-like row breaking
|
60 |
+
self.row = QHBoxLayout()
|
61 |
+
self.qcheckboxes = []
|
62 |
+
for opt in self.options_cfg:
|
63 |
+
checkbox = _QCheckBox(opt)
|
64 |
+
self.qcheckboxes.append(checkbox)
|
65 |
+
self.row.addWidget(checkbox)
|
66 |
+
|
67 |
+
self.addWidget(self.qlabel)
|
68 |
+
self.addLayout(self.row)
|
69 |
+
|
70 |
+
def cfg_init(self):
|
71 |
+
val = set(self.cfg(self.selected_cfg, "QStringList"))
|
72 |
+
for box in self.qcheckboxes:
|
73 |
+
box.setChecked(box.text() in val)
|
74 |
+
|
75 |
+
def cfg_connect(self):
|
76 |
+
def update(_):
|
77 |
+
selected = [b.text() for b in self.qcheckboxes if b.isChecked()]
|
78 |
+
self.cfg.set(self.selected_cfg, selected)
|
79 |
+
|
80 |
+
for box in self.qcheckboxes:
|
81 |
+
box.toggled.connect(update)
|
frontends/krita/krita_diff/widgets/combo_box.py
ADDED
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from functools import partial
|
2 |
+
from typing import Union
|
3 |
+
|
4 |
+
from krita import QComboBox, QHBoxLayout, Qt, QValidator
|
5 |
+
|
6 |
+
from ..config import Config
|
7 |
+
from .misc import QLabel
|
8 |
+
|
9 |
+
|
10 |
+
class QOptionValidator(QValidator):
|
11 |
+
def __init__(self, opts: set, *args, **kwargs):
|
12 |
+
super(QOptionValidator, self).__init__(*args, **kwargs)
|
13 |
+
self.opts = opts
|
14 |
+
|
15 |
+
def validate(self, input, pos):
|
16 |
+
# Below validation rules make it impossible to type invalid options
|
17 |
+
if len(self.opts) < 2:
|
18 |
+
# List hasn't loaded yet
|
19 |
+
return QValidator.Intermediate, input, pos
|
20 |
+
elif input in self.opts:
|
21 |
+
return QValidator.Acceptable, input, pos
|
22 |
+
elif any(o.find(input) == 0 for o in self.opts):
|
23 |
+
return QValidator.Intermediate, input, pos
|
24 |
+
else:
|
25 |
+
return QValidator.Invalid, input, pos
|
26 |
+
|
27 |
+
def fixup(self, input):
|
28 |
+
return ""
|
29 |
+
|
30 |
+
|
31 |
+
class QComboBoxLayout(QHBoxLayout):
|
32 |
+
def __init__(
|
33 |
+
self,
|
34 |
+
cfg: Config,
|
35 |
+
options_cfg: Union[str, list],
|
36 |
+
selected_cfg: str,
|
37 |
+
label: str = None,
|
38 |
+
*args,
|
39 |
+
**kwargs
|
40 |
+
):
|
41 |
+
"""Layout for labelled QComboBox.
|
42 |
+
|
43 |
+
Args:
|
44 |
+
cfg (Config): Config to connect to.
|
45 |
+
options_cfg (Union[str, list]): Config key to read available options from or list of options.
|
46 |
+
selected_cfg (str): Config key to read/write selected option to.
|
47 |
+
label (str, optional): Label, uses `selected_cfg` if None. Defaults to None.
|
48 |
+
num_chars (int, optional): Max length of qcombo in chars. Defaults to None.
|
49 |
+
"""
|
50 |
+
super(QComboBoxLayout, self).__init__(*args, **kwargs)
|
51 |
+
|
52 |
+
# Used to connect to config stored in script
|
53 |
+
self.cfg = cfg
|
54 |
+
self.options_cfg = options_cfg
|
55 |
+
self.selected_cfg = selected_cfg
|
56 |
+
self._items = set()
|
57 |
+
|
58 |
+
self.qlabel = QLabel(self.selected_cfg if label is None else label)
|
59 |
+
self.qcombo = QComboBox()
|
60 |
+
self.qcombo.view().setTextElideMode(Qt.ElideLeft)
|
61 |
+
self.qcombo.setEditable(True)
|
62 |
+
self.qcombo.setInsertPolicy(QComboBox.NoInsert)
|
63 |
+
self.qcombo.setMinimumWidth(10)
|
64 |
+
|
65 |
+
self.addWidget(self.qlabel)
|
66 |
+
self.addWidget(self.qcombo)
|
67 |
+
|
68 |
+
def cfg_init(self):
|
69 |
+
opts = sorted(
|
70 |
+
set(
|
71 |
+
self.cfg(self.options_cfg, "QStringList")
|
72 |
+
if isinstance(self.options_cfg, str)
|
73 |
+
else self.options_cfg
|
74 |
+
),
|
75 |
+
key=str.casefold,
|
76 |
+
)
|
77 |
+
|
78 |
+
# NOTE: assumes the None option will always be labelled as "None"
|
79 |
+
if "None" in opts:
|
80 |
+
opts.remove("None")
|
81 |
+
opts.insert(0, "None")
|
82 |
+
|
83 |
+
# prevent dropdown from closing when cfg_init is called by update
|
84 |
+
if set(opts) != self._items:
|
85 |
+
self._items = set(opts)
|
86 |
+
# as using editable mode, text isn't affected by clearing options
|
87 |
+
self.qcombo.clear()
|
88 |
+
self.qcombo.addItems(opts)
|
89 |
+
self.qcombo.setValidator(QOptionValidator(self._items))
|
90 |
+
|
91 |
+
# avoid resetting the auto-completer
|
92 |
+
if self.qcombo.currentText() != self.cfg(self.selected_cfg):
|
93 |
+
self.qcombo.setEditText(self.cfg(self.selected_cfg))
|
94 |
+
|
95 |
+
def cfg_connect(self):
|
96 |
+
# Possible to get invalid by backspacing after selecting option
|
97 |
+
# but no one would do that deliberately
|
98 |
+
self.qcombo.editTextChanged.connect(partial(self.cfg.set, self.selected_cfg))
|
frontends/krita/krita_diff/widgets/line_edit.py
ADDED
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from functools import partial
|
2 |
+
|
3 |
+
from krita import QHBoxLayout, QLineEdit
|
4 |
+
|
5 |
+
from ..config import Config
|
6 |
+
from .misc import QLabel
|
7 |
+
|
8 |
+
|
9 |
+
class QLineEditLayout(QHBoxLayout):
|
10 |
+
def __init__(
|
11 |
+
self,
|
12 |
+
cfg: Config,
|
13 |
+
field_cfg: str,
|
14 |
+
label: str = None,
|
15 |
+
placeholder: str = "",
|
16 |
+
*args,
|
17 |
+
**kwargs
|
18 |
+
):
|
19 |
+
"""Layout for labelled QLineEdit.
|
20 |
+
|
21 |
+
Args:
|
22 |
+
cfg (Config): Config to connect to.
|
23 |
+
field_cfg (str): Config key to read/write value to.
|
24 |
+
label (str, optional): Label, uses `field_cfg` if None. Defaults to None.
|
25 |
+
placeholder (str, optional): Placeholder. Defaults to "".
|
26 |
+
"""
|
27 |
+
super(QLineEditLayout, self).__init__(*args, **kwargs)
|
28 |
+
|
29 |
+
self.cfg = cfg
|
30 |
+
self.field_cfg = field_cfg
|
31 |
+
|
32 |
+
self.qedit = QLineEdit()
|
33 |
+
self.qedit.setPlaceholderText(placeholder)
|
34 |
+
self.addWidget(QLabel(field_cfg if label is None else label))
|
35 |
+
self.addWidget(self.qedit)
|
36 |
+
|
37 |
+
def cfg_init(self):
|
38 |
+
# NOTE: update timer -> cfg_init, setText seems to reset cursor position so we prevent it
|
39 |
+
val = self.cfg(self.field_cfg, str)
|
40 |
+
if self.qedit.text() != val:
|
41 |
+
self.qedit.setText(val)
|
42 |
+
|
43 |
+
def cfg_connect(self):
|
44 |
+
self.qedit.textChanged.connect(partial(self.cfg.set, self.field_cfg))
|
frontends/krita/krita_diff/widgets/misc.py
ADDED
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from krita import QLabel as _QLabel
|
2 |
+
from krita import Qt
|
3 |
+
|
4 |
+
|
5 |
+
class QLabel(_QLabel):
|
6 |
+
"""QLabel with overwritten default behaviours."""
|
7 |
+
|
8 |
+
def __init__(self, *args, **kwargs):
|
9 |
+
super(QLabel, self).__init__(*args, **kwargs)
|
10 |
+
|
11 |
+
self.setOpenExternalLinks(True)
|
12 |
+
self.setWordWrap(True)
|
13 |
+
self.setTextFormat(Qt.TextFormat.RichText)
|
frontends/krita/krita_diff/widgets/prompt.py
ADDED
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from krita import QPlainTextEdit, QSizePolicy, QVBoxLayout
|
2 |
+
|
3 |
+
from ..config import Config
|
4 |
+
|
5 |
+
|
6 |
+
class QPromptEdit(QPlainTextEdit):
|
7 |
+
def __init__(self, placeholder="Enter prompt...", num_lines=5, *args, **kwargs):
|
8 |
+
super(QPromptEdit, self).__init__(*args, **kwargs)
|
9 |
+
self.setPlaceholderText(placeholder)
|
10 |
+
self.setFixedHeight(self.fontMetrics().lineSpacing() * num_lines)
|
11 |
+
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Maximum)
|
12 |
+
|
13 |
+
|
14 |
+
class QPromptLayout(QVBoxLayout):
|
15 |
+
prompt_label: str = "Prompt:"
|
16 |
+
neg_prompt_label: str = "Negative prompt:"
|
17 |
+
|
18 |
+
def __init__(
|
19 |
+
self, cfg: Config, prompt_cfg: str, neg_prompt_cfg: str, *args, **kwargs
|
20 |
+
):
|
21 |
+
"""Layout for prompt and negative prompt.
|
22 |
+
|
23 |
+
Args:
|
24 |
+
cfg (Config): Config to connect to.
|
25 |
+
prompt_cfg (str): Config key to read/write prompt to.
|
26 |
+
neg_prompt_cfg (str): Config key to read/write negative prompt to.
|
27 |
+
"""
|
28 |
+
super(QPromptLayout, self).__init__(*args, **kwargs)
|
29 |
+
|
30 |
+
# Used to connect to config stored in script
|
31 |
+
self.cfg = cfg
|
32 |
+
self.prompt_cfg = prompt_cfg
|
33 |
+
self.neg_prompt_cfg = neg_prompt_cfg
|
34 |
+
|
35 |
+
self.qedit_prompt = QPromptEdit(placeholder=self.prompt_label)
|
36 |
+
self.qedit_neg_prompt = QPromptEdit(placeholder=self.neg_prompt_label)
|
37 |
+
|
38 |
+
self.addWidget(self.qedit_prompt)
|
39 |
+
self.addWidget(self.qedit_neg_prompt)
|
40 |
+
|
41 |
+
def cfg_init(self):
|
42 |
+
# NOTE: update timer -> cfg_init, setText seems to reset cursor position so we prevent it
|
43 |
+
prompt = self.cfg(self.prompt_cfg, str)
|
44 |
+
neg_prompt = self.cfg(self.neg_prompt_cfg, str)
|
45 |
+
if self.qedit_prompt.toPlainText() != prompt:
|
46 |
+
self.qedit_prompt.setPlainText(prompt)
|
47 |
+
if self.qedit_neg_prompt.toPlainText() != neg_prompt:
|
48 |
+
self.qedit_neg_prompt.setPlainText(neg_prompt)
|
49 |
+
|
50 |
+
def cfg_connect(self):
|
51 |
+
self.qedit_prompt.textChanged.connect(
|
52 |
+
lambda: self.cfg.set(self.prompt_cfg, self.qedit_prompt.toPlainText())
|
53 |
+
)
|
54 |
+
self.qedit_neg_prompt.textChanged.connect(
|
55 |
+
lambda: self.cfg.set(
|
56 |
+
self.neg_prompt_cfg, self.qedit_neg_prompt.toPlainText()
|
57 |
+
)
|
58 |
+
)
|
frontends/krita/krita_diff/widgets/spin_box.py
ADDED
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from functools import partial
|
2 |
+
from math import isclose
|
3 |
+
from typing import Union
|
4 |
+
|
5 |
+
from krita import QDoubleSpinBox, QHBoxLayout, QSpinBox
|
6 |
+
|
7 |
+
from ..config import Config
|
8 |
+
from .misc import QLabel
|
9 |
+
|
10 |
+
|
11 |
+
class QSpinBoxLayout(QHBoxLayout):
|
12 |
+
def __init__(
|
13 |
+
self,
|
14 |
+
cfg: Config,
|
15 |
+
field_cfg: str,
|
16 |
+
label: str = None,
|
17 |
+
min: Union[int, float] = 0.0,
|
18 |
+
max: Union[int, float] = 1.0,
|
19 |
+
step: Union[int, float] = 0.1,
|
20 |
+
*args,
|
21 |
+
**kwargs
|
22 |
+
):
|
23 |
+
"""Layout for labelled QSpinBox/QDoubleSpinBox.
|
24 |
+
|
25 |
+
Will infer which to use based on type of min, max and step.
|
26 |
+
|
27 |
+
Args:
|
28 |
+
cfg (Config): Config to connect to.
|
29 |
+
field_cfg (str): Config key to read/write value to.
|
30 |
+
label (str, optional): Label, uses `field_cfg` if None. Defaults to None.
|
31 |
+
min (Union[int, float], optional): Min value. Defaults to 0.0.
|
32 |
+
max (Union[int, float], optional): Max value. Defaults to 1.0.
|
33 |
+
step (Union[int, float], optional): Value step. Defaults to 0.1.
|
34 |
+
"""
|
35 |
+
super(QSpinBoxLayout, self).__init__(*args, **kwargs)
|
36 |
+
|
37 |
+
self.cfg = cfg
|
38 |
+
self.field_cfg = field_cfg
|
39 |
+
|
40 |
+
self.qlabel = QLabel(field_cfg if label is None else label)
|
41 |
+
|
42 |
+
is_integer = (
|
43 |
+
float(step).is_integer()
|
44 |
+
and float(min).is_integer()
|
45 |
+
and float(max).is_integer()
|
46 |
+
)
|
47 |
+
self.cast = int if is_integer else float
|
48 |
+
|
49 |
+
self.qspin = QSpinBox() if is_integer else QDoubleSpinBox()
|
50 |
+
self.qspin.setMinimum(self.cast(min))
|
51 |
+
self.qspin.setMaximum(self.cast(max))
|
52 |
+
self.qspin.setSingleStep(self.cast(step))
|
53 |
+
self.addWidget(self.qlabel)
|
54 |
+
self.addWidget(self.qspin)
|
55 |
+
|
56 |
+
def cfg_init(self):
|
57 |
+
val = self.cfg(self.field_cfg, self.cast)
|
58 |
+
cur = self.qspin.value()
|
59 |
+
# prevent cursor from jumping when cfg_init is called by update
|
60 |
+
if not isclose(val, cur):
|
61 |
+
self.qspin.setValue(self.cfg(self.field_cfg, self.cast))
|
62 |
+
|
63 |
+
def cfg_connect(self):
|
64 |
+
self.qspin.valueChanged.connect(partial(self.cfg.set, self.field_cfg))
|
frontends/krita/krita_diff/widgets/status_bar.py
ADDED
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from ..defaults import (
|
2 |
+
STATE_DONE,
|
3 |
+
STATE_INIT,
|
4 |
+
STATE_INTERRUPT,
|
5 |
+
STATE_READY,
|
6 |
+
STATE_URLERROR,
|
7 |
+
)
|
8 |
+
from .misc import QLabel
|
9 |
+
|
10 |
+
|
11 |
+
class StatusBar(QLabel):
|
12 |
+
def __init__(self, *args, **kwargs):
|
13 |
+
super(StatusBar, self).__init__(*args, **kwargs)
|
14 |
+
self.set_status(STATE_INIT)
|
15 |
+
|
16 |
+
def set_status(self, s):
|
17 |
+
if s == STATE_READY and STATE_URLERROR not in self.text():
|
18 |
+
return
|
19 |
+
if s == STATE_DONE and STATE_INTERRUPT == self.text():
|
20 |
+
return
|
21 |
+
|
22 |
+
self.setText(f"<b>Status:</b> {s}")
|
frontends/krita/krita_diff/widgets/tips.py
ADDED
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from typing import List
|
2 |
+
|
3 |
+
from krita import QVBoxLayout
|
4 |
+
|
5 |
+
from .misc import QLabel
|
6 |
+
|
7 |
+
|
8 |
+
class TipsLayout(QVBoxLayout):
|
9 |
+
def __init__(self, tips: List[str], prefix="<em>Tip:</em> ", *args, **kwargs):
|
10 |
+
super(TipsLayout, self).__init__(*args, **kwargs)
|
11 |
+
|
12 |
+
self.tips = [QLabel(prefix + t) for t in tips]
|
13 |
+
for t in self.tips:
|
14 |
+
self.addWidget(t)
|
15 |
+
|
16 |
+
def setVisible(self, visible: bool):
|
17 |
+
for t in self.tips:
|
18 |
+
t.setVisible(visible)
|
install.py
ADDED
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
from pathlib import Path
|
3 |
+
|
4 |
+
from launch import git, run
|
5 |
+
|
6 |
+
REPO_LOCATION = Path(__file__).parent
|
7 |
+
auto_update = os.environ.get("AUTO_UPDATE", "True").lower() in {"true", "yes"}
|
8 |
+
|
9 |
+
if auto_update:
|
10 |
+
print("[auto-sd-paint-ext] Attempting auto-update...")
|
11 |
+
|
12 |
+
try:
|
13 |
+
# current_hash = run(
|
14 |
+
# f'"{git}" -C {REPO_LOCATION} rev-parse HEAD',
|
15 |
+
# "[auto-sd-paint-ext] Get commit hash.",
|
16 |
+
# ).strip()
|
17 |
+
|
18 |
+
run(f'"{git}" -C "{REPO_LOCATION}" fetch', "[auto-sd-paint-ext] Fetch upstream.")
|
19 |
+
|
20 |
+
run(f'"{git}" -C "{REPO_LOCATION}" pull', "[auto-sd-paint-ext] Pull upstream.")
|
21 |
+
except Exception as e:
|
22 |
+
print("[auto-sd-paint-ext] Auto-update failed:")
|
23 |
+
print(e)
|
24 |
+
print("[auto-sd-paint-ext] Ensure git was used to install extension.")
|
25 |
+
|
26 |
+
# NOTE: if we ever get dependencies, we can install them here.
|
scripts/main.py
ADDED
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import logging
|
2 |
+
from pathlib import Path
|
3 |
+
|
4 |
+
import backend
|
5 |
+
import gradio as gr
|
6 |
+
from backend.app import app_encryption_middleware
|
7 |
+
from backend.config import LOGGER_NAME, ROUTE_PREFIX, SCRIPT_ID, SCRIPT_NAME
|
8 |
+
from backend.utils import get_encrypt_key
|
9 |
+
from fastapi import FastAPI
|
10 |
+
from modules import script_callbacks, scripts, shared
|
11 |
+
|
12 |
+
PLUGIN_LOCATION = Path(scripts.basedir()) / "frontends" / "krita"
|
13 |
+
|
14 |
+
|
15 |
+
class BackendScript(scripts.Script):
|
16 |
+
def title(self):
|
17 |
+
return SCRIPT_NAME
|
18 |
+
|
19 |
+
def show(self, is_img2img):
|
20 |
+
return scripts.AlwaysVisible
|
21 |
+
|
22 |
+
def ui(self, is_img2img):
|
23 |
+
return []
|
24 |
+
|
25 |
+
def process(self, _):
|
26 |
+
pass
|
27 |
+
|
28 |
+
|
29 |
+
started = False
|
30 |
+
|
31 |
+
|
32 |
+
def on_app_started(demo: gr.Blocks, app: FastAPI):
|
33 |
+
# NOTE: There is currently a glitch where the on_app_started() callback is called twice
|
34 |
+
# Surprisingly, it only breaks the encryption middleware and causes duplicate logs
|
35 |
+
# Below is a workaround to fix said issues
|
36 |
+
# Downside is that for now, restarting Gradio via the webUI will just break the extension
|
37 |
+
global started
|
38 |
+
if started:
|
39 |
+
return
|
40 |
+
started = True
|
41 |
+
|
42 |
+
logger = logging.getLogger(LOGGER_NAME)
|
43 |
+
logger.setLevel(logging.INFO)
|
44 |
+
handler = logging.StreamHandler()
|
45 |
+
handler.setFormatter(
|
46 |
+
logging.Formatter(
|
47 |
+
fmt="%(name)s:%(levelname)s: %(message)s",
|
48 |
+
datefmt="%Y-%m-%d %H:%M:%S",
|
49 |
+
)
|
50 |
+
)
|
51 |
+
logger.addHandler(handler)
|
52 |
+
|
53 |
+
if shared.cmd_opts.api:
|
54 |
+
app.include_router(backend.router, prefix=ROUTE_PREFIX, tags=[SCRIPT_NAME])
|
55 |
+
app.middleware("http")(app_encryption_middleware)
|
56 |
+
# on first run, this creates a key file
|
57 |
+
get_encrypt_key()
|
58 |
+
if not shared.cmd_opts.listen:
|
59 |
+
logger.info(
|
60 |
+
"Add --listen to COMMANDLINE_ARGS to enable usage as a remote backend."
|
61 |
+
)
|
62 |
+
else:
|
63 |
+
logger.warning("COMMANDLINE_ARGS does not contain --api, API won't be mounted.")
|
64 |
+
# if you wanted to do anything massive to the UI, you could modify demo, but why?
|
65 |
+
|
66 |
+
|
67 |
+
def on_ui_settings():
|
68 |
+
# hook to add our own settings to the settings tab
|
69 |
+
pass
|
70 |
+
|
71 |
+
|
72 |
+
def krita_help(folder):
|
73 |
+
folder = "<path_to_pykrita>" if not folder else folder
|
74 |
+
return f"""
|
75 |
+
Search for "Command Prompt" in the Start Menu, right-click and click "Run as Administrator...", paste the follow commands and hit Enter:
|
76 |
+
```bat
|
77 |
+
mklink /j "{folder}\\krita_diff" "{(PLUGIN_LOCATION / 'krita_diff').resolve()}"
|
78 |
+
mklink "{folder}\\krita_diff.desktop" "{(PLUGIN_LOCATION / 'krita_diff.desktop').resolve()}"
|
79 |
+
```
|
80 |
+
|
81 |
+
Linux command:
|
82 |
+
```sh
|
83 |
+
ln -s "{(PLUGIN_LOCATION / 'krita_diff').resolve()}" "{folder}/krita_diff"
|
84 |
+
ln -s "{(PLUGIN_LOCATION / 'krita_diff.desktop').resolve()}" "{folder}/krita_diff.desktop"
|
85 |
+
```
|
86 |
+
"""
|
87 |
+
|
88 |
+
|
89 |
+
def on_ui_tabs():
|
90 |
+
# hook to create our own UI tab
|
91 |
+
with gr.Blocks(analytics_enabled=False) as interface:
|
92 |
+
gr.Markdown(
|
93 |
+
"""
|
94 |
+
### Generate Krita Plugin Symlink Command
|
95 |
+
|
96 |
+
1. Launch Krita.
|
97 |
+
2. On the menubar, go to `Settings > Manage Resources...`.
|
98 |
+
3. In the window that appears, click `Open Resource Folder`.
|
99 |
+
4. In the file explorer that appears, look for a folder called `pykrita` or create it.
|
100 |
+
5. Enter the `pykrita` folder and copy the folder location from the address bar.
|
101 |
+
6. Paste the folder location below.
|
102 |
+
"""
|
103 |
+
)
|
104 |
+
folder = gr.Textbox(
|
105 |
+
placeholder="C:\\\\...\\pykrita", label="Pykrita Folder Location", lines=1
|
106 |
+
)
|
107 |
+
out = gr.Markdown(krita_help(""))
|
108 |
+
folder.change(krita_help, folder, out)
|
109 |
+
gr.Markdown(
|
110 |
+
"""
|
111 |
+
**NOTE**: Symlinks will break if you move or rename the repository or any
|
112 |
+
of its parent folders or otherwise change the path such that the symlink
|
113 |
+
becomes invalid. In which case, repeat the above steps with the new `pykrita`
|
114 |
+
folder location and (auto-detected) repository location.
|
115 |
+
|
116 |
+
**NOTE**: Ensure `webui-user.bat`/`webui-user.sh` contains `--api` in `COMMANDLINE_ARGS`!
|
117 |
+
"""
|
118 |
+
)
|
119 |
+
gr.Markdown(
|
120 |
+
"""
|
121 |
+
### Enabling the Krita Plugin
|
122 |
+
|
123 |
+
1. Restart Krita.
|
124 |
+
2. On the menubar, go to `Settings > Configure Krita...`
|
125 |
+
3. On the left sidebar, go to `Python Plugin Manager`.
|
126 |
+
4. Look for `Stable Diffusion Plugin` and tick the checkbox.
|
127 |
+
5. Restart Krita again for changes to take effect.
|
128 |
+
|
129 |
+
The `SD Plugin` docked window should appear on the left of the Krita window. If it does not, look on the menubar under `Settings > Dockers` for `SD Plugin`.
|
130 |
+
|
131 |
+
### Next Steps
|
132 |
+
|
133 |
+
- [Troubleshooting](https://github.com/Interpause/auto-sd-paint-ext/wiki/Troubleshooting)
|
134 |
+
- [Update Guide](https://github.com/Interpause/auto-sd-paint-ext/wiki/Update-Guide)
|
135 |
+
- [Usage Guide](https://github.com/Interpause/auto-sd-paint-ext/wiki/Usage-Guide)
|
136 |
+
"""
|
137 |
+
)
|
138 |
+
gr.Markdown("TODO: Control/status panel")
|
139 |
+
|
140 |
+
return [(interface, "auto-sd-paint-ext Guide/Panel", SCRIPT_ID)]
|
141 |
+
|
142 |
+
|
143 |
+
# NOTE: see modules/script_callbacks.py for all callbacks
|
144 |
+
script_callbacks.on_app_started(on_app_started)
|
145 |
+
script_callbacks.on_ui_tabs(on_ui_tabs)
|
146 |
+
script_callbacks.on_ui_settings(on_ui_settings)
|