From 035324b18ed7ef16a4113610e7fd989a0dfc5977 Mon Sep 17 00:00:00 2001 From: Vladimir Mandic Date: Tue, 6 May 2025 18:17:41 -0400 Subject: [PATCH 01/13] reduce log spam while progess is active Signed-off-by: Vladimir Mandic --- CHANGELOG.md | 6 ++++++ launch.py | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c19c9d288..1cf66db3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Update for 2025-05-06 +- **Fixes** + - FramePack: monkey-patch for dynamically installed `av` + - Logging: reduce spam while progress is active + +## Update for 2025-05-06 + - **Features** - [FramePack](https://vladmandic.github.io/sdnext-docs/FramePack) add **T2V** mode in addition to **I2V** and **FLF2V** diff --git a/launch.py b/launch.py index 47d66f47d..2f1031ec6 100755 --- a/launch.py +++ b/launch.py @@ -303,8 +303,12 @@ def main(): alive = False requests = 0 t_current = time.time() + t_timestamp = 'none' if float(args.status) > 0 and t_current - t_server > float(args.status): - installer.log.trace(f'Server: alive={alive} requests={requests} memory={get_memory_stats()} {instance.state.status()}') + s = instance.state.status() + if s.timestamp is None or s.timestamp != t_timestamp: # dont spam during active job + installer.log.trace(f'Server: alive={alive} requests={requests} memory={get_memory_stats()} {instance.state.status()}') + t_timestamp = s.timestamp t_server = t_current if float(args.monitor) > 0 and t_current - t_monitor > float(args.monitor): installer.log.trace(f'Monitor: {get_memory_stats(detailed=True)}') From 78e22350b99d33501f0fc619e4f946fe26f79a6f Mon Sep 17 00:00:00 2001 From: Vladimir Mandic Date: Wed, 7 May 2025 10:31:06 -0400 Subject: [PATCH 02/13] add api get-checkpoint Signed-off-by: Vladimir Mandic --- CHANGELOG.md | 5 ++++- cli/api-checkpoint.py | 37 +++++++++++++++++++++++++++++++++++++ modules/api/api.py | 1 + modules/api/endpoints.py | 20 ++++++++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) create mode 100755 cli/api-checkpoint.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cf66db3a..c1e4950d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,10 @@ # Change Log for SD.Next -## Update for 2025-05-06 +## Update for 2025-05-07 +- **API** + - full API support for FramePack + - add `/sdapi/v1/checkpoint` endpoint to get info on currently loaded model/checkpoint - **Fixes** - FramePack: monkey-patch for dynamically installed `av` - Logging: reduce spam while progress is active diff --git a/cli/api-checkpoint.py b/cli/api-checkpoint.py new file mode 100755 index 000000000..61f4e4370 --- /dev/null +++ b/cli/api-checkpoint.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python +import os +import logging +import requests +import urllib3 + + +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) +options = { + "save_images": True, + "send_images": True, +} + +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): + 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.json() + + +if __name__ == "__main__": + model = get('/sdapi/v1/checkpoint') + log.info(f'api-checkpoint: {model}') diff --git a/modules/api/api.py b/modules/api/api.py index 39210ffca..b653250f5 100644 --- a/modules/api/api.py +++ b/modules/api/api.py @@ -89,6 +89,7 @@ class Api: self.add_api_route("/sdapi/v1/png-info", endpoints.post_pnginfo, methods=["POST"], response_model=models.ResImageInfo) self.add_api_route("/sdapi/v1/interrogate", endpoints.post_interrogate, methods=["POST"]) self.add_api_route("/sdapi/v1/vqa", endpoints.post_vqa, methods=["POST"]) + self.add_api_route("/sdapi/v1/checkpoint", endpoints.get_checkpoint, methods=["GET"]) self.add_api_route("/sdapi/v1/refresh-checkpoints", endpoints.post_refresh_checkpoints, methods=["POST"]) self.add_api_route("/sdapi/v1/unload-checkpoint", endpoints.post_unload_checkpoint, methods=["POST"]) self.add_api_route("/sdapi/v1/reload-checkpoint", endpoints.post_reload_checkpoint, methods=["POST"]) diff --git a/modules/api/endpoints.py b/modules/api/endpoints.py index 1decd8c71..80b46f324 100644 --- a/modules/api/endpoints.py +++ b/modules/api/endpoints.py @@ -130,6 +130,26 @@ def post_reload_checkpoint(): sd_models.reload_model_weights() return {} +def get_checkpoint(): + if not shared.sd_loaded or shared.sd_model is None: + checkpoint = { + 'type': None, + 'class': None, + } + else: + checkpoint = { + 'type': shared.sd_model_type, + 'class': shared.sd_model.__class__.__name__, + } + if hasattr(shared.sd_model, 'sd_model_checkpoint'): + checkpoint['checkpoint'] = shared.sd_model.sd_model_checkpoint + if hasattr(shared.sd_model, 'sd_checkpoint_info'): + checkpoint['title'] = shared.sd_model.sd_checkpoint_info.title + checkpoint['name'] = shared.sd_model.sd_checkpoint_info.name + checkpoint['filename'] = shared.sd_model.sd_checkpoint_info.filename + checkpoint['hash'] = shared.sd_model.sd_checkpoint_info.shorthash + return checkpoint + def post_refresh_checkpoints(): shared.refresh_checkpoints() return {} From 5261c558906db28f4d8762b95a83bf369ee25adf Mon Sep 17 00:00:00 2001 From: Vladimir Mandic Date: Wed, 7 May 2025 11:48:21 -0400 Subject: [PATCH 03/13] fix lora legacy disabled Signed-off-by: Vladimir Mandic --- modules/shared.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/modules/shared.py b/modules/shared.py index 07e81d7be..d0e0289af 100644 --- a/modules/shared.py +++ b/modules/shared.py @@ -335,6 +335,10 @@ def temp_disable_extensions(): disabled.append(ext) if not opts.lora_legacy: disabled.append('Lora') + else: + if 'Lora' in disabled: + disabled.remove('Lora') + cmd_opts.controlnet_loglevel = 'WARNING' return disabled From bffdaa2786c55b0867b2a1c56555deda9c0e7ae4 Mon Sep 17 00:00:00 2001 From: Vladimir Mandic Date: Wed, 7 May 2025 11:54:36 -0400 Subject: [PATCH 04/13] update changelog Signed-off-by: Vladimir Mandic --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1e4950d2..796b06edf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,13 @@ - **Fixes** - FramePack: monkey-patch for dynamically installed `av` - Logging: reduce spam while progress is active + - LoRA: legacy handler enable/disable ## Update for 2025-05-06 +Minor refesh with several bugfixes and updates to core libraries +Plus new features with **FramePack** and **HiDream-E1** + - **Features** - [FramePack](https://vladmandic.github.io/sdnext-docs/FramePack) add **T2V** mode in addition to **I2V** and **FLF2V** From a57c7087b83fc7ad69e697378e2ee9b59f240574 Mon Sep 17 00:00:00 2001 From: Disty0 Date: Wed, 7 May 2025 20:34:07 +0300 Subject: [PATCH 05/13] Make NNCF INT4 quant run 75% faster and don't force fp32 decompress --- installer.py | 2 +- modules/model_quant_nncf.py | 26 +++++++++++++------------- modules/shared.py | 7 ++++--- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/installer.py b/installer.py index 4a1eafbbe..baf85ffba 100644 --- a/installer.py +++ b/installer.py @@ -1163,7 +1163,7 @@ def install_optional(): install('albumentations==1.4.3', ignore=True) install('pydantic==1.10.21', ignore=True) reload('pydantic', '1.10.21') - install('nncf==2.16.0', ignore=True) # requires older pandas + install('nncf==2.16.0', ignore=True) install('gguf', ignore=True) install('av', ignore=True) try: diff --git a/modules/model_quant_nncf.py b/modules/model_quant_nncf.py index c4b6df127..d85e53ea5 100644 --- a/modules/model_quant_nncf.py +++ b/modules/model_quant_nncf.py @@ -42,7 +42,7 @@ class QuantizationMethod(str, Enum): NNCF = "nncf" -# de-abstracted and modified slightly from the actual quant functions of nncf 2.16.0: +# de-abstracted and modified from the actual quant functions of nncf 2.16.0: def nncf_compress_layer(layer, num_bits, is_asym_mode, torch_dtype=None, quant_conv=False, param_name=None): if layer.__class__.__name__ in allowed_types: if torch_dtype is None: @@ -77,7 +77,7 @@ def nncf_compress_layer(layer, num_bits, is_asym_mode, torch_dtype=None, quant_c scale = torch.where(torch.abs(scale) < eps, eps, scale) zero_point = level_low - torch.round(min_values / scale) - zero_point = torch.clip(zero_point.to(dtype=torch.int32), level_low, level_high) + zero_point = torch.clip(zero_point.to(dtype=torch.int32), level_low, level_high).to(dtype=torch.float32) else: factor = 2 ** (num_bits - 1) @@ -96,8 +96,12 @@ def nncf_compress_layer(layer, num_bits, is_asym_mode, torch_dtype=None, quant_c level_high = 2**num_bits - 1 if is_asym_mode else 2 ** (num_bits - 1) - 1 compressed_weight = layer.weight.data / scale + if not shared.opts.nncf_decompress_fp32: + scale = scale.to(torch_dtype) + if zero_point is not None: - compressed_weight += zero_point.to(dtype=layer.weight.dtype) + compressed_weight += zero_point + zero_point = zero_point.to(scale.dtype) compressed_weight = torch.round(compressed_weight) compressed_weight = torch.clip(compressed_weight, level_low, level_high).to(dtype) @@ -359,7 +363,6 @@ class NNCF_T5DenseGatedActDense(torch.nn.Module): # forward can't find what self def decompress_asymmetric(input: torch.Tensor, scale: torch.Tensor, zero_point: torch.Tensor) -> torch.Tensor: input = input.to(dtype=scale.dtype) - zero_point = zero_point.to(dtype=scale.dtype) decompressed_input = (input - zero_point) * scale return decompressed_input @@ -401,7 +404,7 @@ class INT8AsymmetricWeightsDecompressor(torch.nn.Module): def __init__(self, scale: torch.Tensor, zero_point: torch.Tensor, result_dtype: Optional[torch.dtype] = None): super().__init__() self.scale = scale - self.zero_point = self.pack_weight(zero_point) + self.zero_point = zero_point self.result_dtype = result_dtype @property @@ -453,6 +456,7 @@ class INT8SymmetricWeightsDecompressor(torch.nn.Module): def forward(self, x, *args, return_decompressed_only=False): result = decompress_symmetric(x.weight, self.scale) result = result.to(dtype=self.result_dtype) + if return_decompressed_only: return result else: @@ -470,10 +474,7 @@ class INT4AsymmetricWeightsDecompressor(torch.nn.Module): ): super().__init__() self.scale = scale - - self.zero_point_shape = zero_point.shape - self.zero_point = self.pack_weight(zero_point) - + self.zero_point = zero_point self.compressed_weight_shape = compressed_weight_shape self.result_shape = result_shape self.result_dtype = result_dtype @@ -496,12 +497,10 @@ class INT4AsymmetricWeightsDecompressor(torch.nn.Module): result = unpack_uint4(x.weight) result = result.reshape(self.compressed_weight_shape) - zero_point = unpack_uint4(self.zero_point) - zero_point = zero_point.reshape(self.zero_point_shape) - - result = decompress_asymmetric(result, self.scale, zero_point) + result = decompress_asymmetric(result, self.scale, self.zero_point) result = result.reshape(self.result_shape) if self.result_shape is not None else result result = result.to(dtype=self.result_dtype) + if return_decompressed_only: return result else: @@ -547,6 +546,7 @@ class INT4SymmetricWeightsDecompressor(torch.nn.Module): result = decompress_symmetric(result, self.scale) result = result.reshape(self.result_shape) if self.result_shape is not None else result result = result.to(dtype=self.result_dtype) + if return_decompressed_only: return result else: diff --git a/modules/shared.py b/modules/shared.py index d0e0289af..884ca3154 100644 --- a/modules/shared.py +++ b/modules/shared.py @@ -532,7 +532,7 @@ options_templates.update(options_section(('quantization', "Quantization Settings "optimum_quanto_weights": OptionInfo([], "Quantization enabled", gr.CheckboxGroup, {"choices": ["Model", "Transformer", "VAE", "TE", "Video", "LLM", "ControlNet"], "visible": native}), "optimum_quanto_weights_type": OptionInfo("qint8", "Quantization weights type", gr.Dropdown, {"choices": ['qint8', 'qfloat8_e4m3fn', 'qfloat8_e5m2', 'qint4', 'qint2'], "visible": native}), "optimum_quanto_activations_type": OptionInfo("none", "Quantization activations type ", gr.Dropdown, {"choices": ['none', 'qint8', 'qfloat8_e4m3fn', 'qfloat8_e5m2'], "visible": native}), - "optimum_quanto_shuffle_weights": OptionInfo(False, "Shuffle weights", gr.Checkbox, {"visible": native}), + "optimum_quanto_shuffle_weights": OptionInfo(False, "Shuffle weights in post mode", gr.Checkbox, {"visible": native}), "torchao_sep": OptionInfo("

