├── .gitignore ├── README.md └── scripts └── stealth_pnginfo.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | .idea 161 | *.pt 162 | *.pth 163 | *.ckpt 164 | *.safetensors 165 | models/control_sd15_scribble.pth 166 | detected_maps/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ~~# sd_webui_stealth_pnginfo 2 | This extension is for bypassing Discord exif data stripping 3 | Only works for png image, does not work for jpg image. 4 | Extension is auto activated, functioning on top of WebUI PNG Info, no additional setup is required after installation.~~ 5 | 6 | ~~Injected PNG Info data is only available for webui PNG info, external exif data viewer will still show nothing.~~ 7 | 8 | Discord is no longer stripping Metadata, archiving this repo. 9 | -------------------------------------------------------------------------------- /scripts/stealth_pnginfo.py: -------------------------------------------------------------------------------- 1 | from modules import script_callbacks, shared, generation_parameters_copypaste 2 | from modules.script_callbacks import ImageSaveParams 3 | import gradio as gr 4 | from modules import images 5 | from PIL import Image 6 | from gradio import processing_utils 7 | import PIL 8 | import warnings 9 | import gzip 10 | 11 | 12 | def add_stealth_pnginfo(params: ImageSaveParams): 13 | stealth_pnginfo_enabled = shared.opts.data.get("stealth_pnginfo", True) 14 | stealth_pnginfo_mode = shared.opts.data.get('stealth_pnginfo_mode', 'alpha') 15 | stealth_pnginfo_compressed = shared.opts.data.get("stealth_pnginfo_compression", True) 16 | if not stealth_pnginfo_enabled: 17 | return 18 | if not params.filename.endswith('.png') or params.pnginfo is None: 19 | return 20 | if 'parameters' not in params.pnginfo: 21 | return 22 | add_data(params, stealth_pnginfo_mode, stealth_pnginfo_compressed) 23 | 24 | 25 | def prepare_data(params, mode='alpha', compressed=False): 26 | signature = f"stealth_{'png' if mode == 'alpha' else 'rgb'}{'info' if not compressed else 'comp'}" 27 | binary_signature = ''.join(format(byte, '08b') for byte in signature.encode('utf-8')) 28 | param = params.encode('utf-8') if not compressed else gzip.compress(bytes(params, 'utf-8')) 29 | binary_param = ''.join(format(byte, '08b') for byte in param) 30 | binary_param_len = format(len(binary_param), '032b') 31 | return binary_signature + binary_param_len + binary_param 32 | 33 | 34 | def add_data(params, mode='alpha', compressed=False): 35 | binary_data = prepare_data(params.pnginfo['parameters'], mode, compressed) 36 | if mode == 'alpha': 37 | params.image.putalpha(255) 38 | width, height = params.image.size 39 | pixels = params.image.load() 40 | index = 0 41 | end_write = False 42 | for x in range(width): 43 | for y in range(height): 44 | if index >= len(binary_data): 45 | end_write = True 46 | break 47 | values = pixels[x, y] 48 | if mode == 'alpha': 49 | r, g, b, a = values 50 | else: 51 | r, g, b = values 52 | if mode == 'alpha': 53 | a = (a & ~1) | int(binary_data[index]) 54 | index += 1 55 | else: 56 | r = (r & ~1) | int(binary_data[index]) 57 | if index + 1 < len(binary_data): 58 | g = (g & ~1) | int(binary_data[index + 1]) 59 | if index + 2 < len(binary_data): 60 | b = (b & ~1) | int(binary_data[index + 2]) 61 | index += 3 62 | pixels[x, y] = (r, g, b, a) if mode == 'alpha' else (r, g, b) 63 | if end_write: 64 | break 65 | 66 | 67 | original_read_info_from_image = images.read_info_from_image 68 | 69 | 70 | def read_info_from_image_stealth(image): 71 | geninfo, items = original_read_info_from_image(image) 72 | # possible_sigs = {'stealth_pnginfo', 'stealth_pngcomp', 'stealth_rgbinfo', 'stealth_rgbcomp'} 73 | 74 | # respecting original pnginfo 75 | if geninfo is not None: 76 | return geninfo, items 77 | 78 | # trying to read stealth pnginfo 79 | width, height = image.size 80 | pixels = image.load() 81 | 82 | has_alpha = True if image.mode == 'RGBA' else False 83 | mode = None 84 | compressed = False 85 | binary_data = '' 86 | buffer_a = '' 87 | buffer_rgb = '' 88 | index_a = 0 89 | index_rgb = 0 90 | sig_confirmed = False 91 | confirming_signature = True 92 | reading_param_len = False 93 | reading_param = False 94 | read_end = False 95 | for x in range(width): 96 | for y in range(height): 97 | if has_alpha: 98 | r, g, b, a = pixels[x, y] 99 | buffer_a += str(a & 1) 100 | index_a += 1 101 | else: 102 | r, g, b = pixels[x, y] 103 | buffer_rgb += str(r & 1) 104 | buffer_rgb += str(g & 1) 105 | buffer_rgb += str(b & 1) 106 | index_rgb += 3 107 | if confirming_signature: 108 | if index_a == len('stealth_pnginfo') * 8: 109 | decoded_sig = bytearray(int(buffer_a[i:i + 8], 2) for i in 110 | range(0, len(buffer_a), 8)).decode('utf-8', errors='ignore') 111 | if decoded_sig in {'stealth_pnginfo', 'stealth_pngcomp'}: 112 | confirming_signature = False 113 | sig_confirmed = True 114 | reading_param_len = True 115 | mode = 'alpha' 116 | if decoded_sig == 'stealth_pngcomp': 117 | compressed = True 118 | buffer_a = '' 119 | index_a = 0 120 | else: 121 | read_end = True 122 | break 123 | elif index_rgb == len('stealth_pnginfo') * 8: 124 | decoded_sig = bytearray(int(buffer_rgb[i:i + 8], 2) for i in 125 | range(0, len(buffer_rgb), 8)).decode('utf-8', errors='ignore') 126 | if decoded_sig in {'stealth_rgbinfo', 'stealth_rgbcomp'}: 127 | confirming_signature = False 128 | sig_confirmed = True 129 | reading_param_len = True 130 | mode = 'rgb' 131 | if decoded_sig == 'stealth_rgbcomp': 132 | compressed = True 133 | buffer_rgb = '' 134 | index_rgb = 0 135 | elif reading_param_len: 136 | if mode == 'alpha': 137 | if index_a == 32: 138 | param_len = int(buffer_a, 2) 139 | reading_param_len = False 140 | reading_param = True 141 | buffer_a = '' 142 | index_a = 0 143 | else: 144 | if index_rgb == 33: 145 | pop = buffer_rgb[-1] 146 | buffer_rgb = buffer_rgb[:-1] 147 | param_len = int(buffer_rgb, 2) 148 | reading_param_len = False 149 | reading_param = True 150 | buffer_rgb = pop 151 | index_rgb = 1 152 | elif reading_param: 153 | if mode == 'alpha': 154 | if index_a == param_len: 155 | binary_data = buffer_a 156 | read_end = True 157 | break 158 | else: 159 | if index_rgb >= param_len: 160 | diff = param_len - index_rgb 161 | if diff < 0: 162 | buffer_rgb = buffer_rgb[:diff] 163 | binary_data = buffer_rgb 164 | read_end = True 165 | break 166 | else: 167 | # impossible 168 | read_end = True 169 | break 170 | if read_end: 171 | break 172 | if sig_confirmed and binary_data != '': 173 | # Convert binary string to UTF-8 encoded text 174 | byte_data = bytearray(int(binary_data[i:i + 8], 2) for i in range(0, len(binary_data), 8)) 175 | try: 176 | if compressed: 177 | decoded_data = gzip.decompress(bytes(byte_data)).decode('utf-8') 178 | else: 179 | decoded_data = byte_data.decode('utf-8', errors='ignore') 180 | geninfo = decoded_data 181 | except: 182 | pass 183 | return geninfo, items 184 | 185 | 186 | def send_rgb_image_and_dimension(x): 187 | if isinstance(x, Image.Image): 188 | img = x 189 | if img.mode == 'RGBA': 190 | img = img.convert('RGB') 191 | else: 192 | img = generation_parameters_copypaste.image_from_url_text(x) 193 | if img.mode == 'RGBA': 194 | img = img.convert('RGB') 195 | 196 | if shared.opts.send_size and isinstance(img, Image.Image): 197 | w = img.width 198 | h = img.height 199 | else: 200 | w = gr.update() 201 | h = gr.update() 202 | 203 | return img, w, h 204 | 205 | 206 | def on_ui_settings(): 207 | section = ('stealth_pnginfo', "Stealth PNGinfo") 208 | shared.opts.add_option("stealth_pnginfo", shared.OptionInfo( 209 | True, "Save Stealth PNGinfo", gr.Checkbox, {"interactive": True}, section=section)) 210 | shared.opts.add_option("stealth_pnginfo_mode", shared.OptionInfo( 211 | "alpha", "Stealth PNGinfo mode", gr.Dropdown, {"choices": ["alpha", "rgb"], "interactive": True}, 212 | section=section)) 213 | shared.opts.add_option("stealth_pnginfo_compression", shared.OptionInfo( 214 | True, "Stealth PNGinfo compression", gr.Checkbox, {"interactive": True}, section=section)) 215 | 216 | 217 | def custom_image_preprocess(self, x): 218 | if x is None: 219 | return x 220 | 221 | mask = "" 222 | if self.tool == "sketch" and self.source in ["upload", "webcam"]: 223 | assert isinstance(x, dict) 224 | x, mask = x["image"], x["mask"] 225 | 226 | assert isinstance(x, str) 227 | im = processing_utils.decode_base64_to_image(x) 228 | with warnings.catch_warnings(): 229 | warnings.simplefilter("ignore") 230 | im = im.convert(self.image_mode) 231 | if self.shape is not None: 232 | im = processing_utils.resize_and_crop(im, self.shape) 233 | if self.invert_colors: 234 | im = PIL.ImageOps.invert(im) 235 | if ( 236 | self.source == "webcam" 237 | and self.mirror_webcam is True 238 | and self.tool != "color-sketch" 239 | ): 240 | im = PIL.ImageOps.mirror(im) 241 | 242 | if self.tool == "sketch" and self.source in ["upload", "webcam"]: 243 | mask_im = None 244 | if mask is not None: 245 | mask_im = processing_utils.decode_base64_to_image(mask) 246 | 247 | return { 248 | "image": self._format_image(im), 249 | "mask": self._format_image(mask_im), 250 | } 251 | 252 | return self._format_image(im) 253 | 254 | 255 | def on_after_component_change_pnginfo_image_mode(component, **_kwargs): 256 | if type(component) is gr.State: 257 | return 258 | if type(component) is gr.Image and component.elem_id == 'pnginfo_image': 259 | component.image_mode = 'RGBA' 260 | 261 | 262 | 263 | def clear_alpha(param): 264 | output_image = param['image'].convert('RGB') 265 | return output_image 266 | 267 | 268 | if type(component) is gr.Image and component.elem_id == 'img2maskimg': 269 | # pass 270 | component.upload(clear_alpha, component, component) 271 | component.preprocess = custom_image_preprocess.__get__(component, gr.Image) 272 | 273 | 274 | original_resize_image = images.resize_image 275 | 276 | 277 | def stealth_resize_image(resize_mode, im, width, height, upscaler_name=None): 278 | """ 279 | Resizes an image with the specified resize_mode, width, and height. 280 | 281 | Args: 282 | resize_mode: The mode to use when resizing the image. 283 | 0: Resize the image to the specified width and height. 284 | 1: Resize the image to fill the specified width and height, maintaining the aspect ratio, and then center the image within the dimensions, cropping the excess. 285 | 2: Resize the image to fit within the specified width and height, maintaining the aspect ratio, and then center the image within the dimensions, filling empty with data from image. 286 | im: The image to resize. 287 | width: The width to resize the image to. 288 | height: The height to resize the image to. 289 | upscaler_name: The name of the upscaler to use. If not provided, defaults to opts.upscaler_for_img2img. 290 | """ 291 | # convert to RGB 292 | if im.mode == 'RGBA': 293 | im = im.convert('RGB') 294 | 295 | return original_resize_image(resize_mode, im, width, height, upscaler_name) 296 | 297 | 298 | 299 | images.read_info_from_image = read_info_from_image_stealth 300 | 301 | generation_parameters_copypaste.send_image_and_dimensions = send_rgb_image_and_dimension 302 | 303 | images.resize_image = stealth_resize_image 304 | 305 | script_callbacks.on_ui_settings(on_ui_settings) 306 | script_callbacks.on_before_image_saved(add_stealth_pnginfo) 307 | script_callbacks.on_after_component(on_after_component_change_pnginfo_image_mode) 308 | --------------------------------------------------------------------------------