update watermarking, add api and cli

Signed-off-by: Vladimir Mandic <[email protected]>
This commit is contained in:
Vladimir Mandic
2026-10-01 10:20:00 +02:00
parent 197d0da894
commit 4fffddf925
12 changed files with 248 additions and 58 deletions
+15 -11
View File
@@ -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
-1
View File
@@ -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
-1
View File
@@ -4,7 +4,6 @@
get list of all samplers and details of current sampler
"""
import sys
import logging
import urllib3
import requests
+1 -1
View File
@@ -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}')
+147
View File
@@ -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)
-1
View File
@@ -1,5 +1,4 @@
#!/bin/env python
import os
import io
import re
+7 -7
View File
@@ -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}')
+5 -1
View File
@@ -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")
+1
View File
@@ -5,3 +5,4 @@ colormap
filetype
albumentations
matplotlib
xmltodict
+7 -2
View File
@@ -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,
)
+6 -3
View File
@@ -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':
+59 -30
View File
@@ -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