diff --git a/CHANGELOG.md b/CHANGELOG.md index 94dd00c34..71549e016 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,22 +1,22 @@ # Change Log for SD.Next -## Update for 2026-09-30 +## Update for 2026-10-01 -### Highlights for 2026-09-30 +### Highlights for 2026-10-01 *What's New*? This release brings: - Few new models: **Qwen-Image 2.1**, **Bria Fibo 1.5** and **ObjectClear** -- **DLSS5** reimagined for much higher performance and full cross-platform support +- **DLSS5** reimagined for much higher performance and full cross-platform and cross-gpu support - New live-preview method using **MicroDecoder** - Better handling of multi-image reference editing -- Compute updates for ROCm, OpenVINO, and XPU -- Massive updates to UI tooltips/hints: over 1,000 new entries and revised existing ones +- Compute updates for *ROCm, OpenVINO, and XPU* +- Massive updates to UI tooltips/hints: *over 1,000 new entries and revised existing ones* Plus quite a few other improvements and inevitable bug-fixes, see full changelog below for details... [Home](https://vladmandic.github.io/sdnext/) | [ChangeLog](https://github.com/vladmandic/automatic/blob/master/CHANGELOG.md) | [Docs](https://vladmandic.github.io/sdnext-docs/) | [Discord](https://discord.com/invite/sd-next-federal-batch-inspectors-1101998836328697867) | [Sponsor](https://github.com/sponsors/vladmandic) -### Details for 2026-09-30 +### Details for 2026-10-01 - **Models** - [Qwen-Image 2.1](https://huggingface.co/Qwen/Qwen-Image-2.1) in *base*, *sdnq-4bit* and *sdnq-8bit* variants @@ -48,6 +48,7 @@ Plus quite a few other improvements and inevitable bug-fixes, see full changelog - can be used via *generate -> extras* or as a separate *process* workflow - additional gpu-acceleration for cuda is included, but skipped on non-cuda devices - no installation needed, just enable and thats it! + - cli usage in `modules/dlss/cli-dlss.py` - **Preview** - full refactor: refactored *taesd*, added *micro-decoder*, removed *simple* and *approximate* methods - new [MicroDecoder](https://huggingface.co/vladmandic/MicroDecoder) vae implementation @@ -58,14 +59,17 @@ Plus quite a few other improvements and inevitable bug-fixes, see full changelog instead of relying on polling and low-priority updates, forces preview calculation on each step - previews now work on batched generations - **Other** - - edit-models: simplify usage of multi-image reference editing + - **edit-models**: simplify usage of multi-image reference editing automatically handle: flux.2, glm-image, nano-banana, joy-image-edit, qwen-image-edit - - processors: `DWPose`, `RTMW`, `RTMO`: image processor updates, thanks @kirtasshh + - **processors**: `DWPose`, `RTMW`, `RTMO`: image processor updates, thanks @kirtasshh refactored to use `ONNX` instead of obsolete `mmpose` lib and with additional configurable settings - - lora: extend support for more model types - - scheduler: expose base and max image sequence lengths + - **lora**: extend support for more model types + - **scheduler**: expose base and max image sequence lengths recommended to change when target resolution is significantly different from the base resolution - - detailer: add option to skip specific classes using `[SKIP=name]` in the detailer prompt, thanks @kirtasshh + - **detailer**: add option to skip specific classes using `[SKIP=name]` in the detailer prompt, thanks @kirtasshh + - **watermarking**: updated code for both image(visible) and text(invisible) watermarking + new api endpoint: `/sdapi/v1/watermark` with `POST` and `GET` methods + new cli example: `cli/api-watermark.py` - **UI** - massive improvement to built-in tooltips/hints over 1,000 new entries, now at zero missing ui controls or settings diff --git a/cli/api-control.py b/cli/api-control.py index a8622c36d..fa2a48bf5 100755 --- a/cli/api-control.py +++ b/cli/api-control.py @@ -2,7 +2,6 @@ # example: api-control.py --prompt "anime girl" --control "Canny:Canny:1.0:0.1:0.9:/home/vlado/generative/Samples/anime1.jpg,None:Depth:0.9:0.0:1.0:/home/vlado/generative/Samples/anime1.jpg" --hires --detailer --output /tmp/anime.jpg import os import io -import sys import time import base64 import logging diff --git a/cli/api-samplers.py b/cli/api-samplers.py index c63baf37c..7371ed0de 100755 --- a/cli/api-samplers.py +++ b/cli/api-samplers.py @@ -4,7 +4,6 @@ get list of all samplers and details of current sampler """ -import sys import logging import urllib3 import requests diff --git a/cli/api-txt2img.py b/cli/api-txt2img.py index 648b72576..61cb2d8e6 100755 --- a/cli/api-txt2img.py +++ b/cli/api-txt2img.py @@ -63,7 +63,7 @@ def generate(args): # pylint: disable=redefined-outer-name info = data['info'] log.info(f'image received: size={image.size} time={t1-t0:.2f} info="{info}"') if args.output: - image.save(args.output, exif=image._getexif()) + image.save(args.output, exif=image._getexif()) # pylint: disable=protected-access log.info(f'image saved: size={image.size} filename={args.output}') else: log.warning(f'no images received: {data}') diff --git a/cli/api-watermark.py b/cli/api-watermark.py new file mode 100755 index 000000000..32837e5fe --- /dev/null +++ b/cli/api-watermark.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python +""" +Example: +cli/api-watermark.py --input ~/generative/Samples --wm-image ui/assets/logo-transparent.png --position center --wm-text "sdnext-test-invisible-watermark-string" --verify +""" +import os +import io +import time +import base64 +import logging +import argparse +import requests +import urllib3 +from PIL import Image + + +sd_url = os.environ.get('SDAPI_URL', "http://127.0.0.1:7860") +sd_username = os.environ.get('SDAPI_USR', None) +sd_password = os.environ.get('SDAPI_PWD', None) + +logging.basicConfig(level = logging.INFO, format = '%(asctime)s %(levelname)s: %(message)s') +log = logging.getLogger(__name__) +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + +def auth(): + if sd_username is not None and sd_password is not None: + return requests.auth.HTTPBasicAuth(sd_username, sd_password) + return None + + +def get(endpoint: str, dct: dict | None = None): + req = requests.get(f'{sd_url}{endpoint}', json = dct, timeout=300, verify=False, auth=auth()) + if req.status_code != 200: + return { 'error': req.status_code, 'reason': req.reason, 'url': req.url } + else: + return req.text.strip('"') + + +def post(endpoint: str, dct: dict | None = None): + req = requests.post(f'{sd_url}{endpoint}', json = dct, timeout=300, verify=False, auth=auth()) + if req.status_code != 200: + return { 'error': req.status_code, 'reason': req.reason, 'url': req.url } + else: + return req.json() + + +def display(image: Image.Image): + try: + import subprocess + buf = io.BytesIO() + image.save(buf, format="PNG") + subprocess.run(["timg", "--pixelation", "sixel", "-"], input=buf.getvalue()) + except Exception: + pass + + +def encode(img: str | Image.Image): + image = Image.open(img) if isinstance(img, str) else img + with io.BytesIO() as stream: + image.save(stream, 'PNG') + image.close() + values = stream.getvalue() + encoded = base64.b64encode(values).decode() + return encoded + + +def decode(encoding): + if encoding.startswith("data:image/"): + parts = encoding.split(";", 1) + if len(parts) == 2: + parts2 = parts[1].split(",", 1) + encoding = parts2[1] if len(parts2) == 2 else parts2[0] + return Image.open(io.BytesIO(base64.b64decode(encoding))) + + +def watermark(args): # pylint: disable=redefined-outer-name + if os.path.isdir(args.input): + filenames = [os.path.join(args.input, f) for f in os.listdir(args.input) if os.path.isfile(os.path.join(args.input, f))] + elif os.path.isfile(args.input): + filenames = [args.input] + else: + filenames = [] + if not args.quiet: + log.info(f'Inputs: {filenames}') + stats = { 'verified': 0, 'failed': 0, 'skipped': 0 } + + t0 = time.perf_counter() + for fn in filenames: + t1 = time.perf_counter() + try: + encoded = encode(fn) + except Exception as e: + log.error(f'Encode: file={fn}: {e}') + stats['skipped'] += 1 + continue + options = { 'image': encoded } + if args.wm_text: + options['wm_text'] = args.wm_text + if args.wm_image: + options['wm_image'] = encode(args.wm_image) + if args.position: + options['position'] = args.position + response = post('/sdapi/v1/watermark', options) + image = decode(response) + t2 = time.perf_counter() + if args.output: + os.makedirs(args.output, exist_ok=True) + out = os.path.join(args.output, os.path.basename(fn)) + image.save(out) + if not args.quiet: + log.info(f'Watermarked: input="{fn}" output="{out}" image={image.size} watermark="{args.wm_image}" time={t2-t1:.4f}') + + if args.display and image is not None: + display(image) + + if args.verify and args.wm_text: + t3 = time.perf_counter() + options = { 'image': encode(image), 'wm_text': '*' * len(args.wm_text) } + wm = get('/sdapi/v1/watermark', options) + t4 = time.perf_counter() + if wm == args.wm_text: + if not args.quiet: + log.info(f'Verified: input="{fn}" watermark="{wm}" time={t4-t3:.4f}') + stats['verified'] += 1 + else: + log.error(f'Failed: input="{fn}" expected="{args.wm_text}" got="{wm}" time={t4-t3:.4f}') + stats['failed'] += 1 + + t5 = time.perf_counter() + log.info(f'Processed: images={len(filenames)} time={t5-t0:.4f} images/sec={len(filenames)/(t5-t0):.4f} avg={(t5-t0)/len(filenames):.4f} {stats}') + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description = 'api-watermark') + parser.add_argument('--input', required=True, default=None, help='input image or folder') + parser.add_argument('--output', required=False, default=None, help='output folder') + parser.add_argument('--wm-image', required=False, default=None, help='watermark image') + parser.add_argument('--wm-text', required=False, default=None, help='watermark text') + parser.add_argument('--position', required=False, default=None, help='watermark position') + parser.add_argument('--display', default=False, action='store_true', help='Display images') + parser.add_argument('--verify', default=False, action='store_true', help='Verify watermarks') + parser.add_argument('--quiet', default=False, action='store_true', help='Suppress debug output') + + args = parser.parse_args() + log.info(f'api-watermark: {args}') + watermark(args) diff --git a/cli/image-exif.py b/cli/image-exif.py index cd25f3b2d..19b5aed42 100755 --- a/cli/image-exif.py +++ b/cli/image-exif.py @@ -1,5 +1,4 @@ #!/bin/env python - import os import io import re diff --git a/cli/lora-quant-fidelity.py b/cli/lora-quant-fidelity.py index 28036ed66..80796f7e9 100644 --- a/cli/lora-quant-fidelity.py +++ b/cli/lora-quant-fidelity.py @@ -76,15 +76,15 @@ modules.cmd_args.parse_args() installer.add_args(modules.cmd_args.parser) modules.cmd_args.parsed, _unknown = modules.cmd_args.parser.parse_known_args([]) -import torch # pylint: disable=wrong-import-position -from safetensors import safe_open # pylint: disable=wrong-import-position -from rich import print as rprint # pylint: disable=wrong-import-position +import torch # pylint: disable=wrong-import-position, wrong-import-order +from safetensors import safe_open # pylint: disable=wrong-import-position, wrong-import-order +from rich import print as rprint # pylint: disable=wrong-import-position, wrong-import-order from modules import shared # pylint: disable=wrong-import-position,unused-import # shared must initialize before sd_models, which imports back into it from modules.lora import native_adapter, network, network_lora, network_lokr, network_hada, network_oft, network_full, network_ia3, network_glora, network_norm, lora_sdnq # pylint: disable=wrong-import-position -from modules.lora.lora_load import NATIVE_DISPATCH # pylint: disable=wrong-import-position -from sdnq.quantizer import sdnq_quantize_layer_weight # pylint: disable=wrong-import-position -from sdnq.quant_utils import rotate_hadamard # pylint: disable=wrong-import-position +from modules.lora.lora_load import NATIVE_DISPATCH # pylint: disable=wrong-import-position, wrong-import-order +from sdnq.quantizer import sdnq_quantize_layer_weight # pylint: disable=wrong-import-position, wrong-import-order +from sdnq.quant_utils import rotate_hadamard # pylint: disable=wrong-import-position, wrong-import-order MODEL_ROOTS = [ @@ -661,7 +661,7 @@ def main(): grid = f'step-ratio={r["step_ratio"]:.3f} crossers={r["crossers"]*100:5.1f}%' if r['step_ratio'] is not None else 'unquantized reference' rprint(f' {r["module"]:48s} fam={r["family"]:5s} dtype={r["dtype"]} {grid} rho={r["requant_rho"]:.3f}') del mapped, net - except KeyboardInterrupt: + except KeyboardInterrupt: # pylint: disable=try-except-raise raise except Exception as e: # one broken file must not cost the rest of the batch rprint(f'\n[red]lora failed[/red]: "{os.path.basename(lora_path)}" {type(e).__name__}: {e}') diff --git a/cli/nvidia-smi.py b/cli/nvidia-smi.py index 298fc562e..a8442ad36 100755 --- a/cli/nvidia-smi.py +++ b/cli/nvidia-smi.py @@ -3,12 +3,16 @@ import os import json import shutil import subprocess -import xmltodict from rich import print # pylint: disable=redefined-builtin from util import log, Map def get_nvidia_smi(output='dict'): + try: + import xmltodict + except ImportError: + log.error("xmltodict module not found") + return None smi = shutil.which('nvidia-smi') if smi is None: log.error("nvidia-smi not found") diff --git a/cli/requirements.txt b/cli/requirements.txt index 4918412d5..8b4b7be74 100644 --- a/cli/requirements.txt +++ b/cli/requirements.txt @@ -5,3 +5,4 @@ colormap filetype albumentations matplotlib +xmltodict diff --git a/modules/api/endpoints.py b/modules/api/endpoints.py index a9f67c196..fa4870973 100644 --- a/modules/api/endpoints.py +++ b/modules/api/endpoints.py @@ -469,10 +469,15 @@ def post_watermark(req: models.ReqWatermark): wm_text=req.wm_text, wm_image=req.wm_image, position=req.position, - fmt=req.fmt + fmt=req.fmt, + quiet=True, ) def get_watermark(req: models.ReqWatermark): """Retrieve the watermark from an image.""" from modules.image import watermark - return watermark.get_watermark(req.image) + return watermark.get_watermark( + image=req.image, + wm_text=req.wm_text, + quiet=True, + ) diff --git a/modules/api/helpers.py b/modules/api/helpers.py index 4a14c53b7..771eb56c2 100644 --- a/modules/api/helpers.py +++ b/modules/api/helpers.py @@ -35,7 +35,7 @@ def validate_sampler_name(name): raise HTTPException(status_code=404, detail="Sampler not found") -def decode_base64_to_image(encoding, quiet=False): +def decode_base64_to_image(encoding, quiet=False, keep_alpha=False): if encoding is None: return None if isinstance(encoding, str) and encoding.startswith("upload:"): @@ -49,7 +49,7 @@ def decode_base64_to_image(encoding, quiet=False): decoded = base64.b64decode(encoding) data = io.BytesIO(decoded) image = Image.open(data) - image = image.convert('RGB') + image = image.convert('RGBA') if keep_alpha else image.convert('RGB') return image except Exception as e: log.warning(f'API cannot decode image: {e}') @@ -120,10 +120,13 @@ def upscaler_to_index(name: str): def save_image(image, fn, ext): # actual save parameters = image.info.get('parameters', None) - image_format = Image.registered_extensions()[f'.{ext}'] + image_format = Image.registered_extensions()[f'.{ext.lower()}'] if image_format == 'PNG': pnginfo_data = PngImagePlugin.PngInfo() + binary_keys = {'icc_profile', 'exif'} # written via dedicated PNG chunks, not as text metadata for k, v in image.info.items(): + if k in binary_keys: + continue pnginfo_data.add_text(k, str(v)) image.save(fn, format=image_format, quality=shared.opts.jpeg_quality, pnginfo=pnginfo_data) elif image_format == 'JPEG': diff --git a/modules/image/watermark.py b/modules/image/watermark.py index 84e3ef761..409a6ce95 100644 --- a/modules/image/watermark.py +++ b/modules/image/watermark.py @@ -8,7 +8,7 @@ from modules.api.helpers import decode_base64_to_image, encode_pil_to_base64 from installer import install -def set_image_watermark(image: Image.Image, wm_image: Image.Image | None = None, position: str | None = None): +def set_image_watermark(image: Image.Image, wm_image: Image.Image | None = None, position: str | None = None, quiet: bool = False): position = (position or shared.opts.image_watermark_position).lower() if (position != 'none') and (wm_image is not None): # visible watermark t0 = time.perf_counter() @@ -41,45 +41,67 @@ def set_image_watermark(image: Image.Image, wm_image: Image.Image | None = None, else: # fallback to random if unknown position coords = (random.randint(0, image.width - wm_image.width), random.randint(0, image.height - wm_image.height)) try: - for x in range(wm_image.width): - for y in range(wm_image.height): - rgba = wm_image.getpixel((x, y)) - orig = image.getpixel((x+coords[0], y+coords[1])) - # alpha blend - a = rgba[3] / 255 - r = int(rgba[0] * a + orig[0] * (1 - a)) - g = int(rgba[1] * a + orig[1] * (1 - a)) - b = int(rgba[2] * a + orig[2] * (1 - a)) - if not a == 0: - image.putpixel((x+coords[0], y+coords[1]), (r, g, b)) + # original per-pixel implementation, kept for reference (slow, and crashes if base image mode is RGBA): + # for x in range(wm_image.width): + # for y in range(wm_image.height): + # rgba = wm_image.getpixel((x, y)) + # orig = image.getpixel((x+coords[0], y+coords[1])) + # # alpha blend + # a = rgba[3] / 255 + # r = int(rgba[0] * a + orig[0] * (1 - a)) + # g = int(rgba[1] * a + orig[1] * (1 - a)) + # b = int(rgba[2] * a + orig[2] * (1 - a)) + # if not a == 0: + # image.putpixel((x+coords[0], y+coords[1]), (r, g, b)) + alpha = wm_image.getchannel('A') + min_a, max_a = alpha.getextrema() + if max_a == 0: + log.warning(f'Watermark: image={wm_image} has no opaque pixels, nothing to blend') + elif min_a == 255: + log.warning(f'Watermark: image={wm_image} has no transparency, will overwrite region opaquely') + base_mode = image.mode + image = image.convert('RGBA') + image.paste(wm_image, coords, mask=alpha) # alpha-composite using native PIL blending + image = image.convert(base_mode) t1 = time.perf_counter() - log.debug(f'Watermark: image={image} watermark={wm_image} position={position} coords={coords} time={t1 - t0:.4f}') + if not quiet: + log.debug(f'Watermark: image={image} watermark={wm_image} position={position} coords={coords} time={t1 - t0:.4f}') except Exception as e: log.warning(f'Watermark: image={image} watermark={wm_image} {e}') return image -def set_text_watermark(image: Image.Image, wm_text: str | None = None): +def set_text_watermark(image: Image.Image, wm_text: str | None = None, quiet: bool = False, verify: bool = False): if wm_text is not None and len(wm_text) > 0: # invisible watermark - install('imwatermark') + install('invisible-watermark') + try: + from imwatermark import WatermarkEncoder, WatermarkDecoder + except Exception as e: + log.warning(f'Watermark: cannot import WatermarkEncoder: {e}') + return image t0 = time.perf_counter() - from imwatermark import WatermarkEncoder wm_type = 'bytes' wm_method = 'dwtDctSvd' - wm_length = 32 + wm_length = len(wm_text) * 8 length = wm_length // 8 info = image.info data = np.asarray(image) encoder = WatermarkEncoder() + decoder = WatermarkDecoder(wm_type, wm_length) text = f"{wm_text:<{length}}"[:length] bytearr = text.encode(encoding='ascii', errors='ignore') try: encoder.set_watermark(wm_type, bytearr) encoded = encoder.encode(data, wm_method) image = Image.fromarray(encoded) + if verify: + decoded = decoder.decode(encoded, wm_method) + if decoded != bytearr: + log.warning(f'Watermark: text="{wm_text}" method={wm_method} bits={wm_length} embedding failed to verify') image.info = info t1 = time.perf_counter() - log.debug(f'Watermark: text="{wm_text}" method={wm_method} bits={wm_length} time={t1 - t0:.4f}') + if not quiet: + log.debug(f'Watermark: text="{wm_text}" method={wm_method} bits={wm_length} time={t1 - t0:.4f}') except Exception as e: log.warning(f'Watermark: text="{wm_text}" method={wm_method} bits={wm_length} {e}') return image @@ -91,26 +113,33 @@ def set_watermark(image: Image.Image, wm_text: str | None = None, wm_image: Imag return image -def get_watermark(image: Image.Image | str) -> str: - install('imwatermark') - from imwatermark import WatermarkDecoder +def get_watermark(image: Image.Image | str, wm_text: str | None = None, quiet: bool = False) -> str: + install('invisible-watermark') + try: + from imwatermark import WatermarkDecoder + except Exception as e: + log.warning(f'Watermark: cannot import WatermarkDecoder: {e}') + return '' wm_type = 'bytes' wm_method = 'dwtDctSvd' - wm_length = 32 + wm_length = len(wm_text) * 8 if wm_text is not None else 32 # set based on desired wm length if isinstance(image, str): - image = decode_base64_to_image(image) + image = decode_base64_to_image(image, keep_alpha=True) data = np.asarray(image) decoder = WatermarkDecoder(wm_type, wm_length) try: decoded = decoder.decode(data, wm_method) - wm = decoded.decode(encoding='ascii', errors='ignore') + if not quiet: + log.debug(f'Watermark: image={image} wm="{decoded}" method={wm_method} bits={wm_length}') except Exception: - wm = '' - return wm + decoded = '' + return decoded -def post_watermark(image: str, wm_text: str | None = None, wm_image: Image.Image | None = None, position: str = 'none', fmt: str = 'PNG') -> str: +def post_watermark(image: str, wm_text: str | None = None, wm_image: Image.Image | None = None, position: str = 'none', fmt: str = 'PNG', quiet: bool = False) -> str: img = decode_base64_to_image(image) - img = set_image_watermark(img, wm_image, position) - img = set_text_watermark(img, wm_text) - return encode_pil_to_base64(img, ext=fmt) + wm = decode_base64_to_image(wm_image, keep_alpha=True) if wm_image is not None else None # preserve alpha for watermark blending + img = set_image_watermark(img, wm, position, quiet=quiet) + img = set_text_watermark(img, wm_text, quiet=quiet) + encoded = encode_pil_to_base64(img, ext=fmt) + return encoded