TorchAO

", "", gr.HTML), "torchao_quantization": OptionInfo([], "Quantization enabled", gr.CheckboxGroup, {"choices": ["Model", "Transformer", "VAE", "TE", "Video", "LLM"], "visible": native}), @@ -546,9 +546,10 @@ options_templates.update(options_section(('quantization', "Quantization Settings "nncf_compress_weights_raito": OptionInfo(0, "Compress ratio", gr.Slider, {"minimum": 0, "maximum": 1, "step": 0.01, "visible": cmd_opts.use_openvino}), "nncf_compress_weights_group_size": OptionInfo(0, "Group size", gr.Slider, {"minimum": -1, "maximum": 512, "step": 1, "visible": cmd_opts.use_openvino}), "nncf_quantize": OptionInfo([], "OpenVINO enabled", gr.CheckboxGroup, {"choices": ["Model", "VAE", "TE"], "visible": cmd_opts.use_openvino}), - "nncf_quantize_mode": OptionInfo("INT8", "OpenVINO mode", gr.Dropdown, {"choices": ['INT8', 'FP8_E4M3', 'FP8_E5M2'], "visible": cmd_opts.use_openvino}), + "nncf_quantize_mode": OptionInfo("INT8", "OpenVINO activations mode", gr.Dropdown, {"choices": ['INT8', 'FP8_E4M3', 'FP8_E5M2'], "visible": cmd_opts.use_openvino}), "nncf_quantize_conv_layers": OptionInfo(False, "Quantize the convolutional layers", gr.Checkbox, {"visible": native}), - "nncf_quantize_shuffle_weights": OptionInfo(False, "Shuffle weights", gr.Checkbox, {"visible": native}), + "nncf_decompress_fp32": OptionInfo(False, "Decompress using full precision", gr.Checkbox, {"visible": native}), + "nncf_quantize_shuffle_weights": OptionInfo(False, "Shuffle weights in post mode", gr.Checkbox, {"visible": native}), "layerwise_quantization_sep": OptionInfo("

Layerwise Casting

", "", gr.HTML), "layerwise_quantization": OptionInfo([], "Layerwise casting enabled", gr.CheckboxGroup, {"choices": ["Model", "Transformer", "TE"], "visible": native}), From b6d2aa7fd87e7527aa147e5a75630b1b9233dad0 Mon Sep 17 00:00:00 2001 From: Disty0 Date: Wed, 7 May 2025 21:50:31 +0300 Subject: [PATCH 06/13] NNCF more optimizations --- modules/model_quant_nncf.py | 76 +++++++++++++++---------------------- 1 file changed, 31 insertions(+), 45 deletions(-) diff --git a/modules/model_quant_nncf.py b/modules/model_quant_nncf.py index d85e53ea5..d40d57487 100644 --- a/modules/model_quant_nncf.py +++ b/modules/model_quant_nncf.py @@ -1,7 +1,8 @@ -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Union from dataclasses import dataclass from enum import Enum +import os import torch from diffusers.quantizers.base import DiffusersQuantizer from diffusers.quantizers.quantization_config import QuantizationConfigMixin @@ -13,6 +14,8 @@ from accelerate.utils import CustomDtype from modules import devices, shared +debug = os.environ.get('SD_QUANT_DEBUG', None) is not None + torch_dtype_dict = { "int8": torch.int8, "uint8": torch.uint8, @@ -112,27 +115,25 @@ def nncf_compress_layer(layer, num_bits, is_asym_mode, torch_dtype=None, quant_c scale=scale.data, zero_point=zero_point.data, compressed_weight_shape=compressed_weight.shape, - result_shape=layer.weight.shape, - result_dtype=torch_dtype + result_dtype=torch_dtype, ) else: decompressor = INT4SymmetricWeightsDecompressor( scale=scale.data, compressed_weight_shape=compressed_weight.shape, - result_shape=layer.weight.shape, - result_dtype=torch_dtype + result_dtype=torch_dtype, ) else: if is_asym_mode: decompressor = INT8AsymmetricWeightsDecompressor( scale=scale.data, zero_point=zero_point.data, - result_dtype=torch_dtype + result_dtype=torch_dtype, ) else: decompressor = INT8SymmetricWeightsDecompressor( scale=scale.data, - result_dtype=torch_dtype + result_dtype=torch_dtype, ) compressed_weight = decompressor.pack_weight(compressed_weight) @@ -377,15 +378,14 @@ def unpack_uint4(packed_tensor: torch.Tensor) -> torch.Tensor: return torch.stack((torch.bitwise_and(packed_tensor, 15), torch.bitwise_right_shift(packed_tensor, 4)), dim=-1) -def unpack_int4(packed_tensor: torch.Tensor) -> torch.Tensor: +def unpack_int4(packed_tensor: torch.Tensor, dtype: Optional[torch.dtype] = torch.int8) -> torch.Tensor: t = unpack_uint4(packed_tensor) - return t.to(dtype=torch.int8) - 8 + return t.to(dtype=dtype) - 8 def pack_uint4(tensor: torch.Tensor) -> torch.Tensor: if tensor.dtype != torch.uint8: - msg = f"Invalid tensor dtype {tensor.type}. torch.uint8 type is supported." - raise RuntimeError(msg) + raise RuntimeError(f"Invalid tensor dtype {tensor.type}. torch.uint8 type is supported.") packed_tensor = tensor.contiguous() packed_tensor = packed_tensor.reshape(-1, 2) packed_tensor = torch.bitwise_and(packed_tensor[..., ::2], 15) | packed_tensor[..., 1::2] << 4 @@ -394,14 +394,13 @@ def pack_uint4(tensor: torch.Tensor) -> torch.Tensor: def pack_int4(tensor: torch.Tensor) -> torch.Tensor: if tensor.dtype != torch.int8: - msg = f"Invalid tensor dtype {tensor.type}. torch.int8 type is supported." - raise RuntimeError(msg) + raise RuntimeError(f"Invalid tensor dtype {tensor.type}. torch.int8 type is supported.") tensor = tensor + 8 return pack_uint4(tensor.to(dtype=torch.uint8)) class INT8AsymmetricWeightsDecompressor(torch.nn.Module): - def __init__(self, scale: torch.Tensor, zero_point: torch.Tensor, result_dtype: Optional[torch.dtype] = None): + def __init__(self, scale: torch.Tensor, zero_point: torch.Tensor, result_dtype: torch.dtype): super().__init__() self.scale = scale self.zero_point = zero_point @@ -416,12 +415,9 @@ class INT8AsymmetricWeightsDecompressor(torch.nn.Module): return "asymmetric" def pack_weight(self, weight: torch.Tensor) -> torch.Tensor: - if torch.is_floating_point(weight): - msg = f"Invalid weight dtype {weight.type}. Integer types are supported." - raise ValueError(msg) - if torch.any((weight < 0) | (weight > 255)): - msg = "Weight values are not in [0, 255]." - raise ValueError(msg) + if debug: + if torch.any((weight < 0) | (weight > 255)): + raise ValueError("Weight values are not in [0, 255].") return weight.to(dtype=torch.uint8) def forward(self, x, *args, return_decompressed_only=False): @@ -434,7 +430,7 @@ class INT8AsymmetricWeightsDecompressor(torch.nn.Module): class INT8SymmetricWeightsDecompressor(torch.nn.Module): - def __init__(self, scale: torch.Tensor, result_dtype: Optional[torch.dtype] = None): + def __init__(self, scale: torch.Tensor, result_dtype: torch.dtype): super().__init__() self.scale = scale self.result_dtype = result_dtype @@ -448,9 +444,9 @@ class INT8SymmetricWeightsDecompressor(torch.nn.Module): return "symmetric" def pack_weight(self, weight: torch.Tensor) -> torch.Tensor: - if torch.any((weight < -128) | (weight > 127)): - msg = "Weight values are not in [-128, 127]." - raise ValueError(msg) + if debug: + if torch.any((weight < -128) | (weight > 127)): + raise ValueError("Weight values are not in [-128, 127].") return weight.to(dtype=torch.int8) def forward(self, x, *args, return_decompressed_only=False): @@ -468,15 +464,13 @@ class INT4AsymmetricWeightsDecompressor(torch.nn.Module): self, scale: torch.Tensor, zero_point: torch.Tensor, - compressed_weight_shape: Tuple[int, ...], - result_shape: Optional[Tuple[int, ...]] = None, - result_dtype: Optional[torch.dtype] = None, + compressed_weight_shape: torch.Size, + result_dtype: torch.dtype, ): super().__init__() self.scale = scale self.zero_point = zero_point self.compressed_weight_shape = compressed_weight_shape - self.result_shape = result_shape self.result_dtype = result_dtype @property @@ -488,9 +482,9 @@ class INT4AsymmetricWeightsDecompressor(torch.nn.Module): return "asymmetric" def pack_weight(self, weight: torch.Tensor) -> torch.Tensor: - if torch.any((weight < 0) | (weight > 15)): - msg = "Weight values are not in [0, 15]." - raise ValueError(msg) + if debug: + if torch.any((weight < 0) | (weight > 15)): + raise ValueError("Weight values are not in [0, 15].") return pack_uint4(weight.to(dtype=torch.uint8)) def forward(self, x, *args, return_decompressed_only=False): @@ -498,7 +492,6 @@ class INT4AsymmetricWeightsDecompressor(torch.nn.Module): result = result.reshape(self.compressed_weight_shape) result = decompress_asymmetric(result, self.scale, self.zero_point) - result = result.reshape(self.result_shape) if self.result_shape is not None else result result = result.to(dtype=self.result_dtype) if return_decompressed_only: @@ -511,15 +504,12 @@ class INT4SymmetricWeightsDecompressor(torch.nn.Module): def __init__( self, scale: torch.Tensor, - compressed_weight_shape: Tuple[int, ...], - result_shape: Optional[Tuple[int, ...]] = None, - result_dtype: Optional[torch.dtype] = None, + compressed_weight_shape: torch.Size, + result_dtype: torch.dtype, ): super().__init__() self.scale = scale - self.compressed_weight_shape = compressed_weight_shape - self.result_shape = result_shape self.result_dtype = result_dtype @property @@ -531,20 +521,16 @@ class INT4SymmetricWeightsDecompressor(torch.nn.Module): return "symmetric" def pack_weight(self, weight: torch.Tensor) -> torch.Tensor: - if torch.is_floating_point(weight): - msg = f"Invalid weight dtype {weight.type}. Integer types are supported." - raise ValueError(msg) - if torch.any((weight < -8) | (weight > 7)): - msg = "Tensor values are not in [-8, 7]." - raise ValueError(msg) + if debug: + if torch.any((weight < -8) | (weight > 7)): + raise ValueError("Tensor values are not in [-8, 7].") return pack_int4(weight.to(dtype=torch.int8)) def forward(self, x, *arg, return_decompressed_only=False): - result = unpack_int4(x.weight) + result = unpack_int4(x.weight, dtype=self.scale.dtype) result = result.reshape(self.compressed_weight_shape) result = decompress_symmetric(result, self.scale) - result = result.reshape(self.result_shape) if self.result_shape is not None else result result = result.to(dtype=self.result_dtype) if return_decompressed_only: From 8433f685e7bb2124fd67a25514bc996223fc613c Mon Sep 17 00:00:00 2001 From: Vladimir Mandic Date: Wed, 7 May 2025 20:22:27 -0400 Subject: [PATCH 07/13] clear-cache on model unload Signed-off-by: Vladimir Mandic --- CHANGELOG.md | 8 +++++--- modules/sd_models.py | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 796b06edf..d64957182 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,15 @@ ## Update for 2025-05-07 -- **API** - - full API support for FramePack - - add `/sdapi/v1/checkpoint` endpoint to get info on currently loaded model/checkpoint +- **Features** + - FramePack: full API support + - NNCF: Faster quantization + - API: add `/sdapi/v1/checkpoint` endpoint to get info on currently loaded model/checkpoint - **Fixes** - FramePack: monkey-patch for dynamically installed `av` - Logging: reduce spam while progress is active - LoRA: legacy handler enable/disable + - LoRA: force clear-cache on model unload ## Update for 2025-05-06 diff --git a/modules/sd_models.py b/modules/sd_models.py index 10a34ad44..22ba21a49 100644 --- a/modules/sd_models.py +++ b/modules/sd_models.py @@ -1086,6 +1086,7 @@ def clear_caches(): def unload_model_weights(op='model'): + clear_caches() if shared.compiled_model_state is not None: shared.compiled_model_state.compiled_cache.clear() shared.compiled_model_state.req_cache.clear() From dfebc909eb8a95a566f575ae0630eb4667c5c1e6 Mon Sep 17 00:00:00 2001 From: Disty0 Date: Thu, 8 May 2025 13:27:06 +0300 Subject: [PATCH 08/13] Disable cuDNN benchmark on ROCm and add cudnn_benchmark_limit option --- modules/devices.py | 4 ++-- modules/shared.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/modules/devices.py b/modules/devices.py index c35c8b909..0c5fb1552 100644 --- a/modules/devices.py +++ b/modules/devices.py @@ -395,10 +395,10 @@ def set_cudnn_params(): torch.use_deterministic_algorithms(opts.cudnn_deterministic) if opts.cudnn_deterministic: os.environ.setdefault('CUBLAS_WORKSPACE_CONFIG', ':4096:8') - torch.backends.cudnn.benchmark = True + torch.backends.cudnn.benchmark = opts.cudnn_benchmark if opts.cudnn_benchmark: log.debug('Torch cuDNN: enable benchmark') - torch.backends.cudnn.benchmark_limit = 0 + torch.backends.cudnn.benchmark_limit = opts.cudnn_benchmark_limit torch.backends.cudnn.allow_tf32 = True except Exception as e: log.warning(f'Torch cudnn: {e}') diff --git a/modules/shared.py b/modules/shared.py index 884ca3154..b1faa53ba 100644 --- a/modules/shared.py +++ b/modules/shared.py @@ -482,9 +482,10 @@ options_templates.update(options_section(('backends', "Backend Settings"), { "other_sep": OptionInfo("

Torch Options

", "", gr.HTML), "opt_channelslast": OptionInfo(False, "Channels last "), "cudnn_deterministic": OptionInfo(False, "Deterministic mode"), - "cudnn_benchmark": OptionInfo(False, "Full-depth cuDNN benchmark"), "diffusers_fuse_projections": OptionInfo(False, "Fused projections"), "torch_expandable_segments": OptionInfo(False, "Expandable segments"), + "cudnn_benchmark": OptionInfo(devices.backend != "rocm", "Full-depth cuDNN benchmark"), + "cudnn_benchmark_limit": OptionInfo(10, "cuDNN benchmark limit", gr.Slider, {"minimum": 0, "maximum": 100, "step": 1}), "torch_tunable_ops": OptionInfo("default", "Tunable ops", gr.Radio, {"choices": ["default", "true", "false"]}), "torch_tunable_limit": OptionInfo(30, "Tunable ops limit", gr.Slider, {"minimum": 1, "maximum": 100, "step": 1}), "cuda_mem_fraction": OptionInfo(0.0, "Memory limit", gr.Slider, {"minimum": 0, "maximum": 2.0, "step": 0.05}), From f3aa3b4574b471e71aea22bb7b9673f1d3001a59 Mon Sep 17 00:00:00 2001 From: Disty0 Date: Thu, 8 May 2025 14:11:19 +0300 Subject: [PATCH 09/13] NNCF remove T5 hijack from pre quant mode --- modules/model_quant_nncf.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/modules/model_quant_nncf.py b/modules/model_quant_nncf.py index d40d57487..0751e071b 100644 --- a/modules/model_quant_nncf.py +++ b/modules/model_quant_nncf.py @@ -244,22 +244,12 @@ class NNCFQuantizer(DiffusersQuantizer): from nncf.torch.nncf_module_replacement import replace_modules_by_nncf_modules self.modules_to_not_convert = self.quantization_config.modules_to_not_convert - if not isinstance(self.modules_to_not_convert, list): self.modules_to_not_convert = [self.modules_to_not_convert] - if keep_in_fp32_modules is not None: self.modules_to_not_convert.extend(keep_in_fp32_modules) model.config.quantization_config = self.quantization_config - - if model.__class__.__name__ in {"T5EncoderModel", "UMT5EncoderModel"}: - for i in range(len(model.encoder.block)): - model.encoder.block[i].layer[1].DenseReluDense = NNCF_T5DenseGatedActDense( - model.encoder.block[i].layer[1].DenseReluDense, - dtype=torch.float32 if devices.dtype != torch.bfloat16 else torch.bfloat16 - ) - with init_empty_weights(): model, _ = replace_modules_by_nncf_modules(model) From 569c777aa9234009b6c6dc7acbe816ea1e3266c9 Mon Sep 17 00:00:00 2001 From: Vladimir Mandic Date: Thu, 8 May 2025 09:43:12 -0400 Subject: [PATCH 10/13] use in-process installer for extensions-builtin Signed-off-by: Vladimir Mandic --- CHANGELOG.md | 8 +++++--- installer.py | 44 +++++++++++++++++++++++++++----------------- launch.py | 5 ++++- 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d64957182..48b37e337 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,14 @@ # Change Log for SD.Next -## Update for 2025-05-07 +## Update for 2025-05-08 - **Features** - - FramePack: full API support - - NNCF: Faster quantization + - FramePack: full API support + - NNCF: Faster quantization - API: add `/sdapi/v1/checkpoint` endpoint to get info on currently loaded model/checkpoint - **Fixes** + - ROCm: disable cuDNN, fixes slow MIOpen tuning with `torch==2.7` + - Extensions: use in-process installer for extensions-builtin, improves startup performance - FramePack: monkey-patch for dynamically installed `av` - Logging: reduce spam while progress is active - LoRA: legacy handler enable/disable diff --git a/installer.py b/installer.py index baf85ffba..f51299d5d 100644 --- a/installer.py +++ b/installer.py @@ -71,8 +71,10 @@ control_extensions = [ # 3rd party extensions marked as safe for control ui try: from modules.timer import init ts = init.ts + elapsed = init.elapsed except Exception: ts = lambda *args, **kwargs: None # pylint: disable=unnecessary-lambda-assignment + elapsed = lambda *args, **kwargs: None # pylint: disable=unnecessary-lambda-assignment def get_console(): @@ -977,21 +979,27 @@ def run_extension_installer(folder): if not os.path.isfile(path_installer): return try: - log.debug(f"Extension installer: {path_installer}") - env = os.environ.copy() - env['PYTHONPATH'] = os.path.abspath(".") - if os.environ.get('PYTHONPATH', None) is not None: - seperator = ';' if sys.platform == 'win32' else ':' - env['PYTHONPATH'] += seperator + os.environ.get('PYTHONPATH', None) - result = subprocess.run(f'"{sys.executable}" "{path_installer}"', shell=True, env=env, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=folder) - txt = result.stdout.decode(encoding="utf8", errors="ignore") - debug(f'Extension installer: file="{path_installer}" {txt}') - if result.returncode != 0: - errors.append(f'ext: {os.path.basename(folder)}') - if len(result.stderr) > 0: - txt = txt + '\n' + result.stderr.decode(encoding="utf8", errors="ignore") - log.error(f'Extension installer error: {path_installer}') - log.debug(txt) + is_builtin = 'extensions-builtin' in folder + log.debug(f'Extension installer: builtin={is_builtin} file="{path_installer}"') + if is_builtin: + module_spec = importlib.util.spec_from_file_location(os.path.basename(folder), path_installer) + module = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(module) + else: + env = os.environ.copy() + env['PYTHONPATH'] = os.path.abspath(".") + if os.environ.get('PYTHONPATH', None) is not None: + seperator = ';' if sys.platform == 'win32' else ':' + env['PYTHONPATH'] += seperator + os.environ.get('PYTHONPATH', None) + result = subprocess.run(f'"{sys.executable}" "{path_installer}"', shell=True, env=env, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=folder) + txt = result.stdout.decode(encoding="utf8", errors="ignore") + debug(f'Extension installer: file="{path_installer}" {txt}') + if result.returncode != 0: + errors.append(f'ext: {os.path.basename(folder)}') + if len(result.stderr) > 0: + txt = txt + '\n' + result.stderr.decode(encoding="utf8", errors="ignore") + log.error(f'Extension installer error: {path_installer}') + log.debug(txt) except Exception as e: log.error(f'Extension installer exception: {e}') @@ -1010,7 +1018,6 @@ def list_extensions_folder(folder, quiet=False): # run installer for each installed and enabled extension and optionally update them def install_extensions(force=False): - t_start = time.time() if args.profile: pr = cProfile.Profile() pr.enable() @@ -1022,12 +1029,14 @@ def install_extensions(force=False): extensions_enabled = [] extension_folders = [extensions_builtin_dir] if args.safe else [extensions_builtin_dir, extensions_dir] res = [] + for folder in extension_folders: if not os.path.isdir(folder): continue extensions = list_extensions_folder(folder, quiet=True) log.debug(f'Extensions all: {extensions}') for ext in extensions: + t_start = time.time() if ext in extensions_enabled: extensions_duplicates.append(ext) continue @@ -1053,13 +1062,14 @@ def install_extensions(force=False): log.info(f'Extension installed packages: {ext} {diff}') except Exception as e: log.error(f'Extension installed unknown package: {e}') + ts(ext, t_start) log.info(f'Extensions enabled: {extensions_enabled}') if len(extensions_duplicates) > 0: log.warning(f'Extensions duplicates: {extensions_duplicates}') if args.profile: pr.disable() print_profile(pr, 'Extensions') - ts('extensions', t_start) + # ts('extensions', t_start) return '\n'.join(res) diff --git a/launch.py b/launch.py index 2f1031ec6..0921065ef 100755 --- a/launch.py +++ b/launch.py @@ -25,10 +25,12 @@ skip_install = False # parsed by some extensions try: - from modules.timer import launch + from modules.timer import launch, init rec = launch.record + init_summary = init.summary except Exception: rec = lambda *args, **kwargs: None # pylint: disable=unnecessary-lambda-assignment + init_summary = lambda *args, **kwargs: None # pylint: disable=unnecessary-lambda-assignment def init_args(): @@ -290,6 +292,7 @@ def main(): installer.log.warning(f'See log file for more details: {installer.log_file}') installer.extensions_preload(parser) # adds additional args from extensions args = installer.parse_args(parser) + installer.log.info(f'Installer time: {init_summary()}') get_custom_args() uv, instance = start_server(immediate=True, server=None) From 432a5977a7b90a4a973754c66dcc8177778fd699 Mon Sep 17 00:00:00 2001 From: Vladimir Mandic Date: Thu, 8 May 2025 10:10:43 -0400 Subject: [PATCH 11/13] adetailer fix enable-disable Signed-off-by: Vladimir Mandic --- CHANGELOG.md | 1 + installer.py | 4 +++- modules/extensions.py | 5 +++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48b37e337..d30e9bac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Logging: reduce spam while progress is active - LoRA: legacy handler enable/disable - LoRA: force clear-cache on model unload + - ADetailer: fix enable/disable ## Update for 2025-05-06 diff --git a/installer.py b/installer.py index f51299d5d..98eda4e88 100644 --- a/installer.py +++ b/installer.py @@ -1027,15 +1027,17 @@ def install_extensions(force=False): from modules.paths import extensions_builtin_dir, extensions_dir extensions_duplicates = [] extensions_enabled = [] + extensions_disabled = [e.lower() for e in opts.get('disabled_extensions', [])] extension_folders = [extensions_builtin_dir] if args.safe else [extensions_builtin_dir, extensions_dir] res = [] - for folder in extension_folders: if not os.path.isdir(folder): continue extensions = list_extensions_folder(folder, quiet=True) log.debug(f'Extensions all: {extensions}') for ext in extensions: + if os.path.basename(ext).lower() in extensions_disabled: + continue t_start = time.time() if ext in extensions_enabled: extensions_duplicates.append(ext) diff --git a/modules/extensions.py b/modules/extensions.py index 78f26c655..0a96f98b4 100644 --- a/modules/extensions.py +++ b/modules/extensions.py @@ -150,8 +150,9 @@ def list_extensions(): continue extension_names.append(extension_dirname) extension_paths.append((extension_dirname, path, dirname == extensions_builtin_dir)) - disabled_extensions = shared.opts.disabled_extensions + shared.temp_disable_extensions() + disabled_extensions = [e.lower() for e in shared.opts.disabled_extensions + shared.temp_disable_extensions()] for dirname, path, is_builtin in extension_paths: - extension = Extension(name=dirname, path=path, enabled=dirname not in disabled_extensions, is_builtin=is_builtin) + enabled = dirname.lower() not in disabled_extensions + extension = Extension(name=dirname, path=path, enabled=enabled, is_builtin=is_builtin) extensions.append(extension) shared.log.debug(f'Extensions: disabled={[e.name for e in extensions if not e.enabled]}') From 55b1cb8c8b95e70f8dd617f36f3a84bca525954a Mon Sep 17 00:00:00 2001 From: Vladimir Mandic Date: Thu, 8 May 2025 10:14:15 -0400 Subject: [PATCH 12/13] lower default teacache threshold Signed-off-by: Vladimir Mandic --- modules/shared.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/shared.py b/modules/shared.py index b1faa53ba..d0993b1ee 100644 --- a/modules/shared.py +++ b/modules/shared.py @@ -606,7 +606,7 @@ options_templates.update(options_section(('advanced', "Pipeline Modifiers"), { "teacache_sep": OptionInfo("

TeaCache

", "", gr.HTML), "teacache_enabled": OptionInfo(False, "TC cache enabled"), - "teacache_thresh": OptionInfo(0.6, "TC L1 threshold", gr.Slider, {"minimum": 0.0, "maximum": 1.0, "step": 0.01}), + "teacache_thresh": OptionInfo(0.1, "TC L1 threshold", gr.Slider, {"minimum": 0.0, "maximum": 1.0, "step": 0.01}), "hypertile_sep": OptionInfo("

HyperTile

", "", gr.HTML), "hypertile_unet_enabled": OptionInfo(False, "UNet Enabled"), From 6489e4c37d078df61aadd7de50aa7e03ab73f93b Mon Sep 17 00:00:00 2001 From: Vladimir Mandic Date: Thu, 8 May 2025 15:31:07 -0400 Subject: [PATCH 13/13] prompt-enhance api support and img2img support Signed-off-by: Vladimir Mandic --- CHANGELOG.md | 13 ++- cli/api-enhance.py | 75 +++++++++++++++ modules/api/api.py | 1 + modules/api/helpers.py | 2 + modules/api/models.py | 13 +++ modules/api/process.py | 47 ++++++++- modules/interrogate/vqa.py | 3 +- ...flux_prompt_enhance.py => flux_enhance.py} | 0 scripts/prompt_enhance.py | 95 ++++++++++++++++--- 9 files changed, 233 insertions(+), 16 deletions(-) create mode 100755 cli/api-enhance.py rename scripts/{flux_prompt_enhance.py => flux_enhance.py} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index d30e9bac0..c582ba8d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,18 @@ ## Update for 2025-05-08 - **Features** - - FramePack: full API support - NNCF: Faster quantization - - API: add `/sdapi/v1/checkpoint` endpoint to get info on currently loaded model/checkpoint + - Prompt Enhancer: support for *img2img* workflows + where prompt enhancer will first analyze input image and then incorporate user prompt to create enhanced prompt +- **API** + - add `/sdapi/v1/framepack` endpoint with full support for FramePack including all optional settings + see example: `sd-extension-framepack/create-video.py` + - add `/sdapi/v1/checkpoint` endpoint to get info on currently loaded model/checkpoint + see example: `cli/api-checkpoint.py` + - add `/sdapi/v1/prompt-enhance` endpoint to enhance prompt using LLM + see example: `cli/api-enhance.py` + supports text, image and video prompts with or without input image + *note*: if input image is provided, model should be left at default `gemma-3-4b-it` as most other LLMs do not support hybrid workflows - **Fixes** - ROCm: disable cuDNN, fixes slow MIOpen tuning with `torch==2.7` - Extensions: use in-process installer for extensions-builtin, improves startup performance diff --git a/cli/api-enhance.py b/cli/api-enhance.py new file mode 100755 index 000000000..fa30d9cb1 --- /dev/null +++ b/cli/api-enhance.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python +import os +import io +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 post(endpoint: str, dct: dict = 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 encode(f): + if f is not None and os.path.exists(f): + image = Image.open(f) + if image.mode == 'RGBA': + image = image.convert('RGB') + log.info(f'encoding image: {image}') + with io.BytesIO() as stream: + image.save(stream, 'JPEG') + image.close() + values = stream.getvalue() + encoded = base64.b64encode(values).decode() + return encoded + else: + return None + + +def enhance(args): # pylint: disable=redefined-outer-name + options = { + 'prompt': str(args.prompt), + 'seed': int(args.seed), + 'type': str(args.type), + } + if args.model: + options['model'] = str(args.model) + if args.image: + options['image'] = encode(args.image) + response = post('/sdapi/v1/prompt-enhance', options) + return response + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description = 'api-enhance') + parser.add_argument('--prompt', type=str, default='', required=False, help='prompt') + parser.add_argument('--seed', type=int, default=-1, required=False, help='seed') + parser.add_argument('--type', type=str, default='text', choices=['text', 'image', 'video'], required=False, help='enhance type') + parser.add_argument('--model', type=str, default=None, required=False, help='model name') + parser.add_argument('--image', type=str, default=None, required=False, help='optional input image') + args = parser.parse_args() + log.info(f'api-upscale: {args}') + result = enhance(args) + log.info(result) diff --git a/modules/api/api.py b/modules/api/api.py index b653250f5..72a2090a0 100644 --- a/modules/api/api.py +++ b/modules/api/api.py @@ -65,6 +65,7 @@ class Api: self.add_api_route("/sdapi/v1/preprocess", self.process.post_preprocess, methods=["POST"]) self.add_api_route("/sdapi/v1/mask", self.process.post_mask, methods=["POST"]) self.add_api_route("/sdapi/v1/detect", self.process.post_detect, methods=["POST"]) + self.add_api_route("/sdapi/v1/prompt-enhance", self.process.post_prompt_enhance, methods=["POST"], response_model=models.ResPromptEnhance) # api dealing with optional scripts self.add_api_route("/sdapi/v1/scripts", script.get_scripts_list, methods=["GET"], response_model=models.ResScripts) diff --git a/modules/api/helpers.py b/modules/api/helpers.py index 21e89c5c1..15b7e29aa 100644 --- a/modules/api/helpers.py +++ b/modules/api/helpers.py @@ -15,6 +15,8 @@ def validate_sampler_name(name): def decode_base64_to_image(encoding, quiet=False): + if encoding is None: + return None if encoding.startswith("data:image/"): encoding = encoding.split(";")[1].split(",")[1] try: diff --git a/modules/api/models.py b/modules/api/models.py index fee8d781d..f60ba3dd7 100644 --- a/modules/api/models.py +++ b/modules/api/models.py @@ -266,6 +266,19 @@ class ReqProcess(BaseModel): class ResProcess(BaseModel): html_info: str = Field(title="HTML info", description="A series of HTML tags containing the process info.") + +class ReqPromptEnhance(BaseModel): + prompt: str = Field(title="Prompt", description="Prompt to enhance") + type: str = Field(title="Type", default='text', description="Type of enhancement to perform") + model: Optional[str] = Field(title="Model", default=None, description="Model to use for enhancement") + system_prompt: Optional[str] = Field(title="System prompt", default=None, description="Model system prompt") + image: Optional[str] = Field(title="Image", default=None, description="Image to work on, must be a Base64 string containing the image's data.") + seed: int = Field(title="Seed", default=-1, description="Seed used to generate the prompt") + +class ResPromptEnhance(BaseModel): + prompt: str = Field(title="Prompt", description="Enhanced prompt") + seed: int = Field(title="Seed", description="Seed used to generate the prompt") + class ReqProcessImage(ReqProcess): image: str = Field(default="", title="Image", description="Image to work on, must be a Base64 string containing the image's data.") diff --git a/modules/api/process.py b/modules/api/process.py index 80b19c52e..1d98df03e 100644 --- a/modules/api/process.py +++ b/modules/api/process.py @@ -2,8 +2,10 @@ from typing import Optional, List from threading import Lock from pydantic import BaseModel, Field # pylint: disable=no-name-in-module from fastapi.responses import JSONResponse +from fastapi.exceptions import HTTPException from modules.api.helpers import decode_base64_to_image, encode_pil_to_base64 from modules import errors, shared +from modules.api import models processor = None # cached instance of processor @@ -65,8 +67,8 @@ class APIProcess(): def post_preprocess(self, req: ReqPreprocess): global processor # pylint: disable=global-statement from modules.control import processors - models = list(processors.config) - if req.model not in models: + processors_list = list(processors.config) + if req.model not in processors_list: return JSONResponse(status_code=400, content={"error": f"Processor model not found: id={req.model}"}) image = decode_base64_to_image(req.image) if processor is None or processor.processor_id != req.model: @@ -129,3 +131,44 @@ class APIProcess(): boxes.append(item.box) shared.state.end(api=False) return ResFace(classes=classes, labels=labels, scores=scores, boxes=boxes, images=images) + + def post_prompt_enhance(self, req: models.ReqPromptEnhance): + from modules import processing_helpers + seed = req.seed or -1 + seed = processing_helpers.get_fixed_seed(seed) + prompt = '' + if req.type == 'text': + from modules.scripts import scripts_txt2img + model = 'google/gemma-3-1b-it' if req.model is None or len(req.model) < 4 else req.model + instance = [s for s in scripts_txt2img.scripts if 'prompt_enhance.py' in s.filename][0] + prompt = instance.enhance( + model=model, + prompt=req.prompt, + system=req.system_prompt, + seed=seed, + ) + elif req.type == 'image': + from modules.scripts import scripts_txt2img + model = 'google/gemma-3-4b-it' if req.model is None or len(req.model) < 4 else req.model + instance = [s for s in scripts_txt2img.scripts if 'prompt_enhance.py' in s.filename][0] + prompt = instance.enhance( + model=model, + prompt=req.prompt, + system=req.system_prompt, + image=decode_base64_to_image(req.image), + seed=seed, + ) + elif req.type == 'video': + from modules.ui_video_vlm import enhance_prompt + model = 'Google Gemma 3 4B' if req.model is None or len(req.model) < 4 else req.model + prompt = enhance_prompt( + enable=True, + image=decode_base64_to_image(req.image), + prompt=req.prompt, + model=model, + system_prompt=req.system_prompt, + ) + else: + raise HTTPException(status_code=400, detail="prompt enhancement: invalid type") + res = models.ResPromptEnhance(prompt=prompt, seed=seed) + return res diff --git a/modules/interrogate/vqa.py b/modules/interrogate/vqa.py index 461ec6c73..c4dcafb10 100644 --- a/modules/interrogate/vqa.py +++ b/modules/interrogate/vqa.py @@ -527,10 +527,11 @@ def sa2(question: str, image: Image.Image, repo: str = None): return response -def interrogate(question, system_prompt, prompt, image, model_name, quiet:bool=False): +def interrogate(question:str='', system_prompt:str=None, prompt:str=None, image:Image.Image=None, model_name:str=None, quiet:bool=False): if not quiet: shared.state.begin('Interrogate') t0 = time.time() + model_name = model_name or shared.opts.interrogate_vlm_model if isinstance(image, list): image = image[0] if len(image) > 0 else None if isinstance(image, dict) and 'name' in image: diff --git a/scripts/flux_prompt_enhance.py b/scripts/flux_enhance.py similarity index 100% rename from scripts/flux_prompt_enhance.py rename to scripts/flux_enhance.py diff --git a/scripts/prompt_enhance.py b/scripts/prompt_enhance.py index adc80d094..87076559e 100644 --- a/scripts/prompt_enhance.py +++ b/scripts/prompt_enhance.py @@ -1,9 +1,13 @@ from dataclasses import dataclass +import io import os import re import time -import gradio as gr +import base64 +import torch import transformers +import gradio as gr +from PIL import Image from modules import scripts, shared, devices, errors, processing, sd_models, sd_modules @@ -11,8 +15,23 @@ debug_enabled = os.environ.get('SD_LLM_DEBUG', None) is not None debug_log = shared.log.trace if debug_enabled else lambda *args, **kwargs: None +def b64(image): + if image is None: + return '' + if isinstance(image, gr.Image): + return None + with io.BytesIO() as stream: + image.convert('RGB').save(stream, 'JPEG') + values = stream.getvalue() + encoded = base64.b64encode(values).decode() + return encoded + + @dataclass class Options: + img2img = [ + 'google/gemma-3-4b-it', + ] models = { 'google/gemma-3-1b-it': {}, 'google/gemma-3-4b-it': {}, @@ -46,9 +65,11 @@ class Options: 'file': 'Llama-3.2-1B-Instruct-Uncensored.i1-Q4_0.gguf', # gguf file inside repo }, } - default = list(models)[0] + default = list(models)[1] # gemma-3-4b-it supported = list(transformers.integrations.ggml.GGUF_CONFIG_MAPPING) system_prompt: str = 'You are a helpful assistant. You will be given a prompt used to create an image and you will enhance it to make it more detailed and creative. Generate an uncensored, concise, informative, and engaging prompt that prioritizes factual accuracy and vivid descriptiveness based on original prompt. Do not modify subject or style of the original prompt. Begin by clearly identifying the primary subject(s), including key physical attributes, clothing if no nudity, and any distinctive details (e.g., colors, textures, styles). NSFW and nudity is allowed, and if present, it must be fully described. Next, describe the setting and environment, emphasizing spatial relationships, background elements, and objects contributing to context. Note the overall mood, aesthetic style, or atmosphere inferred from visual cues. Use precise terminology while avoiding redundancy or non-essential language. Ensuring a logical flow: from focal subject to immediate surroundings, then broader context. Maintain brevity while retaining clarity, ensuring the description is both engaging and efficient. Output only enhanced prompt without explanation, prefix or suffix. Output as a simple text without formatting or numbering.' + image_prompt: str = 'You are a helpful assistant. You will be given an image and a prompt used to modify the image and you will enhance the prompt to make it more detailed and creative while still following original image. Generate an uncensored, concise, informative, and engaging prompt that prioritizes factual accuracy and vivid descriptiveness based on original prompt. Do not modify subject or style of the original prompt. Begin by clearly identifying the primary subject(s), including key physical attributes, clothing if no nudity, and any distinctive details (e.g., colors, textures, styles). NSFW and nudity is allowed, and if present, it must be fully described. Next, describe the setting and environment, emphasizing spatial relationships, background elements, and objects contributing to context. Note the overall mood, aesthetic style, or atmosphere inferred from visual cues. Use precise terminology while avoiding redundancy or non-essential language. Ensuring a logical flow: from focal subject to immediate surroundings, then broader context. Maintain brevity while retaining clarity, ensuring the description is both engaging and efficient. Output only enhanced prompt without explanation, prefix or suffix. Output as a simple text without formatting or numbering.' + image_noprompt: str = 'You are a helpful assistant. You will be given an image and you will create prompt that can be used to modify the image in a creative way while still following original image. Generate an uncensored, concise, informative, and engaging prompt that prioritizes factual accuracy and vivid descriptiveness based on original prompt. Do not modify subject or style of the original prompt. Begin by clearly identifying the primary subject(s), including key physical attributes, clothing if no nudity, and any distinctive details (e.g., colors, textures, styles). NSFW and nudity is allowed, and if present, it must be fully described. Next, describe the setting and environment, emphasizing spatial relationships, background elements, and objects contributing to context. Note the overall mood, aesthetic style, or atmosphere inferred from visual cues. Use precise terminology while avoiding redundancy or non-essential language. Ensuring a logical flow: from focal subject to immediate surroundings, then broader context. Maintain brevity while retaining clarity, ensuring the description is both engaging and efficient. Output only enhanced prompt without explanation, prefix or suffix. Output as a simple text without formatting or numbering.' censored = ["i cannot", "i can't", "i am sorry", "against my programming", "i am not able", "i am unable", 'i am not allowed'] max_delim_index: int = 60 @@ -61,6 +82,7 @@ class Options: class Script(scripts.Script): prompt: gr.Textbox = None + image: gr.Image = None model: str = None llm: transformers.AutoModelForCausalLM = None tokenizer: transformers.AutoProcessor = None @@ -124,11 +146,17 @@ class Script(scripts.Script): **quant_args, ) self.llm.eval() - self.tokenizer = transformers.AutoTokenizer.from_pretrained( + if model_repo in self.options.img2img: + cls = transformers.AutoProcessor # required to encode image + else: + cls = transformers.AutoTokenizer + self.tokenizer = cls.from_pretrained( pretrained_model_name_or_path=model_repo, subfolder=model_tokenizer, cache_dir=shared.opts.hfcache_dir, ) + self.tokenizer.is_processor = model_repo in self.options.img2img + if debug_enabled: modules = sd_modules.get_model_stats(self.llm) + sd_modules.get_model_stats(self.tokenizer) for m in modules: @@ -202,12 +230,12 @@ class Script(scripts.Script): filtered = re.sub(pattern, '', prompt) return filtered, matches - def enhance(self, model: str=None, prompt:str=None, system:str=None, prefix:str=None, suffix:str=None, sample:bool=None, tokens:int=None, temperature:float=None, penalty:float=None, thinking:bool=False): + def enhance(self, model: str=None, prompt:str=None, system:str=None, prefix:str=None, suffix:str=None, sample:bool=None, tokens:int=None, temperature:float=None, penalty:float=None, thinking:bool=False, seed:int=-1, image=None): model = model or self.options.default prompt = prompt or self.prompt.value + image = image or self.image prefix = prefix or '' suffix = suffix or '' - system = system or self.options.system_prompt tokens = tokens or self.options.max_tokens penalty = penalty or self.options.repetition_penalty temperature = temperature or self.options.temperature @@ -216,15 +244,55 @@ class Script(scripts.Script): while self.busy: time.sleep(0.1) self.load(model) + if seed is not None and seed >= 0: + torch.manual_seed(seed) if self.llm is None: shared.log.error('Prompt enhance: model not loaded') return prompt prompt, networks = self.extract(prompt) debug_log(f'Prompt enhance: networks={networks}') - chat_template = [ - { "role": "system", "content": system }, - { "role": "user", "content": prompt }, - ] + if image is not None and isinstance(image, Image.Image): + if not self.tokenizer.is_processor: + shared.log.error('Prompt enhance: image not supported by model') + return prompt + if prompt is not None and len(prompt) > 0: + system = system or self.options.image_prompt + chat_template = [ + { "role": "system", "content": [ + {"type": "text", "text": system } + ] }, + { "role": "user", "content": [ + {"type": "text", "text": prompt}, + {"type": "image", "image": b64(image)} + ] }, + ] + else: + system = system or self.options.image_noprompt + chat_template = [ + { "role": "system", "content": [ + {"type": "text", "text": system } + ] }, + { "role": "user", "content": [ + {"type": "image", "image": b64(image)} + ] }, + ] + else: + system = system or self.options.system_prompt + if not self.tokenizer.is_processor: + chat_template = [ + { "role": "system", "content": system }, + { "role": "user", "content": prompt }, + ] + else: + chat_template = [ + { "role": "system", "content": [ + {"type": "text", "text": system } + ] }, + { "role": "user", "content": [ + {"type": "text", "text": prompt}, + ] }, + ] + t0 = time.time() self.busy = True try: @@ -288,9 +356,10 @@ class Script(scripts.Script): return prompt return response - def apply(self, prompt, apply_prompt, llm_model, prompt_system, prompt_prefix, prompt_suffix, max_tokens, do_sample, temperature, repetition_penalty, thinking_mode): + def apply(self, prompt, image, apply_prompt, llm_model, prompt_system, prompt_prefix, prompt_suffix, max_tokens, do_sample, temperature, repetition_penalty, thinking_mode): response = self.enhance( prompt=prompt, + image=image, prefix=prompt_prefix, suffix=prompt_suffix, model=llm_model, @@ -367,12 +436,16 @@ class Script(scripts.Script): clear_btn.click(fn=lambda: '', inputs=[], outputs=[prompt_output]) copy_btn = gr.Button(value='Set prompt', elem_id='prompt_enhance_copy', variant='secondary') copy_btn.click(fn=lambda x: x, inputs=[prompt_output], outputs=[self.prompt]) - apply_btn.click(fn=self.apply, inputs=[self.prompt, apply_prompt, llm_model, prompt_system, prompt_prefix, prompt_suffix, max_tokens, do_sample, temperature, repetition_penalty, thinking_mode], outputs=[prompt_output, self.prompt]) + if self.image is None: + self.image = gr.Image(type='pil', interactive=False, visible=False) # dummy image + apply_btn.click(fn=self.apply, inputs=[self.prompt, self.image, apply_prompt, llm_model, prompt_system, prompt_prefix, prompt_suffix, max_tokens, do_sample, temperature, repetition_penalty, thinking_mode], outputs=[prompt_output, self.prompt]) return [apply_auto, llm_model, prompt_system, prompt_prefix, prompt_suffix, max_tokens, do_sample, temperature, repetition_penalty, thinking_mode] def after_component(self, component, **kwargs): # searching for actual ui prompt components if getattr(component, 'elem_id', '') in ['txt2img_prompt', 'img2img_prompt', 'control_prompt', 'video_prompt']: self.prompt = component + if getattr(component, 'elem_id', '') in ['img2img_image', 'control_input_select']: + self.image = component def before_process(self, p: processing.StableDiffusionProcessing, *args, **kwargs): # pylint: disable=unused-argument apply_auto, llm_model, prompt_system, prompt_prefix, prompt_suffix, max_tokens, do_sample, temperature, repetition_penalty, thinking_mode = args