mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-07-23 00:08:08 +08:00
Compare commits
88
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1339cb570d | ||
|
|
4eef53041e | ||
|
|
7259e664ef | ||
|
|
ae539cfa0a | ||
|
|
b293f8cefd | ||
|
|
2ca1480f91 | ||
|
|
6ecf5eca7a | ||
|
|
03e511862e | ||
|
|
aab41a9ddb | ||
|
|
4259a0c7c3 | ||
|
|
af3d9b60af | ||
|
|
7b7c5fed7c | ||
|
|
1668aaf037 | ||
|
|
ea174d3f12 | ||
|
|
9f9b32ed97 | ||
|
|
95fdc6cf91 | ||
|
|
5aa5ccc9e0 | ||
|
|
4d6a058bf1 | ||
|
|
a8d2519058 | ||
|
|
4efe1ddb5c | ||
|
|
f9c84c94b4 | ||
|
|
78b5dec6b6 | ||
|
|
72e3f6081c | ||
|
|
7ec7b6ffe9 | ||
|
|
6887165a9d | ||
|
|
cc4d711eb1 | ||
|
|
626b082838 | ||
|
|
d0328b442d | ||
|
|
6b61918a16 | ||
|
|
a4382e056e | ||
|
|
d71cc1c8f2 | ||
|
|
990a7ae7f2 | ||
|
|
df2454b47e | ||
|
|
292814c31e | ||
|
|
187e5237e1 | ||
|
|
164a9d4bbb | ||
|
|
16f862f02a | ||
|
|
d4c6c9eff8 | ||
|
|
264b003286 | ||
|
|
971c9e3518 | ||
|
|
b39af210d0 | ||
|
|
aeadb7acaa | ||
|
|
f48d2a017e | ||
|
|
7c4d95d1bc | ||
|
|
d3607a8e6d | ||
|
|
5d5a4554e1 | ||
|
|
8f82b16993 | ||
|
|
33ce449c8b | ||
|
|
04856acc69 | ||
|
|
72fe66a18b | ||
|
|
07ff14ae02 | ||
|
|
77e2ed5e01 | ||
|
|
b2000029c8 | ||
|
|
b112f68681 | ||
|
|
ed78da062c | ||
|
|
616cab4f97 | ||
|
|
4f6018982d | ||
|
|
7a063e83a7 | ||
|
|
3f9bdc70ee | ||
|
|
3d870ff51f | ||
|
|
ba1c039a04 | ||
|
|
1f28908d6e | ||
|
|
fb51a988b6 | ||
|
|
6220400ad5 | ||
|
|
af55a2308f | ||
|
|
3a649984f2 | ||
|
|
26515acd23 | ||
|
|
74c17a25e5 | ||
|
|
afb4fa15d5 | ||
|
|
b94941d8d3 | ||
|
|
8505abf52e | ||
|
|
a5189fed51 | ||
|
|
240363f11e | ||
|
|
2bd65f2091 | ||
|
|
cccb697aa3 | ||
|
|
300b6c8c91 | ||
|
|
1d95ed211e | ||
|
|
a5f7bc5658 | ||
|
|
fb097bedc2 | ||
|
|
c9589f29b2 | ||
|
|
0155ddcbe3 | ||
|
|
8e53f001a4 | ||
|
|
0a7d2ffd68 | ||
|
|
20e439419c | ||
|
|
428c323780 | ||
|
|
46063aa927 | ||
|
|
b565dc7a6c | ||
|
|
a145651cc0 |
@@ -89,3 +89,12 @@ rules:
|
||||
then:
|
||||
field: description
|
||||
function: truthy
|
||||
|
||||
overrides:
|
||||
# /ws uses HTTP 101 (Switching Protocols) — a legitimate response for a
|
||||
# WebSocket upgrade, but not a 2xx, so operation-success-response fires
|
||||
# as a false positive. OpenAPI 3.x has no native WebSocket support.
|
||||
- files:
|
||||
- "openapi.yaml#/paths/~1ws"
|
||||
rules:
|
||||
operation-success-response: off
|
||||
|
||||
+4
-1
@@ -1,2 +1,5 @@
|
||||
# Admins
|
||||
* @comfyanonymous @kosinkadink @guill @alexisrolland @rattus128 @kijai
|
||||
|
||||
/CODEOWNERS @comfyanonymous
|
||||
/.ci/ @comfyanonymous
|
||||
/.github/ @comfyanonymous
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
ComfyUI is the AI creation engine for visual professionals who demand control over every model, every parameter, and every output. Its powerful and modular node graph interface empowers creatives to generate images, videos, 3D models, audio, and more...
|
||||
- ComfyUI natively supports the latest open-source state of the art models.
|
||||
- API nodes provide access to the best closed source models such as Nano Banana, Seedance, Hunyuan3D, etc.
|
||||
- It is available on Windows, Linux, and macOS, locally with our desktop application or on our cloud.
|
||||
- It is available on Windows, Linux, and macOS, locally with our [desktop application](https://www.comfy.org/download), our [portable install](#installing) or on our [cloud](https://www.comfy.org/cloud).
|
||||
- The most sophisticated workflows can be exposed through a simple UI thanks to App Mode.
|
||||
- It integrates seamlessly into production pipelines with our API endpoints.
|
||||
|
||||
@@ -429,6 +429,8 @@ Use `--tls-keyfile key.pem --tls-certfile cert.pem` to enable TLS/SSL, the app w
|
||||
|
||||
See also: [https://www.comfy.org/](https://www.comfy.org/)
|
||||
|
||||
> _psst — we're hiring!_ Help build ComfyUI: [comfy.org/careers](https://www.comfy.org/careers)
|
||||
|
||||
## Frontend Development
|
||||
|
||||
As of August 15, 2024, we have transitioned to a new frontend, which is now hosted in a separate repository: [ComfyUI Frontend](https://github.com/Comfy-Org/ComfyUI_frontend). This repository now hosts the compiled JS (from TS/Vue) under the `web/` directory.
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
# Security Policy
|
||||
|
||||
## Scope
|
||||
|
||||
ComfyUI is designed to run locally. By default, the server binds to `127.0.0.1`, meaning only the user's own machine can reach it. Our threat model assumes:
|
||||
|
||||
- The user installed ComfyUI through a supported channel: the desktop application, the portable build, or a manual install following the README.
|
||||
- The user has not installed untrusted custom nodes. Custom nodes are arbitrary Python code and are trusted as much as any other software the user chooses to install.
|
||||
- Anyone with access to the ComfyUI URL is trusted (a direct consequence of the localhost-only default).
|
||||
- PyTorch and other dependencies are at the versions we ship or recommend in the README.
|
||||
|
||||
A report is in scope only if it affects a user operating within this threat model.
|
||||
|
||||
## What We Consider a Vulnerability
|
||||
|
||||
We want to hear about issues where a **reasonable user** — someone who does not install random untrusted nodes and who reads UI prompts and warnings before clicking through them — can be harmed by ComfyUI itself.
|
||||
|
||||
The clearest example: a workflow file that such a user might plausibly load and run, using only built-in nodes, that results in **untrusted code execution, arbitrary file read/write outside expected directories, or credential/data exfiltration**.
|
||||
|
||||
When submitting a report, please include a clear description of *why this is a problem for a typical local ComfyUI user*. Reports without this context are difficult to act on.
|
||||
|
||||
## What We Do Not Consider a Security Vulnerability
|
||||
|
||||
Please report the following through our regular [GitHub issues](https://github.com/comfyanonymous/ComfyUI/issues) instead. Filing them as security reports will likely cause them to be deprioritized or closed.
|
||||
|
||||
- **Issues requiring `--listen` or any non-default network exposure.** ComfyUI binds to localhost by default. If a remote attacker needs to reach the server for the attack to work, the user has chosen to expose it and is responsible for securing that deployment (firewall, reverse proxy, authentication, etc.). These are bugs, not vulnerabilities.
|
||||
- **`torch.load` and related deserialization issues in old PyTorch versions.** These are upstream PyTorch issues. Our distributions ship with — and our documentation recommends — recent PyTorch versions where these are addressed.
|
||||
- **Vulnerabilities that depend on outdated library versions** that we neither ship nor recommend (e.g., requiring PyTorch 2.6 or older).
|
||||
- **Issues that require a specific custom node to be installed.** Custom nodes are third-party code. Report these to the maintainer of that node.
|
||||
- **Crashes, hangs, or resource exhaustion from a loaded workflow.** Annoying, but not a security issue in our model. File a regular bug.
|
||||
- **Social-engineering scenarios** where the user is expected to ignore an explicit UI warning or prompt.
|
||||
|
||||
## Reporting
|
||||
|
||||
If you believe you have found an issue that falls within the scope above, please report it privately via GitHub's [Report a vulnerability](https://github.com/comfyanonymous/ComfyUI/security/advisories/new) feature rather than opening a public issue.
|
||||
|
||||
Please include:
|
||||
|
||||
1. A description of the vulnerability and the affected component.
|
||||
2. Reproduction steps, ideally with a minimal workflow file or proof-of-concept.
|
||||
3. The ComfyUI version, install method (desktop / portable / manual), and OS.
|
||||
4. An explanation of how this affects a typical local user as described in the threat model.
|
||||
|
||||
We will acknowledge valid reports and coordinate a fix and disclosure timeline with you.
|
||||
+40
-21
@@ -38,40 +38,54 @@ def is_valid_version(version: str) -> bool:
|
||||
pattern = r"^(\d+)\.(\d+)\.(\d+)$"
|
||||
return bool(re.match(pattern, version))
|
||||
|
||||
def get_installed_frontend_version():
|
||||
"""Get the currently installed frontend package version."""
|
||||
frontend_version_str = version("comfyui-frontend-package")
|
||||
return frontend_version_str
|
||||
|
||||
|
||||
def get_required_frontend_version():
|
||||
return get_required_packages_versions().get("comfyui-frontend-package", None)
|
||||
|
||||
|
||||
def check_frontend_version():
|
||||
"""Check if the frontend version is up to date."""
|
||||
COMFY_PACKAGE_VERSIONS = []
|
||||
def get_comfy_package_versions():
|
||||
"""List installed/required versions for every comfy* package in requirements.txt."""
|
||||
if COMFY_PACKAGE_VERSIONS:
|
||||
return COMFY_PACKAGE_VERSIONS.copy()
|
||||
out = COMFY_PACKAGE_VERSIONS
|
||||
for name, required in (get_required_packages_versions() or {}).items():
|
||||
if not name.startswith("comfy"):
|
||||
continue
|
||||
try:
|
||||
installed = version(name)
|
||||
except Exception:
|
||||
installed = None
|
||||
out.append({"name": name, "installed": installed, "required": required})
|
||||
return out.copy()
|
||||
|
||||
try:
|
||||
frontend_version_str = get_installed_frontend_version()
|
||||
frontend_version = parse_version(frontend_version_str)
|
||||
required_frontend_str = get_required_frontend_version()
|
||||
required_frontend = parse_version(required_frontend_str)
|
||||
if frontend_version < required_frontend:
|
||||
|
||||
def check_comfy_packages_versions():
|
||||
"""Warn for every comfy* package whose installed version is below requirements.txt."""
|
||||
from packaging.version import InvalidVersion, parse as parse_pep440
|
||||
for pkg in get_comfy_package_versions():
|
||||
installed_str = pkg["installed"]
|
||||
required_str = pkg["required"]
|
||||
if not installed_str or not required_str:
|
||||
continue
|
||||
try:
|
||||
outdated = parse_pep440(installed_str) < parse_pep440(required_str)
|
||||
except InvalidVersion as e:
|
||||
logging.error(f"Failed to check {pkg['name']} version: {e}")
|
||||
continue
|
||||
if outdated:
|
||||
app.logger.log_startup_warning(
|
||||
f"""
|
||||
________________________________________________________________________
|
||||
WARNING WARNING WARNING WARNING WARNING
|
||||
|
||||
Installed frontend version {".".join(map(str, frontend_version))} is lower than the recommended version {".".join(map(str, required_frontend))}.
|
||||
Installed {pkg["name"]} version {installed_str} is lower than the recommended version {required_str}.
|
||||
|
||||
{frontend_install_warning_message()}
|
||||
{get_missing_requirements_message()}
|
||||
________________________________________________________________________
|
||||
""".strip()
|
||||
)
|
||||
else:
|
||||
logging.info("ComfyUI frontend version: {}".format(frontend_version_str))
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to check frontend version: {e}")
|
||||
logging.info("{} version: {}".format(pkg["name"], installed_str))
|
||||
|
||||
|
||||
REQUEST_TIMEOUT = 10 # seconds
|
||||
@@ -201,6 +215,11 @@ class FrontendManager:
|
||||
def get_required_templates_version(cls) -> str:
|
||||
return get_required_packages_versions().get("comfyui-workflow-templates", None)
|
||||
|
||||
@classmethod
|
||||
def get_comfy_package_versions(cls):
|
||||
"""List installed/required versions for every comfy* package in requirements.txt."""
|
||||
return get_comfy_package_versions()
|
||||
|
||||
@classmethod
|
||||
def default_frontend_path(cls) -> str:
|
||||
try:
|
||||
@@ -341,7 +360,7 @@ comfyui-workflow-templates is not installed.
|
||||
main error source might be request timeout or invalid URL.
|
||||
"""
|
||||
if version_string == DEFAULT_VERSION_STRING:
|
||||
check_frontend_version()
|
||||
check_comfy_packages_versions()
|
||||
return cls.default_frontend_path()
|
||||
|
||||
repo_owner, repo_name, version = cls.parse_version_string(version_string)
|
||||
@@ -403,7 +422,7 @@ comfyui-workflow-templates is not installed.
|
||||
except Exception as e:
|
||||
logging.error("Failed to initialize frontend: %s", e)
|
||||
logging.info("Falling back to the default frontend.")
|
||||
check_frontend_version()
|
||||
check_comfy_packages_versions()
|
||||
return cls.default_frontend_path()
|
||||
@classmethod
|
||||
def template_asset_handler(cls):
|
||||
|
||||
@@ -44,7 +44,14 @@ class BackgroundRemovalModel():
|
||||
comfy.model_management.load_model_gpu(self.patcher)
|
||||
H, W = image.shape[1], image.shape[2]
|
||||
pixel_values = comfy.clip_model.clip_preprocess(image.to(self.load_device), size=self.image_size, mean=self.image_mean, std=self.image_std, crop=False)
|
||||
out = self.model(pixel_values=pixel_values)
|
||||
|
||||
if pixel_values.shape[0] > 1:
|
||||
out = torch.cat([
|
||||
self.model(pixel_values=pixel_values[i:i+1])
|
||||
for i in range(pixel_values.shape[0])
|
||||
], dim=0)
|
||||
else:
|
||||
out = self.model(pixel_values=pixel_values)
|
||||
out = torch.nn.functional.interpolate(out, size=(H, W), mode="bicubic", antialias=False)
|
||||
|
||||
mask = out.sigmoid().to(device=comfy.model_management.intermediate_device(), dtype=comfy.model_management.intermediate_dtype())
|
||||
|
||||
+5
-5
@@ -110,13 +110,11 @@ parser.add_argument("--preview-method", type=LatentPreviewMethod, default=Latent
|
||||
|
||||
parser.add_argument("--preview-size", type=int, default=512, help="Sets the maximum preview size for sampler nodes.")
|
||||
|
||||
CACHE_RAM_AUTO_GB = -1.0
|
||||
|
||||
cache_group = parser.add_mutually_exclusive_group()
|
||||
cache_group.add_argument("--cache-ram", nargs='*', type=float, default=[], metavar="GB", help="Use RAM pressure caching with the specified headroom thresholds. This is the default caching mode. The first value sets the active-cache threshold; the optional second value sets the inactive-cache/pin threshold. Defaults when no values are provided: active 25%% of system RAM (min 4GB, max 32GB), inactive 75%% of system RAM (min 12GB, max 96GB).")
|
||||
cache_group.add_argument("--cache-classic", action="store_true", help="Use the old style (aggressive) caching.")
|
||||
cache_group.add_argument("--cache-lru", type=int, default=0, help="Use LRU caching with a maximum of N node results cached. May use more RAM/VRAM.")
|
||||
cache_group.add_argument("--cache-none", action="store_true", help="Reduced RAM/VRAM usage at the expense of executing every node for each run.")
|
||||
cache_group.add_argument("--cache-ram", nargs='?', const=CACHE_RAM_AUTO_GB, type=float, default=0, help="Use RAM pressure caching with the specified headroom threshold. If available RAM drops below the threshold the cache removes large items to free RAM. Default (when no value is provided): 25%% of system RAM (min 4GB, max 32GB).")
|
||||
|
||||
attn_group = parser.add_mutually_exclusive_group()
|
||||
attn_group.add_argument("--use-split-cross-attention", action="store_true", help="Use the split cross attention optimization. Ignored when xformers is used.")
|
||||
@@ -141,8 +139,7 @@ manager_group.add_argument("--enable-manager-legacy-ui", action="store_true", he
|
||||
vram_group = parser.add_mutually_exclusive_group()
|
||||
vram_group.add_argument("--gpu-only", action="store_true", help="Store and run everything (text encoders/CLIP models, etc... on the GPU).")
|
||||
vram_group.add_argument("--highvram", action="store_true", help="By default models will be unloaded to CPU memory after being used. This option keeps them in GPU memory.")
|
||||
vram_group.add_argument("--normalvram", action="store_true", help="Used to force normal vram use if lowvram gets automatically enabled.")
|
||||
vram_group.add_argument("--lowvram", action="store_true", help="Split the unet in parts to use less vram.")
|
||||
vram_group.add_argument("--lowvram", action="store_true", help="Doesn't do anything if dynamic vram is enabled. If dynamic vram isn't being used this option makes the text encoders run on the CPU.")
|
||||
vram_group.add_argument("--novram", action="store_true", help="When lowvram isn't enough.")
|
||||
vram_group.add_argument("--cpu", action="store_true", help="To use the CPU for everything (slow).")
|
||||
|
||||
@@ -246,6 +243,9 @@ if comfy.options.args_parsing:
|
||||
else:
|
||||
args = parser.parse_args([])
|
||||
|
||||
if args.cache_ram is not None and len(args.cache_ram) > 2:
|
||||
parser.error("--cache-ram accepts at most two values: active GB and inactive GB")
|
||||
|
||||
if args.windows_standalone_build:
|
||||
args.auto_launch = True
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ class Dino2Encoder(torch.nn.Module):
|
||||
class Dino2PatchEmbeddings(torch.nn.Module):
|
||||
def __init__(self, dim, num_channels=3, patch_size=14, image_size=518, dtype=None, device=None, operations=None):
|
||||
super().__init__()
|
||||
self.patch_size = patch_size
|
||||
self.projection = operations.Conv2d(
|
||||
in_channels=num_channels,
|
||||
out_channels=dim,
|
||||
@@ -125,17 +126,37 @@ class Dino2Embeddings(torch.nn.Module):
|
||||
super().__init__()
|
||||
patch_size = 14
|
||||
image_size = 518
|
||||
self.patch_size = patch_size
|
||||
|
||||
self.patch_embeddings = Dino2PatchEmbeddings(dim, patch_size=patch_size, image_size=image_size, dtype=dtype, device=device, operations=operations)
|
||||
self.position_embeddings = torch.nn.Parameter(torch.empty(1, (image_size // patch_size) ** 2 + 1, dim, dtype=dtype, device=device))
|
||||
self.cls_token = torch.nn.Parameter(torch.empty(1, 1, dim, dtype=dtype, device=device))
|
||||
self.cls_token = torch.nn.Parameter(torch.empty(1, 1, dim, dtype=dtype, device=device)) # mask_token is a pre-training param, kept only so strict loading accepts the key.
|
||||
self.mask_token = torch.nn.Parameter(torch.empty(1, dim, dtype=dtype, device=device))
|
||||
|
||||
def interpolate_pos_encoding(self, x, h_pixels, w_pixels):
|
||||
pos_embed = comfy.model_management.cast_to_device(self.position_embeddings, x.device, torch.float32)
|
||||
|
||||
class_pos = pos_embed[:, 0:1]
|
||||
patch_pos = pos_embed[:, 1:]
|
||||
N = patch_pos.shape[1]
|
||||
M = int(N ** 0.5)
|
||||
h0 = h_pixels // self.patch_size
|
||||
w0 = w_pixels // self.patch_size
|
||||
scale_factor = ((h0 + 0.1) / M, (w0 + 0.1) / M) # +0.1 matches upstream DINOv2's FP-rounding workaround so the interpolate output size lands on (h0, w0).
|
||||
|
||||
patch_pos = patch_pos.reshape(1, M, M, -1).permute(0, 3, 1, 2)
|
||||
patch_pos = torch.nn.functional.interpolate(patch_pos, scale_factor=scale_factor, mode="bicubic", antialias=False)
|
||||
patch_pos = patch_pos.permute(0, 2, 3, 1).flatten(1, 2)
|
||||
return torch.cat((class_pos, patch_pos), dim=1).to(x.dtype)
|
||||
|
||||
def forward(self, pixel_values):
|
||||
x = self.patch_embeddings(pixel_values)
|
||||
# TODO: mask_token?
|
||||
x = torch.cat((self.cls_token.to(device=x.device, dtype=x.dtype).expand(x.shape[0], -1, -1), x), dim=1)
|
||||
x = x + comfy.model_management.cast_to_device(self.position_embeddings, x.device, x.dtype)
|
||||
if x.shape[1] - 1 == self.position_embeddings.shape[1] - 1:
|
||||
x = x + comfy.model_management.cast_to_device(self.position_embeddings, x.device, x.dtype)
|
||||
else:
|
||||
h, w = pixel_values.shape[-2:]
|
||||
x = x + self.interpolate_pos_encoding(x, h, w)
|
||||
return x
|
||||
|
||||
|
||||
@@ -158,3 +179,21 @@ class Dinov2Model(torch.nn.Module):
|
||||
x = self.layernorm(x)
|
||||
pooled_output = x[:, 0, :]
|
||||
return x, i, pooled_output, None
|
||||
|
||||
def get_intermediate_layers(self, pixel_values, indices, apply_norm=True):
|
||||
x = self.embeddings(pixel_values)
|
||||
optimized_attention = optimized_attention_for_device(x.device, False, small_input=True)
|
||||
n_layers = len(self.encoder.layer)
|
||||
resolved = [(i if i >= 0 else n_layers + i) for i in indices]
|
||||
target = set(resolved)
|
||||
max_idx = max(resolved)
|
||||
n_skip = 1 # skip cls token
|
||||
cache = {}
|
||||
for i, layer in enumerate(self.encoder.layer):
|
||||
x = layer(x, optimized_attention)
|
||||
if i in target:
|
||||
normed = self.layernorm(x) if apply_norm else x
|
||||
cache[i] = (normed[:, n_skip:], normed[:, 0])
|
||||
if i >= max_idx:
|
||||
break
|
||||
return [cache[i] for i in resolved]
|
||||
|
||||
@@ -242,6 +242,7 @@ def sample_euler_ancestral_RF(model, x, sigmas, extra_args=None, callback=None,
|
||||
extra_args = {} if extra_args is None else extra_args
|
||||
seed = extra_args.get("seed", None)
|
||||
noise_sampler = default_noise_sampler(x, seed=seed) if noise_sampler is None else noise_sampler
|
||||
s_noise = s_noise * getattr(model.inner_model.model_patcher.get_model_object('model_sampling'), "noise_scale", 1.0)
|
||||
s_in = x.new_ones([x.shape[0]])
|
||||
for i in trange(len(sigmas) - 1, disable=disable):
|
||||
denoised = model(x, sigmas[i] * s_in, **extra_args)
|
||||
@@ -373,6 +374,7 @@ def sample_dpm_2_ancestral_RF(model, x, sigmas, extra_args=None, callback=None,
|
||||
extra_args = {} if extra_args is None else extra_args
|
||||
seed = extra_args.get("seed", None)
|
||||
noise_sampler = default_noise_sampler(x, seed=seed) if noise_sampler is None else noise_sampler
|
||||
s_noise = s_noise * getattr(model.inner_model.model_patcher.get_model_object('model_sampling'), "noise_scale", 1.0)
|
||||
s_in = x.new_ones([x.shape[0]])
|
||||
for i in trange(len(sigmas) - 1, disable=disable):
|
||||
denoised = model(x, sigmas[i] * s_in, **extra_args)
|
||||
@@ -686,6 +688,7 @@ def sample_dpmpp_2s_ancestral_RF(model, x, sigmas, extra_args=None, callback=Non
|
||||
extra_args = {} if extra_args is None else extra_args
|
||||
seed = extra_args.get("seed", None)
|
||||
noise_sampler = default_noise_sampler(x, seed=seed) if noise_sampler is None else noise_sampler
|
||||
s_noise = s_noise * getattr(model.inner_model.model_patcher.get_model_object('model_sampling'), "noise_scale", 1.0)
|
||||
s_in = x.new_ones([x.shape[0]])
|
||||
sigma_fn = lambda lbda: (lbda.exp() + 1) ** -1
|
||||
lambda_fn = lambda sigma: ((1-sigma)/sigma).log()
|
||||
@@ -747,6 +750,7 @@ def sample_dpmpp_sde(model, x, sigmas, extra_args=None, callback=None, disable=N
|
||||
sigma_fn = partial(half_log_snr_to_sigma, model_sampling=model_sampling)
|
||||
lambda_fn = partial(sigma_to_half_log_snr, model_sampling=model_sampling)
|
||||
sigmas = offset_first_sigma_for_snr(sigmas, model_sampling)
|
||||
s_noise = s_noise * getattr(model_sampling, "noise_scale", 1.0)
|
||||
|
||||
for i in trange(len(sigmas) - 1, disable=disable):
|
||||
denoised = model(x, sigmas[i] * s_in, **extra_args)
|
||||
@@ -832,6 +836,7 @@ def sample_dpmpp_2m_sde(model, x, sigmas, extra_args=None, callback=None, disabl
|
||||
model_sampling = model.inner_model.model_patcher.get_model_object('model_sampling')
|
||||
lambda_fn = partial(sigma_to_half_log_snr, model_sampling=model_sampling)
|
||||
sigmas = offset_first_sigma_for_snr(sigmas, model_sampling)
|
||||
s_noise = s_noise * getattr(model_sampling, "noise_scale", 1.0)
|
||||
|
||||
old_denoised = None
|
||||
h, h_last = None, None
|
||||
@@ -889,6 +894,7 @@ def sample_dpmpp_3m_sde(model, x, sigmas, extra_args=None, callback=None, disabl
|
||||
model_sampling = model.inner_model.model_patcher.get_model_object('model_sampling')
|
||||
lambda_fn = partial(sigma_to_half_log_snr, model_sampling=model_sampling)
|
||||
sigmas = offset_first_sigma_for_snr(sigmas, model_sampling)
|
||||
s_noise = s_noise * getattr(model_sampling, "noise_scale", 1.0)
|
||||
|
||||
denoised_1, denoised_2 = None, None
|
||||
h, h_1, h_2 = None, None, None
|
||||
@@ -1006,23 +1012,39 @@ def sample_ddpm(model, x, sigmas, extra_args=None, callback=None, disable=None,
|
||||
return generic_step_sampler(model, x, sigmas, extra_args, callback, disable, noise_sampler, DDPMSampler_step)
|
||||
|
||||
@torch.no_grad()
|
||||
def sample_lcm(model, x, sigmas, extra_args=None, callback=None, disable=None, noise_sampler=None):
|
||||
def sample_lcm(model, x, sigmas, extra_args=None, callback=None, disable=None, noise_sampler=None, s_noise=1.0, s_noise_end=None, noise_clip_std=0.0):
|
||||
|
||||
# s_noise / s_noise_end: per-step noise multiplier, linearly interpolated across steps
|
||||
# noise_clip_std: clamp injected noise to +/- N stddevs (0 disables).
|
||||
|
||||
extra_args = {} if extra_args is None else extra_args
|
||||
seed = extra_args.get("seed", None)
|
||||
noise_sampler = default_noise_sampler(x, seed=seed) if noise_sampler is None else noise_sampler
|
||||
s_in = x.new_ones([x.shape[0]])
|
||||
for i in trange(len(sigmas) - 1, disable=disable):
|
||||
n_steps = max(1, len(sigmas) - 1)
|
||||
model_sampling = model.inner_model.model_patcher.get_model_object('model_sampling')
|
||||
|
||||
s_start = float(s_noise)
|
||||
s_end = s_start if s_noise_end is None else float(s_noise_end)
|
||||
for i in trange(n_steps, disable=disable):
|
||||
denoised = model(x, sigmas[i] * s_in, **extra_args)
|
||||
if callback is not None:
|
||||
callback({'x': x, 'i': i, 'sigma': sigmas[i], 'sigma_hat': sigmas[i], 'denoised': denoised})
|
||||
|
||||
x = denoised
|
||||
if sigmas[i + 1] > 0:
|
||||
x = model.inner_model.inner_model.model_sampling.noise_scaling(sigmas[i + 1], noise_sampler(sigmas[i], sigmas[i + 1]), x)
|
||||
noise = noise_sampler(sigmas[i], sigmas[i + 1])
|
||||
if noise_clip_std > 0:
|
||||
clip_val = noise_clip_std * noise.std()
|
||||
noise = noise.clamp(min=-clip_val, max=clip_val)
|
||||
t = (i / (n_steps - 1)) if n_steps > 1 else 0.0
|
||||
s_noise_i = s_start + (s_end - s_start) * t
|
||||
if s_noise_i != 1.0:
|
||||
noise = noise * s_noise_i
|
||||
x = model_sampling.noise_scaling(sigmas[i + 1], noise, x)
|
||||
return x
|
||||
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def sample_heunpp2(model, x, sigmas, extra_args=None, callback=None, disable=None, s_churn=0., s_tmin=0., s_tmax=float('inf'), s_noise=1.):
|
||||
# From MIT licensed: https://github.com/Carzit/sd-webui-samplers-scheduler/
|
||||
@@ -1249,6 +1271,7 @@ def sample_euler_ancestral_cfg_pp(model, x, sigmas, extra_args=None, callback=No
|
||||
|
||||
model_sampling = model.inner_model.model_patcher.get_model_object("model_sampling")
|
||||
lambda_fn = partial(sigma_to_half_log_snr, model_sampling=model_sampling)
|
||||
s_noise = s_noise * getattr(model_sampling, "noise_scale", 1.0)
|
||||
|
||||
uncond_denoised = None
|
||||
|
||||
@@ -1296,6 +1319,7 @@ def sample_dpmpp_2s_ancestral_cfg_pp(model, x, sigmas, extra_args=None, callback
|
||||
extra_args = {} if extra_args is None else extra_args
|
||||
seed = extra_args.get("seed", None)
|
||||
noise_sampler = default_noise_sampler(x, seed=seed) if noise_sampler is None else noise_sampler
|
||||
s_noise = s_noise * getattr(model.inner_model.model_patcher.get_model_object('model_sampling'), "noise_scale", 1.0)
|
||||
|
||||
temp = [0]
|
||||
def post_cfg_function(args):
|
||||
@@ -1371,6 +1395,7 @@ def res_multistep(model, x, sigmas, extra_args=None, callback=None, disable=None
|
||||
extra_args = {} if extra_args is None else extra_args
|
||||
seed = extra_args.get("seed", None)
|
||||
noise_sampler = default_noise_sampler(x, seed=seed) if noise_sampler is None else noise_sampler
|
||||
s_noise = s_noise * getattr(model.inner_model.model_patcher.get_model_object('model_sampling'), "noise_scale", 1.0)
|
||||
s_in = x.new_ones([x.shape[0]])
|
||||
sigma_fn = lambda t: t.neg().exp()
|
||||
t_fn = lambda sigma: sigma.log().neg()
|
||||
@@ -1504,6 +1529,7 @@ def sample_er_sde(model, x, sigmas, extra_args=None, callback=None, disable=None
|
||||
extra_args = {} if extra_args is None else extra_args
|
||||
seed = extra_args.get("seed", None)
|
||||
noise_sampler = default_noise_sampler(x, seed=seed) if noise_sampler is None else noise_sampler
|
||||
s_noise = s_noise * getattr(model.inner_model.model_patcher.get_model_object('model_sampling'), "noise_scale", 1.0)
|
||||
s_in = x.new_ones([x.shape[0]])
|
||||
|
||||
def default_er_sde_noise_scaler(x):
|
||||
@@ -1574,9 +1600,10 @@ def sample_seeds_2(model, x, sigmas, extra_args=None, callback=None, disable=Non
|
||||
seed = extra_args.get("seed", None)
|
||||
noise_sampler = default_noise_sampler(x, seed=seed) if noise_sampler is None else noise_sampler
|
||||
s_in = x.new_ones([x.shape[0]])
|
||||
inject_noise = eta > 0 and s_noise > 0
|
||||
|
||||
model_sampling = model.inner_model.model_patcher.get_model_object('model_sampling')
|
||||
s_noise = s_noise * getattr(model_sampling, "noise_scale", 1.0)
|
||||
inject_noise = eta > 0 and s_noise > 0
|
||||
sigma_fn = partial(half_log_snr_to_sigma, model_sampling=model_sampling)
|
||||
lambda_fn = partial(sigma_to_half_log_snr, model_sampling=model_sampling)
|
||||
sigmas = offset_first_sigma_for_snr(sigmas, model_sampling)
|
||||
@@ -1645,9 +1672,10 @@ def sample_seeds_3(model, x, sigmas, extra_args=None, callback=None, disable=Non
|
||||
seed = extra_args.get("seed", None)
|
||||
noise_sampler = default_noise_sampler(x, seed=seed) if noise_sampler is None else noise_sampler
|
||||
s_in = x.new_ones([x.shape[0]])
|
||||
inject_noise = eta > 0 and s_noise > 0
|
||||
|
||||
model_sampling = model.inner_model.model_patcher.get_model_object('model_sampling')
|
||||
s_noise = s_noise * getattr(model_sampling, "noise_scale", 1.0)
|
||||
inject_noise = eta > 0 and s_noise > 0
|
||||
sigma_fn = partial(half_log_snr_to_sigma, model_sampling=model_sampling)
|
||||
lambda_fn = partial(sigma_to_half_log_snr, model_sampling=model_sampling)
|
||||
sigmas = offset_first_sigma_for_snr(sigmas, model_sampling)
|
||||
@@ -1713,6 +1741,7 @@ def sample_sa_solver(model, x, sigmas, extra_args=None, callback=None, disable=F
|
||||
s_in = x.new_ones([x.shape[0]])
|
||||
|
||||
model_sampling = model.inner_model.model_patcher.get_model_object("model_sampling")
|
||||
s_noise = s_noise * getattr(model_sampling, "noise_scale", 1.0)
|
||||
sigmas = offset_first_sigma_for_snr(sigmas, model_sampling)
|
||||
lambdas = sigma_to_half_log_snr(sigmas, model_sampling=model_sampling)
|
||||
|
||||
|
||||
@@ -150,6 +150,12 @@ class SD3(LatentFormat):
|
||||
class StableAudio1(LatentFormat):
|
||||
latent_channels = 64
|
||||
latent_dimensions = 1
|
||||
temporal_downscale_ratio = 2048
|
||||
|
||||
class StableAudio3(LatentFormat):
|
||||
latent_channels = 256
|
||||
latent_dimensions = 1
|
||||
temporal_downscale_ratio = 4096
|
||||
|
||||
class Flux(SD3):
|
||||
latent_channels = 16
|
||||
@@ -766,6 +772,7 @@ class ACEAudio(LatentFormat):
|
||||
class ACEAudio15(LatentFormat):
|
||||
latent_channels = 64
|
||||
latent_dimensions = 1
|
||||
temporal_downscale_ratio = 1764
|
||||
|
||||
class ChromaRadiance(LatentFormat):
|
||||
latent_channels = 3
|
||||
@@ -792,6 +799,13 @@ class ZImagePixelSpace(ChromaRadiance):
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class HiDreamO1Pixel(ChromaRadiance):
|
||||
"""Pixel-space latent format for HiDream-O1.
|
||||
No VAE — model patches/unpatches raw RGB internally with patch_size=32.
|
||||
"""
|
||||
pass
|
||||
|
||||
class CogVideoX(LatentFormat):
|
||||
"""Latent format for CogVideoX-2b (THUDM/CogVideoX-2b).
|
||||
|
||||
|
||||
+208
-42
@@ -10,6 +10,17 @@ from torch import nn
|
||||
from torch.nn import functional as F
|
||||
import math
|
||||
import comfy.ops
|
||||
from .embedders import ExpoFourierFeatures
|
||||
|
||||
|
||||
def _left_pad_to_match(emb, target_len):
|
||||
emb_len = emb.shape[-2]
|
||||
if emb_len < target_len:
|
||||
return F.pad(emb, (0, 0, target_len - emb_len, 0), value=0.)
|
||||
elif emb_len > target_len:
|
||||
return emb[:, -target_len:, :]
|
||||
return emb
|
||||
|
||||
|
||||
class FourierFeatures(nn.Module):
|
||||
def __init__(self, in_features, out_features, std=1., dtype=None, device=None):
|
||||
@@ -22,6 +33,7 @@ class FourierFeatures(nn.Module):
|
||||
f = 2 * math.pi * input @ comfy.ops.cast_to_input(self.weight.T, input)
|
||||
return torch.cat([f.cos(), f.sin()], dim=-1)
|
||||
|
||||
|
||||
# norms
|
||||
class LayerNorm(nn.Module):
|
||||
def __init__(self, dim, bias=False, fix_scale=False, dtype=None, device=None):
|
||||
@@ -43,6 +55,16 @@ class LayerNorm(nn.Module):
|
||||
beta = comfy.ops.cast_to_input(beta, x)
|
||||
return F.layer_norm(x, x.shape[-1:], weight=comfy.ops.cast_to_input(self.gamma, x), bias=beta)
|
||||
|
||||
|
||||
class RMSNorm(nn.Module):
|
||||
def __init__(self, dim, dtype=None, device=None):
|
||||
super().__init__()
|
||||
self.gamma = nn.Parameter(torch.empty(dim, dtype=dtype, device=device))
|
||||
|
||||
def forward(self, x):
|
||||
return F.rms_norm(x, x.shape[-1:], weight=comfy.ops.cast_to_input(self.gamma, x))
|
||||
|
||||
|
||||
class GLU(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -236,13 +258,6 @@ class FeedForward(nn.Module):
|
||||
|
||||
linear_out = operations.Linear(inner_dim, dim_out, bias = not no_bias, dtype=dtype, device=device) if not use_conv else operations.Conv1d(inner_dim, dim_out, conv_kernel_size, padding = (conv_kernel_size // 2), bias = not no_bias, dtype=dtype, device=device)
|
||||
|
||||
# # init last linear layer to 0
|
||||
# if zero_init_output:
|
||||
# nn.init.zeros_(linear_out.weight)
|
||||
# if not no_bias:
|
||||
# nn.init.zeros_(linear_out.bias)
|
||||
|
||||
|
||||
self.ff = nn.Sequential(
|
||||
linear_in,
|
||||
rearrange('b d n -> b n d') if use_conv else nn.Identity(),
|
||||
@@ -261,8 +276,10 @@ class Attention(nn.Module):
|
||||
dim_context = None,
|
||||
causal = False,
|
||||
zero_init_output=True,
|
||||
qk_norm = False,
|
||||
qk_norm = "none",
|
||||
differential = False,
|
||||
natten_kernel_size = None,
|
||||
feat_scale = False,
|
||||
dtype=None,
|
||||
device=None,
|
||||
operations=None,
|
||||
@@ -271,6 +288,7 @@ class Attention(nn.Module):
|
||||
self.dim = dim
|
||||
self.dim_heads = dim_heads
|
||||
self.causal = causal
|
||||
self.differential = differential
|
||||
|
||||
dim_kv = dim_context if dim_context is not None else dim
|
||||
|
||||
@@ -278,18 +296,37 @@ class Attention(nn.Module):
|
||||
self.kv_heads = dim_kv // dim_heads
|
||||
|
||||
if dim_context is not None:
|
||||
self.to_q = operations.Linear(dim, dim, bias=False, dtype=dtype, device=device)
|
||||
self.to_kv = operations.Linear(dim_kv, dim_kv * 2, bias=False, dtype=dtype, device=device)
|
||||
if differential:
|
||||
self.to_q = operations.Linear(dim, dim * 2, bias=False, dtype=dtype, device=device)
|
||||
self.to_kv = operations.Linear(dim_kv, dim_kv * 3, bias=False, dtype=dtype, device=device)
|
||||
else:
|
||||
self.to_q = operations.Linear(dim, dim, bias=False, dtype=dtype, device=device)
|
||||
self.to_kv = operations.Linear(dim_kv, dim_kv * 2, bias=False, dtype=dtype, device=device)
|
||||
else:
|
||||
self.to_qkv = operations.Linear(dim, dim * 3, bias=False, dtype=dtype, device=device)
|
||||
if differential:
|
||||
self.to_qkv = operations.Linear(dim, dim * 5, bias=False, dtype=dtype, device=device)
|
||||
else:
|
||||
self.to_qkv = operations.Linear(dim, dim * 3, bias=False, dtype=dtype, device=device)
|
||||
|
||||
self.to_out = operations.Linear(dim, dim, bias=False, dtype=dtype, device=device)
|
||||
|
||||
# if zero_init_output:
|
||||
# nn.init.zeros_(self.to_out.weight)
|
||||
|
||||
# Accept bool for backward compat
|
||||
if isinstance(qk_norm, bool):
|
||||
qk_norm = "l2" if qk_norm else "none"
|
||||
self.qk_norm = qk_norm
|
||||
|
||||
if self.qk_norm == "ln":
|
||||
self.q_norm = operations.LayerNorm(dim_heads, elementwise_affine=True, eps=1.0e-6, dtype=dtype, device=device)
|
||||
self.k_norm = operations.LayerNorm(dim_heads, elementwise_affine=True, eps=1.0e-6, dtype=dtype, device=device)
|
||||
elif self.qk_norm == "rms":
|
||||
self.q_norm = RMSNorm(dim_heads, dtype=dtype, device=device)
|
||||
self.k_norm = RMSNorm(dim_heads, dtype=dtype, device=device)
|
||||
|
||||
self.feat_scale = feat_scale
|
||||
|
||||
if self.feat_scale:
|
||||
self.lambda_dc = nn.Parameter(torch.empty(dim, dtype=dtype, device=device))
|
||||
self.lambda_hf = nn.Parameter(torch.empty(dim, dtype=dtype, device=device))
|
||||
|
||||
def forward(
|
||||
self,
|
||||
@@ -306,22 +343,51 @@ class Attention(nn.Module):
|
||||
kv_input = context if has_context else x
|
||||
|
||||
if hasattr(self, 'to_q'):
|
||||
# Use separate linear projections for q and k/v
|
||||
q = self.to_q(x)
|
||||
q = rearrange(q, 'b n (h d) -> b h n d', h = h)
|
||||
if self.differential:
|
||||
# cross-attention differential: to_q → (q, q_diff), to_kv → (k, k_diff, v)
|
||||
q, q_diff = self.to_q(x).chunk(2, dim=-1)
|
||||
q = rearrange(q, 'b n (h d) -> b h n d', h=h)
|
||||
q_diff = rearrange(q_diff, 'b n (h d) -> b h n d', h=h)
|
||||
q = torch.stack([q, q_diff], dim=1) # (B, 2, H, N, D)
|
||||
k, k_diff, v = self.to_kv(kv_input).chunk(3, dim=-1)
|
||||
k = rearrange(k, 'b n (h d) -> b h n d', h=kv_h)
|
||||
k_diff = rearrange(k_diff, 'b n (h d) -> b h n d', h=kv_h)
|
||||
v = rearrange(v, 'b n (h d) -> b h n d', h=kv_h)
|
||||
k = torch.stack([k, k_diff], dim=1) # (B, 2, H, M, D)
|
||||
else:
|
||||
# Use separate linear projections for q and k/v
|
||||
q = self.to_q(x)
|
||||
q = rearrange(q, 'b n (h d) -> b h n d', h = h)
|
||||
|
||||
k, v = self.to_kv(kv_input).chunk(2, dim=-1)
|
||||
k, v = self.to_kv(kv_input).chunk(2, dim=-1)
|
||||
|
||||
k, v = map(lambda t: rearrange(t, 'b n (h d) -> b h n d', h = kv_h), (k, v))
|
||||
k, v = map(lambda t: rearrange(t, 'b n (h d) -> b h n d', h = kv_h), (k, v))
|
||||
else:
|
||||
# Use fused linear projection
|
||||
q, k, v = self.to_qkv(x).chunk(3, dim=-1)
|
||||
q, k, v = map(lambda t: rearrange(t, 'b n (h d) -> b h n d', h = h), (q, k, v))
|
||||
if self.differential:
|
||||
# self-attention differential: to_qkv → (q, k, v, q_diff, k_diff)
|
||||
q, k, v, q_diff, k_diff = self.to_qkv(x).chunk(5, dim=-1)
|
||||
q, k, v, q_diff, k_diff = map(
|
||||
lambda t: rearrange(t, 'b n (h d) -> b h n d', h=h),
|
||||
(q, k, v, q_diff, k_diff)
|
||||
)
|
||||
q = torch.stack([q, q_diff], dim=1) # (B, 2, H, N, D)
|
||||
k = torch.stack([k, k_diff], dim=1)
|
||||
else:
|
||||
# Use fused linear projection
|
||||
q, k, v = self.to_qkv(x).chunk(3, dim=-1)
|
||||
q, k, v = map(lambda t: rearrange(t, 'b n (h d) -> b h n d', h = h), (q, k, v))
|
||||
|
||||
# Normalize q and k for cosine sim attention
|
||||
if self.qk_norm:
|
||||
if self.qk_norm == "l2":
|
||||
q = F.normalize(q, dim=-1)
|
||||
k = F.normalize(k, dim=-1)
|
||||
elif self.qk_norm == "rms":
|
||||
q_type, k_type = q.dtype, k.dtype
|
||||
q = self.q_norm(q).to(q_type)
|
||||
k = self.k_norm(k).to(k_type)
|
||||
elif self.qk_norm != 'none':
|
||||
q = self.q_norm(q)
|
||||
k = self.k_norm(k)
|
||||
|
||||
if rotary_pos_emb is not None and not has_context:
|
||||
freqs, _ = rotary_pos_emb
|
||||
@@ -364,9 +430,24 @@ class Attention(nn.Module):
|
||||
heads_per_kv_head = h // kv_h
|
||||
k, v = map(lambda t: t.repeat_interleave(heads_per_kv_head, dim = 1), (k, v))
|
||||
|
||||
out = optimized_attention(q, k, v, h, skip_reshape=True, transformer_options=transformer_options)
|
||||
if self.differential:
|
||||
q, q_diff = q.unbind(dim=1)
|
||||
k, k_diff = k.unbind(dim=1)
|
||||
out = optimized_attention(q, k, v, h, skip_reshape=True, transformer_options=transformer_options)
|
||||
out_diff = optimized_attention(q_diff, k_diff, v, h, skip_reshape=True, transformer_options=transformer_options)
|
||||
out = out - out_diff
|
||||
else:
|
||||
out = optimized_attention(q, k, v, h, skip_reshape=True, transformer_options=transformer_options)
|
||||
|
||||
out = self.to_out(out)
|
||||
|
||||
if self.feat_scale:
|
||||
out_dc = out.mean(dim=-2, keepdim=True)
|
||||
out_hf = out - out_dc
|
||||
|
||||
# Selectively modulate DC and high frequency components
|
||||
out = out + comfy.ops.cast_to_input(self.lambda_dc, out) * out_dc + comfy.ops.cast_to_input(self.lambda_hf, out) * out_hf
|
||||
|
||||
if mask is not None:
|
||||
mask = rearrange(mask, 'b n -> b n 1')
|
||||
out = out.masked_fill(~mask, 0.)
|
||||
@@ -417,11 +498,14 @@ class TransformerBlock(nn.Module):
|
||||
cross_attend = False,
|
||||
dim_context = None,
|
||||
global_cond_dim = None,
|
||||
global_cond_shared_embed = False,
|
||||
local_add_cond_dim = None,
|
||||
causal = False,
|
||||
zero_init_branch_outputs = True,
|
||||
conformer = False,
|
||||
layer_ix = -1,
|
||||
remove_norms = False,
|
||||
norm_type = "layer_norm",
|
||||
attn_kwargs = {},
|
||||
ff_kwargs = {},
|
||||
norm_kwargs = {},
|
||||
@@ -436,8 +520,20 @@ class TransformerBlock(nn.Module):
|
||||
self.cross_attend = cross_attend
|
||||
self.dim_context = dim_context
|
||||
self.causal = causal
|
||||
self.global_cond_shared_embed = global_cond_shared_embed
|
||||
|
||||
self.pre_norm = LayerNorm(dim, dtype=dtype, device=device, **norm_kwargs) if not remove_norms else nn.Identity()
|
||||
norm_layer_map = {
|
||||
"layer_norm": LayerNorm,
|
||||
"rms_norm": RMSNorm,
|
||||
}
|
||||
norm_cls = norm_layer_map.get(norm_type, LayerNorm)
|
||||
|
||||
def make_norm():
|
||||
if remove_norms:
|
||||
return nn.Identity()
|
||||
return norm_cls(dim, dtype=dtype, device=device, **norm_kwargs)
|
||||
|
||||
self.pre_norm = make_norm()
|
||||
|
||||
self.self_attn = Attention(
|
||||
dim,
|
||||
@@ -451,7 +547,7 @@ class TransformerBlock(nn.Module):
|
||||
)
|
||||
|
||||
if cross_attend:
|
||||
self.cross_attend_norm = LayerNorm(dim, dtype=dtype, device=device, **norm_kwargs) if not remove_norms else nn.Identity()
|
||||
self.cross_attend_norm = make_norm()
|
||||
self.cross_attn = Attention(
|
||||
dim,
|
||||
dim_heads = dim_heads,
|
||||
@@ -464,37 +560,56 @@ class TransformerBlock(nn.Module):
|
||||
**attn_kwargs
|
||||
)
|
||||
|
||||
self.ff_norm = LayerNorm(dim, dtype=dtype, device=device, **norm_kwargs) if not remove_norms else nn.Identity()
|
||||
self.ff = FeedForward(dim, zero_init_output=zero_init_branch_outputs, dtype=dtype, device=device, operations=operations,**ff_kwargs)
|
||||
self.ff_norm = make_norm()
|
||||
self.ff = FeedForward(dim, zero_init_output=zero_init_branch_outputs, dtype=dtype, device=device, operations=operations, **ff_kwargs)
|
||||
|
||||
self.layer_ix = layer_ix
|
||||
|
||||
self.conformer = ConformerModule(dim, norm_kwargs=norm_kwargs) if conformer else None
|
||||
|
||||
self.global_cond_dim = global_cond_dim
|
||||
# Global conditioning
|
||||
self.has_global_cond = (global_cond_dim is not None) or global_cond_shared_embed
|
||||
|
||||
if global_cond_dim is not None:
|
||||
if global_cond_shared_embed:
|
||||
# SA3 style: learnable per-block additive bias; global_cond is pre-projected to (B, dim*6)
|
||||
self.to_scale_shift_gate = nn.Parameter(torch.empty(dim * 6, device=device, dtype=dtype))
|
||||
elif global_cond_dim is not None:
|
||||
# SA1 style: per-block MLP projects global_cond → (B, dim*6)
|
||||
self.to_scale_shift_gate = nn.Sequential(
|
||||
nn.SiLU(),
|
||||
nn.Linear(global_cond_dim, dim * 6, bias=False)
|
||||
operations.Linear(global_cond_dim, dim * 6, bias=False, device=device, dtype=dtype)
|
||||
)
|
||||
|
||||
nn.init.zeros_(self.to_scale_shift_gate[1].weight)
|
||||
#nn.init.zeros_(self.to_scale_shift_gate_self[1].bias)
|
||||
# Local additive conditioning (e.g. inpaint mask + masked latent)
|
||||
self.local_add_cond_dim = local_add_cond_dim
|
||||
if local_add_cond_dim is not None:
|
||||
self.to_local_embed = nn.Sequential(
|
||||
operations.Linear(local_add_cond_dim, dim, bias=True, dtype=dtype, device=device),
|
||||
nn.SiLU(),
|
||||
operations.Linear(dim, dim, bias=True, dtype=dtype, device=device),
|
||||
)
|
||||
else:
|
||||
self.to_local_embed = None
|
||||
|
||||
def forward(
|
||||
self,
|
||||
x,
|
||||
context = None,
|
||||
global_cond=None,
|
||||
local_add_cond=None,
|
||||
mask = None,
|
||||
context_mask = None,
|
||||
rotary_pos_emb = None,
|
||||
transformer_options={}
|
||||
):
|
||||
if self.global_cond_dim is not None and self.global_cond_dim > 0 and global_cond is not None:
|
||||
if self.has_global_cond and global_cond is not None:
|
||||
if self.global_cond_shared_embed:
|
||||
# global_cond already has shape (B, dim*6)
|
||||
ssg = (comfy.ops.cast_to_input(self.to_scale_shift_gate, global_cond) + global_cond).unsqueeze(1)
|
||||
else:
|
||||
ssg = self.to_scale_shift_gate(global_cond).unsqueeze(1)
|
||||
|
||||
scale_self, shift_self, gate_self, scale_ff, shift_ff, gate_ff = self.to_scale_shift_gate(global_cond).unsqueeze(1).chunk(6, dim = -1)
|
||||
scale_self, shift_self, gate_self, scale_ff, shift_ff, gate_ff = ssg.chunk(6, dim = -1)
|
||||
|
||||
# self-attention with adaLN
|
||||
residual = x
|
||||
@@ -510,6 +625,9 @@ class TransformerBlock(nn.Module):
|
||||
if self.conformer is not None:
|
||||
x = x + self.conformer(x)
|
||||
|
||||
if local_add_cond is not None and self.to_local_embed is not None:
|
||||
x = x + _left_pad_to_match(self.to_local_embed(local_add_cond), x.shape[-2])
|
||||
|
||||
# feedforward with adaLN
|
||||
residual = x
|
||||
x = self.ff_norm(x)
|
||||
@@ -527,6 +645,9 @@ class TransformerBlock(nn.Module):
|
||||
if self.conformer is not None:
|
||||
x = x + self.conformer(x)
|
||||
|
||||
if local_add_cond is not None and self.to_local_embed is not None:
|
||||
x = x + _left_pad_to_match(self.to_local_embed(local_add_cond), x.shape[-2])
|
||||
|
||||
x = x + self.ff(self.ff_norm(x))
|
||||
|
||||
return x
|
||||
@@ -543,6 +664,8 @@ class ContinuousTransformer(nn.Module):
|
||||
cross_attend=False,
|
||||
cond_token_dim=None,
|
||||
global_cond_dim=None,
|
||||
global_cond_shared_embed=False,
|
||||
local_add_cond_dim=None,
|
||||
causal=False,
|
||||
rotary_pos_emb=True,
|
||||
zero_init_branch_outputs=True,
|
||||
@@ -550,6 +673,7 @@ class ContinuousTransformer(nn.Module):
|
||||
use_sinusoidal_emb=False,
|
||||
use_abs_pos_emb=False,
|
||||
abs_pos_emb_max_length=10000,
|
||||
num_memory_tokens=0,
|
||||
dtype=None,
|
||||
device=None,
|
||||
operations=None,
|
||||
@@ -562,6 +686,8 @@ class ContinuousTransformer(nn.Module):
|
||||
self.depth = depth
|
||||
self.causal = causal
|
||||
self.layers = nn.ModuleList([])
|
||||
self.num_memory_tokens = num_memory_tokens
|
||||
self.global_cond_shared_embed = global_cond_shared_embed
|
||||
|
||||
self.project_in = operations.Linear(dim_in, dim, bias=False, dtype=dtype, device=device) if dim_in is not None else nn.Identity()
|
||||
self.project_out = operations.Linear(dim, dim_out, bias=False, dtype=dtype, device=device) if dim_out is not None else nn.Identity()
|
||||
@@ -577,7 +703,22 @@ class ContinuousTransformer(nn.Module):
|
||||
|
||||
self.use_abs_pos_emb = use_abs_pos_emb
|
||||
if use_abs_pos_emb:
|
||||
self.pos_emb = AbsolutePositionalEmbedding(dim, abs_pos_emb_max_length)
|
||||
self.pos_emb = AbsolutePositionalEmbedding(dim, abs_pos_emb_max_length + num_memory_tokens)
|
||||
|
||||
if num_memory_tokens > 0:
|
||||
self.memory_tokens = nn.Parameter(torch.empty(num_memory_tokens, dim, device=device, dtype=dtype))
|
||||
|
||||
# Shared global-cond embedder (SA3 style): projects (B, global_cond_dim) → (B, dim*6)
|
||||
self.global_cond_embedder = None
|
||||
if global_cond_shared_embed and global_cond_dim is not None:
|
||||
self.global_cond_embedder = nn.Sequential(
|
||||
operations.Linear(global_cond_dim, dim, bias=True, dtype=dtype, device=device),
|
||||
nn.SiLU(),
|
||||
operations.Linear(dim, dim * 6, bias=True, dtype=dtype, device=device),
|
||||
)
|
||||
|
||||
# When using shared embed, TransformerBlocks use per-block Parameter (not per-block MLP)
|
||||
block_global_cond_dim = None if global_cond_shared_embed else global_cond_dim
|
||||
|
||||
for i in range(depth):
|
||||
self.layers.append(
|
||||
@@ -586,7 +727,9 @@ class ContinuousTransformer(nn.Module):
|
||||
dim_heads = dim_heads,
|
||||
cross_attend = cross_attend,
|
||||
dim_context = cond_token_dim,
|
||||
global_cond_dim = global_cond_dim,
|
||||
global_cond_dim = block_global_cond_dim,
|
||||
global_cond_shared_embed = global_cond_shared_embed,
|
||||
local_add_cond_dim = local_add_cond_dim,
|
||||
causal = causal,
|
||||
zero_init_branch_outputs = zero_init_branch_outputs,
|
||||
conformer=conformer,
|
||||
@@ -605,6 +748,7 @@ class ContinuousTransformer(nn.Module):
|
||||
prepend_embeds = None,
|
||||
prepend_mask = None,
|
||||
global_cond = None,
|
||||
local_add_cond = None,
|
||||
return_info = False,
|
||||
**kwargs
|
||||
):
|
||||
@@ -632,7 +776,9 @@ class ContinuousTransformer(nn.Module):
|
||||
|
||||
mask = torch.cat((prepend_mask, mask), dim = -1)
|
||||
|
||||
# Attention layers
|
||||
if self.num_memory_tokens > 0:
|
||||
memory_tokens = comfy.ops.cast_to_input(self.memory_tokens, x).expand(batch, -1, -1)
|
||||
x = torch.cat((memory_tokens, x), dim=1)
|
||||
|
||||
if self.rotary_pos_emb is not None:
|
||||
rotary_pos_emb = self.rotary_pos_emb.forward_from_seq_len(x.shape[1], dtype=torch.float, device=x.device)
|
||||
@@ -642,6 +788,10 @@ class ContinuousTransformer(nn.Module):
|
||||
if self.use_sinusoidal_emb or self.use_abs_pos_emb:
|
||||
x = x + self.pos_emb(x)
|
||||
|
||||
# Project global_cond once (SA3 shared-embed path)
|
||||
if global_cond is not None and self.global_cond_embedder is not None:
|
||||
global_cond = self.global_cond_embedder(global_cond)
|
||||
|
||||
blocks_replace = patches_replace.get("dit", {})
|
||||
# Iterate over the transformer layers
|
||||
for i, layer in enumerate(self.layers):
|
||||
@@ -654,12 +804,17 @@ class ContinuousTransformer(nn.Module):
|
||||
out = blocks_replace[("double_block", i)]({"img": x, "txt": context, "vec": global_cond, "pe": rotary_pos_emb, "transformer_options": transformer_options}, {"original_block": block_wrap})
|
||||
x = out["img"]
|
||||
else:
|
||||
x = layer(x, rotary_pos_emb = rotary_pos_emb, global_cond=global_cond, context=context, transformer_options=transformer_options)
|
||||
# x = checkpoint(layer, x, rotary_pos_emb = rotary_pos_emb, global_cond=global_cond, **kwargs)
|
||||
x = layer(x, rotary_pos_emb=rotary_pos_emb, global_cond=global_cond,
|
||||
local_add_cond=local_add_cond, context=context,
|
||||
transformer_options=transformer_options)
|
||||
|
||||
if return_info:
|
||||
info["hidden_states"].append(x)
|
||||
|
||||
# Strip memory tokens before projecting out
|
||||
if self.num_memory_tokens > 0:
|
||||
x = x[:, self.num_memory_tokens:, :]
|
||||
|
||||
x = self.project_out(x)
|
||||
|
||||
if return_info:
|
||||
@@ -682,6 +837,7 @@ class AudioDiffusionTransformer(nn.Module):
|
||||
num_heads=24,
|
||||
transformer_type: tp.Literal["continuous_transformer"] = "continuous_transformer",
|
||||
global_cond_type: tp.Literal["prepend", "adaLN"] = "prepend",
|
||||
timestep_features_type: str = "learned",
|
||||
audio_model="",
|
||||
dtype=None,
|
||||
device=None,
|
||||
@@ -696,7 +852,10 @@ class AudioDiffusionTransformer(nn.Module):
|
||||
# Timestep embeddings
|
||||
timestep_features_dim = 256
|
||||
|
||||
self.timestep_features = FourierFeatures(1, timestep_features_dim, dtype=dtype, device=device)
|
||||
if timestep_features_type == "expo":
|
||||
self.timestep_features = ExpoFourierFeatures(timestep_features_dim, 0.5, 10000.0)
|
||||
else:
|
||||
self.timestep_features = FourierFeatures(1, timestep_features_dim, dtype=dtype, device=device)
|
||||
|
||||
self.to_timestep_embed = nn.Sequential(
|
||||
operations.Linear(timestep_features_dim, embed_dim, bias=True, dtype=dtype, device=device),
|
||||
@@ -781,6 +940,7 @@ class AudioDiffusionTransformer(nn.Module):
|
||||
cross_attn_cond=None,
|
||||
cross_attn_cond_mask=None,
|
||||
input_concat_cond=None,
|
||||
local_add_cond=None,
|
||||
global_embed=None,
|
||||
prepend_cond=None,
|
||||
prepend_cond_mask=None,
|
||||
@@ -802,9 +962,13 @@ class AudioDiffusionTransformer(nn.Module):
|
||||
prepend_cond = self.to_prepend_embed(prepend_cond)
|
||||
|
||||
prepend_inputs = prepend_cond
|
||||
prepend_length = prepend_cond.shape[1]
|
||||
if prepend_cond_mask is not None:
|
||||
prepend_mask = prepend_cond_mask
|
||||
|
||||
if local_add_cond is not None and local_add_cond.dim() == 3:
|
||||
local_add_cond = local_add_cond.permute(0, 2, 1)
|
||||
|
||||
if input_concat_cond is not None:
|
||||
|
||||
# Interpolate input_concat_cond to the same length as x
|
||||
@@ -850,7 +1014,7 @@ class AudioDiffusionTransformer(nn.Module):
|
||||
if self.transformer_type == "x-transformers":
|
||||
output = self.transformer(x, prepend_embeds=prepend_inputs, context=cross_attn_cond, context_mask=cross_attn_cond_mask, mask=mask, prepend_mask=prepend_mask, **extra_args, **kwargs)
|
||||
elif self.transformer_type == "continuous_transformer":
|
||||
output = self.transformer(x, prepend_embeds=prepend_inputs, context=cross_attn_cond, context_mask=cross_attn_cond_mask, mask=mask, prepend_mask=prepend_mask, return_info=return_info, **extra_args, **kwargs)
|
||||
output = self.transformer(x, prepend_embeds=prepend_inputs, context=cross_attn_cond, context_mask=cross_attn_cond_mask, mask=mask, prepend_mask=prepend_mask, return_info=return_info, local_add_cond=local_add_cond, **extra_args, **kwargs)
|
||||
|
||||
if return_info:
|
||||
output, info = output
|
||||
@@ -876,6 +1040,7 @@ class AudioDiffusionTransformer(nn.Module):
|
||||
context=None,
|
||||
context_mask=None,
|
||||
input_concat_cond=None,
|
||||
local_add_cond=None,
|
||||
global_embed=None,
|
||||
negative_global_embed=None,
|
||||
prepend_cond=None,
|
||||
@@ -890,6 +1055,7 @@ class AudioDiffusionTransformer(nn.Module):
|
||||
cross_attn_cond=context,
|
||||
cross_attn_cond_mask=context_mask,
|
||||
input_concat_cond=input_concat_cond,
|
||||
local_add_cond=local_add_cond,
|
||||
global_embed=global_embed,
|
||||
prepend_cond=prepend_cond,
|
||||
prepend_cond_mask=prepend_cond_mask,
|
||||
|
||||
@@ -31,15 +31,39 @@ def TimePositionalEmbedding(dim: int, out_features: int) -> nn.Module:
|
||||
)
|
||||
|
||||
|
||||
class ExpoFourierFeatures(nn.Module):
|
||||
"""Exponentially-spaced Fourier features (no learnable parameters)."""
|
||||
def __init__(self, dim, min_freq=0.5, max_freq=10000.0):
|
||||
super().__init__()
|
||||
self.dim = dim
|
||||
self.min_freq = min_freq
|
||||
self.max_freq = max_freq
|
||||
|
||||
def forward(self, t):
|
||||
in_dtype = t.dtype
|
||||
t = t.float()
|
||||
if t.dim() == 1:
|
||||
t = t.unsqueeze(-1)
|
||||
half_dim = self.dim // 2
|
||||
ramp = torch.linspace(0, 1, half_dim, device=t.device, dtype=torch.float32)
|
||||
freqs = torch.exp(ramp * (math.log(self.max_freq) - math.log(self.min_freq)) + math.log(self.min_freq))
|
||||
args = t * freqs * 2 * math.pi
|
||||
return torch.cat([args.cos(), args.sin()], dim=-1).to(in_dtype)
|
||||
|
||||
|
||||
class NumberEmbedder(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
features: int,
|
||||
dim: int = 256,
|
||||
fourier_features_type="learned",
|
||||
):
|
||||
super().__init__()
|
||||
self.features = features
|
||||
self.embedding = TimePositionalEmbedding(dim=dim, out_features=features)
|
||||
if fourier_features_type == "expo":
|
||||
self.embedding = nn.Sequential(ExpoFourierFeatures(dim=dim), comfy.ops.manual_cast.Linear(in_features=dim, out_features=features))
|
||||
else:
|
||||
self.embedding = TimePositionalEmbedding(dim=dim, out_features=features)
|
||||
|
||||
def forward(self, x: Union[List[float], Tensor]) -> Tensor:
|
||||
if not torch.is_tensor(x):
|
||||
@@ -77,14 +101,15 @@ class NumberConditioner(Conditioner):
|
||||
def __init__(self,
|
||||
output_dim: int,
|
||||
min_val: float=0,
|
||||
max_val: float=1
|
||||
max_val: float=1,
|
||||
fourier_features_type: str = "learned",
|
||||
):
|
||||
super().__init__(output_dim, output_dim)
|
||||
|
||||
self.min_val = min_val
|
||||
self.max_val = max_val
|
||||
|
||||
self.embedder = NumberEmbedder(features=output_dim)
|
||||
self.embedder = NumberEmbedder(features=output_dim, fourier_features_type=fourier_features_type)
|
||||
|
||||
def forward(self, floats, device=None):
|
||||
# Cast the inputs to floats
|
||||
|
||||
@@ -0,0 +1,533 @@
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
import comfy.ops
|
||||
import comfy.model_management
|
||||
from comfy.ldm.modules.attention import optimized_attention
|
||||
from comfy.ldm.audio.autoencoder import WNConv1d
|
||||
|
||||
ops = comfy.ops.disable_weight_init
|
||||
|
||||
class Transpose(nn.Module):
|
||||
def forward(self, x, **kwargs):
|
||||
return x.transpose(-2, -1)
|
||||
|
||||
|
||||
def _zero_pad_modulo_sequence(x, size, dim=-2):
|
||||
input_len = x.shape[dim]
|
||||
pad_len = (size - input_len % size) % size
|
||||
if pad_len > 0:
|
||||
pad_shape = list(x.shape)
|
||||
pad_shape[dim] = pad_len
|
||||
x = torch.cat([x, torch.zeros(pad_shape, device=x.device, dtype=x.dtype)], dim=dim)
|
||||
return x
|
||||
|
||||
|
||||
def _sliding_window_mask(seq_len, window, device, dtype):
|
||||
"""Additive attention mask enforcing a ±window local window (matches flash_attn window_size)."""
|
||||
i = torch.arange(seq_len, device=device).unsqueeze(1)
|
||||
j = torch.arange(seq_len, device=device).unsqueeze(0)
|
||||
out_of_window = (j - i).abs() > window
|
||||
return torch.where(
|
||||
out_of_window,
|
||||
torch.full((1,), torch.finfo(dtype).min / 4, device=device, dtype=dtype),
|
||||
torch.zeros(1, device=device, dtype=dtype),
|
||||
)
|
||||
|
||||
|
||||
class DynamicTanh(nn.Module):
|
||||
def __init__(self, dim, init_alpha=4.0, dtype=None, device=None, **kwargs):
|
||||
super().__init__()
|
||||
self.alpha = nn.Parameter(torch.empty(1, dtype=dtype, device=device))
|
||||
self.gamma = nn.Parameter(torch.empty(dim, dtype=dtype, device=device))
|
||||
self.beta = nn.Parameter(torch.empty(dim, dtype=dtype, device=device))
|
||||
|
||||
def forward(self, x):
|
||||
alpha = comfy.ops.cast_to_input(self.alpha, x)
|
||||
gamma = comfy.ops.cast_to_input(self.gamma, x)
|
||||
beta = comfy.ops.cast_to_input(self.beta, x)
|
||||
return gamma * torch.tanh(alpha * x) + beta
|
||||
|
||||
|
||||
class RotaryEmbedding(nn.Module):
|
||||
def __init__(self, dim, base=10000, base_rescale_factor=1., dtype=None, device=None):
|
||||
super().__init__()
|
||||
base = base * base_rescale_factor ** (dim / (dim - 2))
|
||||
self.register_buffer("inv_freq", torch.empty(dim // 2, dtype=dtype, device=device))
|
||||
|
||||
def forward_from_seq_len(self, seq_len, device, dtype=None):
|
||||
t = torch.arange(seq_len, device=device, dtype=torch.float32)
|
||||
return self.forward(t)
|
||||
|
||||
def forward(self, t):
|
||||
freqs = torch.outer(t.float(), comfy.model_management.cast_to(self.inv_freq, dtype=torch.float32, device=t.device))
|
||||
freqs = torch.cat((freqs, freqs), dim=-1)
|
||||
return freqs, 1.
|
||||
|
||||
|
||||
def _rotate_half(x):
|
||||
d = x.shape[-1] // 2
|
||||
return torch.cat((-x[..., d:], x[..., :d]), dim=-1)
|
||||
|
||||
|
||||
def _apply_rotary_pos_emb(t, freqs):
|
||||
out_dtype = t.dtype
|
||||
rot_dim = freqs.shape[-1]
|
||||
seq_len = t.shape[-2]
|
||||
freqs = freqs[-seq_len:]
|
||||
t_rot, t_pass = t[..., :rot_dim], t[..., rot_dim:]
|
||||
t_rot = t_rot * freqs.cos() + _rotate_half(t_rot) * freqs.sin()
|
||||
return torch.cat((t_rot.to(out_dtype), t_pass.to(out_dtype)), dim=-1)
|
||||
|
||||
|
||||
class Attention(nn.Module):
|
||||
def __init__(self, dim, dim_heads=64, qk_norm="none", qk_norm_eps=1e-6,
|
||||
differential=False, zero_init_output=True,
|
||||
dtype=None, device=None, operations=None, **kwargs):
|
||||
super().__init__()
|
||||
self.num_heads = dim // dim_heads
|
||||
self.differential = differential
|
||||
self.qk_norm = qk_norm
|
||||
|
||||
self.to_qkv = operations.Linear(
|
||||
dim, dim * (5 if differential else 3), bias=False, dtype=dtype, device=device)
|
||||
self.to_out = operations.Linear(dim, dim, bias=False, dtype=dtype, device=device)
|
||||
|
||||
if qk_norm == "dyt":
|
||||
self.q_norm = DynamicTanh(dim_heads, dtype=dtype, device=device)
|
||||
self.k_norm = DynamicTanh(dim_heads, dtype=dtype, device=device)
|
||||
elif qk_norm == "rms":
|
||||
self.q_norm = operations.RMSNorm(dim_heads, eps=qk_norm_eps, dtype=dtype, device=device)
|
||||
self.k_norm = operations.RMSNorm(dim_heads, eps=qk_norm_eps, dtype=dtype, device=device)
|
||||
|
||||
def forward(self, x, rotary_pos_emb=None, mask=None, **kwargs):
|
||||
B, N, _ = x.shape
|
||||
h = self.num_heads
|
||||
|
||||
qkv = self.to_qkv(x)
|
||||
if self.differential:
|
||||
q, k, v, q_diff, k_diff = qkv.chunk(5, dim=-1)
|
||||
del qkv
|
||||
q = q.view(B, N, h, -1).transpose(1, 2)
|
||||
k = k.view(B, N, h, -1).transpose(1, 2)
|
||||
v = v.view(B, N, h, -1).transpose(1, 2)
|
||||
q_diff = q_diff.view(B, N, h, -1).transpose(1, 2)
|
||||
k_diff = k_diff.view(B, N, h, -1).transpose(1, 2)
|
||||
else:
|
||||
q, k, v = qkv.chunk(3, dim=-1)
|
||||
del qkv
|
||||
q = q.view(B, N, h, -1).transpose(1, 2)
|
||||
k = k.view(B, N, h, -1).transpose(1, 2)
|
||||
v = v.view(B, N, h, -1).transpose(1, 2)
|
||||
|
||||
if self.qk_norm != "none":
|
||||
q_dtype, k_dtype = q.dtype, k.dtype
|
||||
q = self.q_norm(q).to(q_dtype)
|
||||
k = self.k_norm(k).to(k_dtype)
|
||||
if self.differential:
|
||||
q_diff = self.q_norm(q_diff).to(q_dtype)
|
||||
k_diff = self.k_norm(k_diff).to(k_dtype)
|
||||
|
||||
if rotary_pos_emb is not None:
|
||||
freqs, _ = rotary_pos_emb
|
||||
q_dtype, k_dtype = q.dtype, k.dtype
|
||||
q = _apply_rotary_pos_emb(q.float(), freqs).to(q_dtype)
|
||||
k = _apply_rotary_pos_emb(k.float(), freqs).to(k_dtype)
|
||||
if self.differential:
|
||||
q_diff = _apply_rotary_pos_emb(q_diff.float(), freqs).to(q_dtype)
|
||||
k_diff = _apply_rotary_pos_emb(k_diff.float(), freqs).to(k_dtype)
|
||||
|
||||
if self.differential:
|
||||
out = (optimized_attention(q, k, v, h, mask=mask, skip_reshape=True)
|
||||
- optimized_attention(q_diff, k_diff, v, h, mask=mask, skip_reshape=True))
|
||||
del q, k, v, q_diff, k_diff
|
||||
else:
|
||||
out = optimized_attention(q, k, v, h, mask=mask, skip_reshape=True)
|
||||
del q, k, v
|
||||
|
||||
return self.to_out(out)
|
||||
|
||||
|
||||
class _Sin(nn.Module):
|
||||
def forward(self, x):
|
||||
return torch.sin(3.14159265359 * x)
|
||||
|
||||
|
||||
class _GLU(nn.Module):
|
||||
def __init__(self, dim_in, dim_out, activation, dtype=None, device=None, operations=None):
|
||||
super().__init__()
|
||||
self.act = activation
|
||||
self.proj = operations.Linear(dim_in, dim_out * 2, dtype=dtype, device=device)
|
||||
|
||||
def forward(self, x):
|
||||
x = self.proj(x)
|
||||
x, gate = x.chunk(2, dim=-1)
|
||||
return x * self.act(gate)
|
||||
|
||||
|
||||
class FeedForward(nn.Module):
|
||||
def __init__(self, dim, mult=4, no_bias=False, zero_init_output=True,
|
||||
sinusoidal=False, dtype=None, device=None, operations=None, **kwargs):
|
||||
super().__init__()
|
||||
inner_dim = int(dim * mult)
|
||||
act = _Sin() if sinusoidal else nn.SiLU()
|
||||
self.ff = nn.Sequential(
|
||||
_GLU(dim, inner_dim, act, dtype=dtype, device=device, operations=operations),
|
||||
nn.Identity(),
|
||||
operations.Linear(inner_dim, dim, bias=not no_bias, dtype=dtype, device=device),
|
||||
nn.Identity(),
|
||||
)
|
||||
|
||||
def forward(self, x, **kwargs):
|
||||
return self.ff(x)
|
||||
|
||||
|
||||
class TransformerBlock(nn.Module):
|
||||
def __init__(self, dim, dim_heads=64, causal=False, zero_init_branch_outputs=True,
|
||||
norm_type="dyt", add_rope=False, attn_kwargs=None, ff_kwargs=None,
|
||||
norm_kwargs=None, dtype=None, device=None, operations=None, **kwargs):
|
||||
super().__init__()
|
||||
if attn_kwargs is None:
|
||||
attn_kwargs = {}
|
||||
if ff_kwargs is None:
|
||||
ff_kwargs = {}
|
||||
if norm_kwargs is None:
|
||||
norm_kwargs = {}
|
||||
dim_heads = min(dim_heads, dim)
|
||||
|
||||
Norm = DynamicTanh if norm_type == "dyt" else operations.RMSNorm
|
||||
norm_kw = {**norm_kwargs, "dtype": dtype, "device": device}
|
||||
|
||||
self.pre_norm = Norm(dim, **norm_kw)
|
||||
self.self_attn = Attention(dim, dim_heads=dim_heads,
|
||||
zero_init_output=zero_init_branch_outputs,
|
||||
dtype=dtype, device=device, operations=operations,
|
||||
**attn_kwargs)
|
||||
self.ff_norm = Norm(dim, **norm_kw)
|
||||
self.ff = FeedForward(dim, zero_init_output=zero_init_branch_outputs,
|
||||
dtype=dtype, device=device, operations=operations, **ff_kwargs)
|
||||
self.rope = RotaryEmbedding(dim_heads // 2, dtype=dtype, device=device) if add_rope else None
|
||||
|
||||
def forward(self, x, mask=None, **kwargs):
|
||||
rope = self.rope.forward_from_seq_len(x.shape[-2], device=x.device) \
|
||||
if self.rope is not None else None
|
||||
x = x + self.self_attn(self.pre_norm(x), rotary_pos_emb=rope, mask=mask)
|
||||
x = x + self.ff(self.ff_norm(x))
|
||||
return x
|
||||
|
||||
|
||||
class TransformerResamplingBlock(nn.Module):
|
||||
def __init__(self, in_channels, out_channels, stride, type="encoder",
|
||||
transformer_depth=3, dim_heads=128, differential=True,
|
||||
sliding_window=None, chunk_size=128, chunk_midpoint_shift=False,
|
||||
dyt=True, ff_mult=3, mapping_bias=True, variable_stride=False,
|
||||
sinusoidal_blocks=0, conv_mapping=False, dtype=None, device=None, operations=None, **kwargs):
|
||||
super().__init__()
|
||||
if type not in ("encoder", "decoder"):
|
||||
raise ValueError(f"type must be 'encoder' or 'decoder', got {type!r}")
|
||||
|
||||
self.type = type
|
||||
self.stride = stride
|
||||
self.chunk_size = chunk_size
|
||||
self.chunk_midpoint_shift = chunk_midpoint_shift
|
||||
self.variable_stride = variable_stride
|
||||
self.transformer_depth = transformer_depth
|
||||
|
||||
transformer_dim = out_channels if type == "encoder" else in_channels
|
||||
|
||||
self.mapping = (WNConv1d(in_channels, out_channels, 3 if conv_mapping else 1, padding="same", bias=mapping_bias)
|
||||
if in_channels != out_channels else nn.Identity())
|
||||
|
||||
self.sliding_window_latents = sliding_window
|
||||
self.sliding_window_seq = self._get_sliding_window_size(sliding_window, stride)
|
||||
self.input_seg_size, self.output_seg_size, self.sub_chunk_size = self._get_seg_sizes(stride)
|
||||
|
||||
token_seq = 1 if variable_stride else self.output_seg_size
|
||||
self.new_tokens = nn.Parameter(torch.empty(1, token_seq, transformer_dim, dtype=dtype, device=device))
|
||||
|
||||
norm_type = "dyt" if dyt else "rms_norm"
|
||||
attn_kwargs = {"qk_norm": "dyt" if dyt else "rms", "qk_norm_eps": 1e-3,
|
||||
"differential": differential}
|
||||
norm_kwargs = {"eps": 1e-3}
|
||||
transformers = []
|
||||
for i in range(transformer_depth):
|
||||
sinusoidal = (transformer_depth - i) < sinusoidal_blocks
|
||||
transformers.append(TransformerBlock(
|
||||
transformer_dim,
|
||||
dim_heads=dim_heads,
|
||||
causal=False,
|
||||
zero_init_branch_outputs=True,
|
||||
norm_type=norm_type,
|
||||
add_rope=True,
|
||||
attn_kwargs=attn_kwargs,
|
||||
ff_kwargs={"mult": ff_mult, "no_bias": False, "sinusoidal": sinusoidal},
|
||||
norm_kwargs=norm_kwargs,
|
||||
dtype=dtype, device=device, operations=operations,
|
||||
))
|
||||
self.transformers = nn.ModuleList(transformers)
|
||||
|
||||
def _get_sliding_window_size(self, window, stride, prepend_cond_length=0):
|
||||
if window is None:
|
||||
return None
|
||||
return [w * (stride + 1 + prepend_cond_length) for w in window]
|
||||
|
||||
def _get_seg_sizes(self, stride, prepend_cond_length=0):
|
||||
sub_chunk_size = stride + 1 + prepend_cond_length
|
||||
input_seg_size = stride if self.type == "encoder" else 1
|
||||
output_seg_size = 1 if self.type == "encoder" else stride
|
||||
return input_seg_size, output_seg_size, sub_chunk_size
|
||||
|
||||
def forward(self, x, stride=None, **kwargs):
|
||||
B = x.shape[0]
|
||||
|
||||
if stride is None:
|
||||
input_seg = self.input_seg_size
|
||||
output_seg = self.output_seg_size
|
||||
sub_chunk = self.sub_chunk_size
|
||||
sliding_window = self.sliding_window_seq
|
||||
else:
|
||||
input_seg, output_seg, sub_chunk = self._get_seg_sizes(stride)
|
||||
sliding_window = self._get_sliding_window_size(self.sliding_window_latents, stride)
|
||||
|
||||
if self.type == "encoder":
|
||||
if self.transformer_depth > 0:
|
||||
pad_mod = self.chunk_size if sliding_window is None else input_seg
|
||||
x = _zero_pad_modulo_sequence(x, pad_mod, dim=-1)
|
||||
x = self.mapping(x)
|
||||
|
||||
if self.transformer_depth > 0:
|
||||
x = x.permute(0, 2, 1)
|
||||
|
||||
if self.type != "encoder":
|
||||
pad_mod = 1 if sliding_window is not None else (
|
||||
self.chunk_size // (stride if stride is not None else self.stride))
|
||||
x = _zero_pad_modulo_sequence(x, pad_mod)
|
||||
|
||||
C = x.shape[2]
|
||||
x = x.reshape(-1, input_seg, C)
|
||||
|
||||
new_tokens = self.new_tokens.expand(x.shape[0], output_seg, -1)
|
||||
x = torch.cat([x, comfy.ops.cast_to_input(new_tokens, x)], dim=-2)
|
||||
del new_tokens
|
||||
|
||||
x = x.reshape(B, -1, C)
|
||||
|
||||
if sliding_window is None:
|
||||
eff_chunk = self.chunk_size + self.chunk_size // (stride if stride is not None else self.stride)
|
||||
|
||||
if sliding_window is None and self.chunk_midpoint_shift:
|
||||
split = self.transformer_depth // 2
|
||||
shift = eff_chunk // 2
|
||||
|
||||
x = x.reshape(-1, eff_chunk, C)
|
||||
for layer in self.transformers[:split]:
|
||||
x = layer(x)
|
||||
x = x.reshape(B, -1, C)
|
||||
|
||||
shifted = torch.cat([x[:, :shift, :], x, x[:, -shift:, :]], dim=1)
|
||||
del x
|
||||
x = shifted.reshape(-1, eff_chunk, C)
|
||||
del shifted
|
||||
for layer in self.transformers[split:]:
|
||||
x = layer(x)
|
||||
x = x.reshape(B, -1, C)
|
||||
x = x[:, shift:-shift, :]
|
||||
elif sliding_window is None:
|
||||
x = x.reshape(-1, eff_chunk, C)
|
||||
for layer in self.transformers:
|
||||
x = layer(x)
|
||||
x = x.reshape(B, -1, C)
|
||||
else:
|
||||
attn_mask = _sliding_window_mask(x.shape[1], sliding_window[0], x.device, x.dtype)
|
||||
for layer in self.transformers:
|
||||
x = layer(x, mask=attn_mask)
|
||||
|
||||
x = x.reshape(-1, sub_chunk, C)
|
||||
x = x[:, -output_seg:, :]
|
||||
x = x.reshape(B, -1, C).transpose(1, 2)
|
||||
|
||||
if self.type == "decoder":
|
||||
x = self.mapping(x)
|
||||
|
||||
return x
|
||||
|
||||
|
||||
class SAMEEncoder(nn.Module):
|
||||
def __init__(self, in_channels=2, channels=128, latent_dim=32,
|
||||
c_mults=(1, 2, 4, 8), strides=(2, 4, 8, 8),
|
||||
transformer_depths=(3, 3, 3, 3),
|
||||
dtype=None, device=None, operations=None, **kwargs):
|
||||
super().__init__()
|
||||
channel_dims = [in_channels] + [channels * c for c in c_mults]
|
||||
layers = []
|
||||
for i in range(len(c_mults)):
|
||||
layers.append(TransformerResamplingBlock(
|
||||
in_channels=channel_dims[i], out_channels=channel_dims[i + 1],
|
||||
stride=strides[i], type="encoder",
|
||||
transformer_depth=transformer_depths[i],
|
||||
dtype=dtype, device=device, operations=operations, **kwargs))
|
||||
layers += [
|
||||
Transpose(),
|
||||
operations.Linear(channel_dims[-1], latent_dim, dtype=dtype, device=device),
|
||||
Transpose(),
|
||||
]
|
||||
self.layers = nn.ModuleList(layers)
|
||||
|
||||
def forward(self, x, **kwargs):
|
||||
for layer in self.layers:
|
||||
x = layer(x)
|
||||
return x
|
||||
|
||||
|
||||
class SAMEDecoder(nn.Module):
|
||||
def __init__(self, out_channels=2, channels=128, latent_dim=32,
|
||||
c_mults=(1, 2, 4, 8), strides=(2, 4, 8, 8),
|
||||
transformer_depths=(3, 3, 3, 3), sinusoidal_blocks=None,
|
||||
dtype=None, device=None, operations=None, **kwargs):
|
||||
super().__init__()
|
||||
if sinusoidal_blocks is None:
|
||||
sinusoidal_blocks = [0] * len(c_mults)
|
||||
channel_dims = [out_channels] + [channels * c for c in c_mults]
|
||||
layers = [
|
||||
Transpose(),
|
||||
operations.Linear(latent_dim, channel_dims[-1], dtype=dtype, device=device),
|
||||
Transpose(),
|
||||
]
|
||||
for i in range(len(c_mults) - 1, -1, -1):
|
||||
layers.append(TransformerResamplingBlock(
|
||||
in_channels=channel_dims[i + 1], out_channels=channel_dims[i],
|
||||
stride=strides[i], type="decoder",
|
||||
transformer_depth=transformer_depths[i],
|
||||
sinusoidal_blocks=sinusoidal_blocks[i],
|
||||
dtype=dtype, device=device, operations=operations, **kwargs))
|
||||
self.layers = nn.ModuleList(layers)
|
||||
|
||||
def forward(self, x, **kwargs):
|
||||
for layer in self.layers:
|
||||
x = layer(x)
|
||||
return x
|
||||
|
||||
|
||||
class SoftNormBottleneck(nn.Module):
|
||||
def __init__(self, dim=32, noise_augment_dim=0, noise_regularize=False,
|
||||
auto_scale=False, freeze=False, dtype=None, device=None, **kwargs):
|
||||
super().__init__()
|
||||
self.noise_augment_dim = noise_augment_dim
|
||||
self.noise_regularize = noise_regularize
|
||||
self.scaling_factor = nn.Parameter(torch.empty(1, dim, 1, dtype=dtype, device=device))
|
||||
self.bias = nn.Parameter(torch.empty(1, dim, 1, dtype=dtype, device=device))
|
||||
self.noise_scaling_factor = nn.Parameter(torch.empty(1, noise_augment_dim, 1, dtype=dtype, device=device))
|
||||
if auto_scale:
|
||||
self.register_parameter("running_std", nn.Parameter(
|
||||
torch.empty(1, dtype=dtype, device=device), requires_grad=False))
|
||||
if freeze:
|
||||
for p in self.parameters():
|
||||
p.requires_grad = False
|
||||
|
||||
def encode(self, x, return_info=False, **kwargs):
|
||||
x = x * comfy.ops.cast_to_input(self.scaling_factor, x) \
|
||||
+ comfy.ops.cast_to_input(self.bias, x)
|
||||
if hasattr(self, "running_std"):
|
||||
x = x / comfy.ops.cast_to_input(self.running_std, x)
|
||||
if return_info:
|
||||
return x, {}
|
||||
return x
|
||||
|
||||
def decode(self, x, **kwargs):
|
||||
if hasattr(self, "running_std"):
|
||||
x = x * comfy.ops.cast_to_input(self.running_std, x)
|
||||
if self.noise_regularize:
|
||||
scaling = self.running_std if hasattr(self, "running_std") \
|
||||
else x.std(dim=-1, keepdim=True)
|
||||
noise = torch.randn_like(x) * comfy.ops.cast_to_input(scaling, x) * 1e-3
|
||||
x = x + noise
|
||||
if self.noise_augment_dim > 0:
|
||||
noise = comfy.ops.cast_to_input(self.noise_scaling_factor, x) * torch.randn(
|
||||
x.shape[0], self.noise_augment_dim, x.shape[-1], device=x.device, dtype=x.dtype)
|
||||
x = torch.cat([x, noise], dim=1)
|
||||
return x
|
||||
|
||||
|
||||
class PatchedPretransform(nn.Module):
|
||||
def __init__(self, channels, patch_size, **kwargs):
|
||||
super().__init__()
|
||||
self.channels = channels
|
||||
self.patch_size = patch_size
|
||||
self.enable_grad = False
|
||||
|
||||
def _pad(self, x):
|
||||
pad_len = (self.patch_size - x.shape[-1] % self.patch_size) % self.patch_size
|
||||
if pad_len > 0:
|
||||
x = torch.cat([x, torch.zeros_like(x[:, :, :pad_len])], dim=-1)
|
||||
return x
|
||||
|
||||
def encode(self, x):
|
||||
x = self._pad(x)
|
||||
B, C, T = x.shape
|
||||
h = self.patch_size
|
||||
L = T // h
|
||||
# b c (l h) -> b (c h) l
|
||||
return x.reshape(B, C, L, h).permute(0, 1, 3, 2).reshape(B, C * h, L)
|
||||
|
||||
def decode(self, x):
|
||||
B, Ch, L = x.shape
|
||||
h = self.patch_size
|
||||
C = Ch // h
|
||||
# b (c h) l -> b c (l h)
|
||||
return x.reshape(B, C, h, L).permute(0, 1, 3, 2).reshape(B, C, L * h)
|
||||
|
||||
|
||||
class SA3AudioVAE(nn.Module):
|
||||
"""SA3 VAE. State dict keys match checkpoint after stripping 'pretransform.model.'"""
|
||||
|
||||
def __init__(self, channels=256, transformer_depths=12, sinusoidal_blocks=8,
|
||||
sliding_window=None, decoder_conv_mapping=False,
|
||||
chunk_size=128, chunk_midpoint_shift=False,
|
||||
dtype=None, device=None, operations=None):
|
||||
super().__init__()
|
||||
if operations is None:
|
||||
operations = ops
|
||||
|
||||
self.pretransform = PatchedPretransform(channels=2, patch_size=256)
|
||||
|
||||
common_kwargs = dict(
|
||||
differential=True, dyt=True, dim_heads=64,
|
||||
sliding_window=sliding_window, variable_stride=True,
|
||||
chunk_size=chunk_size, chunk_midpoint_shift=chunk_midpoint_shift,
|
||||
dtype=dtype, device=device, operations=operations,
|
||||
)
|
||||
self.encoder = SAMEEncoder(
|
||||
in_channels=512, channels=channels, c_mults=[6], strides=[16],
|
||||
latent_dim=256, transformer_depths=[transformer_depths],
|
||||
conv_mapping=False, **common_kwargs,
|
||||
)
|
||||
self.decoder = SAMEDecoder(
|
||||
out_channels=512, channels=channels, c_mults=[6], strides=[16],
|
||||
latent_dim=256, transformer_depths=[transformer_depths], sinusoidal_blocks=[sinusoidal_blocks],
|
||||
conv_mapping=decoder_conv_mapping, **common_kwargs,
|
||||
)
|
||||
self.bottleneck = SoftNormBottleneck(
|
||||
dim=256, noise_augment_dim=0, noise_regularize=True,
|
||||
auto_scale=True, freeze=True,
|
||||
dtype=dtype, device=device,
|
||||
)
|
||||
|
||||
@torch.no_grad()
|
||||
def _pretransform_encode(self, x):
|
||||
return self.pretransform.encode(x)
|
||||
|
||||
@torch.no_grad()
|
||||
def _pretransform_decode(self, x):
|
||||
return self.pretransform.decode(x)
|
||||
|
||||
def encode(self, x):
|
||||
x = self._pretransform_encode(x)
|
||||
x = self.encoder(x)
|
||||
x = self.bottleneck.encode(x)
|
||||
return x
|
||||
|
||||
def decode(self, x):
|
||||
x = self.bottleneck.decode(x)
|
||||
x = self.decoder(x)
|
||||
x = self._pretransform_decode(x)
|
||||
return x
|
||||
@@ -0,0 +1,41 @@
|
||||
"""HiDream-O1 two-pass attention: tokens [0, ar_len) are causal, [ar_len, T)
|
||||
attend full K/V. Splitting Q at the boundary avoids the (B, 1, T, T) additive
|
||||
mask the general-purpose path would build (~500 MB at T~16K) and lets the
|
||||
gen half hit the user's preferred backend via optimized_attention.
|
||||
"""
|
||||
|
||||
import torch
|
||||
|
||||
import comfy.ops
|
||||
from comfy.ldm.modules.attention import optimized_attention
|
||||
|
||||
|
||||
def make_two_pass_attention(ar_len: int, transformer_options=None):
|
||||
"""Build a two-pass attention callable. AR pass uses SDPA-causal directly, gen pass routes through optimized_attention.
|
||||
The AR pass goes through SDPA directand bypasses wrappers, it is only ~1% of T at typical edit sizes.
|
||||
"""
|
||||
|
||||
def two_pass_attention(q, k, v, heads, **kwargs):
|
||||
B, H, T, D = q.shape
|
||||
|
||||
if T < k.shape[2]: # KV-cache hot path: Q is shorter than K/V (cached AR prefix is in K/V only), all fresh Q positions are in the gen region, single full-attention call
|
||||
out = optimized_attention(q, k, v, heads, mask=None, skip_reshape=True, skip_output_reshape=True, transformer_options=transformer_options)
|
||||
elif ar_len >= T:
|
||||
out = comfy.ops.scaled_dot_product_attention(q, k, v, attn_mask=None, dropout_p=0.0, is_causal=True)
|
||||
elif ar_len <= 0:
|
||||
out = optimized_attention(q, k, v, heads, mask=None, skip_reshape=True, skip_output_reshape=True, transformer_options=transformer_options)
|
||||
else:
|
||||
out_ar = comfy.ops.scaled_dot_product_attention(
|
||||
q[:, :, :ar_len], k[:, :, :ar_len], v[:, :, :ar_len],
|
||||
attn_mask=None, dropout_p=0.0, is_causal=True,
|
||||
)
|
||||
out_gen = optimized_attention(
|
||||
q[:, :, ar_len:], k, v, heads,
|
||||
mask=None, skip_reshape=True, skip_output_reshape=True,
|
||||
transformer_options=transformer_options,
|
||||
)
|
||||
out = torch.cat([out_ar, out_gen], dim=2)
|
||||
|
||||
return out.transpose(1, 2).reshape(B, T, H * D)
|
||||
|
||||
return two_pass_attention
|
||||
@@ -0,0 +1,230 @@
|
||||
"""HiDream-O1 conditioning prep — ref-image dual path + extra_conds assembly.
|
||||
|
||||
Each ref image goes through two paths: a 32x32 patchified stream concatenated
|
||||
to the noised target, and a Qwen3-VL ViT path producing tokens that scatter
|
||||
into input_ids at <|image_pad|> positions.
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
import torch
|
||||
|
||||
import comfy.utils
|
||||
from comfy.text_encoders.qwen_vl import process_qwen2vl_images
|
||||
|
||||
from .utils import (PATCH_SIZE, calculate_dimensions, cond_image_size, ref_max_size, resize_tensor)
|
||||
|
||||
# Qwen3-VL ViT preprocessing constants (preprocessor_config.json).
|
||||
VIT_PATCH = 16
|
||||
VIT_MERGE = 2
|
||||
VIT_IMAGE_MEAN = [0.5, 0.5, 0.5]
|
||||
VIT_IMAGE_STD = [0.5, 0.5, 0.5]
|
||||
|
||||
|
||||
def prepare_ref_images(
|
||||
ref_images: List[torch.Tensor],
|
||||
target_h: int,
|
||||
target_w: int,
|
||||
device: torch.device,
|
||||
dtype: torch.dtype,
|
||||
):
|
||||
"""Build the dual-path tensors for K reference images at (target_h, target_w).
|
||||
|
||||
Returns None for K=0, else a dict with ref_patches, ref_pixel_values,
|
||||
ref_image_grid_thw, per_ref_vit_tokens, per_ref_patch_grids.
|
||||
"""
|
||||
K = len(ref_images)
|
||||
if K == 0:
|
||||
return None
|
||||
max_size = ref_max_size(max(target_h, target_w), K)
|
||||
cis = cond_image_size(K)
|
||||
|
||||
refs_t = [img[0].clamp(0, 1).permute(2, 0, 1).unsqueeze(0).contiguous().float() for img in ref_images]
|
||||
refs_t = [resize_tensor(t, max_size, PATCH_SIZE) for t in refs_t]
|
||||
|
||||
# 32-patch path.
|
||||
ref_patches_per = []
|
||||
per_ref_patch_grids = []
|
||||
for t in refs_t:
|
||||
t_norm = (t.squeeze(0) - 0.5) / 0.5 # (3, H, W) in [-1, 1]
|
||||
h_p, w_p = t_norm.shape[-2] // PATCH_SIZE, t_norm.shape[-1] // PATCH_SIZE
|
||||
per_ref_patch_grids.append((h_p, w_p))
|
||||
patches = (
|
||||
t_norm.reshape(3, h_p, PATCH_SIZE, w_p, PATCH_SIZE)
|
||||
.permute(1, 3, 0, 2, 4)
|
||||
.reshape(h_p * w_p, 3 * PATCH_SIZE * PATCH_SIZE)
|
||||
)
|
||||
ref_patches_per.append(patches)
|
||||
ref_patches = torch.cat(ref_patches_per, dim=0).unsqueeze(0).to(device=device, dtype=dtype)
|
||||
|
||||
# ViT path.
|
||||
refs_vlm_t = []
|
||||
for t in refs_t:
|
||||
_, _, h, w = t.shape
|
||||
cond_w, cond_h = calculate_dimensions(cis, w / h)
|
||||
cond_w = max(cond_w, VIT_PATCH * VIT_MERGE)
|
||||
cond_h = max(cond_h, VIT_PATCH * VIT_MERGE)
|
||||
refs_vlm_t.append(comfy.utils.common_upscale(t, cond_w, cond_h, "lanczos", "disabled"))
|
||||
|
||||
pv_list, grid_list, per_ref_vit_tokens = [], [], []
|
||||
for t_v in refs_vlm_t:
|
||||
pv, grid_thw = process_qwen2vl_images(
|
||||
t_v.permute(0, 2, 3, 1),
|
||||
min_pixels=0, max_pixels=10**12,
|
||||
patch_size=VIT_PATCH, merge_size=VIT_MERGE,
|
||||
image_mean=VIT_IMAGE_MEAN, image_std=VIT_IMAGE_STD,
|
||||
)
|
||||
grid_thw = grid_thw[0]
|
||||
pv_list.append(pv.to(device=device, dtype=dtype))
|
||||
grid_list.append(grid_thw.to(device=device))
|
||||
# Post-merge token count = number of <|image_pad|> tokens this image expands to in input_ids.
|
||||
gh, gw = int(grid_thw[1].item()), int(grid_thw[2].item())
|
||||
per_ref_vit_tokens.append((gh // VIT_MERGE) * (gw // VIT_MERGE))
|
||||
|
||||
return {
|
||||
"ref_patches": ref_patches,
|
||||
"ref_pixel_values": torch.cat(pv_list, dim=0),
|
||||
"ref_image_grid_thw": torch.stack(grid_list, dim=0),
|
||||
"per_ref_vit_tokens": per_ref_vit_tokens,
|
||||
"per_ref_patch_grids": per_ref_patch_grids,
|
||||
}
|
||||
|
||||
|
||||
def build_ref_input_ids(
|
||||
text_input_ids: torch.Tensor,
|
||||
per_ref_vit_tokens: List[int],
|
||||
image_token_id: int,
|
||||
vision_start_id: int,
|
||||
vision_end_id: int,
|
||||
):
|
||||
"""Splice [vision_start, image_pad*N, vision_end] blocks into input_ids
|
||||
after the [im_start, user, \\n] prefix (matches original chat template).
|
||||
"""
|
||||
ids = text_input_ids[0].tolist()
|
||||
inserted = []
|
||||
for n_pad in per_ref_vit_tokens:
|
||||
inserted.extend([vision_start_id] + [image_token_id] * n_pad + [vision_end_id])
|
||||
new_ids = ids[:3] + inserted + ids[3:] # 3 = len([im_start, user, \n])
|
||||
return torch.tensor([new_ids], dtype=text_input_ids.dtype, device=text_input_ids.device)
|
||||
|
||||
|
||||
def build_extra_conds(
|
||||
text_input_ids: torch.Tensor,
|
||||
noise: torch.Tensor,
|
||||
ref_images: List[torch.Tensor] = None,
|
||||
target_patch_size: int = 32,
|
||||
):
|
||||
"""Assemble all conditioning tensors for HiDreamO1Transformer.forward:
|
||||
input_ids (with ref-vision tokens spliced in for the edit/IP path),
|
||||
position_ids (MRoPE), token_types, vinput_mask, plus the ref
|
||||
dual-path tensors when refs are provided.
|
||||
"""
|
||||
from .utils import get_rope_index_fix_point
|
||||
from comfy.text_encoders.hidream_o1 import (
|
||||
IMAGE_TOKEN_ID, VISION_START_ID, VISION_END_ID,
|
||||
)
|
||||
|
||||
if text_input_ids.dim() == 1:
|
||||
text_input_ids = text_input_ids.unsqueeze(0)
|
||||
text_input_ids = text_input_ids.long().to(noise.device)
|
||||
B = noise.shape[0]
|
||||
if text_input_ids.shape[0] == 1 and B > 1:
|
||||
text_input_ids = text_input_ids.expand(B, -1)
|
||||
|
||||
H, W = noise.shape[-2], noise.shape[-1]
|
||||
h_p, w_p = H // target_patch_size, W // target_patch_size
|
||||
image_len = h_p * w_p
|
||||
image_grid_thw_tgt = torch.tensor(
|
||||
[[1, h_p, w_p]], dtype=torch.long, device=text_input_ids.device,
|
||||
)
|
||||
|
||||
out = {}
|
||||
if ref_images:
|
||||
ref = prepare_ref_images(ref_images, H, W, device=noise.device, dtype=noise.dtype)
|
||||
text_input_ids = build_ref_input_ids(
|
||||
text_input_ids, ref["per_ref_vit_tokens"],
|
||||
IMAGE_TOKEN_ID, VISION_START_ID, VISION_END_ID,
|
||||
)
|
||||
new_txt_len = text_input_ids.shape[1]
|
||||
|
||||
# Each ref's patchified stream gets a [vision_start, image_pad*N-1]
|
||||
# block in the position-id stream after the noised target.
|
||||
ref_grid_lengths = [hp * wp for (hp, wp) in ref["per_ref_patch_grids"]]
|
||||
tgt_vision = torch.full((1, image_len), IMAGE_TOKEN_ID,
|
||||
dtype=text_input_ids.dtype, device=text_input_ids.device)
|
||||
tgt_vision[:, 0] = VISION_START_ID
|
||||
ref_vision_blocks = []
|
||||
for rl in ref_grid_lengths:
|
||||
blk = torch.full((1, rl), IMAGE_TOKEN_ID,
|
||||
dtype=text_input_ids.dtype, device=text_input_ids.device)
|
||||
blk[:, 0] = VISION_START_ID
|
||||
ref_vision_blocks.append(blk)
|
||||
ref_vision_cat = torch.cat([tgt_vision] + ref_vision_blocks, dim=1)
|
||||
input_ids_pad = torch.cat([text_input_ids, ref_vision_cat], dim=-1)
|
||||
total_ref_patches_len = sum(ref_grid_lengths)
|
||||
total_len = new_txt_len + image_len + total_ref_patches_len
|
||||
|
||||
# K (ViT, post-merge) + 1 (target) + K (ref-patches) image grids.
|
||||
K = len(ref_images)
|
||||
igthw_cond = ref["ref_image_grid_thw"].clone()
|
||||
igthw_cond[:, 1] //= 2
|
||||
igthw_cond[:, 2] //= 2
|
||||
image_grid_thw_ref = torch.tensor(
|
||||
[[1, hp, wp] for (hp, wp) in ref["per_ref_patch_grids"]],
|
||||
dtype=torch.long, device=text_input_ids.device,
|
||||
)
|
||||
igthw_all = torch.cat([
|
||||
igthw_cond.to(text_input_ids.device),
|
||||
image_grid_thw_tgt,
|
||||
image_grid_thw_ref,
|
||||
], dim=0)
|
||||
position_ids, _ = get_rope_index_fix_point(
|
||||
spatial_merge_size=1,
|
||||
image_token_id=IMAGE_TOKEN_ID,
|
||||
vision_start_token_id=VISION_START_ID,
|
||||
input_ids=input_ids_pad, image_grid_thw=igthw_all,
|
||||
attention_mask=None,
|
||||
skip_vision_start_token=[0] * K + [1] + [1] * K,
|
||||
fix_point=4096,
|
||||
)
|
||||
|
||||
# tms + target_image + ref_patches are all gen.
|
||||
tms_pos = new_txt_len - 1
|
||||
ar_len = tms_pos
|
||||
token_types = torch.zeros(B, total_len, dtype=torch.long, device=noise.device)
|
||||
token_types[:, tms_pos:] = 1
|
||||
vinput_mask = torch.zeros(B, total_len, dtype=torch.bool, device=noise.device)
|
||||
vinput_mask[:, new_txt_len:] = True
|
||||
|
||||
# Leading batch dim sidesteps CONDRegular.process_cond's repeat_to_batch_size truncation
|
||||
out["ref_pixel_values"] = ref["ref_pixel_values"].unsqueeze(0)
|
||||
out["ref_image_grid_thw"] = ref["ref_image_grid_thw"].unsqueeze(0)
|
||||
out["ref_patches"] = ref["ref_patches"]
|
||||
else:
|
||||
# T2I: text + noised target only, vision_start replaces the first image token
|
||||
txt_len = text_input_ids.shape[1]
|
||||
total_len = txt_len + image_len
|
||||
vision_tokens = torch.full((B, image_len), IMAGE_TOKEN_ID,
|
||||
dtype=text_input_ids.dtype, device=text_input_ids.device)
|
||||
vision_tokens[:, 0] = VISION_START_ID
|
||||
input_ids_pad = torch.cat([text_input_ids, vision_tokens], dim=-1)
|
||||
position_ids, _ = get_rope_index_fix_point(
|
||||
spatial_merge_size=1,
|
||||
image_token_id=IMAGE_TOKEN_ID,
|
||||
vision_start_token_id=VISION_START_ID,
|
||||
input_ids=input_ids_pad, image_grid_thw=image_grid_thw_tgt,
|
||||
attention_mask=None,
|
||||
skip_vision_start_token=[1],
|
||||
)
|
||||
ar_len = txt_len - 1
|
||||
token_types = torch.zeros(B, total_len, dtype=torch.long, device=noise.device)
|
||||
token_types[:, ar_len:] = 1
|
||||
vinput_mask = torch.zeros(B, total_len, dtype=torch.bool, device=noise.device)
|
||||
vinput_mask[:, txt_len:] = True
|
||||
|
||||
out["input_ids"] = text_input_ids
|
||||
out["position_ids"] = position_ids[:, 0].unsqueeze(0) # Collapse position_ids batch and add a leading dim so CONDRegular's batch-resize doesn't truncate the 3-axis MRoPE dim
|
||||
out["token_types"] = token_types
|
||||
out["vinput_mask"] = vinput_mask
|
||||
out["ar_len"] = ar_len
|
||||
return out
|
||||
@@ -0,0 +1,306 @@
|
||||
"""HiDream-O1-Image transformer.
|
||||
|
||||
Pixel-space DiT built on Qwen3-VL: the vision tower (Qwen35VisionModel)
|
||||
encodes ref images, the Qwen3-VL-8B decoder (Llama2_ with interleaved MRoPE)
|
||||
processes a unified text+image sequence, and 32x32 patch embed/unembed
|
||||
shims map raw RGB in and out of LLM hidden space. The Qwen3-VL deepstack
|
||||
mergers go unused — their weights are dropped at load.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
import einops
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
import comfy.patcher_extension
|
||||
from comfy.ldm.modules.diffusionmodules.mmdit import TimestepEmbedder
|
||||
from comfy.text_encoders.llama import Llama2_
|
||||
from comfy.text_encoders.qwen35 import Qwen35VisionModel
|
||||
|
||||
from .attention import make_two_pass_attention
|
||||
|
||||
|
||||
IMAGE_TOKEN_ID = 151655 # Qwen3-VL <|image_pad|>
|
||||
TMS_TOKEN_ID = 151673 # HiDream-O1 <|tms_token|>
|
||||
PATCH_SIZE = 32
|
||||
|
||||
|
||||
@dataclass
|
||||
class HiDreamO1TextConfig:
|
||||
"""Qwen3-VL-8B text-decoder dims (matches public Qwen3-VL-8B-Instruct)."""
|
||||
vocab_size: int = 151936
|
||||
hidden_size: int = 4096
|
||||
intermediate_size: int = 12288
|
||||
num_hidden_layers: int = 36
|
||||
num_attention_heads: int = 32
|
||||
num_key_value_heads: int = 8
|
||||
head_dim: int = 128
|
||||
max_position_embeddings: int = 128000
|
||||
rms_norm_eps: float = 1e-6
|
||||
rope_theta: float = 5000000.0
|
||||
rope_scale: Optional[float] = None
|
||||
rope_dims: List[int] = field(default_factory=lambda: [24, 20, 20])
|
||||
interleaved_mrope: bool = True
|
||||
transformer_type: str = "llama"
|
||||
rms_norm_add: bool = False
|
||||
mlp_activation: str = "silu"
|
||||
qkv_bias: bool = False
|
||||
q_norm: str = "gemma3"
|
||||
k_norm: str = "gemma3"
|
||||
final_norm: bool = True
|
||||
lm_head: bool = False
|
||||
stop_tokens: List[int] = field(default_factory=lambda: [151643, 151645])
|
||||
|
||||
|
||||
QWEN3VL_VISION_DEFAULTS = dict(
|
||||
hidden_size=1152,
|
||||
num_heads=16,
|
||||
intermediate_size=4304,
|
||||
depth=27,
|
||||
patch_size=16,
|
||||
temporal_patch_size=2,
|
||||
in_channels=3,
|
||||
spatial_merge_size=2,
|
||||
num_position_embeddings=2304,
|
||||
deepstack_visual_indexes=(8, 16, 24),
|
||||
out_hidden_size=4096, # final merger projects directly into LLM hidden
|
||||
)
|
||||
|
||||
|
||||
class BottleneckPatchEmbed(nn.Module):
|
||||
# 3072 -> 1024 -> 4096 (raw 32x32 RGB patch -> bottleneck -> LLM hidden).
|
||||
def __init__(self, patch_size=32, in_chans=3, pca_dim=1024, embed_dim=4096, bias=True, device=None, dtype=None, ops=None):
|
||||
super().__init__()
|
||||
self.proj1 = ops.Linear(patch_size * patch_size * in_chans, pca_dim, bias=False, device=device, dtype=dtype)
|
||||
self.proj2 = ops.Linear(pca_dim, embed_dim, bias=bias, device=device, dtype=dtype)
|
||||
|
||||
def forward(self, x):
|
||||
return self.proj2(self.proj1(x))
|
||||
|
||||
|
||||
class FinalLayer(nn.Module):
|
||||
# 4096 -> 3072 (LLM hidden -> flat pixel patch).
|
||||
def __init__(self, hidden_size, patch_size=32, out_channels=3, device=None, dtype=None, ops=None):
|
||||
super().__init__()
|
||||
self.linear = ops.Linear(hidden_size, patch_size * patch_size * out_channels, bias=True, device=device, dtype=dtype)
|
||||
|
||||
def forward(self, x):
|
||||
return self.linear(x)
|
||||
|
||||
|
||||
class HiDreamO1Transformer(nn.Module):
|
||||
"""HiDream-O1 unified pixel-level transformer."""
|
||||
|
||||
def __init__(self, image_model=None, dtype=None, device=None, operations=None,
|
||||
text_config_overrides=None, vision_config_overrides=None, **kwargs):
|
||||
super().__init__()
|
||||
self.dtype = dtype
|
||||
|
||||
text_cfg = HiDreamO1TextConfig(**(text_config_overrides or {}))
|
||||
vision_cfg = dict(QWEN3VL_VISION_DEFAULTS)
|
||||
if vision_config_overrides:
|
||||
vision_cfg.update(vision_config_overrides)
|
||||
vision_cfg["out_hidden_size"] = text_cfg.hidden_size
|
||||
|
||||
self.text_config = text_cfg
|
||||
self.vision_config = vision_cfg
|
||||
self.hidden_size = text_cfg.hidden_size
|
||||
self.patch_size = PATCH_SIZE
|
||||
self.in_channels = 3
|
||||
self.tms_token_id = TMS_TOKEN_ID
|
||||
|
||||
self.visual = Qwen35VisionModel(vision_cfg, device=device, dtype=dtype, ops=operations)
|
||||
self.language_model = Llama2_(text_cfg, device=device, dtype=dtype, ops=operations)
|
||||
self.t_embedder1 = TimestepEmbedder(
|
||||
text_cfg.hidden_size, device=device, dtype=dtype, operations=operations,
|
||||
)
|
||||
self.x_embedder = BottleneckPatchEmbed(
|
||||
patch_size=self.patch_size, in_chans=self.in_channels,
|
||||
pca_dim=text_cfg.hidden_size // 4, embed_dim=text_cfg.hidden_size,
|
||||
bias=True, device=device, dtype=dtype, ops=operations,
|
||||
)
|
||||
self.final_layer2 = FinalLayer(
|
||||
text_cfg.hidden_size, patch_size=self.patch_size,
|
||||
out_channels=self.in_channels, device=device, dtype=dtype, ops=operations,
|
||||
)
|
||||
|
||||
self._visual_cache = None
|
||||
self._kv_cache_entries = []
|
||||
|
||||
def clear_kv_cache(self):
|
||||
self._kv_cache_entries = []
|
||||
self._visual_cache = None
|
||||
|
||||
def forward(self, x, timesteps, context=None, transformer_options={}, **kwargs):
|
||||
return comfy.patcher_extension.WrapperExecutor.new_class_executor(
|
||||
self._forward,
|
||||
self,
|
||||
comfy.patcher_extension.get_all_wrappers(comfy.patcher_extension.WrappersMP.DIFFUSION_MODEL, transformer_options)
|
||||
).execute(x, timesteps, context, transformer_options, **kwargs)
|
||||
|
||||
def _forward(self, x, timesteps, context=None, transformer_options={}, input_ids=None, attention_mask=None, position_ids=None,
|
||||
vinput_mask=None, ar_len=None, ref_pixel_values=None, ref_image_grid_thw=None, ref_patches=None, **kwargs):
|
||||
"""Returns flow-match velocity (x - x_pred) / sigma"""
|
||||
|
||||
if input_ids is None or position_ids is None:
|
||||
raise ValueError("HiDreamO1Transformer requires input_ids and position_ids in conditioning")
|
||||
|
||||
B, _, H, W = x.shape
|
||||
h_p, w_p = H // self.patch_size, W // self.patch_size
|
||||
tgt_image_len = h_p * w_p
|
||||
|
||||
z = einops.rearrange(
|
||||
x, 'B C (H p1) (W p2) -> B (H W) (C p1 p2)',
|
||||
p1=self.patch_size, p2=self.patch_size,
|
||||
)
|
||||
vinputs = torch.cat([z, ref_patches.to(z.dtype)], dim=1) if ref_patches is not None else z
|
||||
|
||||
inputs_embeds = self.language_model.embed_tokens(input_ids).to(x.dtype)
|
||||
|
||||
if ref_pixel_values is not None and ref_image_grid_thw is not None:
|
||||
# ViT output is constant across sampling steps within a generation
|
||||
# identity-key by the input tensor so refs don't recompute every step.
|
||||
cached = self._visual_cache
|
||||
if cached is not None and cached[0] is ref_pixel_values:
|
||||
image_embeds = cached[1]
|
||||
else:
|
||||
ref_pv = ref_pixel_values.to(inputs_embeds.device)
|
||||
ref_grid = ref_image_grid_thw.to(inputs_embeds.device).long()
|
||||
# extra_conds wraps with a leading batch dim; refs are model-level so [0] always recovers them.
|
||||
if ref_pv.dim() == 3:
|
||||
ref_pv = ref_pv[0]
|
||||
if ref_grid.dim() == 3:
|
||||
ref_grid = ref_grid[0]
|
||||
image_embeds = self.visual(ref_pv, ref_grid).to(inputs_embeds.dtype)
|
||||
self._visual_cache = (ref_pixel_values, image_embeds)
|
||||
# image_pad positions identical across batch (input_ids shared cond/uncond).
|
||||
image_idx = (input_ids[0] == IMAGE_TOKEN_ID).nonzero(as_tuple=True)[0]
|
||||
if image_idx.shape[0] != image_embeds.shape[0]:
|
||||
raise ValueError(
|
||||
f"Image-token count {image_idx.shape[0]} != ViT output count "
|
||||
f"{image_embeds.shape[0]}; check tokenizer/processor alignment."
|
||||
)
|
||||
inputs_embeds[:, image_idx] = image_embeds.unsqueeze(0).expand(B, -1, -1)
|
||||
|
||||
sigma = timesteps.float() / 1000.0
|
||||
t_pixeldit = 1.0 - sigma
|
||||
t_emb = self.t_embedder1(t_pixeldit * 1000, inputs_embeds.dtype)
|
||||
tms_mask_3d = (input_ids == self.tms_token_id).unsqueeze(-1).expand_as(inputs_embeds)
|
||||
inputs_embeds = torch.where(tms_mask_3d, t_emb.unsqueeze(1).expand_as(inputs_embeds), inputs_embeds)
|
||||
|
||||
vinputs_embedded = self.x_embedder(vinputs.to(inputs_embeds.dtype))
|
||||
inputs_embeds = torch.cat([inputs_embeds, vinputs_embedded], dim=1)
|
||||
|
||||
# extra_conds stores position_ids as (1, 3, T); process_cond repeats dim 0 to B. Take row 0.
|
||||
freqs_cis = self.language_model.compute_freqs_cis(position_ids[0].to(x.device), x.device)
|
||||
freqs_cis = tuple(t.to(x.dtype) for t in freqs_cis)
|
||||
|
||||
two_pass_attn = make_two_pass_attention(ar_len, transformer_options=transformer_options)
|
||||
patches_replace = transformer_options.get("patches_replace", {})
|
||||
blocks_replace = patches_replace.get("dit", {})
|
||||
transformer_options["total_blocks"] = len(self.language_model.layers)
|
||||
transformer_options["block_type"] = "double"
|
||||
|
||||
# Cache prefix K/V across steps. Key includes input_ids (prompt), ref_id
|
||||
# (refs scatter into inputs_embeds), and position_ids (RoPE baked into cached K).
|
||||
can_cache = not blocks_replace and ar_len > 0
|
||||
cache_len = ar_len if can_cache else 0
|
||||
ref_id = id(ref_pixel_values) if ref_pixel_values is not None else None
|
||||
pos_ids_key = position_ids[..., :cache_len] if can_cache else position_ids
|
||||
cache_entries = self._kv_cache_entries
|
||||
# Drop stale entries from a previous device (model was unloaded and reloaded).
|
||||
if cache_entries and cache_entries[0]["input_ids"].device != input_ids.device:
|
||||
cache_entries = []
|
||||
self._kv_cache_entries = []
|
||||
kv_cache = None
|
||||
if can_cache:
|
||||
for entry in cache_entries:
|
||||
ck = entry["input_ids"]
|
||||
ep = entry["position_ids"]
|
||||
if (entry["cache_len"] == cache_len
|
||||
and ck.shape == input_ids.shape and torch.equal(ck, input_ids)
|
||||
and entry["ref_id"] == ref_id
|
||||
and ep.shape == pos_ids_key.shape and torch.equal(ep, pos_ids_key)):
|
||||
kv_cache = entry
|
||||
break
|
||||
|
||||
if kv_cache is not None:
|
||||
# Hot path: project Q/K/V only for fresh positions; past_key_value prepends cached AR K/V.
|
||||
hidden_states = inputs_embeds[:, cache_len:]
|
||||
sliced_freqs = tuple(t[..., cache_len:, :] for t in freqs_cis)
|
||||
for i, layer in enumerate(self.language_model.layers):
|
||||
transformer_options["block_index"] = i
|
||||
K_i, V_i = kv_cache["kv"][i]
|
||||
hidden_states, _ = layer(
|
||||
x=hidden_states, attention_mask=None, freqs_cis=sliced_freqs, optimized_attention=two_pass_attn,
|
||||
past_key_value=(K_i, V_i, cache_len),
|
||||
)
|
||||
else:
|
||||
# Cold path: run full sequence; if cacheable, snapshot K/V at AR positions.
|
||||
snapshots = [] if can_cache else None
|
||||
past_kv_cold = () if can_cache else None
|
||||
hidden_states = inputs_embeds
|
||||
for i, layer in enumerate(self.language_model.layers):
|
||||
transformer_options["block_index"] = i
|
||||
if ("double_block", i) in blocks_replace:
|
||||
def block_wrap(args, _layer=layer):
|
||||
out = {}
|
||||
out["x"], _ = _layer(
|
||||
x=args["x"], attention_mask=args.get("attention_mask"),
|
||||
freqs_cis=args["freqs_cis"], optimized_attention=args["optimized_attention"],
|
||||
past_key_value=None,
|
||||
)
|
||||
return out
|
||||
out = blocks_replace[("double_block", i)](
|
||||
{"x": hidden_states, "attention_mask": None,
|
||||
"freqs_cis": freqs_cis, "optimized_attention": two_pass_attn,
|
||||
"transformer_options": transformer_options},
|
||||
{"original_block": block_wrap},
|
||||
)
|
||||
hidden_states = out["x"]
|
||||
else:
|
||||
hidden_states, present_kv = layer(
|
||||
x=hidden_states, attention_mask=None,
|
||||
freqs_cis=freqs_cis, optimized_attention=two_pass_attn,
|
||||
past_key_value=past_kv_cold,
|
||||
)
|
||||
if snapshots is not None:
|
||||
K, V, _ = present_kv
|
||||
snapshots.append((K[:, :, :cache_len].contiguous(),
|
||||
V[:, :, :cache_len].contiguous()))
|
||||
if snapshots is not None:
|
||||
# Cap at 2 entries (cond + uncond). Multi-cond workflows LRU-evict.
|
||||
new_entry = {
|
||||
"input_ids": input_ids.clone(),
|
||||
"cache_len": cache_len,
|
||||
"kv": snapshots,
|
||||
"ref_id": ref_id,
|
||||
"position_ids": pos_ids_key.clone(),
|
||||
}
|
||||
self._kv_cache_entries = (cache_entries + [new_entry])[-2:]
|
||||
|
||||
if self.language_model.norm is not None:
|
||||
hidden_states = self.language_model.norm(hidden_states)
|
||||
|
||||
# Slice target-image positions before the final projection so the Linear only runs on tgt_image_len tokens.
|
||||
# In the hot path hidden_states starts at original position cache_len, so masks/indices shift by cache_len.
|
||||
sliced_offset = cache_len if kv_cache is not None else 0
|
||||
if vinput_mask is not None:
|
||||
vmask = vinput_mask.to(x.device).bool()
|
||||
if sliced_offset > 0:
|
||||
vmask = vmask[:, sliced_offset:]
|
||||
target_hidden = hidden_states[vmask].view(B, -1, hidden_states.shape[-1])[:, :tgt_image_len]
|
||||
else:
|
||||
txt_seq_len = input_ids.shape[1]
|
||||
start = txt_seq_len - sliced_offset
|
||||
target_hidden = hidden_states[:, start:start + tgt_image_len]
|
||||
x_pred_tgt = self.final_layer2(target_hidden)
|
||||
|
||||
# fp32 final subtraction, bf16 here noticeably degrades samples.
|
||||
x_pred_img = einops.rearrange(
|
||||
x_pred_tgt, 'B (H W) (C p1 p2) -> B C (H p1) (W p2)',
|
||||
H=h_p, W=w_p, p1=self.patch_size, p2=self.patch_size,
|
||||
)
|
||||
return (x.float() - x_pred_img.float()) / sigma.view(B, 1, 1, 1).clamp_min(1e-3)
|
||||
@@ -0,0 +1,173 @@
|
||||
"""HiDream-O1 input-prep helpers: image/resolution math and unified-sequence
|
||||
RoPE position-id assembly. The fix_point offset in get_rope_index_fix_point
|
||||
lets the target image and patchified ref images share spatial RoPE positions
|
||||
despite living at different sequence indices — same 2D image plane.
|
||||
"""
|
||||
|
||||
import math
|
||||
from typing import Optional
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
PATCH_SIZE = 32
|
||||
CONDITION_IMAGE_SIZE = 384 # ViT-side base size for ref images
|
||||
|
||||
|
||||
def resize_tensor(img_t, image_size, patch_size=16):
|
||||
"""img_t: (1, 3, H, W) float [0, 1]. Fit to image_size**2 area, patch-aligned, center-cropped."""
|
||||
|
||||
while min(img_t.shape[-2], img_t.shape[-1]) >= 2 * image_size: # Pre-halves with 2x2 box averaging while the image is still very large
|
||||
img_t = torch.nn.functional.avg_pool2d(img_t, kernel_size=2, stride=2)
|
||||
|
||||
_, _, height, width = img_t.shape
|
||||
m = patch_size
|
||||
s_max = image_size * image_size
|
||||
scale = math.sqrt(s_max / (width * height))
|
||||
|
||||
candidates = [
|
||||
(round(width * scale) // m * m, round(height * scale) // m * m),
|
||||
(round(width * scale) // m * m, math.floor(height * scale) // m * m),
|
||||
(math.floor(width * scale) // m * m, round(height * scale) // m * m),
|
||||
(math.floor(width * scale) // m * m, math.floor(height * scale) // m * m),
|
||||
]
|
||||
candidates = sorted(candidates, key=lambda x: x[0] * x[1], reverse=True)
|
||||
new_size = candidates[-1]
|
||||
for c in candidates:
|
||||
if c[0] * c[1] <= s_max:
|
||||
new_size = c
|
||||
break
|
||||
|
||||
new_w, new_h = new_size
|
||||
s1 = width / new_w
|
||||
s2 = height / new_h
|
||||
if s1 < s2:
|
||||
resize_w, resize_h = new_w, round(height / s1)
|
||||
else:
|
||||
resize_w, resize_h = round(width / s2), new_h
|
||||
img_t = torch.nn.functional.interpolate(img_t, size=(resize_h, resize_w), mode="bicubic")
|
||||
top = (resize_h - new_h) // 2
|
||||
left = (resize_w - new_w) // 2
|
||||
return img_t[..., top:top + new_h, left:left + new_w]
|
||||
|
||||
|
||||
def calculate_dimensions(max_size, ratio):
|
||||
"""(W, H) for an aspect ratio fitting in max_size**2 area, 32-aligned."""
|
||||
width = math.sqrt(max_size * max_size * ratio)
|
||||
height = width / ratio
|
||||
width = int(width / 32) * 32
|
||||
height = int(height / 32) * 32
|
||||
return width, height
|
||||
|
||||
|
||||
def ref_max_size(target_max_dim, k):
|
||||
"""K-dependent ref-image max dim before patchifying."""
|
||||
if k == 1:
|
||||
return target_max_dim
|
||||
if k == 2:
|
||||
return target_max_dim * 48 // 64
|
||||
if k <= 4:
|
||||
return target_max_dim // 2
|
||||
if k <= 8:
|
||||
return target_max_dim * 24 // 64
|
||||
return target_max_dim // 4
|
||||
|
||||
|
||||
def cond_image_size(k):
|
||||
"""K-dependent ViT-side image size."""
|
||||
if k <= 4:
|
||||
return CONDITION_IMAGE_SIZE
|
||||
if k <= 8:
|
||||
return CONDITION_IMAGE_SIZE * 48 // 64
|
||||
return CONDITION_IMAGE_SIZE // 2
|
||||
|
||||
|
||||
def get_rope_index_fix_point(
|
||||
spatial_merge_size: int,
|
||||
image_token_id: int,
|
||||
vision_start_token_id: int,
|
||||
input_ids: Optional[torch.LongTensor] = None,
|
||||
image_grid_thw: Optional[torch.LongTensor] = None,
|
||||
attention_mask: Optional[torch.Tensor] = None,
|
||||
skip_vision_start_token=None,
|
||||
fix_point: int = 4096,
|
||||
):
|
||||
mrope_position_deltas = []
|
||||
if input_ids is not None and image_grid_thw is not None:
|
||||
total_input_ids = input_ids
|
||||
if attention_mask is None:
|
||||
attention_mask = torch.ones_like(total_input_ids)
|
||||
position_ids = torch.ones(
|
||||
3, input_ids.shape[0], input_ids.shape[1],
|
||||
dtype=input_ids.dtype, device=input_ids.device,
|
||||
)
|
||||
attention_mask = attention_mask.to(total_input_ids.device)
|
||||
for i, input_ids_b in enumerate(total_input_ids):
|
||||
fp = fix_point
|
||||
image_index = 0
|
||||
input_ids_b = input_ids_b[attention_mask[i] == 1]
|
||||
vision_start_indices = torch.argwhere(input_ids_b == vision_start_token_id).squeeze(1)
|
||||
vision_tokens = input_ids_b[vision_start_indices + 1]
|
||||
image_nums = (vision_tokens == image_token_id).sum()
|
||||
input_tokens = input_ids_b.tolist()
|
||||
llm_pos_ids_list = []
|
||||
st = 0
|
||||
remain_images = image_nums
|
||||
for _ in range(image_nums):
|
||||
if image_token_id in input_tokens and remain_images > 0:
|
||||
ed = input_tokens.index(image_token_id, st)
|
||||
else:
|
||||
ed = len(input_tokens) + 1
|
||||
t = image_grid_thw[image_index][0]
|
||||
h = image_grid_thw[image_index][1]
|
||||
w = image_grid_thw[image_index][2]
|
||||
image_index += 1
|
||||
remain_images -= 1
|
||||
llm_grid_t = t.item()
|
||||
llm_grid_h = h.item() // spatial_merge_size
|
||||
llm_grid_w = w.item() // spatial_merge_size
|
||||
text_len = ed - st
|
||||
text_len -= skip_vision_start_token[image_index - 1]
|
||||
text_len = max(0, text_len)
|
||||
st_idx = llm_pos_ids_list[-1].max() + 1 if len(llm_pos_ids_list) > 0 else 0
|
||||
llm_pos_ids_list.append(torch.arange(text_len).view(1, -1).expand(3, -1) + st_idx)
|
||||
|
||||
t_index = torch.arange(llm_grid_t).view(-1, 1).expand(-1, llm_grid_h * llm_grid_w).flatten()
|
||||
h_index = torch.arange(llm_grid_h).view(1, -1, 1).expand(llm_grid_t, -1, llm_grid_w).flatten()
|
||||
w_index = torch.arange(llm_grid_w).view(1, 1, -1).expand(llm_grid_t, llm_grid_h, -1).flatten()
|
||||
|
||||
if skip_vision_start_token[image_index - 1]:
|
||||
if fp > 0:
|
||||
fp = fp - st_idx
|
||||
llm_pos_ids_list.append(torch.stack([t_index, h_index, w_index]) + fp + st_idx)
|
||||
fp = 0
|
||||
else:
|
||||
llm_pos_ids_list.append(torch.stack([t_index, h_index, w_index]) + text_len + st_idx)
|
||||
st = ed + llm_grid_t * llm_grid_h * llm_grid_w
|
||||
|
||||
if st < len(input_tokens):
|
||||
st_idx = llm_pos_ids_list[-1].max() + 1 if len(llm_pos_ids_list) > 0 else 0
|
||||
text_len = len(input_tokens) - st
|
||||
llm_pos_ids_list.append(torch.arange(text_len).view(1, -1).expand(3, -1) + st_idx)
|
||||
|
||||
llm_positions = torch.cat(llm_pos_ids_list, dim=1).reshape(3, -1)
|
||||
position_ids[..., i, attention_mask[i] == 1] = llm_positions.to(position_ids.device)
|
||||
mrope_position_deltas.append(llm_positions.max() + 1 - len(total_input_ids[i]))
|
||||
mrope_position_deltas = torch.tensor(mrope_position_deltas, device=input_ids.device).unsqueeze(1)
|
||||
return position_ids, mrope_position_deltas
|
||||
|
||||
if attention_mask is not None:
|
||||
position_ids = attention_mask.long().cumsum(-1) - 1
|
||||
position_ids.masked_fill_(attention_mask == 0, 1)
|
||||
position_ids = position_ids.unsqueeze(0).expand(3, -1, -1).to(attention_mask.device)
|
||||
max_position_ids = position_ids.max(0, keepdim=False)[0].max(-1, keepdim=True)[0]
|
||||
mrope_position_deltas = max_position_ids + 1 - attention_mask.shape[-1]
|
||||
else:
|
||||
position_ids = (
|
||||
torch.arange(input_ids.shape[1], device=input_ids.device)
|
||||
.view(1, 1, -1).expand(3, input_ids.shape[0], -1)
|
||||
)
|
||||
mrope_position_deltas = torch.zeros(
|
||||
[input_ids.shape[0], 1], device=input_ids.device, dtype=input_ids.dtype,
|
||||
)
|
||||
return position_ids, mrope_position_deltas
|
||||
@@ -328,7 +328,7 @@ class CrossAttention(nn.Module):
|
||||
kv = torch.cat((k, v), dim=-1)
|
||||
split_size = kv.shape[-1] // self.num_heads // 2
|
||||
|
||||
kv = kv.view(1, -1, self.num_heads, split_size * 2)
|
||||
kv = kv.view(b, -1, self.num_heads, split_size * 2)
|
||||
k, v = torch.split(kv, split_size, dim=-1)
|
||||
|
||||
q = q.view(b, s1, self.num_heads, self.head_dim)
|
||||
@@ -398,7 +398,7 @@ class Attention(nn.Module):
|
||||
qkv_combined = torch.cat((query, key, value), dim=-1)
|
||||
split_size = qkv_combined.shape[-1] // self.num_heads // 3
|
||||
|
||||
qkv = qkv_combined.view(1, -1, self.num_heads, split_size * 3)
|
||||
qkv = qkv_combined.view(B, -1, self.num_heads, split_size * 3)
|
||||
query, key, value = torch.split(qkv, split_size, dim=-1)
|
||||
|
||||
query = query.reshape(B, N, self.num_heads, self.head_dim)
|
||||
@@ -607,9 +607,9 @@ class HunYuanDiTPlain(nn.Module):
|
||||
def forward(self, x, t, context, transformer_options = {}, **kwargs):
|
||||
|
||||
x = x.movedim(-1, -2)
|
||||
uncond_emb, cond_emb = context.chunk(2, dim = 0)
|
||||
|
||||
context = torch.cat([cond_emb, uncond_emb], dim = 0)
|
||||
if context.shape[0] >= 2:
|
||||
uncond_emb, cond_emb = context.chunk(2, dim = 0)
|
||||
context = torch.cat([cond_emb, uncond_emb], dim = 0)
|
||||
main_condition = context
|
||||
|
||||
t = 1.0 - t
|
||||
@@ -657,5 +657,8 @@ class HunYuanDiTPlain(nn.Module):
|
||||
output = self.final_layer(combined)
|
||||
output = output.movedim(-2, -1) * (-1.0)
|
||||
|
||||
cond_emb, uncond_emb = output.chunk(2, dim = 0)
|
||||
return torch.cat([uncond_emb, cond_emb])
|
||||
if output.shape[0] >= 2:
|
||||
cond_emb, uncond_emb = output.chunk(2, dim = 0)
|
||||
return torch.cat([uncond_emb, cond_emb])
|
||||
else:
|
||||
return output
|
||||
|
||||
@@ -22,26 +22,25 @@ class CompressedTimestep:
|
||||
"""Store video timestep embeddings in compressed form using per-frame indexing."""
|
||||
__slots__ = ('data', 'batch_size', 'num_frames', 'patches_per_frame', 'feature_dim')
|
||||
|
||||
def __init__(self, tensor: torch.Tensor, patches_per_frame: int):
|
||||
def __init__(self, tensor: torch.Tensor, patches_per_frame: int, per_frame: bool = False):
|
||||
"""
|
||||
tensor: [batch_size, num_tokens, feature_dim] tensor where num_tokens = num_frames * patches_per_frame
|
||||
patches_per_frame: Number of spatial patches per frame (height * width in latent space), or None to disable compression
|
||||
tensor: [batch, num_tokens, feature_dim] (per-token, default) or
|
||||
[batch, num_frames, feature_dim] (per_frame=True, already compressed).
|
||||
patches_per_frame: spatial patches per frame; pass None to disable compression.
|
||||
"""
|
||||
self.batch_size, num_tokens, self.feature_dim = tensor.shape
|
||||
|
||||
# Check if compression is valid (num_tokens must be divisible by patches_per_frame)
|
||||
if patches_per_frame is not None and num_tokens % patches_per_frame == 0 and num_tokens >= patches_per_frame:
|
||||
self.batch_size, n, self.feature_dim = tensor.shape
|
||||
if per_frame:
|
||||
self.patches_per_frame = patches_per_frame
|
||||
self.num_frames = num_tokens // patches_per_frame
|
||||
|
||||
# Reshape to [batch, frames, patches_per_frame, feature_dim] and store one value per frame
|
||||
# All patches in a frame are identical, so we only keep the first one
|
||||
reshaped = tensor.view(self.batch_size, self.num_frames, patches_per_frame, self.feature_dim)
|
||||
self.data = reshaped[:, :, 0, :].contiguous() # [batch, frames, feature_dim]
|
||||
self.num_frames = n
|
||||
self.data = tensor
|
||||
elif patches_per_frame is not None and n >= patches_per_frame and n % patches_per_frame == 0:
|
||||
self.patches_per_frame = patches_per_frame
|
||||
self.num_frames = n // patches_per_frame
|
||||
# All patches in a frame are identical — keep only the first.
|
||||
self.data = tensor.view(self.batch_size, self.num_frames, patches_per_frame, self.feature_dim)[:, :, 0, :].contiguous()
|
||||
else:
|
||||
# Not divisible or too small - store directly without compression
|
||||
self.patches_per_frame = 1
|
||||
self.num_frames = num_tokens
|
||||
self.num_frames = n
|
||||
self.data = tensor
|
||||
|
||||
def expand(self):
|
||||
@@ -716,32 +715,35 @@ class LTXAVModel(LTXVModel):
|
||||
|
||||
def _prepare_timestep(self, timestep, batch_size, hidden_dtype, **kwargs):
|
||||
"""Prepare timestep embeddings."""
|
||||
# TODO: some code reuse is needed here.
|
||||
grid_mask = kwargs.get("grid_mask", None)
|
||||
if grid_mask is not None:
|
||||
timestep = timestep[:, grid_mask]
|
||||
|
||||
timestep_scaled = timestep * self.timestep_scale_multiplier
|
||||
|
||||
v_timestep, v_embedded_timestep = self.adaln_single(
|
||||
timestep_scaled.flatten(),
|
||||
{"resolution": None, "aspect_ratio": None},
|
||||
batch_size=batch_size,
|
||||
hidden_dtype=hidden_dtype,
|
||||
)
|
||||
|
||||
# Calculate patches_per_frame from orig_shape: [batch, channels, frames, height, width]
|
||||
# Video tokens are arranged as (frames * height * width), so patches_per_frame = height * width
|
||||
orig_shape = kwargs.get("orig_shape")
|
||||
has_spatial_mask = kwargs.get("has_spatial_mask", None)
|
||||
v_patches_per_frame = None
|
||||
if not has_spatial_mask and orig_shape is not None and len(orig_shape) == 5:
|
||||
# orig_shape[3] = height, orig_shape[4] = width (in latent space)
|
||||
v_patches_per_frame = orig_shape[3] * orig_shape[4]
|
||||
|
||||
# Reshape to [batch_size, num_tokens, dim] and compress for storage
|
||||
v_timestep = CompressedTimestep(v_timestep.view(batch_size, -1, v_timestep.shape[-1]), v_patches_per_frame)
|
||||
v_embedded_timestep = CompressedTimestep(v_embedded_timestep.view(batch_size, -1, v_embedded_timestep.shape[-1]), v_patches_per_frame)
|
||||
# Used by compute_prompt_timestep and the audio cross-attention paths.
|
||||
timestep_scaled = (timestep[:, grid_mask] if grid_mask is not None else timestep) * self.timestep_scale_multiplier
|
||||
|
||||
# When patches in a frame share a timestep (no spatial mask), project one row per frame instead of one per token
|
||||
per_frame_path = v_patches_per_frame is not None and (timestep.numel() // batch_size) % v_patches_per_frame == 0
|
||||
if per_frame_path:
|
||||
per_frame = timestep.reshape(batch_size, -1, v_patches_per_frame)[:, :, 0]
|
||||
if grid_mask is not None:
|
||||
# All-or-nothing per frame when has_spatial_mask=False.
|
||||
per_frame = per_frame[:, grid_mask[::v_patches_per_frame]]
|
||||
ts_input = per_frame * self.timestep_scale_multiplier
|
||||
else:
|
||||
ts_input = timestep_scaled
|
||||
|
||||
v_timestep, v_embedded_timestep = self.adaln_single(
|
||||
ts_input.flatten(),
|
||||
{"resolution": None, "aspect_ratio": None},
|
||||
batch_size=batch_size,
|
||||
hidden_dtype=hidden_dtype,
|
||||
)
|
||||
v_timestep = CompressedTimestep(v_timestep.view(batch_size, -1, v_timestep.shape[-1]), v_patches_per_frame, per_frame=per_frame_path)
|
||||
v_embedded_timestep = CompressedTimestep(v_embedded_timestep.view(batch_size, -1, v_embedded_timestep.shape[-1]), v_patches_per_frame, per_frame=per_frame_path)
|
||||
|
||||
v_prompt_timestep = compute_prompt_timestep(
|
||||
self.prompt_adaln_single, timestep_scaled, batch_size, hidden_dtype
|
||||
|
||||
@@ -358,6 +358,61 @@ def apply_split_rotary_emb(input_tensor, cos, sin):
|
||||
return output.swapaxes(1, 2).reshape(B, T, -1) if needs_reshape else output
|
||||
|
||||
|
||||
class GuideAttentionMask:
|
||||
"""Holds the two per-group masks for LTXV guide self-attention.
|
||||
_attention_with_guide_mask splits queries into noisy and tracked-guide
|
||||
groups, so the largest mask is (1, 1, tracked_count, T).
|
||||
"""
|
||||
__slots__ = ("guide_start", "tracked_count", "noisy_mask", "tracked_mask")
|
||||
|
||||
def __init__(self, total_tokens, guide_start, tracked_count, tracked_weights):
|
||||
device = tracked_weights.device
|
||||
dtype = tracked_weights.dtype
|
||||
finfo = torch.finfo(dtype)
|
||||
|
||||
pos = tracked_weights > 0
|
||||
log_w = torch.full_like(tracked_weights, finfo.min)
|
||||
log_w[pos] = torch.log(tracked_weights[pos].clamp(min=finfo.tiny))
|
||||
|
||||
self.guide_start = guide_start
|
||||
self.tracked_count = tracked_count
|
||||
|
||||
self.noisy_mask = torch.zeros((1, 1, 1, total_tokens), device=device, dtype=dtype)
|
||||
self.noisy_mask[:, :, :, guide_start:guide_start + tracked_count] = log_w.view(1, 1, 1, -1)
|
||||
|
||||
self.tracked_mask = torch.zeros((1, 1, tracked_count, total_tokens), device=device, dtype=dtype)
|
||||
self.tracked_mask[:, :, :, :guide_start] = log_w.view(1, 1, -1, 1)
|
||||
|
||||
|
||||
def _attention_with_guide_mask(q, k, v, heads, guide_mask, attn_precision, transformer_options):
|
||||
"""Apply the guide mask by partitioning Q into noisy and tracked-guide
|
||||
groups, so each group needs only its own sub-mask. Avoids materializing
|
||||
the (1,1,T,T) dense mask.
|
||||
"""
|
||||
guide_start = guide_mask.guide_start
|
||||
tracked_end = guide_start + guide_mask.tracked_count
|
||||
|
||||
out = torch.empty_like(q)
|
||||
|
||||
if guide_start > 0: # In practice currently guides are always after noise, guard for safety if this changes.
|
||||
out[:, :guide_start, :] = comfy.ldm.modules.attention.optimized_attention(
|
||||
q[:, :guide_start, :], k, v, heads, mask=guide_mask.noisy_mask,
|
||||
attn_precision=attn_precision, transformer_options=transformer_options,
|
||||
low_precision_attention=False, # sageattn mask support is unreliable
|
||||
)
|
||||
out[:, guide_start:tracked_end, :] = comfy.ldm.modules.attention.optimized_attention(
|
||||
q[:, guide_start:tracked_end, :], k, v, heads, mask=guide_mask.tracked_mask,
|
||||
attn_precision=attn_precision, transformer_options=transformer_options,
|
||||
low_precision_attention=False,
|
||||
)
|
||||
if tracked_end < q.shape[1]: # Every guide token is tracked, and nothing comes after them, guard for safety if this changes.
|
||||
out[:, tracked_end:, :] = comfy.ldm.modules.attention.optimized_attention(
|
||||
q[:, tracked_end:, :], k, v, heads,
|
||||
attn_precision=attn_precision, transformer_options=transformer_options,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
class CrossAttention(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -412,8 +467,10 @@ class CrossAttention(nn.Module):
|
||||
|
||||
if mask is None:
|
||||
out = comfy.ldm.modules.attention.optimized_attention(q, k, v, self.heads, attn_precision=self.attn_precision, transformer_options=transformer_options)
|
||||
elif isinstance(mask, GuideAttentionMask):
|
||||
out = _attention_with_guide_mask(q, k, v, self.heads, mask, attn_precision=self.attn_precision, transformer_options=transformer_options)
|
||||
else:
|
||||
out = comfy.ldm.modules.attention.optimized_attention_masked(q, k, v, self.heads, mask, attn_precision=self.attn_precision, transformer_options=transformer_options)
|
||||
out = comfy.ldm.modules.attention.optimized_attention(q, k, v, self.heads, mask=mask, attn_precision=self.attn_precision, transformer_options=transformer_options)
|
||||
|
||||
# Apply per-head gating if enabled
|
||||
if self.to_gate_logits is not None:
|
||||
@@ -1063,7 +1120,9 @@ class LTXVModel(LTXBaseModel):
|
||||
additional_args["resolved_guide_entries"] = resolved_entries
|
||||
|
||||
keyframe_idxs = keyframe_idxs[..., kf_grid_mask, :]
|
||||
pixel_coords[:, :, -keyframe_idxs.shape[2]:, :] = keyframe_idxs
|
||||
|
||||
if keyframe_idxs.shape[2] > 0: # Guard for the case of no keyframes surviving
|
||||
pixel_coords[:, :, -keyframe_idxs.shape[2]:, :] = keyframe_idxs
|
||||
|
||||
# Total surviving guide tokens (all guides)
|
||||
additional_args["num_guide_tokens"] = keyframe_idxs.shape[2]
|
||||
@@ -1099,12 +1158,12 @@ class LTXVModel(LTXBaseModel):
|
||||
if not resolved_entries:
|
||||
return None
|
||||
|
||||
# Check if any attenuation is actually needed
|
||||
needs_attenuation = any(
|
||||
e["strength"] < 1.0 or e.get("pixel_mask") is not None
|
||||
# strength != 1.0 means we want to either attenuate (< 1) or amplify (> 1) guide attention.
|
||||
needs_mask = any(
|
||||
e["strength"] != 1.0 or e.get("pixel_mask") is not None
|
||||
for e in resolved_entries
|
||||
)
|
||||
if not needs_attenuation:
|
||||
if not needs_mask:
|
||||
return None
|
||||
|
||||
# Build per-guide-token weights for all tracked guide tokens.
|
||||
@@ -1159,16 +1218,11 @@ class LTXVModel(LTXBaseModel):
|
||||
# Concatenate per-token weights for all tracked guides
|
||||
tracked_weights = torch.cat(all_weights, dim=1) # (1, total_tracked)
|
||||
|
||||
# Check if any weight is actually < 1.0 (otherwise no attenuation needed)
|
||||
if (tracked_weights >= 1.0).all():
|
||||
# Skip when every weight is exactly 1.0 (additive bias would be 0).
|
||||
if (tracked_weights == 1.0).all():
|
||||
return None
|
||||
|
||||
# Build the mask: guide tokens are at the end of the sequence.
|
||||
# Tracked guides come first (in order), untracked follow.
|
||||
return self._build_self_attention_mask(
|
||||
total_tokens, num_guide_tokens, total_tracked,
|
||||
tracked_weights, guide_start, device, dtype,
|
||||
)
|
||||
return GuideAttentionMask(total_tokens, guide_start, total_tracked, tracked_weights)
|
||||
|
||||
@staticmethod
|
||||
def _downsample_mask_to_latent(mask, f_lat, h_lat, w_lat):
|
||||
@@ -1234,45 +1288,6 @@ class LTXVModel(LTXBaseModel):
|
||||
|
||||
return rearrange(latent_mask, "b 1 f h w -> b (f h w)")
|
||||
|
||||
@staticmethod
|
||||
def _build_self_attention_mask(total_tokens, num_guide_tokens, tracked_count,
|
||||
tracked_weights, guide_start, device, dtype):
|
||||
"""Build a log-space additive self-attention bias mask.
|
||||
|
||||
Attenuates attention between noisy tokens and tracked guide tokens.
|
||||
Untracked guide tokens (at the end of the guide portion) keep full attention.
|
||||
|
||||
Args:
|
||||
total_tokens: Total sequence length.
|
||||
num_guide_tokens: Total guide tokens (all guides) at end of sequence.
|
||||
tracked_count: Number of tracked guide tokens (first in the guide portion).
|
||||
tracked_weights: (1, tracked_count) tensor, values in [0, 1].
|
||||
guide_start: Index where guide tokens begin in the sequence.
|
||||
device: Target device.
|
||||
dtype: Target dtype.
|
||||
|
||||
Returns:
|
||||
(1, 1, total_tokens, total_tokens) additive bias mask.
|
||||
0.0 = full attention, negative = attenuated, finfo.min = effectively fully masked.
|
||||
"""
|
||||
finfo = torch.finfo(dtype)
|
||||
mask = torch.zeros((1, 1, total_tokens, total_tokens), device=device, dtype=dtype)
|
||||
tracked_end = guide_start + tracked_count
|
||||
|
||||
# Convert weights to log-space bias
|
||||
w = tracked_weights.to(device=device, dtype=dtype) # (1, tracked_count)
|
||||
log_w = torch.full_like(w, finfo.min)
|
||||
positive_mask = w > 0
|
||||
if positive_mask.any():
|
||||
log_w[positive_mask] = torch.log(w[positive_mask].clamp(min=finfo.tiny))
|
||||
|
||||
# noisy → tracked guides: each noisy row gets the same per-guide weight
|
||||
mask[:, :, :guide_start, guide_start:tracked_end] = log_w.view(1, 1, 1, -1)
|
||||
# tracked guides → noisy: each guide row broadcasts its weight across noisy cols
|
||||
mask[:, :, guide_start:tracked_end, :guide_start] = log_w.view(1, 1, -1, 1)
|
||||
|
||||
return mask
|
||||
|
||||
def _process_transformer_blocks(self, x, context, attention_mask, timestep, pe, transformer_options={}, self_attention_mask=None, **kwargs):
|
||||
"""Process transformer blocks for LTXV."""
|
||||
patches_replace = transformer_options.get("patches_replace", {})
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Pure-torch + scipy geometry helpers for MoGe inference and mesh export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
from scipy.optimize import least_squares
|
||||
|
||||
def normalized_view_plane_uv(width: int, height: int, aspect_ratio: Optional[float] = None,
|
||||
dtype: Optional[torch.dtype] = None, device: Optional[torch.device] = None) -> torch.Tensor:
|
||||
"""Normalized view-plane UV coordinates with corners at +/-(W, H)/diagonal."""
|
||||
if aspect_ratio is None:
|
||||
aspect_ratio = width / height
|
||||
span_x = aspect_ratio / (1 + aspect_ratio ** 2) ** 0.5
|
||||
span_y = 1.0 / (1 + aspect_ratio ** 2) ** 0.5
|
||||
u = torch.linspace(-span_x * (width - 1) / width, span_x * (width - 1) / width, width, dtype=dtype, device=device)
|
||||
v = torch.linspace(-span_y * (height - 1) / height, span_y * (height - 1) / height, height, dtype=dtype, device=device)
|
||||
u, v = torch.meshgrid(u, v, indexing="xy")
|
||||
return torch.stack([u, v], dim=-1)
|
||||
|
||||
|
||||
def intrinsics_from_focal_center(fx: torch.Tensor, fy: torch.Tensor, cx: torch.Tensor, cy: torch.Tensor) -> torch.Tensor:
|
||||
"""Assemble (..., 3, 3) intrinsics from broadcastable fx, fy, cx, cy."""
|
||||
fx, fy, cx, cy = [torch.as_tensor(v) for v in (fx, fy, cx, cy)]
|
||||
fx, fy, cx, cy = torch.broadcast_tensors(fx, fy, cx, cy)
|
||||
zero = torch.zeros_like(fx)
|
||||
one = torch.ones_like(fx)
|
||||
return torch.stack([
|
||||
torch.stack([fx, zero, cx], dim=-1),
|
||||
torch.stack([zero, fy, cy], dim=-1),
|
||||
torch.stack([zero, zero, one], dim=-1),
|
||||
], dim=-2)
|
||||
|
||||
|
||||
def depth_map_to_point_map(depth: torch.Tensor, intrinsics: torch.Tensor) -> torch.Tensor:
|
||||
"""Back-project a (..., H, W) depth map through K^-1 to (..., H, W, 3) camera-space points.
|
||||
|
||||
Intrinsics use normalized image coords (x in [0, 1] left->right, y in [0, 1] top->bottom).
|
||||
"""
|
||||
H, W = depth.shape[-2:]
|
||||
device, dtype = depth.device, depth.dtype
|
||||
u = (torch.arange(W, dtype=dtype, device=device) + 0.5) / W
|
||||
v = (torch.arange(H, dtype=dtype, device=device) + 0.5) / H
|
||||
grid_v, grid_u = torch.meshgrid(v, u, indexing="ij")
|
||||
pix = torch.stack([grid_u, grid_v, torch.ones_like(grid_u)], dim=-1)
|
||||
K_inv = torch.linalg.inv(intrinsics)
|
||||
rays = torch.einsum("...ij,hwj->...hwi", K_inv, pix)
|
||||
return rays * depth.unsqueeze(-1)
|
||||
|
||||
|
||||
def _solve_optimal_shift(uv: np.ndarray, xyz: np.ndarray,
|
||||
focal: Optional[float] = None) -> Tuple[float, float]:
|
||||
"""LM-solve for z-shift; when focal is None, also recovers the optimal focal."""
|
||||
uv = uv.reshape(-1, 2)
|
||||
xy = xyz[..., :2].reshape(-1, 2)
|
||||
z = xyz[..., 2].reshape(-1)
|
||||
|
||||
def fn(shift):
|
||||
xy_proj = xy / (z + shift)[:, None]
|
||||
f = focal if focal is not None else (xy_proj * uv).sum() / np.square(xy_proj).sum()
|
||||
return (f * xy_proj - uv).ravel()
|
||||
|
||||
sol = least_squares(fn, x0=0.0, ftol=1e-3, method="lm")
|
||||
shift = float(np.asarray(sol["x"]).squeeze())
|
||||
if focal is None:
|
||||
xy_proj = xy / (z + shift)[:, None]
|
||||
focal = float((xy_proj * uv).sum() / np.square(xy_proj).sum())
|
||||
return shift, focal
|
||||
|
||||
|
||||
def recover_focal_shift(points: torch.Tensor, mask: Optional[torch.Tensor] = None,
|
||||
focal: Optional[torch.Tensor] = None, downsample_size: Tuple[int, int] = (64, 64)
|
||||
) -> Tuple[torch.Tensor, torch.Tensor]:
|
||||
"""Recover the focal length and z-shift that turn points into a metric point map.
|
||||
|
||||
Optical center is at the image center; returned focal is relative to half the image diagonal.
|
||||
Returns (focal, shift) on the same device/dtype as points.
|
||||
"""
|
||||
shape = points.shape
|
||||
H, W = shape[-3], shape[-2]
|
||||
points_b = points.reshape(-1, H, W, 3)
|
||||
mask_b = None if mask is None else mask.reshape(-1, H, W)
|
||||
focal_b = None if focal is None else focal.reshape(-1)
|
||||
|
||||
uv = normalized_view_plane_uv(W, H, dtype=points.dtype, device=points.device)
|
||||
|
||||
points_lr = F.interpolate(points_b.permute(0, 3, 1, 2), downsample_size, mode="nearest").permute(0, 2, 3, 1)
|
||||
uv_lr = F.interpolate(uv.unsqueeze(0).permute(0, 3, 1, 2), downsample_size, mode="nearest").squeeze(0).permute(1, 2, 0)
|
||||
mask_lr = None
|
||||
if mask_b is not None:
|
||||
mask_lr = F.interpolate(mask_b.to(torch.float32).unsqueeze(1), downsample_size, mode="nearest").squeeze(1) > 0
|
||||
|
||||
uv_np = uv_lr.detach().cpu().numpy()
|
||||
points_np = points_lr.detach().cpu().numpy()
|
||||
mask_np = None if mask_lr is None else mask_lr.detach().cpu().numpy()
|
||||
focal_np = None if focal_b is None else focal_b.detach().cpu().numpy()
|
||||
|
||||
out_focal: list = []
|
||||
out_shift: list = []
|
||||
for i in range(points_b.shape[0]):
|
||||
if mask_np is None:
|
||||
xyz_i = points_np[i].reshape(-1, 3)
|
||||
uv_i = uv_np.reshape(-1, 2)
|
||||
else:
|
||||
sel = mask_np[i]
|
||||
if sel.sum() < 2:
|
||||
out_focal.append(1.0)
|
||||
out_shift.append(0.0)
|
||||
continue
|
||||
xyz_i = points_np[i][sel]
|
||||
uv_i = uv_np[sel]
|
||||
if focal_np is None:
|
||||
shift_i, focal_i = _solve_optimal_shift(uv_i, xyz_i)
|
||||
out_focal.append(focal_i)
|
||||
else:
|
||||
shift_i, _ = _solve_optimal_shift(uv_i, xyz_i, focal=float(focal_np[i]))
|
||||
out_shift.append(shift_i)
|
||||
|
||||
shift_t = torch.tensor(out_shift, device=points.device, dtype=points.dtype).reshape(shape[:-3])
|
||||
if focal is None:
|
||||
focal_t = torch.tensor(out_focal, device=points.device, dtype=points.dtype).reshape(shape[:-3])
|
||||
else:
|
||||
focal_t = focal.reshape(shape[:-3])
|
||||
return focal_t, shift_t
|
||||
|
||||
|
||||
def depth_map_edge(depth: torch.Tensor, atol: Optional[float] = None, rtol: Optional[float] = None, kernel_size: int = 3) -> torch.Tensor:
|
||||
"""Per-pixel boolean: True where the local depth window's max-min span exceeds atol or rtol*depth."""
|
||||
shape = depth.shape
|
||||
d = depth.reshape(-1, 1, *shape[-2:])
|
||||
pad = kernel_size // 2
|
||||
diff = F.max_pool2d(d, kernel_size, stride=1, padding=pad) + F.max_pool2d(-d, kernel_size, stride=1, padding=pad)
|
||||
edge = torch.zeros_like(d, dtype=torch.bool)
|
||||
if atol is not None:
|
||||
edge |= diff > atol
|
||||
if rtol is not None:
|
||||
edge |= (diff / d.clamp_min(1e-6)).nan_to_num_() > rtol
|
||||
return edge.reshape(*shape)
|
||||
|
||||
|
||||
def triangulate_grid_mesh(points: torch.Tensor, mask: Optional[torch.Tensor] = None, decimation: int = 1, discontinuity_threshold: float = 0.04,
|
||||
depth: Optional[torch.Tensor] = None) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
||||
"""Triangulate a (H, W, 3) point map into (vertices, faces, uvs) on CPU.
|
||||
|
||||
Vertices: pixels with finite coords (passing optional mask). Quads with four valid corners
|
||||
become two triangles. depth overrides the scalar used for the rtol edge check; pass radial
|
||||
depth for panoramas (the default points[..., 2] goes negative below the equator).
|
||||
"""
|
||||
points = points.detach().cpu()
|
||||
finite = torch.isfinite(points).all(dim=-1)
|
||||
if mask is None:
|
||||
mask = finite
|
||||
else:
|
||||
mask = mask.detach().cpu().to(torch.bool) & finite
|
||||
|
||||
if discontinuity_threshold > 0:
|
||||
d = depth.detach().cpu() if depth is not None else points[..., 2]
|
||||
# Replace inf with 0 so max-pool doesn't poison neighbourhoods (mask above already excludes those pixels).
|
||||
d_finite = torch.where(finite, d, torch.zeros_like(d))
|
||||
edge = depth_map_edge(d_finite, rtol=discontinuity_threshold)
|
||||
mask = mask & ~edge
|
||||
|
||||
if decimation > 1:
|
||||
points = points[::decimation, ::decimation].contiguous()
|
||||
mask = mask[::decimation, ::decimation].contiguous()
|
||||
H, W = points.shape[:2]
|
||||
|
||||
flat_mask = mask.reshape(-1)
|
||||
idx = torch.full((H * W,), -1, dtype=torch.long)
|
||||
n_valid = int(flat_mask.sum().item())
|
||||
idx[flat_mask] = torch.arange(n_valid, dtype=torch.long)
|
||||
idx = idx.reshape(H, W)
|
||||
|
||||
vertices = points.reshape(-1, 3)[flat_mask].contiguous()
|
||||
|
||||
yy, xx = torch.meshgrid(torch.arange(H), torch.arange(W), indexing="ij")
|
||||
u = xx.float() / max(W - 1, 1)
|
||||
v = yy.float() / max(H - 1, 1)
|
||||
uvs = torch.stack([u, v], dim=-1).reshape(-1, 2)[flat_mask].contiguous()
|
||||
|
||||
a, b, c, d = idx[:-1, :-1], idx[:-1, 1:], idx[1:, 1:], idx[1:, :-1]
|
||||
quad_ok = (a >= 0) & (b >= 0) & (c >= 0) & (d >= 0)
|
||||
a, b, c, d = a[quad_ok], b[quad_ok], c[quad_ok], d[quad_ok]
|
||||
faces = torch.cat([torch.stack([a, b, c], dim=-1), torch.stack([a, c, d], dim=-1)], dim=0).contiguous()
|
||||
return vertices, faces, uvs
|
||||
@@ -0,0 +1,347 @@
|
||||
"""MoGe v1 / v2 inference modules and a state-dict-driven builder.
|
||||
|
||||
V1: DINOv2 backbone + multi-output head (points, mask).
|
||||
V2: DINOv2 encoder + neck + per-output heads (points, mask, normal, optional metric-scale MLP).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from numbers import Number
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
|
||||
import comfy.ops
|
||||
import comfy.model_management
|
||||
import comfy.model_patcher
|
||||
|
||||
from comfy.image_encoders.dino2 import Dinov2Model
|
||||
|
||||
from .geometry import depth_map_to_point_map, intrinsics_from_focal_center, recover_focal_shift
|
||||
from .modules import ConvStack, DINOv2Encoder, HeadV1, MLP, _view_plane_uv_grid
|
||||
|
||||
|
||||
def _remap_points(points: torch.Tensor) -> torch.Tensor:
|
||||
"""Apply the exp remap: z -> exp(z), xy stays linear and gets scaled by the new z."""
|
||||
xy, z = points.split([2, 1], dim=-1)
|
||||
z = torch.exp(z)
|
||||
return torch.cat([xy * z, z], dim=-1)
|
||||
|
||||
|
||||
def _detect_dinov2(sd: dict, prefix: str) -> Dict[str, Any]:
|
||||
# All shipped MoGe checkpoints use plain DINOv2
|
||||
hidden = sd[prefix + "embeddings.cls_token"].shape[-1]
|
||||
layer_prefix = prefix + "encoder.layer."
|
||||
depth = 1 + max(int(k[len(layer_prefix):].split(".")[0]) for k in sd if k.startswith(layer_prefix))
|
||||
return {
|
||||
"hidden_size": hidden,
|
||||
"num_attention_heads": hidden // 64,
|
||||
"num_hidden_layers": depth,
|
||||
"layer_norm_eps": 1e-6,
|
||||
"use_swiglu_ffn": False,
|
||||
}
|
||||
|
||||
|
||||
class MoGeModelV1(nn.Module):
|
||||
"""MoGe v1: DINOv2 backbone + HeadV1 (points, mask)."""
|
||||
|
||||
image_mean: torch.Tensor
|
||||
image_std: torch.Tensor
|
||||
|
||||
intermediate_layers = 4
|
||||
num_tokens_range: Tuple[Number, Number] = (1200, 2500)
|
||||
mask_threshold = 0.5
|
||||
|
||||
def __init__(self, backbone: Dict[str, Any], dim_upsample: List[int] = (256, 128, 128),
|
||||
num_res_blocks: int = 1, dim_times_res_block_hidden: int = 1,
|
||||
dtype=None, device=None, operations=comfy.ops.manual_cast):
|
||||
super().__init__()
|
||||
self.backbone = Dinov2Model(backbone, dtype, device, operations)
|
||||
self.head = HeadV1(dim_in=backbone["hidden_size"], dim_upsample=list(dim_upsample),
|
||||
num_res_blocks=num_res_blocks, dim_times_res_block_hidden=dim_times_res_block_hidden,
|
||||
dtype=dtype, device=device, operations=operations)
|
||||
self.register_buffer("image_mean", torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1))
|
||||
self.register_buffer("image_std", torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1))
|
||||
|
||||
def forward(self, image: torch.Tensor, num_tokens: int) -> Dict[str, torch.Tensor]:
|
||||
H, W = image.shape[-2:]
|
||||
resize = ((num_tokens * 14 ** 2) / (H * W)) ** 0.5
|
||||
rh, rw = int(H * resize), int(W * resize)
|
||||
x = F.interpolate(image, (rh, rw), mode="bicubic", align_corners=False, antialias=True)
|
||||
x = (x - self.image_mean) / self.image_std
|
||||
x14 = F.interpolate(x, (rh // 14 * 14, rw // 14 * 14), mode="bilinear", align_corners=False, antialias=True)
|
||||
|
||||
n_layers = len(self.backbone.encoder.layer)
|
||||
indices = list(range(n_layers - self.intermediate_layers, n_layers))
|
||||
feats = self.backbone.get_intermediate_layers(x14, indices, apply_norm=True)
|
||||
|
||||
points, mask = self.head(feats, x)
|
||||
points = F.interpolate(points.float(), (H, W), mode="bilinear", align_corners=False)
|
||||
points = _remap_points(points.permute(0, 2, 3, 1))
|
||||
|
||||
mask = F.interpolate(mask.float(), (H, W), mode="bilinear", align_corners=False).squeeze(1)
|
||||
|
||||
return {"points": points, "mask": mask}
|
||||
|
||||
@classmethod
|
||||
def from_state_dict(cls, sd, dtype=None, device=None, operations=comfy.ops.manual_cast):
|
||||
"""Detect the v1 head config from sd, build a model, and load weights."""
|
||||
n_up = 1 + max(int(k.split(".")[2]) for k in sd if k.startswith("head.upsample_blocks."))
|
||||
dim_upsample = [sd[f"head.upsample_blocks.{i}.0.0.weight"].shape[1] for i in range(n_up)]
|
||||
# Each upsample stage is Sequential[upsampler, *res_blocks]; count res blocks at level 0.
|
||||
num_res_blocks = max({int(k.split(".")[3]) for k in sd if k.startswith("head.upsample_blocks.0.")})
|
||||
hidden_out = sd["head.upsample_blocks.0.1.layers.2.weight"].shape[0]
|
||||
dim_times = max(hidden_out // dim_upsample[0], 1)
|
||||
model = cls(backbone=_detect_dinov2(sd, prefix="backbone."),
|
||||
dim_upsample=dim_upsample, num_res_blocks=num_res_blocks, dim_times_res_block_hidden=dim_times,
|
||||
dtype=dtype, device=device, operations=operations)
|
||||
model.load_state_dict(sd, strict=True)
|
||||
return model
|
||||
|
||||
|
||||
class MoGeModelV2(nn.Module):
|
||||
"""MoGe v2: DINOv2 encoder + neck + per-output heads (points/mask/normal/metric-scale)."""
|
||||
|
||||
intermediate_layers = 4
|
||||
num_tokens_range: Tuple[Number, Number] = (1200, 3600)
|
||||
|
||||
def __init__(self,
|
||||
encoder: Dict[str, Any],
|
||||
neck: Dict[str, Any],
|
||||
points_head: Dict[str, Any],
|
||||
mask_head: Dict[str, Any],
|
||||
scale_head: Dict[str, Any],
|
||||
normal_head: Optional[Dict[str, Any]] = None,
|
||||
dtype=None, device=None, operations=comfy.ops.manual_cast):
|
||||
super().__init__()
|
||||
self.encoder = DINOv2Encoder(**encoder, dtype=dtype, device=device, operations=operations)
|
||||
self.neck = ConvStack(**neck, dtype=dtype, device=device, operations=operations)
|
||||
self.points_head = ConvStack(**points_head, dtype=dtype, device=device, operations=operations)
|
||||
self.mask_head = ConvStack(**mask_head, dtype=dtype, device=device, operations=operations)
|
||||
self.scale_head = MLP(**scale_head, dtype=dtype, device=device, operations=operations)
|
||||
if normal_head is not None:
|
||||
self.normal_head = ConvStack(**normal_head, dtype=dtype, device=device, operations=operations)
|
||||
|
||||
def forward(self, image: torch.Tensor, num_tokens: int) -> Dict[str, torch.Tensor]:
|
||||
B, _, H, W = image.shape
|
||||
device, dtype = image.device, image.dtype
|
||||
aspect_ratio = W / H
|
||||
base_h = round((num_tokens / aspect_ratio) ** 0.5)
|
||||
base_w = round((num_tokens * aspect_ratio) ** 0.5)
|
||||
|
||||
feat_top, cls_token = self.encoder(image, base_h, base_w, return_class_token=True)
|
||||
|
||||
# 5-level pyramid: feat at level 0 concatenated with UV, other levels UV-only.
|
||||
levels = [_view_plane_uv_grid(B, base_h * (2 ** L), base_w * (2 ** L), aspect_ratio, dtype, device)
|
||||
for L in range(5)]
|
||||
levels[0] = torch.cat([feat_top, levels[0]], dim=1)
|
||||
|
||||
feats = self.neck(levels)
|
||||
|
||||
def _resize(v):
|
||||
return F.interpolate(v, (H, W), mode="bilinear", align_corners=False)
|
||||
|
||||
points = _remap_points(_resize(self.points_head(feats)[-1]).permute(0, 2, 3, 1))
|
||||
mask = _resize(self.mask_head(feats)[-1]).squeeze(1).sigmoid()
|
||||
metric_scale = self.scale_head(cls_token).squeeze(1).exp()
|
||||
|
||||
result = {"points": points, "mask": mask, "metric_scale": metric_scale}
|
||||
if hasattr(self, "normal_head"):
|
||||
normal = _resize(self.normal_head(feats)[-1])
|
||||
result["normal"] = F.normalize(normal.permute(0, 2, 3, 1), dim=-1)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_state_dict(cls, sd, dtype=None, device=None, operations=comfy.ops.manual_cast):
|
||||
"""Detect the v2 encoder/neck/heads config from sd, build a model, and load weights."""
|
||||
backbone = _detect_dinov2(sd, prefix="encoder.backbone.")
|
||||
depth = backbone["num_hidden_layers"]
|
||||
n = cls.intermediate_layers
|
||||
encoder = {
|
||||
"backbone": backbone,
|
||||
"intermediate_layers": [(depth // n) * (i + 1) - 1 for i in range(n)],
|
||||
"dim_out": sd["encoder.output_projections.0.weight"].shape[0],
|
||||
}
|
||||
# scale_head is an MLP: Sequential of [Linear, ReLU, ..., Linear]; Linear weight is (out, in).
|
||||
scale_idxs = sorted({int(k.split(".")[1]) for k in sd if k.startswith("scale_head.")})
|
||||
scale_first = sd[f"scale_head.{scale_idxs[0]}.weight"]
|
||||
cfg: Dict[str, Any] = {
|
||||
"encoder": encoder,
|
||||
"neck": cls._detect_convstack(sd, "neck."),
|
||||
"points_head": cls._detect_convstack(sd, "points_head."),
|
||||
"mask_head": cls._detect_convstack(sd, "mask_head."),
|
||||
"scale_head": {"dims": [scale_first.shape[1]] + [sd[f"scale_head.{i}.weight"].shape[0] for i in scale_idxs]},
|
||||
}
|
||||
if any(k.startswith("normal_head.") for k in sd):
|
||||
cfg["normal_head"] = cls._detect_convstack(sd, "normal_head.")
|
||||
model = cls(**cfg, dtype=dtype, device=device, operations=operations)
|
||||
model.load_state_dict(sd, strict=True)
|
||||
return model
|
||||
|
||||
@staticmethod
|
||||
def _detect_convstack(sd: dict, prefix: str) -> Dict[str, Any]:
|
||||
"""Reconstruct a ConvStack config from the keys under prefix"""
|
||||
in_keys = [k for k in sd if k.startswith(f"{prefix}input_blocks.") and k.endswith(".weight")]
|
||||
n = 1 + max(int(k[len(f"{prefix}input_blocks."):].split(".")[0]) for k in in_keys)
|
||||
|
||||
in_shapes = [sd[f"{prefix}input_blocks.{i}.weight"].shape for i in range(n)]
|
||||
has_out = lambda i: f"{prefix}output_blocks.{i}.weight" in sd
|
||||
has_norm = f"{prefix}res_blocks.0.0.layers.0.weight" in sd
|
||||
|
||||
def num_res_at(i):
|
||||
rb_prefix = f"{prefix}res_blocks.{i}."
|
||||
return len({int(k[len(rb_prefix):].split(".")[0]) for k in sd if k.startswith(rb_prefix)})
|
||||
|
||||
return {
|
||||
"dim_in": [s[1] for s in in_shapes],
|
||||
"dim_res_blocks": [s[0] for s in in_shapes],
|
||||
"dim_out": [sd[f"{prefix}output_blocks.{i}.weight"].shape[0] if has_out(i) else None for i in range(n)],
|
||||
"num_res_blocks": [num_res_at(i) for i in range(n)],
|
||||
"resamplers": ["conv_transpose" if f"{prefix}resamplers.{i}.0.weight" in sd else "bilinear"
|
||||
for i in range(n - 1)],
|
||||
"res_block_in_norm": "layer_norm" if has_norm else "none",
|
||||
"res_block_hidden_norm": "group_norm" if has_norm else "none",
|
||||
}
|
||||
|
||||
|
||||
# Translate the Meta-style DINOv2 keys MoGe ships to the naming ComfyUI DINOv2 port expects,
|
||||
# and split each fused qkv tensor into Q/K/V.
|
||||
_DINOV2_TOPLEVEL_RENAMES = {
|
||||
"patch_embed.proj.weight": "embeddings.patch_embeddings.projection.weight",
|
||||
"patch_embed.proj.bias": "embeddings.patch_embeddings.projection.bias",
|
||||
"cls_token": "embeddings.cls_token",
|
||||
"pos_embed": "embeddings.position_embeddings",
|
||||
"register_tokens": "embeddings.register_tokens",
|
||||
"mask_token": "embeddings.mask_token",
|
||||
"norm.weight": "layernorm.weight",
|
||||
"norm.bias": "layernorm.bias",
|
||||
}
|
||||
_DINOV2_BLOCK_RENAMES = [
|
||||
("ls1.gamma", "layer_scale1.lambda1"),
|
||||
("ls2.gamma", "layer_scale2.lambda1"),
|
||||
("attn.proj.", "attention.output.dense."),
|
||||
("mlp.w12.", "mlp.weights_in."),
|
||||
("mlp.w3.", "mlp.weights_out."),
|
||||
]
|
||||
|
||||
|
||||
def _remap_state_dict(sd: dict) -> dict:
|
||||
if "model" in sd and "model_config" in sd:
|
||||
sd = sd["model"]
|
||||
prefix = "encoder.backbone." if any(k.startswith("encoder.backbone.") for k in sd) else "backbone."
|
||||
out: dict = {}
|
||||
for k, v in sd.items():
|
||||
if not k.startswith(prefix):
|
||||
out[k] = v
|
||||
continue
|
||||
rel = k[len(prefix):]
|
||||
if rel in _DINOV2_TOPLEVEL_RENAMES:
|
||||
out[prefix + _DINOV2_TOPLEVEL_RENAMES[rel]] = v
|
||||
continue
|
||||
if not rel.startswith("blocks."):
|
||||
out[k] = v
|
||||
continue
|
||||
_, idx, sub = rel.split(".", 2)
|
||||
if sub in ("attn.qkv.weight", "attn.qkv.bias"):
|
||||
tail = sub.rsplit(".", 1)[1]
|
||||
q, kw, vw = v.chunk(3, dim=0)
|
||||
base = f"{prefix}encoder.layer.{idx}.attention.attention"
|
||||
out[f"{base}.query.{tail}"] = q
|
||||
out[f"{base}.key.{tail}"] = kw
|
||||
out[f"{base}.value.{tail}"] = vw
|
||||
continue
|
||||
for old, new in _DINOV2_BLOCK_RENAMES:
|
||||
sub = sub.replace(old, new)
|
||||
out[f"{prefix}encoder.layer.{idx}.{sub}"] = v
|
||||
return out
|
||||
|
||||
|
||||
def build_from_state_dict(sd: dict, dtype=None, device=None, operations=comfy.ops.manual_cast) -> nn.Module:
|
||||
"""Dispatch to v1 or v2 based on the DINOv2 backbone prefix."""
|
||||
sd = _remap_state_dict(sd)
|
||||
cls = MoGeModelV2 if any(k.startswith("encoder.backbone.") for k in sd) else MoGeModelV1
|
||||
return cls.from_state_dict(sd, dtype=dtype, device=device, operations=operations)
|
||||
|
||||
|
||||
class MoGeModel:
|
||||
"""Loaded MoGe model + ComfyUI memory management."""
|
||||
|
||||
def __init__(self, state_dict: dict):
|
||||
# text encoder dtype closest match
|
||||
self.load_device = comfy.model_management.text_encoder_device()
|
||||
offload_device = comfy.model_management.text_encoder_offload_device()
|
||||
self.dtype = comfy.model_management.text_encoder_dtype(self.load_device)
|
||||
|
||||
self.model = build_from_state_dict(state_dict, dtype=self.dtype, device=offload_device, operations=comfy.ops.manual_cast).eval()
|
||||
self.patcher = comfy.model_patcher.CoreModelPatcher(self.model, load_device=self.load_device, offload_device=offload_device)
|
||||
self.version = "v2" if hasattr(self.model, "encoder") else "v1"
|
||||
self.mask_threshold = float(getattr(self.model, "mask_threshold", 0.5))
|
||||
nt = getattr(self.model, "num_tokens_range", (1200, 2500 if self.version == "v1" else 3600))
|
||||
self.num_tokens_range = (int(nt[0]), int(nt[1]))
|
||||
|
||||
def infer(self, image: torch.Tensor, num_tokens: Optional[int] = None,
|
||||
resolution_level: int = 9, fov_x: Optional[Union[Number, torch.Tensor]] = None,
|
||||
force_projection: bool = True, apply_mask: bool = True,
|
||||
apply_metric_scale: bool = True
|
||||
) -> Dict[str, torch.Tensor]:
|
||||
"""Run a single MoGe forward + post-process pass. image is (B, 3, H, W) in [0, 1]."""
|
||||
comfy.model_management.load_model_gpu(self.patcher)
|
||||
image = image.to(device=self.load_device, dtype=self.dtype)
|
||||
H, W = image.shape[-2:]
|
||||
aspect_ratio = W / H
|
||||
|
||||
if num_tokens is None:
|
||||
lo, hi = self.num_tokens_range
|
||||
num_tokens = int(lo + (resolution_level / 9) * (hi - lo))
|
||||
|
||||
out = self.model.forward(image, num_tokens=num_tokens)
|
||||
points = out["points"].float() # recover_focal_shift goes through scipy on CPU; needs fp32.
|
||||
mask_binary = out["mask"] > self.mask_threshold
|
||||
normal = out.get("normal")
|
||||
metric_scale = out.get("metric_scale")
|
||||
|
||||
diag = (1 + aspect_ratio ** 2) ** 0.5
|
||||
|
||||
def focal_from_fov_deg(deg):
|
||||
fov = torch.as_tensor(deg, device=points.device, dtype=points.dtype)
|
||||
return aspect_ratio / diag / torch.tan(torch.deg2rad(fov / 2))
|
||||
|
||||
if fov_x is None:
|
||||
focal, shift = recover_focal_shift(points, mask_binary)
|
||||
# Fall back to 60 deg FoV when the least-squares solver flips the focal sign.
|
||||
bad = ~torch.isfinite(focal) | (focal <= 0)
|
||||
if bool(bad.any()):
|
||||
focal = torch.where(bad, focal_from_fov_deg(60.0), focal)
|
||||
_, shift = recover_focal_shift(points, mask_binary, focal=focal)
|
||||
else:
|
||||
focal = focal_from_fov_deg(fov_x).expand(points.shape[0])
|
||||
_, shift = recover_focal_shift(points, mask_binary, focal=focal)
|
||||
|
||||
f_diag = focal / 2 * diag
|
||||
half = torch.tensor(0.5, device=points.device, dtype=points.dtype)
|
||||
intrinsics = intrinsics_from_focal_center(f_diag / aspect_ratio, f_diag, half, half)
|
||||
points[..., 2] = points[..., 2] + shift[..., None, None]
|
||||
# v2 only: filter mask by depth>0 to drop metric-scale negative-depth artifacts.
|
||||
if self.version == "v2":
|
||||
mask_binary = mask_binary & (points[..., 2] > 0)
|
||||
depth = points[..., 2].clone()
|
||||
|
||||
if force_projection:
|
||||
points = depth_map_to_point_map(depth, intrinsics=intrinsics)
|
||||
|
||||
if apply_metric_scale and metric_scale is not None:
|
||||
points = points * metric_scale[:, None, None, None]
|
||||
depth = depth * metric_scale[:, None, None]
|
||||
|
||||
if apply_mask:
|
||||
points = torch.where(mask_binary[..., None], points, torch.full_like(points, float("inf")))
|
||||
depth = torch.where(mask_binary, depth, torch.full_like(depth, float("inf")))
|
||||
if normal is not None:
|
||||
normal = torch.where(mask_binary[..., None], normal, torch.zeros_like(normal))
|
||||
|
||||
result = {"points": points, "depth": depth, "intrinsics": intrinsics, "mask": mask_binary}
|
||||
if normal is not None:
|
||||
result["normal"] = normal
|
||||
return result
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Building blocks for MoGe: residual conv stack, resamplers, MLP, DINOv2 encoder, v1 head."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional, Sequence, Tuple, Union
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
|
||||
import comfy.ops
|
||||
from comfy.image_encoders.dino2 import Dinov2Model
|
||||
|
||||
from .geometry import normalized_view_plane_uv
|
||||
|
||||
|
||||
def _conv2d(operations, c_in: int, c_out: int, k: int = 3, *, dtype=None, device=None):
|
||||
return operations.Conv2d(c_in, c_out, kernel_size=k, padding=k // 2, padding_mode="replicate", dtype=dtype, device=device)
|
||||
|
||||
|
||||
def _view_plane_uv_grid(batch: int, height: int, width: int, aspect_ratio: float, dtype, device) -> torch.Tensor:
|
||||
"""Batched normalized view-plane UV grid as a (B, 2, H, W) tensor."""
|
||||
uv = normalized_view_plane_uv(width, height, aspect_ratio=aspect_ratio, dtype=dtype, device=device)
|
||||
return uv.permute(2, 0, 1).unsqueeze(0).expand(batch, -1, -1, -1)
|
||||
|
||||
|
||||
def _concat_view_plane_uv(x: torch.Tensor, aspect_ratio: float) -> torch.Tensor:
|
||||
"""Append a 2-channel normalized view-plane UV grid to x along the channel dim."""
|
||||
uv = _view_plane_uv_grid(x.shape[0], x.shape[-2], x.shape[-1], aspect_ratio, x.dtype, x.device)
|
||||
return torch.cat([x, uv], dim=1)
|
||||
|
||||
|
||||
class ResidualConvBlock(nn.Module):
|
||||
def __init__(self, channels: int, hidden_channels: Optional[int] = None, in_norm: str = "layer_norm", hidden_norm: str = "group_norm",
|
||||
dtype=None, device=None, operations=comfy.ops.manual_cast):
|
||||
super().__init__()
|
||||
hidden_channels = hidden_channels if hidden_channels is not None else channels
|
||||
|
||||
in_norm_layer = operations.GroupNorm(1, channels, dtype=dtype, device=device) if in_norm == "layer_norm" else nn.Identity()
|
||||
hidden_norm_layer = (operations.GroupNorm(max(hidden_channels // 32, 1), hidden_channels, dtype=dtype, device=device)
|
||||
if hidden_norm == "group_norm" else nn.Identity())
|
||||
|
||||
self.layers = nn.Sequential(
|
||||
in_norm_layer, nn.ReLU(), _conv2d(operations, channels, hidden_channels, dtype=dtype, device=device),
|
||||
hidden_norm_layer, nn.ReLU(), _conv2d(operations, hidden_channels, channels, dtype=dtype, device=device),
|
||||
)
|
||||
|
||||
def forward(self, x):
|
||||
return self.layers(x) + x
|
||||
|
||||
|
||||
class Resampler(nn.Sequential):
|
||||
"""2x upsampler: ConvTranspose2d(2x2) or bilinear upsample, followed by a 3x3 conv."""
|
||||
|
||||
def __init__(self, in_channels: int, out_channels: int, type_: str, dtype=None, device=None, operations=comfy.ops.manual_cast):
|
||||
if type_ == "conv_transpose":
|
||||
up = operations.ConvTranspose2d(in_channels, out_channels, kernel_size=2, stride=2, dtype=dtype, device=device)
|
||||
conv_in = out_channels
|
||||
else: # "bilinear"
|
||||
up = nn.Upsample(scale_factor=2, mode="bilinear", align_corners=False)
|
||||
conv_in = in_channels
|
||||
super().__init__(up, _conv2d(operations, conv_in, out_channels, dtype=dtype, device=device))
|
||||
|
||||
|
||||
class MLP(nn.Sequential):
|
||||
def __init__(self, dims: Sequence[int], dtype=None, device=None, operations=comfy.ops.manual_cast):
|
||||
layers = []
|
||||
for d_in, d_out in zip(dims[:-2], dims[1:-1]):
|
||||
layers.append(operations.Linear(d_in, d_out, dtype=dtype, device=device))
|
||||
layers.append(nn.ReLU(inplace=True))
|
||||
layers.append(operations.Linear(dims[-2], dims[-1], dtype=dtype, device=device))
|
||||
super().__init__(*layers)
|
||||
|
||||
|
||||
class ConvStack(nn.Module):
|
||||
def __init__(self, dim_in: List[Optional[int]], dim_res_blocks: List[int], dim_out: List[Optional[int]], resamplers: List[str],
|
||||
num_res_blocks: List[int], dim_times_res_block_hidden: int = 1, res_block_in_norm: str = "layer_norm", res_block_hidden_norm: str = "group_norm",
|
||||
dtype=None, device=None, operations=comfy.ops.manual_cast):
|
||||
super().__init__()
|
||||
|
||||
self.input_blocks = nn.ModuleList([
|
||||
(_conv2d(operations, d_in, d_res, k=1, dtype=dtype, device=device)
|
||||
if d_in is not None else nn.Identity())
|
||||
for d_in, d_res in zip(dim_in, dim_res_blocks)
|
||||
])
|
||||
|
||||
self.resamplers = nn.ModuleList([
|
||||
Resampler(prev, succ, type_=r, dtype=dtype, device=device, operations=operations)
|
||||
for prev, succ, r in zip(dim_res_blocks[:-1], dim_res_blocks[1:], resamplers)
|
||||
])
|
||||
|
||||
self.res_blocks = nn.ModuleList([
|
||||
nn.Sequential(*[
|
||||
ResidualConvBlock(d_res, dim_times_res_block_hidden * d_res, in_norm=res_block_in_norm, hidden_norm=res_block_hidden_norm, dtype=dtype, device=device, operations=operations)
|
||||
for _ in range(num_res_blocks[i])
|
||||
])
|
||||
for i, d_res in enumerate(dim_res_blocks)
|
||||
])
|
||||
|
||||
self.output_blocks = nn.ModuleList([
|
||||
(_conv2d(operations, d_res, d_out, k=1, dtype=dtype, device=device)
|
||||
if d_out is not None else nn.Identity())
|
||||
for d_out, d_res in zip(dim_out, dim_res_blocks)
|
||||
])
|
||||
|
||||
def forward(self, in_features: List[Optional[torch.Tensor]]):
|
||||
out_features = []
|
||||
x = None
|
||||
for i in range(len(self.res_blocks)):
|
||||
feat = self.input_blocks[i](in_features[i]) if in_features[i] is not None else None
|
||||
if i == 0:
|
||||
x = feat
|
||||
elif feat is not None:
|
||||
x = x + feat
|
||||
x = self.res_blocks[i](x)
|
||||
out_features.append(self.output_blocks[i](x))
|
||||
if i < len(self.res_blocks) - 1:
|
||||
x = self.resamplers[i](x)
|
||||
return out_features
|
||||
|
||||
|
||||
class DINOv2Encoder(nn.Module):
|
||||
"""Comfy DINOv2 backbone with per-layer 1x1 projection heads."""
|
||||
|
||||
def __init__(self, backbone: dict, intermediate_layers: List[int], dim_out: int, dtype=None, device=None, operations=comfy.ops.manual_cast):
|
||||
super().__init__()
|
||||
self.intermediate_layers = list(intermediate_layers)
|
||||
dim_features = backbone["hidden_size"]
|
||||
self.backbone = Dinov2Model(backbone, dtype, device, operations)
|
||||
self.output_projections = nn.ModuleList([
|
||||
_conv2d(operations, dim_features, dim_out, k=1, dtype=dtype, device=device)
|
||||
for _ in range(len(self.intermediate_layers))
|
||||
])
|
||||
self.register_buffer("image_mean", torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1))
|
||||
self.register_buffer("image_std", torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1))
|
||||
|
||||
def forward(self, image: torch.Tensor, token_rows: int, token_cols: int,
|
||||
return_class_token: bool = False) -> Union[torch.Tensor, Tuple[torch.Tensor, torch.Tensor]]:
|
||||
image_14 = F.interpolate(image, (token_rows * 14, token_cols * 14), mode="bilinear", align_corners=False, antialias=True)
|
||||
image_14 = (image_14 - self.image_mean) / self.image_std
|
||||
feats = self.backbone.get_intermediate_layers(image_14, self.intermediate_layers, apply_norm=True)
|
||||
x = torch.stack([
|
||||
proj(feat.permute(0, 2, 1).unflatten(2, (token_rows, token_cols)).contiguous())
|
||||
for proj, (feat, _cls) in zip(self.output_projections, feats)
|
||||
], dim=1).sum(dim=1)
|
||||
if return_class_token:
|
||||
return x, feats[-1][1]
|
||||
return x
|
||||
|
||||
|
||||
class HeadV1(nn.Module):
|
||||
"""v1 head: 4 backbone-feature projections -> shared upsample stack -> per-target output convs (points, mask)."""
|
||||
|
||||
NUM_FEATURES = 4
|
||||
DIM_PROJ = 512
|
||||
DIM_OUT = (3, 1) # 3 channels for points, 1 for mask
|
||||
LAST_CONV_CHANNELS = 32
|
||||
|
||||
def __init__(self, dim_in: int, dim_upsample: List[int] = (256, 128, 128), num_res_blocks: int = 1, dim_times_res_block_hidden: int = 1,
|
||||
dtype=None, device=None, operations=comfy.ops.manual_cast):
|
||||
super().__init__()
|
||||
self.projects = nn.ModuleList([
|
||||
_conv2d(operations, dim_in, self.DIM_PROJ, k=1, dtype=dtype, device=device)
|
||||
for _ in range(self.NUM_FEATURES)
|
||||
])
|
||||
def upsampler(in_ch, out_ch):
|
||||
return nn.Sequential(
|
||||
operations.ConvTranspose2d(in_ch, out_ch, kernel_size=2, stride=2, dtype=dtype, device=device),
|
||||
_conv2d(operations, out_ch, out_ch, dtype=dtype, device=device),
|
||||
)
|
||||
|
||||
in_chs = [self.DIM_PROJ] + list(dim_upsample[:-1])
|
||||
self.upsample_blocks = nn.ModuleList([
|
||||
nn.Sequential(
|
||||
upsampler(in_ch + 2, out_ch),
|
||||
*(ResidualConvBlock(out_ch, dim_times_res_block_hidden * out_ch, dtype=dtype, device=device, operations=operations)
|
||||
for _ in range(num_res_blocks))
|
||||
)
|
||||
for in_ch, out_ch in zip(in_chs, dim_upsample)
|
||||
])
|
||||
self.output_block = nn.ModuleList([
|
||||
nn.Sequential(
|
||||
_conv2d(operations, dim_upsample[-1] + 2, self.LAST_CONV_CHANNELS, dtype=dtype, device=device),
|
||||
nn.ReLU(inplace=True),
|
||||
_conv2d(operations, self.LAST_CONV_CHANNELS, d_out, k=1, dtype=dtype, device=device),
|
||||
)
|
||||
for d_out in self.DIM_OUT
|
||||
])
|
||||
|
||||
def forward(self, hidden_states, image: torch.Tensor):
|
||||
img_h, img_w = image.shape[-2:]
|
||||
patch_h, patch_w = img_h // 14, img_w // 14
|
||||
aspect = img_w / img_h
|
||||
x = torch.stack([
|
||||
proj(feat.permute(0, 2, 1).unflatten(2, (patch_h, patch_w)).contiguous())
|
||||
for proj, (feat, _cls) in zip(self.projects, hidden_states)
|
||||
], dim=1).sum(dim=1)
|
||||
|
||||
for block in self.upsample_blocks:
|
||||
x = block(_concat_view_plane_uv(x, aspect))
|
||||
|
||||
x = F.interpolate(x, (img_h, img_w), mode="bilinear", align_corners=False)
|
||||
x = _concat_view_plane_uv(x, aspect)
|
||||
return [block(x) for block in self.output_block]
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Panorama (equirectangular) inference helpers for MoGe.
|
||||
|
||||
Splits an equirect into 12 perspective views via an icosahedron camera rig, runs
|
||||
the model per view, and stitches per-view distance maps back into a single
|
||||
equirect distance map via a multi-scale Poisson + gradient sparse solve.
|
||||
Image sampling uses F.grid_sample (GPU); the sparse solve uses lsmr (CPU).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
from scipy.ndimage import convolve, map_coordinates
|
||||
from scipy.sparse import vstack, csr_array
|
||||
from scipy.sparse.linalg import lsmr
|
||||
|
||||
|
||||
def _icosahedron_directions() -> np.ndarray:
|
||||
"""12 icosahedron-vertex directions (non-normalised, matching upstream's vertex order)."""
|
||||
A = (1.0 + np.sqrt(5.0)) / 2.0
|
||||
return np.array([
|
||||
[0, 1, A], [0, -1, A], [0, 1, -A], [0, -1, -A],
|
||||
[1, A, 0], [-1, A, 0], [1, -A, 0], [-1, -A, 0],
|
||||
[A, 0, 1], [A, 0, -1], [-A, 0, 1], [-A, 0, -1],
|
||||
], dtype=np.float32)
|
||||
|
||||
|
||||
def _intrinsics_from_fov(fov_x_rad: float, fov_y_rad: float) -> np.ndarray:
|
||||
"""Normalised-image (unit-square) K matrix."""
|
||||
fx = 0.5 / np.tan(fov_x_rad / 2)
|
||||
fy = 0.5 / np.tan(fov_y_rad / 2)
|
||||
return np.array([[fx, 0, 0.5], [0, fy, 0.5], [0, 0, 1]], dtype=np.float32)
|
||||
|
||||
|
||||
def _extrinsics_look_at(eye: np.ndarray, target: np.ndarray, up: np.ndarray) -> np.ndarray:
|
||||
"""OpenCV-convention world->camera extrinsics for an array of look-at targets (N, 4, 4)."""
|
||||
eye = np.asarray(eye, dtype=np.float32)
|
||||
target = np.asarray(target, dtype=np.float32)
|
||||
up = np.asarray(up, dtype=np.float32)
|
||||
if target.ndim == 1:
|
||||
target = target[None]
|
||||
|
||||
fwd = target - eye
|
||||
fwd = fwd / np.linalg.norm(fwd, axis=-1, keepdims=True).clip(1e-12)
|
||||
right = np.cross(fwd, up)
|
||||
right_norm = np.linalg.norm(right, axis=-1, keepdims=True)
|
||||
# Fall back to an arbitrary perpendicular if forward is parallel to up.
|
||||
parallel = right_norm.squeeze(-1) < 1e-6
|
||||
if parallel.any():
|
||||
alt_up = np.array([1, 0, 0], dtype=np.float32)
|
||||
right = np.where(parallel[:, None], np.cross(fwd, alt_up), right)
|
||||
right_norm = np.linalg.norm(right, axis=-1, keepdims=True)
|
||||
right = right / right_norm.clip(1e-12)
|
||||
new_up = np.cross(fwd, right)
|
||||
|
||||
R = np.stack([right, new_up, fwd], axis=-2)
|
||||
t = -np.einsum("nij,j->ni", R, eye)
|
||||
E = np.zeros((R.shape[0], 4, 4), dtype=np.float32)
|
||||
E[:, :3, :3] = R
|
||||
E[:, :3, 3] = t
|
||||
E[:, 3, 3] = 1.0
|
||||
return E
|
||||
|
||||
|
||||
def get_panorama_cameras() -> Tuple[np.ndarray, List[np.ndarray]]:
|
||||
"""Returns (extrinsics (12, 4, 4), [intrinsics] * 12) for icosahedron views at 90 deg FoV."""
|
||||
targets = _icosahedron_directions()
|
||||
eye = np.zeros(3, dtype=np.float32)
|
||||
up = np.array([0, 0, 1], dtype=np.float32)
|
||||
extrinsics = _extrinsics_look_at(eye, targets, up)
|
||||
K = _intrinsics_from_fov(np.deg2rad(90.0), np.deg2rad(90.0))
|
||||
return extrinsics, [K] * len(targets)
|
||||
|
||||
|
||||
def spherical_uv_to_directions(uv: np.ndarray) -> np.ndarray:
|
||||
"""Equirect UV in [0, 1] -> 3D unit-direction (Z up)."""
|
||||
theta = (1 - uv[..., 0]) * (2 * np.pi)
|
||||
phi = uv[..., 1] * np.pi
|
||||
return np.stack([
|
||||
np.sin(phi) * np.cos(theta),
|
||||
np.sin(phi) * np.sin(theta),
|
||||
np.cos(phi),
|
||||
], axis=-1).astype(np.float32)
|
||||
|
||||
|
||||
def directions_to_spherical_uv(directions: np.ndarray) -> np.ndarray:
|
||||
"""3D direction -> equirect UV in [0, 1]."""
|
||||
n = np.linalg.norm(directions, axis=-1, keepdims=True).clip(1e-12)
|
||||
d = directions / n
|
||||
u = 1 - np.arctan2(d[..., 1], d[..., 0]) / (2 * np.pi) % 1.0
|
||||
v = np.arccos(d[..., 2].clip(-1, 1)) / np.pi
|
||||
return np.stack([u, v], axis=-1).astype(np.float32)
|
||||
|
||||
|
||||
def _uv_grid(H: int, W: int) -> np.ndarray:
|
||||
"""Pixel-center UV grid in [0, 1]; (H, W, 2)."""
|
||||
u = (np.arange(W, dtype=np.float32) + 0.5) / W
|
||||
v = (np.arange(H, dtype=np.float32) + 0.5) / H
|
||||
return np.stack(np.meshgrid(u, v, indexing="xy"), axis=-1)
|
||||
|
||||
|
||||
def _unproject_cv(uv: np.ndarray, depth: np.ndarray,
|
||||
extrinsics: np.ndarray, intrinsics: np.ndarray) -> np.ndarray:
|
||||
"""Back-project pixels into world coords (OpenCV convention)."""
|
||||
pix = np.concatenate([uv, np.ones_like(uv[..., :1])], axis=-1)
|
||||
K_inv = np.linalg.inv(intrinsics)
|
||||
cam = pix @ K_inv.T * depth[..., None]
|
||||
cam_h = np.concatenate([cam, np.ones_like(cam[..., :1])], axis=-1)
|
||||
E_inv = np.linalg.inv(extrinsics)
|
||||
return (cam_h @ E_inv.T)[..., :3]
|
||||
|
||||
|
||||
def _project_cv(points: np.ndarray, extrinsics: np.ndarray, intrinsics: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
||||
"""World coords -> (uv, depth) in the camera (OpenCV convention)."""
|
||||
pts_h = np.concatenate([points, np.ones_like(points[..., :1])], axis=-1)
|
||||
cam = pts_h @ extrinsics.T
|
||||
cam_xyz = cam[..., :3]
|
||||
depth = cam_xyz[..., 2]
|
||||
proj = cam_xyz @ intrinsics.T
|
||||
uv = proj[..., :2] / proj[..., 2:3].clip(1e-12)
|
||||
return uv.astype(np.float32), depth.astype(np.float32)
|
||||
|
||||
|
||||
def _grid_sample_uv(img_bchw: torch.Tensor, uv: torch.Tensor, mode: str = "bilinear") -> torch.Tensor:
|
||||
"""Sample img_bchw at UV-in-[0,1] coords uv of shape (B, H, W, 2); replicate-border."""
|
||||
grid = uv * 2.0 - 1.0
|
||||
return F.grid_sample(img_bchw, grid, mode=mode, padding_mode="border", align_corners=False)
|
||||
|
||||
|
||||
def split_panorama_image(image: torch.Tensor, extrinsics: np.ndarray, intrinsics: List[np.ndarray], resolution: int) -> torch.Tensor:
|
||||
"""(3, Hp, Wp) equirect on any device -> (N, 3, R, R) perspective crops on the same device."""
|
||||
device = image.device
|
||||
N = len(extrinsics)
|
||||
uv = _uv_grid(resolution, resolution)
|
||||
sample_uvs = []
|
||||
for i in range(N):
|
||||
world = _unproject_cv(uv, np.ones(uv.shape[:-1], dtype=np.float32), extrinsics[i], intrinsics[i])
|
||||
sample_uvs.append(directions_to_spherical_uv(world))
|
||||
sample_uvs = np.stack(sample_uvs, axis=0)
|
||||
|
||||
img_bchw = image.unsqueeze(0).expand(N, -1, -1, -1).contiguous()
|
||||
sample_uvs_t = torch.from_numpy(sample_uvs).to(device=device, dtype=image.dtype)
|
||||
return _grid_sample_uv(img_bchw, sample_uvs_t, mode="bilinear")
|
||||
|
||||
|
||||
def _poisson_equation(W: int, H: int, wrap_x: bool = False, wrap_y: bool = False):
|
||||
"""Sparse Laplacian operator over the H x W grid."""
|
||||
grid_index = np.arange(H * W).reshape(H, W)
|
||||
grid_index = np.pad(grid_index, ((0, 0), (1, 1)), mode="wrap" if wrap_x else "edge")
|
||||
grid_index = np.pad(grid_index, ((1, 1), (0, 0)), mode="wrap" if wrap_y else "edge")
|
||||
|
||||
data = np.array([[-4, 1, 1, 1, 1]], dtype=np.float32).repeat(H * W, axis=0).reshape(-1)
|
||||
indices = np.stack([
|
||||
grid_index[1:-1, 1:-1],
|
||||
grid_index[:-2, 1:-1], grid_index[2:, 1:-1],
|
||||
grid_index[1:-1, :-2], grid_index[1:-1, 2:],
|
||||
], axis=-1).reshape(-1)
|
||||
indptr = np.arange(0, H * W * 5 + 1, 5)
|
||||
return csr_array((data, indices, indptr), shape=(H * W, H * W))
|
||||
|
||||
|
||||
def _grad_equation(W: int, H: int, wrap_x: bool = False, wrap_y: bool = False):
|
||||
"""Sparse forward-difference operator over the H x W grid."""
|
||||
grid_index = np.arange(W * H).reshape(H, W)
|
||||
if wrap_x:
|
||||
grid_index = np.pad(grid_index, ((0, 0), (0, 1)), mode="wrap")
|
||||
if wrap_y:
|
||||
grid_index = np.pad(grid_index, ((0, 1), (0, 0)), mode="wrap")
|
||||
|
||||
data = np.concatenate([
|
||||
np.concatenate([
|
||||
np.ones((grid_index.shape[0], grid_index.shape[1] - 1), dtype=np.float32).reshape(-1, 1),
|
||||
-np.ones((grid_index.shape[0], grid_index.shape[1] - 1), dtype=np.float32).reshape(-1, 1),
|
||||
], axis=1).reshape(-1),
|
||||
np.concatenate([
|
||||
np.ones((grid_index.shape[0] - 1, grid_index.shape[1]), dtype=np.float32).reshape(-1, 1),
|
||||
-np.ones((grid_index.shape[0] - 1, grid_index.shape[1]), dtype=np.float32).reshape(-1, 1),
|
||||
], axis=1).reshape(-1),
|
||||
])
|
||||
indices = np.concatenate([
|
||||
np.concatenate([grid_index[:, :-1].reshape(-1, 1), grid_index[:, 1:].reshape(-1, 1)], axis=1).reshape(-1),
|
||||
np.concatenate([grid_index[:-1, :].reshape(-1, 1), grid_index[1:, :].reshape(-1, 1)], axis=1).reshape(-1),
|
||||
])
|
||||
nx = grid_index.shape[0] * (grid_index.shape[1] - 1)
|
||||
ny = (grid_index.shape[0] - 1) * grid_index.shape[1]
|
||||
indptr = np.arange(0, nx * 2 + ny * 2 + 1, 2)
|
||||
return csr_array((data, indices, indptr), shape=(nx + ny, H * W))
|
||||
|
||||
|
||||
def _scipy_remap_bilinear(img: np.ndarray, sample_pixels: np.ndarray, mode: str = "bilinear") -> np.ndarray:
|
||||
"""Bilinear/nearest sampling at fractional pixel coords; out-of-range clamps to nearest border."""
|
||||
H, W = img.shape[:2]
|
||||
yy = np.clip(sample_pixels[..., 1], 0, H - 1)
|
||||
xx = np.clip(sample_pixels[..., 0], 0, W - 1)
|
||||
order = 1 if mode == "bilinear" else 0
|
||||
if img.ndim == 2:
|
||||
return map_coordinates(img, [yy, xx], order=order, mode="nearest").astype(img.dtype)
|
||||
out = np.stack([
|
||||
map_coordinates(img[..., c], [yy, xx], order=order, mode="nearest")
|
||||
for c in range(img.shape[-1])
|
||||
], axis=-1)
|
||||
return out.astype(img.dtype)
|
||||
|
||||
|
||||
def merge_panorama_depth(width: int, height: int,
|
||||
distance_maps: List[np.ndarray], pred_masks: List[np.ndarray],
|
||||
extrinsics: List[np.ndarray], intrinsics: List[np.ndarray],
|
||||
on_view: Optional[Callable[[], None]] = None,
|
||||
on_solve_start: Optional[Callable[[int, int], None]] = None,
|
||||
on_solve_end: Optional[Callable[[int, int], None]] = None,
|
||||
) -> Tuple[np.ndarray, np.ndarray]:
|
||||
"""Stitch per-view distance maps into a single equirect distance map.
|
||||
|
||||
Recursive multi-scale solve: solves at half resolution first and uses that as the lsmr init
|
||||
for the full-resolution solve. Optional callbacks fire per view processed and around each
|
||||
lsmr solve so callers can drive a progress bar.
|
||||
"""
|
||||
|
||||
if max(width, height) > 256:
|
||||
coarse_depth, _ = merge_panorama_depth(width // 2, height // 2,
|
||||
distance_maps, pred_masks, extrinsics, intrinsics,
|
||||
on_view=on_view,
|
||||
on_solve_start=on_solve_start,
|
||||
on_solve_end=on_solve_end)
|
||||
t = torch.from_numpy(coarse_depth).unsqueeze(0).unsqueeze(0)
|
||||
t = F.interpolate(t, size=(height, width), mode="bilinear", align_corners=False)
|
||||
depth_init = t.squeeze().numpy().astype(np.float32)
|
||||
else:
|
||||
depth_init = None
|
||||
|
||||
spherical_directions = spherical_uv_to_directions(_uv_grid(height, width))
|
||||
|
||||
pano_log_grad_maps, pano_grad_masks = [], []
|
||||
pano_log_lap_maps, pano_lap_masks = [], []
|
||||
pano_pred_masks: List[np.ndarray] = []
|
||||
|
||||
for i in range(len(distance_maps)):
|
||||
proj_uv, proj_depth = _project_cv(spherical_directions, extrinsics[i], intrinsics[i])
|
||||
proj_valid = (proj_depth > 0) & (proj_uv > 0).all(axis=-1) & (proj_uv < 1).all(axis=-1)
|
||||
|
||||
Hd, Wd = distance_maps[i].shape[:2]
|
||||
proj_pixels = np.clip(proj_uv, 0, 1) * np.array([Wd - 1, Hd - 1], dtype=np.float32)
|
||||
|
||||
log_dist = np.log(np.clip(distance_maps[i], 1e-6, None))
|
||||
sampled = _scipy_remap_bilinear(log_dist, proj_pixels, mode="bilinear")
|
||||
pano_log = np.where(proj_valid, sampled, 0.0).astype(np.float32)
|
||||
|
||||
sampled_mask = _scipy_remap_bilinear(pred_masks[i].astype(np.uint8), proj_pixels, mode="nearest")
|
||||
pano_pred = proj_valid & (sampled_mask > 0)
|
||||
|
||||
# Equirect wraps horizontally but not vertically: wrap pad along x, edge pad along y.
|
||||
padded = np.pad(pano_log, ((0, 0), (0, 1)), mode="wrap")
|
||||
gx, gy = padded[:, :-1] - padded[:, 1:], padded[:-1, :] - padded[1:, :]
|
||||
padded_m = np.pad(pano_pred, ((0, 0), (0, 1)), mode="wrap")
|
||||
mx, my = padded_m[:, :-1] & padded_m[:, 1:], padded_m[:-1, :] & padded_m[1:, :]
|
||||
pano_log_grad_maps.append((gx, gy))
|
||||
pano_grad_masks.append((mx, my))
|
||||
|
||||
padded = np.pad(pano_log, ((1, 1), (0, 0)), mode="edge")
|
||||
padded = np.pad(padded, ((0, 0), (1, 1)), mode="wrap")
|
||||
lap_kernel = np.array([[0, 1, 0], [1, -4, 1], [0, 1, 0]], dtype=np.float32)
|
||||
lap = convolve(padded, lap_kernel)[1:-1, 1:-1]
|
||||
padded_m = np.pad(pano_pred, ((1, 1), (0, 0)), mode="edge")
|
||||
padded_m = np.pad(padded_m, ((0, 0), (1, 1)), mode="wrap")
|
||||
m_kernel = np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], dtype=np.uint8)
|
||||
lap_mask = convolve(padded_m.astype(np.uint8), m_kernel)[1:-1, 1:-1] == 5
|
||||
pano_log_lap_maps.append(lap)
|
||||
pano_lap_masks.append(lap_mask)
|
||||
pano_pred_masks.append(pano_pred)
|
||||
|
||||
if on_view is not None:
|
||||
on_view()
|
||||
|
||||
gx = np.stack([m[0] for m in pano_log_grad_maps], axis=0)
|
||||
gy = np.stack([m[1] for m in pano_log_grad_maps], axis=0)
|
||||
mx = np.stack([m[0] for m in pano_grad_masks], axis=0)
|
||||
my = np.stack([m[1] for m in pano_grad_masks], axis=0)
|
||||
gx_avg = (gx * mx).sum(axis=0) / mx.sum(axis=0).clip(1e-3)
|
||||
gy_avg = (gy * my).sum(axis=0) / my.sum(axis=0).clip(1e-3)
|
||||
|
||||
laps = np.stack(pano_log_lap_maps, axis=0)
|
||||
lap_masks = np.stack(pano_lap_masks, axis=0)
|
||||
lap_avg = (laps * lap_masks).sum(axis=0) / lap_masks.sum(axis=0).clip(1e-3)
|
||||
|
||||
grad_x_mask = mx.any(axis=0).reshape(-1)
|
||||
grad_y_mask = my.any(axis=0).reshape(-1)
|
||||
grad_mask = np.concatenate([grad_x_mask, grad_y_mask])
|
||||
lap_mask_flat = lap_masks.any(axis=0).reshape(-1)
|
||||
|
||||
A = vstack([
|
||||
_grad_equation(width, height, wrap_x=True, wrap_y=False)[grad_mask],
|
||||
_poisson_equation(width, height, wrap_x=True, wrap_y=False)[lap_mask_flat],
|
||||
])
|
||||
b = np.concatenate([
|
||||
gx_avg.reshape(-1)[grad_x_mask],
|
||||
gy_avg.reshape(-1)[grad_y_mask],
|
||||
lap_avg.reshape(-1)[lap_mask_flat],
|
||||
])
|
||||
x0 = np.log(np.clip(depth_init, 1e-6, None)).reshape(-1) if depth_init is not None else None
|
||||
|
||||
if on_solve_start is not None:
|
||||
on_solve_start(width, height)
|
||||
x, *_ = lsmr(A, b, atol=1e-5, btol=1e-5, x0=x0, show=False)
|
||||
if on_solve_end is not None:
|
||||
on_solve_end(width, height)
|
||||
|
||||
pano_depth = np.exp(x).reshape(height, width).astype(np.float32)
|
||||
pano_mask = np.any(pano_pred_masks, axis=0)
|
||||
return pano_depth, pano_mask
|
||||
+22
-6
@@ -97,12 +97,14 @@ def load_lora(lora, to_load, log_missing=True):
|
||||
|
||||
def model_lora_keys_clip(model, key_map={}):
|
||||
sdk = model.state_dict().keys()
|
||||
prefix_set = set()
|
||||
for k in sdk:
|
||||
if k.endswith(".weight"):
|
||||
key_map["text_encoders.{}".format(k[:-len(".weight")])] = k #generic lora format without any weird key names
|
||||
tp = k.find(".transformer.") #also map without wrapper prefix for composite text encoder models
|
||||
if tp > 0 and not k.startswith("clip_"):
|
||||
key_map["text_encoders.{}".format(k[tp + 1:-len(".weight")])] = k
|
||||
prefix_set.add(k.split('.')[0])
|
||||
|
||||
text_model_lora_key = "lora_te_text_model_encoder_layers_{}_{}"
|
||||
clip_l_present = False
|
||||
@@ -163,6 +165,13 @@ def model_lora_keys_clip(model, key_map={}):
|
||||
lora_key = "lora_te1_{}".format(l_key.replace(".", "_"))
|
||||
key_map[lora_key] = k
|
||||
|
||||
if len(prefix_set) == 1:
|
||||
full_prefix = "{}.transformer.model.".format(next(iter(prefix_set))) # kohya anima and maybe other single TE models that use a single llama arch based te
|
||||
for k in sdk:
|
||||
if k.endswith(".weight"):
|
||||
if k.startswith(full_prefix):
|
||||
l_key = k[len(full_prefix):-len(".weight")]
|
||||
key_map["lora_te_{}".format(l_key.replace(".", "_"))] = k
|
||||
|
||||
k = "clip_g.transformer.text_projection.weight"
|
||||
if k in sdk:
|
||||
@@ -475,16 +484,23 @@ def calculate_weight(patches, weight, key, intermediate_dtype=torch.float32, ori
|
||||
|
||||
return weight
|
||||
|
||||
def prefetch_prepared_value(value, allocate_buffer, stream):
|
||||
def prefetch_prepared_value(value, counter, destination, stream, copy):
|
||||
if isinstance(value, torch.Tensor):
|
||||
dest = allocate_buffer(comfy.memory_management.vram_aligned_size(value))
|
||||
comfy.model_management.cast_to_gathered([value], dest, non_blocking=True, stream=stream)
|
||||
size = comfy.memory_management.vram_aligned_size(value)
|
||||
offset = counter[0]
|
||||
counter[0] += size
|
||||
if destination is None:
|
||||
return value
|
||||
|
||||
dest = destination[offset:offset + size]
|
||||
if copy:
|
||||
comfy.model_management.cast_to_gathered([value], dest, non_blocking=True, stream=stream)
|
||||
return comfy.memory_management.interpret_gathered_like([value], dest)[0]
|
||||
elif isinstance(value, weight_adapter.WeightAdapterBase):
|
||||
return type(value)(value.loaded_keys, prefetch_prepared_value(value.weights, allocate_buffer, stream))
|
||||
return type(value)(value.loaded_keys, prefetch_prepared_value(value.weights, counter, destination, stream, copy))
|
||||
elif isinstance(value, tuple):
|
||||
return tuple(prefetch_prepared_value(item, allocate_buffer, stream) for item in value)
|
||||
return tuple(prefetch_prepared_value(item, counter, destination, stream, copy) for item in value)
|
||||
elif isinstance(value, list):
|
||||
return [prefetch_prepared_value(item, allocate_buffer, stream) for item in value]
|
||||
return [prefetch_prepared_value(item, counter, destination, stream, copy) for item in value]
|
||||
|
||||
return value
|
||||
|
||||
@@ -15,7 +15,7 @@ class TensorFileSlice(NamedTuple):
|
||||
size: int
|
||||
|
||||
|
||||
def read_tensor_file_slice_into(tensor, destination):
|
||||
def read_tensor_file_slice_into(tensor, destination, stream=None, destination2=None):
|
||||
|
||||
if isinstance(tensor, QuantizedTensor):
|
||||
if not isinstance(destination, QuantizedTensor):
|
||||
@@ -23,12 +23,17 @@ def read_tensor_file_slice_into(tensor, destination):
|
||||
if tensor._layout_cls != destination._layout_cls:
|
||||
return False
|
||||
|
||||
if not read_tensor_file_slice_into(tensor._qdata, destination._qdata):
|
||||
if not read_tensor_file_slice_into(tensor._qdata, destination._qdata, stream=stream,
|
||||
destination2=(destination2._qdata if destination2 is not None else None)):
|
||||
return False
|
||||
|
||||
dst_orig_dtype = destination._params.orig_dtype
|
||||
destination._params.copy_from(tensor._params, non_blocking=False)
|
||||
destination._params = dataclasses.replace(destination._params, orig_dtype=dst_orig_dtype)
|
||||
if destination2 is not None:
|
||||
dst_orig_dtype = destination2._params.orig_dtype
|
||||
destination2._params.copy_from(destination._params, non_blocking=True)
|
||||
destination2._params = dataclasses.replace(destination2._params, orig_dtype=dst_orig_dtype)
|
||||
return True
|
||||
|
||||
info = getattr(tensor.untyped_storage(), "_comfy_tensor_file_slice", None)
|
||||
@@ -48,6 +53,17 @@ def read_tensor_file_slice_into(tensor, destination):
|
||||
if info.size == 0:
|
||||
return True
|
||||
|
||||
hostbuf = getattr(destination.untyped_storage(), "_comfy_hostbuf", None)
|
||||
if hostbuf is not None:
|
||||
stream_ptr = getattr(stream, "cuda_stream", 0) if stream is not None else 0
|
||||
device_ptr = destination2.data_ptr() if destination2 is not None else 0
|
||||
hostbuf.read_file_slice(file_obj, info.offset, info.size,
|
||||
offset=destination.data_ptr() - hostbuf.get_raw_address(),
|
||||
stream=stream_ptr,
|
||||
device_ptr=device_ptr,
|
||||
device=None if destination2 is None else destination2.device.index)
|
||||
return True
|
||||
|
||||
buf_type = ctypes.c_ubyte * info.size
|
||||
view = memoryview(buf_type.from_address(destination.data_ptr()))
|
||||
|
||||
@@ -151,7 +167,7 @@ def set_ram_cache_release_state(callback, headroom):
|
||||
extra_ram_release_callback = callback
|
||||
RAM_CACHE_HEADROOM = max(0, int(headroom))
|
||||
|
||||
def extra_ram_release(target):
|
||||
def extra_ram_release(target, free_active=False):
|
||||
if extra_ram_release_callback is None:
|
||||
return 0
|
||||
return extra_ram_release_callback(target)
|
||||
return extra_ram_release_callback(target, free_active=free_active)
|
||||
|
||||
@@ -58,6 +58,8 @@ import comfy.ldm.cogvideo.model
|
||||
import comfy.ldm.rt_detr.rtdetr_v4
|
||||
import comfy.ldm.ernie.model
|
||||
import comfy.ldm.sam3.detector
|
||||
import comfy.ldm.hidream_o1.model
|
||||
from comfy.ldm.hidream_o1.conditioning import build_extra_conds
|
||||
|
||||
import comfy.model_management
|
||||
import comfy.patcher_extension
|
||||
@@ -811,6 +813,85 @@ class StableAudio1(BaseModel):
|
||||
sd["{}{}".format(k, l)] = s[l]
|
||||
return sd
|
||||
|
||||
class StableAudio3(BaseModel):
|
||||
def __init__(self, model_config, seconds_total_embedder_weights, padding_embedding=None, model_type=ModelType.FLOW, device=None):
|
||||
super().__init__(model_config, model_type, device=device, unet_model=comfy.ldm.audio.dit.AudioDiffusionTransformer)
|
||||
self.seconds_total_embedder = comfy.ldm.audio.embedders.NumberConditioner(768, min_val=0, max_val=384, fourier_features_type=model_config.unet_config["timestep_features_type"])
|
||||
self.seconds_total_embedder.load_state_dict(seconds_total_embedder_weights)
|
||||
if padding_embedding is not None:
|
||||
self.padding_embedding = torch.nn.Parameter(padding_embedding, requires_grad=False)
|
||||
else:
|
||||
self.padding_embedding = None
|
||||
|
||||
def concat_cond(self, **kwargs):
|
||||
noise = kwargs.get("noise", None)
|
||||
image = kwargs.get("concat_latent_image", None)
|
||||
|
||||
if image is None:
|
||||
shape_image = list(noise.shape)
|
||||
image = torch.zeros(shape_image, dtype=noise.dtype, layout=noise.layout, device=noise.device)
|
||||
else:
|
||||
image = self.process_latent_in(image)
|
||||
# TODO: scale if not match
|
||||
image = utils.resize_to_batch_size(image, noise.shape[0])
|
||||
|
||||
mask = kwargs.get("concat_mask", kwargs.get("denoise_mask", None))
|
||||
if mask is None:
|
||||
mask = torch.zeros_like(noise)[:, :1]
|
||||
else:
|
||||
if mask.shape[1] != 1:
|
||||
mask = torch.mean(mask, dim=1, keepdim=True)
|
||||
mask = 1.0 - mask
|
||||
# TODO: scale if not match
|
||||
mask = utils.resize_to_batch_size(mask, noise.shape[0])
|
||||
|
||||
return torch.cat((mask, image), dim=1)
|
||||
|
||||
def extra_conds(self, **kwargs):
|
||||
out = {}
|
||||
|
||||
concat_cond = self.concat_cond(**kwargs)
|
||||
if concat_cond is not None:
|
||||
out['local_add_cond'] = comfy.conds.CONDNoiseShape(concat_cond)
|
||||
|
||||
noise = kwargs.get("noise", None)
|
||||
device = kwargs["device"]
|
||||
|
||||
seconds_total = kwargs.get("seconds_total", int(noise.shape[-1] / 10.7666))
|
||||
seconds_total_embed = self.seconds_total_embedder([seconds_total])[0].to(device)
|
||||
|
||||
global_embed = seconds_total_embed.reshape((1, -1))
|
||||
out['global_embed'] = comfy.conds.CONDRegular(global_embed)
|
||||
|
||||
cross_attn = kwargs.get("cross_attn", None)
|
||||
if cross_attn is not None:
|
||||
cross_attn = cross_attn.to(device)
|
||||
if self.padding_embedding is not None:
|
||||
pe = self.padding_embedding.to(device=device, dtype=cross_attn.dtype)
|
||||
max_text_tokens = self.model_config.unet_config.get("max_text_tokens", 256)
|
||||
n_text = cross_attn.shape[1]
|
||||
if n_text < max_text_tokens:
|
||||
pad = pe.view(1, 1, -1).expand(cross_attn.shape[0], max_text_tokens - n_text, -1)
|
||||
cross_attn = torch.cat([cross_attn, pad], dim=1)
|
||||
cross_attn = torch.cat([cross_attn, seconds_total_embed.repeat((cross_attn.shape[0], 1, 1))], dim=1)
|
||||
out['c_crossattn'] = comfy.conds.CONDRegular(cross_attn)
|
||||
|
||||
return out
|
||||
|
||||
def state_dict_for_saving(self, unet_state_dict, clip_state_dict=None, vae_state_dict=None, clip_vision_state_dict=None):
|
||||
sd = super().state_dict_for_saving(unet_state_dict, clip_state_dict=clip_state_dict, vae_state_dict=vae_state_dict, clip_vision_state_dict=clip_vision_state_dict)
|
||||
|
||||
d = {"conditioner.conditioners.seconds_total.": self.seconds_total_embedder.state_dict()}
|
||||
|
||||
for k in d:
|
||||
s = d[k]
|
||||
for l in s:
|
||||
sd["{}{}".format(k, l)] = s[l]
|
||||
|
||||
if self.padding_embedding is not None:
|
||||
sd["conditioner.conditioners.prompt.padding_embedding"] = self.padding_embedding.data
|
||||
return sd
|
||||
|
||||
|
||||
class HunyuanDiT(BaseModel):
|
||||
def __init__(self, model_config, model_type=ModelType.V_PREDICTION, device=None):
|
||||
@@ -1674,6 +1755,39 @@ class HiDream(BaseModel):
|
||||
out['image_cond'] = comfy.conds.CONDNoiseShape(self.process_latent_in(image_cond))
|
||||
return out
|
||||
|
||||
class HiDreamO1(BaseModel):
|
||||
"""HiDream-O1-Image: pixel-space DiT (no VAE). Refs from HiDreamO1ReferenceImages and tokens from the stub TE flow through
|
||||
extra_conds; the heavy preprocessing lives in comfy.ldm.hidream_o1.conditioning."""
|
||||
PATCH_SIZE = 32
|
||||
|
||||
def __init__(self, model_config, model_type=ModelType.FLOW, device=None):
|
||||
super().__init__(model_config, model_type, device=device, unet_model=comfy.ldm.hidream_o1.model.HiDreamO1Transformer)
|
||||
|
||||
def extra_conds(self, **kwargs):
|
||||
out = super().extra_conds(**kwargs)
|
||||
text_input_ids = kwargs.get("text_input_ids", None)
|
||||
noise = kwargs.get("noise", None)
|
||||
if text_input_ids is None or noise is None:
|
||||
return out
|
||||
|
||||
# handle area conds
|
||||
area = kwargs.get("area", None)
|
||||
if area is not None:
|
||||
crop_h = min(noise.shape[-2] - area[2], area[0])
|
||||
crop_w = min(noise.shape[-1] - area[3], area[1])
|
||||
noise = torch.empty((noise.shape[0], 3, crop_h, crop_w), dtype=noise.dtype, device=noise.device)
|
||||
|
||||
conds = build_extra_conds(
|
||||
text_input_ids, noise,
|
||||
ref_images=kwargs.get("reference_latents", None),
|
||||
target_patch_size=self.PATCH_SIZE,
|
||||
)
|
||||
for k, v in conds.items():
|
||||
# ar_len is a Python int (precomputed to avoid a GPU sync in forward).
|
||||
cls = comfy.conds.CONDConstant if k == "ar_len" else comfy.conds.CONDRegular
|
||||
out[k] = cls(v)
|
||||
return out
|
||||
|
||||
class Chroma(Flux):
|
||||
def __init__(self, model_config, model_type=ModelType.FLUX, device=None, unet_model=comfy.ldm.chroma.model.Chroma):
|
||||
super().__init__(model_config, model_type, device=device, unet_model=unet_model)
|
||||
|
||||
@@ -116,6 +116,45 @@ def detect_unet_config(state_dict, key_prefix, metadata=None):
|
||||
if '{}transformer.rotary_pos_emb.inv_freq'.format(key_prefix) in state_dict_keys: #stable audio dit
|
||||
unet_config = {}
|
||||
unet_config["audio_model"] = "dit1.0"
|
||||
unet_config["global_cond_dim"] = state_dict['{}to_global_embed.0.weight'.format(key_prefix)].shape[1]
|
||||
cond_embed = state_dict['{}to_cond_embed.0.weight'.format(key_prefix)]
|
||||
unet_config["project_cond_tokens"] = cond_embed.shape[0] != cond_embed.shape[1]
|
||||
unet_config["embed_dim"] = state_dict['{}to_timestep_embed.0.weight'.format(key_prefix)].shape[0]
|
||||
mem_tokens = state_dict.get('{}transformer.memory_tokens'.format(key_prefix), None)
|
||||
to_qkv = state_dict.get('{}transformer.layers.0.self_attn.to_qkv.weight'.format(key_prefix), None)
|
||||
differential = False
|
||||
if to_qkv is not None:
|
||||
if to_qkv.shape[0] == to_qkv.shape[1] * 5:
|
||||
differential = True
|
||||
if mem_tokens is not None:
|
||||
unet_config["num_memory_tokens"] = mem_tokens.shape[0]
|
||||
if '{}transformer.layers.0.self_attn.q_norm.weight'.format(key_prefix) in state_dict:
|
||||
unet_config["attn_kwargs"] = {"qk_norm": "ln", "feat_scale": True}
|
||||
rms_norm = state_dict.get('{}transformer.layers.0.self_attn.q_norm.gamma'.format(key_prefix), None)
|
||||
if rms_norm is not None:
|
||||
unet_config["attn_kwargs"] = {"qk_norm": "rms", "differential": differential}
|
||||
unet_config["norm_type"] = "rms_norm"
|
||||
unet_config["num_heads"] = unet_config["embed_dim"] // rms_norm.shape[0]
|
||||
|
||||
if '{}timestep_features.weight'.format(key_prefix) in state_dict:
|
||||
unet_config["timestep_features_type"] = "learned"
|
||||
else:
|
||||
unet_config["timestep_features_type"] = "expo"
|
||||
|
||||
io_channels = state_dict['{}postprocess_conv.weight'.format(key_prefix)].shape[0]
|
||||
unet_config["io_channels"] = io_channels
|
||||
unet_config["input_concat_dim"] = state_dict['{}transformer.project_in.weight'.format(key_prefix)].shape[1] - io_channels
|
||||
|
||||
local_add_cond = state_dict.get('{}transformer.layers.0.to_local_embed.0.weight'.format(key_prefix), None)
|
||||
if local_add_cond is not None:
|
||||
unet_config["local_add_cond_dim"] = local_add_cond.shape[1]
|
||||
|
||||
global_cond_embed = state_dict.get('{}transformer.global_cond_embedder.0.weight'.format(key_prefix), None)
|
||||
if global_cond_embed is not None:
|
||||
unet_config["global_cond_shared_embed"] = True
|
||||
unet_config["global_cond_type"] = "adaLN"
|
||||
|
||||
unet_config["depth"] = count_blocks(state_dict_keys, '{}transformer.layers.'.format(key_prefix) + '{}.')
|
||||
return unet_config
|
||||
|
||||
if '{}double_layers.0.attn.w1q.weight'.format(key_prefix) in state_dict_keys: #aura flow dit
|
||||
@@ -620,6 +659,9 @@ def detect_unet_config(state_dict, key_prefix, metadata=None):
|
||||
dit_config["guidance_cond_proj_dim"] = None#f"{key_prefix}t_embedder.cond_proj.weight" in state_dict_keys
|
||||
return dit_config
|
||||
|
||||
if '{}t_embedder1.mlp.0.weight'.format(key_prefix) in state_dict_keys and '{}x_embedder.proj1.weight'.format(key_prefix) in state_dict_keys: # HiDream-O1
|
||||
return {"image_model": "hidream_o1"}
|
||||
|
||||
if '{}caption_projection.0.linear.weight'.format(key_prefix) in state_dict_keys: # HiDream
|
||||
dit_config = {}
|
||||
dit_config["image_model"] = "hidream"
|
||||
|
||||
+117
-72
@@ -31,6 +31,7 @@ from contextlib import nullcontext
|
||||
import comfy.memory_management
|
||||
import comfy.utils
|
||||
import comfy.quant_ops
|
||||
import comfy_aimdo.host_buffer
|
||||
import comfy_aimdo.vram_buffer
|
||||
|
||||
class VRAMState(Enum):
|
||||
@@ -495,6 +496,14 @@ except:
|
||||
|
||||
current_loaded_models = []
|
||||
|
||||
DIRTY_MMAPS = set()
|
||||
|
||||
PIN_PRESSURE_HYSTERESIS = 256 * 1024 * 1024
|
||||
|
||||
#Freeing registerables on pressure does imply a GPU sync, so go big on
|
||||
#the hysteresis so each expensive sync gives us back a good chunk.
|
||||
REGISTERABLE_PIN_HYSTERESIS = 2048 * 1024 * 1024
|
||||
|
||||
def module_size(module):
|
||||
module_mem = 0
|
||||
sd = module.state_dict()
|
||||
@@ -503,27 +512,46 @@ def module_size(module):
|
||||
module_mem += t.nbytes
|
||||
return module_mem
|
||||
|
||||
def module_mmap_residency(module, free=False):
|
||||
mmap_touched_mem = 0
|
||||
module_mem = 0
|
||||
bounced_mmaps = set()
|
||||
sd = module.state_dict()
|
||||
for k in sd:
|
||||
t = sd[k]
|
||||
module_mem += t.nbytes
|
||||
storage = t._qdata.untyped_storage() if isinstance(t, comfy.quant_ops.QuantizedTensor) else t.untyped_storage()
|
||||
if not getattr(storage, "_comfy_tensor_mmap_touched", False):
|
||||
continue
|
||||
mmap_touched_mem += t.nbytes
|
||||
if not free:
|
||||
continue
|
||||
storage._comfy_tensor_mmap_touched = False
|
||||
mmap_obj = storage._comfy_tensor_mmap_refs[0]
|
||||
if mmap_obj in bounced_mmaps:
|
||||
continue
|
||||
mmap_obj.bounce()
|
||||
bounced_mmaps.add(mmap_obj)
|
||||
return mmap_touched_mem, module_mem
|
||||
def mark_mmap_dirty(storage):
|
||||
mmap_refs = getattr(storage, "_comfy_tensor_mmap_refs", None)
|
||||
if mmap_refs is not None:
|
||||
DIRTY_MMAPS.add(mmap_refs[0])
|
||||
|
||||
def free_pins(size, evict_active=False):
|
||||
freed_total = 0
|
||||
for loaded_model in reversed(current_loaded_models):
|
||||
if size <= 0:
|
||||
return freed_total
|
||||
model = loaded_model.model
|
||||
if model is not None and model.is_dynamic() and (evict_active or not model.model.dynamic_pins[model.load_device]["active"]):
|
||||
freed = model.partially_unload_ram(size)
|
||||
freed_total += freed
|
||||
size -= freed
|
||||
return freed_total
|
||||
|
||||
def ensure_pin_budget(size, evict_active=False):
|
||||
shortfall = size + comfy.memory_management.RAM_CACHE_HEADROOM / 2 - psutil.virtual_memory().available
|
||||
if shortfall <= 0:
|
||||
return True
|
||||
|
||||
to_free = shortfall + PIN_PRESSURE_HYSTERESIS
|
||||
return free_pins(to_free, evict_active=evict_active) >= shortfall
|
||||
|
||||
def ensure_pin_registerable(size, evict_active=False):
|
||||
shortfall = TOTAL_PINNED_MEMORY + size - MAX_PINNED_MEMORY
|
||||
if MAX_PINNED_MEMORY <= 0:
|
||||
return False
|
||||
if shortfall <= 0:
|
||||
return True
|
||||
|
||||
shortfall += REGISTERABLE_PIN_HYSTERESIS
|
||||
for loaded_model in reversed(current_loaded_models):
|
||||
model = loaded_model.model
|
||||
if model is not None and model.is_dynamic() and (evict_active or not model.model.dynamic_pins[model.load_device]["active"]):
|
||||
shortfall -= model.unregister_inactive_pins(shortfall)
|
||||
if shortfall <= 0:
|
||||
return True
|
||||
return shortfall <= REGISTERABLE_PIN_HYSTERESIS
|
||||
|
||||
class LoadedModel:
|
||||
def __init__(self, model):
|
||||
@@ -553,9 +581,6 @@ class LoadedModel:
|
||||
def model_memory(self):
|
||||
return self.model.model_size()
|
||||
|
||||
def model_mmap_residency(self, free=False):
|
||||
return self.model.model_mmap_residency(free=free)
|
||||
|
||||
def model_loaded_memory(self):
|
||||
return self.model.loaded_size()
|
||||
|
||||
@@ -635,15 +660,9 @@ WINDOWS = any(platform.win32_ver())
|
||||
|
||||
EXTRA_RESERVED_VRAM = 400 * 1024 * 1024
|
||||
if WINDOWS:
|
||||
import comfy.windows
|
||||
EXTRA_RESERVED_VRAM = 600 * 1024 * 1024 #Windows is higher because of the shared vram issue
|
||||
if total_vram > (15 * 1024): # more extra reserved vram on 16GB+ cards
|
||||
EXTRA_RESERVED_VRAM += 100 * 1024 * 1024
|
||||
def get_free_ram():
|
||||
return comfy.windows.get_free_ram()
|
||||
else:
|
||||
def get_free_ram():
|
||||
return psutil.virtual_memory().available
|
||||
|
||||
if args.reserve_vram is not None:
|
||||
EXTRA_RESERVED_VRAM = args.reserve_vram * 1024 * 1024 * 1024
|
||||
@@ -657,7 +676,6 @@ def minimum_inference_memory():
|
||||
|
||||
def free_memory(memory_required, device, keep_loaded=[], for_dynamic=False, pins_required=0, ram_required=0):
|
||||
cleanup_models_gc()
|
||||
comfy.memory_management.extra_ram_release(max(pins_required, ram_required))
|
||||
unloaded_model = []
|
||||
can_unload = []
|
||||
unloaded_models = []
|
||||
@@ -673,11 +691,9 @@ def free_memory(memory_required, device, keep_loaded=[], for_dynamic=False, pins
|
||||
for x in can_unload_sorted:
|
||||
i = x[-1]
|
||||
memory_to_free = 1e32
|
||||
pins_to_free = 1e32
|
||||
if not DISABLE_SMART_MEMORY or device is None:
|
||||
if current_loaded_models[i].model.is_dynamic() and (not DISABLE_SMART_MEMORY or device is None):
|
||||
memory_to_free = 0 if device is None else memory_required - get_free_memory(device)
|
||||
pins_to_free = pins_required - get_free_ram()
|
||||
if current_loaded_models[i].model.is_dynamic() and for_dynamic:
|
||||
if for_dynamic:
|
||||
#don't actually unload dynamic models for the sake of other dynamic models
|
||||
#as that works on-demand.
|
||||
memory_required -= current_loaded_models[i].model.loaded_size()
|
||||
@@ -685,18 +701,6 @@ def free_memory(memory_required, device, keep_loaded=[], for_dynamic=False, pins
|
||||
if memory_to_free > 0 and current_loaded_models[i].model_unload(memory_to_free):
|
||||
logging.debug(f"Unloading {current_loaded_models[i].model.model.__class__.__name__}")
|
||||
unloaded_model.append(i)
|
||||
if pins_to_free > 0:
|
||||
logging.debug(f"PIN Unloading {current_loaded_models[i].model.model.__class__.__name__}")
|
||||
current_loaded_models[i].model.partially_unload_ram(pins_to_free)
|
||||
|
||||
for x in can_unload_sorted:
|
||||
i = x[-1]
|
||||
ram_to_free = ram_required - psutil.virtual_memory().available
|
||||
if ram_to_free <= 0 and i not in unloaded_model:
|
||||
continue
|
||||
resident_memory, _ = current_loaded_models[i].model_mmap_residency(free=True)
|
||||
if resident_memory > 0:
|
||||
logging.debug(f"RAM Unloading {current_loaded_models[i].model.model.__class__.__name__}")
|
||||
|
||||
for i in sorted(unloaded_model, reverse=True):
|
||||
unloaded_models.append(current_loaded_models.pop(i))
|
||||
@@ -762,29 +766,16 @@ def load_models_gpu(models, memory_required=0, force_patch_weights=False, minimu
|
||||
model_to_unload.model.detach(unpatch_all=False)
|
||||
model_to_unload.model_finalizer.detach()
|
||||
|
||||
|
||||
total_memory_required = {}
|
||||
total_pins_required = {}
|
||||
total_ram_required = {}
|
||||
for loaded_model in models_to_load:
|
||||
device = loaded_model.device
|
||||
total_memory_required[device] = total_memory_required.get(device, 0) + loaded_model.model_memory_required(device)
|
||||
resident_memory, model_memory = loaded_model.model_mmap_residency()
|
||||
pinned_memory = loaded_model.model.pinned_memory_size()
|
||||
#FIXME: This can over-free the pins as it budgets to pin the entire model. We should
|
||||
#make this JIT to keep as much pinned as possible.
|
||||
pins_required = model_memory - pinned_memory
|
||||
ram_required = model_memory - resident_memory
|
||||
total_pins_required[device] = total_pins_required.get(device, 0) + pins_required
|
||||
total_ram_required[device] = total_ram_required.get(device, 0) + ram_required
|
||||
|
||||
for device in total_memory_required:
|
||||
if device != torch.device("cpu"):
|
||||
free_memory(total_memory_required[device] * 1.1 + extra_mem,
|
||||
device,
|
||||
for_dynamic=free_for_dynamic,
|
||||
pins_required=total_pins_required[device],
|
||||
ram_required=total_ram_required[device])
|
||||
for_dynamic=free_for_dynamic)
|
||||
|
||||
for device in total_memory_required:
|
||||
if device != torch.device("cpu"):
|
||||
@@ -1180,6 +1171,7 @@ STREAM_CAST_BUFFERS = {}
|
||||
LARGEST_CASTED_WEIGHT = (None, 0)
|
||||
STREAM_AIMDO_CAST_BUFFERS = {}
|
||||
LARGEST_AIMDO_CASTED_WEIGHT = (None, 0)
|
||||
STREAM_PIN_BUFFERS = {}
|
||||
|
||||
DEFAULT_AIMDO_CAST_BUFFER_RESERVATION_SIZE = 16 * 1024 ** 3
|
||||
|
||||
@@ -1220,21 +1212,66 @@ def get_aimdo_cast_buffer(offload_stream, device):
|
||||
if cast_buffer is None:
|
||||
cast_buffer = comfy_aimdo.vram_buffer.VRAMBuffer(DEFAULT_AIMDO_CAST_BUFFER_RESERVATION_SIZE, device.index)
|
||||
STREAM_AIMDO_CAST_BUFFERS[offload_stream] = cast_buffer
|
||||
|
||||
return cast_buffer
|
||||
|
||||
def get_pin_buffer(offload_stream):
|
||||
pin_buffer = STREAM_PIN_BUFFERS.get(offload_stream, None)
|
||||
if pin_buffer is None:
|
||||
pin_buffer = comfy_aimdo.host_buffer.HostBuffer(0, 0, pinned_hostbuf_size(8 * 1024**3))
|
||||
STREAM_PIN_BUFFERS[offload_stream] = pin_buffer
|
||||
elif offload_stream is not None:
|
||||
event = getattr(pin_buffer, "_comfy_event", None)
|
||||
if event is not None:
|
||||
event.synchronize()
|
||||
delattr(pin_buffer, "_comfy_event")
|
||||
return pin_buffer
|
||||
|
||||
def resize_pin_buffer(pin_buffer, size):
|
||||
global TOTAL_PINNED_MEMORY
|
||||
old_size = pin_buffer.size
|
||||
if size <= old_size:
|
||||
return True
|
||||
growth = size - old_size
|
||||
comfy.memory_management.extra_ram_release(comfy.memory_management.RAM_CACHE_HEADROOM)
|
||||
ensure_pin_budget(growth, evict_active=True)
|
||||
ensure_pin_registerable(growth, evict_active=True)
|
||||
try:
|
||||
pin_buffer.extend(size=size, reallocate=True)
|
||||
except RuntimeError:
|
||||
return False
|
||||
TOTAL_PINNED_MEMORY += pin_buffer.size - old_size
|
||||
return True
|
||||
|
||||
def reset_cast_buffers():
|
||||
global TOTAL_PINNED_MEMORY
|
||||
global LARGEST_CASTED_WEIGHT
|
||||
global LARGEST_AIMDO_CASTED_WEIGHT
|
||||
|
||||
LARGEST_CASTED_WEIGHT = (None, 0)
|
||||
LARGEST_AIMDO_CASTED_WEIGHT = (None, 0)
|
||||
for offload_stream in set(STREAM_CAST_BUFFERS) | set(STREAM_AIMDO_CAST_BUFFERS):
|
||||
for offload_stream in set(STREAM_CAST_BUFFERS) | set(STREAM_AIMDO_CAST_BUFFERS) | set(STREAM_PIN_BUFFERS):
|
||||
if offload_stream is not None:
|
||||
offload_stream.synchronize()
|
||||
synchronize()
|
||||
|
||||
for mmap_obj in DIRTY_MMAPS:
|
||||
mmap_obj.bounce()
|
||||
DIRTY_MMAPS.clear()
|
||||
|
||||
for pin_buffer in STREAM_PIN_BUFFERS.values():
|
||||
TOTAL_PINNED_MEMORY -= pin_buffer.size
|
||||
TOTAL_PINNED_MEMORY = max(0, TOTAL_PINNED_MEMORY)
|
||||
|
||||
for loaded_model in current_loaded_models:
|
||||
model = loaded_model.model
|
||||
if model is not None and model.is_dynamic():
|
||||
model.model.dynamic_pins[model.load_device]["active"] = False
|
||||
model.partially_unload_ram(1e30, subsets=[ "patches" ])
|
||||
model.model.dynamic_pins[model.load_device]["patches"] = (comfy_aimdo.host_buffer.HostBuffer(0, 8 * 1024 * 1024, pinned_hostbuf_size(model.model_size())), [], [-1], [0])
|
||||
|
||||
STREAM_CAST_BUFFERS.clear()
|
||||
STREAM_AIMDO_CAST_BUFFERS.clear()
|
||||
STREAM_PIN_BUFFERS.clear()
|
||||
soft_empty_cache()
|
||||
|
||||
def get_offload_stream(device):
|
||||
@@ -1280,7 +1317,7 @@ def sync_stream(device, stream):
|
||||
current_stream(device).wait_stream(stream)
|
||||
|
||||
|
||||
def cast_to_gathered(tensors, r, non_blocking=False, stream=None):
|
||||
def cast_to_gathered(tensors, r, non_blocking=False, stream=None, r2=None):
|
||||
wf_context = nullcontext()
|
||||
if stream is not None:
|
||||
wf_context = stream
|
||||
@@ -1288,17 +1325,20 @@ def cast_to_gathered(tensors, r, non_blocking=False, stream=None):
|
||||
wf_context = wf_context.as_context(stream)
|
||||
|
||||
dest_views = comfy.memory_management.interpret_gathered_like(tensors, r)
|
||||
dest2_views = comfy.memory_management.interpret_gathered_like(tensors, r2) if r2 is not None else None
|
||||
with wf_context:
|
||||
for tensor in tensors:
|
||||
dest_view = dest_views.pop(0)
|
||||
dest2_view = dest2_views.pop(0) if dest2_views is not None else None
|
||||
if tensor is None:
|
||||
continue
|
||||
if comfy.memory_management.read_tensor_file_slice_into(tensor, dest_view):
|
||||
if comfy.memory_management.read_tensor_file_slice_into(tensor, dest_view, stream=stream, destination2=dest2_view):
|
||||
continue
|
||||
storage = tensor._qdata.untyped_storage() if isinstance(tensor, comfy.quant_ops.QuantizedTensor) else tensor.untyped_storage()
|
||||
if hasattr(storage, "_comfy_tensor_mmap_touched"):
|
||||
storage._comfy_tensor_mmap_touched = True
|
||||
mark_mmap_dirty(storage)
|
||||
dest_view.copy_(tensor, non_blocking=non_blocking)
|
||||
if dest2_view is not None:
|
||||
dest2_view.copy_(dest_view, non_blocking=non_blocking)
|
||||
|
||||
|
||||
def cast_to(weight, dtype=None, device=None, non_blocking=False, copy=False, stream=None, r=None):
|
||||
@@ -1339,14 +1379,18 @@ TOTAL_PINNED_MEMORY = 0
|
||||
MAX_PINNED_MEMORY = -1
|
||||
if not args.disable_pinned_memory:
|
||||
if is_nvidia() or is_amd():
|
||||
ram = get_total_memory(torch.device("cpu"))
|
||||
if WINDOWS:
|
||||
MAX_PINNED_MEMORY = get_total_memory(torch.device("cpu")) * 0.40 # Windows limit is apparently 50%
|
||||
MAX_PINNED_MEMORY = ram * 0.40 # Windows limit is apparently 50%
|
||||
else:
|
||||
MAX_PINNED_MEMORY = get_total_memory(torch.device("cpu")) * 0.90
|
||||
MAX_PINNED_MEMORY = ram * 0.90
|
||||
logging.info("Enabled pinned memory {}".format(MAX_PINNED_MEMORY // (1024 * 1024)))
|
||||
|
||||
PINNING_ALLOWED_TYPES = set(["Tensor", "Parameter", "QuantizedTensor"])
|
||||
|
||||
def pinned_hostbuf_size(size):
|
||||
return max(0, int(min(size, MAX_PINNED_MEMORY) * 2))
|
||||
|
||||
def discard_cuda_async_error():
|
||||
try:
|
||||
a = torch.tensor([1], dtype=torch.uint8, device=get_torch_device())
|
||||
@@ -1378,8 +1422,8 @@ def pin_memory(tensor):
|
||||
return False
|
||||
|
||||
size = tensor.nbytes
|
||||
if (TOTAL_PINNED_MEMORY + size) > MAX_PINNED_MEMORY:
|
||||
return False
|
||||
comfy.memory_management.extra_ram_release(comfy.memory_management.RAM_CACHE_HEADROOM)
|
||||
ensure_pin_registerable(size)
|
||||
|
||||
ptr = tensor.data_ptr()
|
||||
if ptr == 0:
|
||||
@@ -1416,7 +1460,8 @@ def unpin_memory(tensor):
|
||||
return False
|
||||
|
||||
if torch.cuda.cudart().cudaHostUnregister(ptr) == 0:
|
||||
TOTAL_PINNED_MEMORY -= PINNED_MEMORY.pop(ptr)
|
||||
size = PINNED_MEMORY.pop(ptr)
|
||||
TOTAL_PINNED_MEMORY -= size
|
||||
return True
|
||||
else:
|
||||
logging.warning("Unpin error.")
|
||||
|
||||
+184
-44
@@ -35,6 +35,7 @@ import comfy.model_management
|
||||
import comfy.ops
|
||||
import comfy.patcher_extension
|
||||
import comfy.utils
|
||||
import comfy_aimdo.host_buffer
|
||||
from comfy.comfy_types import UnetWrapperFunction
|
||||
from comfy.quant_ops import QuantizedTensor
|
||||
from comfy.patcher_extension import CallbacksMP, PatcherInjection, WrappersMP
|
||||
@@ -117,6 +118,8 @@ def string_to_seed(data):
|
||||
return comfy.utils.string_to_seed(data)
|
||||
|
||||
class LowVramPatch:
|
||||
is_lowvram_patch = True
|
||||
|
||||
def __init__(self, key, patches, convert_func=None, set_func=None):
|
||||
self.key = key
|
||||
self.patches = patches
|
||||
@@ -124,11 +127,21 @@ class LowVramPatch:
|
||||
self.set_func = set_func
|
||||
self.prepared_patches = None
|
||||
|
||||
def prepare(self, allocate_buffer, stream):
|
||||
self.prepared_patches = [
|
||||
(patch[0], comfy.lora.prefetch_prepared_value(patch[1], allocate_buffer, stream), patch[2], patch[3], patch[4])
|
||||
def memory_required(self):
|
||||
counter = [0]
|
||||
for patch in self.patches[self.key]:
|
||||
comfy.lora.prefetch_prepared_value(patch[1], counter, None, None, False)
|
||||
return counter[0]
|
||||
|
||||
def prepare(self, destination, stream, copy=True, commit=True):
|
||||
counter = [0]
|
||||
prepared_patches = [
|
||||
(patch[0], comfy.lora.prefetch_prepared_value(patch[1], counter, destination, stream, copy), patch[2], patch[3], patch[4])
|
||||
for patch in self.patches[self.key]
|
||||
]
|
||||
if commit:
|
||||
self.prepared_patches = prepared_patches
|
||||
return prepared_patches
|
||||
|
||||
def clear_prepared(self):
|
||||
self.prepared_patches = None
|
||||
@@ -242,6 +255,37 @@ class LazyCastingParam(torch.nn.Parameter):
|
||||
return self.model.patch_weight_to_device(self.key, device_to=self.model.load_device, return_weight=True).to("cpu")
|
||||
|
||||
|
||||
class LazyCastingQuantizedParam:
|
||||
def __init__(self, model, key):
|
||||
self.model = model
|
||||
self.key = key
|
||||
self.cpu_state_dict = None
|
||||
|
||||
def state_dict_tensor(self, state_dict_key):
|
||||
if self.cpu_state_dict is None:
|
||||
weight = self.model.patch_weight_to_device(self.key, device_to=self.model.load_device, return_weight=True)
|
||||
self.cpu_state_dict = {k: v.to("cpu") for k, v in weight.state_dict(self.key).items()}
|
||||
return self.cpu_state_dict[state_dict_key]
|
||||
|
||||
|
||||
class LazyCastingParamPiece(torch.nn.Parameter):
|
||||
def __new__(cls, caster, state_dict_key, tensor):
|
||||
return super().__new__(cls, tensor)
|
||||
|
||||
def __init__(self, caster, state_dict_key, tensor):
|
||||
self.caster = caster
|
||||
self.state_dict_key = state_dict_key
|
||||
|
||||
@property
|
||||
def device(self):
|
||||
return CustomTorchDevice
|
||||
|
||||
def to(self, *args, **kwargs):
|
||||
caster = self.caster
|
||||
del self.caster
|
||||
return caster.state_dict_tensor(self.state_dict_key)
|
||||
|
||||
|
||||
class ModelPatcher:
|
||||
def __init__(self, model, load_device, offload_device, size=0, weight_inplace_update=False):
|
||||
self.size = size
|
||||
@@ -310,9 +354,6 @@ class ModelPatcher:
|
||||
self.size = comfy.model_management.module_size(self.model)
|
||||
return self.size
|
||||
|
||||
def model_mmap_residency(self, free=False):
|
||||
return comfy.model_management.module_mmap_residency(self.model, free=free)
|
||||
|
||||
def loaded_size(self):
|
||||
return self.model.model_loaded_weight_memory
|
||||
|
||||
@@ -1087,8 +1128,12 @@ class ModelPatcher:
|
||||
# Pinned memory pressure tracking is only implemented for DynamicVram loading
|
||||
return 0
|
||||
|
||||
def loaded_ram_size(self):
|
||||
# Loaded RAM pressure tracking is only implemented for DynamicVram loading
|
||||
return 0
|
||||
|
||||
def partially_unload_ram(self, ram_to_unload):
|
||||
pass
|
||||
return 0
|
||||
|
||||
def detach(self, unpatch_all=True):
|
||||
self.eject_model()
|
||||
@@ -1462,21 +1507,45 @@ class ModelPatcher:
|
||||
self.unpatch_hooks()
|
||||
self.clear_cached_hook_weights()
|
||||
|
||||
def state_dict_for_saving(self, clip_state_dict=None, vae_state_dict=None, clip_vision_state_dict=None):
|
||||
unet_state_dict = self.model.diffusion_model.state_dict()
|
||||
for k, v in unet_state_dict.items():
|
||||
def model_state_dict_for_saving(self, model=None, prefix=""):
|
||||
if model is None:
|
||||
model = self.model
|
||||
|
||||
original_state_dict = model.state_dict()
|
||||
output_state_dict = {}
|
||||
keys = list(original_state_dict)
|
||||
while len(keys) > 0:
|
||||
k = keys.pop(0)
|
||||
v = original_state_dict[k]
|
||||
op_keys = k.rsplit('.', 1)
|
||||
if (len(op_keys) < 2) or op_keys[1] not in ["weight", "bias"]:
|
||||
output_state_dict[k] = v
|
||||
continue
|
||||
try:
|
||||
op = comfy.utils.get_attr(self.model.diffusion_model, op_keys[0])
|
||||
op = comfy.utils.get_attr(model, op_keys[0])
|
||||
except:
|
||||
output_state_dict[k] = v
|
||||
continue
|
||||
if not op or not hasattr(op, "comfy_cast_weights") or \
|
||||
(hasattr(op, "comfy_patched_weights") and op.comfy_patched_weights == True):
|
||||
output_state_dict[k] = v
|
||||
continue
|
||||
key = "diffusion_model." + k
|
||||
unet_state_dict[k] = LazyCastingParam(self, key, comfy.utils.get_attr(self.model, key))
|
||||
key = prefix + k
|
||||
weight = comfy.utils.get_attr(self.model, key)
|
||||
if isinstance(weight, QuantizedTensor) and k in original_state_dict:
|
||||
qt_state_dict = weight.state_dict(k)
|
||||
caster = LazyCastingQuantizedParam(self, key)
|
||||
for group_key in (x for x in qt_state_dict if x in original_state_dict):
|
||||
if group_key in keys:
|
||||
keys.remove(group_key)
|
||||
output_state_dict.pop(group_key, "")
|
||||
output_state_dict[group_key] = LazyCastingParamPiece(caster, prefix + group_key, original_state_dict[group_key])
|
||||
continue
|
||||
output_state_dict[k] = LazyCastingParam(self, key, weight)
|
||||
return output_state_dict
|
||||
|
||||
def state_dict_for_saving(self, clip_state_dict=None, vae_state_dict=None, clip_vision_state_dict=None):
|
||||
unet_state_dict = self.model_state_dict_for_saving(self.model.diffusion_model, "diffusion_model.")
|
||||
return self.model.state_dict_for_saving(unet_state_dict, clip_state_dict=clip_state_dict, vae_state_dict=vae_state_dict, clip_vision_state_dict=clip_vision_state_dict)
|
||||
|
||||
def __del__(self):
|
||||
@@ -1495,6 +1564,16 @@ class ModelPatcherDynamic(ModelPatcher):
|
||||
super().__init__(model, load_device, offload_device, size, weight_inplace_update)
|
||||
if not hasattr(self.model, "dynamic_vbars"):
|
||||
self.model.dynamic_vbars = {}
|
||||
if not hasattr(self.model, "dynamic_pins"):
|
||||
self.model.dynamic_pins = {}
|
||||
if self.load_device not in self.model.dynamic_pins:
|
||||
self.model.dynamic_pins[self.load_device] = {
|
||||
"weights": (comfy_aimdo.host_buffer.HostBuffer(0, 0, 0), [], [-1], [0]),
|
||||
"patches": (comfy_aimdo.host_buffer.HostBuffer(0, 0, 0), [], [-1], [0]),
|
||||
"hostbufs_initialized": False,
|
||||
"failed": False,
|
||||
"active": False,
|
||||
}
|
||||
self.non_dynamic_delegate_model = None
|
||||
assert load_device is not None
|
||||
|
||||
@@ -1534,6 +1613,16 @@ class ModelPatcherDynamic(ModelPatcher):
|
||||
#use all ModelPatcherDynamic this is ignored and its all done dynamically.
|
||||
return super().memory_required(input_shape=input_shape) * 1.3 + (1024 ** 3)
|
||||
|
||||
def restore_loaded_backups(self):
|
||||
restored = self.model.model_loaded_weight_memory
|
||||
for key in list(self.backup.keys()):
|
||||
bk = self.backup.pop(key)
|
||||
comfy.utils.set_attr_param(self.model, key, bk.weight)
|
||||
for key in list(self.backup_buffers.keys()):
|
||||
comfy.utils.set_attr_buffer(self.model, key, self.backup_buffers.pop(key))
|
||||
self.model.model_loaded_weight_memory = 0
|
||||
return restored
|
||||
|
||||
|
||||
def load(self, device_to=None, lowvram_model_memory=0, force_patch_weights=False, full_load=False, dirty=False):
|
||||
|
||||
@@ -1550,12 +1639,20 @@ class ModelPatcherDynamic(ModelPatcher):
|
||||
|
||||
num_patches = 0
|
||||
allocated_size = 0
|
||||
self.model.model_loaded_weight_memory = 0
|
||||
self.restore_loaded_backups()
|
||||
|
||||
with self.use_ejected():
|
||||
self.unpatch_hooks()
|
||||
|
||||
vbar = self._vbar_get(create=True)
|
||||
pin_state = self.model.dynamic_pins[self.load_device]
|
||||
if not pin_state["hostbufs_initialized"]:
|
||||
hostbuf_size = comfy.model_management.pinned_hostbuf_size(self.model_size())
|
||||
pin_state["weights"] = (comfy_aimdo.host_buffer.HostBuffer(0, 64 * 1024 * 1024, hostbuf_size), [], [-1], [0])
|
||||
pin_state["patches"] = (comfy_aimdo.host_buffer.HostBuffer(0, 8 * 1024 * 1024, hostbuf_size), [], [-1], [0])
|
||||
pin_state["hostbufs_initialized"] = True
|
||||
pin_state["failed"] = False
|
||||
pin_state["active"] = True
|
||||
if vbar is not None:
|
||||
vbar.prioritize()
|
||||
|
||||
@@ -1581,7 +1678,9 @@ class ModelPatcherDynamic(ModelPatcher):
|
||||
if key in self.patches:
|
||||
if comfy.lora.calculate_shape(self.patches[key], weight, key) != weight.shape:
|
||||
return (True, 0)
|
||||
setattr(m, param_key + "_lowvram_function", LowVramPatch(key, self.patches))
|
||||
lowvram_patch = LowVramPatch(key, self.patches)
|
||||
lowvram_patch._pin_state = pin_state
|
||||
setattr(m, param_key + "_lowvram_function", lowvram_patch)
|
||||
num_patches += 1
|
||||
else:
|
||||
setattr(m, param_key + "_lowvram_function", None)
|
||||
@@ -1598,6 +1697,9 @@ class ModelPatcherDynamic(ModelPatcher):
|
||||
|
||||
def force_load_param(self, param_key, device_to):
|
||||
key = key_param_name_to_key(n, param_key)
|
||||
weight, _, _ = get_key_weight(self.model, key)
|
||||
if weight is None:
|
||||
return
|
||||
if key in self.backup:
|
||||
comfy.utils.set_attr_param(self.model, key, self.backup[key].weight)
|
||||
self.patch_weight_to_device(key, device_to=device_to, force_cast=True)
|
||||
@@ -1607,17 +1709,26 @@ class ModelPatcherDynamic(ModelPatcher):
|
||||
|
||||
if hasattr(m, "comfy_cast_weights"):
|
||||
m.comfy_cast_weights = True
|
||||
m.pin_failed = False
|
||||
m.seed_key = n
|
||||
m._pin_state = pin_state
|
||||
set_dirty(m, dirty)
|
||||
|
||||
force_load, v_weight_size = setup_param(self, m, n, "weight")
|
||||
force_load_bias, v_weight_bias = setup_param(self, m, n, "bias")
|
||||
force_load = force_load or force_load_bias
|
||||
v_weight_size += v_weight_bias
|
||||
#Models that mix tiny and giant weights can causing lopsided stream buffer
|
||||
#rotations and stall. force the tinys over.
|
||||
if module_mem > 16 * 1024:
|
||||
force_load, v_weight_size = setup_param(self, m, n, "weight")
|
||||
force_load_bias, v_weight_bias = setup_param(self, m, n, "bias")
|
||||
force_load = force_load or force_load_bias
|
||||
v_weight_size += v_weight_bias
|
||||
if force_load:
|
||||
logging.info(f"Module {n} has resizing Lora - force loading")
|
||||
else:
|
||||
force_load=True
|
||||
|
||||
if force_load:
|
||||
logging.info(f"Module {n} has resizing Lora - force loading")
|
||||
if hasattr(m, "_v"):
|
||||
comfy_aimdo.model_vbar.vbar_unpin(m._v)
|
||||
delattr(m, "_v")
|
||||
force_load_param(self, "weight", device_to)
|
||||
force_load_param(self, "bias", device_to)
|
||||
else:
|
||||
@@ -1675,33 +1786,62 @@ class ModelPatcherDynamic(ModelPatcher):
|
||||
freed = 0 if vbar is None else vbar.free_memory(memory_to_free)
|
||||
|
||||
if freed < memory_to_free:
|
||||
for key in list(self.backup.keys()):
|
||||
bk = self.backup.pop(key)
|
||||
comfy.utils.set_attr_param(self.model, key, bk.weight)
|
||||
for key in list(self.backup_buffers.keys()):
|
||||
comfy.utils.set_attr_buffer(self.model, key, self.backup_buffers.pop(key))
|
||||
freed += self.model.model_loaded_weight_memory
|
||||
self.model.model_loaded_weight_memory = 0
|
||||
freed += self.restore_loaded_backups()
|
||||
|
||||
return freed
|
||||
|
||||
def pinned_memory_size(self):
|
||||
total = 0
|
||||
loading = self._load_list(for_dynamic=True)
|
||||
for x in loading:
|
||||
_, _, _, _, m, _ = x
|
||||
pin = comfy.pinned_memory.get_pin(m)
|
||||
if pin is not None:
|
||||
total += pin.numel() * pin.element_size()
|
||||
return total
|
||||
def loaded_ram_size(self):
|
||||
return (self.model.dynamic_pins[self.load_device]["weights"][0].size +
|
||||
self.model.dynamic_pins[self.load_device]["patches"][0].size)
|
||||
|
||||
def partially_unload_ram(self, ram_to_unload):
|
||||
loading = self._load_list(for_dynamic=True, default_device=self.offload_device)
|
||||
for x in loading:
|
||||
*_, m, _ = x
|
||||
ram_to_unload -= comfy.pinned_memory.unpin_memory(m)
|
||||
if ram_to_unload <= 0:
|
||||
return
|
||||
def pinned_memory_size(self):
|
||||
return (self.model.dynamic_pins[self.load_device]["weights"][3][0] +
|
||||
self.model.dynamic_pins[self.load_device]["patches"][3][0])
|
||||
|
||||
def unregister_inactive_pins(self, ram_to_unload, subsets=[ "weights", "patches" ]):
|
||||
freed = 0
|
||||
pin_state = self.model.dynamic_pins[self.load_device]
|
||||
for subset in subsets:
|
||||
hostbuf, stack, stack_split, pinned_size = pin_state[subset]
|
||||
split = stack_split[0]
|
||||
while split >= 0:
|
||||
module, offset = stack[split]
|
||||
split -= 1
|
||||
stack_split[0] = split
|
||||
if not module._pin_registered:
|
||||
continue
|
||||
size = module._pin.numel() * module._pin.element_size()
|
||||
if torch.cuda.cudart().cudaHostUnregister(module._pin.data_ptr()) != 0:
|
||||
comfy.model_management.discard_cuda_async_error()
|
||||
continue
|
||||
module._pin_registered = False
|
||||
comfy.model_management.TOTAL_PINNED_MEMORY = max(0, comfy.model_management.TOTAL_PINNED_MEMORY - size)
|
||||
pinned_size[0] = max(0, pinned_size[0] - size)
|
||||
freed += size
|
||||
ram_to_unload -= size
|
||||
if ram_to_unload <= 0:
|
||||
return freed
|
||||
return freed
|
||||
|
||||
def partially_unload_ram(self, ram_to_unload, subsets=[ "weights", "patches" ]):
|
||||
freed = 0
|
||||
pin_state = self.model.dynamic_pins[self.load_device]
|
||||
for subset in subsets:
|
||||
hostbuf, stack, stack_split, pinned_size = pin_state[subset]
|
||||
while len(stack) > 0:
|
||||
module, offset = stack.pop()
|
||||
size = module._pin.numel() * module._pin.element_size()
|
||||
del module._pin
|
||||
hostbuf.truncate(offset, do_unregister=module._pin_registered)
|
||||
stack_split[0] = min(stack_split[0], len(stack) - 1)
|
||||
if module._pin_registered:
|
||||
comfy.model_management.TOTAL_PINNED_MEMORY = max(0, comfy.model_management.TOTAL_PINNED_MEMORY - size)
|
||||
pinned_size[0] = max(0, pinned_size[0] - size)
|
||||
freed += size
|
||||
ram_to_unload -= size
|
||||
if ram_to_unload <= 0:
|
||||
return freed
|
||||
return freed
|
||||
|
||||
def patch_model(self, device_to=None, lowvram_model_memory=0, load_weights=True, force_patch_weights=False):
|
||||
#This isn't used by the core at all and can only be to load a model out of
|
||||
|
||||
+10
-2
@@ -93,7 +93,8 @@ class CONST:
|
||||
|
||||
def noise_scaling(self, sigma, noise, latent_image, max_denoise=False):
|
||||
sigma = reshape_sigma(sigma, noise.ndim)
|
||||
return sigma * noise + (1.0 - sigma) * latent_image
|
||||
s = getattr(self, "noise_scale", 1.0)
|
||||
return sigma * (s * noise) + (1.0 - sigma) * latent_image
|
||||
|
||||
def inverse_noise_scaling(self, sigma, latent):
|
||||
sigma = reshape_sigma(sigma, latent.ndim)
|
||||
@@ -288,7 +289,11 @@ class ModelSamplingDiscreteFlow(torch.nn.Module):
|
||||
else:
|
||||
sampling_settings = {}
|
||||
|
||||
self.set_parameters(shift=sampling_settings.get("shift", 1.0), multiplier=sampling_settings.get("multiplier", 1000))
|
||||
self.set_noise_scale(sampling_settings.get("noise_scale", 1.0))
|
||||
self.set_parameters(
|
||||
shift=sampling_settings.get("shift", 1.0),
|
||||
multiplier=sampling_settings.get("multiplier", 1000),
|
||||
)
|
||||
|
||||
def set_parameters(self, shift=1.0, timesteps=1000, multiplier=1000):
|
||||
self.shift = shift
|
||||
@@ -296,6 +301,9 @@ class ModelSamplingDiscreteFlow(torch.nn.Module):
|
||||
ts = self.sigma((torch.arange(1, timesteps + 1, 1) / timesteps) * multiplier)
|
||||
self.register_buffer('sigmas', ts)
|
||||
|
||||
def set_noise_scale(self, noise_scale):
|
||||
self.noise_scale = float(noise_scale)
|
||||
|
||||
@property
|
||||
def sigma_min(self):
|
||||
return self.sigmas[0]
|
||||
|
||||
+79
-15
@@ -75,6 +75,8 @@ except:
|
||||
|
||||
cast_to = comfy.model_management.cast_to #TODO: remove once no more references
|
||||
|
||||
STREAM_PIN_BUFFER_HEADROOM = 8 * 1024 * 1024
|
||||
|
||||
def cast_to_input(weight, input, non_blocking=False, copy=True):
|
||||
return comfy.model_management.cast_to(weight, input.dtype, input.device, non_blocking=non_blocking, copy=copy)
|
||||
|
||||
@@ -91,6 +93,9 @@ def cast_modules_with_vbar(comfy_modules, dtype, device, bias_dtype, non_blockin
|
||||
offload_stream = None
|
||||
cast_buffer = None
|
||||
cast_buffer_offset = 0
|
||||
stream_pin_hostbuf = None
|
||||
stream_pin_offset = 0
|
||||
stream_pin_queue = []
|
||||
|
||||
def ensure_offload_stream(module, required_size, check_largest):
|
||||
nonlocal offload_stream
|
||||
@@ -124,6 +129,22 @@ def cast_modules_with_vbar(comfy_modules, dtype, device, bias_dtype, non_blockin
|
||||
cast_buffer_offset += buffer_size
|
||||
return buffer
|
||||
|
||||
def get_stream_pin_buffer_offset(buffer_size):
|
||||
nonlocal stream_pin_hostbuf
|
||||
nonlocal stream_pin_offset
|
||||
|
||||
if buffer_size == 0 or offload_stream is None:
|
||||
return None
|
||||
|
||||
if stream_pin_hostbuf is None:
|
||||
stream_pin_hostbuf = comfy.model_management.get_pin_buffer(offload_stream)
|
||||
if stream_pin_hostbuf is None:
|
||||
return None
|
||||
|
||||
offset = stream_pin_offset
|
||||
stream_pin_offset += buffer_size
|
||||
return offset
|
||||
|
||||
for s in comfy_modules:
|
||||
signature = comfy_aimdo.model_vbar.vbar_fault(s._v)
|
||||
resident = comfy_aimdo.model_vbar.vbar_signature_compare(signature, s._v_signature)
|
||||
@@ -162,23 +183,47 @@ def cast_modules_with_vbar(comfy_modules, dtype, device, bias_dtype, non_blockin
|
||||
if xfer_dest is None:
|
||||
xfer_dest = get_cast_buffer(dest_size)
|
||||
|
||||
if signature is None and pin is None:
|
||||
comfy.pinned_memory.pin_memory(s)
|
||||
pin = comfy.pinned_memory.get_pin(s)
|
||||
else:
|
||||
pin = None
|
||||
def cast_maybe_lowvram_patch(xfer_source, xfer_dest, stream):
|
||||
if xfer_source is not None:
|
||||
if getattr(xfer_source, "is_lowvram_patch", False):
|
||||
xfer_source.prepare(xfer_dest, stream, copy=True, commit=False)
|
||||
else:
|
||||
comfy.model_management.cast_to_gathered(xfer_source, xfer_dest, non_blocking=non_blocking, stream=stream)
|
||||
|
||||
if pin is not None:
|
||||
comfy.model_management.cast_to_gathered(xfer_source, pin)
|
||||
xfer_source = [ pin ]
|
||||
#send it over
|
||||
comfy.model_management.cast_to_gathered(xfer_source, xfer_dest, non_blocking=non_blocking, stream=offload_stream)
|
||||
def handle_pin(m, pin, source, dest, subset="weights", size=None):
|
||||
if pin is not None:
|
||||
cast_maybe_lowvram_patch([pin], dest, offload_stream)
|
||||
return
|
||||
if signature is None:
|
||||
comfy.pinned_memory.pin_memory(m, subset=subset, size=size)
|
||||
pin = comfy.pinned_memory.get_pin(m, subset=subset)
|
||||
if pin is not None:
|
||||
if isinstance(source, list):
|
||||
comfy.model_management.cast_to_gathered(source, pin, non_blocking=non_blocking, stream=offload_stream, r2=dest)
|
||||
else:
|
||||
cast_maybe_lowvram_patch(source, pin, None)
|
||||
cast_maybe_lowvram_patch([ pin ], dest, offload_stream)
|
||||
return
|
||||
if pin is None:
|
||||
pin_offset = get_stream_pin_buffer_offset(size)
|
||||
if pin_offset is not None:
|
||||
stream_pin_queue.append((source, pin_offset, size, dest))
|
||||
return
|
||||
cast_maybe_lowvram_patch(source, dest, offload_stream)
|
||||
|
||||
handle_pin(s, pin, xfer_source, xfer_dest, size=dest_size)
|
||||
|
||||
for param_key in ("weight", "bias"):
|
||||
lowvram_fn = getattr(s, param_key + "_lowvram_function", None)
|
||||
if lowvram_fn is not None:
|
||||
lowvram_source = getattr(s, param_key + "_lowvram_function", None)
|
||||
if lowvram_source is not None:
|
||||
ensure_offload_stream(s, cast_buffer_offset, False)
|
||||
lowvram_fn.prepare(lambda size: get_cast_buffer(size), offload_stream)
|
||||
lowvram_size = lowvram_source.memory_required()
|
||||
lowvram_dest = get_cast_buffer(lowvram_size)
|
||||
lowvram_source.prepare(lowvram_dest, None, copy=False, commit=True)
|
||||
|
||||
pin = comfy.pinned_memory.get_pin(lowvram_source, subset="patches")
|
||||
handle_pin(lowvram_source, pin, lowvram_source, lowvram_dest, subset="patches", size=lowvram_size)
|
||||
|
||||
|
||||
prefetch["xfer_dest"] = xfer_dest
|
||||
prefetch["cast_dest"] = cast_dest
|
||||
@@ -186,6 +231,23 @@ def cast_modules_with_vbar(comfy_modules, dtype, device, bias_dtype, non_blockin
|
||||
prefetch["needs_cast"] = needs_cast
|
||||
s._prefetch = prefetch
|
||||
|
||||
if stream_pin_offset > 0:
|
||||
if stream_pin_hostbuf.size < stream_pin_offset:
|
||||
if not comfy.model_management.resize_pin_buffer(stream_pin_hostbuf, stream_pin_offset + STREAM_PIN_BUFFER_HEADROOM):
|
||||
for xfer_source, _, _, xfer_dest in stream_pin_queue:
|
||||
cast_maybe_lowvram_patch(xfer_source, xfer_dest, offload_stream)
|
||||
return offload_stream
|
||||
stream_pin_tensor = comfy_aimdo.torch.hostbuf_to_tensor(stream_pin_hostbuf)
|
||||
stream_pin_tensor.untyped_storage()._comfy_hostbuf = stream_pin_hostbuf
|
||||
for xfer_source, pin_offset, pin_size, xfer_dest in stream_pin_queue:
|
||||
pin = stream_pin_tensor[pin_offset:pin_offset + pin_size]
|
||||
if isinstance(xfer_source, list):
|
||||
comfy.model_management.cast_to_gathered(xfer_source, pin, non_blocking=non_blocking, stream=offload_stream, r2=xfer_dest)
|
||||
else:
|
||||
cast_maybe_lowvram_patch(xfer_source, pin, None)
|
||||
comfy.model_management.cast_to_gathered([ pin ], xfer_dest, non_blocking=non_blocking, stream=offload_stream)
|
||||
stream_pin_hostbuf._comfy_event = offload_stream.record_event()
|
||||
|
||||
return offload_stream
|
||||
|
||||
|
||||
@@ -260,7 +322,7 @@ def resolve_cast_module_with_vbar(s, dtype, device, bias_dtype, compute_dtype, w
|
||||
|
||||
|
||||
def cast_bias_weight(s, input=None, dtype=None, device=None, bias_dtype=None, offloadable=False, compute_dtype=None, want_requant=False):
|
||||
# NOTE: offloadable=False is a a legacy and if you are a custom node author reading this please pass
|
||||
# NOTE: offloadable=False is a legacy mode and if you are a custom node author reading this please pass
|
||||
# offloadable=True and call uncast_bias_weight() after your last usage of the weight/bias. This
|
||||
# will add async-offload support to your cast and improve performance.
|
||||
if input is not None:
|
||||
@@ -1285,7 +1347,8 @@ def mixed_precision_ops(quant_config={}, compute_dtype=torch.bfloat16, full_prec
|
||||
if quant_format in ["float8_e4m3fn", "float8_e5m2"] and weight_key in state_dict:
|
||||
self.quant_format = quant_format
|
||||
qconfig = QUANT_ALGOS[quant_format]
|
||||
layout_cls = get_layout_class(qconfig["comfy_tensor_layout"])
|
||||
self.layout_type = qconfig["comfy_tensor_layout"]
|
||||
layout_cls = get_layout_class(self.layout_type)
|
||||
weight = state_dict.pop(weight_key)
|
||||
manually_loaded_keys = [weight_key]
|
||||
|
||||
@@ -1375,6 +1438,7 @@ def pick_operations(weight_dtype, compute_dtype, load_device=None, disable_fast_
|
||||
if not fp8_compute:
|
||||
disabled.add("float8_e4m3fn")
|
||||
disabled.add("float8_e5m2")
|
||||
logging.info("Native ops: {} {}".format(", ".join(QUANT_ALGOS.keys() - disabled), ", emulated ops: {}".format(", ".join(disabled)) if len(disabled) > 0 else ""))
|
||||
return mixed_precision_ops(model_config.quant_config, compute_dtype, disabled=disabled)
|
||||
|
||||
if (
|
||||
|
||||
+44
-24
@@ -2,42 +2,62 @@ import comfy.model_management
|
||||
import comfy.memory_management
|
||||
import comfy_aimdo.host_buffer
|
||||
import comfy_aimdo.torch
|
||||
import torch
|
||||
|
||||
from comfy.cli_args import args
|
||||
|
||||
def get_pin(module):
|
||||
return getattr(module, "_pin", None)
|
||||
def get_pin(module, subset="weights"):
|
||||
pin = getattr(module, "_pin", None)
|
||||
if pin is None or module._pin_registered or args.disable_pinned_memory:
|
||||
return pin
|
||||
|
||||
def pin_memory(module):
|
||||
if module.pin_failed or args.disable_pinned_memory or get_pin(module) is not None:
|
||||
_, _, stack_split, pinned_size = module._pin_state[subset]
|
||||
size = pin.nbytes
|
||||
comfy.model_management.ensure_pin_registerable(size)
|
||||
|
||||
if torch.cuda.cudart().cudaHostRegister(pin.data_ptr(), size, 1) != 0:
|
||||
comfy.model_management.discard_cuda_async_error()
|
||||
return pin
|
||||
|
||||
module._pin_registered = True
|
||||
stack_split[0] = max(stack_split[0], module._pin_stack_index)
|
||||
comfy.model_management.TOTAL_PINNED_MEMORY += size
|
||||
pinned_size[0] += size
|
||||
return pin
|
||||
|
||||
def pin_memory(module, subset="weights", size=None):
|
||||
pin_state = module._pin_state
|
||||
if args.disable_pinned_memory:
|
||||
return
|
||||
|
||||
size = comfy.memory_management.vram_aligned_size([ module.weight, module.bias ])
|
||||
pin = get_pin(module, subset)
|
||||
if pin is not None or pin_state["failed"]:
|
||||
return
|
||||
|
||||
if comfy.model_management.MAX_PINNED_MEMORY <= 0 or (comfy.model_management.TOTAL_PINNED_MEMORY + size) > comfy.model_management.MAX_PINNED_MEMORY:
|
||||
module.pin_failed = True
|
||||
hostbuf, stack, stack_split, pinned_size = pin_state[subset]
|
||||
if size is None:
|
||||
size = comfy.memory_management.vram_aligned_size([ module.weight, module.bias ])
|
||||
offset = hostbuf.size
|
||||
registerable_size = size + max(0, hostbuf.size - pinned_size[0])
|
||||
|
||||
comfy.memory_management.extra_ram_release(comfy.memory_management.RAM_CACHE_HEADROOM)
|
||||
if (not comfy.model_management.ensure_pin_budget(size) or
|
||||
not comfy.model_management.ensure_pin_registerable(registerable_size)):
|
||||
pin_state["failed"] = True
|
||||
return False
|
||||
|
||||
try:
|
||||
hostbuf = comfy_aimdo.host_buffer.HostBuffer(size)
|
||||
hostbuf.extend(size=size)
|
||||
except RuntimeError:
|
||||
module.pin_failed = True
|
||||
pin_state["failed"] = True
|
||||
return False
|
||||
|
||||
module._pin = comfy_aimdo.torch.hostbuf_to_tensor(hostbuf)
|
||||
module._pin_hostbuf = hostbuf
|
||||
module._pin = comfy_aimdo.torch.hostbuf_to_tensor(hostbuf)[offset:offset + size]
|
||||
module._pin.untyped_storage()._comfy_hostbuf = hostbuf
|
||||
stack.append((module, offset))
|
||||
module._pin_registered = True
|
||||
module._pin_stack_index = len(stack) - 1
|
||||
stack_split[0] = max(stack_split[0], module._pin_stack_index)
|
||||
comfy.model_management.TOTAL_PINNED_MEMORY += size
|
||||
pinned_size[0] += size
|
||||
return True
|
||||
|
||||
def unpin_memory(module):
|
||||
if get_pin(module) is None:
|
||||
return 0
|
||||
size = module._pin.numel() * module._pin.element_size()
|
||||
|
||||
comfy.model_management.TOTAL_PINNED_MEMORY -= size
|
||||
if comfy.model_management.TOTAL_PINNED_MEMORY < 0:
|
||||
comfy.model_management.TOTAL_PINNED_MEMORY = 0
|
||||
|
||||
del module._pin
|
||||
del module._pin_hostbuf
|
||||
return size
|
||||
|
||||
+10
-2
@@ -37,11 +37,12 @@ def prepare_noise(latent_image, seed, noise_inds=None):
|
||||
|
||||
return noises
|
||||
|
||||
def fix_empty_latent_channels(model, latent_image, downscale_ratio_spacial=None):
|
||||
def fix_empty_latent_channels(model, latent_image, downscale_ratio_spacial=None, downscale_ratio_temporal=None):
|
||||
if latent_image.is_nested:
|
||||
return latent_image
|
||||
latent_format = model.get_model_object("latent_format") #Resize the empty latent image so it has the right number of channels
|
||||
if torch.count_nonzero(latent_image) == 0:
|
||||
is_empty = torch.count_nonzero(latent_image) == 0
|
||||
if is_empty:
|
||||
if latent_format.latent_channels != latent_image.shape[1]:
|
||||
latent_image = comfy.utils.repeat_to_batch_size(latent_image, latent_format.latent_channels, dim=1)
|
||||
if downscale_ratio_spacial is not None:
|
||||
@@ -51,6 +52,13 @@ def fix_empty_latent_channels(model, latent_image, downscale_ratio_spacial=None)
|
||||
|
||||
if latent_format.latent_dimensions == 3 and latent_image.ndim == 4:
|
||||
latent_image = latent_image.unsqueeze(2)
|
||||
|
||||
if is_empty and downscale_ratio_temporal is not None:
|
||||
if downscale_ratio_temporal != latent_format.temporal_downscale_ratio:
|
||||
ratio = downscale_ratio_temporal / latent_format.temporal_downscale_ratio
|
||||
new_t = max(1, round(latent_image.shape[2] * ratio))
|
||||
latent_image = comfy.utils.repeat_to_batch_size(latent_image, new_t, dim=2)
|
||||
|
||||
return latent_image
|
||||
|
||||
def prepare_sampling(model, noise_shape, positive, negative, noise_mask):
|
||||
|
||||
+53
-3
@@ -21,6 +21,7 @@ import comfy.ldm.ace.vae.music_dcae_pipeline
|
||||
import comfy.ldm.cogvideo.vae
|
||||
import comfy.ldm.hunyuan_video.vae
|
||||
import comfy.ldm.mmaudio.vae.autoencoder
|
||||
import comfy.ldm.audio.vae_sa3
|
||||
import comfy.pixel_space_convert
|
||||
import comfy.weight_adapter
|
||||
import yaml
|
||||
@@ -67,6 +68,7 @@ import comfy.text_encoders.qwen35
|
||||
import comfy.text_encoders.ernie
|
||||
import comfy.text_encoders.gemma4
|
||||
import comfy.text_encoders.cogvideo
|
||||
import comfy.text_encoders.sa3
|
||||
|
||||
import comfy.model_patcher
|
||||
import comfy.lora
|
||||
@@ -79,7 +81,7 @@ import comfy.latent_formats
|
||||
|
||||
import comfy.ldm.flux.redux
|
||||
|
||||
def load_lora_for_models(model, clip, lora, strength_model, strength_clip):
|
||||
def load_lora_for_models(model, clip, lora, strength_model, strength_clip, lora_metadata=None):
|
||||
key_map = {}
|
||||
if model is not None:
|
||||
key_map = comfy.lora.model_lora_keys_unet(model.model, key_map)
|
||||
@@ -91,6 +93,8 @@ def load_lora_for_models(model, clip, lora, strength_model, strength_clip):
|
||||
if model is not None:
|
||||
new_modelpatcher = model.clone()
|
||||
k = new_modelpatcher.add_patches(loaded, strength_model)
|
||||
if lora_metadata:
|
||||
new_modelpatcher.set_attachments("lora_metadata", lora_metadata)
|
||||
else:
|
||||
k = ()
|
||||
new_modelpatcher = None
|
||||
@@ -98,6 +102,8 @@ def load_lora_for_models(model, clip, lora, strength_model, strength_clip):
|
||||
if clip is not None:
|
||||
new_clip = clip.clone()
|
||||
k1 = new_clip.add_patches(loaded, strength_clip)
|
||||
if lora_metadata:
|
||||
new_clip.patcher.set_attachments("lora_metadata", lora_metadata)
|
||||
else:
|
||||
k1 = ()
|
||||
new_clip = None
|
||||
@@ -239,7 +245,8 @@ class CLIP:
|
||||
model_management.archive_model_dtypes(self.cond_stage_model)
|
||||
|
||||
self.tokenizer = tokenizer(embedding_directory=embedding_directory, tokenizer_data=tokenizer_data)
|
||||
ModelPatcher = comfy.model_patcher.ModelPatcher if disable_dynamic else comfy.model_patcher.CoreModelPatcher
|
||||
te_disable_dynamic = disable_dynamic or getattr(self.cond_stage_model, "disable_offload", False)
|
||||
ModelPatcher = comfy.model_patcher.ModelPatcher if te_disable_dynamic else comfy.model_patcher.CoreModelPatcher
|
||||
self.patcher = ModelPatcher(self.cond_stage_model, load_device=load_device, offload_device=offload_device)
|
||||
#Match torch.float32 hardcode upcast in TE implemention
|
||||
self.patcher.set_model_compute_dtype(torch.float32)
|
||||
@@ -418,6 +425,13 @@ class CLIP:
|
||||
sd_clip[k] = sd_tokenizer[k]
|
||||
return sd_clip
|
||||
|
||||
def state_dict_for_saving(self):
|
||||
sd_clip = self.patcher.model_state_dict_for_saving()
|
||||
sd_tokenizer = self.tokenizer.state_dict()
|
||||
for k in sd_tokenizer:
|
||||
sd_clip[k] = sd_tokenizer[k]
|
||||
return sd_clip
|
||||
|
||||
def load_model(self, tokens={}):
|
||||
memory_used = 0
|
||||
if hasattr(self.cond_stage_model, "memory_estimation_function"):
|
||||
@@ -776,6 +790,7 @@ class VAE:
|
||||
self.latent_channels = 3
|
||||
self.latent_dim = 2
|
||||
self.output_channels = 3
|
||||
self.disable_offload = True
|
||||
elif "vocoder.activation_post.downsample.lowpass.filter" in sd: #MMAudio VAE
|
||||
sample_rate = 16000
|
||||
if sample_rate == 16000:
|
||||
@@ -841,6 +856,34 @@ class VAE:
|
||||
self.working_dtypes = [torch.float32]
|
||||
self.disable_offload = True
|
||||
self.extra_1d_channel = 16
|
||||
elif "decoder.layers.3.transformers.0.pre_norm.alpha" in sd: # Stable Audio 3 VAE
|
||||
if "decoder.layers.3.transformers.11.self_attn.to_out.weight" in sd:
|
||||
config = {"channels": 256, "transformer_depths": 12, "sinusoidal_blocks": 8,
|
||||
"sliding_window": [1, 1], "decoder_conv_mapping": False,
|
||||
"chunk_size": 128, "chunk_midpoint_shift": False}
|
||||
self.memory_used_encode = lambda shape, dtype: (1500 * shape[2]) * model_management.dtype_size(dtype)
|
||||
self.memory_used_decode = lambda shape, dtype: (1500 * shape[2] * 4096) * model_management.dtype_size(dtype)
|
||||
else:
|
||||
config = {"channels": 128, "transformer_depths": 6, "sinusoidal_blocks": 0,
|
||||
"sliding_window": None, "decoder_conv_mapping": True,
|
||||
"chunk_size": 32, "chunk_midpoint_shift": True}
|
||||
self.memory_used_encode = lambda shape, dtype: (72 * shape[2]) * model_management.dtype_size(dtype)
|
||||
self.memory_used_decode = lambda shape, dtype: (72 * shape[2] * 4096) * model_management.dtype_size(dtype)
|
||||
|
||||
self.first_stage_model = comfy.ldm.audio.vae_sa3.SA3AudioVAE(**config)
|
||||
self.latent_channels = 256
|
||||
self.output_channels = 2
|
||||
self.upscale_ratio = 4096
|
||||
self.downscale_ratio = 4096
|
||||
self.latent_dim = 1
|
||||
self.audio_sample_rate = 44100
|
||||
self.process_output = lambda audio: audio
|
||||
self.process_input = lambda audio: audio
|
||||
self.working_dtypes = [torch.bfloat16, torch.float16, torch.float32]
|
||||
#This VAE has Parameters and Buffers the non-dynamic caster cannot handle
|
||||
#Force cast it for --disable-dynamic-vram users until there is a true core fix.
|
||||
if not comfy.memory_management.aimdo_enabled:
|
||||
self.disable_offload = True
|
||||
else:
|
||||
logging.warning("WARNING: No VAE weights detected, VAE not initalized.")
|
||||
self.first_stage_model = None
|
||||
@@ -1277,6 +1320,7 @@ class TEModel(Enum):
|
||||
GEMMA_4_E4B = 29
|
||||
GEMMA_4_E2B = 30
|
||||
GEMMA_4_31B = 31
|
||||
T5_GEMMA = 32
|
||||
|
||||
|
||||
def detect_te_model(sd):
|
||||
@@ -1301,6 +1345,8 @@ def detect_te_model(sd):
|
||||
if weight.shape[0] == 384:
|
||||
return TEModel.BYT5_SMALL_GLYPH
|
||||
return TEModel.T5_BASE
|
||||
if "model.encoder.layers.0.pre_self_attn_layernorm.weight" in sd:
|
||||
return TEModel.T5_GEMMA
|
||||
if 'model.layers.0.post_feedforward_layernorm.weight' in sd:
|
||||
if 'model.layers.59.self_attn.q_norm.weight' in sd:
|
||||
return TEModel.GEMMA_4_31B
|
||||
@@ -1450,6 +1496,10 @@ def load_text_encoder_state_dicts(state_dicts=[], embedding_directory=None, clip
|
||||
else:
|
||||
clip_target.clip = comfy.text_encoders.sa_t5.SAT5Model
|
||||
clip_target.tokenizer = comfy.text_encoders.sa_t5.SAT5Tokenizer
|
||||
elif te_model == TEModel.T5_GEMMA:
|
||||
clip_target.clip = comfy.text_encoders.sa3.SAT5GemmaModel
|
||||
clip_target.tokenizer = comfy.text_encoders.sa3.SAT5GemmaTokenizer
|
||||
tokenizer_data["spiece_model"] = clip_data[0].get("spiece_model", None)
|
||||
elif te_model in (TEModel.GEMMA_4_E4B, TEModel.GEMMA_4_E2B, TEModel.GEMMA_4_31B):
|
||||
variant = {TEModel.GEMMA_4_E4B: comfy.text_encoders.gemma4.Gemma4_E4B,
|
||||
TEModel.GEMMA_4_E2B: comfy.text_encoders.gemma4.Gemma4_E2B,
|
||||
@@ -1902,7 +1952,7 @@ def save_checkpoint(output_path, model, clip=None, vae=None, clip_vision=None, m
|
||||
load_models = [model]
|
||||
if clip is not None:
|
||||
load_models.append(clip.load_model())
|
||||
clip_sd = clip.get_sd()
|
||||
clip_sd = clip.state_dict_for_saving()
|
||||
vae_sd = None
|
||||
if vae is not None:
|
||||
vae_sd = vae.get_sd()
|
||||
|
||||
@@ -7,6 +7,7 @@ from . import sdxl_clip
|
||||
import comfy.text_encoders.sd2_clip
|
||||
import comfy.text_encoders.sd3_clip
|
||||
import comfy.text_encoders.sa_t5
|
||||
import comfy.text_encoders.sa3
|
||||
import comfy.text_encoders.aura_t5
|
||||
import comfy.text_encoders.pixart_t5
|
||||
import comfy.text_encoders.hydit
|
||||
@@ -28,6 +29,7 @@ import comfy.text_encoders.ace15
|
||||
import comfy.text_encoders.longcat_image
|
||||
import comfy.text_encoders.ernie
|
||||
import comfy.text_encoders.cogvideo
|
||||
import comfy.text_encoders.hidream_o1
|
||||
|
||||
from . import supported_models_base
|
||||
from . import latent_formats
|
||||
@@ -602,6 +604,29 @@ class StableAudio(supported_models_base.BASE):
|
||||
def clip_target(self, state_dict={}):
|
||||
return supported_models_base.ClipTarget(comfy.text_encoders.sa_t5.SAT5Tokenizer, comfy.text_encoders.sa_t5.SAT5Model)
|
||||
|
||||
class StableAudio3(StableAudio):
|
||||
unet_config = {
|
||||
"audio_model": "dit1.0",
|
||||
"global_cond_shared_embed": True,
|
||||
}
|
||||
|
||||
sampling_settings = {
|
||||
"multiplier": 1.0,
|
||||
"shift": 2.0,
|
||||
}
|
||||
|
||||
latent_format = latent_formats.StableAudio3
|
||||
|
||||
memory_usage_factor = 7
|
||||
|
||||
def get_model(self, state_dict, prefix="", device=None):
|
||||
seconds_total_sd = utils.state_dict_prefix_replace(state_dict, {"conditioner.conditioners.seconds_total.": ""}, filter_keys=True)
|
||||
padding_embedding = state_dict.get("conditioner.conditioners.prompt.padding_embedding", None)
|
||||
return model_base.StableAudio3(self, seconds_total_embedder_weights=seconds_total_sd, padding_embedding=padding_embedding, device=device)
|
||||
|
||||
def clip_target(self, state_dict={}):
|
||||
return supported_models_base.ClipTarget(comfy.text_encoders.sa3.SAT5GemmaTokenizer, comfy.text_encoders.sa3.SAT5GemmaModel)
|
||||
|
||||
class AuraFlow(supported_models_base.BASE):
|
||||
unet_config = {
|
||||
"cond_seq_dim": 2048,
|
||||
@@ -1431,6 +1456,50 @@ class HiDream(supported_models_base.BASE):
|
||||
def clip_target(self, state_dict={}):
|
||||
return None # TODO
|
||||
|
||||
class HiDreamO1(supported_models_base.BASE):
|
||||
unet_config = {
|
||||
"image_model": "hidream_o1",
|
||||
}
|
||||
|
||||
sampling_settings = {
|
||||
"shift": 3.0,
|
||||
"noise_scale": 8.0,
|
||||
}
|
||||
|
||||
latent_format = latent_formats.HiDreamO1Pixel
|
||||
memory_usage_factor = 0.033
|
||||
# fp16 not supported: LM MLP down_proj activations fp16 overflow, causing NaNs
|
||||
supported_inference_dtypes = [torch.bfloat16, torch.float32]
|
||||
|
||||
vae_key_prefix = ["vae."]
|
||||
text_encoder_key_prefix = ["text_encoders."]
|
||||
|
||||
optimizations = {"fp8": False}
|
||||
|
||||
def get_model(self, state_dict, prefix="", device=None):
|
||||
return model_base.HiDreamO1(self, device=device)
|
||||
|
||||
def process_unet_state_dict(self, state_dict):
|
||||
# Drop unused Qwen3-VL deepstack merger weights; upstream discards them at inference.
|
||||
for key in list(state_dict.keys()):
|
||||
if "visual.deepstack_merger_list" in key:
|
||||
del state_dict[key]
|
||||
return state_dict
|
||||
|
||||
def process_vae_state_dict(self, state_dict):
|
||||
# Pixel-space model: inject sentinel so VAE construction picks PixelspaceConversionVAE.
|
||||
return {"pixel_space_vae": torch.tensor(1.0)}
|
||||
|
||||
def process_clip_state_dict(self, state_dict):
|
||||
# Tokenizer-only TE: inject sentinel so load_state_dict_guess_config triggers CLIP init.
|
||||
return {"_hidream_o1_te_sentinel": torch.zeros(1)}
|
||||
|
||||
def clip_target(self, state_dict={}):
|
||||
return supported_models_base.ClipTarget(
|
||||
comfy.text_encoders.hidream_o1.HiDreamO1Tokenizer,
|
||||
comfy.text_encoders.hidream_o1.HiDreamO1TE,
|
||||
)
|
||||
|
||||
class Chroma(supported_models_base.BASE):
|
||||
unet_config = {
|
||||
"image_model": "chroma",
|
||||
@@ -1973,6 +2042,7 @@ models = [
|
||||
SV3D_u,
|
||||
SV3D_p,
|
||||
SD3,
|
||||
StableAudio3,
|
||||
StableAudio,
|
||||
AuraFlow,
|
||||
PixArtAlpha,
|
||||
@@ -2018,6 +2088,7 @@ models = [
|
||||
Hunyuan3Dv2,
|
||||
Hunyuan3Dv2_1,
|
||||
HiDream,
|
||||
HiDreamO1,
|
||||
Chroma,
|
||||
ChromaRadiance,
|
||||
ACEStep,
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""HiDream-O1-Image tokenizer-only text encoder.
|
||||
|
||||
The real Qwen3-VL backbone runs inside diffusion_model.* every step, so this
|
||||
module just tokenizes the prompt into text_input_ids and emits them as
|
||||
conditioning. Position ids / token_types / vinput_mask depend on target H/W
|
||||
and are built later in model_base.HiDreamO1.extra_conds.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import torch
|
||||
from transformers import Qwen2Tokenizer
|
||||
|
||||
from comfy import sd1_clip
|
||||
|
||||
|
||||
# Qwen3-VL special tokens
|
||||
IM_START_ID = 151644
|
||||
IM_END_ID = 151645
|
||||
ASSISTANT_ID = 77091
|
||||
USER_ID = 872
|
||||
NEWLINE_ID = 198
|
||||
VISION_START_ID = 151652
|
||||
VISION_END_ID = 151653
|
||||
IMAGE_TOKEN_ID = 151655
|
||||
VIDEO_TOKEN_ID = 151656
|
||||
# HiDream-O1-specific tokens
|
||||
BOI_TOKEN_ID = 151669
|
||||
BOR_TOKEN_ID = 151670
|
||||
EOR_TOKEN_ID = 151671
|
||||
BOT_TOKEN_ID = 151672
|
||||
TMS_TOKEN_ID = 151673
|
||||
|
||||
|
||||
class HiDreamO1QwenTokenizer(sd1_clip.SDTokenizer):
|
||||
def __init__(self, embedding_directory=None, tokenizer_data={}):
|
||||
tokenizer_path = os.path.join(
|
||||
os.path.dirname(os.path.realpath(__file__)), "qwen25_tokenizer"
|
||||
)
|
||||
super().__init__(
|
||||
tokenizer_path,
|
||||
pad_with_end=False,
|
||||
embedding_size=4096,
|
||||
embedding_key="hidream_o1",
|
||||
tokenizer_class=Qwen2Tokenizer,
|
||||
has_start_token=False,
|
||||
has_end_token=False,
|
||||
pad_to_max_length=False,
|
||||
max_length=99999999,
|
||||
min_length=1,
|
||||
pad_token=151643,
|
||||
tokenizer_data=tokenizer_data,
|
||||
)
|
||||
|
||||
|
||||
class HiDreamO1Tokenizer(sd1_clip.SD1Tokenizer):
|
||||
"""Wraps prompt in the upstream chat template ending with boi/tms markers.
|
||||
Image tokens get spliced in at sample time once target H/W is known.
|
||||
"""
|
||||
|
||||
def __init__(self, embedding_directory=None, tokenizer_data={}):
|
||||
super().__init__(
|
||||
embedding_directory=embedding_directory,
|
||||
tokenizer_data=tokenizer_data,
|
||||
name="hidream_o1",
|
||||
tokenizer=HiDreamO1QwenTokenizer,
|
||||
)
|
||||
|
||||
def tokenize_with_weights(self, text, return_word_ids=False, **kwargs):
|
||||
text_tokens_dict = super().tokenize_with_weights(
|
||||
text, return_word_ids=return_word_ids, disable_weights=True, **kwargs
|
||||
)
|
||||
text_tuples = text_tokens_dict["hidream_o1"][0]
|
||||
text_tuples = [t for t in text_tuples if int(t[0]) != 151643] # strip pad
|
||||
|
||||
# <|im_start|>user\n{text}<|im_end|>\n<|im_start|>assistant\n<|boi|><|tms|>
|
||||
def tok(tid):
|
||||
return (tid, 1.0) if not return_word_ids else (tid, 1.0, 0)
|
||||
|
||||
prefix = [tok(IM_START_ID), tok(USER_ID), tok(NEWLINE_ID)]
|
||||
suffix = [
|
||||
tok(IM_END_ID), tok(NEWLINE_ID),
|
||||
tok(IM_START_ID), tok(ASSISTANT_ID), tok(NEWLINE_ID),
|
||||
tok(BOI_TOKEN_ID), tok(TMS_TOKEN_ID),
|
||||
]
|
||||
full = prefix + list(text_tuples) + suffix
|
||||
return {"hidream_o1": [full]}
|
||||
|
||||
|
||||
class HiDreamO1TE(torch.nn.Module):
|
||||
"""Passthrough TE: emits int token ids; the Qwen3-VL backbone in diffusion_model does the actual encoding."""
|
||||
|
||||
def __init__(self, device="cpu", dtype=None, model_options={}):
|
||||
super().__init__()
|
||||
self.dtypes = {torch.float32}
|
||||
self.disable_offload = True # skips dynamic VRAM management for this zero-parameter module
|
||||
self.device = torch.device("cpu") if device is None else torch.device(device)
|
||||
|
||||
def encode_token_weights(self, token_weight_pairs):
|
||||
tok_pairs = token_weight_pairs["hidream_o1"][0]
|
||||
ids = [int(t[0]) for t in tok_pairs]
|
||||
input_ids = torch.tensor([ids], dtype=torch.long)
|
||||
# Surrogate keeps the cross_attn slot non-empty for CONDITIONING
|
||||
# plumbing; the model reads text_input_ids out of `extra` instead.
|
||||
cross_attn = input_ids.unsqueeze(-1).to(torch.float32)
|
||||
extra = {"text_input_ids": input_ids}
|
||||
return cross_attn, None, extra
|
||||
|
||||
def load_sd(self, sd):
|
||||
return []
|
||||
|
||||
def get_sd(self):
|
||||
return {}
|
||||
|
||||
def reset_clip_options(self):
|
||||
pass
|
||||
|
||||
def set_clip_options(self, options):
|
||||
pass
|
||||
@@ -397,7 +397,7 @@ class RMSNorm(nn.Module):
|
||||
|
||||
|
||||
|
||||
def precompute_freqs_cis(head_dim, position_ids, theta, rope_scale=None, rope_dims=None, device=None):
|
||||
def precompute_freqs_cis(head_dim, position_ids, theta, rope_scale=None, rope_dims=None, device=None, interleaved_mrope=False):
|
||||
if not isinstance(theta, list):
|
||||
theta = [theta]
|
||||
|
||||
@@ -415,16 +415,27 @@ def precompute_freqs_cis(head_dim, position_ids, theta, rope_scale=None, rope_di
|
||||
inv_freq_expanded = inv_freq[None, :, None].float().expand(position_ids.shape[0], -1, 1)
|
||||
position_ids_expanded = position_ids[:, None, :].float()
|
||||
freqs = (inv_freq_expanded.float() @ position_ids_expanded.float()).transpose(1, 2)
|
||||
emb = torch.cat((freqs, freqs), dim=-1)
|
||||
cos = emb.cos()
|
||||
sin = emb.sin()
|
||||
if rope_dims is not None and position_ids.shape[0] > 1:
|
||||
mrope_section = rope_dims * 2
|
||||
cos = torch.cat([m[i % 3] for i, m in enumerate(cos.split(mrope_section, dim=-1))], dim=-1).unsqueeze(0)
|
||||
sin = torch.cat([m[i % 3] for i, m in enumerate(sin.split(mrope_section, dim=-1))], dim=-1).unsqueeze(0)
|
||||
if rope_dims is not None and position_ids.shape[0] > 1 and interleaved_mrope:
|
||||
# Qwen3-VL interleaved MRoPE: T-freqs by default, H/W replace every 3rd dim.
|
||||
freqs_inter = freqs[0].clone()
|
||||
for axis_idx, offset in ((1, 1), (2, 2)):
|
||||
length = rope_dims[axis_idx] * 3
|
||||
idx = slice(offset, length, 3)
|
||||
freqs_inter[..., idx] = freqs[axis_idx, ..., idx]
|
||||
emb = torch.cat((freqs_inter, freqs_inter), dim=-1)
|
||||
cos = emb.cos().unsqueeze(0)
|
||||
sin = emb.sin().unsqueeze(0)
|
||||
else:
|
||||
cos = cos.unsqueeze(1)
|
||||
sin = sin.unsqueeze(1)
|
||||
emb = torch.cat((freqs, freqs), dim=-1)
|
||||
cos = emb.cos()
|
||||
sin = emb.sin()
|
||||
if rope_dims is not None and position_ids.shape[0] > 1:
|
||||
mrope_section = rope_dims * 2
|
||||
cos = torch.cat([m[i % 3] for i, m in enumerate(cos.split(mrope_section, dim=-1))], dim=-1).unsqueeze(0)
|
||||
sin = torch.cat([m[i % 3] for i, m in enumerate(sin.split(mrope_section, dim=-1))], dim=-1).unsqueeze(0)
|
||||
else:
|
||||
cos = cos.unsqueeze(1)
|
||||
sin = sin.unsqueeze(1)
|
||||
sin_split = sin.shape[-1] // 2
|
||||
out.append((cos, sin[..., : sin_split], -sin[..., sin_split :]))
|
||||
|
||||
@@ -689,6 +700,7 @@ class Llama2_(nn.Module):
|
||||
self.config.rope_theta,
|
||||
self.config.rope_scale,
|
||||
self.config.rope_dims,
|
||||
interleaved_mrope=getattr(self.config, "interleaved_mrope", False),
|
||||
device=device)
|
||||
|
||||
def forward(self, x, attention_mask=None, embeds=None, num_tokens=None, intermediate_output=None, final_layer_norm_intermediate=True, dtype=None, position_ids=None, embeds_info=[], past_key_values=None, input_ids=None):
|
||||
|
||||
@@ -451,9 +451,8 @@ class Qwen35VisionPatchEmbed(nn.Module):
|
||||
self.proj = ops.Conv3d(self.in_channels, self.embed_dim, kernel_size=kernel_size, stride=kernel_size, bias=True, device=device, dtype=dtype)
|
||||
|
||||
def forward(self, x):
|
||||
target_dtype = self.proj.weight.dtype
|
||||
x = x.view(-1, self.in_channels, self.temporal_patch_size, self.patch_size, self.patch_size)
|
||||
return self.proj(x.to(target_dtype)).view(-1, self.embed_dim)
|
||||
return self.proj(x).view(-1, self.embed_dim)
|
||||
|
||||
|
||||
class Qwen35VisionMLP(nn.Module):
|
||||
@@ -651,7 +650,7 @@ class Qwen35VisionModel(nn.Module):
|
||||
x = self.patch_embed(x)
|
||||
pos_embeds = self.fast_pos_embed_interpolate(grid_thw).to(x.device)
|
||||
x = x + pos_embeds
|
||||
rotary_pos_emb = self.rot_pos_emb(grid_thw)
|
||||
rotary_pos_emb = self.rot_pos_emb(grid_thw).to(x.device)
|
||||
seq_len = x.shape[0]
|
||||
x = x.reshape(seq_len, -1)
|
||||
rotary_pos_emb = rotary_pos_emb.reshape(seq_len, -1)
|
||||
@@ -761,7 +760,7 @@ class Qwen35ImageTokenizer(sd1_clip.SD1Tokenizer):
|
||||
def tokenize_with_weights(self, text, return_word_ids=False, llama_template=None, images=[], prevent_empty_text=False, thinking=False, **kwargs):
|
||||
image = kwargs.get("image", None)
|
||||
if image is not None and len(images) == 0:
|
||||
images = [image]
|
||||
images = [image[i:i + 1] for i in range(image.shape[0])]
|
||||
|
||||
skip_template = False
|
||||
if text.startswith('<|im_start|>'):
|
||||
@@ -772,13 +771,16 @@ class Qwen35ImageTokenizer(sd1_clip.SD1Tokenizer):
|
||||
if skip_template:
|
||||
llama_text = text
|
||||
else:
|
||||
if llama_template is None:
|
||||
if len(images) > 0:
|
||||
llama_text = self.llama_template_images.format(text)
|
||||
else:
|
||||
llama_text = self.llama_template.format(text)
|
||||
if llama_template is not None:
|
||||
template = llama_template
|
||||
elif len(images) == 0:
|
||||
template = self.llama_template
|
||||
else:
|
||||
llama_text = llama_template.format(text)
|
||||
template = self.llama_template_images
|
||||
if len(images) > 1:
|
||||
vision_block = "<|vision_start|><|image_pad|><|vision_end|>"
|
||||
template = template.replace(vision_block, vision_block * len(images), 1)
|
||||
llama_text = template.format(text)
|
||||
if not thinking:
|
||||
llama_text += "<think>\n</think>\n"
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from comfy import sd1_clip
|
||||
from comfy.text_encoders.llama import Attention as LlamaAttention, RMSNorm, MLP, precompute_freqs_cis, apply_rope, _make_scaled_embedding
|
||||
from comfy.text_encoders.spiece_tokenizer import SPieceTokenizer
|
||||
|
||||
|
||||
class T5GemmaEncoderConfig:
|
||||
def __init__(self):
|
||||
self.vocab_size = 256000
|
||||
self.hidden_size = 768
|
||||
self.intermediate_size = 2048
|
||||
self.num_hidden_layers = 12
|
||||
self.num_attention_heads = 12
|
||||
self.num_key_value_heads = 12
|
||||
self.head_dim = 64
|
||||
self.rms_norm_eps = 1e-6
|
||||
self.rms_norm_add = False
|
||||
self.rope_theta = 10000.0
|
||||
self.attn_logit_softcapping = 50.0
|
||||
self.query_pre_attn_scalar = 64
|
||||
self.sliding_window = 4096
|
||||
self.mlp_activation = "gelu_pytorch_tanh"
|
||||
self.layer_types = ["sliding_attention", "full_attention"] * 6
|
||||
self.qkv_bias = False
|
||||
self.q_norm = None
|
||||
self.k_norm = None
|
||||
self.rms_norm_add = True
|
||||
|
||||
|
||||
class T5GemmaAttention(LlamaAttention):
|
||||
"""Reuses LlamaAttention projection setup; overrides forward for softcap attention.
|
||||
|
||||
T5Gemma applies tanh(QK^T * scale / cap) * cap between the matmul and softmax.
|
||||
This nonlinearity is incompatible with fused SDPA kernels, so attention is
|
||||
computed manually. Everything else (projections, RoPE, GQA expansion) is identical
|
||||
to LlamaAttention so __init__ is inherited unchanged.
|
||||
"""
|
||||
|
||||
def __init__(self, config, device=None, dtype=None, ops=None):
|
||||
super().__init__(config, device=device, dtype=dtype, ops=ops)
|
||||
self.scale = config.query_pre_attn_scalar ** -0.5
|
||||
self.softcap = config.attn_logit_softcapping
|
||||
|
||||
def forward(self, hidden_states, attention_mask=None, freqs_cis=None, **kwargs):
|
||||
B, S, _ = hidden_states.shape
|
||||
xq = self.q_proj(hidden_states).view(B, S, self.num_heads, self.head_dim).transpose(1, 2)
|
||||
xk = self.k_proj(hidden_states).view(B, S, self.num_kv_heads, self.head_dim).transpose(1, 2)
|
||||
xv = self.v_proj(hidden_states).view(B, S, self.num_kv_heads, self.head_dim).transpose(1, 2)
|
||||
xq, xk = apply_rope(xq, xk, freqs_cis)
|
||||
xk = xk.repeat_interleave(self.num_heads // self.num_kv_heads, dim=1)
|
||||
xv = xv.repeat_interleave(self.num_heads // self.num_kv_heads, dim=1)
|
||||
attn = torch.matmul(xq * self.scale, xk.transpose(-2, -1))
|
||||
attn = torch.tanh(attn / self.softcap) * self.softcap
|
||||
if attention_mask is not None:
|
||||
attn = attn + attention_mask
|
||||
attn = torch.nn.functional.softmax(attn.float(), dim=-1).to(xq.dtype)
|
||||
out = torch.matmul(attn, xv).transpose(1, 2).reshape(B, S, self.inner_size)
|
||||
return self.o_proj(out), None
|
||||
|
||||
|
||||
class T5GemmaBlock(nn.Module):
|
||||
def __init__(self, config, layer_type, device=None, dtype=None, ops=None):
|
||||
super().__init__()
|
||||
self.self_attn = T5GemmaAttention(config, device=device, dtype=dtype, ops=ops)
|
||||
self.mlp = MLP(config, device=device, dtype=dtype, ops=ops)
|
||||
# Names match checkpoint keys: model.encoder.layers.X.<name>.weight
|
||||
self.pre_self_attn_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps, add=True, device=device, dtype=dtype)
|
||||
self.post_self_attn_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps, add=True, device=device, dtype=dtype)
|
||||
self.pre_feedforward_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps, add=True, device=device, dtype=dtype)
|
||||
self.post_feedforward_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps, add=True, device=device, dtype=dtype)
|
||||
self.is_sliding = (layer_type == "sliding_attention")
|
||||
self.sliding_window = config.sliding_window
|
||||
|
||||
def forward(self, x, attention_mask=None, freqs_cis=None):
|
||||
attn_mask = attention_mask
|
||||
if self.is_sliding and x.shape[1] > self.sliding_window:
|
||||
S = x.shape[1]
|
||||
pos = torch.arange(S, device=x.device)
|
||||
dist = (pos.unsqueeze(0) - pos.unsqueeze(1)).abs()
|
||||
sw_mask = torch.zeros(S, S, dtype=x.dtype, device=x.device)
|
||||
sw_mask.masked_fill_(dist > self.sliding_window, -torch.finfo(x.dtype).max)
|
||||
sw_mask = sw_mask.unsqueeze(0).unsqueeze(0)
|
||||
attn_mask = (attention_mask + sw_mask) if attention_mask is not None else sw_mask
|
||||
residual = x
|
||||
x = self.pre_self_attn_layernorm(x)
|
||||
x, _ = self.self_attn(x, attention_mask=attn_mask, freqs_cis=freqs_cis)
|
||||
x = self.post_self_attn_layernorm(x)
|
||||
x = residual + x
|
||||
residual = x
|
||||
x = self.pre_feedforward_layernorm(x)
|
||||
x = self.mlp(x)
|
||||
x = self.post_feedforward_layernorm(x)
|
||||
x = residual + x
|
||||
return x
|
||||
|
||||
|
||||
class T5GemmaEncoder(nn.Module):
|
||||
"""Encoder stack: embed_tokens, layers, norm.
|
||||
Keys: embed_tokens.*, layers.X.*, norm.*"""
|
||||
|
||||
def __init__(self, config, device, dtype, ops):
|
||||
super().__init__()
|
||||
self.config = config
|
||||
# Gemma-style scaled embedding: output *= sqrt(hidden_size)
|
||||
self.embed_tokens = _make_scaled_embedding(
|
||||
ops, config.vocab_size, config.hidden_size, config.hidden_size ** 0.5, device, dtype)
|
||||
self.layers = nn.ModuleList([
|
||||
T5GemmaBlock(config, config.layer_types[i], device=device, dtype=dtype, ops=ops)
|
||||
for i in range(config.num_hidden_layers)
|
||||
])
|
||||
self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps, add=True, device=device, dtype=dtype)
|
||||
|
||||
def forward(self, input_ids, attention_mask=None, embeds=None, intermediate_output=None,
|
||||
final_layer_norm_intermediate=True, dtype=None, num_layers=None):
|
||||
x = embeds if embeds is not None else self.embed_tokens(input_ids, out_dtype=dtype or torch.float32)
|
||||
seq_len = x.shape[1]
|
||||
position_ids = torch.arange(seq_len, device=x.device).unsqueeze(0)
|
||||
freqs_cis = precompute_freqs_cis(self.config.head_dim, position_ids, self.config.rope_theta, device=x.device)
|
||||
mask = None
|
||||
if attention_mask is not None:
|
||||
mask = 1.0 - attention_mask.to(x.dtype).reshape(
|
||||
(attention_mask.shape[0], 1, -1, attention_mask.shape[-1])
|
||||
).expand(attention_mask.shape[0], 1, seq_len, attention_mask.shape[-1])
|
||||
mask = mask.masked_fill(mask.to(torch.bool), -torch.finfo(x.dtype).max)
|
||||
intermediate = None
|
||||
for i, layer in enumerate(self.layers):
|
||||
x = layer(x, attention_mask=mask, freqs_cis=freqs_cis)
|
||||
if i == intermediate_output:
|
||||
intermediate = x.clone()
|
||||
x = self.norm(x)
|
||||
if intermediate is not None and final_layer_norm_intermediate:
|
||||
intermediate = self.norm(intermediate)
|
||||
return x, intermediate
|
||||
|
||||
|
||||
class T5GemmaBody(nn.Module):
|
||||
"""Provides the 'encoder' sub-module.
|
||||
Keys: encoder.*"""
|
||||
|
||||
def __init__(self, config, device, dtype, ops):
|
||||
super().__init__()
|
||||
self.encoder = T5GemmaEncoder(config, device, dtype, ops)
|
||||
|
||||
|
||||
class T5GemmaModel(nn.Module):
|
||||
"""Top-level model class passed to SDClipModel as model_class.
|
||||
Module layout: self.model.encoder.* → matches checkpoint keys model.encoder.*"""
|
||||
|
||||
def __init__(self, config_dict, dtype, device, operations):
|
||||
super().__init__()
|
||||
config = T5GemmaEncoderConfig()
|
||||
self.num_layers = config.num_hidden_layers
|
||||
self.dtype = dtype
|
||||
self.model = T5GemmaBody(config, device, dtype, operations)
|
||||
|
||||
def get_input_embeddings(self):
|
||||
return self.model.encoder.embed_tokens
|
||||
|
||||
def set_input_embeddings(self, embeddings):
|
||||
self.model.encoder.embed_tokens = embeddings
|
||||
|
||||
def forward(self, input_ids, attention_mask=None, embeds=None, num_tokens=None,
|
||||
intermediate_output=None, final_layer_norm_intermediate=True, dtype=None, **kwargs):
|
||||
if intermediate_output is not None and intermediate_output < 0:
|
||||
intermediate_output = self.num_layers + intermediate_output
|
||||
return self.model.encoder(
|
||||
input_ids, attention_mask=attention_mask, embeds=embeds,
|
||||
intermediate_output=intermediate_output,
|
||||
final_layer_norm_intermediate=final_layer_norm_intermediate,
|
||||
dtype=dtype, num_layers=self.num_layers)
|
||||
|
||||
|
||||
class T5GemmaSDClipModel(sd1_clip.SDClipModel):
|
||||
def __init__(self, device="cpu", layer="last", layer_idx=None, dtype=None, model_options={}):
|
||||
super().__init__(device=device, layer=layer, layer_idx=layer_idx,
|
||||
textmodel_json_config={}, dtype=dtype,
|
||||
special_tokens={"pad": 0},
|
||||
model_class=T5GemmaModel,
|
||||
enable_attention_masks=True, zero_out_masked=True,
|
||||
model_options=model_options)
|
||||
|
||||
|
||||
class T5GemmaSDTokenizer(sd1_clip.SDTokenizer):
|
||||
def __init__(self, embedding_directory=None, tokenizer_data={}):
|
||||
tokenizer_model = tokenizer_data.get("spiece_model", None)
|
||||
super().__init__(tokenizer_model, pad_with_end=False, embedding_size=768,
|
||||
embedding_key="t5gemma", tokenizer_class=SPieceTokenizer,
|
||||
has_start_token=False, has_end_token=False, pad_to_max_length=False,
|
||||
max_length=99999999, min_length=1, pad_token=0,
|
||||
tokenizer_data=tokenizer_data,
|
||||
tokenizer_args={"add_bos": False, "add_eos": False})
|
||||
|
||||
def state_dict(self):
|
||||
return {"spiece_model": self.tokenizer.serialize_model()}
|
||||
|
||||
|
||||
class SAT5GemmaTokenizer(sd1_clip.SD1Tokenizer):
|
||||
def __init__(self, embedding_directory=None, tokenizer_data={}):
|
||||
super().__init__(embedding_directory=embedding_directory,
|
||||
tokenizer_data=tokenizer_data, clip_name="t5gemma", tokenizer=T5GemmaSDTokenizer)
|
||||
|
||||
|
||||
class SAT5GemmaModel(sd1_clip.SD1ClipModel):
|
||||
def __init__(self, device="cpu", dtype=None, model_options={}, **kwargs):
|
||||
super().__init__(device=device, dtype=dtype, model_options=model_options,
|
||||
name="t5gemma", clip_model=T5GemmaSDClipModel, **kwargs)
|
||||
+13
-8
@@ -113,7 +113,6 @@ def load_safetensors(ckpt):
|
||||
"_comfy_tensor_file_slice",
|
||||
comfy.memory_management.TensorFileSlice(f, threading.get_ident(), data_base_offset + start, end - start))
|
||||
setattr(storage, "_comfy_tensor_mmap_refs", (model_mmap, mv))
|
||||
setattr(storage, "_comfy_tensor_mmap_touched", False)
|
||||
sd[name] = tensor
|
||||
|
||||
return sd, header.get("__metadata__", {}),
|
||||
@@ -1020,10 +1019,11 @@ def bislerp(samples, width, height):
|
||||
|
||||
def lanczos(samples, width, height):
|
||||
#the below API is strict and expects grayscale to be squeezed
|
||||
samples = samples.squeeze(1) if samples.shape[1] == 1 else samples.movedim(1, -1)
|
||||
if samples.ndim == 4:
|
||||
samples = samples.squeeze(1) if samples.shape[1] == 1 else samples.movedim(1, -1)
|
||||
images = [Image.fromarray(np.clip(255. * image.cpu().numpy(), 0, 255).astype(np.uint8)) for image in samples]
|
||||
images = [image.resize((width, height), resample=Image.Resampling.LANCZOS) for image in images]
|
||||
images = [torch.from_numpy(np.array(image).astype(np.float32) / 255.0).movedim(-1, 0) for image in images]
|
||||
images = [torch.from_numpy(t).movedim(-1, 0) if (t := np.array(image).astype(np.float32) / 255.0).ndim == 3 else torch.from_numpy(t) for image in images]
|
||||
result = torch.stack(images)
|
||||
return result.to(samples.device, samples.dtype)
|
||||
|
||||
@@ -1164,12 +1164,18 @@ def tiled_scale_multidim(samples, function, tile=(64, 64), overlap=8, upscale_am
|
||||
|
||||
o = out
|
||||
o_d = out_div
|
||||
ps_view = ps
|
||||
mask_view = mask
|
||||
for d in range(dims):
|
||||
o = o.narrow(d + 2, upscaled[d], mask.shape[d + 2])
|
||||
o_d = o_d.narrow(d + 2, upscaled[d], mask.shape[d + 2])
|
||||
l = min(ps_view.shape[d + 2], o.shape[d + 2] - upscaled[d])
|
||||
o = o.narrow(d + 2, upscaled[d], l)
|
||||
o_d = o_d.narrow(d + 2, upscaled[d], l)
|
||||
if l < ps_view.shape[d + 2]:
|
||||
ps_view = ps_view.narrow(d + 2, 0, l)
|
||||
mask_view = mask_view.narrow(d + 2, 0, l)
|
||||
|
||||
o.add_(ps * mask)
|
||||
o_d.add_(mask)
|
||||
o.add_(ps_view * mask_view)
|
||||
o_d.add_(mask_view)
|
||||
|
||||
if pbar is not None:
|
||||
pbar.update(1)
|
||||
@@ -1445,4 +1451,3 @@ def deepcopy_list_dict(obj, memo=None):
|
||||
|
||||
memo[obj_id] = res
|
||||
return res
|
||||
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import ctypes
|
||||
import logging
|
||||
import psutil
|
||||
from ctypes import wintypes
|
||||
|
||||
import comfy_aimdo.control
|
||||
|
||||
psapi = ctypes.WinDLL("psapi")
|
||||
kernel32 = ctypes.WinDLL("kernel32")
|
||||
|
||||
class PERFORMANCE_INFORMATION(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("cb", wintypes.DWORD),
|
||||
("CommitTotal", ctypes.c_size_t),
|
||||
("CommitLimit", ctypes.c_size_t),
|
||||
("CommitPeak", ctypes.c_size_t),
|
||||
("PhysicalTotal", ctypes.c_size_t),
|
||||
("PhysicalAvailable", ctypes.c_size_t),
|
||||
("SystemCache", ctypes.c_size_t),
|
||||
("KernelTotal", ctypes.c_size_t),
|
||||
("KernelPaged", ctypes.c_size_t),
|
||||
("KernelNonpaged", ctypes.c_size_t),
|
||||
("PageSize", ctypes.c_size_t),
|
||||
("HandleCount", wintypes.DWORD),
|
||||
("ProcessCount", wintypes.DWORD),
|
||||
("ThreadCount", wintypes.DWORD),
|
||||
]
|
||||
|
||||
def get_free_ram():
|
||||
#Windows is way too conservative and chalks recently used uncommitted model RAM
|
||||
#as "in-use". So, calculate free RAM for the sake of general use as the greater of:
|
||||
#
|
||||
#1: What psutil says
|
||||
#2: Total Memory - (Committed Memory - VRAM in use)
|
||||
#
|
||||
#We have to subtract VRAM in use from the comitted memory as WDDM creates a naked
|
||||
#commit charge for all VRAM used just incase it wants to page it all out. This just
|
||||
#isn't realistic so "overcommit" on our calculations by just subtracting it off.
|
||||
|
||||
pi = PERFORMANCE_INFORMATION()
|
||||
pi.cb = ctypes.sizeof(pi)
|
||||
|
||||
if not psapi.GetPerformanceInfo(ctypes.byref(pi), pi.cb):
|
||||
logging.warning("WARNING: Failed to query windows performance info. RAM usage may be sub optimal")
|
||||
return psutil.virtual_memory().available
|
||||
|
||||
committed = pi.CommitTotal * pi.PageSize
|
||||
total = pi.PhysicalTotal * pi.PageSize
|
||||
|
||||
return max(psutil.virtual_memory().available,
|
||||
total - (committed - comfy_aimdo.control.get_total_vram_usage()))
|
||||
|
||||
@@ -12,9 +12,24 @@ class VOXEL:
|
||||
|
||||
|
||||
class MESH:
|
||||
def __init__(self, vertices: torch.Tensor, faces: torch.Tensor):
|
||||
self.vertices = vertices
|
||||
self.faces = faces
|
||||
def __init__(self, vertices: torch.Tensor, faces: torch.Tensor,
|
||||
uvs: torch.Tensor | None = None,
|
||||
vertex_colors: torch.Tensor | None = None,
|
||||
texture: torch.Tensor | None = None,
|
||||
vertex_counts: torch.Tensor | None = None,
|
||||
face_counts: torch.Tensor | None = None):
|
||||
|
||||
assert (vertex_counts is None) == (face_counts is None), \
|
||||
"vertex_counts and face_counts must be provided together (both or neither)"
|
||||
self.vertices = vertices # vertices: (B, N, 3)
|
||||
self.faces = faces # faces: (B, M, 3)
|
||||
self.uvs = uvs # uvs: (B, N, 2)
|
||||
self.vertex_colors = vertex_colors # vertex_colors: (B, N, 3 or 4)
|
||||
self.texture = texture # texture: (B, H, W, 3)
|
||||
# When vertices/faces are zero-padded to a common N/M across the batch (variable-size mesh batch),
|
||||
# these hold the real per-item lengths (B,). None means rows are uniform and no slicing is needed.
|
||||
self.vertex_counts = vertex_counts
|
||||
self.face_counts = face_counts
|
||||
|
||||
|
||||
class File3D:
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
from enum import Enum
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AnthropicRole(str, Enum):
|
||||
user = "user"
|
||||
assistant = "assistant"
|
||||
|
||||
|
||||
class AnthropicTextContent(BaseModel):
|
||||
type: Literal["text"] = "text"
|
||||
text: str = Field(...)
|
||||
|
||||
|
||||
class AnthropicImageSourceBase64(BaseModel):
|
||||
type: Literal["base64"] = "base64"
|
||||
media_type: str = Field(..., description="MIME type of the image, e.g. image/png, image/jpeg")
|
||||
data: str = Field(..., description="Base64-encoded image data")
|
||||
|
||||
|
||||
class AnthropicImageSourceUrl(BaseModel):
|
||||
type: Literal["url"] = "url"
|
||||
url: str = Field(...)
|
||||
|
||||
|
||||
class AnthropicImageContent(BaseModel):
|
||||
type: Literal["image"] = "image"
|
||||
source: AnthropicImageSourceBase64 | AnthropicImageSourceUrl = Field(...)
|
||||
|
||||
|
||||
class AnthropicMessage(BaseModel):
|
||||
role: AnthropicRole = Field(...)
|
||||
content: list[AnthropicTextContent | AnthropicImageContent] = Field(...)
|
||||
|
||||
|
||||
class AnthropicThinkingConfig(BaseModel):
|
||||
type: Literal["enabled", "disabled", "adaptive"] = Field(...)
|
||||
budget_tokens: int | None = Field(
|
||||
None, ge=1024,
|
||||
description="Reasoning budget in tokens. Used when type is 'enabled'. Must be less than max_tokens.",
|
||||
)
|
||||
|
||||
|
||||
class AnthropicOutputConfig(BaseModel):
|
||||
"""Used with `thinking.type='adaptive'` on models like Opus 4.7."""
|
||||
effort: Literal["low", "medium", "high"] | None = Field(None)
|
||||
|
||||
|
||||
class AnthropicMessagesRequest(BaseModel):
|
||||
model: str = Field(...)
|
||||
messages: list[AnthropicMessage] = Field(...)
|
||||
max_tokens: int = Field(..., ge=1)
|
||||
system: str | None = Field(None, description="Top-level system prompt")
|
||||
temperature: float | None = Field(None, ge=0.0, le=1.0)
|
||||
top_p: float | None = Field(None, ge=0.0, le=1.0)
|
||||
top_k: int | None = Field(None, ge=0)
|
||||
stop_sequences: list[str] | None = Field(None)
|
||||
thinking: AnthropicThinkingConfig | None = Field(None)
|
||||
output_config: AnthropicOutputConfig | None = Field(None)
|
||||
|
||||
|
||||
class AnthropicResponseTextBlock(BaseModel):
|
||||
type: Literal["text"] = "text"
|
||||
text: str = Field(...)
|
||||
|
||||
|
||||
class AnthropicResponseThinkingBlock(BaseModel):
|
||||
type: Literal["thinking"] = "thinking"
|
||||
thinking: str = Field(...)
|
||||
|
||||
|
||||
AnthropicResponseBlock = AnthropicResponseTextBlock | AnthropicResponseThinkingBlock
|
||||
|
||||
|
||||
class AnthropicCacheCreationUsage(BaseModel):
|
||||
ephemeral_5m_input_tokens: int | None = Field(None)
|
||||
ephemeral_1h_input_tokens: int | None = Field(None)
|
||||
|
||||
|
||||
class AnthropicMessagesUsage(BaseModel):
|
||||
input_tokens: int | None = Field(None)
|
||||
output_tokens: int | None = Field(None)
|
||||
cache_creation_input_tokens: int | None = Field(None)
|
||||
cache_read_input_tokens: int | None = Field(None)
|
||||
cache_creation: AnthropicCacheCreationUsage | None = Field(None)
|
||||
|
||||
|
||||
class AnthropicMessagesResponse(BaseModel):
|
||||
id: str | None = Field(None)
|
||||
type: str | None = Field(None)
|
||||
role: str | None = Field(None)
|
||||
model: str | None = Field(None)
|
||||
content: list[AnthropicResponseBlock] | None = Field(None)
|
||||
stop_reason: str | None = Field(None)
|
||||
stop_sequence: str | None = Field(None)
|
||||
usage: AnthropicMessagesUsage | None = Field(None)
|
||||
@@ -198,6 +198,62 @@ RECOMMENDED_PRESETS_SEEDREAM_4 = [
|
||||
("Custom", None, None),
|
||||
]
|
||||
|
||||
_PRESETS_SEEDREAM_1K = [
|
||||
("(1K) 1024x1024 (1:1)", 1024, 1024),
|
||||
("(1K) 864x1152 (3:4)", 864, 1152),
|
||||
("(1K) 1152x864 (4:3)", 1152, 864),
|
||||
("(1K) 1312x736 (16:9)", 1312, 736),
|
||||
("(1K) 736x1312 (9:16)", 736, 1312),
|
||||
("(1K) 832x1248 (2:3)", 832, 1248),
|
||||
("(1K) 1248x832 (3:2)", 1248, 832),
|
||||
("(1K) 1568x672 (21:9)", 1568, 672),
|
||||
]
|
||||
|
||||
_PRESETS_SEEDREAM_2K = [
|
||||
("(2K) 2048x2048 (1:1)", 2048, 2048),
|
||||
("(2K) 1728x2304 (3:4)", 1728, 2304),
|
||||
("(2K) 2304x1728 (4:3)", 2304, 1728),
|
||||
("(2K) 2848x1600 (16:9)", 2848, 1600),
|
||||
("(2K) 1600x2848 (9:16)", 1600, 2848),
|
||||
("(2K) 1664x2496 (2:3)", 1664, 2496),
|
||||
("(2K) 2496x1664 (3:2)", 2496, 1664),
|
||||
("(2K) 3136x1344 (21:9)", 3136, 1344),
|
||||
]
|
||||
|
||||
_PRESETS_SEEDREAM_3K = [
|
||||
("(3K) 3072x3072 (1:1)", 3072, 3072),
|
||||
("(3K) 2592x3456 (3:4)", 2592, 3456),
|
||||
("(3K) 3456x2592 (4:3)", 3456, 2592),
|
||||
("(3K) 4096x2304 (16:9)", 4096, 2304),
|
||||
("(3K) 2304x4096 (9:16)", 2304, 4096),
|
||||
("(3K) 2496x3744 (2:3)", 2496, 3744),
|
||||
("(3K) 3744x2496 (3:2)", 3744, 2496),
|
||||
("(3K) 4704x2016 (21:9)", 4704, 2016),
|
||||
]
|
||||
|
||||
_PRESETS_SEEDREAM_4K = [
|
||||
("(4K) 4096x4096 (1:1)", 4096, 4096),
|
||||
("(4K) 3520x4704 (3:4)", 3520, 4704),
|
||||
("(4K) 4704x3520 (4:3)", 4704, 3520),
|
||||
("(4K) 5504x3040 (16:9)", 5504, 3040),
|
||||
("(4K) 3040x5504 (9:16)", 3040, 5504),
|
||||
("(4K) 3328x4992 (2:3)", 3328, 4992),
|
||||
("(4K) 4992x3328 (3:2)", 4992, 3328),
|
||||
("(4K) 6240x2656 (21:9)", 6240, 2656),
|
||||
]
|
||||
|
||||
_CUSTOM_PRESET = [("Custom", None, None)]
|
||||
|
||||
RECOMMENDED_PRESETS_SEEDREAM_5_LITE = (
|
||||
_PRESETS_SEEDREAM_2K + _PRESETS_SEEDREAM_3K + _PRESETS_SEEDREAM_4K + _CUSTOM_PRESET
|
||||
)
|
||||
RECOMMENDED_PRESETS_SEEDREAM_4_5 = (
|
||||
_PRESETS_SEEDREAM_2K + _PRESETS_SEEDREAM_4K + _CUSTOM_PRESET
|
||||
)
|
||||
RECOMMENDED_PRESETS_SEEDREAM_4_0 = (
|
||||
_PRESETS_SEEDREAM_1K + _PRESETS_SEEDREAM_2K + _PRESETS_SEEDREAM_4K + _CUSTOM_PRESET
|
||||
)
|
||||
|
||||
# Seedance 2.0 reference video pixel count limits per model and output resolution.
|
||||
SEEDANCE2_REF_VIDEO_PIXEL_LIMITS = {
|
||||
"dreamina-seedance-2-0-260128": {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Pydantic models for BytePlus ModelArk Responses API.
|
||||
|
||||
See: https://docs.byteplus.com/en/docs/ModelArk/1585128 (request)
|
||||
https://docs.byteplus.com/en/docs/ModelArk/1783703 (response)
|
||||
"""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class BytePlusInputText(BaseModel):
|
||||
type: Literal["input_text"] = "input_text"
|
||||
text: str = Field(...)
|
||||
|
||||
|
||||
class BytePlusInputImage(BaseModel):
|
||||
type: Literal["input_image"] = "input_image"
|
||||
image_url: str = Field(..., description="Image URL or `data:image/...;base64,...` payload")
|
||||
detail: str = Field("auto", description="One of high, low, auto")
|
||||
|
||||
|
||||
class BytePlusInputVideo(BaseModel):
|
||||
type: Literal["input_video"] = "input_video"
|
||||
video_url: str = Field(..., description="Video URL or `data:video/...;base64,...` payload")
|
||||
fps: float | None = Field(None, ge=0.2, le=5.0)
|
||||
|
||||
|
||||
BytePlusMessageContent = BytePlusInputText | BytePlusInputImage | BytePlusInputVideo
|
||||
|
||||
|
||||
class BytePlusInputMessage(BaseModel):
|
||||
type: Literal["message"] = "message"
|
||||
role: str = Field(..., description="One of user, system, assistant, developer")
|
||||
content: list[BytePlusMessageContent] = Field(...)
|
||||
|
||||
|
||||
class BytePlusResponseCreateRequest(BaseModel):
|
||||
model: str = Field(...)
|
||||
input: list[BytePlusInputMessage] = Field(...)
|
||||
instructions: str | None = Field(None)
|
||||
max_output_tokens: int | None = Field(None, ge=1)
|
||||
temperature: float | None = Field(None, ge=0.0, le=2.0)
|
||||
store: bool | None = Field(False)
|
||||
stream: bool | None = Field(False)
|
||||
|
||||
|
||||
class BytePlusOutputText(BaseModel):
|
||||
type: Literal["output_text"] = "output_text"
|
||||
text: str = Field(...)
|
||||
|
||||
|
||||
class BytePlusOutputRefusal(BaseModel):
|
||||
type: Literal["refusal"] = "refusal"
|
||||
refusal: str = Field(...)
|
||||
|
||||
|
||||
class BytePlusOutputContent(BaseModel):
|
||||
type: str = Field(...)
|
||||
text: str | None = Field(None)
|
||||
refusal: str | None = Field(None)
|
||||
|
||||
|
||||
class BytePlusOutputMessage(BaseModel):
|
||||
type: str = Field(...)
|
||||
id: str | None = Field(None)
|
||||
role: str | None = Field(None)
|
||||
status: str | None = Field(None)
|
||||
content: list[BytePlusOutputContent] | None = Field(None)
|
||||
|
||||
|
||||
class BytePlusInputTokensDetails(BaseModel):
|
||||
cached_tokens: int | None = Field(None)
|
||||
|
||||
|
||||
class BytePlusOutputTokensDetails(BaseModel):
|
||||
reasoning_tokens: int | None = Field(None)
|
||||
|
||||
|
||||
class BytePlusResponseUsage(BaseModel):
|
||||
input_tokens: int | None = Field(None)
|
||||
output_tokens: int | None = Field(None)
|
||||
total_tokens: int | None = Field(None)
|
||||
input_tokens_details: BytePlusInputTokensDetails | None = Field(None)
|
||||
output_tokens_details: BytePlusOutputTokensDetails | None = Field(None)
|
||||
|
||||
|
||||
class BytePlusResponseError(BaseModel):
|
||||
code: str = Field(...)
|
||||
message: str = Field(...)
|
||||
|
||||
|
||||
class BytePlusResponseObject(BaseModel):
|
||||
id: str | None = Field(None)
|
||||
object: str | None = Field(None)
|
||||
created_at: int | None = Field(None)
|
||||
model: str | None = Field(None)
|
||||
status: str | None = Field(None)
|
||||
error: BytePlusResponseError | None = Field(None)
|
||||
output: list[BytePlusOutputMessage] | None = Field(None)
|
||||
usage: BytePlusResponseUsage | None = Field(None)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Pydantic models for the OpenRouter chat completions API.
|
||||
|
||||
See: https://openrouter.ai/docs/api/api-reference/chat/send-chat-completion-request
|
||||
"""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class OpenRouterTextContent(BaseModel):
|
||||
type: Literal["text"] = "text"
|
||||
text: str = Field(...)
|
||||
|
||||
|
||||
class OpenRouterImageUrl(BaseModel):
|
||||
url: str = Field(...)
|
||||
|
||||
|
||||
class OpenRouterImageContent(BaseModel):
|
||||
type: Literal["image_url"] = "image_url"
|
||||
image_url: OpenRouterImageUrl = Field(...)
|
||||
|
||||
|
||||
class OpenRouterVideoUrl(BaseModel):
|
||||
url: str = Field(...)
|
||||
|
||||
|
||||
class OpenRouterVideoContent(BaseModel):
|
||||
type: Literal["video_url"] = "video_url"
|
||||
video_url: OpenRouterVideoUrl = Field(...)
|
||||
|
||||
|
||||
OpenRouterContentBlock = OpenRouterTextContent | OpenRouterImageContent | OpenRouterVideoContent
|
||||
|
||||
|
||||
class OpenRouterMessage(BaseModel):
|
||||
role: Literal["system", "user", "assistant"] = Field(...)
|
||||
content: str | list[OpenRouterContentBlock] = Field(...)
|
||||
|
||||
|
||||
class OpenRouterReasoningConfig(BaseModel):
|
||||
effort: str | None = Field(None)
|
||||
exclude: bool | None = Field(None, description="If true, model reasons but reasoning is excluded from response.")
|
||||
|
||||
|
||||
class OpenRouterWebSearchOptions(BaseModel):
|
||||
search_context_size: str | None = Field(None)
|
||||
|
||||
|
||||
class OpenRouterChatRequest(BaseModel):
|
||||
model: str = Field(...)
|
||||
messages: list[OpenRouterMessage] = Field(...)
|
||||
seed: int | None = Field(None)
|
||||
reasoning: OpenRouterReasoningConfig | None = Field(None)
|
||||
web_search_options: OpenRouterWebSearchOptions | None = Field(None)
|
||||
stream: bool = Field(False)
|
||||
|
||||
|
||||
class OpenRouterUsage(BaseModel):
|
||||
prompt_tokens: int | None = Field(None)
|
||||
completion_tokens: int | None = Field(None)
|
||||
total_tokens: int | None = Field(None)
|
||||
cost: float | None = Field(None, description="Server-side authoritative USD cost of the call.")
|
||||
|
||||
|
||||
class OpenRouterResponseMessage(BaseModel):
|
||||
role: str | None = Field(None)
|
||||
content: str | None = Field(None)
|
||||
reasoning: str | None = Field(None)
|
||||
refusal: str | None = Field(None)
|
||||
|
||||
|
||||
class OpenRouterChoice(BaseModel):
|
||||
index: int | None = Field(None)
|
||||
message: OpenRouterResponseMessage | None = Field(None)
|
||||
finish_reason: str | None = Field(None)
|
||||
|
||||
|
||||
class OpenRouterError(BaseModel):
|
||||
code: int | str | None = Field(None)
|
||||
message: str | None = Field(None)
|
||||
metadata: dict | None = Field(None)
|
||||
|
||||
|
||||
class OpenRouterChatResponse(BaseModel):
|
||||
id: str | None = Field(None)
|
||||
model: str | None = Field(None)
|
||||
object: str | None = Field(None)
|
||||
provider: str | None = Field(None)
|
||||
choices: list[OpenRouterChoice] | None = Field(None)
|
||||
usage: OpenRouterUsage | None = Field(None)
|
||||
error: OpenRouterError | None = Field(None)
|
||||
@@ -0,0 +1,306 @@
|
||||
"""API Nodes for Anthropic Claude (Messages API). See: https://docs.anthropic.com/en/api/messages"""
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from comfy_api.latest import IO, ComfyExtension, Input
|
||||
from comfy_api_nodes.apis.anthropic import (
|
||||
AnthropicImageContent,
|
||||
AnthropicImageSourceUrl,
|
||||
AnthropicMessage,
|
||||
AnthropicMessagesRequest,
|
||||
AnthropicMessagesResponse,
|
||||
AnthropicOutputConfig,
|
||||
AnthropicResponseTextBlock,
|
||||
AnthropicRole,
|
||||
AnthropicTextContent,
|
||||
AnthropicThinkingConfig,
|
||||
)
|
||||
from comfy_api_nodes.util import (
|
||||
ApiEndpoint,
|
||||
get_number_of_images,
|
||||
sync_op,
|
||||
upload_images_to_comfyapi,
|
||||
validate_string,
|
||||
)
|
||||
|
||||
ANTHROPIC_MESSAGES_ENDPOINT = "/proxy/anthropic/v1/messages"
|
||||
ANTHROPIC_IMAGE_MAX_PIXELS = 1568 * 1568
|
||||
CLAUDE_MAX_IMAGES = 20
|
||||
|
||||
CLAUDE_MODELS: dict[str, str] = {
|
||||
"Opus 4.7": "claude-opus-4-7",
|
||||
"Opus 4.6": "claude-opus-4-6",
|
||||
"Sonnet 4.6": "claude-sonnet-4-6",
|
||||
"Sonnet 4.5": "claude-sonnet-4-5-20250929",
|
||||
"Haiku 4.5": "claude-haiku-4-5-20251001",
|
||||
}
|
||||
|
||||
_THINKING_UNSUPPORTED = {"Haiku 4.5"}
|
||||
# Models that use the newer "adaptive" thinking mode (Opus 4.7 requires it; older models keep the explicit budget API).
|
||||
# Anthropic decides the actual budget when adaptive is used, based on the `output_config.effort` hint.
|
||||
_ADAPTIVE_THINKING_MODELS = {"Opus 4.7", "Opus 4.6", "Sonnet 4.6"}
|
||||
|
||||
# Budget mode (Sonnet 4.5): effort -> reasoning budget in tokens. Must be < max_tokens.
|
||||
# Sized so even the "high" budget fits comfortably under the default max_tokens=32768.
|
||||
_REASONING_BUDGET: dict[str, int] = {
|
||||
"low": 2048,
|
||||
"medium": 8192,
|
||||
"high": 16384,
|
||||
}
|
||||
_REASONING_EFFORTS = ["off", "low", "medium", "high"]
|
||||
|
||||
|
||||
def _claude_model_inputs(model_label: str):
|
||||
inputs: list = [
|
||||
IO.Int.Input(
|
||||
"max_tokens",
|
||||
default=32768,
|
||||
min=4096,
|
||||
max=64000,
|
||||
tooltip="Maximum number of tokens to generate (includes reasoning tokens when enabled).",
|
||||
advanced=True,
|
||||
),
|
||||
IO.Float.Input(
|
||||
"temperature",
|
||||
default=1.0,
|
||||
min=0.0,
|
||||
max=1.0,
|
||||
step=0.01,
|
||||
tooltip=(
|
||||
"Controls randomness. 0.0 is deterministic, 1.0 is most random. "
|
||||
"Ignored for Opus 4.7 and any model when reasoning_effort is set."
|
||||
),
|
||||
advanced=True,
|
||||
),
|
||||
]
|
||||
if model_label not in _THINKING_UNSUPPORTED:
|
||||
inputs.append(
|
||||
IO.Combo.Input(
|
||||
"reasoning_effort",
|
||||
options=_REASONING_EFFORTS,
|
||||
default="off",
|
||||
tooltip="Extended thinking effort. 'off' disables reasoning.",
|
||||
advanced=True,
|
||||
)
|
||||
)
|
||||
return inputs
|
||||
|
||||
|
||||
def _model_price_per_million(model: str) -> tuple[float, float] | None:
|
||||
"""Return (input_per_1M, output_per_1M) USD for a Claude model, or None if unknown."""
|
||||
if "opus-4-7" in model or "opus-4-6" in model or "opus-4-5" in model:
|
||||
return 5.0, 25.0
|
||||
if "sonnet-4" in model:
|
||||
return 3.0, 15.0
|
||||
if "haiku-4-5" in model:
|
||||
return 1.0, 5.0
|
||||
return None
|
||||
|
||||
|
||||
def calculate_tokens_price(response: AnthropicMessagesResponse) -> float | None:
|
||||
"""Compute approximate USD price from response usage. Server-side billing is authoritative."""
|
||||
if not response.usage or not response.model:
|
||||
return None
|
||||
rates = _model_price_per_million(response.model)
|
||||
if rates is None:
|
||||
return None
|
||||
input_rate, output_rate = rates
|
||||
input_tokens = response.usage.input_tokens or 0
|
||||
output_tokens = response.usage.output_tokens or 0
|
||||
cache_read = response.usage.cache_read_input_tokens or 0
|
||||
cache_5m = 0
|
||||
cache_1h = 0
|
||||
if response.usage.cache_creation:
|
||||
cache_5m = response.usage.cache_creation.ephemeral_5m_input_tokens or 0
|
||||
cache_1h = response.usage.cache_creation.ephemeral_1h_input_tokens or 0
|
||||
total = (
|
||||
input_tokens * input_rate
|
||||
+ output_tokens * output_rate
|
||||
+ cache_read * input_rate * 0.1
|
||||
+ cache_5m * input_rate * 1.25
|
||||
+ cache_1h * input_rate * 2.0
|
||||
)
|
||||
return total / 1_000_000.0
|
||||
|
||||
|
||||
def _get_text_from_response(response: AnthropicMessagesResponse) -> str:
|
||||
if not response.content:
|
||||
return ""
|
||||
# Thinking blocks are silently dropped — we never want reasoning in the output.
|
||||
return "\n".join(
|
||||
block.text for block in response.content
|
||||
if isinstance(block, AnthropicResponseTextBlock) and block.text
|
||||
)
|
||||
|
||||
|
||||
async def _build_image_content_blocks(
|
||||
cls: type[IO.ComfyNode],
|
||||
image_tensors: list[Input.Image],
|
||||
) -> list[AnthropicImageContent]:
|
||||
urls = await upload_images_to_comfyapi(
|
||||
cls,
|
||||
image_tensors,
|
||||
max_images=CLAUDE_MAX_IMAGES,
|
||||
total_pixels=ANTHROPIC_IMAGE_MAX_PIXELS,
|
||||
wait_label="Uploading reference images",
|
||||
)
|
||||
return [AnthropicImageContent(source=AnthropicImageSourceUrl(url=url)) for url in urls]
|
||||
|
||||
|
||||
class ClaudeNode(IO.ComfyNode):
|
||||
"""Generate text responses from an Anthropic Claude model."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="ClaudeNode",
|
||||
display_name="Anthropic Claude",
|
||||
category="api node/text/Anthropic",
|
||||
essentials_category="Text Generation",
|
||||
description="Generate text responses with Anthropic's Claude models. "
|
||||
"Provide a text prompt and optionally one or more images for multimodal context.",
|
||||
inputs=[
|
||||
IO.String.Input(
|
||||
"prompt",
|
||||
multiline=True,
|
||||
default="",
|
||||
tooltip="Text input to the model.",
|
||||
),
|
||||
IO.DynamicCombo.Input(
|
||||
"model",
|
||||
options=[
|
||||
IO.DynamicCombo.Option(label, _claude_model_inputs(label))
|
||||
for label in CLAUDE_MODELS
|
||||
],
|
||||
tooltip="The Claude model used to generate the response.",
|
||||
),
|
||||
IO.Int.Input(
|
||||
"seed",
|
||||
default=0,
|
||||
min=0,
|
||||
max=2147483647,
|
||||
control_after_generate=True,
|
||||
tooltip="Seed controls whether the node should re-run; "
|
||||
"results are non-deterministic regardless of seed.",
|
||||
),
|
||||
IO.Autogrow.Input(
|
||||
"images",
|
||||
template=IO.Autogrow.TemplateNames(
|
||||
IO.Image.Input("image"),
|
||||
names=[f"image_{i}" for i in range(1, CLAUDE_MAX_IMAGES + 1)],
|
||||
min=0,
|
||||
),
|
||||
tooltip=f"Optional image(s) to use as context for the model. Up to {CLAUDE_MAX_IMAGES} images.",
|
||||
),
|
||||
IO.String.Input(
|
||||
"system_prompt",
|
||||
multiline=True,
|
||||
default="",
|
||||
optional=True,
|
||||
advanced=True,
|
||||
tooltip="Foundational instructions that dictate the model's behavior.",
|
||||
),
|
||||
],
|
||||
outputs=[IO.String.Output()],
|
||||
hidden=[
|
||||
IO.Hidden.auth_token_comfy_org,
|
||||
IO.Hidden.api_key_comfy_org,
|
||||
IO.Hidden.unique_id,
|
||||
],
|
||||
is_api_node=True,
|
||||
price_badge=IO.PriceBadge(
|
||||
depends_on=IO.PriceBadgeDepends(widgets=["model"]),
|
||||
expr="""
|
||||
(
|
||||
$m := widgets.model;
|
||||
$contains($m, "opus") ? {
|
||||
"type": "list_usd",
|
||||
"usd": [0.005, 0.025],
|
||||
"format": { "approximate": true, "separator": "-", "suffix": " per 1K tokens" }
|
||||
}
|
||||
: $contains($m, "sonnet") ? {
|
||||
"type": "list_usd",
|
||||
"usd": [0.003, 0.015],
|
||||
"format": { "approximate": true, "separator": "-", "suffix": " per 1K tokens" }
|
||||
}
|
||||
: $contains($m, "haiku") ? {
|
||||
"type": "list_usd",
|
||||
"usd": [0.001, 0.005],
|
||||
"format": { "approximate": true, "separator": "-", "suffix": " per 1K tokens" }
|
||||
}
|
||||
: {"type":"text", "text":"Token-based"}
|
||||
)
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def execute(
|
||||
cls,
|
||||
prompt: str,
|
||||
model: dict,
|
||||
seed: int,
|
||||
images: dict | None = None,
|
||||
system_prompt: str = "",
|
||||
) -> IO.NodeOutput:
|
||||
validate_string(prompt, strip_whitespace=True, min_length=1)
|
||||
model_label = model["model"]
|
||||
max_tokens = model.get("max_tokens", 32768)
|
||||
reasoning_effort = model.get("reasoning_effort", "off")
|
||||
thinking_enabled = reasoning_effort not in ("off", None) and model_label not in _THINKING_UNSUPPORTED
|
||||
|
||||
# Anthropic requires temperature to be unset (defaults to 1.0) when thinking is enabled.
|
||||
# Opus 4.7 also rejects user-supplied temperature.
|
||||
if thinking_enabled or model_label == "Opus 4.7":
|
||||
temperature = None
|
||||
else:
|
||||
temperature = model.get("temperature", 1.0)
|
||||
|
||||
thinking_cfg: AnthropicThinkingConfig | None = None
|
||||
output_cfg: AnthropicOutputConfig | None = None
|
||||
if thinking_enabled:
|
||||
if model_label in _ADAPTIVE_THINKING_MODELS:
|
||||
# Adaptive mode - Anthropic chooses the budget based on effort hint
|
||||
thinking_cfg = AnthropicThinkingConfig(type="adaptive")
|
||||
output_cfg = AnthropicOutputConfig(effort=reasoning_effort)
|
||||
else:
|
||||
# Budget mode (Sonnet 4.5). Leave at least 1024 tokens for the actual response
|
||||
budget = _REASONING_BUDGET[reasoning_effort]
|
||||
budget = min(budget, max(1024, max_tokens - 1024))
|
||||
thinking_cfg = AnthropicThinkingConfig(type="enabled", budget_tokens=budget)
|
||||
|
||||
image_tensors: list[Input.Image] = [t for t in (images or {}).values() if t is not None]
|
||||
if sum(get_number_of_images(t) for t in image_tensors) > CLAUDE_MAX_IMAGES:
|
||||
raise ValueError(f"Up to {CLAUDE_MAX_IMAGES} images are supported per request.")
|
||||
|
||||
content: list[AnthropicTextContent | AnthropicImageContent] = []
|
||||
if image_tensors:
|
||||
content.extend(await _build_image_content_blocks(cls, image_tensors))
|
||||
content.append(AnthropicTextContent(text=prompt))
|
||||
|
||||
response = await sync_op(
|
||||
cls,
|
||||
ApiEndpoint(path=ANTHROPIC_MESSAGES_ENDPOINT, method="POST"),
|
||||
response_model=AnthropicMessagesResponse,
|
||||
data=AnthropicMessagesRequest(
|
||||
model=CLAUDE_MODELS[model_label],
|
||||
max_tokens=max_tokens,
|
||||
messages=[AnthropicMessage(role=AnthropicRole.user, content=content)],
|
||||
system=system_prompt or None,
|
||||
temperature=temperature,
|
||||
thinking=thinking_cfg,
|
||||
output_config=output_cfg,
|
||||
),
|
||||
price_extractor=calculate_tokens_price,
|
||||
)
|
||||
return IO.NodeOutput(_get_text_from_response(response) or "Empty response from Claude model.")
|
||||
|
||||
|
||||
class AnthropicExtension(ComfyExtension):
|
||||
@override
|
||||
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
|
||||
return [ClaudeNode]
|
||||
|
||||
|
||||
async def comfy_entrypoint() -> AnthropicExtension:
|
||||
return AnthropicExtension()
|
||||
@@ -596,6 +596,7 @@ class Flux2ProImageNode(IO.ComfyNode):
|
||||
depends_on=IO.PriceBadgeDepends(widgets=["width", "height"], inputs=["images"]),
|
||||
expr=cls.PRICE_BADGE_EXPR,
|
||||
),
|
||||
is_deprecated=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -674,6 +675,175 @@ class Flux2MaxImageNode(Flux2ProImageNode):
|
||||
"""
|
||||
|
||||
|
||||
_FLUX2_MODEL_ENDPOINTS = {
|
||||
"Flux.2 [pro]": "/proxy/bfl/flux-2-pro/generate",
|
||||
"Flux.2 [max]": "/proxy/bfl/flux-2-max/generate",
|
||||
}
|
||||
|
||||
|
||||
def _flux2_model_inputs():
|
||||
return [
|
||||
IO.Int.Input(
|
||||
"width",
|
||||
default=1024,
|
||||
min=256,
|
||||
max=2048,
|
||||
step=32,
|
||||
),
|
||||
IO.Int.Input(
|
||||
"height",
|
||||
default=768,
|
||||
min=256,
|
||||
max=2048,
|
||||
step=32,
|
||||
),
|
||||
IO.Autogrow.Input(
|
||||
"images",
|
||||
template=IO.Autogrow.TemplateNames(
|
||||
IO.Image.Input("image"),
|
||||
names=[f"image_{i}" for i in range(1, 9)],
|
||||
min=0,
|
||||
),
|
||||
tooltip="Optional reference image(s) for image-to-image generation. Up to 8 images.",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class Flux2ImageNode(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls) -> IO.Schema:
|
||||
return IO.Schema(
|
||||
node_id="Flux2ImageNode",
|
||||
display_name="Flux.2 Image",
|
||||
category="api node/image/BFL",
|
||||
description="Generate images via Flux.2 [pro] or Flux.2 [max] from a prompt and optional reference images.",
|
||||
inputs=[
|
||||
IO.String.Input(
|
||||
"prompt",
|
||||
multiline=True,
|
||||
default="",
|
||||
tooltip="Prompt for the image generation or edit",
|
||||
),
|
||||
IO.DynamicCombo.Input(
|
||||
"model",
|
||||
options=[
|
||||
IO.DynamicCombo.Option("Flux.2 [pro]", _flux2_model_inputs()),
|
||||
IO.DynamicCombo.Option("Flux.2 [max]", _flux2_model_inputs()),
|
||||
],
|
||||
),
|
||||
IO.Int.Input(
|
||||
"seed",
|
||||
default=0,
|
||||
min=0,
|
||||
max=0xFFFFFFFFFFFFFFFF,
|
||||
control_after_generate=True,
|
||||
tooltip="The random seed used for creating the noise.",
|
||||
),
|
||||
],
|
||||
outputs=[IO.Image.Output()],
|
||||
hidden=[
|
||||
IO.Hidden.auth_token_comfy_org,
|
||||
IO.Hidden.api_key_comfy_org,
|
||||
IO.Hidden.unique_id,
|
||||
],
|
||||
is_api_node=True,
|
||||
price_badge=IO.PriceBadge(
|
||||
depends_on=IO.PriceBadgeDepends(
|
||||
widgets=["model", "model.width", "model.height"],
|
||||
input_groups=["model.images"],
|
||||
),
|
||||
expr="""
|
||||
(
|
||||
$isMax := widgets.model = "flux.2 [max]";
|
||||
$MP := 1024 * 1024;
|
||||
$w := $lookup(widgets, "model.width");
|
||||
$h := $lookup(widgets, "model.height");
|
||||
$outMP := $max([1, $floor((($w * $h) + $MP - 1) / $MP)]);
|
||||
$outputCost := $isMax
|
||||
? (0.07 + 0.03 * ($outMP - 1))
|
||||
: (0.03 + 0.015 * ($outMP - 1));
|
||||
$refMin := $isMax ? 0.03 : 0.015;
|
||||
$refMax := $isMax ? 0.24 : 0.12;
|
||||
$hasRefs := $lookup(inputGroups, "model.images") > 0;
|
||||
$hasRefs
|
||||
? {
|
||||
"type": "range_usd",
|
||||
"min_usd": $outputCost + $refMin,
|
||||
"max_usd": $outputCost + $refMax,
|
||||
"format": { "approximate": true }
|
||||
}
|
||||
: {"type": "usd", "usd": $outputCost}
|
||||
)
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def execute(
|
||||
cls,
|
||||
prompt: str,
|
||||
model: dict,
|
||||
seed: int,
|
||||
) -> IO.NodeOutput:
|
||||
model_choice = model["model"]
|
||||
endpoint = _FLUX2_MODEL_ENDPOINTS[model_choice]
|
||||
width = model["width"]
|
||||
height = model["height"]
|
||||
images_dict = model.get("images") or {}
|
||||
|
||||
image_tensors: list[Input.Image] = [t for t in images_dict.values() if t is not None]
|
||||
n_images = sum(get_number_of_images(t) for t in image_tensors)
|
||||
if n_images > 8:
|
||||
raise ValueError("The current maximum number of supported images is 8.")
|
||||
|
||||
flat_tensors: list[torch.Tensor] = []
|
||||
for tensor in image_tensors:
|
||||
if len(tensor.shape) == 4:
|
||||
flat_tensors.extend(tensor[i] for i in range(tensor.shape[0]))
|
||||
else:
|
||||
flat_tensors.append(tensor)
|
||||
|
||||
reference_images: dict[str, str] = {}
|
||||
for idx, tensor in enumerate(flat_tensors):
|
||||
key_name = f"input_image_{idx + 1}" if idx else "input_image"
|
||||
reference_images[key_name] = tensor_to_base64_string(tensor, total_pixels=2048 * 2048)
|
||||
|
||||
initial_response = await sync_op(
|
||||
cls,
|
||||
ApiEndpoint(path=endpoint, method="POST"),
|
||||
response_model=BFLFluxProGenerateResponse,
|
||||
data=Flux2ProGenerateRequest(
|
||||
prompt=prompt,
|
||||
width=width,
|
||||
height=height,
|
||||
seed=seed,
|
||||
**reference_images,
|
||||
),
|
||||
)
|
||||
|
||||
def price_extractor(_r: BaseModel) -> float | None:
|
||||
return None if initial_response.cost is None else initial_response.cost / 100
|
||||
|
||||
response = await poll_op(
|
||||
cls,
|
||||
ApiEndpoint(initial_response.polling_url),
|
||||
response_model=BFLFluxStatusResponse,
|
||||
status_extractor=lambda r: r.status,
|
||||
progress_extractor=lambda r: r.progress,
|
||||
price_extractor=price_extractor,
|
||||
completed_statuses=[BFLStatus.ready],
|
||||
failed_statuses=[
|
||||
BFLStatus.request_moderated,
|
||||
BFLStatus.content_moderated,
|
||||
BFLStatus.error,
|
||||
BFLStatus.task_not_found,
|
||||
],
|
||||
queued_statuses=[],
|
||||
)
|
||||
return IO.NodeOutput(await download_url_to_image_tensor(response.result["sample"]))
|
||||
|
||||
|
||||
class BFLExtension(ComfyExtension):
|
||||
@override
|
||||
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
|
||||
@@ -685,6 +855,7 @@ class BFLExtension(ComfyExtension):
|
||||
FluxProFillNode,
|
||||
Flux2ProImageNode,
|
||||
Flux2MaxImageNode,
|
||||
Flux2ImageNode,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ from comfy_api.latest import IO, ComfyExtension, Input
|
||||
from comfy_api_nodes.apis.bytedance import (
|
||||
RECOMMENDED_PRESETS,
|
||||
RECOMMENDED_PRESETS_SEEDREAM_4,
|
||||
RECOMMENDED_PRESETS_SEEDREAM_4_0,
|
||||
RECOMMENDED_PRESETS_SEEDREAM_4_5,
|
||||
RECOMMENDED_PRESETS_SEEDREAM_5_LITE,
|
||||
SEEDANCE2_PRICE_PER_1K_TOKENS,
|
||||
SEEDANCE2_REF_VIDEO_PIXEL_LIMITS,
|
||||
VIDEO_TASKS_EXECUTION_TIME,
|
||||
@@ -40,15 +43,16 @@ from comfy_api_nodes.util import (
|
||||
ApiEndpoint,
|
||||
download_url_to_image_tensor,
|
||||
download_url_to_video_output,
|
||||
downscale_video_to_max_pixels,
|
||||
get_number_of_images,
|
||||
image_tensor_pair_to_batch,
|
||||
poll_op,
|
||||
resize_video_to_pixel_budget,
|
||||
sync_op,
|
||||
upload_audio_to_comfyapi,
|
||||
upload_image_to_comfyapi,
|
||||
upload_images_to_comfyapi,
|
||||
upload_video_to_comfyapi,
|
||||
upscale_video_to_min_pixels,
|
||||
validate_image_aspect_ratio,
|
||||
validate_image_dimensions,
|
||||
validate_string,
|
||||
@@ -68,6 +72,12 @@ SEEDREAM_MODELS = {
|
||||
"seedream-4-0-250828": "seedream-4-0-250828",
|
||||
}
|
||||
|
||||
SEEDREAM_PRESETS = {
|
||||
"seedream-5-0-260128": RECOMMENDED_PRESETS_SEEDREAM_5_LITE,
|
||||
"seedream-4-5-251128": RECOMMENDED_PRESETS_SEEDREAM_4_5,
|
||||
"seedream-4-0-250828": RECOMMENDED_PRESETS_SEEDREAM_4_0,
|
||||
}
|
||||
|
||||
# Long-running tasks endpoints(e.g., video)
|
||||
BYTEPLUS_TASK_ENDPOINT = "/proxy/byteplus/api/v3/contents/generations/tasks"
|
||||
BYTEPLUS_TASK_STATUS_ENDPOINT = "/proxy/byteplus/api/v3/contents/generations/tasks" # + /{task_id}
|
||||
@@ -101,12 +111,13 @@ def _validate_ref_video_pixels(video: Input.Video, model_id: str, resolution: st
|
||||
max_px = limits.get("max")
|
||||
if min_px and pixels < min_px:
|
||||
raise ValueError(
|
||||
f"Reference video {index} is too small: {w}x{h} = {pixels:,}px. " f"Minimum is {min_px:,}px for this model."
|
||||
f"Reference video {index} is too small: {w}x{h} = {pixels:,} total pixels. "
|
||||
f"Minimum for this model is {min_px:,} total pixels."
|
||||
)
|
||||
if max_px and pixels > max_px:
|
||||
raise ValueError(
|
||||
f"Reference video {index} is too large: {w}x{h} = {pixels:,}px. "
|
||||
f"Maximum is {max_px:,}px for this model. Try downscaling the video."
|
||||
f"Reference video {index} is too large: {w}x{h} = {pixels:,} total pixels. "
|
||||
f"Maximum for this model is {max_px:,} total pixels. Try downscaling the video."
|
||||
)
|
||||
|
||||
|
||||
@@ -562,6 +573,7 @@ class ByteDanceSeedreamNode(IO.ComfyNode):
|
||||
)
|
||||
""",
|
||||
),
|
||||
is_deprecated=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -651,6 +663,226 @@ class ByteDanceSeedreamNode(IO.ComfyNode):
|
||||
return IO.NodeOutput(torch.cat([await download_url_to_image_tensor(i) for i in urls]))
|
||||
|
||||
|
||||
def _seedream_model_inputs(*, max_ref_images: int, presets: list):
|
||||
return [
|
||||
IO.Combo.Input(
|
||||
"size_preset",
|
||||
options=[label for label, _, _ in presets],
|
||||
tooltip="Pick a recommended size. Select Custom to use the width and height below.",
|
||||
),
|
||||
IO.Int.Input(
|
||||
"width",
|
||||
default=2048,
|
||||
min=1024,
|
||||
max=6240,
|
||||
step=2,
|
||||
tooltip="Custom width for image. Value is working only if `size_preset` is set to `Custom`",
|
||||
),
|
||||
IO.Int.Input(
|
||||
"height",
|
||||
default=2048,
|
||||
min=1024,
|
||||
max=4992,
|
||||
step=2,
|
||||
tooltip="Custom height for image. Value is working only if `size_preset` is set to `Custom`",
|
||||
),
|
||||
IO.Int.Input(
|
||||
"max_images",
|
||||
default=1,
|
||||
min=1,
|
||||
max=max_ref_images,
|
||||
step=1,
|
||||
display_mode=IO.NumberDisplay.number,
|
||||
tooltip="Maximum number of images to generate. With 1, exactly one image is produced. "
|
||||
"With >1, the model generates between 1 and max_images related images "
|
||||
"(e.g., story scenes, character variations). "
|
||||
"Total images (input + generated) cannot exceed 15.",
|
||||
),
|
||||
IO.Autogrow.Input(
|
||||
"images",
|
||||
template=IO.Autogrow.TemplateNames(
|
||||
IO.Image.Input("image"),
|
||||
names=[f"image_{i}" for i in range(1, max_ref_images + 1)],
|
||||
min=0,
|
||||
),
|
||||
tooltip=f"Optional reference image(s) for image-to-image or multi-reference generation. "
|
||||
f"Up to {max_ref_images} images.",
|
||||
),
|
||||
IO.Boolean.Input(
|
||||
"fail_on_partial",
|
||||
default=False,
|
||||
tooltip="If enabled, abort execution if any requested images are missing or return an error.",
|
||||
advanced=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class ByteDanceSeedreamNodeV2(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="ByteDanceSeedreamNodeV2",
|
||||
display_name="ByteDance Seedream 4.5 & 5.0",
|
||||
category="api node/image/ByteDance",
|
||||
description="Unified text-to-image generation and precise single-sentence editing at up to 4K resolution.",
|
||||
inputs=[
|
||||
IO.String.Input(
|
||||
"prompt",
|
||||
multiline=True,
|
||||
default="",
|
||||
tooltip="Text prompt for creating or editing an image.",
|
||||
),
|
||||
IO.DynamicCombo.Input(
|
||||
"model",
|
||||
options=[
|
||||
IO.DynamicCombo.Option(
|
||||
"seedream 5.0 lite",
|
||||
_seedream_model_inputs(max_ref_images=14, presets=RECOMMENDED_PRESETS_SEEDREAM_5_LITE),
|
||||
),
|
||||
IO.DynamicCombo.Option(
|
||||
"seedream-4-5-251128",
|
||||
_seedream_model_inputs(max_ref_images=10, presets=RECOMMENDED_PRESETS_SEEDREAM_4_5),
|
||||
),
|
||||
IO.DynamicCombo.Option(
|
||||
"seedream-4-0-250828",
|
||||
_seedream_model_inputs(max_ref_images=10, presets=RECOMMENDED_PRESETS_SEEDREAM_4_0),
|
||||
),
|
||||
],
|
||||
),
|
||||
IO.Int.Input(
|
||||
"seed",
|
||||
default=0,
|
||||
min=0,
|
||||
max=2147483647,
|
||||
step=1,
|
||||
display_mode=IO.NumberDisplay.number,
|
||||
control_after_generate=True,
|
||||
tooltip="Seed to use for generation.",
|
||||
),
|
||||
IO.Boolean.Input(
|
||||
"watermark",
|
||||
default=False,
|
||||
tooltip='Whether to add an "AI generated" watermark to the image.',
|
||||
advanced=True,
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
IO.Image.Output(),
|
||||
],
|
||||
hidden=[
|
||||
IO.Hidden.auth_token_comfy_org,
|
||||
IO.Hidden.api_key_comfy_org,
|
||||
IO.Hidden.unique_id,
|
||||
],
|
||||
is_api_node=True,
|
||||
price_badge=IO.PriceBadge(
|
||||
depends_on=IO.PriceBadgeDepends(widgets=["model"]),
|
||||
expr="""
|
||||
(
|
||||
$price := $contains(widgets.model, "5.0 lite") ? 0.035 :
|
||||
$contains(widgets.model, "4-5") ? 0.04 : 0.03;
|
||||
{
|
||||
"type":"usd",
|
||||
"usd": $price,
|
||||
"format": { "suffix":" x images/Run", "approximate": true }
|
||||
}
|
||||
)
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def execute(
|
||||
cls,
|
||||
prompt: str,
|
||||
model: dict,
|
||||
seed: int = 0,
|
||||
watermark: bool = False,
|
||||
) -> IO.NodeOutput:
|
||||
validate_string(prompt, strip_whitespace=True, min_length=1)
|
||||
model_id = SEEDREAM_MODELS[model["model"]]
|
||||
presets = SEEDREAM_PRESETS[model_id]
|
||||
|
||||
size_preset = model.get("size_preset", presets[0][0])
|
||||
width = model.get("width", 2048)
|
||||
height = model.get("height", 2048)
|
||||
max_images = model.get("max_images", 1)
|
||||
sequential_image_generation = "disabled" if max_images == 1 else "auto"
|
||||
images_dict = model.get("images") or {}
|
||||
fail_on_partial = model.get("fail_on_partial", False)
|
||||
|
||||
w = h = None
|
||||
for label, tw, th in presets:
|
||||
if label == size_preset:
|
||||
w, h = tw, th
|
||||
break
|
||||
if w is None or h is None:
|
||||
w, h = width, height
|
||||
|
||||
out_num_pixels = w * h
|
||||
mp_provided = out_num_pixels / 1_000_000.0
|
||||
if ("seedream-4-5" in model_id or "seedream-5-0" in model_id) and out_num_pixels < 3686400:
|
||||
raise ValueError(
|
||||
f"Minimum image resolution for the selected model is 3.68MP, but {mp_provided:.2f}MP provided."
|
||||
)
|
||||
if "seedream-4-0" in model_id and out_num_pixels < 921600:
|
||||
raise ValueError(
|
||||
f"Minimum image resolution that the selected model can generate is 0.92MP, "
|
||||
f"but {mp_provided:.2f}MP provided."
|
||||
)
|
||||
if out_num_pixels > 16_777_216:
|
||||
raise ValueError(
|
||||
f"Maximum image resolution for the selected model is 16.78MP, but {mp_provided:.2f}MP provided."
|
||||
)
|
||||
|
||||
image_tensors: list[Input.Image] = [t for t in images_dict.values() if t is not None]
|
||||
n_input_images = sum(get_number_of_images(t) for t in image_tensors)
|
||||
max_num_of_images = 14 if model_id == "seedream-5-0-260128" else 10
|
||||
if n_input_images > max_num_of_images:
|
||||
raise ValueError(
|
||||
f"Maximum of {max_num_of_images} reference images are supported, but {n_input_images} received."
|
||||
)
|
||||
if sequential_image_generation == "auto" and n_input_images + max_images > 15:
|
||||
raise ValueError(
|
||||
"The maximum number of generated images plus the number of reference images cannot exceed 15."
|
||||
)
|
||||
|
||||
reference_images_urls: list[str] = []
|
||||
if image_tensors:
|
||||
for tensor in image_tensors:
|
||||
validate_image_aspect_ratio(tensor, (1, 3), (3, 1))
|
||||
reference_images_urls = await upload_images_to_comfyapi(
|
||||
cls,
|
||||
image_tensors,
|
||||
max_images=n_input_images,
|
||||
mime_type="image/png",
|
||||
wait_label="Uploading reference images",
|
||||
)
|
||||
|
||||
response = await sync_op(
|
||||
cls,
|
||||
ApiEndpoint(path=BYTEPLUS_IMAGE_ENDPOINT, method="POST"),
|
||||
response_model=ImageTaskCreationResponse,
|
||||
data=Seedream4TaskCreationRequest(
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
image=reference_images_urls,
|
||||
size=f"{w}x{h}",
|
||||
seed=seed,
|
||||
sequential_image_generation=sequential_image_generation,
|
||||
sequential_image_generation_options=Seedream4Options(max_images=max_images),
|
||||
watermark=watermark,
|
||||
),
|
||||
)
|
||||
if len(response.data) == 1:
|
||||
return IO.NodeOutput(await download_url_to_image_tensor(get_image_url_from_response(response)))
|
||||
urls = [str(d["url"]) for d in response.data if isinstance(d, dict) and "url" in d]
|
||||
if fail_on_partial and len(urls) < len(response.data):
|
||||
raise RuntimeError(f"Only {len(urls)} of {len(response.data)} images were generated before error.")
|
||||
return IO.NodeOutput(torch.cat([await download_url_to_image_tensor(i) for i in urls]))
|
||||
|
||||
|
||||
class ByteDanceTextToVideoNode(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
@@ -1446,14 +1678,14 @@ class ByteDance2FirstLastFrameNode(IO.ComfyNode):
|
||||
"first_frame_asset_id",
|
||||
default="",
|
||||
tooltip="Seedance asset_id to use as the first frame. "
|
||||
"Mutually exclusive with the first_frame image input.",
|
||||
"Mutually exclusive with the first_frame image input.",
|
||||
optional=True,
|
||||
),
|
||||
IO.String.Input(
|
||||
"last_frame_asset_id",
|
||||
default="",
|
||||
tooltip="Seedance asset_id to use as the last frame. "
|
||||
"Mutually exclusive with the last_frame image input.",
|
||||
"Mutually exclusive with the last_frame image input.",
|
||||
optional=True,
|
||||
),
|
||||
IO.Int.Input(
|
||||
@@ -1635,11 +1867,20 @@ def _seedance2_reference_inputs(resolutions: list[str], default_ratio: str = "16
|
||||
IO.Boolean.Input(
|
||||
"auto_downscale",
|
||||
default=False,
|
||||
advanced=True,
|
||||
optional=True,
|
||||
tooltip="Automatically downscale reference videos that exceed the model's pixel budget "
|
||||
"for the selected resolution. Aspect ratio is preserved; videos already within limits are untouched.",
|
||||
),
|
||||
IO.Boolean.Input(
|
||||
"auto_upscale",
|
||||
default=False,
|
||||
advanced=True,
|
||||
optional=True,
|
||||
tooltip="Automatically upscale reference videos that are below the model's minimum pixel count "
|
||||
"for the selected resolution. Aspect ratio is preserved; videos already meeting the minimum are "
|
||||
"untouched. Note: upscaling a low-resolution source does not add real detail and may produce "
|
||||
"lower-quality generations.",
|
||||
),
|
||||
IO.Autogrow.Input(
|
||||
"reference_assets",
|
||||
template=IO.Autogrow.TemplateNames(
|
||||
@@ -1800,7 +2041,13 @@ class ByteDance2ReferenceNode(IO.ComfyNode):
|
||||
max_px = SEEDANCE2_REF_VIDEO_PIXEL_LIMITS.get(model_id, {}).get(model["resolution"], {}).get("max")
|
||||
if max_px:
|
||||
for key in reference_videos:
|
||||
reference_videos[key] = resize_video_to_pixel_budget(reference_videos[key], max_px)
|
||||
reference_videos[key] = downscale_video_to_max_pixels(reference_videos[key], max_px)
|
||||
|
||||
if model.get("auto_upscale") and reference_videos:
|
||||
min_px = SEEDANCE2_REF_VIDEO_PIXEL_LIMITS.get(model_id, {}).get(model["resolution"], {}).get("min")
|
||||
if min_px:
|
||||
for key in reference_videos:
|
||||
reference_videos[key] = upscale_video_to_min_pixels(reference_videos[key], min_px)
|
||||
|
||||
total_video_duration = 0.0
|
||||
for i, key in enumerate(reference_videos, 1):
|
||||
@@ -2105,6 +2352,7 @@ class ByteDanceExtension(ComfyExtension):
|
||||
return [
|
||||
ByteDanceImageNode,
|
||||
ByteDanceSeedreamNode,
|
||||
ByteDanceSeedreamNodeV2,
|
||||
ByteDanceTextToVideoNode,
|
||||
ByteDanceImageToVideoNode,
|
||||
ByteDanceFirstLastFrameNode,
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""API Nodes for ByteDance Seed LLM via the BytePlus ModelArk Responses API.
|
||||
|
||||
See: https://docs.byteplus.com/en/docs/ModelArk/1585128
|
||||
"""
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from comfy_api.latest import IO, ComfyExtension, Input
|
||||
from comfy_api_nodes.apis.bytedance_llm import (
|
||||
BytePlusInputImage,
|
||||
BytePlusInputMessage,
|
||||
BytePlusInputText,
|
||||
BytePlusInputVideo,
|
||||
BytePlusMessageContent,
|
||||
BytePlusResponseCreateRequest,
|
||||
BytePlusResponseObject,
|
||||
)
|
||||
from comfy_api_nodes.util import (
|
||||
ApiEndpoint,
|
||||
get_number_of_images,
|
||||
sync_op,
|
||||
upload_images_to_comfyapi,
|
||||
upload_video_to_comfyapi,
|
||||
validate_string,
|
||||
)
|
||||
|
||||
BYTEPLUS_RESPONSES_ENDPOINT = "/proxy/byteplus/api/v3/responses"
|
||||
SEED_MAX_IMAGES = 20
|
||||
SEED_MAX_VIDEOS = 4
|
||||
|
||||
SEED_MODELS: dict[str, str] = {
|
||||
"Seed 2.0 Pro": "seed-2-0-pro-260328",
|
||||
"Seed 2.0 Lite": "seed-2-0-lite-260228",
|
||||
"Seed 2.0 Mini": "seed-2-0-mini-260215",
|
||||
}
|
||||
|
||||
# USD per 1M tokens: (input, cache_hit_input, output)
|
||||
_SEED_PRICES_PER_MILLION: dict[str, tuple[float, float, float]] = {
|
||||
"seed-2-0-pro-260328": (0.50, 0.10, 3.00),
|
||||
"seed-2-0-lite-260228": (0.25, 0.05, 2.00),
|
||||
"seed-2-0-mini-260215": (0.10, 0.02, 0.40),
|
||||
}
|
||||
|
||||
|
||||
def _seed_model_inputs(max_images: int = SEED_MAX_IMAGES, max_videos: int = SEED_MAX_VIDEOS):
|
||||
return [
|
||||
IO.Autogrow.Input(
|
||||
"images",
|
||||
template=IO.Autogrow.TemplateNames(
|
||||
IO.Image.Input("image"),
|
||||
names=[f"image_{i}" for i in range(1, max_images + 1)],
|
||||
min=0,
|
||||
),
|
||||
tooltip=f"Optional image(s) to use as context for the model. Up to {max_images} images.",
|
||||
),
|
||||
IO.Autogrow.Input(
|
||||
"videos",
|
||||
template=IO.Autogrow.TemplateNames(
|
||||
IO.Video.Input("video"),
|
||||
names=[f"video_{i}" for i in range(1, max_videos + 1)],
|
||||
min=0,
|
||||
),
|
||||
tooltip=f"Optional video(s) to use as context for the model. Up to {max_videos} videos.",
|
||||
),
|
||||
IO.Float.Input(
|
||||
"temperature",
|
||||
default=1.0,
|
||||
min=0.0,
|
||||
max=2.0,
|
||||
step=0.01,
|
||||
tooltip="Controls randomness. 0.0 is deterministic, higher values are more random.",
|
||||
advanced=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _calculate_price(model_id: str, response: BytePlusResponseObject) -> float | None:
|
||||
"""Compute approximate USD price from response usage."""
|
||||
if not response.usage:
|
||||
return None
|
||||
rates = _SEED_PRICES_PER_MILLION.get(model_id)
|
||||
if rates is None:
|
||||
return None
|
||||
input_rate, cache_hit_rate, output_rate = rates
|
||||
input_tokens = response.usage.input_tokens or 0
|
||||
output_tokens = response.usage.output_tokens or 0
|
||||
cached = 0
|
||||
if response.usage.input_tokens_details:
|
||||
cached = response.usage.input_tokens_details.cached_tokens or 0
|
||||
fresh_input = max(0, input_tokens - cached)
|
||||
total = fresh_input * input_rate + cached * cache_hit_rate + output_tokens * output_rate
|
||||
return total / 1_000_000.0
|
||||
|
||||
|
||||
def _get_text_from_response(response: BytePlusResponseObject) -> str:
|
||||
"""Extract concatenated text from all assistant message output_text blocks."""
|
||||
if not response.output:
|
||||
return ""
|
||||
chunks: list[str] = []
|
||||
for item in response.output:
|
||||
if item.type != "message" or not item.content:
|
||||
continue
|
||||
for block in item.content:
|
||||
if block.type == "output_text" and block.text:
|
||||
chunks.append(block.text)
|
||||
elif block.type == "refusal" and block.refusal:
|
||||
raise ValueError(f"Model refused to respond: {block.refusal}")
|
||||
return "\n".join(chunks)
|
||||
|
||||
|
||||
async def _build_image_content_blocks(
|
||||
cls: type[IO.ComfyNode],
|
||||
image_tensors: list[Input.Image],
|
||||
) -> list[BytePlusInputImage]:
|
||||
urls = await upload_images_to_comfyapi(
|
||||
cls,
|
||||
image_tensors,
|
||||
max_images=SEED_MAX_IMAGES,
|
||||
wait_label="Uploading reference images",
|
||||
)
|
||||
return [BytePlusInputImage(image_url=url) for url in urls]
|
||||
|
||||
|
||||
async def _build_video_content_blocks(
|
||||
cls: type[IO.ComfyNode],
|
||||
videos: list[Input.Video],
|
||||
) -> list[BytePlusInputVideo]:
|
||||
blocks: list[BytePlusInputVideo] = []
|
||||
total = len(videos)
|
||||
for idx, video in enumerate(videos):
|
||||
label = "Uploading reference video"
|
||||
if total > 1:
|
||||
label = f"{label} ({idx + 1}/{total})"
|
||||
url = await upload_video_to_comfyapi(cls, video, wait_label=label)
|
||||
blocks.append(BytePlusInputVideo(video_url=url))
|
||||
return blocks
|
||||
|
||||
|
||||
class ByteDanceSeedNode(IO.ComfyNode):
|
||||
"""Generate text responses from a ByteDance Seed 2.0 model."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="ByteDanceSeedNode",
|
||||
display_name="ByteDance Seed",
|
||||
category="api node/text/ByteDance",
|
||||
essentials_category="Text Generation",
|
||||
description="Generate text responses with ByteDance's Seed 2.0 models. "
|
||||
"Provide a text prompt and optionally one or more images or videos for multimodal context.",
|
||||
inputs=[
|
||||
IO.String.Input(
|
||||
"prompt",
|
||||
multiline=True,
|
||||
default="",
|
||||
tooltip="Text input to the model.",
|
||||
),
|
||||
IO.DynamicCombo.Input(
|
||||
"model",
|
||||
options=[IO.DynamicCombo.Option(label, _seed_model_inputs()) for label in SEED_MODELS],
|
||||
tooltip="The Seed model used to generate the response.",
|
||||
),
|
||||
IO.Int.Input(
|
||||
"seed",
|
||||
default=0,
|
||||
min=0,
|
||||
max=2147483647,
|
||||
control_after_generate=True,
|
||||
tooltip="Seed controls whether the node should re-run; "
|
||||
"results are non-deterministic regardless of seed.",
|
||||
),
|
||||
IO.String.Input(
|
||||
"system_prompt",
|
||||
multiline=True,
|
||||
default="",
|
||||
optional=True,
|
||||
advanced=True,
|
||||
tooltip="Foundational instructions that dictate the model's behavior.",
|
||||
),
|
||||
],
|
||||
outputs=[IO.String.Output()],
|
||||
hidden=[
|
||||
IO.Hidden.auth_token_comfy_org,
|
||||
IO.Hidden.api_key_comfy_org,
|
||||
IO.Hidden.unique_id,
|
||||
],
|
||||
is_api_node=True,
|
||||
price_badge=IO.PriceBadge(
|
||||
depends_on=IO.PriceBadgeDepends(widgets=["model"]),
|
||||
expr="""
|
||||
(
|
||||
$m := widgets.model;
|
||||
$contains($m, "mini") ? {
|
||||
"type": "list_usd",
|
||||
"usd": [0.00025, 0.0009],
|
||||
"format": { "approximate": true, "separator": "-", "suffix": " per 1K tokens" }
|
||||
}
|
||||
: $contains($m, "lite") ? {
|
||||
"type": "list_usd",
|
||||
"usd": [0.0003, 0.002],
|
||||
"format": { "approximate": true, "separator": "-", "suffix": " per 1K tokens" }
|
||||
}
|
||||
: $contains($m, "pro") ? {
|
||||
"type": "list_usd",
|
||||
"usd": [0.0005, 0.003],
|
||||
"format": { "approximate": true, "separator": "-", "suffix": " per 1K tokens" }
|
||||
}
|
||||
: {"type":"text", "text":"Token-based"}
|
||||
)
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def execute(
|
||||
cls,
|
||||
prompt: str,
|
||||
model: dict,
|
||||
seed: int,
|
||||
system_prompt: str = "",
|
||||
) -> IO.NodeOutput:
|
||||
validate_string(prompt, strip_whitespace=True, min_length=1)
|
||||
model_label = model["model"]
|
||||
temperature = model["temperature"]
|
||||
model_id = SEED_MODELS[model_label]
|
||||
|
||||
image_tensors: list[Input.Image] = [t for t in (model.get("images") or {}).values() if t is not None]
|
||||
if sum(get_number_of_images(t) for t in image_tensors) > SEED_MAX_IMAGES:
|
||||
raise ValueError(f"Up to {SEED_MAX_IMAGES} images are supported per request.")
|
||||
|
||||
video_inputs: list[Input.Video] = [v for v in (model.get("videos") or {}).values() if v is not None]
|
||||
if len(video_inputs) > SEED_MAX_VIDEOS:
|
||||
raise ValueError(f"Up to {SEED_MAX_VIDEOS} videos are supported per request.")
|
||||
|
||||
content: list[BytePlusMessageContent] = []
|
||||
if image_tensors:
|
||||
content.extend(await _build_image_content_blocks(cls, image_tensors))
|
||||
if video_inputs:
|
||||
content.extend(await _build_video_content_blocks(cls, video_inputs))
|
||||
content.append(BytePlusInputText(text=prompt))
|
||||
|
||||
response = await sync_op(
|
||||
cls,
|
||||
ApiEndpoint(path=BYTEPLUS_RESPONSES_ENDPOINT, method="POST"),
|
||||
response_model=BytePlusResponseObject,
|
||||
data=BytePlusResponseCreateRequest(
|
||||
model=model_id,
|
||||
input=[BytePlusInputMessage(role="user", content=content)],
|
||||
instructions=system_prompt or None,
|
||||
temperature=temperature,
|
||||
store=False,
|
||||
stream=False,
|
||||
),
|
||||
price_extractor=lambda r: _calculate_price(model_id, r),
|
||||
)
|
||||
if response.error:
|
||||
raise ValueError(f"Seed API error ({response.error.code}): {response.error.message}")
|
||||
result = _get_text_from_response(response)
|
||||
if not result:
|
||||
raise ValueError("Empty response from Seed model.")
|
||||
return IO.NodeOutput(result)
|
||||
|
||||
|
||||
class ByteDanceLLMExtension(ComfyExtension):
|
||||
@override
|
||||
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
|
||||
return [ByteDanceSeedNode]
|
||||
|
||||
|
||||
async def comfy_entrypoint() -> ByteDanceLLMExtension:
|
||||
return ByteDanceLLMExtension()
|
||||
@@ -162,6 +162,61 @@ class GrokImageNode(IO.ComfyNode):
|
||||
)
|
||||
|
||||
|
||||
_GROK_IMAGE_EDIT_ASPECT_RATIO_OPTIONS = [
|
||||
"auto",
|
||||
"1:1",
|
||||
"2:3",
|
||||
"3:2",
|
||||
"3:4",
|
||||
"4:3",
|
||||
"9:16",
|
||||
"16:9",
|
||||
"9:19.5",
|
||||
"19.5:9",
|
||||
"9:20",
|
||||
"20:9",
|
||||
"1:2",
|
||||
"2:1",
|
||||
]
|
||||
|
||||
|
||||
def _grok_image_edit_model_inputs(*, max_ref_images: int, with_aspect_ratio: bool):
|
||||
inputs = [
|
||||
IO.Autogrow.Input(
|
||||
"images",
|
||||
template=IO.Autogrow.TemplateNames(
|
||||
IO.Image.Input("image"),
|
||||
names=[f"image_{i}" for i in range(1, max_ref_images + 1)],
|
||||
min=1,
|
||||
),
|
||||
tooltip=(
|
||||
"Reference image to edit."
|
||||
if max_ref_images == 1
|
||||
else f"Reference image(s) to edit. Up to {max_ref_images} images."
|
||||
),
|
||||
),
|
||||
IO.Combo.Input("resolution", options=["1K", "2K"]),
|
||||
IO.Int.Input(
|
||||
"number_of_images",
|
||||
default=1,
|
||||
min=1,
|
||||
max=10,
|
||||
step=1,
|
||||
tooltip="Number of edited images to generate",
|
||||
display_mode=IO.NumberDisplay.number,
|
||||
),
|
||||
]
|
||||
if with_aspect_ratio:
|
||||
inputs.append(
|
||||
IO.Combo.Input(
|
||||
"aspect_ratio",
|
||||
options=_GROK_IMAGE_EDIT_ASPECT_RATIO_OPTIONS,
|
||||
tooltip="Only allowed when multiple images are connected.",
|
||||
)
|
||||
)
|
||||
return inputs
|
||||
|
||||
|
||||
class GrokImageEditNode(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
@@ -256,6 +311,7 @@ class GrokImageEditNode(IO.ComfyNode):
|
||||
)
|
||||
""",
|
||||
),
|
||||
is_deprecated=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -303,6 +359,143 @@ class GrokImageEditNode(IO.ComfyNode):
|
||||
)
|
||||
|
||||
|
||||
class GrokImageEditNodeV2(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="GrokImageEditNodeV2",
|
||||
display_name="Grok Image Edit",
|
||||
category="api node/image/Grok",
|
||||
description="Modify an existing image based on a text prompt",
|
||||
inputs=[
|
||||
IO.String.Input(
|
||||
"prompt",
|
||||
multiline=True,
|
||||
default="",
|
||||
tooltip="The text prompt used to generate the image",
|
||||
),
|
||||
IO.DynamicCombo.Input(
|
||||
"model",
|
||||
options=[
|
||||
IO.DynamicCombo.Option(
|
||||
"grok-imagine-image-quality",
|
||||
_grok_image_edit_model_inputs(max_ref_images=3, with_aspect_ratio=True),
|
||||
),
|
||||
IO.DynamicCombo.Option(
|
||||
"grok-imagine-image-pro",
|
||||
_grok_image_edit_model_inputs(max_ref_images=1, with_aspect_ratio=False),
|
||||
),
|
||||
IO.DynamicCombo.Option(
|
||||
"grok-imagine-image",
|
||||
_grok_image_edit_model_inputs(max_ref_images=3, with_aspect_ratio=True),
|
||||
),
|
||||
],
|
||||
),
|
||||
IO.Int.Input(
|
||||
"seed",
|
||||
default=0,
|
||||
min=0,
|
||||
max=2147483647,
|
||||
step=1,
|
||||
display_mode=IO.NumberDisplay.number,
|
||||
control_after_generate=True,
|
||||
tooltip="Seed to determine if node should re-run; "
|
||||
"actual results are nondeterministic regardless of seed.",
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
IO.Image.Output(),
|
||||
],
|
||||
hidden=[
|
||||
IO.Hidden.auth_token_comfy_org,
|
||||
IO.Hidden.api_key_comfy_org,
|
||||
IO.Hidden.unique_id,
|
||||
],
|
||||
is_api_node=True,
|
||||
price_badge=IO.PriceBadge(
|
||||
depends_on=IO.PriceBadgeDepends(
|
||||
widgets=["model", "model.resolution", "model.number_of_images"],
|
||||
),
|
||||
expr="""
|
||||
(
|
||||
$isQualityModel := widgets.model = "grok-imagine-image-quality";
|
||||
$isPro := $contains(widgets.model, "pro");
|
||||
$res := $lookup(widgets, "model.resolution");
|
||||
$n := $lookup(widgets, "model.number_of_images");
|
||||
$rate := $isQualityModel
|
||||
? ($res = "1k" ? 0.05 : 0.07)
|
||||
: ($isPro ? 0.07 : 0.02);
|
||||
$base := $isQualityModel ? 0.01 : 0.002;
|
||||
$output := $rate * $n;
|
||||
$isPro
|
||||
? {"type":"usd","usd": $base + $output}
|
||||
: {"type":"range_usd","min_usd": $base + $output, "max_usd": 3 * $base + $output}
|
||||
)
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def execute(
|
||||
cls,
|
||||
prompt: str,
|
||||
model: dict,
|
||||
seed: int,
|
||||
) -> IO.NodeOutput:
|
||||
validate_string(prompt, strip_whitespace=True, min_length=1)
|
||||
model_id = model["model"]
|
||||
resolution = model["resolution"]
|
||||
number_of_images = model["number_of_images"]
|
||||
images_dict = model.get("images") or {}
|
||||
aspect_ratio = model.get("aspect_ratio", "auto")
|
||||
|
||||
image_tensors: list[Input.Image] = [t for t in images_dict.values() if t is not None]
|
||||
n_images = sum(get_number_of_images(t) for t in image_tensors)
|
||||
if n_images < 1:
|
||||
raise ValueError("At least one image is required for editing.")
|
||||
if model_id == "grok-imagine-image-pro" and n_images > 1:
|
||||
raise ValueError("The pro model supports only 1 input image.")
|
||||
if model_id != "grok-imagine-image-pro" and n_images > 3:
|
||||
raise ValueError("A maximum of 3 input images is supported.")
|
||||
if aspect_ratio != "auto" and n_images == 1:
|
||||
raise ValueError(
|
||||
"Custom aspect ratio is only allowed when multiple images are connected to the image input."
|
||||
)
|
||||
|
||||
flat_tensors: list[torch.Tensor] = []
|
||||
for tensor in image_tensors:
|
||||
if len(tensor.shape) == 4:
|
||||
flat_tensors.extend(tensor[i] for i in range(tensor.shape[0]))
|
||||
else:
|
||||
flat_tensors.append(tensor)
|
||||
|
||||
response = await sync_op(
|
||||
cls,
|
||||
ApiEndpoint(path="/proxy/xai/v1/images/edits", method="POST"),
|
||||
data=ImageEditRequest(
|
||||
model=model_id,
|
||||
images=[
|
||||
InputUrlObject(url=f"data:image/png;base64,{tensor_to_base64_string(i)}") for i in flat_tensors
|
||||
],
|
||||
prompt=prompt,
|
||||
resolution=resolution.lower(),
|
||||
n=number_of_images,
|
||||
seed=seed,
|
||||
aspect_ratio=None if aspect_ratio == "auto" else aspect_ratio,
|
||||
),
|
||||
response_model=ImageGenerationResponse,
|
||||
price_extractor=_extract_grok_price,
|
||||
)
|
||||
if len(response.data) == 1:
|
||||
return IO.NodeOutput(await download_url_to_image_tensor(response.data[0].url))
|
||||
return IO.NodeOutput(
|
||||
torch.cat(
|
||||
[await download_url_to_image_tensor(i) for i in [str(d.url) for d in response.data if d.url]],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class GrokVideoNode(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
@@ -737,6 +930,7 @@ class GrokExtension(ComfyExtension):
|
||||
return [
|
||||
GrokImageNode,
|
||||
GrokImageEditNode,
|
||||
GrokImageEditNodeV2,
|
||||
GrokVideoNode,
|
||||
GrokVideoReferenceNode,
|
||||
GrokVideoEditNode,
|
||||
|
||||
@@ -27,6 +27,7 @@ from comfy_api_nodes.util import (
|
||||
ApiEndpoint,
|
||||
download_url_to_bytesio,
|
||||
downscale_image_tensor,
|
||||
get_number_of_images,
|
||||
poll_op,
|
||||
sync_op,
|
||||
tensor_to_base64_string,
|
||||
@@ -372,6 +373,7 @@ class OpenAIGPTImage1(IO.ComfyNode):
|
||||
display_name="OpenAI GPT Image 2",
|
||||
category="api node/image/OpenAI",
|
||||
description="Generates images synchronously via OpenAI's GPT Image endpoint.",
|
||||
is_deprecated=True,
|
||||
inputs=[
|
||||
IO.String.Input(
|
||||
"prompt",
|
||||
@@ -640,6 +642,316 @@ class OpenAIGPTImage1(IO.ComfyNode):
|
||||
return IO.NodeOutput(await validate_and_cast_response(response))
|
||||
|
||||
|
||||
def _gpt_image_shared_inputs():
|
||||
"""Inputs shared by all GPT Image models (quality + reference images + mask)."""
|
||||
return [
|
||||
IO.Combo.Input(
|
||||
"quality",
|
||||
default="low",
|
||||
options=["low", "medium", "high"],
|
||||
tooltip="Image quality, affects cost and generation time.",
|
||||
),
|
||||
IO.Autogrow.Input(
|
||||
"images",
|
||||
template=IO.Autogrow.TemplateNames(
|
||||
IO.Image.Input("image"),
|
||||
names=[f"image_{i}" for i in range(1, 17)],
|
||||
min=0,
|
||||
),
|
||||
tooltip="Optional reference image(s) for image editing. Up to 16 images.",
|
||||
),
|
||||
IO.Mask.Input(
|
||||
"mask",
|
||||
optional=True,
|
||||
tooltip="Optional mask for inpainting (white areas will be replaced). "
|
||||
"Requires exactly one reference image.",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _gpt_image_legacy_model_inputs():
|
||||
"""Per-model widget set for legacy gpt-image-1 / gpt-image-1.5 (4 base sizes, transparent bg allowed)."""
|
||||
return [
|
||||
IO.Combo.Input(
|
||||
"size",
|
||||
default="auto",
|
||||
options=["auto", "1024x1024", "1024x1536", "1536x1024"],
|
||||
tooltip="Image size.",
|
||||
),
|
||||
IO.Combo.Input(
|
||||
"background",
|
||||
default="auto",
|
||||
options=["auto", "opaque", "transparent"],
|
||||
tooltip="Return image with or without background.",
|
||||
),
|
||||
*_gpt_image_shared_inputs(),
|
||||
]
|
||||
|
||||
|
||||
class OpenAIGPTImageNodeV2(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="OpenAIGPTImageNodeV2",
|
||||
display_name="OpenAI GPT Image 2",
|
||||
category="api node/image/OpenAI",
|
||||
description="Generates images via OpenAI's GPT Image endpoint.",
|
||||
inputs=[
|
||||
IO.String.Input(
|
||||
"prompt",
|
||||
default="",
|
||||
multiline=True,
|
||||
tooltip="Text prompt for GPT Image",
|
||||
),
|
||||
IO.DynamicCombo.Input(
|
||||
"model",
|
||||
options=[
|
||||
IO.DynamicCombo.Option(
|
||||
"gpt-image-2",
|
||||
[
|
||||
IO.Combo.Input(
|
||||
"size",
|
||||
default="auto",
|
||||
options=[
|
||||
"auto",
|
||||
"1024x1024",
|
||||
"1024x1536",
|
||||
"1536x1024",
|
||||
"2048x2048",
|
||||
"2048x1152",
|
||||
"1152x2048",
|
||||
"3840x2160",
|
||||
"2160x3840",
|
||||
"Custom",
|
||||
],
|
||||
tooltip="Image size. Select 'Custom' to use the custom width and height.",
|
||||
),
|
||||
IO.Int.Input(
|
||||
"custom_width",
|
||||
default=1024,
|
||||
min=1024,
|
||||
max=3840,
|
||||
step=16,
|
||||
tooltip="Used only when `size` is 'Custom'. Must be a multiple of 16.",
|
||||
),
|
||||
IO.Int.Input(
|
||||
"custom_height",
|
||||
default=1024,
|
||||
min=1024,
|
||||
max=3840,
|
||||
step=16,
|
||||
tooltip="Used only when `size` is 'Custom'. Must be a multiple of 16.",
|
||||
),
|
||||
IO.Combo.Input(
|
||||
"background",
|
||||
default="auto",
|
||||
options=["auto", "opaque"],
|
||||
tooltip="Return image with or without background.",
|
||||
),
|
||||
*_gpt_image_shared_inputs(),
|
||||
],
|
||||
),
|
||||
IO.DynamicCombo.Option("gpt-image-1.5", _gpt_image_legacy_model_inputs()),
|
||||
IO.DynamicCombo.Option("gpt-image-1", _gpt_image_legacy_model_inputs()),
|
||||
],
|
||||
),
|
||||
IO.Int.Input(
|
||||
"n",
|
||||
default=1,
|
||||
min=1,
|
||||
max=8,
|
||||
step=1,
|
||||
tooltip="How many images to generate",
|
||||
display_mode=IO.NumberDisplay.number,
|
||||
),
|
||||
IO.Int.Input(
|
||||
"seed",
|
||||
default=0,
|
||||
min=0,
|
||||
max=2147483647,
|
||||
step=1,
|
||||
display_mode=IO.NumberDisplay.number,
|
||||
control_after_generate=True,
|
||||
tooltip="not implemented yet in backend",
|
||||
),
|
||||
],
|
||||
outputs=[IO.Image.Output()],
|
||||
hidden=[
|
||||
IO.Hidden.auth_token_comfy_org,
|
||||
IO.Hidden.api_key_comfy_org,
|
||||
IO.Hidden.unique_id,
|
||||
],
|
||||
is_api_node=True,
|
||||
price_badge=IO.PriceBadge(
|
||||
depends_on=IO.PriceBadgeDepends(widgets=["model", "model.quality", "n"]),
|
||||
expr="""
|
||||
(
|
||||
$ranges := {
|
||||
"gpt-image-1": {
|
||||
"low": [0.011, 0.02],
|
||||
"medium": [0.042, 0.07],
|
||||
"high": [0.167, 0.25]
|
||||
},
|
||||
"gpt-image-1.5": {
|
||||
"low": [0.009, 0.02],
|
||||
"medium": [0.034, 0.062],
|
||||
"high": [0.133, 0.22]
|
||||
},
|
||||
"gpt-image-2": {
|
||||
"low": [0.0048, 0.019],
|
||||
"medium": [0.041, 0.168],
|
||||
"high": [0.165, 0.67]
|
||||
}
|
||||
};
|
||||
$range := $lookup($lookup($ranges, widgets.model), $lookup(widgets, "model.quality"));
|
||||
$nRaw := widgets.n;
|
||||
$n := ($nRaw != null and $nRaw != 0) ? $nRaw : 1;
|
||||
($n = 1)
|
||||
? {"type":"range_usd","min_usd": $range[0], "max_usd": $range[1], "format": {"approximate": true}}
|
||||
: {
|
||||
"type":"range_usd",
|
||||
"min_usd": $range[0] * $n,
|
||||
"max_usd": $range[1] * $n,
|
||||
"format": { "suffix": "/Run", "approximate": true }
|
||||
}
|
||||
)
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def execute(
|
||||
cls,
|
||||
prompt: str,
|
||||
model: dict,
|
||||
n: int,
|
||||
seed: int,
|
||||
) -> IO.NodeOutput:
|
||||
validate_string(prompt, strip_whitespace=False)
|
||||
|
||||
model_id = model["model"]
|
||||
size = model["size"]
|
||||
background = model["background"]
|
||||
quality = model["quality"]
|
||||
custom_width = model.get("custom_width", 1024)
|
||||
custom_height = model.get("custom_height", 1024)
|
||||
|
||||
images_dict = model.get("images") or {}
|
||||
image_tensors: list[Input.Image] = [t for t in images_dict.values() if t is not None]
|
||||
n_images = sum(get_number_of_images(t) for t in image_tensors)
|
||||
mask = model.get("mask")
|
||||
|
||||
if mask is not None and n_images == 0:
|
||||
raise ValueError("Cannot use a mask without an input image")
|
||||
|
||||
if size == "Custom":
|
||||
if custom_width % 16 != 0 or custom_height % 16 != 0:
|
||||
raise ValueError(
|
||||
f"Custom width and height must be multiples of 16, got {custom_width}x{custom_height}"
|
||||
)
|
||||
if max(custom_width, custom_height) > 3840:
|
||||
raise ValueError(
|
||||
f"Custom resolution max edge must be <= 3840, got {custom_width}x{custom_height}"
|
||||
)
|
||||
ratio = max(custom_width, custom_height) / min(custom_width, custom_height)
|
||||
if ratio > 3:
|
||||
raise ValueError(
|
||||
f"Custom resolution aspect ratio must not exceed 3:1, got {custom_width}x{custom_height}"
|
||||
)
|
||||
total_pixels = custom_width * custom_height
|
||||
if not 655_360 <= total_pixels <= 8_294_400:
|
||||
raise ValueError(
|
||||
f"Custom resolution total pixels must be between 655,360 and 8,294,400, got {total_pixels}"
|
||||
)
|
||||
size = f"{custom_width}x{custom_height}"
|
||||
|
||||
if model_id == "gpt-image-1":
|
||||
price_extractor = calculate_tokens_price_image_1
|
||||
elif model_id == "gpt-image-1.5":
|
||||
price_extractor = calculate_tokens_price_image_1_5
|
||||
elif model_id == "gpt-image-2":
|
||||
price_extractor = calculate_tokens_price_image_2_0
|
||||
else:
|
||||
raise ValueError(f"Unknown model: {model_id}")
|
||||
|
||||
if image_tensors:
|
||||
flat: list[torch.Tensor] = []
|
||||
for tensor in image_tensors:
|
||||
if len(tensor.shape) == 4:
|
||||
flat.extend(tensor[i : i + 1] for i in range(tensor.shape[0]))
|
||||
else:
|
||||
flat.append(tensor.unsqueeze(0))
|
||||
|
||||
files = []
|
||||
for i, single_image in enumerate(flat):
|
||||
scaled_image = downscale_image_tensor(single_image, total_pixels=2048 * 2048).squeeze()
|
||||
image_np = (scaled_image.numpy() * 255).astype(np.uint8)
|
||||
img = Image.fromarray(image_np)
|
||||
img_byte_arr = BytesIO()
|
||||
img.save(img_byte_arr, format="PNG")
|
||||
img_byte_arr.seek(0)
|
||||
|
||||
if len(flat) == 1:
|
||||
files.append(("image", (f"image_{i}.png", img_byte_arr, "image/png")))
|
||||
else:
|
||||
files.append(("image[]", (f"image_{i}.png", img_byte_arr, "image/png")))
|
||||
|
||||
if mask is not None:
|
||||
if len(flat) != 1:
|
||||
raise Exception("Cannot use a mask with multiple image")
|
||||
ref_image = flat[0]
|
||||
if mask.shape[1:] != ref_image.shape[1:-1]:
|
||||
raise Exception("Mask and Image must be the same size")
|
||||
_, height, width = mask.shape
|
||||
rgba_mask = torch.zeros(height, width, 4, device="cpu")
|
||||
rgba_mask[:, :, 3] = 1 - mask.squeeze().cpu()
|
||||
scaled_mask = downscale_image_tensor(
|
||||
rgba_mask.unsqueeze(0), total_pixels=2048 * 2048
|
||||
).squeeze()
|
||||
mask_np = (scaled_mask.numpy() * 255).astype(np.uint8)
|
||||
mask_img = Image.fromarray(mask_np)
|
||||
mask_img_byte_arr = BytesIO()
|
||||
mask_img.save(mask_img_byte_arr, format="PNG")
|
||||
mask_img_byte_arr.seek(0)
|
||||
files.append(("mask", ("mask.png", mask_img_byte_arr, "image/png")))
|
||||
|
||||
response = await sync_op(
|
||||
cls,
|
||||
ApiEndpoint(path="/proxy/openai/images/edits", method="POST"),
|
||||
response_model=OpenAIImageGenerationResponse,
|
||||
data=OpenAIImageEditRequest(
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
quality=quality,
|
||||
background=background,
|
||||
n=n,
|
||||
size=size,
|
||||
moderation="low",
|
||||
),
|
||||
content_type="multipart/form-data",
|
||||
files=files,
|
||||
price_extractor=price_extractor,
|
||||
)
|
||||
else:
|
||||
response = await sync_op(
|
||||
cls,
|
||||
ApiEndpoint(path="/proxy/openai/images/generations", method="POST"),
|
||||
response_model=OpenAIImageGenerationResponse,
|
||||
data=OpenAIImageGenerationRequest(
|
||||
model=model_id,
|
||||
prompt=prompt,
|
||||
quality=quality,
|
||||
background=background,
|
||||
n=n,
|
||||
size=size,
|
||||
moderation="low",
|
||||
),
|
||||
price_extractor=price_extractor,
|
||||
)
|
||||
return IO.NodeOutput(await validate_and_cast_response(response))
|
||||
|
||||
|
||||
class OpenAIChatNode(IO.ComfyNode):
|
||||
"""
|
||||
Node to generate text responses from an OpenAI model.
|
||||
@@ -999,6 +1311,7 @@ class OpenAIExtension(ComfyExtension):
|
||||
OpenAIDalle2,
|
||||
OpenAIDalle3,
|
||||
OpenAIGPTImage1,
|
||||
OpenAIGPTImageNodeV2,
|
||||
OpenAIChatNode,
|
||||
OpenAIInputFiles,
|
||||
OpenAIChatConfig,
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
"""API Nodes for OpenRouter LLM chat completions."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from comfy_api.latest import IO, ComfyExtension, Input
|
||||
from comfy_api_nodes.apis.openrouter import (
|
||||
OpenRouterChatRequest,
|
||||
OpenRouterChatResponse,
|
||||
OpenRouterContentBlock,
|
||||
OpenRouterImageContent,
|
||||
OpenRouterImageUrl,
|
||||
OpenRouterMessage,
|
||||
OpenRouterReasoningConfig,
|
||||
OpenRouterTextContent,
|
||||
OpenRouterVideoContent,
|
||||
OpenRouterVideoUrl,
|
||||
OpenRouterWebSearchOptions,
|
||||
)
|
||||
from comfy_api_nodes.util import (
|
||||
ApiEndpoint,
|
||||
get_number_of_images,
|
||||
sync_op,
|
||||
upload_images_to_comfyapi,
|
||||
upload_video_to_comfyapi,
|
||||
validate_string,
|
||||
)
|
||||
|
||||
OPENROUTER_CHAT_ENDPOINT = "/proxy/openrouter/api/v1/chat/completions"
|
||||
|
||||
|
||||
Profile = Literal["standard", "reasoning", "frontier_reasoning", "perplexity", "perplexity_reasoning"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ModelSpec:
|
||||
slug: str # exact OpenRouter model id
|
||||
profile: Profile
|
||||
price_in: float # USD per token (prompt)
|
||||
price_out: float # USD per token (completion)
|
||||
max_images: int = 0 # 0 = no image input; otherwise max URL-passed images supported
|
||||
max_videos: int = 0 # 0 = no video input; otherwise max URL-passed videos supported
|
||||
|
||||
|
||||
MODELS: list[_ModelSpec] = [
|
||||
_ModelSpec("anthropic/claude-opus-4.7", "frontier_reasoning", 0.000005, 0.000025, max_images=20),
|
||||
_ModelSpec("openai/gpt-5.5-pro", "frontier_reasoning", 0.00003, 0.00018, max_images=20),
|
||||
_ModelSpec("openai/gpt-5.5", "frontier_reasoning", 0.000005, 0.00003, max_images=20),
|
||||
_ModelSpec("google/gemini-3.5-flash", "reasoning", 0.0000015, 0.000009, max_images=20, max_videos=4),
|
||||
_ModelSpec("x-ai/grok-4.20", "reasoning", 0.00000125, 0.0000025, max_images=20),
|
||||
_ModelSpec("x-ai/grok-4.3", "reasoning", 0.00000125, 0.0000025, max_images=20),
|
||||
_ModelSpec("deepseek/deepseek-v4-pro", "reasoning", 0.000000435, 0.00000087),
|
||||
_ModelSpec("deepseek/deepseek-v4-flash", "reasoning", 0.000000112, 0.000000224),
|
||||
_ModelSpec("deepseek/deepseek-v3.2", "reasoning", 0.000000252, 0.000000378),
|
||||
_ModelSpec("qwen/qwen3.6-max-preview", "reasoning", 0.00000104, 0.00000624),
|
||||
_ModelSpec("qwen/qwen3.6-plus", "reasoning", 0.000000325, 0.00000195, max_images=10, max_videos=4),
|
||||
_ModelSpec("qwen/qwen3.6-flash", "reasoning", 0.0000001875, 0.000001125, max_images=10, max_videos=4),
|
||||
_ModelSpec("mistralai/mistral-large-2512", "standard", 0.0000005, 0.0000015, max_images=8),
|
||||
_ModelSpec("mistralai/mistral-medium-3-5", "reasoning", 0.0000015, 0.0000075, max_images=8),
|
||||
_ModelSpec("z-ai/glm-4.6", "reasoning", 0.00000043, 0.00000174),
|
||||
_ModelSpec("z-ai/glm-5", "reasoning", 0.0000006, 0.00000192),
|
||||
_ModelSpec("moonshotai/kimi-k2.6", "reasoning", 0.00000073, 0.00000349, max_images=10),
|
||||
_ModelSpec("moonshotai/kimi-k2-thinking", "reasoning", 0.0000006, 0.0000025),
|
||||
_ModelSpec("perplexity/sonar-pro", "perplexity", 0.000003, 0.000015),
|
||||
_ModelSpec("perplexity/sonar-reasoning-pro", "perplexity_reasoning", 0.000002, 0.000008),
|
||||
_ModelSpec("perplexity/sonar-deep-research", "perplexity_reasoning", 0.000002, 0.000008),
|
||||
]
|
||||
|
||||
_MODELS_BY_SLUG: dict[str, _ModelSpec] = {m.slug: m for m in MODELS}
|
||||
_REASONING_EFFORTS = ["off", "low", "medium", "high"]
|
||||
_SEARCH_CONTEXT_SIZES = ["low", "medium", "high"]
|
||||
|
||||
|
||||
def _reasoning_extra_inputs() -> list:
|
||||
return [
|
||||
IO.Combo.Input(
|
||||
"reasoning_effort",
|
||||
options=_REASONING_EFFORTS,
|
||||
default="off",
|
||||
tooltip="Reasoning effort. 'off' disables reasoning entirely.",
|
||||
advanced=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _perplexity_extra_inputs() -> list:
|
||||
return [
|
||||
IO.Combo.Input(
|
||||
"search_context_size",
|
||||
options=_SEARCH_CONTEXT_SIZES,
|
||||
default="medium",
|
||||
tooltip="How much web search context to retrieve. Larger = more grounded but slower/pricier.",
|
||||
advanced=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _profile_inputs(profile: Profile) -> list:
|
||||
if profile == "standard":
|
||||
return []
|
||||
if profile in ("reasoning", "frontier_reasoning"):
|
||||
return _reasoning_extra_inputs()
|
||||
if profile == "perplexity":
|
||||
return _perplexity_extra_inputs()
|
||||
if profile == "perplexity_reasoning":
|
||||
return _perplexity_extra_inputs() + _reasoning_extra_inputs()
|
||||
raise ValueError(f"Unknown profile: {profile}")
|
||||
|
||||
|
||||
def _media_inputs(spec: _ModelSpec) -> list:
|
||||
extras: list = []
|
||||
if spec.max_images > 0:
|
||||
extras.append(
|
||||
IO.Autogrow.Input(
|
||||
"images",
|
||||
template=IO.Autogrow.TemplateNames(
|
||||
IO.Image.Input("image"),
|
||||
names=[f"image_{i}" for i in range(1, spec.max_images + 1)],
|
||||
min=0,
|
||||
),
|
||||
tooltip=f"Optional reference image(s) — up to {spec.max_images}. Sent as URLs.",
|
||||
)
|
||||
)
|
||||
if spec.max_videos > 0:
|
||||
extras.append(
|
||||
IO.Autogrow.Input(
|
||||
"videos",
|
||||
template=IO.Autogrow.TemplateNames(
|
||||
IO.Video.Input("video"),
|
||||
names=[f"video_{i}" for i in range(1, spec.max_videos + 1)],
|
||||
min=0,
|
||||
),
|
||||
tooltip=f"Optional reference video(s) — up to {spec.max_videos}. Sent as URLs.",
|
||||
)
|
||||
)
|
||||
return extras
|
||||
|
||||
|
||||
def _inputs_for_model(spec: _ModelSpec) -> list:
|
||||
return _profile_inputs(spec.profile) + _media_inputs(spec)
|
||||
|
||||
|
||||
def _build_model_options() -> list[IO.DynamicCombo.Option]:
|
||||
return [IO.DynamicCombo.Option(spec.slug, _inputs_for_model(spec)) for spec in MODELS]
|
||||
|
||||
|
||||
def _calculate_price(response: OpenRouterChatResponse) -> float | None:
|
||||
if response.usage and response.usage.cost is not None:
|
||||
return float(response.usage.cost)
|
||||
return None
|
||||
|
||||
|
||||
def _price_badge_jsonata() -> str:
|
||||
rates_pairs = []
|
||||
for spec in MODELS:
|
||||
prompt_per_1k = spec.price_in * 1000
|
||||
completion_per_1k = spec.price_out * 1000
|
||||
rates_pairs.append(f' "{spec.slug}": [{prompt_per_1k:.8g}, {completion_per_1k:.8g}]')
|
||||
rates_block = ",\n".join(rates_pairs)
|
||||
return (
|
||||
"(\n"
|
||||
" $rates := {\n"
|
||||
f"{rates_block}\n"
|
||||
" };\n"
|
||||
" $r := $lookup($rates, widgets.model);\n"
|
||||
" $r ? {\n"
|
||||
' "type": "list_usd",\n'
|
||||
' "usd": $r,\n'
|
||||
' "format": { "approximate": true, "separator": "-", "suffix": " per 1K tokens" }\n'
|
||||
' } : {"type": "text", "text": "Token-based"}\n'
|
||||
")"
|
||||
)
|
||||
|
||||
|
||||
async def _build_image_blocks(
|
||||
cls: type[IO.ComfyNode], spec: _ModelSpec, images: list[Input.Image]
|
||||
) -> list[OpenRouterImageContent]:
|
||||
urls = await upload_images_to_comfyapi(
|
||||
cls,
|
||||
images,
|
||||
max_images=spec.max_images,
|
||||
total_pixels=2048 * 2048,
|
||||
mime_type="image/png",
|
||||
wait_label="Uploading reference images",
|
||||
)
|
||||
return [OpenRouterImageContent(image_url=OpenRouterImageUrl(url=url)) for url in urls]
|
||||
|
||||
|
||||
async def _build_video_blocks(cls: type[IO.ComfyNode], videos: list[Input.Video]) -> list[OpenRouterVideoContent]:
|
||||
blocks: list[OpenRouterVideoContent] = []
|
||||
total = len(videos)
|
||||
for idx, video in enumerate(videos):
|
||||
label = "Uploading reference video"
|
||||
if total > 1:
|
||||
label = f"{label} ({idx + 1}/{total})"
|
||||
url = await upload_video_to_comfyapi(cls, video, wait_label=label)
|
||||
blocks.append(OpenRouterVideoContent(video_url=OpenRouterVideoUrl(url=url)))
|
||||
return blocks
|
||||
|
||||
|
||||
def _user_message(prompt: str, media_blocks: list[OpenRouterContentBlock]) -> OpenRouterMessage:
|
||||
if not media_blocks:
|
||||
return OpenRouterMessage(role="user", content=prompt)
|
||||
blocks: list[OpenRouterContentBlock] = list(media_blocks)
|
||||
blocks.append(OpenRouterTextContent(text=prompt))
|
||||
return OpenRouterMessage(role="user", content=blocks)
|
||||
|
||||
|
||||
def _build_messages(
|
||||
system_prompt: str, prompt: str, media_blocks: list[OpenRouterContentBlock]
|
||||
) -> list[OpenRouterMessage]:
|
||||
messages: list[OpenRouterMessage] = []
|
||||
if system_prompt:
|
||||
messages.append(OpenRouterMessage(role="system", content=system_prompt))
|
||||
messages.append(_user_message(prompt, media_blocks))
|
||||
return messages
|
||||
|
||||
|
||||
def _build_request(
|
||||
slug: str,
|
||||
system_prompt: str,
|
||||
prompt: str,
|
||||
media_blocks: list[OpenRouterContentBlock],
|
||||
*,
|
||||
seed: int,
|
||||
reasoning_effort: str | None,
|
||||
search_context_size: str | None,
|
||||
) -> OpenRouterChatRequest:
|
||||
reasoning_cfg: OpenRouterReasoningConfig | None = None
|
||||
if reasoning_effort and reasoning_effort != "off":
|
||||
# exclude=True asks providers to reason internally but not return the trace
|
||||
reasoning_cfg = OpenRouterReasoningConfig(effort=reasoning_effort, exclude=True)
|
||||
web_search_cfg: OpenRouterWebSearchOptions | None = None
|
||||
if search_context_size:
|
||||
web_search_cfg = OpenRouterWebSearchOptions(search_context_size=search_context_size)
|
||||
return OpenRouterChatRequest(
|
||||
model=slug,
|
||||
messages=_build_messages(system_prompt, prompt, media_blocks),
|
||||
seed=seed if seed > 0 else None,
|
||||
reasoning=reasoning_cfg,
|
||||
web_search_options=web_search_cfg,
|
||||
)
|
||||
|
||||
|
||||
def _extract_text(response: OpenRouterChatResponse) -> str:
|
||||
if response.error:
|
||||
code = response.error.code if response.error.code is not None else "unknown"
|
||||
raise ValueError(f"OpenRouter error ({code}): {response.error.message or 'no message'}")
|
||||
if not response.choices:
|
||||
raise ValueError("Empty response from OpenRouter (no choices).")
|
||||
message = response.choices[0].message
|
||||
if not message:
|
||||
raise ValueError("Empty response from OpenRouter (no message).")
|
||||
if message.refusal:
|
||||
raise ValueError(f"Model refused to respond: {message.refusal}")
|
||||
return message.content or ""
|
||||
|
||||
|
||||
class OpenRouterLLMNode(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="OpenRouterLLMNode",
|
||||
display_name="OpenRouter LLM",
|
||||
category="api node/text/OpenRouter",
|
||||
essentials_category="Text Generation",
|
||||
description=(
|
||||
"Generate text responses through OpenRouter. Routes to a curated set of popular "
|
||||
"models from xAI, DeepSeek, Qwen, Mistral, Z.AI (GLM), Moonshot (Kimi), and "
|
||||
"Perplexity Sonar."
|
||||
),
|
||||
inputs=[
|
||||
IO.String.Input(
|
||||
"prompt",
|
||||
multiline=True,
|
||||
default="",
|
||||
tooltip="Text input to the model.",
|
||||
),
|
||||
IO.DynamicCombo.Input(
|
||||
"model",
|
||||
options=_build_model_options(),
|
||||
tooltip="The OpenRouter model used to generate the response.",
|
||||
),
|
||||
IO.Int.Input(
|
||||
"seed",
|
||||
default=0,
|
||||
min=0,
|
||||
max=2147483647,
|
||||
control_after_generate=True,
|
||||
tooltip="Seed for sampling. Set to 0 to omit. Most models treat this as a hint only.",
|
||||
),
|
||||
IO.String.Input(
|
||||
"system_prompt",
|
||||
multiline=True,
|
||||
default="",
|
||||
optional=True,
|
||||
advanced=True,
|
||||
tooltip="Foundational instructions that dictate the model's behavior.",
|
||||
),
|
||||
],
|
||||
outputs=[IO.String.Output()],
|
||||
hidden=[
|
||||
IO.Hidden.auth_token_comfy_org,
|
||||
IO.Hidden.api_key_comfy_org,
|
||||
IO.Hidden.unique_id,
|
||||
],
|
||||
is_api_node=True,
|
||||
price_badge=IO.PriceBadge(
|
||||
depends_on=IO.PriceBadgeDepends(widgets=["model"]),
|
||||
expr=_price_badge_jsonata(),
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def execute(
|
||||
cls,
|
||||
prompt: str,
|
||||
model: dict,
|
||||
seed: int,
|
||||
system_prompt: str = "",
|
||||
) -> IO.NodeOutput:
|
||||
validate_string(prompt, strip_whitespace=True, min_length=1)
|
||||
slug: str = model["model"]
|
||||
spec = _MODELS_BY_SLUG.get(slug)
|
||||
if spec is None:
|
||||
raise ValueError(f"Unknown OpenRouter model: {slug}")
|
||||
|
||||
reasoning_effort: str | None = model.get("reasoning_effort")
|
||||
search_context_size: str | None = model.get("search_context_size")
|
||||
|
||||
image_tensors: list[Input.Image] = [t for t in (model.get("images") or {}).values() if t is not None]
|
||||
if image_tensors and sum(get_number_of_images(t) for t in image_tensors) > spec.max_images:
|
||||
raise ValueError(f"Up to {spec.max_images} images are supported for {slug}.")
|
||||
video_inputs: list[Input.Video] = [v for v in (model.get("videos") or {}).values() if v is not None]
|
||||
if video_inputs and len(video_inputs) > spec.max_videos:
|
||||
raise ValueError(f"Up to {spec.max_videos} videos are supported for {slug}.")
|
||||
|
||||
media_blocks: list[OpenRouterContentBlock] = []
|
||||
if image_tensors:
|
||||
media_blocks.extend(await _build_image_blocks(cls, spec, image_tensors))
|
||||
if video_inputs:
|
||||
media_blocks.extend(await _build_video_blocks(cls, video_inputs))
|
||||
|
||||
request = _build_request(
|
||||
slug,
|
||||
system_prompt,
|
||||
prompt,
|
||||
media_blocks,
|
||||
seed=seed,
|
||||
reasoning_effort=reasoning_effort,
|
||||
search_context_size=search_context_size,
|
||||
)
|
||||
|
||||
response = await sync_op(
|
||||
cls,
|
||||
ApiEndpoint(path=OPENROUTER_CHAT_ENDPOINT, method="POST"),
|
||||
response_model=OpenRouterChatResponse,
|
||||
data=request,
|
||||
price_extractor=_calculate_price,
|
||||
)
|
||||
return IO.NodeOutput(_extract_text(response))
|
||||
|
||||
|
||||
class OpenRouterExtension(ComfyExtension):
|
||||
@override
|
||||
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
|
||||
return [OpenRouterLLMNode]
|
||||
|
||||
|
||||
async def comfy_entrypoint() -> OpenRouterExtension:
|
||||
return OpenRouterExtension()
|
||||
@@ -143,7 +143,7 @@ class QuiverTextToSVGNode(IO.ComfyNode):
|
||||
if reference_images:
|
||||
references = []
|
||||
for key in reference_images:
|
||||
url = await upload_image_to_comfyapi(cls, reference_images[key])
|
||||
url = await upload_image_to_comfyapi(cls, reference_images[key], mime_type="image/png")
|
||||
references.append(QuiverImageObject(url=url))
|
||||
if len(references) > 4:
|
||||
raise ValueError("Maximum 4 reference images are allowed.")
|
||||
@@ -252,7 +252,7 @@ class QuiverImageToSVGNode(IO.ComfyNode):
|
||||
model: dict,
|
||||
seed: int,
|
||||
) -> IO.NodeOutput:
|
||||
image_url = await upload_image_to_comfyapi(cls, image)
|
||||
image_url = await upload_image_to_comfyapi(cls, image, mime_type="image/png")
|
||||
|
||||
response = await sync_op(
|
||||
cls,
|
||||
|
||||
@@ -16,16 +16,17 @@ from .conversions import (
|
||||
convert_mask_to_image,
|
||||
downscale_image_tensor,
|
||||
downscale_image_tensor_by_max_side,
|
||||
downscale_video_to_max_pixels,
|
||||
image_tensor_pair_to_batch,
|
||||
pil_to_bytesio,
|
||||
resize_mask_to_image,
|
||||
resize_video_to_pixel_budget,
|
||||
tensor_to_base64_string,
|
||||
tensor_to_bytesio,
|
||||
tensor_to_pil,
|
||||
text_filepath_to_base64_string,
|
||||
text_filepath_to_data_uri,
|
||||
trim_video,
|
||||
upscale_video_to_min_pixels,
|
||||
video_to_base64_string,
|
||||
)
|
||||
from .download_helpers import (
|
||||
@@ -88,16 +89,17 @@ __all__ = [
|
||||
"convert_mask_to_image",
|
||||
"downscale_image_tensor",
|
||||
"downscale_image_tensor_by_max_side",
|
||||
"downscale_video_to_max_pixels",
|
||||
"image_tensor_pair_to_batch",
|
||||
"pil_to_bytesio",
|
||||
"resize_mask_to_image",
|
||||
"resize_video_to_pixel_budget",
|
||||
"tensor_to_base64_string",
|
||||
"tensor_to_bytesio",
|
||||
"tensor_to_pil",
|
||||
"text_filepath_to_base64_string",
|
||||
"text_filepath_to_data_uri",
|
||||
"trim_video",
|
||||
"upscale_video_to_min_pixels",
|
||||
"video_to_base64_string",
|
||||
# Validation utilities
|
||||
"get_image_dimensions",
|
||||
|
||||
@@ -415,14 +415,48 @@ def trim_video(video: Input.Video, duration_sec: float) -> Input.Video:
|
||||
raise RuntimeError(f"Failed to trim video: {str(e)}") from e
|
||||
|
||||
|
||||
def resize_video_to_pixel_budget(video: Input.Video, total_pixels: int) -> Input.Video:
|
||||
"""Downscale a video to fit within ``total_pixels`` (w * h), preserving aspect ratio.
|
||||
def downscale_video_to_max_pixels(video: Input.Video, max_pixels: int) -> Input.Video:
|
||||
"""Downscale a video to fit within ``max_pixels`` (w * h), preserving aspect ratio.
|
||||
|
||||
Returns the original video object untouched when it already fits. Preserves frame rate, duration, and audio.
|
||||
Aspect ratio is preserved up to a fraction of a percent (even-dim rounding).
|
||||
"""
|
||||
src_w, src_h = video.get_dimensions()
|
||||
scale_dims = _compute_downscale_dims(src_w, src_h, total_pixels)
|
||||
scale_dims = _compute_downscale_dims(src_w, src_h, max_pixels)
|
||||
if scale_dims is None:
|
||||
return video
|
||||
return _apply_video_scale(video, scale_dims)
|
||||
|
||||
|
||||
def _compute_upscale_dims(src_w: int, src_h: int, total_pixels: int) -> tuple[int, int] | None:
|
||||
"""Return upscaled (w, h) with even dims meeting at least ``total_pixels``, or None if already large enough.
|
||||
|
||||
Source aspect ratio is preserved; output may drift by a fraction of a percent because both dimensions
|
||||
are rounded up to even values (many codecs require divisible-by-2). The result is guaranteed to be at
|
||||
least ``total_pixels``.
|
||||
"""
|
||||
pixels = src_w * src_h
|
||||
if pixels >= total_pixels:
|
||||
return None
|
||||
scale = math.sqrt(total_pixels / pixels)
|
||||
new_w = math.ceil(src_w * scale)
|
||||
new_h = math.ceil(src_h * scale)
|
||||
if new_w % 2:
|
||||
new_w += 1
|
||||
if new_h % 2:
|
||||
new_h += 1
|
||||
return new_w, new_h
|
||||
|
||||
|
||||
def upscale_video_to_min_pixels(video: Input.Video, min_pixels: int) -> Input.Video:
|
||||
"""Upscale a video to meet at least ``min_pixels`` (w * h), preserving aspect ratio.
|
||||
|
||||
Returns the original video object untouched when it already meets the minimum. Preserves frame rate,
|
||||
duration, and audio. Aspect ratio is preserved up to a fraction of a percent (even-dim rounding).
|
||||
Note: upscaling a low-resolution source does not add real detail; downstream model quality may suffer.
|
||||
"""
|
||||
src_w, src_h = video.get_dimensions()
|
||||
scale_dims = _compute_upscale_dims(src_w, src_h, min_pixels)
|
||||
if scale_dims is None:
|
||||
return video
|
||||
return _apply_video_scale(video, scale_dims)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Pure-numpy port of MediaPipe's face_geometry (FACE_LANDMARK_PIPELINE mode)
|
||||
+ weighted Procrustes solver. Computes the 4x4 facial transformation matrix.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import numpy as np
|
||||
|
||||
|
||||
def _solve_weighted_orthogonal_problem(src: np.ndarray, tgt: np.ndarray, weights: np.ndarray) -> np.ndarray:
|
||||
"""Weighted orthogonal Procrustes (similarity). Returns 4x4 M with
|
||||
`target ≈ M @ homogeneous(source)` in the weighted LS sense. fp64 for
|
||||
SVD stability. Port of procrustes_solver.cc."""
|
||||
sqrt_w = np.sqrt(weights.astype(np.float64))
|
||||
w_total = float((sqrt_w ** 2).sum())
|
||||
ws = src.astype(np.float64) * sqrt_w
|
||||
wt = tgt.astype(np.float64) * sqrt_w
|
||||
|
||||
c_w = (ws @ sqrt_w) / w_total
|
||||
centered = ws - np.outer(c_w, sqrt_w)
|
||||
U, _S, Vt = np.linalg.svd(wt @ centered.T, full_matrices=True)
|
||||
# Disallow reflection: flip the least-significant axis when det(U)·det(V)<0.
|
||||
post, pre = U.copy(), Vt.T.copy()
|
||||
if np.linalg.det(post) * np.linalg.det(pre) < 0:
|
||||
post[:, 2] *= -1.0
|
||||
R = post @ pre.T
|
||||
|
||||
denom = float((centered * ws).sum())
|
||||
if denom < 1e-12:
|
||||
raise ValueError("Procrustes denominator collapsed (degenerate source).")
|
||||
scale = float((R @ centered * wt).sum()) / denom
|
||||
translation = ((wt - scale * (R @ ws)) @ sqrt_w) / w_total
|
||||
|
||||
M = np.eye(4, dtype=np.float64)
|
||||
M[:3, :3] = scale * R
|
||||
M[:3, 3] = translation
|
||||
return M
|
||||
|
||||
|
||||
def _estimate_scale(canonical: np.ndarray, runtime: np.ndarray, weights: np.ndarray) -> float:
|
||||
"""scale = ‖first column of M[:3]‖ per geometry_pipeline.cc::EstimateScale."""
|
||||
return float(np.linalg.norm(_solve_weighted_orthogonal_problem(canonical, runtime, weights)[:3, 0]))
|
||||
|
||||
|
||||
def solve_facial_transformation_matrix(
|
||||
landmarks_normalized: np.ndarray,
|
||||
canonical_vertices: np.ndarray,
|
||||
procrustes_indices: np.ndarray,
|
||||
procrustes_weights: np.ndarray,
|
||||
image_width: int,
|
||||
image_height: int,
|
||||
# face_geometry_calculator_options.pbtxt defaults
|
||||
vertical_fov_degrees: float = 63.0,
|
||||
near: float = 1.0,
|
||||
) -> np.ndarray:
|
||||
"""4x4 facial transformation matrix via two-pass scale recovery
|
||||
`landmarks_normalized` is (N, 3) in MediaPipe normalized convention: x, y
|
||||
in [0,1] with TOP-LEFT origin, z in width-scaled units.
|
||||
"""
|
||||
|
||||
h_near = 2.0 * near * math.tan(0.5 * math.radians(vertical_fov_degrees))
|
||||
w_near = image_width * h_near / image_height
|
||||
|
||||
sub = procrustes_indices.astype(np.int64)
|
||||
screen = landmarks_normalized[sub].T.astype(np.float64).copy()
|
||||
canon = canonical_vertices[sub].T.astype(np.float64).copy()
|
||||
weights = procrustes_weights.astype(np.float64)
|
||||
|
||||
# ProjectXY (TOP_LEFT y-flip, then scale all 3 axes; z uses x-scale).
|
||||
screen[1] = 1.0 - screen[1]
|
||||
screen[0] = screen[0] * w_near - 0.5 * w_near
|
||||
screen[1] = screen[1] * h_near - 0.5 * h_near
|
||||
screen[2] = screen[2] * w_near
|
||||
depth_offset = float(screen[2].mean())
|
||||
|
||||
def _unproject(s: np.ndarray, scale: float) -> np.ndarray:
|
||||
s = s.copy()
|
||||
s[2] = (s[2] - depth_offset + near) / scale
|
||||
s[0] *= s[2] / near
|
||||
s[1] *= s[2] / near
|
||||
s[2] *= -1.0
|
||||
return s
|
||||
|
||||
first = screen.copy()
|
||||
first[2] *= -1.0
|
||||
s1 = _estimate_scale(canon, first, weights) # 1st pass: Procrustes on projected XY
|
||||
s2 = _estimate_scale(canon, _unproject(screen, s1), weights) # 2nd pass: rescale z by s1, un-project XY
|
||||
return _solve_weighted_orthogonal_problem(canon, _unproject(screen, s1 * s2), weights).astype(np.float32)
|
||||
|
||||
|
||||
def transformation_matrix_from_detection(face_dict: dict, image_width: int, image_height: int, canonical_data: dict) -> np.ndarray:
|
||||
"""Adapt a FaceLandmarker face dict to MP's normalized convention and solve.
|
||||
FaceMesh emits (x, y, z) in 192-canonical units; MP's geometry expects
|
||||
z_norm = z_canonical * scale_x / image_width"""
|
||||
|
||||
lmks_xy, lmks_3d = face_dict["landmarks_xy"], face_dict["landmarks_3d"]
|
||||
aug = np.concatenate([lmks_3d[:, :2].astype(np.float64), np.ones((lmks_xy.shape[0], 1))], axis=1)
|
||||
M, *_ = np.linalg.lstsq(aug, lmks_xy.astype(np.float64), rcond=None)
|
||||
scale_x = float(np.linalg.norm(M[0]))
|
||||
z_scale = scale_x / image_width if scale_x > 1e-6 else 1.0 / image_width
|
||||
|
||||
normalized = np.empty((lmks_xy.shape[0], 3), dtype=np.float32)
|
||||
normalized[:, 0] = lmks_xy[:, 0] / image_width
|
||||
normalized[:, 1] = lmks_xy[:, 1] / image_height
|
||||
normalized[:, 2] = lmks_3d[:, 2] * z_scale
|
||||
return solve_facial_transformation_matrix(
|
||||
normalized, canonical_data["canonical_vertices"],
|
||||
canonical_data["procrustes_indices"], canonical_data["procrustes_weights"],
|
||||
image_width=image_width, image_height=image_height,
|
||||
)
|
||||
@@ -0,0 +1,682 @@
|
||||
"""Pure-PyTorch port of MediaPipe's face_landmarker_v2_with_blendshapes.task:
|
||||
BlazeFace detector → FaceMesh v2 → ARKit-52 blendshapes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from functools import lru_cache
|
||||
from typing import List, Tuple
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from scipy.special import expit
|
||||
from torch import Tensor, nn
|
||||
|
||||
|
||||
# Values below must stay verbatim with the published face_landmarker_v2 graph
|
||||
|
||||
# face_blendshapes_graph.cc::kLandmarksSubsetIdxs
|
||||
_BS_INPUT_INDICES: Tuple[int, ...] = (
|
||||
0, 1, 4, 5, 6, 7, 8, 10, 13, 14, 17, 21, 33, 37, 39, 40, 46, 52, 53, 54,
|
||||
55, 58, 61, 63, 65, 66, 67, 70, 78, 80, 81, 82, 84, 87, 88, 91, 93, 95,
|
||||
103, 105, 107, 109, 127, 132, 133, 136, 144, 145, 146, 148, 149, 150, 152,
|
||||
153, 154, 155, 157, 158, 159, 160, 161, 162, 163, 168, 172, 173, 176, 178,
|
||||
181, 185, 191, 195, 197, 234, 246, 249, 251, 263, 267, 269, 270, 276, 282,
|
||||
283, 284, 285, 288, 291, 293, 295, 296, 297, 300, 308, 310, 311, 312, 314,
|
||||
317, 318, 321, 323, 324, 332, 334, 336, 338, 356, 361, 362, 365, 373, 374,
|
||||
375, 377, 378, 379, 380, 381, 382, 384, 385, 386, 387, 388, 389, 390, 397,
|
||||
398, 400, 402, 405, 409, 415, 454, 466, 468, 469, 470, 471, 472, 473, 474,
|
||||
475, 476, 477,
|
||||
)
|
||||
|
||||
# face_blendshapes_graph.cc::kCategoryNames
|
||||
BLENDSHAPE_NAMES: Tuple[str, ...] = (
|
||||
"_neutral", "browDownLeft", "browDownRight", "browInnerUp", "browOuterUpLeft",
|
||||
"browOuterUpRight", "cheekPuff", "cheekSquintLeft", "cheekSquintRight",
|
||||
"eyeBlinkLeft", "eyeBlinkRight", "eyeLookDownLeft", "eyeLookDownRight",
|
||||
"eyeLookInLeft", "eyeLookInRight", "eyeLookOutLeft", "eyeLookOutRight",
|
||||
"eyeLookUpLeft", "eyeLookUpRight", "eyeSquintLeft", "eyeSquintRight",
|
||||
"eyeWideLeft", "eyeWideRight", "jawForward", "jawLeft", "jawOpen",
|
||||
"jawRight", "mouthClose", "mouthDimpleLeft", "mouthDimpleRight",
|
||||
"mouthFrownLeft", "mouthFrownRight", "mouthFunnel", "mouthLeft",
|
||||
"mouthLowerDownLeft", "mouthLowerDownRight", "mouthPressLeft",
|
||||
"mouthPressRight", "mouthPucker", "mouthRight", "mouthRollLower",
|
||||
"mouthRollUpper", "mouthShrugLower", "mouthShrugUpper", "mouthSmileLeft",
|
||||
"mouthSmileRight", "mouthStretchLeft", "mouthStretchRight",
|
||||
"mouthUpperUpLeft", "mouthUpperUpRight", "noseSneerLeft", "noseSneerRight",
|
||||
)
|
||||
|
||||
# face_detection.pbtxt — short-range BlazeFace.
|
||||
_BF_NUM_LAYERS = 4
|
||||
_BF_INPUT_SIZE = 128
|
||||
_BF_STRIDES = (8, 16, 16, 16)
|
||||
_BF_ANCHOR_OFFSET_X = 0.5
|
||||
_BF_ANCHOR_OFFSET_Y = 0.5
|
||||
_BF_ASPECT_RATIOS = (1.0,)
|
||||
_BF_INTERP_SCALE_AR = 1.0
|
||||
_BF_BOX_SCALE = 128.0
|
||||
_BF_KP_OFFSET = 4
|
||||
_BF_SCORE_CLIP = 100.0
|
||||
_BF_MIN_SCORE = 0.5
|
||||
|
||||
# face_detection_full_range.pbtxt — 48x48 grid at stride 4, 1 anchor/cell.
|
||||
_BF_FR_INPUT_SIZE = 192
|
||||
_BF_FR_GRID = 48
|
||||
_BF_FR_NUM_ANCHORS = _BF_FR_GRID * _BF_FR_GRID
|
||||
_BF_FR_BOX_SCALE = 192.0
|
||||
_BF_FR_SCORE_CLIP = 100.0
|
||||
|
||||
_FM_INPUT_SIZE = 192
|
||||
|
||||
# Face ROI: 1.5xbbox rect warped anisotropically into 192x192.
|
||||
_FACE_LEFT_EYE_KP = 0
|
||||
_FACE_RIGHT_EYE_KP = 1
|
||||
_FACE_ROI_SCALE_X = 1.5
|
||||
_FACE_ROI_SCALE_Y = 1.5
|
||||
_FACE_ROI_TARGET_ANGLE = 0.0
|
||||
|
||||
|
||||
def _tf_same_pad(x: Tensor, kernel: int, stride: int) -> Tensor:
|
||||
"""TF SAME pad (asymmetric on stride-2; PyTorch's symmetric pad undershoots by 1 px)."""
|
||||
H, W = x.shape[-2], x.shape[-1]
|
||||
pad_h = max(((H + stride - 1) // stride - 1) * stride + kernel - H, 0)
|
||||
pad_w = max(((W + stride - 1) // stride - 1) * stride + kernel - W, 0)
|
||||
if pad_h == 0 and pad_w == 0:
|
||||
return x
|
||||
return F.pad(x, (pad_w // 2, pad_w - pad_w // 2, pad_h // 2, pad_h - pad_h // 2))
|
||||
|
||||
|
||||
# BlazeFace short-range: stem 5x5/s2 → 16 BlazeBlocks → parallel heads at
|
||||
# 16²x88 (2 anchors/cell) and 8²x96 (6/cell) = 896 anchors. (in, out, stride):
|
||||
_BLAZEFACE_BLOCKS = [
|
||||
(24, 24, 1), (24, 28, 1), (28, 32, 2), (32, 36, 1),
|
||||
(36, 42, 1), (42, 48, 2), (48, 56, 1), (56, 64, 1),
|
||||
(64, 72, 1), (72, 80, 1), (80, 88, 1), (88, 96, 2),
|
||||
(96, 96, 1), (96, 96, 1), (96, 96, 1), (96, 96, 1),
|
||||
]
|
||||
|
||||
|
||||
class BlazeFaceBlock(nn.Module):
|
||||
"""DW 3x3 + PW + residual. Residual max-pools on stride>1, channel-pads on out_ch>in_ch."""
|
||||
|
||||
def __init__(self, in_ch: int, out_ch: int, stride: int, device=None, dtype=None, operations=None):
|
||||
super().__init__()
|
||||
ops = operations if operations is not None else nn
|
||||
self.in_ch, self.out_ch, self.stride = in_ch, out_ch, stride
|
||||
self.depthwise = ops.Conv2d(in_ch, in_ch, 3, stride=stride, padding=0, groups=in_ch, bias=True, device=device, dtype=dtype)
|
||||
self.pointwise = ops.Conv2d(in_ch, out_ch, 1, padding=0, bias=True, device=device, dtype=dtype)
|
||||
|
||||
def forward(self, x: Tensor) -> Tensor:
|
||||
residual = F.max_pool2d(x, 2, 2) if self.stride > 1 else x
|
||||
if self.out_ch > self.in_ch:
|
||||
residual = F.pad(residual, (0, 0, 0, 0, 0, self.out_ch - self.in_ch))
|
||||
x = _tf_same_pad(x, 3, self.stride) if self.stride > 1 else F.pad(x, (1, 1, 1, 1))
|
||||
return F.relu(self.pointwise(self.depthwise(x)) + residual)
|
||||
|
||||
|
||||
class BlazeFace(nn.Module):
|
||||
"""Short-range BlazeFace: (B, 3, 128, 128) in [-1, 1] → 896 anchors x 17."""
|
||||
|
||||
def __init__(self, device=None, dtype=None, operations=None):
|
||||
super().__init__()
|
||||
ops = operations if operations is not None else nn
|
||||
kw = dict(device=device, dtype=dtype)
|
||||
self.stem = ops.Conv2d(3, 24, 5, stride=2, padding=0, bias=True, **kw)
|
||||
self.blocks = nn.ModuleList(BlazeFaceBlock(i, o, s, device=device, dtype=dtype, operations=operations)
|
||||
for (i, o, s) in _BLAZEFACE_BLOCKS)
|
||||
# 16²x2 + 8²x6 = 512 + 384 = 896 anchors.
|
||||
self.cls_16 = ops.Conv2d(88, 2, 1, padding=0, bias=True, **kw)
|
||||
self.cls_8 = ops.Conv2d(96, 6, 1, padding=0, bias=True, **kw)
|
||||
self.reg_16 = ops.Conv2d(88, 32, 1, padding=0, bias=True, **kw)
|
||||
self.reg_8 = ops.Conv2d(96, 96, 1, padding=0, bias=True, **kw)
|
||||
|
||||
def forward(self, image_chw_normalized: Tensor) -> tuple[Tensor, Tensor]:
|
||||
x = F.relu(self.stem(_tf_same_pad(image_chw_normalized, 5, 2)))
|
||||
# 16x16 tap is block-10 output (before the 88→96 stride-2 in block 11).
|
||||
for i in range(11):
|
||||
x = self.blocks[i](x)
|
||||
feat_16 = x
|
||||
for i in range(11, 16):
|
||||
x = self.blocks[i](x)
|
||||
feat_8 = x
|
||||
|
||||
def flat(t, a, k): # NHWC flatten → (B, H*W*A, K)
|
||||
B, _, H, W = t.shape
|
||||
return t.permute(0, 2, 3, 1).reshape(B, H * W * a, k)
|
||||
|
||||
cls = torch.cat([flat(self.cls_16(feat_16), 2, 1), flat(self.cls_8(feat_8), 6, 1)], dim=1)
|
||||
reg = torch.cat([flat(self.reg_16(feat_16), 2, 16), flat(self.reg_8(feat_8), 6, 16)], dim=1)
|
||||
return reg, cls
|
||||
|
||||
|
||||
# BlazeFace full-range (face_detection_full_range_sparse.tflite): MobileNetV2-ish
|
||||
# backbone + top-down FPN, 192² input → 2304 anchors at the 48x48 grid.
|
||||
class FRBlock(nn.Module):
|
||||
"""Double inverted residual: DW → PW(mid) → DW → PW(out) [+ residual].
|
||||
|
||||
Per source tflite: dw* have no fused activation, pw1 is always ReLU, pw2
|
||||
is ReLU only when no residual (else ReLU fuses into the ADD).
|
||||
"""
|
||||
|
||||
def __init__(self, in_ch: int, mid_ch: int, out_ch: int, stride: int, device=None, dtype=None, operations=None):
|
||||
super().__init__()
|
||||
ops = operations if operations is not None else nn
|
||||
kw = dict(device=device, dtype=dtype)
|
||||
self.has_residual = (in_ch == out_ch and stride == 1)
|
||||
self.dw1 = ops.Conv2d(in_ch, in_ch, 3, stride=stride, padding=0, groups=in_ch, bias=True, **kw)
|
||||
self.pw1 = ops.Conv2d(in_ch, mid_ch, 1, padding=0, bias=True, **kw)
|
||||
self.dw2 = ops.Conv2d(mid_ch, mid_ch, 3, stride=1, padding=0, groups=mid_ch, bias=True, **kw)
|
||||
self.pw2 = ops.Conv2d(mid_ch, out_ch, 1, padding=0, bias=True, **kw)
|
||||
|
||||
def forward(self, x: Tensor) -> Tensor:
|
||||
residual = x if self.has_residual else None
|
||||
x = F.relu(self.pw1(self.dw1(F.pad(x, (1, 1, 1, 1)))))
|
||||
x = self.pw2(self.dw2(F.pad(x, (1, 1, 1, 1))))
|
||||
return F.relu(x + residual) if residual is not None else F.relu(x)
|
||||
|
||||
|
||||
# (in_ch, mid_ch, out_ch, stride). Stages downsample 96²x32 → 48²x64 → 24²x128
|
||||
# → 12²x192 → 6²x384. Lateral taps at indices 4, 7, 10 (see _FR_LATERAL_*).
|
||||
_FR_BACKBONE_BLOCKS = [
|
||||
(32, 8, 32, 1), (32, 8, 32, 1), # 96²x32
|
||||
(32, 16, 64, 2), (64, 16, 64, 1), (64, 16, 64, 1), # 48²x64 — tap[0]
|
||||
(64, 32, 128, 2), (128, 32, 128, 1), (128, 32, 128, 1), # 24²x128 — tap[1]
|
||||
(128, 48, 192, 2), (192, 48, 192, 1), (192, 48, 192, 1), # 12²x192 — tap[2]
|
||||
(192, 96, 384, 2), (384, 96, 384, 1), (384, 96, 384, 1), (384, 96, 384, 1), # 6²x384
|
||||
]
|
||||
_FR_LATERAL_TAP_INDICES = (4, 7, 10)
|
||||
_FR_LATERAL_CHANNELS = ((64, 48), (128, 64), (192, 96)) # (in, out) per side-conv
|
||||
|
||||
# Decoder blocks per FPN level (after upsample-and-merge with the lateral).
|
||||
_FR_DECODER_BLOCKS = [
|
||||
[(96, 48, 96, 1), (96, 48, 96, 1)], # 12²x96
|
||||
[(64, 32, 64, 1), (64, 32, 64, 1)], # 24²x64
|
||||
[(48, 24, 48, 1)], # 48²x48 — feeds the heads
|
||||
]
|
||||
|
||||
|
||||
def _dcr_depth_to_space(t: Tensor, r: int, c_out: int) -> Tensor:
|
||||
"""TF DEPTH_TO_SPACE in DCR layout (input channels = (i, j, c_out)).
|
||||
pixel_shuffle uses CRD which permutes output channels for c_out > 1."""
|
||||
B_, _, H_, W_ = t.shape
|
||||
t = t.reshape(B_, r, r, c_out, H_, W_)
|
||||
t = t.permute(0, 3, 4, 1, 5, 2).contiguous()
|
||||
return t.reshape(B_, c_out, H_ * r, W_ * r)
|
||||
|
||||
|
||||
class BlazeFaceFullRange(nn.Module):
|
||||
"""Full-range face detector: (B, 3, 192, 192) in [-1, 1] → 2304 anchors x 17 values."""
|
||||
|
||||
def __init__(self, device=None, dtype=None, operations=None):
|
||||
super().__init__()
|
||||
ops = operations if operations is not None else nn
|
||||
kw = dict(device=device, dtype=dtype)
|
||||
mk_block = lambda i, m, o, s: FRBlock(i, m, o, s, device=device, dtype=dtype, operations=operations)
|
||||
self.stem = ops.Conv2d(3, 32, 3, stride=2, padding=0, bias=True, **kw)
|
||||
self.backbone = nn.ModuleList(mk_block(i, m, o, s) for (i, m, o, s) in _FR_BACKBONE_BLOCKS)
|
||||
self.lateral_convs = nn.ModuleList(ops.Conv2d(i, o, 1, padding=0, bias=True, **kw) for (i, o) in _FR_LATERAL_CHANNELS)
|
||||
self.top_conv = ops.Conv2d(384, 96, 1, padding=0, bias=True, **kw)
|
||||
self.decoder_levels = nn.ModuleList(
|
||||
nn.ModuleList(mk_block(i, m, o, s) for (i, m, o, s) in lvl) for lvl in _FR_DECODER_BLOCKS
|
||||
)
|
||||
# 96→64 before 12→24, 64→48 before 24→48.
|
||||
self.decoder_reduce_convs = nn.ModuleList([
|
||||
ops.Conv2d(96, 64, 1, padding=0, bias=True, **kw),
|
||||
ops.Conv2d(64, 48, 1, padding=0, bias=True, **kw),
|
||||
])
|
||||
# Heads mix 2x2-cell info via DW-stride-2 + depth_to_space block_size=2.
|
||||
self.cls_conv = ops.Conv2d(48, 4, 1, padding=0, bias=True, **kw)
|
||||
self.cls_dw = ops.Conv2d(4, 4, 3, stride=2, padding=0, groups=4, bias=True, **kw)
|
||||
self.reg_conv = ops.Conv2d(48, 64, 1, padding=0, bias=True, **kw)
|
||||
self.reg_dw = ops.Conv2d(64, 64, 3, stride=2, padding=0, groups=64, bias=True, **kw)
|
||||
|
||||
def forward(self, image_chw_normalized: Tensor) -> tuple[Tensor, Tensor]:
|
||||
# Symmetric pad-1 throughout (full-range tflite uses explicit TF PAD, not SAME).
|
||||
x = F.relu(self.stem(F.pad(image_chw_normalized, (1, 1, 1, 1))))
|
||||
tap_set = set(_FR_LATERAL_TAP_INDICES)
|
||||
laterals: list[Tensor] = []
|
||||
for i, blk in enumerate(self.backbone):
|
||||
x = blk(x)
|
||||
if i in tap_set:
|
||||
laterals.append(x)
|
||||
|
||||
# top_conv / lateral_convs / decoder_reduce_convs all have fused ReLU in the tflite.
|
||||
p = F.relu(self.top_conv(x))
|
||||
laterals_rev = list(reversed(laterals))
|
||||
lateral_convs_rev = list(reversed(self.lateral_convs))
|
||||
for level in range(len(self.decoder_levels)):
|
||||
lateral = laterals_rev[level]
|
||||
p = F.interpolate(p, size=lateral.shape[-2:], mode="bilinear", align_corners=False)
|
||||
p = p + F.relu(lateral_convs_rev[level](lateral))
|
||||
for blk in self.decoder_levels[level]:
|
||||
p = blk(p)
|
||||
if level < len(self.decoder_reduce_convs):
|
||||
p = F.relu(self.decoder_reduce_convs[level](p))
|
||||
|
||||
c = self.cls_dw(F.pad(self.cls_conv(p), (1, 1, 1, 1)))
|
||||
c = _dcr_depth_to_space(c, r=2, c_out=1)
|
||||
r = self.reg_dw(F.pad(self.reg_conv(p), (1, 1, 1, 1)))
|
||||
r = _dcr_depth_to_space(r, r=2, c_out=16)
|
||||
B = c.shape[0]
|
||||
cls_out = c.permute(0, 2, 3, 1).reshape(B, _BF_FR_NUM_ANCHORS, 1)
|
||||
reg_out = r.permute(0, 2, 3, 1).reshape(B, _BF_FR_NUM_ANCHORS, 16)
|
||||
return reg_out, cls_out
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _blazeface_full_range_anchors() -> np.ndarray:
|
||||
"""2304 anchors over 48x48; anchor_w=anchor_h=1 (fixed_anchor_size)."""
|
||||
feat = _BF_FR_GRID
|
||||
yy, xx = np.meshgrid(np.arange(feat, dtype=np.float32), np.arange(feat, dtype=np.float32), indexing="ij")
|
||||
cx, cy, ones = (xx + 0.5) / feat, (yy + 0.5) / feat, np.ones_like(xx)
|
||||
return np.stack([cx, cy, ones, ones], axis=-1).reshape(_BF_FR_NUM_ANCHORS, 4)
|
||||
|
||||
|
||||
def _decode_blazeface_full_range(regressors: np.ndarray, classificators: np.ndarray,
|
||||
score_thresh: float = _BF_MIN_SCORE) -> np.ndarray:
|
||||
"""Same decode as short-range with 2304-anchor grid and box_scale=192."""
|
||||
scores = expit(np.clip(classificators[:, 0], -_BF_FR_SCORE_CLIP, _BF_FR_SCORE_CLIP))
|
||||
keep = scores >= score_thresh
|
||||
if not keep.any():
|
||||
return np.empty((0, 17), dtype=np.float32)
|
||||
r = regressors[keep] / _BF_FR_BOX_SCALE
|
||||
a = _blazeface_full_range_anchors()[keep]
|
||||
cxs, cys, aws, ahs = a[:, 0:1], a[:, 1:2], a[:, 2:3], a[:, 3:4]
|
||||
xc, yc = r[:, 0:1] * aws + cxs, r[:, 1:2] * ahs + cys
|
||||
w, h = r[:, 2:3] * aws, r[:, 3:4] * ahs
|
||||
out = np.empty((r.shape[0], 17), dtype=np.float32)
|
||||
out[:, 0:1], out[:, 1:2], out[:, 2:3], out[:, 3:4] = xc - w / 2, yc - h / 2, xc + w / 2, yc + h / 2
|
||||
out[:, 4:16:2] = r[:, _BF_KP_OFFSET::2] * aws + cxs
|
||||
out[:, 5:16:2] = r[:, _BF_KP_OFFSET + 1::2] * ahs + cys
|
||||
out[:, 16] = scores[keep]
|
||||
return out
|
||||
|
||||
|
||||
# FaceMesh (face_landmarks_detector.tflite): PReLU variant of BlazeBlock,
|
||||
# 17 blocks, heads for 478x3 landmarks + presence.
|
||||
_FACEMESH_BLOCKS = [ # (in_ch, out_ch, stride)
|
||||
(16, 16, 1), (16, 16, 1), (16, 32, 2), (32, 32, 1), (32, 32, 1), (32, 64, 2),
|
||||
(64, 64, 1), (64, 64, 1), (64, 128, 2), (128, 128, 1), (128, 128, 1), (128, 128, 2),
|
||||
(128, 128, 1), (128, 128, 1), (128, 128, 2), (128, 128, 1), (128, 128, 1),
|
||||
]
|
||||
|
||||
|
||||
class FaceMeshBlock(nn.Module):
|
||||
"""PReLU BlazeBlock: PReLU between DW and PW, and after the residual add."""
|
||||
|
||||
def __init__(self, in_ch: int, out_ch: int, stride: int, device=None, dtype=None, operations=None):
|
||||
super().__init__()
|
||||
ops = operations if operations is not None else nn
|
||||
kw = dict(device=device, dtype=dtype)
|
||||
self.in_ch, self.out_ch, self.stride = in_ch, out_ch, stride
|
||||
self.depthwise = ops.Conv2d(in_ch, in_ch, 3, stride=stride, padding=0, groups=in_ch, bias=True, **kw)
|
||||
self.prelu_dwise = nn.PReLU(num_parameters=in_ch, **kw)
|
||||
self.pointwise = ops.Conv2d(in_ch, out_ch, 1, padding=0, bias=True, **kw)
|
||||
self.prelu_out = nn.PReLU(num_parameters=out_ch, **kw)
|
||||
|
||||
def forward(self, x: Tensor) -> Tensor:
|
||||
residual = F.max_pool2d(x, 2, 2) if self.stride > 1 else x
|
||||
if self.out_ch > self.in_ch:
|
||||
residual = F.pad(residual, (0, 0, 0, 0, 0, self.out_ch - self.in_ch))
|
||||
x = _tf_same_pad(x, 3, self.stride) if self.stride > 1 else F.pad(x, (1, 1, 1, 1))
|
||||
return self.prelu_out(self.pointwise(self.prelu_dwise(self.depthwise(x))) + residual)
|
||||
|
||||
|
||||
class FaceMesh(nn.Module):
|
||||
NUM_LANDMARKS = 478
|
||||
|
||||
def __init__(self, device=None, dtype=None, operations=None):
|
||||
super().__init__()
|
||||
ops = operations if operations is not None else nn
|
||||
kw = dict(device=device, dtype=dtype)
|
||||
self.stem = ops.Conv2d(3, 16, 3, stride=2, padding=0, bias=True, **kw)
|
||||
self.prelu_stem = nn.PReLU(num_parameters=16, **kw)
|
||||
self.blocks = nn.ModuleList(FaceMeshBlock(i, o, s, device=device, dtype=dtype, operations=operations)
|
||||
for (i, o, s) in _FACEMESH_BLOCKS)
|
||||
self.head_reduce = ops.Conv2d(128, 8, 1, padding=0, bias=True, **kw)
|
||||
self.prelu_head_reduce = nn.PReLU(num_parameters=8, **kw)
|
||||
self.head_block = FaceMeshBlock(8, 8, 1, device=device, dtype=dtype, operations=operations)
|
||||
self.head_presence = ops.Conv2d(8, 1, 3, padding=0, bias=True, **kw)
|
||||
self.head_landmarks = ops.Conv2d(8, self.NUM_LANDMARKS * 3, 3, padding=0, bias=True, **kw)
|
||||
|
||||
def forward(self, face_chw_normalized: Tensor) -> tuple[Tensor, Tensor]:
|
||||
"""(B, 3, 192, 192) in [0, 1] → ((B, 478, 3) landmarks in 192-canonical, (B,) presence)."""
|
||||
x = self.prelu_stem(self.stem(_tf_same_pad(face_chw_normalized, 3, 2)))
|
||||
for blk in self.blocks:
|
||||
x = blk(x)
|
||||
x = self.prelu_head_reduce(self.head_reduce(x))
|
||||
x = self.head_block(x)
|
||||
B = x.shape[0]
|
||||
presence = self.head_presence(x).reshape(B)
|
||||
lmks = self.head_landmarks(x).reshape(B, self.NUM_LANDMARKS, 3)
|
||||
return lmks, presence
|
||||
|
||||
|
||||
# FaceBlendshapes (MLP-Mixer "GhumMarkerPoserMlpMixerGeneral"):
|
||||
# 146x2 → token-reduce 146→96 → embed 2→64 → +cls token → 4x mixer → cls→52.
|
||||
_BS_NUM_INPUT_LANDMARKS = 146
|
||||
_BS_NUM_TOKENS_REDUCED = 96
|
||||
_BS_NUM_TOKENS = 97 # +1 cls
|
||||
_BS_TOKEN_DIM = 64
|
||||
_BS_TOKEN_MIX_HIDDEN = 384
|
||||
_BS_CHANNEL_MIX_HIDDEN = 256
|
||||
_BS_NUM_BLENDSHAPES = 52
|
||||
_BS_LN_EPS = 1e-6
|
||||
|
||||
|
||||
class MlpMixerBlock(nn.Module):
|
||||
"""MLP-Mixer block: token-mixing MLP (over tokens) → channel-mixing MLP (over dim).
|
||||
Both pre-LN, both residual. LN has no beta (bias=False) to match MP."""
|
||||
|
||||
def __init__(self, num_tokens: int, token_dim: int, token_hidden: int, channel_hidden: int,
|
||||
device=None, dtype=None, operations=None):
|
||||
super().__init__()
|
||||
ops = operations if operations is not None else nn
|
||||
kw = dict(device=device, dtype=dtype)
|
||||
# bias=False → no LN beta (matches MP).
|
||||
self.ln1 = ops.LayerNorm(token_dim, eps=_BS_LN_EPS, bias=False, **kw)
|
||||
self.ln2 = ops.LayerNorm(token_dim, eps=_BS_LN_EPS, bias=False, **kw)
|
||||
self.token_mlp1 = ops.Linear(num_tokens, token_hidden, bias=True, **kw)
|
||||
self.token_mlp2 = ops.Linear(token_hidden, num_tokens, bias=True, **kw)
|
||||
self.channel_mlp1 = ops.Linear(token_dim, channel_hidden, bias=True, **kw)
|
||||
self.channel_mlp2 = ops.Linear(channel_hidden, token_dim, bias=True, **kw)
|
||||
|
||||
def forward(self, x: Tensor) -> Tensor:
|
||||
y = self.ln1(x).transpose(1, 2)
|
||||
x = x + self.token_mlp2(F.relu(self.token_mlp1(y))).transpose(1, 2)
|
||||
return x + self.channel_mlp2(F.relu(self.channel_mlp1(self.ln2(x))))
|
||||
|
||||
|
||||
class FaceBlendshapes(nn.Module):
|
||||
def __init__(self, device=None, dtype=None, operations=None):
|
||||
super().__init__()
|
||||
ops = operations if operations is not None else nn
|
||||
kw = dict(device=device, dtype=dtype)
|
||||
self.token_reduce = ops.Linear(_BS_NUM_INPUT_LANDMARKS, _BS_NUM_TOKENS_REDUCED, bias=True, **kw)
|
||||
self.token_embed = ops.Linear(2, _BS_TOKEN_DIM, bias=True, **kw)
|
||||
self.cls_token = nn.Parameter(torch.zeros(1, 1, _BS_TOKEN_DIM, **kw))
|
||||
self.blocks = nn.ModuleList(
|
||||
MlpMixerBlock(_BS_NUM_TOKENS, _BS_TOKEN_DIM, _BS_TOKEN_MIX_HIDDEN, _BS_CHANNEL_MIX_HIDDEN,
|
||||
device=device, dtype=dtype, operations=operations) for _ in range(4)
|
||||
)
|
||||
self.head = ops.Linear(_BS_TOKEN_DIM, _BS_NUM_BLENDSHAPES, bias=True, **kw)
|
||||
|
||||
@staticmethod
|
||||
def _input_normalize(landmarks_2d: Tensor) -> Tensor:
|
||||
# Centroid-subtract → L2 scale → x0.5. The 0.5 is baked into training.
|
||||
centroid = landmarks_2d.mean(dim=1, keepdim=True)
|
||||
x = landmarks_2d - centroid
|
||||
mag = torch.sqrt((x * x).sum(dim=-1, keepdim=True))
|
||||
scale = mag.mean(dim=1, keepdim=True)
|
||||
return (x / scale.clamp(min=1e-12)) * 0.5
|
||||
|
||||
def forward(self, landmarks_2d: Tensor) -> Tensor:
|
||||
"""(B, 146, 2) → (B, 52) in [0, 1]. Input units don't matter (centroid + L2 normalize)."""
|
||||
x = self._input_normalize(landmarks_2d)
|
||||
x = self.token_reduce(x.transpose(1, 2)).transpose(1, 2)
|
||||
x = self.token_embed(x)
|
||||
cls = self.cls_token.expand(x.shape[0], -1, -1)
|
||||
x = torch.cat([cls, x], dim=1)
|
||||
for blk in self.blocks:
|
||||
x = blk(x)
|
||||
return torch.sigmoid(self.head(x[:, 0]))
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _blazeface_anchors() -> np.ndarray:
|
||||
"""896 anchors per SsdAnchorsCalculator (fixed_anchor_size → anchor_w=anchor_h=1)."""
|
||||
per_ar = len(_BF_ASPECT_RATIOS) + (1 if _BF_INTERP_SCALE_AR > 0 else 0)
|
||||
layer_anchors: List[np.ndarray] = []
|
||||
layer = 0
|
||||
while layer < _BF_NUM_LAYERS:
|
||||
stride = _BF_STRIDES[layer]
|
||||
last = layer
|
||||
while last < _BF_NUM_LAYERS and _BF_STRIDES[last] == stride:
|
||||
last += 1
|
||||
per_cell = per_ar * (last - layer)
|
||||
feat = (_BF_INPUT_SIZE + stride - 1) // stride
|
||||
yy, xx = np.meshgrid(np.arange(feat, dtype=np.float32), np.arange(feat, dtype=np.float32), indexing="ij")
|
||||
cx, cy, ones = (xx + _BF_ANCHOR_OFFSET_X) / feat, (yy + _BF_ANCHOR_OFFSET_Y) / feat, np.ones_like(xx)
|
||||
cell = np.stack([cx, cy, ones, ones], axis=-1).reshape(-1, 4)
|
||||
layer_anchors.append(np.repeat(cell, per_cell, axis=0))
|
||||
layer = last
|
||||
out = np.concatenate(layer_anchors, axis=0)
|
||||
assert out.shape == (896, 4), out.shape
|
||||
return out
|
||||
|
||||
|
||||
def _decode_blazeface(regressors: np.ndarray, classificators: np.ndarray,
|
||||
score_thresh: float = _BF_MIN_SCORE) -> np.ndarray:
|
||||
"""Decode (regs (896,16), cls (896,1)) → (N, 17) = [xyxy, kp0x..kp5y, score] in [0, 1]."""
|
||||
scores = expit(np.clip(classificators[:, 0], -_BF_SCORE_CLIP, _BF_SCORE_CLIP))
|
||||
keep = scores >= score_thresh
|
||||
if not keep.any():
|
||||
return np.empty((0, 17), dtype=np.float32)
|
||||
r = regressors[keep] / _BF_BOX_SCALE
|
||||
a = _blazeface_anchors()[keep] # (N, 4) cx, cy, 1, 1
|
||||
cxs, cys, aws, ahs = a[:, 0:1], a[:, 1:2], a[:, 2:3], a[:, 3:4]
|
||||
xc, yc = r[:, 0:1] * aws + cxs, r[:, 1:2] * ahs + cys
|
||||
w, h = r[:, 2:3] * aws, r[:, 3:4] * ahs
|
||||
out = np.empty((r.shape[0], 17), dtype=np.float32)
|
||||
out[:, 0:1], out[:, 1:2], out[:, 2:3], out[:, 3:4] = xc - w / 2, yc - h / 2, xc + w / 2, yc + h / 2
|
||||
out[:, 4:16:2] = r[:, _BF_KP_OFFSET::2] * aws + cxs
|
||||
out[:, 5:16:2] = r[:, _BF_KP_OFFSET + 1::2] * ahs + cys
|
||||
out[:, 16] = scores[keep]
|
||||
return out
|
||||
|
||||
|
||||
def _weighted_nms(detections: np.ndarray, iou_thresh: float = 0.5) -> np.ndarray:
|
||||
"""MP weighted NMS — kept boxes are score-weighted averages of overlapping detections."""
|
||||
if detections.shape[0] == 0:
|
||||
return detections
|
||||
dets = detections[np.argsort(-detections[:, 16])]
|
||||
N = dets.shape[0]
|
||||
areas = np.clip(dets[:, 2] - dets[:, 0], 0, None) * np.clip(dets[:, 3] - dets[:, 1], 0, None)
|
||||
kept: List[np.ndarray] = []
|
||||
used = np.zeros(N, dtype=bool)
|
||||
for i in range(N):
|
||||
if used[i]:
|
||||
continue
|
||||
ax1, ay1, ax2, ay2 = dets[i, 0:4]
|
||||
merge_idx = [i]
|
||||
for j in range(i + 1, N):
|
||||
if used[j]:
|
||||
continue
|
||||
bx1, by1, bx2, by2 = dets[j, 0:4]
|
||||
iw = max(0.0, min(ax2, bx2) - max(ax1, bx1))
|
||||
ih = max(0.0, min(ay2, by2) - max(ay1, by1))
|
||||
inter = iw * ih
|
||||
union = areas[i] + areas[j] - inter
|
||||
if union > 0 and inter / union > iou_thresh: # strict > matches MP
|
||||
merge_idx.append(j)
|
||||
used[j] = True
|
||||
used[i] = True
|
||||
cluster = dets[merge_idx]
|
||||
ws = cluster[:, 16:17]
|
||||
ws_sum = ws.sum()
|
||||
merged = np.copy(cluster[0])
|
||||
if ws_sum > 0:
|
||||
merged[:16] = (cluster[:, :16] * ws).sum(axis=0) / ws_sum
|
||||
kept.append(merged)
|
||||
return np.stack(kept, axis=0) if kept else np.empty((0, 17), dtype=np.float32)
|
||||
|
||||
|
||||
def _detection_to_face_rect(detection: np.ndarray, image_w: int, image_h: int) -> Tuple[float, float, float, float, float]:
|
||||
"""Detection (normalized) → rotated 1.5xbbox ROI in image pixels (anisotropic)."""
|
||||
xmin, ymin, xmax, ymax = detection[0:4]
|
||||
lx = detection[4 + _FACE_LEFT_EYE_KP * 2 + 0] * image_w
|
||||
ly = detection[4 + _FACE_LEFT_EYE_KP * 2 + 1] * image_h
|
||||
rx = detection[4 + _FACE_RIGHT_EYE_KP * 2 + 0] * image_w
|
||||
ry = detection[4 + _FACE_RIGHT_EYE_KP * 2 + 1] * image_h
|
||||
# Image-y-down convention: angle = target - atan2(-dy, dx).
|
||||
angle = _FACE_ROI_TARGET_ANGLE - math.atan2(ly - ry, rx - lx)
|
||||
return (float((xmin + xmax) * 0.5 * image_w),
|
||||
float((ymin + ymax) * 0.5 * image_h),
|
||||
float((xmax - xmin) * image_w * _FACE_ROI_SCALE_X),
|
||||
float((ymax - ymin) * image_h * _FACE_ROI_SCALE_Y),
|
||||
float(angle))
|
||||
|
||||
|
||||
def _sample_warp(image_chw: Tensor, src_x: Tensor, src_y: Tensor, padding_mode: str) -> Tensor:
|
||||
"""Bilinear-sample image_chw at corner-aligned (src_x, src_y)."""
|
||||
H, W = int(image_chw.shape[-2]), int(image_chw.shape[-1])
|
||||
grid = torch.stack([(2.0 * src_x + 1.0) / W - 1.0,
|
||||
(2.0 * src_y + 1.0) / H - 1.0], dim=-1).unsqueeze(0)
|
||||
return F.grid_sample(image_chw.unsqueeze(0), grid, mode="bilinear",
|
||||
align_corners=False, padding_mode=padding_mode).squeeze(0)
|
||||
|
||||
|
||||
def _warp_face_crop(image_chw: Tensor, cx: float, cy: float, width: float, height: float,
|
||||
angle: float, output_size: int = _FM_INPUT_SIZE) -> Tensor:
|
||||
"""Rotated rect → output_size² with BORDER_REPLICATE. image_chw must be in [0, 1]."""
|
||||
s_x, s_y = width / output_size, height / output_size
|
||||
cos_a, sin_a = math.cos(angle), math.sin(angle)
|
||||
arange = torch.arange(output_size, dtype=image_chw.dtype, device=image_chw.device) - output_size * 0.5
|
||||
v_grid, u_grid = torch.meshgrid(arange, arange, indexing="ij")
|
||||
src_x = cx + u_grid * s_x * cos_a - v_grid * s_y * sin_a
|
||||
src_y = cy + u_grid * s_x * sin_a + v_grid * s_y * cos_a
|
||||
return _sample_warp(image_chw, src_x, src_y, "border")
|
||||
|
||||
|
||||
def _blazeface_input_warp(image_chw_raw: Tensor, target: int = _BF_INPUT_SIZE) -> Tuple[Tensor, float, float, float]:
|
||||
"""Centered max(W,H) square → target² with BORDER_ZERO + [-1, 1] norm.
|
||||
|
||||
Sub-pixel grid_sample matters; integer-pad-then-resize drifts the bbox ~5%.
|
||||
Returns (warped, sub_rect_cx, sub_rect_cy, sub_rect_size) — the triplet maps
|
||||
tensor-normalized [0,1] detections back to image pixels.
|
||||
"""
|
||||
H, W = int(image_chw_raw.shape[1]), int(image_chw_raw.shape[2])
|
||||
sub_rect_size = float(max(W, H))
|
||||
sub_rect_cx, sub_rect_cy = W * 0.5, H * 0.5
|
||||
s = sub_rect_size / target
|
||||
arange = torch.arange(target, dtype=image_chw_raw.dtype, device=image_chw_raw.device) - target * 0.5
|
||||
v_grid, u_grid = torch.meshgrid(arange, arange, indexing="ij")
|
||||
out = _sample_warp(image_chw_raw, sub_rect_cx + u_grid * s, sub_rect_cy + v_grid * s, "zeros")
|
||||
return (out / 127.5) - 1.0, sub_rect_cx, sub_rect_cy, sub_rect_size
|
||||
|
||||
|
||||
class FaceLandmarker(nn.Module):
|
||||
"""BlazeFace → FaceMesh v2 → blendshapes. `detector_variant` selects 'short'
|
||||
(128², ≤2m) or 'full' (192² FPN, ≤5m). State dict uses inner-module prefixes
|
||||
`detector.*` / `mesh.*` / `blendshapes.*`; the outer FaceLandmarkerModel
|
||||
wrapper rewrites `detector_{variant}.*` keys to `detector.*` before loading.
|
||||
"""
|
||||
|
||||
def __init__(self, device=None, dtype=None, operations=None, detector_variant: str = "short"):
|
||||
super().__init__()
|
||||
det_cls = {"short": BlazeFace, "full": BlazeFaceFullRange}.get(detector_variant)
|
||||
|
||||
self.detector_variant = detector_variant
|
||||
self.detector = det_cls(device=device, dtype=dtype, operations=operations)
|
||||
self.mesh = FaceMesh(device=device, dtype=dtype, operations=operations)
|
||||
self.blendshapes = FaceBlendshapes(device=device, dtype=dtype, operations=operations)
|
||||
self.register_buffer("_bs_idx", torch.tensor(_BS_INPUT_INDICES, dtype=torch.long), persistent=False)
|
||||
|
||||
def run_detector_batch(self, images_rgb_uint8: List[np.ndarray],
|
||||
score_thresh: float = _BF_MIN_SCORE,
|
||||
iou_thresh: float = 0.5):
|
||||
"""Batched detector pass. Returns (img_raws, sub_rects, sizes, per_frame_decoded)
|
||||
where per_frame_decoded[b] is (N, 17) in tensor-normalized [0,1] coords."""
|
||||
if not images_rgb_uint8:
|
||||
return [], [], [], []
|
||||
device, dtype = self.detector.stem.weight.device, self.detector.stem.weight.dtype
|
||||
det_input_size, decode_fn = ((_BF_FR_INPUT_SIZE, _decode_blazeface_full_range)
|
||||
if self.detector_variant == "full"
|
||||
else (_BF_INPUT_SIZE, _decode_blazeface))
|
||||
|
||||
# Same-size frames: stack once and transfer once. Variable size falls back
|
||||
# to per-image (only triggers for SAM3DBody's head crops).
|
||||
sizes = [tuple(img.shape[:2]) for img in images_rgb_uint8]
|
||||
if len(set(sizes)) == 1:
|
||||
batch_chw = torch.from_numpy(np.stack(images_rgb_uint8, axis=0)).to(device, dtype).movedim(-1, -3).contiguous()
|
||||
img_raws = [batch_chw[bi] for bi in range(batch_chw.shape[0])]
|
||||
else:
|
||||
img_raws = [torch.from_numpy(img).to(device, dtype).movedim(-1, -3).contiguous() for img in images_rgb_uint8]
|
||||
|
||||
warps = [_blazeface_input_warp(img_raw, det_input_size) for img_raw in img_raws]
|
||||
det_crops = [w[0] for w in warps]
|
||||
sub_rects = [(w[1], w[2], w[3]) for w in warps]
|
||||
|
||||
regs_b, cls_b = self.detector(torch.stack(det_crops, dim=0))
|
||||
regs_np, cls_np = regs_b.float().cpu().numpy(), cls_b.float().cpu().numpy()
|
||||
per_frame = []
|
||||
for b in range(len(images_rgb_uint8)):
|
||||
decoded = decode_fn(regs_np[b], cls_np[b], score_thresh=score_thresh)
|
||||
per_frame.append(_weighted_nms(decoded, iou_thresh=iou_thresh) if decoded.shape[0] > 0 else decoded)
|
||||
return img_raws, sub_rects, sizes, per_frame
|
||||
|
||||
def detect_batch(self, images_rgb_uint8: List[np.ndarray], num_faces: int = 1,
|
||||
score_thresh: float = _BF_MIN_SCORE) -> List[List[dict]]:
|
||||
"""Full pipeline batched across `images_rgb_uint8`. Returns one face-dict
|
||||
list per image (empty if nothing detected). Face dict:
|
||||
bbox_xyxy (4,) image pixels, blendshapes {52} ∈ [0,1],
|
||||
landmarks_xy (478, 2) image pixels, landmarks_3d (478, 3) in
|
||||
192-canonical (pre-transformation) units, presence float (raw logit).
|
||||
"""
|
||||
img_raws, sub_rects, sizes, per_frame_dets = self.run_detector_batch(
|
||||
images_rgb_uint8, score_thresh=score_thresh,
|
||||
)
|
||||
# tensor-normalized → image-normalized [0,1] for _detection_to_face_rect.
|
||||
for b, decoded in enumerate(per_frame_dets):
|
||||
if decoded.shape[0] == 0:
|
||||
continue
|
||||
cx, cy, size = sub_rects[b]
|
||||
H, W = sizes[b]
|
||||
sx0, sy0 = cx - size * 0.5, cy - size * 0.5
|
||||
decoded[:, 0:16:2] = (sx0 + size * decoded[:, 0:16:2]) / W
|
||||
decoded[:, 1:16:2] = (sy0 + size * decoded[:, 1:16:2]) / H
|
||||
if num_faces > 0:
|
||||
per_frame_dets[b] = decoded[: int(num_faces)]
|
||||
|
||||
# Collect every detected face across all frames into one mesh input.
|
||||
face_params: List[Tuple[int, float, float, float, float, float, float]] = []
|
||||
mesh_crops: List[Tensor] = []
|
||||
for b, dets in enumerate(per_frame_dets):
|
||||
if dets.shape[0] == 0:
|
||||
continue
|
||||
H, W = sizes[b]
|
||||
img_for_mesh = img_raws[b] / 255.0
|
||||
for det in dets:
|
||||
cx, cy, w, h, angle = _detection_to_face_rect(det, W, H)
|
||||
mesh_crops.append(_warp_face_crop(img_for_mesh, cx, cy, w, h, angle, _FM_INPUT_SIZE))
|
||||
face_params.append((b, float(det[16]), cx, cy, w, h, angle))
|
||||
|
||||
results: List[List[dict]] = [[] for _ in range(len(images_rgb_uint8))]
|
||||
if not mesh_crops:
|
||||
return results
|
||||
|
||||
lmks_canon_b, presence_b = self.mesh(torch.stack(mesh_crops, dim=0))
|
||||
bs_out_b = self.blendshapes(lmks_canon_b[:, self._bs_idx, :2])
|
||||
|
||||
# Batched canonical→image affine
|
||||
params_t = torch.tensor(
|
||||
[(cx, cy, w, h, math.cos(a), math.sin(a)) for (_b, _s, cx, cy, w, h, a) in face_params],
|
||||
device=lmks_canon_b.device, dtype=lmks_canon_b.dtype,
|
||||
)
|
||||
cxs, cys, ws, hs, cos_a, sin_a = params_t.unbind(dim=1)
|
||||
inv = 1.0 / _FM_INPUT_SIZE
|
||||
u = lmks_canon_b[..., 0] - _FM_INPUT_SIZE * 0.5
|
||||
v = lmks_canon_b[..., 1] - _FM_INPUT_SIZE * 0.5
|
||||
lmks_xy_t = torch.stack([
|
||||
cxs[:, None] + u * (ws * inv * cos_a)[:, None] - v * (hs * inv * sin_a)[:, None],
|
||||
cys[:, None] + u * (ws * inv * sin_a)[:, None] + v * (hs * inv * cos_a)[:, None],
|
||||
], dim=-1)
|
||||
|
||||
lmks_xy_np = lmks_xy_t.float().cpu().numpy()
|
||||
lmks_canon_np = lmks_canon_b.float().cpu().numpy()
|
||||
presence_np = presence_b.float().cpu().numpy()
|
||||
bs_np = bs_out_b.float().cpu().numpy()
|
||||
|
||||
for i, (b, score, *_) in enumerate(face_params):
|
||||
lmks_xy = lmks_xy_np[i]
|
||||
mn, mx = lmks_xy.min(0), lmks_xy.max(0)
|
||||
results[b].append({
|
||||
"bbox_xyxy": np.array([mn[0], mn[1], mx[0], mx[1]], dtype=np.float32),
|
||||
"blendshapes": dict(zip(BLENDSHAPE_NAMES, bs_np[i].tolist())),
|
||||
"landmarks_xy": lmks_xy,
|
||||
"landmarks_3d": lmks_canon_np[i],
|
||||
"presence": float(presence_np[i]),
|
||||
"score": score,
|
||||
})
|
||||
return results
|
||||
@@ -104,7 +104,7 @@ class EmptyAceStep15LatentAudio(IO.ComfyNode):
|
||||
def execute(cls, seconds, batch_size) -> IO.NodeOutput:
|
||||
length = round((seconds * 48000 / 1920))
|
||||
latent = torch.zeros([batch_size, 64, length], device=comfy.model_management.intermediate_device(), dtype=comfy.model_management.intermediate_dtype())
|
||||
return IO.NodeOutput({"samples": latent, "type": "audio"})
|
||||
return IO.NodeOutput({"samples": latent, "type": "audio", "downscale_ratio_temporal": 1764})
|
||||
|
||||
class ReferenceAudio(IO.ComfyNode):
|
||||
@classmethod
|
||||
|
||||
@@ -45,7 +45,7 @@ class SamplerLCMUpscale(io.ComfyNode):
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(
|
||||
node_id="SamplerLCMUpscale",
|
||||
category="sampling/custom_sampling/samplers",
|
||||
category="sampling/samplers",
|
||||
inputs=[
|
||||
io.Float.Input("scale_ratio", default=1.0, min=0.1, max=20.0, step=0.01, advanced=True),
|
||||
io.Int.Input("scale_steps", default=-1, min=-1, max=1000, step=1, advanced=True),
|
||||
@@ -86,13 +86,44 @@ def sample_euler_pp(model, x, sigmas, extra_args=None, callback=None, disable=No
|
||||
return x
|
||||
|
||||
|
||||
class SamplerLCM(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(
|
||||
node_id="SamplerLCM",
|
||||
category="sampling/samplers",
|
||||
description=("LCM sampler with tunable per-step noise. s_noise is a multiplier on the model's training noise scale"),
|
||||
inputs=[
|
||||
io.Float.Input("s_noise", default=1.0, min=0.0, max=64.0, step=0.01,
|
||||
tooltip="Per-step noise multiplier at the first step (1.0 = match training)."),
|
||||
io.Float.Input("s_noise_end", default=1.0, min=0.0, max=64.0, step=0.01,
|
||||
tooltip="Per-step noise multiplier at the last step. Set equal to s_noise for a constant schedule."),
|
||||
io.Float.Input("noise_clip_std", default=0.0, min=0.0, max=10.0, step=0.01,
|
||||
tooltip="Clamp per-step noise to +/- N*std. 0 disables."),
|
||||
],
|
||||
outputs=[io.Sampler.Output()],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, s_noise, s_noise_end, noise_clip_std) -> io.NodeOutput:
|
||||
sampler = comfy.samplers.ksampler(
|
||||
"lcm",
|
||||
{
|
||||
"s_noise": float(s_noise),
|
||||
"s_noise_end": float(s_noise_end),
|
||||
"noise_clip_std": float(noise_clip_std),
|
||||
},
|
||||
)
|
||||
return io.NodeOutput(sampler)
|
||||
|
||||
|
||||
class SamplerEulerCFGpp(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(
|
||||
node_id="SamplerEulerCFGpp",
|
||||
display_name="SamplerEulerCFG++",
|
||||
category="experimental", # "sampling/custom_sampling/samplers"
|
||||
category="experimental", # "sampling/samplers"
|
||||
inputs=[
|
||||
io.Combo.Input("version", options=["regular", "alternative"], advanced=True),
|
||||
],
|
||||
@@ -114,6 +145,7 @@ class AdvancedSamplersExtension(ComfyExtension):
|
||||
async def get_node_list(self) -> list[type[io.ComfyNode]]:
|
||||
return [
|
||||
SamplerLCMUpscale,
|
||||
SamplerLCM,
|
||||
SamplerEulerCFGpp,
|
||||
]
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ class AlignYourStepsScheduler(io.ComfyNode):
|
||||
return io.Schema(
|
||||
node_id="AlignYourStepsScheduler",
|
||||
search_aliases=["AYS scheduler"],
|
||||
category="sampling/custom_sampling/schedulers",
|
||||
category="sampling/schedulers",
|
||||
inputs=[
|
||||
io.Combo.Input("model_type", options=["SD1", "SDXL", "SVD"]),
|
||||
io.Int.Input("steps", default=10, min=1, max=10000),
|
||||
|
||||
@@ -53,7 +53,7 @@ class SamplerARVideo(io.ComfyNode):
|
||||
return io.Schema(
|
||||
node_id="SamplerARVideo",
|
||||
display_name="Sampler AR Video",
|
||||
category="sampling/custom_sampling/samplers",
|
||||
category="sampling/samplers",
|
||||
inputs=[
|
||||
io.Int.Input(
|
||||
"num_frame_per_block",
|
||||
|
||||
@@ -33,7 +33,7 @@ class EmptyLatentAudio(IO.ComfyNode):
|
||||
def execute(cls, seconds, batch_size) -> IO.NodeOutput:
|
||||
length = round((seconds * 44100 / 2048) / 2) * 2
|
||||
latent = torch.zeros([batch_size, 64, length], device=comfy.model_management.intermediate_device())
|
||||
return IO.NodeOutput({"samples":latent, "type": "audio"})
|
||||
return IO.NodeOutput({"samples": latent, "type": "audio", "downscale_ratio_temporal": 2048})
|
||||
|
||||
generate = execute # TODO: remove
|
||||
|
||||
@@ -82,6 +82,8 @@ class VAEEncodeAudio(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def execute(cls, vae, audio) -> IO.NodeOutput:
|
||||
if audio is None:
|
||||
raise ValueError("VAEEncodeAudio: input audio is None (source video may have no audio track).")
|
||||
sample_rate = audio["sample_rate"]
|
||||
vae_sample_rate = getattr(vae, "audio_sample_rate", 44100)
|
||||
if vae_sample_rate != sample_rate:
|
||||
@@ -171,6 +173,8 @@ class SaveAudio(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def execute(cls, audio, filename_prefix="ComfyUI", format="flac") -> IO.NodeOutput:
|
||||
if audio is None:
|
||||
raise ValueError("SaveAudio: input audio is None (source video may have no audio track).")
|
||||
return IO.NodeOutput(
|
||||
ui=UI.AudioSaveHelper.get_save_audio_ui(audio, filename_prefix=filename_prefix, cls=cls, format=format)
|
||||
)
|
||||
@@ -198,6 +202,8 @@ class SaveAudioMP3(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def execute(cls, audio, filename_prefix="ComfyUI", format="mp3", quality="128k") -> IO.NodeOutput:
|
||||
if audio is None:
|
||||
raise ValueError("SaveAudioMP3: input audio is None (source video may have no audio track).")
|
||||
return IO.NodeOutput(
|
||||
ui=UI.AudioSaveHelper.get_save_audio_ui(
|
||||
audio, filename_prefix=filename_prefix, cls=cls, format=format, quality=quality
|
||||
@@ -226,6 +232,8 @@ class SaveAudioOpus(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def execute(cls, audio, filename_prefix="ComfyUI", format="opus", quality="V3") -> IO.NodeOutput:
|
||||
if audio is None:
|
||||
raise ValueError("SaveAudioOpus: input audio is None (source video may have no audio track).")
|
||||
return IO.NodeOutput(
|
||||
ui=UI.AudioSaveHelper.get_save_audio_ui(
|
||||
audio, filename_prefix=filename_prefix, cls=cls, format=format, quality=quality
|
||||
@@ -252,6 +260,8 @@ class PreviewAudio(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def execute(cls, audio) -> IO.NodeOutput:
|
||||
if audio is None:
|
||||
raise ValueError("PreviewAudio: input audio is None (source video may have no audio track).")
|
||||
return IO.NodeOutput(ui=UI.PreviewAudio(audio, cls=cls))
|
||||
|
||||
save_flac = execute # TODO: remove
|
||||
@@ -297,6 +307,7 @@ class LoadAudio(IO.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
os.makedirs(input_dir, exist_ok=True)
|
||||
files = folder_paths.filter_files_content_types(os.listdir(input_dir), ["audio", "video"])
|
||||
return IO.Schema(
|
||||
node_id="LoadAudio",
|
||||
@@ -391,21 +402,26 @@ class TrimAudioDuration(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def execute(cls, audio, start_index, duration) -> IO.NodeOutput:
|
||||
if audio is None:
|
||||
return IO.NodeOutput(None)
|
||||
waveform = audio["waveform"]
|
||||
sample_rate = audio["sample_rate"]
|
||||
audio_length = waveform.shape[-1]
|
||||
|
||||
if audio_length == 0:
|
||||
return IO.NodeOutput(audio)
|
||||
|
||||
if start_index < 0:
|
||||
start_frame = audio_length + int(round(start_index * sample_rate))
|
||||
else:
|
||||
start_frame = int(round(start_index * sample_rate))
|
||||
start_frame = max(0, min(start_frame, audio_length - 1))
|
||||
start_frame = max(0, min(start_frame, audio_length))
|
||||
|
||||
end_frame = start_frame + int(round(duration * sample_rate))
|
||||
end_frame = max(0, min(end_frame, audio_length))
|
||||
|
||||
if start_frame >= end_frame:
|
||||
raise ValueError("AudioTrim: Start time must be less than end time and be within the audio length.")
|
||||
raise ValueError("TrimAudioDuration: Start time must be less than end time and be within the audio length.")
|
||||
|
||||
return IO.NodeOutput({"waveform": waveform[..., start_frame:end_frame], "sample_rate": sample_rate})
|
||||
|
||||
@@ -432,11 +448,13 @@ class SplitAudioChannels(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def execute(cls, audio) -> IO.NodeOutput:
|
||||
if audio is None:
|
||||
return IO.NodeOutput(None, None)
|
||||
waveform = audio["waveform"]
|
||||
sample_rate = audio["sample_rate"]
|
||||
|
||||
if waveform.shape[1] != 2:
|
||||
raise ValueError("AudioSplit: Input audio has only one channel.")
|
||||
raise ValueError(f"AudioSplit: Input audio must be stereo (2 channels), got {waveform.shape[1]} channel(s).")
|
||||
|
||||
left_channel = waveform[..., 0:1, :]
|
||||
right_channel = waveform[..., 1:2, :]
|
||||
@@ -464,6 +482,12 @@ class JoinAudioChannels(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def execute(cls, audio_left, audio_right) -> IO.NodeOutput:
|
||||
if audio_left is None and audio_right is None:
|
||||
return IO.NodeOutput(None)
|
||||
if audio_left is None:
|
||||
return IO.NodeOutput(audio_right)
|
||||
if audio_right is None:
|
||||
return IO.NodeOutput(audio_left)
|
||||
waveform_left = audio_left["waveform"]
|
||||
sample_rate_left = audio_left["sample_rate"]
|
||||
waveform_right = audio_right["waveform"]
|
||||
@@ -519,7 +543,7 @@ class AudioConcat(IO.ComfyNode):
|
||||
return IO.Schema(
|
||||
node_id="AudioConcat",
|
||||
search_aliases=["join audio", "combine audio", "append audio"],
|
||||
display_name="Audio Concat",
|
||||
display_name="Concatenate Audio",
|
||||
description="Concatenates the audio1 to audio2 in the specified direction.",
|
||||
category="audio",
|
||||
inputs=[
|
||||
@@ -537,6 +561,12 @@ class AudioConcat(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def execute(cls, audio1, audio2, direction) -> IO.NodeOutput:
|
||||
if audio1 is None and audio2 is None:
|
||||
return IO.NodeOutput(None)
|
||||
if audio1 is None:
|
||||
return IO.NodeOutput(audio2)
|
||||
if audio2 is None:
|
||||
return IO.NodeOutput(audio1)
|
||||
waveform_1 = audio1["waveform"]
|
||||
waveform_2 = audio2["waveform"]
|
||||
sample_rate_1 = audio1["sample_rate"]
|
||||
@@ -567,7 +597,7 @@ class AudioMerge(IO.ComfyNode):
|
||||
return IO.Schema(
|
||||
node_id="AudioMerge",
|
||||
search_aliases=["mix audio", "overlay audio", "layer audio"],
|
||||
display_name="Audio Merge",
|
||||
display_name="Merge Audio",
|
||||
description="Combine two audio tracks by overlaying their waveforms.",
|
||||
category="audio",
|
||||
inputs=[
|
||||
@@ -584,6 +614,12 @@ class AudioMerge(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def execute(cls, audio1, audio2, merge_method) -> IO.NodeOutput:
|
||||
if audio1 is None and audio2 is None:
|
||||
return IO.NodeOutput(None)
|
||||
if audio1 is None:
|
||||
return IO.NodeOutput(audio2)
|
||||
if audio2 is None:
|
||||
return IO.NodeOutput(audio1)
|
||||
waveform_1 = audio1["waveform"]
|
||||
waveform_2 = audio2["waveform"]
|
||||
sample_rate_1 = audio1["sample_rate"]
|
||||
@@ -594,6 +630,9 @@ class AudioMerge(IO.ComfyNode):
|
||||
length_1 = waveform_1.shape[-1]
|
||||
length_2 = waveform_2.shape[-1]
|
||||
|
||||
if length_1 == 0 or length_2 == 0:
|
||||
return IO.NodeOutput({"waveform": waveform_1, "sample_rate": output_sample_rate})
|
||||
|
||||
if length_2 > length_1:
|
||||
logging.info(f"AudioMerge: Trimming audio2 from {length_2} to {length_1} samples to match audio1 length.")
|
||||
waveform_2 = waveform_2[..., :length_1]
|
||||
@@ -628,8 +667,9 @@ class AudioAdjustVolume(IO.ComfyNode):
|
||||
return IO.Schema(
|
||||
node_id="AudioAdjustVolume",
|
||||
search_aliases=["audio gain", "loudness", "audio level"],
|
||||
display_name="Audio Adjust Volume",
|
||||
display_name="Adjust Audio Volume",
|
||||
category="audio",
|
||||
description="Adjust the volume of the audio by a specified amount in decibels (dB).",
|
||||
inputs=[
|
||||
IO.Audio.Input("audio"),
|
||||
IO.Int.Input(
|
||||
@@ -645,6 +685,8 @@ class AudioAdjustVolume(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def execute(cls, audio, volume) -> IO.NodeOutput:
|
||||
if audio is None:
|
||||
return IO.NodeOutput(None)
|
||||
if volume == 0:
|
||||
return IO.NodeOutput(audio)
|
||||
waveform = audio["waveform"]
|
||||
@@ -728,8 +770,14 @@ class AudioEqualizer3Band(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def execute(cls, audio, low_gain_dB, low_freq, mid_gain_dB, mid_freq, mid_q, high_gain_dB, high_freq) -> IO.NodeOutput:
|
||||
if audio is None:
|
||||
return IO.NodeOutput(None)
|
||||
waveform = audio["waveform"]
|
||||
sample_rate = audio["sample_rate"]
|
||||
|
||||
if waveform.shape[-1] == 0:
|
||||
return IO.NodeOutput(audio)
|
||||
|
||||
eq_waveform = waveform.clone()
|
||||
|
||||
# 1. Apply Low Shelf (Bass)
|
||||
|
||||
@@ -34,6 +34,7 @@ class RemoveBackground(IO.ComfyNode):
|
||||
node_id="RemoveBackground",
|
||||
display_name="Remove Background",
|
||||
category="image/background removal",
|
||||
description="Generates a foreground mask to remove the background from an image using a background removal model.",
|
||||
inputs=[
|
||||
IO.Image.Input("image", tooltip="Input image to remove the background from"),
|
||||
IO.BackgroundRemoval.Input("bg_removal_model", tooltip="Background removal model used to generate the mask")
|
||||
|
||||
@@ -11,9 +11,9 @@ class Canny(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="Canny",
|
||||
display_name="Canny",
|
||||
display_name="Detect Edges (Canny)",
|
||||
search_aliases=["edge detection", "outline", "contour detection", "line art"],
|
||||
category="image/preprocessors",
|
||||
category="image/filters",
|
||||
essentials_category="Image Tools",
|
||||
inputs=[
|
||||
io.Image.Input("image"),
|
||||
|
||||
@@ -111,7 +111,7 @@ class PorterDuffImageComposite(io.ComfyNode):
|
||||
node_id="PorterDuffImageComposite",
|
||||
search_aliases=["alpha composite", "blend modes", "layer blend", "transparency blend"],
|
||||
display_name="Porter-Duff Image Composite",
|
||||
category="mask/compositing",
|
||||
category="image/compositing",
|
||||
inputs=[
|
||||
io.Image.Input("source"),
|
||||
io.Mask.Input("source_alpha"),
|
||||
@@ -168,7 +168,7 @@ class SplitImageWithAlpha(io.ComfyNode):
|
||||
node_id="SplitImageWithAlpha",
|
||||
search_aliases=["extract alpha", "separate transparency", "remove alpha"],
|
||||
display_name="Split Image with Alpha",
|
||||
category="mask/compositing",
|
||||
category="image/compositing",
|
||||
inputs=[
|
||||
io.Image.Input("image"),
|
||||
],
|
||||
@@ -192,7 +192,7 @@ class JoinImageWithAlpha(io.ComfyNode):
|
||||
node_id="JoinImageWithAlpha",
|
||||
search_aliases=["add transparency", "apply alpha", "composite alpha", "RGBA"],
|
||||
display_name="Join Image with Alpha",
|
||||
category="mask/compositing",
|
||||
category="image/compositing",
|
||||
inputs=[
|
||||
io.Image.Input("image"),
|
||||
io.Mask.Input("alpha"),
|
||||
|
||||
@@ -17,7 +17,7 @@ class BasicScheduler(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="BasicScheduler",
|
||||
category="sampling/custom_sampling/schedulers",
|
||||
category="sampling/schedulers",
|
||||
inputs=[
|
||||
io.Model.Input("model"),
|
||||
io.Combo.Input("scheduler", options=comfy.samplers.SCHEDULER_NAMES),
|
||||
@@ -47,7 +47,7 @@ class KarrasScheduler(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="KarrasScheduler",
|
||||
category="sampling/custom_sampling/schedulers",
|
||||
category="sampling/schedulers",
|
||||
inputs=[
|
||||
io.Int.Input("steps", default=20, min=1, max=10000),
|
||||
io.Float.Input("sigma_max", default=14.614642, min=0.0, max=5000.0, step=0.01, round=False, advanced=True),
|
||||
@@ -69,7 +69,7 @@ class ExponentialScheduler(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="ExponentialScheduler",
|
||||
category="sampling/custom_sampling/schedulers",
|
||||
category="sampling/schedulers",
|
||||
inputs=[
|
||||
io.Int.Input("steps", default=20, min=1, max=10000),
|
||||
io.Float.Input("sigma_max", default=14.614642, min=0.0, max=5000.0, step=0.01, round=False, advanced=True),
|
||||
@@ -90,7 +90,7 @@ class PolyexponentialScheduler(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="PolyexponentialScheduler",
|
||||
category="sampling/custom_sampling/schedulers",
|
||||
category="sampling/schedulers",
|
||||
inputs=[
|
||||
io.Int.Input("steps", default=20, min=1, max=10000),
|
||||
io.Float.Input("sigma_max", default=14.614642, min=0.0, max=5000.0, step=0.01, round=False, advanced=True),
|
||||
@@ -112,7 +112,7 @@ class LaplaceScheduler(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="LaplaceScheduler",
|
||||
category="sampling/custom_sampling/schedulers",
|
||||
category="sampling/schedulers",
|
||||
inputs=[
|
||||
io.Int.Input("steps", default=20, min=1, max=10000),
|
||||
io.Float.Input("sigma_max", default=14.614642, min=0.0, max=5000.0, step=0.01, round=False, advanced=True),
|
||||
@@ -136,7 +136,7 @@ class SDTurboScheduler(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="SDTurboScheduler",
|
||||
category="sampling/custom_sampling/schedulers",
|
||||
category="sampling/schedulers",
|
||||
inputs=[
|
||||
io.Model.Input("model"),
|
||||
io.Int.Input("steps", default=1, min=1, max=10),
|
||||
@@ -160,7 +160,7 @@ class BetaSamplingScheduler(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="BetaSamplingScheduler",
|
||||
category="sampling/custom_sampling/schedulers",
|
||||
category="sampling/schedulers",
|
||||
inputs=[
|
||||
io.Model.Input("model"),
|
||||
io.Int.Input("steps", default=20, min=1, max=10000),
|
||||
@@ -182,7 +182,7 @@ class VPScheduler(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="VPScheduler",
|
||||
category="sampling/custom_sampling/schedulers",
|
||||
category="sampling/schedulers",
|
||||
inputs=[
|
||||
io.Int.Input("steps", default=20, min=1, max=10000),
|
||||
io.Float.Input("beta_d", default=19.9, min=0.0, max=5000.0, step=0.01, round=False, advanced=True), #TODO: fix default values
|
||||
@@ -204,7 +204,7 @@ class SplitSigmas(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="SplitSigmas",
|
||||
category="sampling/custom_sampling/sigmas",
|
||||
category="sampling/sigmas",
|
||||
inputs=[
|
||||
io.Sigmas.Input("sigmas"),
|
||||
io.Int.Input("step", default=0, min=0, max=10000),
|
||||
@@ -228,7 +228,7 @@ class SplitSigmasDenoise(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="SplitSigmasDenoise",
|
||||
category="sampling/custom_sampling/sigmas",
|
||||
category="sampling/sigmas",
|
||||
inputs=[
|
||||
io.Sigmas.Input("sigmas"),
|
||||
io.Float.Input("denoise", default=1.0, min=0.0, max=1.0, step=0.01),
|
||||
@@ -254,7 +254,7 @@ class FlipSigmas(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="FlipSigmas",
|
||||
category="sampling/custom_sampling/sigmas",
|
||||
category="sampling/sigmas",
|
||||
inputs=[io.Sigmas.Input("sigmas")],
|
||||
outputs=[io.Sigmas.Output()]
|
||||
)
|
||||
@@ -276,7 +276,7 @@ class SetFirstSigma(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="SetFirstSigma",
|
||||
category="sampling/custom_sampling/sigmas",
|
||||
category="sampling/sigmas",
|
||||
inputs=[
|
||||
io.Sigmas.Input("sigmas"),
|
||||
io.Float.Input("sigma", default=136.0, min=0.0, max=20000.0, step=0.001, round=False),
|
||||
@@ -298,7 +298,7 @@ class ExtendIntermediateSigmas(io.ComfyNode):
|
||||
return io.Schema(
|
||||
node_id="ExtendIntermediateSigmas",
|
||||
search_aliases=["interpolate sigmas"],
|
||||
category="sampling/custom_sampling/sigmas",
|
||||
category="sampling/sigmas",
|
||||
inputs=[
|
||||
io.Sigmas.Input("sigmas"),
|
||||
io.Int.Input("steps", default=2, min=1, max=100),
|
||||
@@ -351,7 +351,7 @@ class SamplingPercentToSigma(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="SamplingPercentToSigma",
|
||||
category="sampling/custom_sampling/sigmas",
|
||||
category="sampling/sigmas",
|
||||
inputs=[
|
||||
io.Model.Input("model"),
|
||||
io.Float.Input("sampling_percent", default=0.0, min=0.0, max=1.0, step=0.0001),
|
||||
@@ -379,7 +379,7 @@ class KSamplerSelect(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="KSamplerSelect",
|
||||
category="sampling/custom_sampling/samplers",
|
||||
category="sampling/samplers",
|
||||
inputs=[io.Combo.Input("sampler_name", options=comfy.samplers.SAMPLER_NAMES)],
|
||||
outputs=[io.Sampler.Output()]
|
||||
)
|
||||
@@ -396,7 +396,7 @@ class SamplerDPMPP_3M_SDE(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="SamplerDPMPP_3M_SDE",
|
||||
category="sampling/custom_sampling/samplers",
|
||||
category="sampling/samplers",
|
||||
inputs=[
|
||||
io.Float.Input("eta", default=1.0, min=0.0, max=100.0, step=0.01, round=False, advanced=True),
|
||||
io.Float.Input("s_noise", default=1.0, min=0.0, max=100.0, step=0.01, round=False, advanced=True),
|
||||
@@ -421,7 +421,7 @@ class SamplerDPMPP_2M_SDE(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="SamplerDPMPP_2M_SDE",
|
||||
category="sampling/custom_sampling/samplers",
|
||||
category="sampling/samplers",
|
||||
inputs=[
|
||||
io.Combo.Input("solver_type", options=['midpoint', 'heun']),
|
||||
io.Float.Input("eta", default=1.0, min=0.0, max=100.0, step=0.01, round=False, advanced=True),
|
||||
@@ -448,7 +448,7 @@ class SamplerDPMPP_SDE(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="SamplerDPMPP_SDE",
|
||||
category="sampling/custom_sampling/samplers",
|
||||
category="sampling/samplers",
|
||||
inputs=[
|
||||
io.Float.Input("eta", default=1.0, min=0.0, max=100.0, step=0.01, round=False, advanced=True),
|
||||
io.Float.Input("s_noise", default=1.0, min=0.0, max=100.0, step=0.01, round=False, advanced=True),
|
||||
@@ -474,7 +474,7 @@ class SamplerDPMPP_2S_Ancestral(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="SamplerDPMPP_2S_Ancestral",
|
||||
category="sampling/custom_sampling/samplers",
|
||||
category="sampling/samplers",
|
||||
inputs=[
|
||||
io.Float.Input("eta", default=1.0, min=0.0, max=100.0, step=0.01, round=False),
|
||||
io.Float.Input("s_noise", default=1.0, min=0.0, max=100.0, step=0.01, round=False),
|
||||
@@ -494,7 +494,7 @@ class SamplerEulerAncestral(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="SamplerEulerAncestral",
|
||||
category="sampling/custom_sampling/samplers",
|
||||
category="sampling/samplers",
|
||||
inputs=[
|
||||
io.Float.Input("eta", default=1.0, min=0.0, max=100.0, step=0.01, round=False, advanced=True),
|
||||
io.Float.Input("s_noise", default=1.0, min=0.0, max=100.0, step=0.01, round=False, advanced=True),
|
||||
@@ -515,7 +515,7 @@ class SamplerEulerAncestralCFGPP(io.ComfyNode):
|
||||
return io.Schema(
|
||||
node_id="SamplerEulerAncestralCFGPP",
|
||||
display_name="SamplerEulerAncestralCFG++",
|
||||
category="sampling/custom_sampling/samplers",
|
||||
category="sampling/samplers",
|
||||
inputs=[
|
||||
io.Float.Input("eta", default=1.0, min=0.0, max=1.0, step=0.01, round=False),
|
||||
io.Float.Input("s_noise", default=1.0, min=0.0, max=10.0, step=0.01, round=False),
|
||||
@@ -537,7 +537,7 @@ class SamplerLMS(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="SamplerLMS",
|
||||
category="sampling/custom_sampling/samplers",
|
||||
category="sampling/samplers",
|
||||
inputs=[io.Int.Input("order", default=4, min=1, max=100, advanced=True)],
|
||||
outputs=[io.Sampler.Output()]
|
||||
)
|
||||
@@ -554,7 +554,7 @@ class SamplerDPMAdaptative(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="SamplerDPMAdaptative",
|
||||
category="sampling/custom_sampling/samplers",
|
||||
category="sampling/samplers",
|
||||
inputs=[
|
||||
io.Int.Input("order", default=3, min=2, max=3, advanced=True),
|
||||
io.Float.Input("rtol", default=0.05, min=0.0, max=100.0, step=0.01, round=False, advanced=True),
|
||||
@@ -585,7 +585,7 @@ class SamplerER_SDE(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="SamplerER_SDE",
|
||||
category="sampling/custom_sampling/samplers",
|
||||
category="sampling/samplers",
|
||||
inputs=[
|
||||
io.Combo.Input("solver_type", options=["ER-SDE", "Reverse-time SDE", "ODE"]),
|
||||
io.Int.Input("max_stage", default=3, min=1, max=3, advanced=True),
|
||||
@@ -623,7 +623,7 @@ class SamplerSASolver(io.ComfyNode):
|
||||
return io.Schema(
|
||||
node_id="SamplerSASolver",
|
||||
search_aliases=["sde"],
|
||||
category="sampling/custom_sampling/samplers",
|
||||
category="sampling/samplers",
|
||||
inputs=[
|
||||
io.Model.Input("model"),
|
||||
io.Float.Input("eta", default=1.0, min=0.0, max=10.0, step=0.01, round=False, advanced=True),
|
||||
@@ -668,7 +668,7 @@ class SamplerSEEDS2(io.ComfyNode):
|
||||
return io.Schema(
|
||||
node_id="SamplerSEEDS2",
|
||||
search_aliases=["sde", "exp heun"],
|
||||
category="sampling/custom_sampling/samplers",
|
||||
category="sampling/samplers",
|
||||
inputs=[
|
||||
io.Combo.Input("solver_type", options=["phi_1", "phi_2"]),
|
||||
io.Float.Input("eta", default=1.0, min=0.0, max=100.0, step=0.01, round=False, tooltip="Stochastic strength", advanced=True),
|
||||
@@ -750,7 +750,7 @@ class SamplerCustom(io.ComfyNode):
|
||||
latent = latent_image
|
||||
latent_image = latent["samples"]
|
||||
latent = latent.copy()
|
||||
latent_image = comfy.sample.fix_empty_latent_channels(model, latent_image, latent.get("downscale_ratio_spacial", None))
|
||||
latent_image = comfy.sample.fix_empty_latent_channels(model, latent_image, latent.get("downscale_ratio_spacial", None), latent.get("downscale_ratio_temporal", None))
|
||||
latent["samples"] = latent_image
|
||||
|
||||
if not add_noise:
|
||||
@@ -770,6 +770,7 @@ class SamplerCustom(io.ComfyNode):
|
||||
|
||||
out = latent.copy()
|
||||
out.pop("downscale_ratio_spacial", None)
|
||||
out.pop("downscale_ratio_temporal", None)
|
||||
out["samples"] = samples
|
||||
if "x0" in x0_output:
|
||||
x0_out = model.model.process_latent_out(x0_output["x0"].cpu())
|
||||
@@ -793,7 +794,8 @@ class BasicGuider(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="BasicGuider",
|
||||
category="sampling/custom_sampling/guiders",
|
||||
display_name="Basic Guider",
|
||||
category="sampling/guiders",
|
||||
inputs=[
|
||||
io.Model.Input("model"),
|
||||
io.Conditioning.Input("conditioning"),
|
||||
@@ -814,7 +816,8 @@ class CFGGuider(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="CFGGuider",
|
||||
category="sampling/custom_sampling/guiders",
|
||||
display_name="CFG Guider",
|
||||
category="sampling/guiders",
|
||||
inputs=[
|
||||
io.Model.Input("model"),
|
||||
io.Conditioning.Input("positive"),
|
||||
@@ -868,7 +871,8 @@ class DualCFGGuider(io.ComfyNode):
|
||||
return io.Schema(
|
||||
node_id="DualCFGGuider",
|
||||
search_aliases=["dual prompt guidance"],
|
||||
category="sampling/custom_sampling/guiders",
|
||||
display_name="Dual CFG Guider",
|
||||
category="sampling/guiders",
|
||||
inputs=[
|
||||
io.Model.Input("model"),
|
||||
io.Conditioning.Input("cond1"),
|
||||
@@ -896,7 +900,7 @@ class DisableNoise(io.ComfyNode):
|
||||
return io.Schema(
|
||||
node_id="DisableNoise",
|
||||
search_aliases=["zero noise"],
|
||||
category="sampling/custom_sampling/noise",
|
||||
category="sampling/noise",
|
||||
inputs=[],
|
||||
outputs=[io.Noise.Output()]
|
||||
)
|
||||
@@ -913,7 +917,7 @@ class RandomNoise(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="RandomNoise",
|
||||
category="sampling/custom_sampling/noise",
|
||||
category="sampling/noise",
|
||||
inputs=[io.Int.Input("noise_seed", default=0, min=0, max=0xffffffffffffffff, control_after_generate=True)],
|
||||
outputs=[io.Noise.Output()]
|
||||
)
|
||||
@@ -949,7 +953,7 @@ class SamplerCustomAdvanced(io.ComfyNode):
|
||||
latent = latent_image
|
||||
latent_image = latent["samples"]
|
||||
latent = latent.copy()
|
||||
latent_image = comfy.sample.fix_empty_latent_channels(guider.model_patcher, latent_image, latent.get("downscale_ratio_spacial", None))
|
||||
latent_image = comfy.sample.fix_empty_latent_channels(guider.model_patcher, latent_image, latent.get("downscale_ratio_spacial", None), latent.get("downscale_ratio_temporal", None))
|
||||
latent["samples"] = latent_image
|
||||
|
||||
noise_mask = None
|
||||
@@ -965,6 +969,7 @@ class SamplerCustomAdvanced(io.ComfyNode):
|
||||
|
||||
out = latent.copy()
|
||||
out.pop("downscale_ratio_spacial", None)
|
||||
out.pop("downscale_ratio_temporal", None)
|
||||
out["samples"] = samples
|
||||
if "x0" in x0_output:
|
||||
x0_out = guider.model_patcher.model.process_latent_out(x0_output["x0"].cpu())
|
||||
|
||||
+129
-59
@@ -47,8 +47,10 @@ class LoadImageDataSetFromFolderNode(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="LoadImageDataSetFromFolder",
|
||||
display_name="Load Image Dataset from Folder",
|
||||
category="dataset",
|
||||
search_aliases=["load folder", "load from folder", "load dataset", "load images", "import dataset"],
|
||||
display_name="Load Image (from Folder)",
|
||||
category="image",
|
||||
description="Load a dataset of images from a specified folder and return a list of images. Supported formats: PNG, JPG, JPEG, WEBP.",
|
||||
is_experimental=True,
|
||||
inputs=[
|
||||
io.Combo.Input(
|
||||
@@ -84,14 +86,16 @@ class LoadImageTextDataSetFromFolderNode(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="LoadImageTextDataSetFromFolder",
|
||||
display_name="Load Image and Text Dataset from Folder",
|
||||
category="dataset",
|
||||
search_aliases=["load folder", "load from folder", "load dataset", "load images", "import dataset"],
|
||||
display_name="Load Image-Text (from Folder)",
|
||||
category="image",
|
||||
description="Load a dataset of pairs of images and text captions from a specified folder and return them as a list. Supported formats: PNG, JPG, JPEG, WEBP.",
|
||||
is_experimental=True,
|
||||
inputs=[
|
||||
io.Combo.Input(
|
||||
"folder",
|
||||
options=folder_paths.get_input_subfolders(),
|
||||
tooltip="The folder to load images from.",
|
||||
tooltip="The folder to load images and text captions from.",
|
||||
)
|
||||
],
|
||||
outputs=[
|
||||
@@ -206,8 +210,10 @@ class SaveImageDataSetToFolderNode(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="SaveImageDataSetToFolder",
|
||||
display_name="Save Image Dataset to Folder",
|
||||
category="dataset",
|
||||
search_aliases=["save folder", "save to folder", "save dataset", "save images", "export dataset"],
|
||||
display_name="Save Image (to Folder) (DEPRECATED)",
|
||||
category="image",
|
||||
description="Save a dataset of images to a specified folder. Supported formats: PNG.",
|
||||
is_experimental=True,
|
||||
is_output_node=True,
|
||||
is_input_list=True, # Receive images as list
|
||||
@@ -226,6 +232,7 @@ class SaveImageDataSetToFolderNode(io.ComfyNode):
|
||||
),
|
||||
],
|
||||
outputs=[],
|
||||
is_deprecated=True, # This node is redundant and superseded by existing Save Image nodes where the target folder can be specified in the filename_prefix
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -246,14 +253,20 @@ class SaveImageTextDataSetToFolderNode(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="SaveImageTextDataSetToFolder",
|
||||
display_name="Save Image and Text Dataset to Folder",
|
||||
category="dataset",
|
||||
search_aliases=["save folder", "save to folder", "save dataset", "save images", "save text", "export dataset"],
|
||||
display_name="Save Image-Text (to Folder)",
|
||||
category="image",
|
||||
description="Save a dataset of pairs of images and text captions to a specified folder. Images are saved as PNG files and captions are saved as TXT files with the same filename_prefix.",
|
||||
is_experimental=True,
|
||||
is_output_node=True,
|
||||
is_input_list=True, # Receive both images and texts as lists
|
||||
inputs=[
|
||||
io.Image.Input("images", tooltip="List of images to save."),
|
||||
io.String.Input("texts", tooltip="List of text captions to save."),
|
||||
io.String.Input("texts",
|
||||
optional=True,
|
||||
force_input=True,
|
||||
tooltip="List of text captions to save."
|
||||
),
|
||||
io.String.Input(
|
||||
"folder_name",
|
||||
default="dataset",
|
||||
@@ -270,7 +283,7 @@ class SaveImageTextDataSetToFolderNode(io.ComfyNode):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, images, texts, folder_name, filename_prefix):
|
||||
def execute(cls, images, folder_name, filename_prefix, texts=None):
|
||||
# Extract scalar values
|
||||
folder_name = folder_name[0]
|
||||
filename_prefix = filename_prefix[0]
|
||||
@@ -279,11 +292,12 @@ class SaveImageTextDataSetToFolderNode(io.ComfyNode):
|
||||
saved_files = save_images_to_folder(images, output_dir, filename_prefix)
|
||||
|
||||
# Save captions
|
||||
for idx, (filename, caption) in enumerate(zip(saved_files, texts)):
|
||||
caption_filename = filename.replace(".png", ".txt")
|
||||
caption_path = os.path.join(output_dir, caption_filename)
|
||||
with open(caption_path, "w", encoding="utf-8") as f:
|
||||
f.write(caption)
|
||||
if texts:
|
||||
for idx, (filename, caption) in enumerate(zip(saved_files, texts)):
|
||||
caption_filename = filename.replace(".png", ".txt")
|
||||
caption_path = os.path.join(output_dir, caption_filename)
|
||||
with open(caption_path, "w", encoding="utf-8") as f:
|
||||
f.write(caption)
|
||||
|
||||
logging.info(f"Saved {len(saved_files)} images and captions to {output_dir}.")
|
||||
return io.NodeOutput()
|
||||
@@ -314,11 +328,13 @@ class ImageProcessingNode(io.ComfyNode):
|
||||
|
||||
Child classes should set:
|
||||
node_id: Unique node identifier (required)
|
||||
search_aliases: List of search aliases (optional)
|
||||
display_name: Display name (optional, defaults to node_id)
|
||||
description: Node description (optional)
|
||||
extra_inputs: List of additional io.Input objects beyond "images" (optional)
|
||||
is_group_process: None (auto-detect), True (group), or False (individual) (optional)
|
||||
is_output_list: True (list output) or False (single output) (optional, default True)
|
||||
is_deprecated: True if the node is deprecated (optional, default False)
|
||||
|
||||
Child classes must implement ONE of:
|
||||
_process(cls, image, **kwargs) -> tensor (for single-item processing)
|
||||
@@ -326,12 +342,13 @@ class ImageProcessingNode(io.ComfyNode):
|
||||
"""
|
||||
|
||||
node_id = None
|
||||
search_aliases = []
|
||||
display_name = None
|
||||
description = None
|
||||
extra_inputs = []
|
||||
is_group_process = None # None = auto-detect, True/False = explicit
|
||||
is_output_list = None # None = auto-detect based on processing mode
|
||||
|
||||
is_deprecated = False
|
||||
@classmethod
|
||||
def _detect_processing_mode(cls):
|
||||
"""Detect whether this node uses group or individual processing.
|
||||
@@ -402,8 +419,10 @@ class ImageProcessingNode(io.ComfyNode):
|
||||
|
||||
return io.Schema(
|
||||
node_id=cls.node_id,
|
||||
search_aliases=cls.search_aliases,
|
||||
display_name=cls.display_name or cls.node_id,
|
||||
category="dataset/image",
|
||||
category=cls.category,
|
||||
description=cls.description,
|
||||
is_experimental=True,
|
||||
is_input_list=is_group, # True for group, False for individual
|
||||
inputs=inputs,
|
||||
@@ -472,11 +491,13 @@ class TextProcessingNode(io.ComfyNode):
|
||||
|
||||
Child classes should set:
|
||||
node_id: Unique node identifier (required)
|
||||
search_aliases: List of search aliases (optional)
|
||||
display_name: Display name (optional, defaults to node_id)
|
||||
description: Node description (optional)
|
||||
extra_inputs: List of additional io.Input objects beyond "texts" (optional)
|
||||
is_group_process: None (auto-detect), True (group), or False (individual) (optional)
|
||||
is_output_list: True (list output) or False (single output) (optional, default True)
|
||||
is_deprecated: True if the node is deprecated (optional, default False)
|
||||
|
||||
Child classes must implement ONE of:
|
||||
_process(cls, text, **kwargs) -> str (for single-item processing)
|
||||
@@ -484,12 +505,13 @@ class TextProcessingNode(io.ComfyNode):
|
||||
"""
|
||||
|
||||
node_id = None
|
||||
search_aliases = []
|
||||
display_name = None
|
||||
description = None
|
||||
extra_inputs = []
|
||||
is_group_process = None # None = auto-detect, True/False = explicit
|
||||
is_output_list = None # None = auto-detect based on processing mode
|
||||
|
||||
is_deprecated = False
|
||||
@classmethod
|
||||
def _detect_processing_mode(cls):
|
||||
"""Detect whether this node uses group or individual processing.
|
||||
@@ -627,15 +649,17 @@ class TextProcessingNode(io.ComfyNode):
|
||||
|
||||
class ResizeImagesByShorterEdgeNode(ImageProcessingNode):
|
||||
node_id = "ResizeImagesByShorterEdge"
|
||||
display_name = "Resize Images by Shorter Edge"
|
||||
description = "Resize images so that the shorter edge matches the specified length while preserving aspect ratio."
|
||||
display_name = "Resize Images by Shorter Edge (DEPRECATED)"
|
||||
category = "image/transform"
|
||||
description = "Resize images so that the shorter edge matches the specified dimension while preserving aspect ratio."
|
||||
is_deprecated = True # This node is superseded by Resize Image/Mask with resize_type = scale shorter dimension
|
||||
extra_inputs = [
|
||||
io.Int.Input(
|
||||
"shorter_edge",
|
||||
default=512,
|
||||
min=1,
|
||||
max=8192,
|
||||
tooltip="Target length for the shorter edge.",
|
||||
tooltip="Target dimension for the shorter edge.",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -655,15 +679,17 @@ class ResizeImagesByShorterEdgeNode(ImageProcessingNode):
|
||||
|
||||
class ResizeImagesByLongerEdgeNode(ImageProcessingNode):
|
||||
node_id = "ResizeImagesByLongerEdge"
|
||||
display_name = "Resize Images by Longer Edge"
|
||||
description = "Resize images so that the longer edge matches the specified length while preserving aspect ratio."
|
||||
display_name = "Resize Images by Longer Edge (DEPRECATED)"
|
||||
category = "image/transform"
|
||||
description = "Resize images so that the longer edge matches the specified dimension while preserving aspect ratio."
|
||||
is_deprecated = True # This node is superseded by Resize Image/Mask with resize_type = scale longer dimension
|
||||
extra_inputs = [
|
||||
io.Int.Input(
|
||||
"longer_edge",
|
||||
default=1024,
|
||||
min=1,
|
||||
max=8192,
|
||||
tooltip="Target length for the longer edge.",
|
||||
tooltip="Target dimension for the longer edge.",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -686,8 +712,10 @@ class ResizeImagesByLongerEdgeNode(ImageProcessingNode):
|
||||
|
||||
class CenterCropImagesNode(ImageProcessingNode):
|
||||
node_id = "CenterCropImages"
|
||||
display_name = "Center Crop Images"
|
||||
description = "Center crop all images to the specified dimensions."
|
||||
search_aliases=["crop", "cut", "trim"]
|
||||
display_name="Crop Image (Center)"
|
||||
category="image/transform"
|
||||
description = "Center crop an image to the specified dimensions."
|
||||
extra_inputs = [
|
||||
io.Int.Input("width", default=512, min=1, max=8192, tooltip="Crop width."),
|
||||
io.Int.Input("height", default=512, min=1, max=8192, tooltip="Crop height."),
|
||||
@@ -706,10 +734,11 @@ class CenterCropImagesNode(ImageProcessingNode):
|
||||
|
||||
class RandomCropImagesNode(ImageProcessingNode):
|
||||
node_id = "RandomCropImages"
|
||||
display_name = "Random Crop Images"
|
||||
description = (
|
||||
"Randomly crop all images to the specified dimensions (for data augmentation)."
|
||||
)
|
||||
search_aliases=["crop", "cut", "trim"]
|
||||
display_name = "Crop Image (Random)"
|
||||
category="image/transform"
|
||||
description = "Randomly crop an image to the specified dimensions."
|
||||
|
||||
extra_inputs = [
|
||||
io.Int.Input("width", default=512, min=1, max=8192, tooltip="Crop width."),
|
||||
io.Int.Input("height", default=512, min=1, max=8192, tooltip="Crop height."),
|
||||
@@ -734,7 +763,9 @@ class RandomCropImagesNode(ImageProcessingNode):
|
||||
|
||||
class NormalizeImagesNode(ImageProcessingNode):
|
||||
node_id = "NormalizeImages"
|
||||
display_name = "Normalize Images"
|
||||
search_aliases=["normalize", "normalize colors"]
|
||||
display_name = "Normalize Image Colors"
|
||||
category = "image/color"
|
||||
description = "Normalize images using mean and standard deviation."
|
||||
extra_inputs = [
|
||||
io.Float.Input(
|
||||
@@ -762,8 +793,10 @@ class NormalizeImagesNode(ImageProcessingNode):
|
||||
|
||||
class AdjustBrightnessNode(ImageProcessingNode):
|
||||
node_id = "AdjustBrightness"
|
||||
search_aliases=["brightness"]
|
||||
display_name = "Adjust Brightness"
|
||||
description = "Adjust brightness of all images."
|
||||
category="image/adjustments"
|
||||
description = "Adjust the brightness of an image."
|
||||
extra_inputs = [
|
||||
io.Float.Input(
|
||||
"factor",
|
||||
@@ -781,8 +814,10 @@ class AdjustBrightnessNode(ImageProcessingNode):
|
||||
|
||||
class AdjustContrastNode(ImageProcessingNode):
|
||||
node_id = "AdjustContrast"
|
||||
search_aliases=["contrast"]
|
||||
display_name = "Adjust Contrast"
|
||||
description = "Adjust contrast of all images."
|
||||
category="image/adjustments"
|
||||
description = "Adjust the contrast of an image."
|
||||
extra_inputs = [
|
||||
io.Float.Input(
|
||||
"factor",
|
||||
@@ -800,8 +835,10 @@ class AdjustContrastNode(ImageProcessingNode):
|
||||
|
||||
class ShuffleDatasetNode(ImageProcessingNode):
|
||||
node_id = "ShuffleDataset"
|
||||
display_name = "Shuffle Image Dataset"
|
||||
description = "Randomly shuffle the order of images in the dataset."
|
||||
search_aliases=["shuffle", "randomize", "mix"]
|
||||
display_name = "Shuffle Images List"
|
||||
category = "image/batch"
|
||||
description = "Randomly shuffle the order of images in a list."
|
||||
is_group_process = True # Requires full list to shuffle
|
||||
extra_inputs = [
|
||||
io.Int.Input(
|
||||
@@ -823,13 +860,15 @@ class ShuffleImageTextDatasetNode(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="ShuffleImageTextDataset",
|
||||
display_name="Shuffle Image-Text Dataset",
|
||||
category="dataset/image",
|
||||
search_aliases=["shuffle", "randomize", "mix"],
|
||||
display_name = "Shuffle Pairs of Image-Text",
|
||||
category = "image/batch",
|
||||
description = "Randomly shuffle the order of pairs of image-text in a list.",
|
||||
is_experimental=True,
|
||||
is_input_list=True,
|
||||
inputs=[
|
||||
io.Image.Input("images", tooltip="List of images to shuffle."),
|
||||
io.String.Input("texts", tooltip="List of texts to shuffle."),
|
||||
io.String.Input("texts", tooltip="List of texts to shuffle.", force_input=True),
|
||||
io.Int.Input(
|
||||
"seed",
|
||||
default=0,
|
||||
@@ -865,8 +904,11 @@ class ShuffleImageTextDatasetNode(io.ComfyNode):
|
||||
|
||||
class TextToLowercaseNode(TextProcessingNode):
|
||||
node_id = "TextToLowercase"
|
||||
display_name = "Text to Lowercase"
|
||||
description = "Convert all texts to lowercase."
|
||||
search_aliases=["lowercase"]
|
||||
display_name = "Convert Text to Lowercase (DEPRECATED)"
|
||||
category = "text"
|
||||
description = "Convert text to lowercase."
|
||||
is_deprecated = True # This node is superseded by the Convert Text Case node
|
||||
|
||||
@classmethod
|
||||
def _process(cls, text):
|
||||
@@ -875,8 +917,11 @@ class TextToLowercaseNode(TextProcessingNode):
|
||||
|
||||
class TextToUppercaseNode(TextProcessingNode):
|
||||
node_id = "TextToUppercase"
|
||||
display_name = "Text to Uppercase"
|
||||
description = "Convert all texts to uppercase."
|
||||
search_aliases=["uppercase"]
|
||||
display_name = "Convert Text to Uppercase (DEPRECATED)"
|
||||
category = "text"
|
||||
description = "Convert text to uppercase."
|
||||
is_deprecated = True # This node is superseded by the Convert Text Case node
|
||||
|
||||
@classmethod
|
||||
def _process(cls, text):
|
||||
@@ -885,8 +930,10 @@ class TextToUppercaseNode(TextProcessingNode):
|
||||
|
||||
class TruncateTextNode(TextProcessingNode):
|
||||
node_id = "TruncateText"
|
||||
search_aliases=["truncate", "cut", "shorten"]
|
||||
display_name = "Truncate Text"
|
||||
description = "Truncate all texts to a maximum length."
|
||||
category = "text"
|
||||
description = "Truncate text to a maximum length."
|
||||
extra_inputs = [
|
||||
io.Int.Input(
|
||||
"max_length", default=77, min=1, max=10000, tooltip="Maximum text length."
|
||||
@@ -900,8 +947,10 @@ class TruncateTextNode(TextProcessingNode):
|
||||
|
||||
class AddTextPrefixNode(TextProcessingNode):
|
||||
node_id = "AddTextPrefix"
|
||||
display_name = "Add Text Prefix"
|
||||
display_name = "Add Text Prefix (DEPRECATED)"
|
||||
category = "text"
|
||||
description = "Add a prefix to all texts."
|
||||
is_deprecated = True # This node is superseded by the Concatenate Text node
|
||||
extra_inputs = [
|
||||
io.String.Input("prefix", default="", tooltip="Prefix to add."),
|
||||
]
|
||||
@@ -913,8 +962,10 @@ class AddTextPrefixNode(TextProcessingNode):
|
||||
|
||||
class AddTextSuffixNode(TextProcessingNode):
|
||||
node_id = "AddTextSuffix"
|
||||
display_name = "Add Text Suffix"
|
||||
display_name = "Add Text Suffix (DEPRECATED)"
|
||||
category = "text"
|
||||
description = "Add a suffix to all texts."
|
||||
is_deprecated = True # This node is superseded by the Concatenate Text node
|
||||
extra_inputs = [
|
||||
io.String.Input("suffix", default="", tooltip="Suffix to add."),
|
||||
]
|
||||
@@ -926,8 +977,10 @@ class AddTextSuffixNode(TextProcessingNode):
|
||||
|
||||
class ReplaceTextNode(TextProcessingNode):
|
||||
node_id = "ReplaceText"
|
||||
display_name = "Replace Text"
|
||||
display_name = "Replace Text (DEPRECATED)"
|
||||
category = "text"
|
||||
description = "Replace text in all texts."
|
||||
is_deprecated = True # This node is superseded by the other Replace Text node
|
||||
extra_inputs = [
|
||||
io.String.Input("find", default="", tooltip="Text to find."),
|
||||
io.String.Input("replace", default="", tooltip="Text to replace with."),
|
||||
@@ -940,8 +993,10 @@ class ReplaceTextNode(TextProcessingNode):
|
||||
|
||||
class StripWhitespaceNode(TextProcessingNode):
|
||||
node_id = "StripWhitespace"
|
||||
display_name = "Strip Whitespace"
|
||||
display_name = "Strip Whitespace (DEPRECATED)"
|
||||
category = "text"
|
||||
description = "Strip leading and trailing whitespace from all texts."
|
||||
is_deprecated = True # This node is superseded by the Trim Text node
|
||||
|
||||
@classmethod
|
||||
def _process(cls, text):
|
||||
@@ -952,11 +1007,13 @@ class StripWhitespaceNode(TextProcessingNode):
|
||||
|
||||
|
||||
class ImageDeduplicationNode(ImageProcessingNode):
|
||||
"""Remove duplicate or very similar images from the dataset using perceptual hashing."""
|
||||
"""Remove duplicate or very similar images from a list using perceptual hashing."""
|
||||
|
||||
node_id = "ImageDeduplication"
|
||||
display_name = "Image Deduplication"
|
||||
description = "Remove duplicate or very similar images from the dataset."
|
||||
search_aliases=["deduplicate", "remove duplicates", "similarity filter"]
|
||||
display_name = "Deduplicate Images"
|
||||
category = "image/batch"
|
||||
description = "Remove duplicate or very similar images from a list."
|
||||
is_group_process = True # Requires full list to compare images
|
||||
extra_inputs = [
|
||||
io.Float.Input(
|
||||
@@ -1026,7 +1083,9 @@ class ImageGridNode(ImageProcessingNode):
|
||||
"""Combine multiple images into a single grid/collage."""
|
||||
|
||||
node_id = "ImageGrid"
|
||||
display_name = "Image Grid"
|
||||
search_aliases=["grid", "collage", "combine"]
|
||||
display_name = "Make Image Grid"
|
||||
category="image/batch"
|
||||
description = "Arrange multiple images into a grid layout."
|
||||
is_group_process = True # Requires full list to create grid
|
||||
is_output_list = False # Outputs single grid image
|
||||
@@ -1102,9 +1161,12 @@ class MergeImageListsNode(ImageProcessingNode):
|
||||
"""Merge multiple image lists into a single list."""
|
||||
|
||||
node_id = "MergeImageLists"
|
||||
display_name = "Merge Image Lists"
|
||||
search_aliases=["list", "merge list", "make list"]
|
||||
display_name = "Merge Image Lists (DEPRECATED)"
|
||||
category = "image/batch"
|
||||
description = "Concatenate multiple image lists into one."
|
||||
is_group_process = True # Receives images as list
|
||||
is_deprecated = True # This node is superseded by the Create List node
|
||||
|
||||
@classmethod
|
||||
def _group_process(cls, images):
|
||||
@@ -1119,9 +1181,11 @@ class MergeTextListsNode(TextProcessingNode):
|
||||
"""Merge multiple text lists into a single list."""
|
||||
|
||||
node_id = "MergeTextLists"
|
||||
display_name = "Merge Text Lists"
|
||||
display_name = "Merge Text Lists (DEPRECATED)"
|
||||
category = "text"
|
||||
description = "Concatenate multiple text lists into one."
|
||||
is_group_process = True # Receives texts as list
|
||||
is_deprecated = True # This node is superseded by the Create List node
|
||||
|
||||
@classmethod
|
||||
def _group_process(cls, texts):
|
||||
@@ -1142,8 +1206,10 @@ class ResolutionBucket(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="ResolutionBucket",
|
||||
search_aliases=["bucket by resolution", "group by resolution", "batch by resolution"],
|
||||
display_name="Resolution Bucket",
|
||||
category="dataset",
|
||||
category="training",
|
||||
description="Group latents and conditionings into buckets",
|
||||
is_experimental=True,
|
||||
is_input_list=True,
|
||||
inputs=[
|
||||
@@ -1236,7 +1302,8 @@ class MakeTrainingDataset(io.ComfyNode):
|
||||
node_id="MakeTrainingDataset",
|
||||
search_aliases=["encode dataset"],
|
||||
display_name="Make Training Dataset",
|
||||
category="dataset",
|
||||
category="training",
|
||||
description="Encode images with VAE and texts with CLIP to create a training dataset of latents and conditionings.",
|
||||
is_experimental=True,
|
||||
is_input_list=True, # images and texts as lists
|
||||
inputs=[
|
||||
@@ -1251,6 +1318,7 @@ class MakeTrainingDataset(io.ComfyNode):
|
||||
"texts",
|
||||
optional=True,
|
||||
tooltip="List of text captions. Can be length n (matching images), 1 (repeated for all), or omitted (uses empty string).",
|
||||
force_input=True
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
@@ -1320,9 +1388,10 @@ class SaveTrainingDataset(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="SaveTrainingDataset",
|
||||
search_aliases=["export training data"],
|
||||
search_aliases=["export dataset", "save dataset"],
|
||||
display_name="Save Training Dataset",
|
||||
category="dataset",
|
||||
category="training",
|
||||
description="Save encoded training dataset (latents + conditioning) to disk for efficient loading during training.",
|
||||
is_experimental=True,
|
||||
is_output_node=True,
|
||||
is_input_list=True, # Receive lists
|
||||
@@ -1424,7 +1493,8 @@ class LoadTrainingDataset(io.ComfyNode):
|
||||
node_id="LoadTrainingDataset",
|
||||
search_aliases=["import dataset", "training data"],
|
||||
display_name="Load Training Dataset",
|
||||
category="dataset",
|
||||
category="training",
|
||||
description="Load encoded training dataset (latents + conditioning) from disk for use in training.",
|
||||
is_experimental=True,
|
||||
inputs=[
|
||||
io.String.Input(
|
||||
|
||||
@@ -215,7 +215,7 @@ class Flux2Scheduler(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="Flux2Scheduler",
|
||||
category="sampling/custom_sampling/schedulers",
|
||||
category="sampling/schedulers",
|
||||
inputs=[
|
||||
io.Int.Input("steps", default=20, min=1, max=4096),
|
||||
io.Int.Input("width", default=1024, min=16, max=nodes.MAX_RESOLUTION, step=1),
|
||||
@@ -263,7 +263,7 @@ class FluxKVCache(io.ComfyNode):
|
||||
node_id="FluxKVCache",
|
||||
display_name="Flux KV Cache",
|
||||
description="Enables KV Cache optimization for reference images on Flux family models.",
|
||||
category="",
|
||||
category="experimental",
|
||||
is_experimental=True,
|
||||
inputs=[
|
||||
io.Model.Input("model", tooltip="The model to use KV Cache on."),
|
||||
|
||||
@@ -340,7 +340,7 @@ class GITSScheduler(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="GITSScheduler",
|
||||
category="sampling/custom_sampling/schedulers",
|
||||
category="sampling/schedulers",
|
||||
inputs=[
|
||||
io.Float.Input("coeff", default=1.20, min=0.80, max=1.50, step=0.05, advanced=True),
|
||||
io.Int.Input("steps", default=10, min=2, max=1000),
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
from typing_extensions import override
|
||||
|
||||
import torch
|
||||
|
||||
import comfy.model_management
|
||||
import comfy.patcher_extension
|
||||
import node_helpers
|
||||
from comfy_api.latest import ComfyExtension, io
|
||||
|
||||
|
||||
class EmptyHiDreamO1LatentImage(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(
|
||||
node_id="EmptyHiDreamO1LatentImage",
|
||||
display_name="Empty HiDream-O1 Latent Image",
|
||||
category="latent/image",
|
||||
description=(
|
||||
"Empty pixel-space latent for HiDream-O1-Image. The model was "
|
||||
"trained at ~4 megapixels; lower resolutions go off-distribution "
|
||||
"and quality regresses noticeably. Trained resolutions: "
|
||||
"2048x2048, 2304x1728, 1728x2304, 2560x1440, 1440x2560, "
|
||||
"2496x1664, 1664x2496, 3104x1312, 1312x3104, 2304x1792, 1792x2304."
|
||||
),
|
||||
inputs=[
|
||||
io.Int.Input(id="width", default=2048, min=64, max=4096, step=32),
|
||||
io.Int.Input(id="height", default=2048, min=64, max=4096, step=32),
|
||||
io.Int.Input(id="batch_size", default=1, min=1, max=64),
|
||||
],
|
||||
outputs=[io.Latent().Output()],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, *, width: int, height: int, batch_size: int = 1) -> io.NodeOutput:
|
||||
latent = torch.zeros(
|
||||
(batch_size, 3, height, width),
|
||||
device=comfy.model_management.intermediate_device(),
|
||||
)
|
||||
return io.NodeOutput({"samples": latent})
|
||||
|
||||
|
||||
class HiDreamO1ReferenceImages(io.ComfyNode):
|
||||
"""Attach reference images to both positive and negative conditioning."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(
|
||||
node_id="HiDreamO1ReferenceImages",
|
||||
display_name="HiDream-O1 Reference Images",
|
||||
category="conditioning/image",
|
||||
description=(
|
||||
"Attach 1-10 reference images to conditioning, one for edit instruction"
|
||||
"or multiple for subject-driven personalization."
|
||||
),
|
||||
inputs=[
|
||||
io.Conditioning.Input(id="positive"),
|
||||
io.Conditioning.Input(id="negative"),
|
||||
io.Autogrow.Input(
|
||||
"images",
|
||||
template=io.Autogrow.TemplateNames(
|
||||
io.Image.Input("image"),
|
||||
names=[f"image_{i}" for i in range(1, 11)],
|
||||
min=1,
|
||||
),
|
||||
tooltip=("Reference images. 1 image = instruction edit; 2-10 images = multi reference."
|
||||
),
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
io.Conditioning.Output(display_name="positive"),
|
||||
io.Conditioning.Output(display_name="negative"),
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, *, positive, negative, images: io.Autogrow.Type) -> io.NodeOutput:
|
||||
refs = [images[f"image_{i}"] for i in range(1, 11) if f"image_{i}" in images]
|
||||
positive = node_helpers.conditioning_set_values(positive, {"reference_latents": refs}, append=True)
|
||||
negative = node_helpers.conditioning_set_values(negative, {"reference_latents": refs}, append=True)
|
||||
return io.NodeOutput(positive, negative)
|
||||
|
||||
|
||||
class HiDreamO1PatchSeamSmoothing(io.ComfyNode):
|
||||
PATCH_SIZE = 32
|
||||
EDGE_FEATHER = 4
|
||||
|
||||
# Shift presets per (pattern, N). 8-pass = 4-quadrant + 4 quarter-patch offsets.
|
||||
SHIFTS_BY_PATTERN = {
|
||||
("single_shift", 2): [(0, 0), (16, 16)],
|
||||
("single_shift", 4): [(0, 0), (16, 0), (0, 16), (16, 16)],
|
||||
("single_shift", 8): [(0, 0), (16, 0), (0, 16), (16, 16),
|
||||
(8, 8), (24, 8), (8, 24), (24, 24)],
|
||||
("symmetric", 2): [(-8, -8), (8, 8)],
|
||||
("symmetric", 4): [(-8, -8), (8, -8), (-8, 8), (8, 8)],
|
||||
("symmetric", 8): [(-12, -12), (4, -12), (-12, 4), (4, 4),
|
||||
(-4, -4), (12, -4), (-4, 12), (12, 12)],
|
||||
}
|
||||
RAMP_LEVELS = {
|
||||
"2": [2],
|
||||
"4": [4],
|
||||
"ramp_2_4": [2, 4],
|
||||
"ramp_2_4_8": [2, 4, 8],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _hann_tile(cy: int, cx: int, size: int = 32) -> torch.Tensor:
|
||||
"""size x size Hann tile peaking at (cy, cx) within a patch."""
|
||||
half = size // 2
|
||||
yy = torch.arange(size).view(size, 1)
|
||||
xx = torch.arange(size).view(1, size)
|
||||
dy = ((yy - cy + half) % size) - half
|
||||
dx = ((xx - cx + half) % size) - half
|
||||
return 0.25 * (1 + torch.cos(torch.pi * dy / half)) * (1 + torch.cos(torch.pi * dx / half))
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(
|
||||
node_id="HiDreamO1PatchSeamSmoothing",
|
||||
display_name="HiDream-O1 Patch Seam Smoothing",
|
||||
category="advanced/model",
|
||||
is_experimental=True,
|
||||
description=(
|
||||
"Average the model output across multiple shifted patch-grid "
|
||||
"positions during the late portion of sampling. Cancels seams."
|
||||
),
|
||||
inputs=[
|
||||
io.Model.Input(id="model"),
|
||||
io.Float.Input(id="start_percent", default=0.8, min=0.0, max=1.0, step=0.01,
|
||||
tooltip="Sampling progress (0=start, 1=end) at which the blend turns ON.",
|
||||
),
|
||||
io.Float.Input(id="end_percent", default=1.0, min=0.0, max=1.0, step=0.01,
|
||||
tooltip="Sampling progress at which the blend turns OFF.",
|
||||
),
|
||||
io.Combo.Input(
|
||||
id="pattern",
|
||||
options=["single_shift", "symmetric"],
|
||||
default="single_shift",
|
||||
tooltip="Shift layout. single_shift: one pass at the natural patch grid + others offset. symmetric: all passes off-grid, shifts split around origin.",
|
||||
),
|
||||
io.Combo.Input(
|
||||
id="passes",
|
||||
options=["2", "4", "ramp_2_4", "ramp_2_4_8"],
|
||||
default="2",
|
||||
tooltip="Number of passes per gated step. 2/4 = fixed. ramp_*: pass count increases as sampling approaches end (more smoothing where seams are most visible).",
|
||||
),
|
||||
io.Combo.Input(
|
||||
id="blend",
|
||||
options=["average", "window", "median"],
|
||||
default="average",
|
||||
tooltip="average: equal-weight mean. window: Hann-windowed weighting favoring each pass away from its patch boundaries. median: per-pixel median, rejects wraparound-outlier passes.",
|
||||
),
|
||||
io.Float.Input(id="strength", default=1.0, min=0.0, max=1.0, step=0.01,
|
||||
tooltip="Interpolation between the natural-grid pred (0) and the averaged result (1).",
|
||||
),
|
||||
],
|
||||
outputs=[io.Model.Output()],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, *, model, start_percent: float, end_percent: float, pattern: str, passes: str, blend: str, strength: float) -> io.NodeOutput:
|
||||
if strength <= 0.0 or end_percent <= start_percent:
|
||||
return io.NodeOutput(model)
|
||||
|
||||
P = cls.PATCH_SIZE
|
||||
half = P // 2
|
||||
shift_levels = [cls.SHIFTS_BY_PATTERN[(pattern, n)] for n in cls.RAMP_LEVELS[passes]]
|
||||
|
||||
if blend == "window":
|
||||
window_tile_levels = [
|
||||
torch.stack([cls._hann_tile((half - sy) % P, (half - sx) % P, P) for sy, sx in lst], dim=0)
|
||||
for lst in shift_levels
|
||||
]
|
||||
else:
|
||||
window_tile_levels = [None] * len(shift_levels)
|
||||
|
||||
m = model.clone()
|
||||
model_sampling = m.get_model_object("model_sampling")
|
||||
multiplier = float(model_sampling.multiplier)
|
||||
start_t = float(model_sampling.percent_to_sigma(start_percent)) * multiplier
|
||||
end_t = float(model_sampling.percent_to_sigma(end_percent)) * multiplier
|
||||
|
||||
edge_ramp_cache: dict = {}
|
||||
|
||||
def get_edge_ramp(H: int, W: int, device, dtype) -> torch.Tensor:
|
||||
key = (H, W, device, dtype)
|
||||
cached = edge_ramp_cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
feather = cls.EDGE_FEATHER
|
||||
ys = torch.minimum(torch.arange(H, device=device, dtype=torch.float32),
|
||||
(H - 1) - torch.arange(H, device=device, dtype=torch.float32))
|
||||
xs = torch.minimum(torch.arange(W, device=device, dtype=torch.float32),
|
||||
(W - 1) - torch.arange(W, device=device, dtype=torch.float32))
|
||||
y_mask = ((ys - P) / feather).clamp(0, 1)
|
||||
x_mask = ((xs - P) / feather).clamp(0, 1)
|
||||
ramp = (y_mask[:, None] * x_mask[None, :]).to(dtype)
|
||||
edge_ramp_cache[key] = ramp
|
||||
return ramp
|
||||
|
||||
def smoothing_wrapper(executor, *args, **kwargs):
|
||||
x = args[0]
|
||||
t = float(args[1][0])
|
||||
pred = executor(*args, **kwargs)
|
||||
if not (end_t <= t <= start_t):
|
||||
return pred
|
||||
# Pick shift-level by sigma phase across the gated range.
|
||||
if len(shift_levels) == 1:
|
||||
level_idx = 0
|
||||
else:
|
||||
phase = (start_t - t) / max(start_t - end_t, 1e-8)
|
||||
level_idx = min(int(phase * len(shift_levels)), len(shift_levels) - 1)
|
||||
shifts = shift_levels[level_idx]
|
||||
window_tiles = window_tile_levels[level_idx]
|
||||
|
||||
preds = []
|
||||
for sy, sx in shifts:
|
||||
if sy == 0 and sx == 0:
|
||||
preds.append(pred)
|
||||
continue
|
||||
x_rolled = torch.roll(x, shifts=(sy, sx), dims=(-2, -1))
|
||||
pred_rolled = executor(x_rolled, *args[1:], **kwargs)
|
||||
preds.append(torch.roll(pred_rolled, shifts=(-sy, -sx), dims=(-2, -1)))
|
||||
stacked = torch.stack(preds, dim=0) # (N, B, C, H, W)
|
||||
_, _, _, H, W = stacked.shape
|
||||
if blend == "window":
|
||||
N = stacked.shape[0]
|
||||
tiles = window_tiles.to(device=stacked.device, dtype=stacked.dtype)
|
||||
w = tiles.repeat(1, H // P, W // P)[:, :H, :W]
|
||||
sum_w = w.sum(dim=0, keepdim=True)
|
||||
w = torch.where(sum_w < 1e-3, torch.full_like(w, 1.0 / N), w / sum_w.clamp(min=1e-8))
|
||||
avg = (stacked * w[:, None, None, :, :]).sum(dim=0)
|
||||
elif blend == "median":
|
||||
avg = torch.median(stacked, dim=0).values
|
||||
else:
|
||||
avg = stacked.mean(dim=0)
|
||||
|
||||
# Mask out the P-px wraparound contamination strip at each edge.
|
||||
mask = get_edge_ramp(H, W, pred.device, pred.dtype)
|
||||
return pred * (1.0 - mask * strength) + avg * (mask * strength)
|
||||
|
||||
m.add_wrapper_with_key(comfy.patcher_extension.WrappersMP.DIFFUSION_MODEL, "hidream_o1_patch_seam_smoothing", smoothing_wrapper)
|
||||
return io.NodeOutput(m)
|
||||
|
||||
|
||||
class HiDreamO1Extension(ComfyExtension):
|
||||
@override
|
||||
async def get_node_list(self) -> list[type[io.ComfyNode]]:
|
||||
return [
|
||||
EmptyHiDreamO1LatentImage,
|
||||
HiDreamO1ReferenceImages,
|
||||
HiDreamO1PatchSeamSmoothing,
|
||||
]
|
||||
|
||||
|
||||
async def comfy_entrypoint() -> HiDreamO1Extension:
|
||||
return HiDreamO1Extension()
|
||||
+13
-207
@@ -1,12 +1,7 @@
|
||||
import torch
|
||||
import os
|
||||
import json
|
||||
import struct
|
||||
import numpy as np
|
||||
from comfy.ldm.modules.diffusionmodules.mmdit import get_1d_sincos_pos_embed_from_grid_torch
|
||||
import folder_paths
|
||||
import comfy.model_management
|
||||
from comfy.cli_args import args
|
||||
from comfy_extras.nodes_save_3d import pack_variable_mesh_batch
|
||||
from typing_extensions import override
|
||||
from comfy_api.latest import ComfyExtension, IO, Types
|
||||
from comfy_api.latest._util import MESH, VOXEL # only for backward compatibility if someone import it from this file (will be removed later) # noqa
|
||||
@@ -424,15 +419,17 @@ class VoxelToMeshBasic(IO.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="VoxelToMeshBasic",
|
||||
display_name="Voxel to Mesh (Basic)",
|
||||
display_name="Voxel to Mesh (Basic) (DEPRECATED)",
|
||||
category="3d",
|
||||
description="Converts a voxel grid to a mesh.",
|
||||
is_deprecated=True, # This node is superseded by the Voxel To Mesh node
|
||||
inputs=[
|
||||
IO.Voxel.Input("voxel"),
|
||||
IO.Float.Input("threshold", default=0.6, min=-1.0, max=1.0, step=0.01),
|
||||
],
|
||||
outputs=[
|
||||
IO.Mesh.Output(),
|
||||
]
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -444,7 +441,9 @@ class VoxelToMeshBasic(IO.ComfyNode):
|
||||
vertices.append(v)
|
||||
faces.append(f)
|
||||
|
||||
return IO.NodeOutput(Types.MESH(torch.stack(vertices), torch.stack(faces)))
|
||||
if vertices and all(v.shape == vertices[0].shape for v in vertices) and all(f.shape == faces[0].shape for f in faces):
|
||||
return IO.NodeOutput(Types.MESH(torch.stack(vertices), torch.stack(faces)))
|
||||
return IO.NodeOutput(pack_variable_mesh_batch(vertices, faces))
|
||||
|
||||
decode = execute # TODO: remove
|
||||
|
||||
@@ -456,9 +455,10 @@ class VoxelToMesh(IO.ComfyNode):
|
||||
node_id="VoxelToMesh",
|
||||
display_name="Voxel to Mesh",
|
||||
category="3d",
|
||||
description="Converts a voxel grid to a mesh.",
|
||||
inputs=[
|
||||
IO.Voxel.Input("voxel"),
|
||||
IO.Combo.Input("algorithm", options=["surface net", "basic"], advanced=True),
|
||||
IO.Combo.Input("algorithm", options=["surface net", "basic"]),
|
||||
IO.Float.Input("threshold", default=0.6, min=-1.0, max=1.0, step=0.01),
|
||||
],
|
||||
outputs=[
|
||||
@@ -481,206 +481,13 @@ class VoxelToMesh(IO.ComfyNode):
|
||||
vertices.append(v)
|
||||
faces.append(f)
|
||||
|
||||
return IO.NodeOutput(Types.MESH(torch.stack(vertices), torch.stack(faces)))
|
||||
if vertices and all(v.shape == vertices[0].shape for v in vertices) and all(f.shape == faces[0].shape for f in faces):
|
||||
return IO.NodeOutput(Types.MESH(torch.stack(vertices), torch.stack(faces)))
|
||||
return IO.NodeOutput(pack_variable_mesh_batch(vertices, faces))
|
||||
|
||||
decode = execute # TODO: remove
|
||||
|
||||
|
||||
def save_glb(vertices, faces, filepath, metadata=None):
|
||||
"""
|
||||
Save PyTorch tensor vertices and faces as a GLB file without external dependencies.
|
||||
|
||||
Parameters:
|
||||
vertices: torch.Tensor of shape (N, 3) - The vertex coordinates
|
||||
faces: torch.Tensor of shape (M, 3) - The face indices (triangle faces)
|
||||
filepath: str - Output filepath (should end with .glb)
|
||||
"""
|
||||
|
||||
# Convert tensors to numpy arrays
|
||||
vertices_np = vertices.cpu().numpy().astype(np.float32)
|
||||
faces_np = faces.cpu().numpy().astype(np.uint32)
|
||||
|
||||
vertices_buffer = vertices_np.tobytes()
|
||||
indices_buffer = faces_np.tobytes()
|
||||
|
||||
def pad_to_4_bytes(buffer):
|
||||
padding_length = (4 - (len(buffer) % 4)) % 4
|
||||
return buffer + b'\x00' * padding_length
|
||||
|
||||
vertices_buffer_padded = pad_to_4_bytes(vertices_buffer)
|
||||
indices_buffer_padded = pad_to_4_bytes(indices_buffer)
|
||||
|
||||
buffer_data = vertices_buffer_padded + indices_buffer_padded
|
||||
|
||||
vertices_byte_length = len(vertices_buffer)
|
||||
vertices_byte_offset = 0
|
||||
indices_byte_length = len(indices_buffer)
|
||||
indices_byte_offset = len(vertices_buffer_padded)
|
||||
|
||||
gltf = {
|
||||
"asset": {"version": "2.0", "generator": "ComfyUI"},
|
||||
"buffers": [
|
||||
{
|
||||
"byteLength": len(buffer_data)
|
||||
}
|
||||
],
|
||||
"bufferViews": [
|
||||
{
|
||||
"buffer": 0,
|
||||
"byteOffset": vertices_byte_offset,
|
||||
"byteLength": vertices_byte_length,
|
||||
"target": 34962 # ARRAY_BUFFER
|
||||
},
|
||||
{
|
||||
"buffer": 0,
|
||||
"byteOffset": indices_byte_offset,
|
||||
"byteLength": indices_byte_length,
|
||||
"target": 34963 # ELEMENT_ARRAY_BUFFER
|
||||
}
|
||||
],
|
||||
"accessors": [
|
||||
{
|
||||
"bufferView": 0,
|
||||
"byteOffset": 0,
|
||||
"componentType": 5126, # FLOAT
|
||||
"count": len(vertices_np),
|
||||
"type": "VEC3",
|
||||
"max": vertices_np.max(axis=0).tolist(),
|
||||
"min": vertices_np.min(axis=0).tolist()
|
||||
},
|
||||
{
|
||||
"bufferView": 1,
|
||||
"byteOffset": 0,
|
||||
"componentType": 5125, # UNSIGNED_INT
|
||||
"count": faces_np.size,
|
||||
"type": "SCALAR"
|
||||
}
|
||||
],
|
||||
"meshes": [
|
||||
{
|
||||
"primitives": [
|
||||
{
|
||||
"attributes": {
|
||||
"POSITION": 0
|
||||
},
|
||||
"indices": 1,
|
||||
"mode": 4 # TRIANGLES
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"nodes": [
|
||||
{
|
||||
"mesh": 0
|
||||
}
|
||||
],
|
||||
"scenes": [
|
||||
{
|
||||
"nodes": [0]
|
||||
}
|
||||
],
|
||||
"scene": 0
|
||||
}
|
||||
|
||||
if metadata is not None:
|
||||
gltf["asset"]["extras"] = metadata
|
||||
|
||||
# Convert the JSON to bytes
|
||||
gltf_json = json.dumps(gltf).encode('utf8')
|
||||
|
||||
def pad_json_to_4_bytes(buffer):
|
||||
padding_length = (4 - (len(buffer) % 4)) % 4
|
||||
return buffer + b' ' * padding_length
|
||||
|
||||
gltf_json_padded = pad_json_to_4_bytes(gltf_json)
|
||||
|
||||
# Create the GLB header
|
||||
# Magic glTF
|
||||
glb_header = struct.pack('<4sII', b'glTF', 2, 12 + 8 + len(gltf_json_padded) + 8 + len(buffer_data))
|
||||
|
||||
# Create JSON chunk header (chunk type 0)
|
||||
json_chunk_header = struct.pack('<II', len(gltf_json_padded), 0x4E4F534A) # "JSON" in little endian
|
||||
|
||||
# Create BIN chunk header (chunk type 1)
|
||||
bin_chunk_header = struct.pack('<II', len(buffer_data), 0x004E4942) # "BIN\0" in little endian
|
||||
|
||||
# Write the GLB file
|
||||
with open(filepath, 'wb') as f:
|
||||
f.write(glb_header)
|
||||
f.write(json_chunk_header)
|
||||
f.write(gltf_json_padded)
|
||||
f.write(bin_chunk_header)
|
||||
f.write(buffer_data)
|
||||
|
||||
return filepath
|
||||
|
||||
|
||||
class SaveGLB(IO.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="SaveGLB",
|
||||
display_name="Save 3D Model",
|
||||
search_aliases=["export 3d model", "save mesh"],
|
||||
category="3d",
|
||||
essentials_category="Basics",
|
||||
is_output_node=True,
|
||||
inputs=[
|
||||
IO.MultiType.Input(
|
||||
IO.Mesh.Input("mesh"),
|
||||
types=[
|
||||
IO.File3DGLB,
|
||||
IO.File3DGLTF,
|
||||
IO.File3DOBJ,
|
||||
IO.File3DFBX,
|
||||
IO.File3DSTL,
|
||||
IO.File3DUSDZ,
|
||||
IO.File3DAny,
|
||||
],
|
||||
tooltip="Mesh or 3D file to save",
|
||||
),
|
||||
IO.String.Input("filename_prefix", default="3d/ComfyUI"),
|
||||
],
|
||||
hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, mesh: Types.MESH | Types.File3D, filename_prefix: str) -> IO.NodeOutput:
|
||||
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(filename_prefix, folder_paths.get_output_directory())
|
||||
results = []
|
||||
|
||||
metadata = {}
|
||||
if not args.disable_metadata:
|
||||
if cls.hidden.prompt is not None:
|
||||
metadata["prompt"] = json.dumps(cls.hidden.prompt)
|
||||
if cls.hidden.extra_pnginfo is not None:
|
||||
for x in cls.hidden.extra_pnginfo:
|
||||
metadata[x] = json.dumps(cls.hidden.extra_pnginfo[x])
|
||||
|
||||
if isinstance(mesh, Types.File3D):
|
||||
# Handle File3D input - save BytesIO data to output folder
|
||||
ext = mesh.format or "glb"
|
||||
f = f"{filename}_{counter:05}_.{ext}"
|
||||
mesh.save_to(os.path.join(full_output_folder, f))
|
||||
results.append({
|
||||
"filename": f,
|
||||
"subfolder": subfolder,
|
||||
"type": "output"
|
||||
})
|
||||
else:
|
||||
# Handle Mesh input - save vertices and faces as GLB
|
||||
for i in range(mesh.vertices.shape[0]):
|
||||
f = f"{filename}_{counter:05}_.glb"
|
||||
save_glb(mesh.vertices[i], mesh.faces[i], os.path.join(full_output_folder, f), metadata)
|
||||
results.append({
|
||||
"filename": f,
|
||||
"subfolder": subfolder,
|
||||
"type": "output"
|
||||
})
|
||||
counter += 1
|
||||
return IO.NodeOutput(ui={"3d": results})
|
||||
|
||||
|
||||
class Hunyuan3dExtension(ComfyExtension):
|
||||
@override
|
||||
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
|
||||
@@ -691,7 +498,6 @@ class Hunyuan3dExtension(ComfyExtension):
|
||||
VAEDecodeHunyuan3D,
|
||||
VoxelToMeshBasic,
|
||||
VoxelToMesh,
|
||||
SaveGLB,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -55,9 +55,10 @@ class ImageCropV2(IO.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="ImageCropV2",
|
||||
search_aliases=["trim"],
|
||||
search_aliases=["crop", "cut", "trim"],
|
||||
display_name="Crop Image",
|
||||
category="image/transform",
|
||||
description = "Crop an image to the specified dimensions.",
|
||||
essentials_category="Image Tools",
|
||||
has_intermediate_output=True,
|
||||
inputs=[
|
||||
@@ -136,7 +137,7 @@ class ImageFromBatch(IO.ComfyNode):
|
||||
category="image/batch",
|
||||
inputs=[
|
||||
IO.Image.Input("image"),
|
||||
IO.Int.Input("batch_index", default=0, min=0, max=4095),
|
||||
IO.Int.Input("batch_index", default=0, min=-MAX_RESOLUTION, max=MAX_RESOLUTION),
|
||||
IO.Int.Input("length", default=1, min=1, max=4096),
|
||||
],
|
||||
outputs=[IO.Image.Output()],
|
||||
@@ -145,7 +146,9 @@ class ImageFromBatch(IO.ComfyNode):
|
||||
@classmethod
|
||||
def execute(cls, image, batch_index, length) -> IO.NodeOutput:
|
||||
s_in = image
|
||||
batch_index = min(s_in.shape[0] - 1, batch_index)
|
||||
if batch_index < 0:
|
||||
batch_index += s_in.shape[0]
|
||||
batch_index = max(0, min(s_in.shape[0] - 1, batch_index))
|
||||
length = min(s_in.shape[0] - batch_index, length)
|
||||
s = s_in[batch_index:batch_index + length].clone()
|
||||
return IO.NodeOutput(s)
|
||||
@@ -160,7 +163,7 @@ class ImageAddNoise(IO.ComfyNode):
|
||||
node_id="ImageAddNoise",
|
||||
search_aliases=["film grain"],
|
||||
display_name="Add Noise to Image",
|
||||
category="image/postprocessing",
|
||||
category="image/filters",
|
||||
inputs=[
|
||||
IO.Image.Input("image"),
|
||||
IO.Int.Input(
|
||||
@@ -192,7 +195,8 @@ class SaveAnimatedWEBP(IO.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="SaveAnimatedWEBP",
|
||||
category="image/animation",
|
||||
display_name="Save Animated WEBP",
|
||||
category="image",
|
||||
inputs=[
|
||||
IO.Image.Input("images"),
|
||||
IO.String.Input("filename_prefix", default="ComfyUI"),
|
||||
@@ -229,7 +233,8 @@ class SaveAnimatedPNG(IO.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="SaveAnimatedPNG",
|
||||
category="image/animation",
|
||||
display_name="Save Animated PNG",
|
||||
category="image",
|
||||
inputs=[
|
||||
IO.Image.Input("images"),
|
||||
IO.String.Input("filename_prefix", default="ComfyUI"),
|
||||
@@ -491,7 +496,7 @@ class SaveSVGNode(IO.ComfyNode):
|
||||
search_aliases=["export vector", "save vector graphics"],
|
||||
display_name="Save SVG",
|
||||
description="Save SVG files on disk.",
|
||||
category="image/save",
|
||||
category="image",
|
||||
inputs=[
|
||||
IO.SVG.Input("svg"),
|
||||
IO.String.Input(
|
||||
|
||||
+132
-14
@@ -14,6 +14,49 @@ from typing_extensions import override
|
||||
from comfy.ldm.lightricks.symmetric_patchifier import SymmetricPatchifier, latent_to_pixel_coords
|
||||
from comfy_api.latest import ComfyExtension, io
|
||||
|
||||
ICLoRAParameters = io.Custom("IC_LORA_PARAMETERS")
|
||||
|
||||
|
||||
class GetICLoRAParameters(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="GetICLoRAParameters",
|
||||
display_name="Get IC-LoRA Parameters",
|
||||
description="Extracts IC-LoRA parameters from the safetensors metadata of a LoRA-loaded "
|
||||
"model and outputs them for LTXVAddGuide (eg. reference_downscale_factor).",
|
||||
category="conditioning/video_models",
|
||||
search_aliases=["ic-lora", "ic lora", "iclora", "downscale factor", "reference downscale"],
|
||||
inputs=[
|
||||
io.Model.Input(
|
||||
"iclora_model",
|
||||
tooltip="Direct output from a LoRA Loader for the specific IC-LoRA "
|
||||
"from which to extract the metadata.",
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
ICLoRAParameters.Output(
|
||||
"iclora_parameters",
|
||||
tooltip="IC-LoRA parameters extracted from the LoRA metadata "
|
||||
"(eg. reference_downscale_factor). Connect to LTXVAddGuide "
|
||||
"if the LoRA requires special handling of the guides.",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, iclora_model) -> io.NodeOutput:
|
||||
metadata = iclora_model.get_attachment("lora_metadata")
|
||||
factor = 1
|
||||
if metadata:
|
||||
try:
|
||||
factor = max(1, round(float(metadata.get("reference_downscale_factor", 1))))
|
||||
except (TypeError, ValueError):
|
||||
factor = 1
|
||||
parameters = {"reference_downscale_factor": factor}
|
||||
return io.NodeOutput(parameters)
|
||||
|
||||
|
||||
class EmptyLTXVLatentVideo(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
@@ -34,7 +77,7 @@ class EmptyLTXVLatentVideo(io.ComfyNode):
|
||||
@classmethod
|
||||
def execute(cls, width, height, length, batch_size=1) -> io.NodeOutput:
|
||||
latent = torch.zeros([batch_size, 128, ((length - 1) // 8) + 1, height // 32, width // 32], device=comfy.model_management.intermediate_device())
|
||||
return io.NodeOutput({"samples": latent})
|
||||
return io.NodeOutput({"samples": latent, "downscale_ratio_spacial": 32})
|
||||
|
||||
generate = execute # TODO: remove
|
||||
|
||||
@@ -132,7 +175,7 @@ class LTXVImgToVideoInplace(io.ComfyNode):
|
||||
generate = execute # TODO: remove
|
||||
|
||||
|
||||
def _append_guide_attention_entry(positive, negative, pre_filter_count, latent_shape, strength=1.0):
|
||||
def _append_guide_attention_entry(positive, negative, pre_filter_count, latent_shape, strength=1.0, attention_mask=None):
|
||||
"""Append a guide_attention_entry to both positive and negative conditioning.
|
||||
|
||||
Each entry tracks one guide reference for per-reference attention control.
|
||||
@@ -141,9 +184,10 @@ def _append_guide_attention_entry(positive, negative, pre_filter_count, latent_s
|
||||
new_entry = {
|
||||
"pre_filter_count": pre_filter_count,
|
||||
"strength": strength,
|
||||
"pixel_mask": None,
|
||||
"pixel_mask": attention_mask.unsqueeze(0).unsqueeze(0) if attention_mask is not None else None, # reshape to (1, 1, F, H, W)
|
||||
"latent_shape": latent_shape,
|
||||
}
|
||||
|
||||
results = []
|
||||
for cond in (positive, negative):
|
||||
# Read existing entries from this specific conditioning
|
||||
@@ -153,8 +197,7 @@ def _append_guide_attention_entry(positive, negative, pre_filter_count, latent_s
|
||||
if found is not None:
|
||||
existing = found
|
||||
break
|
||||
# Shallow copy and append (no deepcopy needed — entries contain
|
||||
# only scalars and None for pixel_mask at this call site).
|
||||
# Shallow copy only and append (pixel_mask is never mutated).
|
||||
entries = [*existing, new_entry]
|
||||
results.append(node_helpers.conditioning_set_values(
|
||||
cond, {"guide_attention_entries": entries}
|
||||
@@ -219,7 +262,21 @@ class LTXVAddGuide(io.ComfyNode):
|
||||
"For videos with 9+ frames, frame_idx must be divisible by 8, otherwise it will be rounded "
|
||||
"down to the nearest multiple of 8. Negative values are counted from the end of the video.",
|
||||
),
|
||||
io.Float.Input("strength", default=1.0, min=0.0, max=1.0, step=0.01),
|
||||
io.Float.Input("strength", default=1.0, min=0.0, max=10.0, step=0.01),
|
||||
io.Mask.Input(
|
||||
"attention_mask",
|
||||
optional=True,
|
||||
tooltip="Optional pixel-space spatial mask. Controls per-region "
|
||||
"conditioning influence via self-attention, multiplied by strength.",
|
||||
),
|
||||
ICLoRAParameters.Input(
|
||||
"iclora_parameters",
|
||||
optional=True,
|
||||
tooltip="Optional IC-LoRA parameters from a Get IC-LoRA Parameters node. "
|
||||
"Used for adjusting guide processing as required by certain IC-LoRAs "
|
||||
"(eg. those with a reference_downscale_factor > 1). "
|
||||
"When chained, each LTXVAddGuide uses only the parameters connected to it.",
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
io.Conditioning.Output(display_name="positive"),
|
||||
@@ -229,14 +286,41 @@ class LTXVAddGuide(io.ComfyNode):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def encode(cls, vae, latent_width, latent_height, images, scale_factors):
|
||||
def encode(cls, vae, latent_width, latent_height, images, scale_factors, latent_downscale_factor=1):
|
||||
time_scale_factor, width_scale_factor, height_scale_factor = scale_factors
|
||||
images = images[:(images.shape[0] - 1) // time_scale_factor * time_scale_factor + 1]
|
||||
pixels = comfy.utils.common_upscale(images.movedim(-1, 1), latent_width * width_scale_factor, latent_height * height_scale_factor, "bilinear", crop="center").movedim(1, -1)
|
||||
target_width = int(latent_width * width_scale_factor / latent_downscale_factor)
|
||||
target_height = int(latent_height * height_scale_factor / latent_downscale_factor)
|
||||
pixels = comfy.utils.common_upscale(images.movedim(-1, 1), target_width, target_height, "bilinear", crop="center").movedim(1, -1)
|
||||
encode_pixels = pixels[:, :, :, :3]
|
||||
t = vae.encode(encode_pixels)
|
||||
return encode_pixels, t
|
||||
|
||||
@classmethod
|
||||
def dilate_latent(cls, guide_latent, latent_downscale_factor):
|
||||
if latent_downscale_factor <= 1:
|
||||
return guide_latent, None
|
||||
scale = int(latent_downscale_factor)
|
||||
dilated_shape = guide_latent.shape[:3] + (guide_latent.shape[3] * scale, guide_latent.shape[4] * scale)
|
||||
dilated = torch.zeros(dilated_shape, device=guide_latent.device, dtype=guide_latent.dtype)
|
||||
dilated[..., ::scale, ::scale] = guide_latent
|
||||
dilated_mask = torch.full(
|
||||
(dilated.shape[0], 1, dilated.shape[2], dilated.shape[3], dilated.shape[4]),
|
||||
-1.0, device=guide_latent.device, dtype=guide_latent.dtype,
|
||||
)
|
||||
dilated_mask[..., ::scale, ::scale] = 1.0
|
||||
return dilated, dilated_mask
|
||||
|
||||
@classmethod
|
||||
def get_reference_downscale_factor(cls, iclora_parameters):
|
||||
if not iclora_parameters:
|
||||
return 1
|
||||
try:
|
||||
factor = max(1, round(float(iclora_parameters.get("reference_downscale_factor", 1))))
|
||||
except (TypeError, ValueError):
|
||||
factor = 1
|
||||
return factor
|
||||
|
||||
@classmethod
|
||||
def get_latent_index(cls, cond, latent_length, guide_length, frame_idx, scale_factors):
|
||||
time_scale_factor, _, _ = scale_factors
|
||||
@@ -298,7 +382,7 @@ class LTXVAddGuide(io.ComfyNode):
|
||||
else:
|
||||
mask = torch.full(
|
||||
(noise_mask.shape[0], 1, guiding_latent.shape[2], noise_mask.shape[3], noise_mask.shape[4]),
|
||||
1.0 - strength,
|
||||
max(0.0, 1.0 - strength), # clamp here to amplify only via the attention mask
|
||||
dtype=noise_mask.dtype,
|
||||
device=noise_mask.device,
|
||||
)
|
||||
@@ -318,7 +402,7 @@ class LTXVAddGuide(io.ComfyNode):
|
||||
|
||||
mask = torch.full(
|
||||
(noise_mask.shape[0], 1, cond_length, 1, 1),
|
||||
1.0 - strength,
|
||||
max(0.0, 1.0 - strength), # clamp here to amplify only via the attention mask
|
||||
dtype=noise_mask.dtype,
|
||||
device=noise_mask.device,
|
||||
)
|
||||
@@ -332,13 +416,43 @@ class LTXVAddGuide(io.ComfyNode):
|
||||
return latent_image, noise_mask
|
||||
|
||||
@classmethod
|
||||
def execute(cls, positive, negative, vae, latent, image, frame_idx, strength) -> io.NodeOutput:
|
||||
def execute(cls, positive, negative, vae, latent, image, frame_idx, strength, attention_mask=None, iclora_parameters=None) -> io.NodeOutput:
|
||||
scale_factors = vae.downscale_index_formula
|
||||
latent_image = latent["samples"]
|
||||
noise_mask = get_noise_mask(latent)
|
||||
|
||||
_, _, latent_length, latent_height, latent_width = latent_image.shape
|
||||
image, t = cls.encode(vae, latent_width, latent_height, image, scale_factors)
|
||||
|
||||
latent_downscale_factor = cls.get_reference_downscale_factor(iclora_parameters)
|
||||
if latent_downscale_factor > 1:
|
||||
if latent_width % latent_downscale_factor != 0 or latent_height % latent_downscale_factor != 0:
|
||||
raise ValueError(
|
||||
f"Latent spatial size {latent_width}x{latent_height} must be divisible by "
|
||||
f"reference_downscale_factor {latent_downscale_factor} from the IC-LoRA parameters."
|
||||
)
|
||||
|
||||
# For mid-video multi-frame guides, prepend+strip a throwaway first frame so the VAE's "first latent = 1 pixel frame" asymmetry lands on the discarded slot
|
||||
time_scale_factor = scale_factors[0]
|
||||
num_frames_to_keep = ((image.shape[0] - 1) // time_scale_factor) * time_scale_factor + 1
|
||||
resolved_frame_idx = frame_idx
|
||||
if frame_idx < 0:
|
||||
_, num_keyframes = get_keyframe_idxs(positive)
|
||||
resolved_frame_idx = max((latent_length - num_keyframes - 1) * time_scale_factor + 1 + frame_idx, 0)
|
||||
causal_fix = resolved_frame_idx == 0 or num_frames_to_keep == 1
|
||||
|
||||
if not causal_fix:
|
||||
image = torch.cat([image[:1], image], dim=0)
|
||||
|
||||
image, t = cls.encode(vae, latent_width, latent_height, image, scale_factors, latent_downscale_factor)
|
||||
|
||||
if not causal_fix:
|
||||
t = t[:, :, 1:, :, :]
|
||||
image = image[1:]
|
||||
|
||||
guide_latent_shape = list(t.shape[2:]) # pre-dilation [F, H, W] for spatial-mask downsampling
|
||||
guide_mask = None
|
||||
if latent_downscale_factor > 1:
|
||||
t, guide_mask = cls.dilate_latent(t, latent_downscale_factor)
|
||||
|
||||
frame_idx, latent_idx = cls.get_latent_index(positive, latent_length, len(image), frame_idx, scale_factors)
|
||||
assert latent_idx + t.shape[2] <= latent_length, "Conditioning frames exceed the length of the latent sequence."
|
||||
@@ -352,13 +466,16 @@ class LTXVAddGuide(io.ComfyNode):
|
||||
t,
|
||||
strength,
|
||||
scale_factors,
|
||||
guide_mask=guide_mask,
|
||||
latent_downscale_factor=latent_downscale_factor,
|
||||
causal_fix=causal_fix,
|
||||
)
|
||||
|
||||
# Track this guide for per-reference attention control.
|
||||
pre_filter_count = t.shape[2] * t.shape[3] * t.shape[4]
|
||||
guide_latent_shape = list(t.shape[2:]) # [F, H, W]
|
||||
positive, negative = _append_guide_attention_entry(
|
||||
positive, negative, pre_filter_count, guide_latent_shape, strength=strength,
|
||||
attention_mask=attention_mask,
|
||||
)
|
||||
|
||||
return io.NodeOutput(positive, negative, {"samples": latent_image, "noise_mask": noise_mask})
|
||||
@@ -484,7 +601,7 @@ class LTXVScheduler(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="LTXVScheduler",
|
||||
category="sampling/custom_sampling/schedulers",
|
||||
category="sampling/schedulers",
|
||||
inputs=[
|
||||
io.Int.Input("steps", default=20, min=1, max=10000),
|
||||
io.Float.Input("max_shift", default=2.05, min=0.0, max=100.0, step=0.01),
|
||||
@@ -776,6 +893,7 @@ class LtxvExtension(ComfyExtension):
|
||||
ModelSamplingLTXV,
|
||||
LTXVConditioning,
|
||||
LTXVScheduler,
|
||||
GetICLoRAParameters,
|
||||
LTXVAddGuide,
|
||||
LTXVPreprocess,
|
||||
LTXVCropGuides,
|
||||
|
||||
@@ -11,8 +11,8 @@ class LTXVAudioVAELoader(io.ComfyNode):
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(
|
||||
node_id="LTXVAudioVAELoader",
|
||||
display_name="LTXV Audio VAE Loader",
|
||||
category="audio",
|
||||
display_name="Load LTXV Audio VAE",
|
||||
category="loaders",
|
||||
inputs=[
|
||||
io.Combo.Input(
|
||||
"ckpt_name",
|
||||
@@ -40,7 +40,7 @@ class LTXVAudioVAEEncode(VAEEncodeAudio):
|
||||
return io.Schema(
|
||||
node_id="LTXVAudioVAEEncode",
|
||||
display_name="LTXV Audio VAE Encode",
|
||||
category="audio",
|
||||
category="latent/audio",
|
||||
inputs=[
|
||||
io.Audio.Input("audio", tooltip="The audio to be encoded."),
|
||||
io.Vae.Input(
|
||||
@@ -63,7 +63,7 @@ class LTXVAudioVAEDecode(io.ComfyNode):
|
||||
return io.Schema(
|
||||
node_id="LTXVAudioVAEDecode",
|
||||
display_name="LTXV Audio VAE Decode",
|
||||
category="audio",
|
||||
category="latent/audio",
|
||||
inputs=[
|
||||
io.Latent.Input("samples", tooltip="The latent to be decoded."),
|
||||
io.Vae.Input(
|
||||
|
||||
+23
-35
@@ -40,23 +40,13 @@ def composite(destination, source, x, y, mask = None, multiplier = 8, resize_sou
|
||||
|
||||
inverse_mask = torch.ones_like(mask) - mask
|
||||
|
||||
source_rgb = source[:, :3, :visible_height, :visible_width]
|
||||
dest_slice = destination[..., top:bottom, left:right]
|
||||
|
||||
if destination.shape[1] == 4:
|
||||
if torch.max(dest_slice) == 0:
|
||||
destination[:, :3, top:bottom, left:right] = source_rgb
|
||||
destination[:, 3:4, top:bottom, left:right] = mask
|
||||
else:
|
||||
destination[:, :3, top:bottom, left:right] = (mask * source_rgb) + (inverse_mask * dest_slice[:, :3])
|
||||
destination[:, 3:4, top:bottom, left:right] = torch.max(mask, dest_slice[:, 3:4])
|
||||
else:
|
||||
source_portion = mask * source_rgb
|
||||
destination_portion = inverse_mask * dest_slice
|
||||
destination[..., top:bottom, left:right] = source_portion + destination_portion
|
||||
source_portion = mask * source[..., :visible_height, :visible_width]
|
||||
destination_portion = inverse_mask * destination[..., top:bottom, left:right]
|
||||
|
||||
destination[..., top:bottom, left:right] = source_portion + destination_portion
|
||||
return destination
|
||||
|
||||
|
||||
class LatentCompositeMasked(IO.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
@@ -93,25 +83,20 @@ class ImageCompositeMasked(IO.ComfyNode):
|
||||
node_id="ImageCompositeMasked",
|
||||
search_aliases=["overlay", "layer", "paste image", "images composition"],
|
||||
display_name="Image Composite Masked",
|
||||
category="image",
|
||||
category="image/compositing",
|
||||
inputs=[
|
||||
IO.Image.Input("destination"),
|
||||
IO.Image.Input("source"),
|
||||
IO.Int.Input("x", default=0, min=0, max=nodes.MAX_RESOLUTION, step=1),
|
||||
IO.Int.Input("y", default=0, min=0, max=nodes.MAX_RESOLUTION, step=1),
|
||||
IO.Boolean.Input("resize_source", default=False),
|
||||
IO.Image.Input("destination", optional=True),
|
||||
IO.Mask.Input("mask", optional=True),
|
||||
],
|
||||
outputs=[IO.Image.Output()],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, source, x, y, resize_source, destination = None, mask = None) -> IO.NodeOutput:
|
||||
if destination is None: # transparent rgba
|
||||
B, H, W, C = source.shape
|
||||
destination = torch.zeros((B, H, W, 4), dtype=source.dtype, device=source.device)
|
||||
if C == 3:
|
||||
source = torch.nn.functional.pad(source, (0, 1), value=1.0)
|
||||
def execute(cls, destination, source, x, y, resize_source, mask = None) -> IO.NodeOutput:
|
||||
destination, source = node_helpers.image_alpha_fix(destination, source)
|
||||
destination = destination.clone().movedim(-1, 1)
|
||||
output = composite(destination, source.movedim(-1, 1), x, y, mask, 1, resize_source).movedim(1, -1)
|
||||
@@ -127,7 +112,7 @@ class MaskToImage(IO.ComfyNode):
|
||||
node_id="MaskToImage",
|
||||
search_aliases=["convert mask"],
|
||||
display_name="Convert Mask to Image",
|
||||
category="mask",
|
||||
category="image/mask",
|
||||
inputs=[
|
||||
IO.Mask.Input("mask"),
|
||||
],
|
||||
@@ -149,7 +134,7 @@ class ImageToMask(IO.ComfyNode):
|
||||
node_id="ImageToMask",
|
||||
search_aliases=["extract channel", "channel to mask"],
|
||||
display_name="Convert Image to Mask",
|
||||
category="mask",
|
||||
category="image/mask",
|
||||
inputs=[
|
||||
IO.Image.Input("image"),
|
||||
IO.Combo.Input("channel", options=["red", "green", "blue", "alpha"]),
|
||||
@@ -172,7 +157,8 @@ class ImageColorToMask(IO.ComfyNode):
|
||||
return IO.Schema(
|
||||
node_id="ImageColorToMask",
|
||||
search_aliases=["color keying", "chroma key"],
|
||||
category="mask",
|
||||
display_name="Convert Image Color to Mask",
|
||||
category="image/mask",
|
||||
inputs=[
|
||||
IO.Image.Input("image"),
|
||||
IO.Int.Input("color", default=0, min=0, max=0xFFFFFF, step=1, display_mode=IO.NumberDisplay.number),
|
||||
@@ -195,7 +181,8 @@ class SolidMask(IO.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="SolidMask",
|
||||
category="mask",
|
||||
display_name="Create Solid Mask",
|
||||
category="image/mask",
|
||||
inputs=[
|
||||
IO.Float.Input("value", default=1.0, min=0.0, max=1.0, step=0.01),
|
||||
IO.Int.Input("width", default=512, min=1, max=nodes.MAX_RESOLUTION, step=1),
|
||||
@@ -219,7 +206,7 @@ class InvertMask(IO.ComfyNode):
|
||||
node_id="InvertMask",
|
||||
search_aliases=["reverse mask", "flip mask"],
|
||||
display_name="Invert Mask",
|
||||
category="mask",
|
||||
category="image/mask",
|
||||
inputs=[
|
||||
IO.Mask.Input("mask"),
|
||||
],
|
||||
@@ -241,7 +228,7 @@ class CropMask(IO.ComfyNode):
|
||||
node_id="CropMask",
|
||||
search_aliases=["cut mask", "extract mask region", "mask slice"],
|
||||
display_name="Crop Mask",
|
||||
category="mask",
|
||||
category="image/mask",
|
||||
inputs=[
|
||||
IO.Mask.Input("mask"),
|
||||
IO.Int.Input("x", default=0, min=0, max=nodes.MAX_RESOLUTION, step=1),
|
||||
@@ -268,7 +255,7 @@ class MaskComposite(IO.ComfyNode):
|
||||
node_id="MaskComposite",
|
||||
search_aliases=["combine masks", "blend masks", "layer masks", "masks composition"],
|
||||
display_name="Combine Masks",
|
||||
category="mask",
|
||||
category="image/mask",
|
||||
inputs=[
|
||||
IO.Mask.Input("destination"),
|
||||
IO.Mask.Input("source"),
|
||||
@@ -319,7 +306,7 @@ class FeatherMask(IO.ComfyNode):
|
||||
node_id="FeatherMask",
|
||||
search_aliases=["soft edge mask", "blur mask edges", "gradient mask edge"],
|
||||
display_name="Feather Mask",
|
||||
category="mask",
|
||||
category="image/mask",
|
||||
inputs=[
|
||||
IO.Mask.Input("mask"),
|
||||
IO.Int.Input("left", default=0, min=0, max=nodes.MAX_RESOLUTION, step=1),
|
||||
@@ -345,7 +332,7 @@ class FeatherMask(IO.ComfyNode):
|
||||
|
||||
for x in range(right):
|
||||
feather_rate = (x + 1) / right
|
||||
output[:, :, -x] *= feather_rate
|
||||
output[:, :, -(x + 1)] *= feather_rate
|
||||
|
||||
for y in range(top):
|
||||
feather_rate = (y + 1) / top
|
||||
@@ -353,7 +340,7 @@ class FeatherMask(IO.ComfyNode):
|
||||
|
||||
for y in range(bottom):
|
||||
feather_rate = (y + 1) / bottom
|
||||
output[:, -y, :] *= feather_rate
|
||||
output[:, -(y + 1), :] *= feather_rate
|
||||
|
||||
return IO.NodeOutput(output)
|
||||
|
||||
@@ -367,7 +354,7 @@ class GrowMask(IO.ComfyNode):
|
||||
node_id="GrowMask",
|
||||
search_aliases=["expand mask", "shrink mask"],
|
||||
display_name="Grow Mask",
|
||||
category="mask",
|
||||
category="image/mask",
|
||||
inputs=[
|
||||
IO.Mask.Input("mask"),
|
||||
IO.Int.Input("expand", default=0, min=-nodes.MAX_RESOLUTION, max=nodes.MAX_RESOLUTION, step=1),
|
||||
@@ -403,7 +390,8 @@ class ThresholdMask(IO.ComfyNode):
|
||||
return IO.Schema(
|
||||
node_id="ThresholdMask",
|
||||
search_aliases=["binary mask"],
|
||||
category="mask",
|
||||
display_name="Threshold Mask",
|
||||
category="image/mask",
|
||||
inputs=[
|
||||
IO.Mask.Input("mask"),
|
||||
IO.Float.Input("value", default=0.5, min=0.0, max=1.0, step=0.01),
|
||||
@@ -429,7 +417,7 @@ class MaskPreview(IO.ComfyNode):
|
||||
node_id="MaskPreview",
|
||||
search_aliases=["show mask", "view mask", "inspect mask", "debug mask"],
|
||||
display_name="Preview Mask",
|
||||
category="mask",
|
||||
category="image/mask",
|
||||
description="Saves the input images to your ComfyUI output directory.",
|
||||
inputs=[
|
||||
IO.Mask.Input("mask"),
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
"""ComfyUI nodes for the pure-PyTorch MediaPipe Face Landmarker port.
|
||||
|
||||
Custom IO types:
|
||||
FACE_LANDMARKER — FaceLandmarkerModel wrapper (ModelPatcher inside)
|
||||
FACE_LANDMARKS — {"frames": List[List[face_dict]], "image_size": (H, W),
|
||||
"connection_sets": dict[str, frozenset[(int, int)]]}
|
||||
face_dict: bbox_xyxy, blendshapes, landmarks_xy,
|
||||
landmarks_3d, presence, score, transformation_matrix
|
||||
|
||||
MediaPipeFaceLandmarker also emits the core BOUNDING_BOX type — pair with DrawBBoxes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image, ImageColor, ImageDraw
|
||||
from tqdm.auto import tqdm
|
||||
from typing_extensions import override
|
||||
|
||||
import comfy.model_management
|
||||
import comfy.model_patcher
|
||||
import comfy.utils
|
||||
import folder_paths
|
||||
from comfy_api.latest import ComfyExtension, io
|
||||
|
||||
from comfy_extras.mediapipe.face_landmarker import FaceLandmarker
|
||||
from comfy_extras.mediapipe.face_geometry import transformation_matrix_from_detection
|
||||
|
||||
|
||||
FaceDetectionType = io.Custom("FACE_DETECTION_MODEL")
|
||||
FaceLandmarksType = io.Custom("FACE_LANDMARKS")
|
||||
|
||||
_CANONICAL_KEYS = ("canonical_vertices", "procrustes_indices", "procrustes_weights")
|
||||
_CONTOUR_PARTS = ("face_oval", "left_eye", "right_eye", "left_eyebrow", "right_eyebrow", "lips")
|
||||
|
||||
|
||||
class FaceLandmarkerModel:
|
||||
"""Loaded FaceLandmarker variants + ModelPatcher per variant.
|
||||
|
||||
Safetensors layout: `detector_short.*` / `detector_full.*` plus shared
|
||||
`mesh.*`, `blendshapes.*`, `canonical_*`, and `topology.*`.
|
||||
PReLU forces plain-nn / fp32 (manual_cast strands buffers across devices).
|
||||
"""
|
||||
|
||||
def __init__(self, state_dict: dict):
|
||||
self.load_device = comfy.model_management.text_encoder_device()
|
||||
offload_device = comfy.model_management.text_encoder_offload_device()
|
||||
self.dtype = torch.float32
|
||||
|
||||
# FACEMESH_* connection sets, embedded as int32 (N, 2) under topology.*.
|
||||
base: dict[str, frozenset] = {}
|
||||
for k in [k for k in state_dict if k.startswith("topology.")]:
|
||||
base[k[len("topology."):]] = frozenset(map(tuple, state_dict.pop(k).tolist()))
|
||||
base["contours"] = frozenset().union(*(base[p] for p in _CONTOUR_PARTS))
|
||||
base["all"] = base["contours"] | base["irises"] | base["nose"]
|
||||
|
||||
self.connection_sets: dict[str, frozenset] = base
|
||||
self.canonical_data: dict[str, np.ndarray] = {k: state_dict.pop(k).numpy() for k in _CANONICAL_KEYS}
|
||||
|
||||
shared = {k: v for k, v in state_dict.items() if k.startswith(("mesh.", "blendshapes."))}
|
||||
|
||||
self.models: dict[str, FaceLandmarker] = {}
|
||||
self.patchers: dict[str, comfy.model_patcher.ModelPatcher] = {}
|
||||
for variant in ("short", "full"):
|
||||
prefix = f"detector_{variant}."
|
||||
sub = dict(shared)
|
||||
sub.update({f"detector.{k[len(prefix):]}": v for k, v in state_dict.items() if k.startswith(prefix)})
|
||||
fl = FaceLandmarker(device=offload_device, dtype=self.dtype, operations=None, detector_variant=variant).eval()
|
||||
fl.load_state_dict(sub, strict=False)
|
||||
|
||||
self.models[variant] = fl
|
||||
self.patchers[variant] = comfy.model_patcher.CoreModelPatcher(
|
||||
fl, load_device=self.load_device, offload_device=offload_device,
|
||||
size=comfy.model_management.module_size(fl),
|
||||
)
|
||||
|
||||
def detect_batch(self, images, num_faces: int, score_thresh: float, variant: str):
|
||||
comfy.model_management.load_model_gpu(self.patchers[variant])
|
||||
return self.models[variant].detect_batch(images, num_faces=num_faces, score_thresh=score_thresh)
|
||||
|
||||
|
||||
def _image_to_uint8(image: torch.Tensor) -> np.ndarray:
|
||||
return image[..., :3].mul(255.0).add_(0.5).clamp_(0, 255).to(torch.uint8).cpu().numpy()
|
||||
|
||||
|
||||
def _parse_color(color: str) -> tuple[int, int, int]:
|
||||
try:
|
||||
return ImageColor.getrgb(color)[:3]
|
||||
except ValueError:
|
||||
return (0, 255, 0)
|
||||
|
||||
|
||||
def _copy_face(face: dict) -> dict:
|
||||
"""Shallow copy of a face_dict with array-fields cloned so callers can mutate."""
|
||||
return {
|
||||
"bbox_xyxy": face["bbox_xyxy"].copy(),
|
||||
"blendshapes": dict(face["blendshapes"]),
|
||||
"landmarks_xy": face["landmarks_xy"].copy(),
|
||||
"landmarks_3d": face["landmarks_3d"].copy(),
|
||||
"presence": face["presence"],
|
||||
"score": face["score"],
|
||||
}
|
||||
|
||||
|
||||
def _lerp_face(a: dict, b: dict, t: float) -> dict:
|
||||
return {
|
||||
"bbox_xyxy": (1 - t) * a["bbox_xyxy"] + t * b["bbox_xyxy"],
|
||||
"blendshapes": {k: (1 - t) * a["blendshapes"][k] + t * b["blendshapes"][k] for k in a["blendshapes"]},
|
||||
"landmarks_xy": (1 - t) * a["landmarks_xy"] + t * b["landmarks_xy"],
|
||||
"landmarks_3d": (1 - t) * a["landmarks_3d"] + t * b["landmarks_3d"],
|
||||
"presence": (1 - t) * a["presence"] + t * b["presence"],
|
||||
"score": (1 - t) * a["score"] + t * b["score"],
|
||||
}
|
||||
|
||||
|
||||
def _match_faces(a: list[dict], b: list[dict]) -> list[tuple[int, int]]:
|
||||
"""Greedy nearest-neighbour pairing of faces between two frames by bbox
|
||||
centre distance. Unmatched (when counts differ) are dropped."""
|
||||
if not a or not b:
|
||||
return []
|
||||
centers_a = np.array([(0.5 * (f["bbox_xyxy"][0] + f["bbox_xyxy"][2]),
|
||||
0.5 * (f["bbox_xyxy"][1] + f["bbox_xyxy"][3])) for f in a])
|
||||
centers_b = np.array([(0.5 * (f["bbox_xyxy"][0] + f["bbox_xyxy"][2]),
|
||||
0.5 * (f["bbox_xyxy"][1] + f["bbox_xyxy"][3])) for f in b])
|
||||
dists = np.linalg.norm(centers_a[:, None] - centers_b[None], axis=-1)
|
||||
pairs: list[tuple[int, int]] = []
|
||||
used_a: set[int] = set()
|
||||
used_b: set[int] = set()
|
||||
candidates = sorted((dists[ia, ib], ia, ib) for ia in range(len(a)) for ib in range(len(b)))
|
||||
for _, ia, ib in candidates:
|
||||
if ia in used_a or ib in used_b:
|
||||
continue
|
||||
pairs.append((ia, ib))
|
||||
used_a.add(ia)
|
||||
used_b.add(ib)
|
||||
return pairs
|
||||
|
||||
|
||||
def _fill_missing_frames(frames: list[list[dict]], mode: str) -> None:
|
||||
"""In-place fill empty frame slots from neighbouring detections. Multi-face
|
||||
aware: pairs faces across bracketing frames by greedy bbox-centre NN.
|
||||
When counts differ, unmatched faces are dropped from the synthesised frame."""
|
||||
if mode == "empty":
|
||||
return
|
||||
valid = [i for i, fr in enumerate(frames) if fr]
|
||||
if not valid:
|
||||
return # nothing to fill from
|
||||
if mode == "previous":
|
||||
last: list[dict] = []
|
||||
for i, fr in enumerate(frames):
|
||||
if fr:
|
||||
last = fr
|
||||
elif last:
|
||||
frames[i] = [_copy_face(f) for f in last]
|
||||
return
|
||||
# interpolate: lerp between bracketing valid frames; clamp at ends.
|
||||
for i in range(len(frames)):
|
||||
if frames[i]:
|
||||
continue
|
||||
prev_i = max((v for v in valid if v < i), default=None)
|
||||
next_i = min((v for v in valid if v > i), default=None)
|
||||
if prev_i is None:
|
||||
frames[i] = [_copy_face(f) for f in frames[next_i]]
|
||||
elif next_i is None:
|
||||
frames[i] = [_copy_face(f) for f in frames[prev_i]]
|
||||
else:
|
||||
t = (i - prev_i) / (next_i - prev_i)
|
||||
pairs = _match_faces(frames[prev_i], frames[next_i])
|
||||
frames[i] = [_lerp_face(frames[prev_i][a], frames[next_i][b], t) for a, b in pairs]
|
||||
|
||||
|
||||
def _ordered_rings(edges: frozenset[tuple[int, int]]) -> list[list[int]]:
|
||||
"""Walk an unordered edge set into one or more closed-loop vertex rings
|
||||
(handles multi-loop sets like FACEMESH_LIPS: outer + inner)."""
|
||||
adj: dict[int, set[int]] = {}
|
||||
for a, b in edges:
|
||||
adj.setdefault(a, set()).add(b)
|
||||
adj.setdefault(b, set()).add(a)
|
||||
visited: set[int] = set()
|
||||
rings: list[list[int]] = []
|
||||
for start in adj:
|
||||
if start in visited:
|
||||
continue
|
||||
ring = [start]
|
||||
visited.add(start)
|
||||
prev, cur = -1, start
|
||||
while True:
|
||||
nxt = next((v for v in adj[cur] if v != prev), None)
|
||||
if nxt is None or nxt == start:
|
||||
break
|
||||
ring.append(nxt)
|
||||
visited.add(nxt)
|
||||
prev, cur = cur, nxt
|
||||
rings.append(ring)
|
||||
return rings
|
||||
|
||||
|
||||
class LoadMediaPipeFaceLandmarker(io.ComfyNode):
|
||||
"""Load MediaPipe Face Landmarker v2 weights. Contains both detector variants
|
||||
(short / full), shared mesh, blendshapes, and canonical geometry."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="LoadMediaPipeFaceLandmarker",
|
||||
search_aliases=["face", "facial", "mediapipe", "face landmark", "face mesh", "blazeface", "face detection"],
|
||||
display_name="Load Face Detection Model (MediaPipe)",
|
||||
category="loaders",
|
||||
inputs=[
|
||||
io.Combo.Input("model_name", options=folder_paths.get_filename_list("detection"),
|
||||
tooltip="Face detection model from models/detection/."),
|
||||
],
|
||||
outputs=[FaceDetectionType.Output()],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, model_name) -> io.NodeOutput:
|
||||
sd = comfy.utils.load_torch_file(folder_paths.get_full_path_or_raise("detection", model_name), safe_load=True)
|
||||
wrapper = FaceLandmarkerModel(sd)
|
||||
return io.NodeOutput(wrapper)
|
||||
|
||||
|
||||
# Per-frame fallback modes for detection failures in a batch.
|
||||
_FALLBACK_MODES = ("empty", "previous", "interpolate")
|
||||
|
||||
|
||||
class MediaPipeFaceLandmarker(io.ComfyNode):
|
||||
"""BlazeFace → FaceMesh v2 → ARKit-52 blendshapes, batched across the
|
||||
input. Also emits a BOUNDING_BOX list (landmark-extent bbox per face) —
|
||||
pair with DrawBBoxes for detector-only viz or MediaPipeFaceMeshVisualize
|
||||
for the mesh overlay."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="MediaPipeFaceLandmarker",
|
||||
search_aliases=["face", "facial", "mediapipe", "face landmark", "face mesh", "blazeface", "face detection"],
|
||||
display_name="Detect Face Landmarks (MediaPipe)",
|
||||
category="image/detection",
|
||||
description="Detects facial landmarks using MediaPipe model.",
|
||||
inputs=[
|
||||
FaceDetectionType.Input("face_detection_model"),
|
||||
io.Image.Input("image"),
|
||||
io.Combo.Input("detector_variant", options=["short", "full", "both"], default="short",
|
||||
tooltip="Face detector range. 'short' is tuned for close-up faces "
|
||||
"(within ~2 m of the camera); 'full' covers farther / smaller "
|
||||
"faces (up to ~5 m) but is slower. 'both' runs both detectors and "
|
||||
"keeps whichever found more faces per frame (~2× detection cost)."),
|
||||
io.Int.Input("num_faces", default=1, min=0, max=16, step=1,
|
||||
tooltip="Maximum faces to return per frame. 0 = no cap (return all detected)."),
|
||||
io.Float.Input("min_confidence", default=0.5, min=0.0, max=1.0, step=0.01, advanced=True,
|
||||
tooltip="BlazeFace score threshold. Lower to catch small/occluded faces."),
|
||||
io.Combo.Input("missing_frame_fallback", options=list(_FALLBACK_MODES), default="empty", advanced=True,
|
||||
tooltip="Per-frame behaviour when detection fails in a batch. "
|
||||
"'empty' leaves the frame faceless. 'previous' copies the most recent successful "
|
||||
"detection. 'interpolate' lerps landmarks/bbox/blendshapes between bracketing "
|
||||
"successful frames. Multi-face: pairs faces across frames by greedy bbox-centre NN."),
|
||||
],
|
||||
outputs=[
|
||||
FaceLandmarksType.Output(display_name="face_landmarks"),
|
||||
io.BoundingBox.Output("bboxes"),
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, face_detection_model, image, detector_variant, num_faces, min_confidence,
|
||||
missing_frame_fallback) -> io.NodeOutput:
|
||||
canonical = face_detection_model.canonical_data
|
||||
img_np = _image_to_uint8(image)
|
||||
B, H, W = img_np.shape[:3]
|
||||
chunk = 16
|
||||
is_both = detector_variant == "both"
|
||||
total_work = 2 * B if is_both else B
|
||||
pbar = comfy.utils.ProgressBar(total_work)
|
||||
|
||||
def _run(variant: str) -> list[list[dict]]:
|
||||
res: list[list[dict]] = []
|
||||
with tqdm(total=B, desc=f"MediaPipe Face Landmarker ({variant})") as tq:
|
||||
for i in range(0, B, chunk):
|
||||
end = min(i + chunk, B)
|
||||
res.extend(face_detection_model.detect_batch(
|
||||
[img_np[bi] for bi in range(i, end)],
|
||||
num_faces=int(num_faces),
|
||||
score_thresh=float(min_confidence),
|
||||
variant=variant,
|
||||
))
|
||||
pbar.update_absolute(min(pbar.current + (end - i), total_work))
|
||||
tq.update(end - i)
|
||||
return res
|
||||
|
||||
if is_both:
|
||||
short_res = _run("short")
|
||||
full_res = _run("full")
|
||||
# Per-frame keep whichever found more faces (tie → short).
|
||||
frames: list[list[dict]] = [
|
||||
short_res[bi] if len(short_res[bi]) >= len(full_res[bi]) else full_res[bi]
|
||||
for bi in range(B)
|
||||
]
|
||||
else:
|
||||
frames = _run(detector_variant)
|
||||
_fill_missing_frames(frames, missing_frame_fallback)
|
||||
bboxes = []
|
||||
for per_frame in frames:
|
||||
per_bb = []
|
||||
for f in per_frame:
|
||||
f["transformation_matrix"] = transformation_matrix_from_detection(f, W, H, canonical)
|
||||
x1, y1, x2, y2 = (float(v) for v in f["bbox_xyxy"])
|
||||
per_bb.append({"x": x1, "y": y1, "width": x2 - x1, "height": y2 - y1, "label": "face", "score": float(f["score"])})
|
||||
bboxes.append(per_bb)
|
||||
return io.NodeOutput({"frames": frames, "image_size": (H, W),
|
||||
"connection_sets": face_detection_model.connection_sets}, bboxes)
|
||||
|
||||
|
||||
# Topology keys unioned by the 'all' connections preset (contour parts + irises + nose).
|
||||
_ALL_CONNECTION_PARTS: tuple[str, ...] = (*_CONTOUR_PARTS, "irises", "nose")
|
||||
_CUSTOM_FEATURES: tuple[tuple[str, bool], ...] = (
|
||||
("face_oval", True),
|
||||
("lips", True),
|
||||
("left_eye", True),
|
||||
("right_eye", True),
|
||||
("left_eyebrow", True),
|
||||
("right_eyebrow", True),
|
||||
("irises", True),
|
||||
("nose", True),
|
||||
("tesselation", False),
|
||||
)
|
||||
|
||||
|
||||
class MediaPipeFaceMeshVisualize(io.ComfyNode):
|
||||
"""Draw a FACEMESH_* subset over an image. Topology travels with the
|
||||
FACE_LANDMARKS payload (set at detection time)."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="MediaPipeFaceMeshVisualize",
|
||||
search_aliases=["face", "facial", "mediapipe", "face landmark", "face mesh", "blazeface", "face detection", "visualize"],
|
||||
display_name="Visualize Face Landmarks (MediaPipe)",
|
||||
category="image/detection",
|
||||
description="Draws face landmarks mesh on the input image.",
|
||||
inputs=[
|
||||
FaceLandmarksType.Input("face_landmarks"),
|
||||
io.Image.Input("image", optional=True, tooltip="If not connected, a black canvas will be used."),
|
||||
io.DynamicCombo.Input(
|
||||
"connections",
|
||||
tooltip="'all' = oval+eyes+brows+lips+irises+nose. 'fill' = solid face_oval polygon (silhouette mask). 'custom' = toggle each feature individually (including 'tesselation', the full 2547-edge wireframe).",
|
||||
options=[
|
||||
io.DynamicCombo.Option("all", []),
|
||||
io.DynamicCombo.Option("fill", []),
|
||||
io.DynamicCombo.Option("custom", [
|
||||
io.Boolean.Input(feat, default=default,
|
||||
tooltip=f"Draw the '{feat}' connection set.")
|
||||
for feat, default in _CUSTOM_FEATURES
|
||||
]),
|
||||
],
|
||||
),
|
||||
io.Color.Input("color", default="#00ff00"),
|
||||
io.Int.Input("thickness", default=1, min=0, max=8, step=1,
|
||||
tooltip="Edge line thickness in pixels. 0 disables edge drawing."),
|
||||
io.Int.Input("point_size", default=2, min=0, max=16, step=1,
|
||||
tooltip="Landmark dot radius in pixels. 0 disables point drawing."),
|
||||
],
|
||||
outputs=[io.Image.Output()],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, face_landmarks, connections, color, thickness, point_size, image=None) -> io.NodeOutput:
|
||||
sets = face_landmarks["connection_sets"]
|
||||
sel = connections["connections"]
|
||||
fill_rings: list[list[int]] | None = None
|
||||
if sel == "fill":
|
||||
fill_rings = _ordered_rings(sets["face_oval"])
|
||||
edges = frozenset()
|
||||
elif sel == "custom":
|
||||
parts = [feat for feat, _ in _CUSTOM_FEATURES if connections.get(feat, False)]
|
||||
edges = frozenset().union(*(sets[p] for p in parts))
|
||||
else: # "all"
|
||||
edges = frozenset().union(*(sets[p] for p in _ALL_CONNECTION_PARTS))
|
||||
rgb, thick, psize = _parse_color(color), int(thickness), int(point_size)
|
||||
frames = face_landmarks["frames"]
|
||||
if image is None:
|
||||
H, W = face_landmarks["image_size"]
|
||||
img_np = np.zeros((len(frames), H, W, 3), dtype=np.uint8)
|
||||
else:
|
||||
img_np = _image_to_uint8(image)
|
||||
B = img_np.shape[0]
|
||||
n_frames = len(frames)
|
||||
pbar = comfy.utils.ProgressBar(B)
|
||||
out = np.empty_like(img_np)
|
||||
for bi in range(B):
|
||||
faces = frames[bi] if bi < n_frames else []
|
||||
out[bi] = _draw_mesh(img_np[bi], faces, edges, rgb, thick, psize, fill_rings)
|
||||
pbar.update_absolute(bi + 1)
|
||||
return io.NodeOutput(torch.from_numpy(out).to(
|
||||
device=comfy.model_management.intermediate_device(),
|
||||
dtype=comfy.model_management.intermediate_dtype(),
|
||||
).div_(255.0))
|
||||
|
||||
|
||||
def _draw_mesh(image_rgb: np.ndarray, faces: list, edges,
|
||||
rgb: tuple[int, int, int], thickness: int,
|
||||
point_size: int, fill_rings: list[list[int]] | None = None) -> np.ndarray:
|
||||
draw_edges = thickness > 0 and edges
|
||||
if not faces or (fill_rings is None and not draw_edges and point_size <= 0):
|
||||
return image_rgb.copy()
|
||||
pil = Image.fromarray(image_rgb)
|
||||
draw = ImageDraw.Draw(pil)
|
||||
r = point_size * 0.5
|
||||
if fill_rings is not None:
|
||||
for f in faces:
|
||||
lmks = f["landmarks_xy"]
|
||||
for ring in fill_rings:
|
||||
draw.polygon([(float(lmks[i, 0]), float(lmks[i, 1])) for i in ring], fill=rgb)
|
||||
return np.asarray(pil)
|
||||
for f in faces:
|
||||
lmks = f["landmarks_xy"]
|
||||
n = lmks.shape[0]
|
||||
if draw_edges:
|
||||
for a, b in edges:
|
||||
if a < n and b < n:
|
||||
draw.line([(float(lmks[a, 0]), float(lmks[a, 1])),
|
||||
(float(lmks[b, 0]), float(lmks[b, 1]))], fill=rgb, width=thickness)
|
||||
if point_size == 1:
|
||||
draw.point(lmks.flatten().tolist(), fill=rgb)
|
||||
elif point_size > 1:
|
||||
for x, y in lmks:
|
||||
draw.ellipse((float(x) - r, float(y) - r, float(x) + r, float(y) + r), fill=rgb)
|
||||
return np.asarray(pil)
|
||||
|
||||
|
||||
# Mask region presets — closed-loop topologies only.
|
||||
_MASK_REGIONS: tuple[str, ...] = ("face_oval", "lips", "left_eye", "right_eye", "irises")
|
||||
_MASK_CUSTOM_FEATURES: tuple[tuple[str, bool], ...] = (
|
||||
("face_oval", True),
|
||||
("lips", False),
|
||||
("left_eye", False),
|
||||
("right_eye", False),
|
||||
("irises", False),
|
||||
)
|
||||
|
||||
|
||||
class MediaPipeFaceMask(io.ComfyNode):
|
||||
"""Binary mask from face landmarks, filled polygon per face. One mask per
|
||||
frame in the batch; faces in the same frame composite (union)."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="MediaPipeFaceMask",
|
||||
search_aliases=["face", "facial", "mediapipe", "face mask", "blazeface", "face detection", "visualize"],
|
||||
display_name="Draw Face Mask (MediaPipe)",
|
||||
category="image/detection",
|
||||
description="Draws a mask from face landmarks.",
|
||||
inputs=[
|
||||
FaceLandmarksType.Input("face_landmarks"),
|
||||
io.DynamicCombo.Input(
|
||||
"regions",
|
||||
tooltip="'all' = union of face_oval+lips+eyes+irises (which collapses to face_oval since it encloses the rest). 'custom' = toggle each region individually for combos like lips+eyes.",
|
||||
options=[
|
||||
io.DynamicCombo.Option("all", []),
|
||||
io.DynamicCombo.Option("custom", [
|
||||
io.Boolean.Input(reg, default=default,
|
||||
tooltip=f"Include the '{reg}' region in the mask.")
|
||||
for reg, default in _MASK_CUSTOM_FEATURES
|
||||
]),
|
||||
],
|
||||
),
|
||||
],
|
||||
outputs=[io.Mask.Output()],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, face_landmarks, regions) -> io.NodeOutput:
|
||||
sets = face_landmarks["connection_sets"]
|
||||
sel = regions["regions"]
|
||||
if sel == "custom":
|
||||
picked = [reg for reg, _ in _MASK_CUSTOM_FEATURES if regions.get(reg, False)]
|
||||
else:
|
||||
picked = list(_MASK_REGIONS)
|
||||
rings = [r for reg in picked for r in _ordered_rings(sets[reg])]
|
||||
frames = face_landmarks["frames"]
|
||||
H, W = face_landmarks["image_size"]
|
||||
masks = np.zeros((len(frames), H, W), dtype=np.uint8)
|
||||
pbar = comfy.utils.ProgressBar(len(frames))
|
||||
for bi, per_frame in enumerate(frames):
|
||||
if per_frame:
|
||||
pil = Image.new("L", (W, H), 0)
|
||||
draw = ImageDraw.Draw(pil)
|
||||
for f in per_frame:
|
||||
lmks = f["landmarks_xy"]
|
||||
for ring in rings:
|
||||
draw.polygon([(float(lmks[i, 0]), float(lmks[i, 1])) for i in ring], fill=255)
|
||||
masks[bi] = np.asarray(pil)
|
||||
pbar.update_absolute(bi + 1)
|
||||
return io.NodeOutput(torch.from_numpy(masks).to(
|
||||
device=comfy.model_management.intermediate_device(),
|
||||
dtype=comfy.model_management.intermediate_dtype(),
|
||||
).div_(255.0))
|
||||
|
||||
|
||||
class MediaPipeFaceExtension(ComfyExtension):
|
||||
@override
|
||||
async def get_node_list(self) -> list[type[io.ComfyNode]]:
|
||||
return [LoadMediaPipeFaceLandmarker, MediaPipeFaceLandmarker, MediaPipeFaceMeshVisualize, MediaPipeFaceMask]
|
||||
|
||||
|
||||
async def comfy_entrypoint() -> MediaPipeFaceExtension:
|
||||
return MediaPipeFaceExtension()
|
||||
@@ -134,8 +134,11 @@ class ModelSamplingSD3:
|
||||
class ModelSamplingAdvanced(sampling_base, sampling_type):
|
||||
pass
|
||||
|
||||
original = m.get_model_object("model_sampling")
|
||||
model_sampling = ModelSamplingAdvanced(model.model.model_config)
|
||||
model_sampling.set_parameters(shift=shift, multiplier=multiplier)
|
||||
if hasattr(original, "noise_scale"):
|
||||
model_sampling.set_noise_scale(original.noise_scale)
|
||||
m.add_object_patch("model_sampling", model_sampling)
|
||||
return (m, )
|
||||
|
||||
@@ -300,6 +303,29 @@ class RescaleCFG:
|
||||
m.set_model_sampler_cfg_function(rescale_cfg)
|
||||
return (m, )
|
||||
|
||||
class ModelNoiseScale:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {"required": { "model": ("MODEL",),
|
||||
"noise_scale": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 64.0, "step": 0.01,
|
||||
"tooltip": "Absolute training noise scale. For example HiDream-O1 base: 8.0, dev: 7.5."}),
|
||||
}}
|
||||
|
||||
RETURN_TYPES = ("MODEL",)
|
||||
FUNCTION = "patch"
|
||||
|
||||
CATEGORY = "advanced/model"
|
||||
|
||||
def patch(self, model, noise_scale):
|
||||
m = model.clone()
|
||||
original = m.get_model_object("model_sampling")
|
||||
ms = type(original)(m.model.model_config)
|
||||
ms.set_parameters(shift=original.shift, multiplier=original.multiplier)
|
||||
ms.set_noise_scale(noise_scale)
|
||||
m.add_object_patch("model_sampling", ms)
|
||||
return (m, )
|
||||
|
||||
|
||||
class ModelComputeDtype:
|
||||
SEARCH_ALIASES = ["model precision", "change dtype"]
|
||||
@classmethod
|
||||
@@ -327,6 +353,7 @@ NODE_CLASS_MAPPINGS = {
|
||||
"ModelSamplingSD3": ModelSamplingSD3,
|
||||
"ModelSamplingAuraFlow": ModelSamplingAuraFlow,
|
||||
"ModelSamplingFlux": ModelSamplingFlux,
|
||||
"ModelNoiseScale": ModelNoiseScale,
|
||||
"RescaleCFG": RescaleCFG,
|
||||
"ModelComputeDtype": ModelComputeDtype,
|
||||
}
|
||||
|
||||
@@ -276,8 +276,8 @@ class CLIPSave:
|
||||
for x in extra_pnginfo:
|
||||
metadata[x] = json.dumps(extra_pnginfo[x])
|
||||
|
||||
comfy.model_management.load_models_gpu([clip.load_model()], force_patch_weights=True)
|
||||
clip_sd = clip.get_sd()
|
||||
clip.load_model()
|
||||
clip_sd = clip.state_dict_for_saving()
|
||||
|
||||
for prefix in ["clip_l.", "clip_g.", "clip_h.", "t5xxl.", "pile_t5xl.", "mt5xl.", "umt5xxl.", "t5base.", "gemma2_2b.", "llama.", "hydit_clip.", ""]:
|
||||
k = list(filter(lambda a: a.startswith(prefix), clip_sd.keys()))
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
"""ComfyUI nodes for the native MoGe (Monocular Geometry Estimation) integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
|
||||
import comfy.utils
|
||||
import folder_paths
|
||||
from comfy_api.latest import ComfyExtension, Types, io
|
||||
from typing_extensions import override
|
||||
|
||||
from comfy.ldm.moge.model import MoGeModel
|
||||
from comfy.ldm.moge.geometry import triangulate_grid_mesh
|
||||
from comfy.ldm.moge.panorama import get_panorama_cameras, split_panorama_image, merge_panorama_depth, spherical_uv_to_directions, _uv_grid
|
||||
import comfy.model_management
|
||||
from tqdm.auto import tqdm
|
||||
|
||||
MoGeModelType = io.Custom("MOGE_MODEL")
|
||||
MoGeGeometry = io.Custom("MOGE_GEOMETRY")
|
||||
|
||||
|
||||
# MOGE_GEOMETRY is a dict with these optional keys (absent when the upstream model didn't produce them):
|
||||
# "points": torch.Tensor (B, H, W, 3)
|
||||
# "depth": torch.Tensor (B, H, W)
|
||||
# "intrinsics": torch.Tensor (B, 3, 3) -- perspective only
|
||||
# "mask": torch.Tensor (B, H, W) bool
|
||||
# "normal": torch.Tensor (B, H, W, 3) -- v2 only
|
||||
# "image": torch.Tensor (B, H, W, 3) in [0, 1], CPU (always present)
|
||||
|
||||
|
||||
def _turbo(x: torch.Tensor) -> torch.Tensor:
|
||||
"""Anton Mikhailov polynomial approximation of the turbo colormap."""
|
||||
x = x.clamp(0.0, 1.0)
|
||||
x2 = x * x
|
||||
x3 = x2 * x
|
||||
x4 = x2 * x2
|
||||
x5 = x4 * x
|
||||
r = 0.13572138 + 4.61539260*x - 42.66032258*x2 + 132.13108234*x3 - 152.94239396*x4 + 59.28637943*x5
|
||||
g = 0.09140261 + 2.19418839*x + 4.84296658*x2 - 14.18503333*x3 + 4.27729857*x4 + 2.82956604*x5
|
||||
b = 0.10667330 + 12.64194608*x - 60.58204836*x2 + 110.36276771*x3 - 89.90310912*x4 + 27.34824973*x5
|
||||
return torch.stack([r, g, b], dim=-1).clamp(0.0, 1.0)
|
||||
|
||||
|
||||
def _normals_from_points(points: torch.Tensor) -> torch.Tensor:
|
||||
"""Camera-space surface normals from a (B, H, W, 3) point map (v1 fallback)."""
|
||||
finite = torch.isfinite(points).all(dim=-1)
|
||||
pts = torch.where(finite.unsqueeze(-1), points, torch.zeros_like(points))
|
||||
dx = pts[..., :, 2:, :] - pts[..., :, :-2, :]
|
||||
dy = pts[..., 2:, :, :] - pts[..., :-2, :, :]
|
||||
dx = torch.nn.functional.pad(dx.permute(0, 3, 1, 2), (1, 1, 0, 0)).permute(0, 2, 3, 1)
|
||||
dy = torch.nn.functional.pad(dy.permute(0, 3, 1, 2), (0, 0, 1, 1)).permute(0, 2, 3, 1)
|
||||
# dy x dx (not dx x dy) so the result is outward-facing in OpenCV (Y-down flips the right-hand rule), matching v2's predicted normals.
|
||||
n = torch.cross(dy, dx, dim=-1)
|
||||
n = torch.nn.functional.normalize(n, dim=-1)
|
||||
return torch.where(finite.unsqueeze(-1), n, torch.zeros_like(n))
|
||||
|
||||
|
||||
def _normalize_disparity(depth: torch.Tensor) -> torch.Tensor:
|
||||
"""Per-batch normalize 1/depth to [0, 1] using 0.1/99.9 percentile clipping."""
|
||||
out = torch.zeros_like(depth)
|
||||
for i in range(depth.shape[0]):
|
||||
d = depth[i]
|
||||
valid = torch.isfinite(d) & (d > 0)
|
||||
if not valid.any():
|
||||
continue
|
||||
disp = torch.where(valid, 1.0 / d.clamp_min(1e-6), torch.zeros_like(d))
|
||||
disp_valid = disp[valid]
|
||||
lo = torch.quantile(disp_valid, 0.001)
|
||||
hi = torch.quantile(disp_valid, 0.999)
|
||||
scale = (hi - lo).clamp_min(1e-6)
|
||||
norm = ((disp - lo) / scale).clamp(0.0, 1.0)
|
||||
out[i] = torch.where(valid, norm, torch.zeros_like(norm))
|
||||
return out
|
||||
|
||||
|
||||
class LoadMoGeModel(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="LoadMoGeModel",
|
||||
display_name="Load MoGe Model",
|
||||
category="loaders",
|
||||
inputs=[
|
||||
io.Combo.Input("model_name", options=folder_paths.get_filename_list("geometry_estimation")),
|
||||
],
|
||||
outputs=[MoGeModelType.Output()],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, model_name) -> io.NodeOutput:
|
||||
path = folder_paths.get_full_path_or_raise("geometry_estimation", model_name)
|
||||
sd = comfy.utils.load_torch_file(path, safe_load=True)
|
||||
return io.NodeOutput(MoGeModel(sd))
|
||||
|
||||
|
||||
class MoGePanoramaInference(io.ComfyNode):
|
||||
"""Equirectangular panorama inference: split into 12 perspective views, run
|
||||
MoGe at fov_x=90 on each, merge via multi-scale Poisson + gradient solve.
|
||||
v2's predicted normals and metric scale are ignored (per-view scales would not align across seams).
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="MoGePanoramaInference",
|
||||
search_aliases=["moge", "panorama", "depth", "geometry", "depth estimation", "geometry estimation"],
|
||||
display_name="Run MoGe Panorama Inference",
|
||||
category="image/geometry_estimation",
|
||||
description="Run MoGe on an equirectangular panorama by splitting it into 12 perspective views, running inference on each, and merging the results into a single depth map.",
|
||||
inputs=[
|
||||
MoGeModelType.Input("moge_model"),
|
||||
io.Image.Input("image", tooltip="Equirectangular panorama (any aspect)."),
|
||||
io.Int.Input("resolution_level", default=9, min=0, max=9,
|
||||
tooltip="Per-view detail (0 = fastest, 9 = most detailed)."),
|
||||
io.Int.Input("split_resolution", default=512, min=256, max=1024,
|
||||
tooltip="Resolution of each perspective split."),
|
||||
io.Int.Input("merge_resolution", default=1920, min=256, max=8192,
|
||||
tooltip="Long-side resolution of the merged equirect distance map."),
|
||||
io.Int.Input("batch_size", default=4, min=1, max=12,
|
||||
tooltip="Views per inference batch (12 splits total)."),
|
||||
],
|
||||
outputs=[MoGeGeometry.Output(display_name="moge_geometry")],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, moge_model, image, resolution_level, split_resolution, merge_resolution, batch_size) -> io.NodeOutput:
|
||||
|
||||
if image.shape[0] != 1:
|
||||
raise ValueError(f"MoGePanoramaInference takes a single image (got batch of {image.shape[0]})")
|
||||
|
||||
image = image[..., :3]
|
||||
H, W = int(image.shape[1]), int(image.shape[2])
|
||||
scale = min(merge_resolution / max(H, W), 1.0)
|
||||
merge_h, merge_w = max(int(H * scale), 32), max(int(W * scale), 32)
|
||||
|
||||
extrinsics, intrinsics = get_panorama_cameras()
|
||||
|
||||
comfy.model_management.load_model_gpu(moge_model.patcher)
|
||||
device = moge_model.load_device
|
||||
img_chw = image[0].movedim(-1, -3).to(device=device, dtype=moge_model.dtype)
|
||||
splits = split_panorama_image(img_chw, extrinsics, intrinsics, split_resolution)
|
||||
|
||||
n_views = splits.shape[0]
|
||||
|
||||
# Weight each lsmr solve by 4^level so the final-resolution solve doesn't leave the bar idle.
|
||||
merge_levels: list[tuple[int, int]] = []
|
||||
w_, h_ = merge_w, merge_h
|
||||
while True:
|
||||
merge_levels.append((w_, h_))
|
||||
if max(w_, h_) <= 256:
|
||||
break
|
||||
w_, h_ = w_ // 2, h_ // 2
|
||||
merge_levels.reverse()
|
||||
|
||||
solve_weight = {wh: 4 ** i for i, wh in enumerate(merge_levels)}
|
||||
n_merge_view_units = n_views * len(merge_levels)
|
||||
n_merge_solve_units = sum(solve_weight.values())
|
||||
|
||||
pbar = comfy.utils.ProgressBar(n_views + n_merge_view_units + n_merge_solve_units)
|
||||
done = 0
|
||||
|
||||
distance_maps: list = []
|
||||
masks: list = []
|
||||
with tqdm(total=n_views, desc="MoGe panorama inference") as tq:
|
||||
for i in range(0, n_views, batch_size):
|
||||
batch = splits[i:i + batch_size]
|
||||
# apply_metric_scale=False: per-view scales would not align across overlap seams.
|
||||
result = moge_model.infer(batch, resolution_level=resolution_level,
|
||||
fov_x=90.0, force_projection=True,
|
||||
apply_mask=False, apply_metric_scale=False)
|
||||
distance_maps.extend(list(result["points"].float().norm(dim=-1).cpu().numpy()))
|
||||
masks.extend(list(result["mask"].cpu().numpy()))
|
||||
n = batch.shape[0]
|
||||
done += n
|
||||
pbar.update_absolute(done)
|
||||
tq.update(n)
|
||||
|
||||
with tqdm(total=n_merge_view_units + n_merge_solve_units, desc="MoGe panorama merge: views") as tq:
|
||||
def _on_merge_view():
|
||||
nonlocal done
|
||||
done += 1
|
||||
pbar.update_absolute(done)
|
||||
tq.update(1)
|
||||
|
||||
def _on_solve_start(w, h):
|
||||
tq.set_description(f"MoGe panorama merge: solving {w}x{h}")
|
||||
|
||||
def _on_solve_end(w, h):
|
||||
nonlocal done
|
||||
weight = solve_weight[(w, h)]
|
||||
done += weight
|
||||
pbar.update_absolute(done)
|
||||
tq.update(weight)
|
||||
tq.set_description("MoGe panorama merge: views")
|
||||
|
||||
pano_depth, pano_mask = merge_panorama_depth(
|
||||
merge_w, merge_h, distance_maps, masks, list(extrinsics), intrinsics,
|
||||
on_view=_on_merge_view, on_solve_start=_on_solve_start, on_solve_end=_on_solve_end)
|
||||
|
||||
pano_depth = torch.from_numpy(pano_depth)
|
||||
pano_mask = torch.from_numpy(pano_mask)
|
||||
|
||||
if (merge_h, merge_w) != (H, W):
|
||||
pano_depth = torch.nn.functional.interpolate(pano_depth[None, None], size=(H, W), mode="bilinear", align_corners=False).squeeze()
|
||||
pano_mask = torch.nn.functional.interpolate(pano_mask[None, None].float(), size=(H, W), mode="nearest").squeeze() > 0
|
||||
|
||||
# Pixels uncovered by any view's predicted foreground are unconstrained in the lsmr solve and stay at log_depth=0 (depth=1)
|
||||
if pano_mask.any() and not pano_mask.all():
|
||||
far = torch.quantile(pano_depth[pano_mask], 0.95) * 5.0
|
||||
pano_depth = torch.where(pano_mask, pano_depth, far)
|
||||
|
||||
directions = torch.from_numpy(spherical_uv_to_directions(_uv_grid(H, W)))
|
||||
points = (directions * pano_depth[..., None]).unsqueeze(0)
|
||||
depth = pano_depth.unsqueeze(0)
|
||||
mask = pano_mask.unsqueeze(0)
|
||||
|
||||
# Points stay in MoGe spherical coords; MoGePointMapToMesh applies the spherical->glTF rotation after triangulation
|
||||
moge_geometry = {"points": points, "depth": depth, "mask": mask, "image": image.cpu()}
|
||||
return io.NodeOutput(moge_geometry)
|
||||
|
||||
|
||||
class MoGeInference(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="MoGeInference",
|
||||
search_aliases=["moge", "depth", "geometry", "depth estimation", "geometry estimation"],
|
||||
display_name="Run MoGe Inference",
|
||||
description="Run MoGe on a single image to estimate depth and geometry.",
|
||||
category="image/geometry_estimation",
|
||||
inputs=[
|
||||
MoGeModelType.Input("moge_model"),
|
||||
io.Image.Input("image"),
|
||||
io.Int.Input("resolution_level", default=9, min=0, max=9,
|
||||
tooltip="0 = fastest, 9 = most detail."),
|
||||
io.Float.Input("fov_x_degrees", default=0.0, min=0.0, max=170.0, step=0.1, advanced=True,
|
||||
tooltip="Horizontal field of view of the source camera. Sets the focal length used to unproject the depth map into 3D. 0 = auto-recover from the predicted points."),
|
||||
io.Int.Input("batch_size", default=4, min=1, max=64,
|
||||
tooltip="Images per inference call. Lower if you OOM on a long video / image set."),
|
||||
io.Boolean.Input("force_projection", default=True, advanced=True),
|
||||
io.Boolean.Input("apply_mask", default=True, advanced=True,
|
||||
tooltip="Set masked-out (sky / invalid) pixels to inf in points and depth so meshing culls them. Disable to keep the raw predicted geometry everywhere; the mask is still returned separately."),
|
||||
],
|
||||
outputs=[MoGeGeometry.Output(display_name="moge_geometry")],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, moge_model, image, resolution_level, fov_x_degrees, batch_size, force_projection, apply_mask) -> io.NodeOutput:
|
||||
|
||||
image = image[..., :3]
|
||||
bchw = image.movedim(-1, -3).contiguous()
|
||||
B = bchw.shape[0]
|
||||
fov = None if fov_x_degrees <= 0 else float(fov_x_degrees)
|
||||
|
||||
pbar = comfy.utils.ProgressBar(B)
|
||||
chunks: list[dict] = []
|
||||
with tqdm(total=B, desc="MoGe inference") as tq:
|
||||
for i in range(0, B, batch_size):
|
||||
chunk = bchw[i:i + batch_size]
|
||||
chunks.append(moge_model.infer(chunk, resolution_level=resolution_level, fov_x=fov,
|
||||
force_projection=force_projection, apply_mask=apply_mask))
|
||||
pbar.update_absolute(min(i + batch_size, B))
|
||||
tq.update(chunk.shape[0])
|
||||
|
||||
def stack(field):
|
||||
vals = [c[field] for c in chunks if field in c]
|
||||
return torch.cat(vals, dim=0) if vals else None
|
||||
|
||||
moge_geometry = {"image": image.cpu()}
|
||||
for field in ("points", "depth", "intrinsics", "mask", "normal"):
|
||||
v = stack(field)
|
||||
if v is not None:
|
||||
moge_geometry[field] = v
|
||||
return io.NodeOutput(moge_geometry)
|
||||
|
||||
|
||||
class MoGeRender(io.ComfyNode):
|
||||
"""Render a visualization or mask from a MOGE_GEOMETRY packet."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="MoGeRender",
|
||||
search_aliases=["moge", "render", "geometry", "depth", "normal"],
|
||||
display_name="Render MoGe Geometry",
|
||||
description="Render a depth map or normal map from geometry data",
|
||||
category="image/geometry_estimation",
|
||||
inputs=[
|
||||
MoGeGeometry.Input("moge_geometry"),
|
||||
io.Combo.Input("output", options=["depth", "depth_colored", "normal_opengl", "normal_directx", "mask"], default="depth",
|
||||
tooltip="DirectX vs OpenGL controls the normal-map green-channel convention. DirectX: green = -Y down (Unreal). OpenGL: green = +Y up (Blender, Substance, Unity, glTF)."),
|
||||
],
|
||||
outputs=[io.Image.Output()],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, moge_geometry, output) -> io.NodeOutput:
|
||||
is_normal = output in ("normal_directx", "normal_opengl")
|
||||
opengl = output.endswith("_opengl")
|
||||
|
||||
# Pick the input tensor for the chosen mode and validate availability.
|
||||
if output in ("depth", "depth_colored"):
|
||||
if "depth" not in moge_geometry:
|
||||
raise ValueError("moge_geometry has no depth output.")
|
||||
src = moge_geometry["depth"]
|
||||
elif is_normal:
|
||||
if "normal" in moge_geometry:
|
||||
src = moge_geometry["normal"]
|
||||
elif "points" in moge_geometry:
|
||||
src = moge_geometry["points"]
|
||||
else:
|
||||
raise ValueError("moge_geometry has neither normals nor points to derive normals from.")
|
||||
elif output == "mask":
|
||||
if "mask" not in moge_geometry:
|
||||
raise ValueError("moge_geometry has no mask output.")
|
||||
src = moge_geometry["mask"]
|
||||
else:
|
||||
raise ValueError(f"Unknown output mode: {output}")
|
||||
|
||||
B = src.shape[0]
|
||||
pbar = comfy.utils.ProgressBar(B)
|
||||
out: list[torch.Tensor] = []
|
||||
with tqdm(total=B, desc=f"MoGe render: {output}") as tq:
|
||||
for i in range(B):
|
||||
slc = src[i:i + 1].float()
|
||||
if output in ("depth", "depth_colored"):
|
||||
d = _normalize_disparity(slc)
|
||||
out.append(_turbo(d) if output == "depth_colored"
|
||||
else d.unsqueeze(-1).expand(*d.shape, 3).contiguous())
|
||||
elif is_normal:
|
||||
n = slc if "normal" in moge_geometry else _normals_from_points(slc)
|
||||
# MoGe is OpenCV (Z+ into scene); normal-map convention is Z+ out of surface, so flip Z.
|
||||
y_sign = -1.0 if opengl else 1.0
|
||||
n = n * n.new_tensor([1.0, y_sign, -1.0])
|
||||
out.append((n * 0.5 + 0.5).clamp(0.0, 1.0))
|
||||
elif output == "mask":
|
||||
out.append(slc.unsqueeze(-1).expand(*slc.shape, 3).contiguous())
|
||||
pbar.update_absolute(i + 1)
|
||||
tq.update(1)
|
||||
result = torch.cat(out, dim=0).to(device=comfy.model_management.intermediate_device(), dtype=comfy.model_management.intermediate_dtype())
|
||||
return io.NodeOutput(result)
|
||||
|
||||
|
||||
class MoGePointMapToMesh(io.ComfyNode):
|
||||
"""Triangulate one image of a MoGe point map into a Types.MESH (UVs + texture)."""
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="MoGePointMapToMesh",
|
||||
search_aliases=["moge", "mesh", "geometry", "point map"],
|
||||
display_name="Convert MoGe Point Map to Mesh",
|
||||
description="Convert a MoGe point map into a 3D mesh.",
|
||||
category="image/geometry_estimation",
|
||||
inputs=[
|
||||
MoGeGeometry.Input("moge_geometry"),
|
||||
io.Int.Input("batch_index", default=0, min=0, max=4096,
|
||||
tooltip="Which image of a batched MoGe geometry to mesh. Per-image vertex counts "
|
||||
"differ, so batches can't be stacked into a single MESH."),
|
||||
io.Int.Input("decimation", default=1, min=1, max=8,
|
||||
tooltip="Vertex stride; 1 = full resolution."),
|
||||
io.Float.Input("discontinuity_threshold", default=0.04, min=0.0, max=1.0, step=0.01,
|
||||
tooltip="Drop pixels whose 3x3 depth span exceeds this fraction. 0 = off."),
|
||||
io.Boolean.Input("texture", default=True,
|
||||
tooltip="Carry the source image through as the baseColor texture."),
|
||||
],
|
||||
outputs=[io.Mesh.Output()],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, moge_geometry, batch_index, decimation, discontinuity_threshold, texture) -> io.NodeOutput:
|
||||
if "points" not in moge_geometry:
|
||||
raise ValueError("moge_geometry has no points output.")
|
||||
points = moge_geometry["points"]
|
||||
B = points.shape[0]
|
||||
if batch_index >= B:
|
||||
raise ValueError(f"batch_index {batch_index} out of range; moge_geometry has batch size {B}.")
|
||||
|
||||
# Pass depth so the rtol edge check sees radial depth -- for panoramas
|
||||
# points[..., 2] = cos(phi)*r goes negative below the equator and the rtol clamp would drop the bottom half.
|
||||
edge_depth = moge_geometry["depth"][batch_index] if "depth" in moge_geometry else None
|
||||
verts, faces, uvs = triangulate_grid_mesh(
|
||||
points[batch_index], decimation=decimation,
|
||||
discontinuity_threshold=discontinuity_threshold, depth=edge_depth,
|
||||
)
|
||||
if verts.shape[0] == 0 or faces.shape[0] == 0:
|
||||
raise ValueError("MoGe produced an empty mesh; try discontinuity_threshold=0 or apply_mask=False.")
|
||||
|
||||
if "intrinsics" not in moge_geometry:
|
||||
# Panorama: rotate MoGe spherical (Z up) -> glTF (Y up, Z back), correct for inside-the-sphere viewing)
|
||||
verts = verts[:, [1, 2, 0]].contiguous()
|
||||
else:
|
||||
# Perspective MoGe (X right, Y down, Z forward) -> glTF; face flip keeps winding CCW after the Y/Z flip.
|
||||
verts = verts * torch.tensor([1.0, -1.0, -1.0], dtype=verts.dtype)
|
||||
faces = faces[:, [0, 2, 1]].contiguous()
|
||||
|
||||
tex = moge_geometry["image"][batch_index:batch_index + 1] if texture else None
|
||||
mesh = Types.MESH(
|
||||
vertices=verts.unsqueeze(0),
|
||||
faces=faces.unsqueeze(0),
|
||||
uvs=uvs.unsqueeze(0),
|
||||
texture=tex,
|
||||
)
|
||||
return io.NodeOutput(mesh)
|
||||
|
||||
|
||||
class MoGeExtension(ComfyExtension):
|
||||
@override
|
||||
async def get_node_list(self) -> list[type[io.ComfyNode]]:
|
||||
return [LoadMoGeModel, MoGeInference, MoGePanoramaInference, MoGeRender, MoGePointMapToMesh]
|
||||
|
||||
|
||||
async def comfy_entrypoint() -> MoGeExtension:
|
||||
return MoGeExtension()
|
||||
@@ -13,8 +13,8 @@ class Morphology(io.ComfyNode):
|
||||
return io.Schema(
|
||||
node_id="Morphology",
|
||||
search_aliases=["erode", "dilate"],
|
||||
display_name="ImageMorphology",
|
||||
category="image/postprocessing",
|
||||
display_name="Apply Morphology",
|
||||
category="image/filters",
|
||||
inputs=[
|
||||
io.Image.Input("image"),
|
||||
io.Combo.Input(
|
||||
|
||||
@@ -13,7 +13,7 @@ class wanBlockSwap(io.ComfyNode):
|
||||
return io.Schema(
|
||||
node_id="wanBlockSwap",
|
||||
category="",
|
||||
description="NOP",
|
||||
description="Intercept wanBlockSwap custom node that causes major instability and make it no-op.",
|
||||
inputs=[
|
||||
io.Model.Input("model"),
|
||||
],
|
||||
|
||||
@@ -20,7 +20,7 @@ class NumberConvertNode(io.ComfyNode):
|
||||
def define_schema(cls) -> io.Schema:
|
||||
return io.Schema(
|
||||
node_id="ComfyNumberConvert",
|
||||
display_name="Number Convert",
|
||||
display_name="Convert Number",
|
||||
category="utils",
|
||||
search_aliases=[
|
||||
"int to float", "float to int", "number convert",
|
||||
|
||||
@@ -31,7 +31,7 @@ class OptimalStepsScheduler(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="OptimalStepsScheduler",
|
||||
category="sampling/custom_sampling/schedulers",
|
||||
category="sampling/schedulers",
|
||||
inputs=[
|
||||
io.Combo.Input("model_type", options=["FLUX", "Wan", "Chroma"]),
|
||||
io.Int.Input("steps", default=20, min=3, max=1000),
|
||||
|
||||
@@ -22,7 +22,7 @@ class Blend(io.ComfyNode):
|
||||
node_id="ImageBlend",
|
||||
search_aliases=["mix images"],
|
||||
display_name="Blend Images",
|
||||
category="image/postprocessing",
|
||||
category="image/filters",
|
||||
essentials_category="Image Tools",
|
||||
inputs=[
|
||||
io.Image.Input("image1"),
|
||||
@@ -80,8 +80,8 @@ class Blur(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="ImageBlur",
|
||||
display_name="Image Blur",
|
||||
category="image/postprocessing",
|
||||
display_name="Blur Image",
|
||||
category="image/filters",
|
||||
inputs=[
|
||||
io.Image.Input("image"),
|
||||
io.Int.Input("blur_radius", default=1, min=1, max=31, step=1),
|
||||
@@ -117,7 +117,7 @@ class Quantize(io.ComfyNode):
|
||||
return io.Schema(
|
||||
node_id="ImageQuantize",
|
||||
display_name="Quantize Image",
|
||||
category="image/postprocessing",
|
||||
category="image/filters",
|
||||
inputs=[
|
||||
io.Image.Input("image"),
|
||||
io.Int.Input("colors", default=256, min=1, max=256, step=1),
|
||||
@@ -183,7 +183,7 @@ class Sharpen(io.ComfyNode):
|
||||
return io.Schema(
|
||||
node_id="ImageSharpen",
|
||||
display_name="Sharpen Image",
|
||||
category="image/postprocessing",
|
||||
category="image/filters",
|
||||
inputs=[
|
||||
io.Image.Input("image"),
|
||||
io.Int.Input("sharpen_radius", default=1, min=1, max=31, step=1, advanced=True),
|
||||
@@ -568,7 +568,7 @@ def batch_latents(latents: list[dict[str, torch.Tensor]]) -> dict[str, torch.Ten
|
||||
class BatchImagesNode(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
autogrow_template = io.Autogrow.TemplatePrefix(io.Image.Input("image"), prefix="image", min=2, max=50)
|
||||
autogrow_template = io.Autogrow.TemplatePrefix(io.Image.Input("image"), prefix="image", min=1, max=50)
|
||||
return io.Schema(
|
||||
node_id="BatchImagesNode",
|
||||
display_name="Batch Images",
|
||||
@@ -590,12 +590,12 @@ class BatchImagesNode(io.ComfyNode):
|
||||
class BatchMasksNode(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
autogrow_template = io.Autogrow.TemplatePrefix(io.Mask.Input("mask"), prefix="mask", min=2, max=50)
|
||||
autogrow_template = io.Autogrow.TemplatePrefix(io.Mask.Input("mask"), prefix="mask", min=1, max=50)
|
||||
return io.Schema(
|
||||
node_id="BatchMasksNode",
|
||||
search_aliases=["combine masks", "stack masks", "merge masks"],
|
||||
display_name="Batch Masks",
|
||||
category="mask",
|
||||
category="image/mask",
|
||||
inputs=[
|
||||
io.Autogrow.Input("masks", template=autogrow_template)
|
||||
],
|
||||
@@ -611,7 +611,7 @@ class BatchMasksNode(io.ComfyNode):
|
||||
class BatchLatentsNode(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
autogrow_template = io.Autogrow.TemplatePrefix(io.Latent.Input("latent"), prefix="latent", min=2, max=50)
|
||||
autogrow_template = io.Autogrow.TemplatePrefix(io.Latent.Input("latent"), prefix="latent", min=1, max=50)
|
||||
return io.Schema(
|
||||
node_id="BatchLatentsNode",
|
||||
search_aliases=["combine latents", "stack latents", "merge latents"],
|
||||
@@ -670,8 +670,8 @@ class ColorTransfer(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="ColorTransfer",
|
||||
display_name="Color Transfer",
|
||||
category="image/postprocessing",
|
||||
display_name="Transfer Color",
|
||||
category="image/filters",
|
||||
description="Match the colors of one image to another using various algorithms.",
|
||||
search_aliases=["color match", "color grading", "color correction", "match colors", "color transform", "mkl", "reinhard", "histogram"],
|
||||
inputs=[
|
||||
|
||||
@@ -15,7 +15,7 @@ class RTDETR_detect(io.ComfyNode):
|
||||
return io.Schema(
|
||||
node_id="RTDETR_detect",
|
||||
display_name="RT-DETR Detect",
|
||||
category="detection",
|
||||
category="image/detection",
|
||||
search_aliases=["bbox", "bounding box", "object detection", "coco"],
|
||||
inputs=[
|
||||
io.Model.Input("model", display_name="model"),
|
||||
@@ -71,7 +71,7 @@ class DrawBBoxes(io.ComfyNode):
|
||||
return io.Schema(
|
||||
node_id="DrawBBoxes",
|
||||
display_name="Draw BBoxes",
|
||||
category="detection",
|
||||
category="image/detection",
|
||||
search_aliases=["bbox", "bounding box", "object detection", "rt_detr", "visualize detections", "coco"],
|
||||
inputs=[
|
||||
io.Image.Input("image", optional=True),
|
||||
|
||||
@@ -93,7 +93,7 @@ class SAM3_Detect(io.ComfyNode):
|
||||
return io.Schema(
|
||||
node_id="SAM3_Detect",
|
||||
display_name="SAM3 Detect",
|
||||
category="detection",
|
||||
category="image/detection",
|
||||
search_aliases=["sam3", "segment anything", "open vocabulary", "text detection", "segment"],
|
||||
inputs=[
|
||||
io.Model.Input("model", display_name="model"),
|
||||
@@ -265,7 +265,7 @@ class SAM3_VideoTrack(io.ComfyNode):
|
||||
return io.Schema(
|
||||
node_id="SAM3_VideoTrack",
|
||||
display_name="SAM3 Video Track",
|
||||
category="detection",
|
||||
category="image/detection",
|
||||
search_aliases=["sam3", "video", "track", "propagate"],
|
||||
inputs=[
|
||||
io.Image.Input("images", display_name="images", tooltip="Video frames as batched images"),
|
||||
@@ -320,7 +320,7 @@ class SAM3_TrackPreview(io.ComfyNode):
|
||||
return io.Schema(
|
||||
node_id="SAM3_TrackPreview",
|
||||
display_name="SAM3 Track Preview",
|
||||
category="detection",
|
||||
category="image/detection",
|
||||
inputs=[
|
||||
SAM3TrackData.Input("track_data", display_name="track_data"),
|
||||
io.Image.Input("images", display_name="images", optional=True),
|
||||
@@ -478,7 +478,7 @@ class SAM3_TrackToMask(io.ComfyNode):
|
||||
return io.Schema(
|
||||
node_id="SAM3_TrackToMask",
|
||||
display_name="SAM3 Track to Mask",
|
||||
category="detection",
|
||||
category="image/detection",
|
||||
inputs=[
|
||||
SAM3TrackData.Input("track_data", display_name="track_data"),
|
||||
io.String.Input("object_indices", display_name="object_indices", default="",
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
"""Save-side 3D nodes: mesh packing/slicing helpers + GLB writer + SaveGLB node."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
from io import BytesIO
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
import torch
|
||||
from typing_extensions import override
|
||||
|
||||
import folder_paths
|
||||
from comfy.cli_args import args
|
||||
from comfy_api.latest import ComfyExtension, IO, Types
|
||||
|
||||
|
||||
def pack_variable_mesh_batch(vertices, faces, colors=None, uvs=None, texture=None):
|
||||
# Pack lists of (Nᵢ, *) vertex/face/color/uv tensors into padded batched tensors,
|
||||
# stashing per-item lengths as runtime attrs so consumers can recover the real slice.
|
||||
# colors and uvs are 1:1 with vertices, so they're padded to max_vertices and read with vertex_counts.
|
||||
# texture is (B, H, W, 3) — passed through unchanged
|
||||
batch_size = len(vertices)
|
||||
max_vertices = max(v.shape[0] for v in vertices)
|
||||
max_faces = max(f.shape[0] for f in faces)
|
||||
|
||||
packed_vertices = vertices[0].new_zeros((batch_size, max_vertices, vertices[0].shape[1]))
|
||||
packed_faces = faces[0].new_zeros((batch_size, max_faces, faces[0].shape[1]))
|
||||
vertex_counts = torch.tensor([v.shape[0] for v in vertices], device=vertices[0].device, dtype=torch.int64)
|
||||
face_counts = torch.tensor([f.shape[0] for f in faces], device=faces[0].device, dtype=torch.int64)
|
||||
|
||||
for i, (v, f) in enumerate(zip(vertices, faces)):
|
||||
packed_vertices[i, :v.shape[0]] = v
|
||||
packed_faces[i, :f.shape[0]] = f
|
||||
|
||||
packed_colors = None
|
||||
if colors is not None:
|
||||
packed_colors = colors[0].new_zeros((batch_size, max_vertices, colors[0].shape[1]))
|
||||
for i, c in enumerate(colors):
|
||||
assert c.shape[0] == vertices[i].shape[0], (
|
||||
f"vertex_colors[{i}] has {c.shape[0]} entries, expected {vertices[i].shape[0]} (1:1 with vertices)"
|
||||
)
|
||||
packed_colors[i, :c.shape[0]] = c
|
||||
|
||||
packed_uvs = None
|
||||
if uvs is not None:
|
||||
packed_uvs = uvs[0].new_zeros((batch_size, max_vertices, uvs[0].shape[1]))
|
||||
for i, u in enumerate(uvs):
|
||||
assert u.shape[0] == vertices[i].shape[0], (
|
||||
f"uvs[{i}] has {u.shape[0]} entries, expected {vertices[i].shape[0]} (1:1 with vertices)"
|
||||
)
|
||||
packed_uvs[i, :u.shape[0]] = u
|
||||
|
||||
return Types.MESH(packed_vertices, packed_faces,
|
||||
uvs=packed_uvs, vertex_colors=packed_colors, texture=texture,
|
||||
vertex_counts=vertex_counts, face_counts=face_counts)
|
||||
|
||||
|
||||
def get_mesh_batch_item(mesh, index):
|
||||
# Returns (vertices, faces, colors, uvs) for batch index, slicing to real lengths
|
||||
# if the mesh carries per-item counts (variable-size batch).
|
||||
v_colors = getattr(mesh, "vertex_colors", None)
|
||||
v_uvs = getattr(mesh, "uvs", None)
|
||||
if getattr(mesh, "vertex_counts", None) is not None:
|
||||
vertex_count = int(mesh.vertex_counts[index].item())
|
||||
face_count = int(mesh.face_counts[index].item())
|
||||
vertices = mesh.vertices[index, :vertex_count]
|
||||
faces = mesh.faces[index, :face_count]
|
||||
colors = v_colors[index, :vertex_count] if v_colors is not None else None
|
||||
uvs = v_uvs[index, :vertex_count] if v_uvs is not None else None
|
||||
return vertices, faces, colors, uvs
|
||||
|
||||
colors = v_colors[index] if v_colors is not None else None
|
||||
uvs = v_uvs[index] if v_uvs is not None else None
|
||||
return mesh.vertices[index], mesh.faces[index], colors, uvs
|
||||
|
||||
|
||||
def save_glb(vertices, faces, filepath, metadata=None,
|
||||
uvs=None, vertex_colors=None, texture_image=None):
|
||||
"""
|
||||
Save PyTorch tensor vertices and faces as a GLB file without external dependencies.
|
||||
|
||||
Parameters:
|
||||
vertices: torch.Tensor of shape (N, 3) - The vertex coordinates
|
||||
faces: torch.Tensor of shape (M, 3) - The face indices (triangle faces)
|
||||
filepath: str - Output filepath (should end with .glb)
|
||||
metadata: dict - Optional asset.extras metadata
|
||||
uvs: torch.Tensor of shape (N, 2) - Optional per-vertex texture coordinates
|
||||
vertex_colors: torch.Tensor of shape (N, 3) or (N, 4) - Optional per-vertex colors in [0, 1]
|
||||
texture_image: PIL.Image - Optional baseColor texture, embedded as PNG
|
||||
"""
|
||||
|
||||
# Convert tensors to numpy arrays
|
||||
vertices_np = vertices.cpu().numpy().astype(np.float32)
|
||||
faces_signed = faces.cpu().numpy().astype(np.int64)
|
||||
uvs_np = uvs.cpu().numpy().astype(np.float32) if uvs is not None else None
|
||||
colors_np = vertex_colors.cpu().numpy().astype(np.float32) if vertex_colors is not None else None
|
||||
if colors_np is not None:
|
||||
colors_np = np.clip(colors_np, 0.0, 1.0)
|
||||
|
||||
n_verts = vertices_np.shape[0]
|
||||
if n_verts == 0:
|
||||
raise ValueError("save_glb: vertices is empty")
|
||||
if faces_signed.size > 0:
|
||||
fmin = int(faces_signed.min())
|
||||
fmax = int(faces_signed.max())
|
||||
if fmin < 0 or fmax >= n_verts:
|
||||
raise ValueError(
|
||||
f"save_glb: face index out of range [0, {n_verts}): min={fmin}, max={fmax}"
|
||||
)
|
||||
if uvs_np is not None and uvs_np.shape[0] != n_verts:
|
||||
raise ValueError(
|
||||
f"save_glb: uvs has {uvs_np.shape[0]} entries but vertex count is {n_verts}"
|
||||
)
|
||||
if colors_np is not None and colors_np.shape[0] != n_verts:
|
||||
raise ValueError(
|
||||
f"save_glb: vertex_colors has {colors_np.shape[0]} entries but vertex count is {n_verts}"
|
||||
)
|
||||
faces_np = faces_signed.astype(np.uint32)
|
||||
texture_png_bytes = None
|
||||
if texture_image is not None:
|
||||
buf = BytesIO()
|
||||
texture_image.save(buf, format="PNG")
|
||||
texture_png_bytes = buf.getvalue()
|
||||
|
||||
vertices_buffer = vertices_np.tobytes()
|
||||
indices_buffer = faces_np.tobytes()
|
||||
uvs_buffer = uvs_np.tobytes() if uvs_np is not None else b""
|
||||
colors_buffer = colors_np.tobytes() if colors_np is not None else b""
|
||||
texture_buffer = texture_png_bytes if texture_png_bytes is not None else b""
|
||||
|
||||
def pad_to_4_bytes(buffer):
|
||||
padding_length = (4 - (len(buffer) % 4)) % 4
|
||||
return buffer + b'\x00' * padding_length
|
||||
|
||||
vertices_buffer_padded = pad_to_4_bytes(vertices_buffer)
|
||||
indices_buffer_padded = pad_to_4_bytes(indices_buffer)
|
||||
uvs_buffer_padded = pad_to_4_bytes(uvs_buffer)
|
||||
colors_buffer_padded = pad_to_4_bytes(colors_buffer)
|
||||
texture_buffer_padded = pad_to_4_bytes(texture_buffer)
|
||||
|
||||
buffer_data = b"".join([
|
||||
vertices_buffer_padded,
|
||||
indices_buffer_padded,
|
||||
uvs_buffer_padded,
|
||||
colors_buffer_padded,
|
||||
texture_buffer_padded,
|
||||
])
|
||||
|
||||
vertices_byte_length = len(vertices_buffer)
|
||||
vertices_byte_offset = 0
|
||||
indices_byte_length = len(indices_buffer)
|
||||
indices_byte_offset = len(vertices_buffer_padded)
|
||||
uvs_byte_offset = indices_byte_offset + len(indices_buffer_padded)
|
||||
colors_byte_offset = uvs_byte_offset + len(uvs_buffer_padded)
|
||||
texture_byte_offset = colors_byte_offset + len(colors_buffer_padded)
|
||||
|
||||
buffer_views = [
|
||||
{
|
||||
"buffer": 0,
|
||||
"byteOffset": vertices_byte_offset,
|
||||
"byteLength": vertices_byte_length,
|
||||
"target": 34962 # ARRAY_BUFFER
|
||||
},
|
||||
{
|
||||
"buffer": 0,
|
||||
"byteOffset": indices_byte_offset,
|
||||
"byteLength": indices_byte_length,
|
||||
"target": 34963 # ELEMENT_ARRAY_BUFFER
|
||||
}
|
||||
]
|
||||
accessors = [
|
||||
{
|
||||
"bufferView": 0,
|
||||
"byteOffset": 0,
|
||||
"componentType": 5126, # FLOAT
|
||||
"count": len(vertices_np),
|
||||
"type": "VEC3",
|
||||
"max": vertices_np.max(axis=0).tolist(),
|
||||
"min": vertices_np.min(axis=0).tolist()
|
||||
},
|
||||
{
|
||||
"bufferView": 1,
|
||||
"byteOffset": 0,
|
||||
"componentType": 5125, # UNSIGNED_INT
|
||||
"count": faces_np.size,
|
||||
"type": "SCALAR"
|
||||
}
|
||||
]
|
||||
primitive_attributes = {"POSITION": 0}
|
||||
|
||||
if uvs_np is not None and len(uvs_np) > 0:
|
||||
buffer_views.append({
|
||||
"buffer": 0,
|
||||
"byteOffset": uvs_byte_offset,
|
||||
"byteLength": len(uvs_buffer),
|
||||
"target": 34962
|
||||
})
|
||||
accessor_idx = len(accessors)
|
||||
accessors.append({
|
||||
"bufferView": len(buffer_views) - 1,
|
||||
"byteOffset": 0,
|
||||
"componentType": 5126,
|
||||
"count": len(uvs_np),
|
||||
"type": "VEC2",
|
||||
})
|
||||
primitive_attributes["TEXCOORD_0"] = accessor_idx
|
||||
|
||||
if colors_np is not None and len(colors_np) > 0:
|
||||
buffer_views.append({
|
||||
"buffer": 0,
|
||||
"byteOffset": colors_byte_offset,
|
||||
"byteLength": len(colors_buffer),
|
||||
"target": 34962
|
||||
})
|
||||
accessor_idx = len(accessors)
|
||||
accessors.append({
|
||||
"bufferView": len(buffer_views) - 1,
|
||||
"byteOffset": 0,
|
||||
"componentType": 5126,
|
||||
"count": len(colors_np),
|
||||
"type": "VEC3" if colors_np.shape[1] == 3 else "VEC4",
|
||||
})
|
||||
primitive_attributes["COLOR_0"] = accessor_idx
|
||||
|
||||
primitive = {
|
||||
"attributes": primitive_attributes,
|
||||
"indices": 1,
|
||||
"mode": 4 # TRIANGLES
|
||||
}
|
||||
|
||||
images = []
|
||||
textures = []
|
||||
samplers = []
|
||||
materials = []
|
||||
if texture_png_bytes is not None and "TEXCOORD_0" in primitive_attributes:
|
||||
buffer_views.append({
|
||||
"buffer": 0,
|
||||
"byteOffset": texture_byte_offset,
|
||||
"byteLength": len(texture_buffer),
|
||||
})
|
||||
images.append({"bufferView": len(buffer_views) - 1, "mimeType": "image/png"})
|
||||
samplers.append({"magFilter": 9729, "minFilter": 9729, "wrapS": 33071, "wrapT": 33071})
|
||||
textures.append({"source": 0, "sampler": 0})
|
||||
materials.append({
|
||||
"pbrMetallicRoughness": {
|
||||
"baseColorTexture": {"index": 0, "texCoord": 0},
|
||||
"metallicFactor": 0.0,
|
||||
"roughnessFactor": 1.0,
|
||||
},
|
||||
"doubleSided": True,
|
||||
})
|
||||
primitive["material"] = 0
|
||||
|
||||
gltf = {
|
||||
"asset": {"version": "2.0", "generator": "ComfyUI"},
|
||||
"buffers": [{"byteLength": len(buffer_data)}],
|
||||
"bufferViews": buffer_views,
|
||||
"accessors": accessors,
|
||||
"meshes": [{"primitives": [primitive]}],
|
||||
"nodes": [{"mesh": 0}],
|
||||
"scenes": [{"nodes": [0]}],
|
||||
"scene": 0,
|
||||
}
|
||||
if images:
|
||||
gltf["images"] = images
|
||||
if samplers:
|
||||
gltf["samplers"] = samplers
|
||||
if textures:
|
||||
gltf["textures"] = textures
|
||||
if materials:
|
||||
gltf["materials"] = materials
|
||||
|
||||
if metadata:
|
||||
gltf["asset"]["extras"] = metadata
|
||||
|
||||
# Convert the JSON to bytes
|
||||
gltf_json = json.dumps(gltf).encode('utf8')
|
||||
|
||||
def pad_json_to_4_bytes(buffer):
|
||||
padding_length = (4 - (len(buffer) % 4)) % 4
|
||||
return buffer + b' ' * padding_length
|
||||
|
||||
gltf_json_padded = pad_json_to_4_bytes(gltf_json)
|
||||
|
||||
# Create the GLB header (a 4-byte ASCII magic identifier glTF)
|
||||
glb_header = struct.pack('<4sII', b'glTF', 2, 12 + 8 + len(gltf_json_padded) + 8 + len(buffer_data))
|
||||
|
||||
# Create JSON chunk header (chunk type 0)
|
||||
json_chunk_header = struct.pack('<II', len(gltf_json_padded), 0x4E4F534A) # "JSON" in little endian
|
||||
|
||||
# Create BIN chunk header (chunk type 1)
|
||||
bin_chunk_header = struct.pack('<II', len(buffer_data), 0x004E4942) # "BIN\0" in little endian
|
||||
|
||||
# Write the GLB file
|
||||
with open(filepath, 'wb') as f:
|
||||
f.write(glb_header)
|
||||
f.write(json_chunk_header)
|
||||
f.write(gltf_json_padded)
|
||||
f.write(bin_chunk_header)
|
||||
f.write(buffer_data)
|
||||
|
||||
return filepath
|
||||
|
||||
|
||||
class SaveGLB(IO.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="SaveGLB",
|
||||
display_name="Save 3D Model",
|
||||
search_aliases=["export 3d model", "save mesh"],
|
||||
category="3d",
|
||||
essentials_category="Basics",
|
||||
is_output_node=True,
|
||||
inputs=[
|
||||
IO.MultiType.Input(
|
||||
IO.Mesh.Input("mesh"),
|
||||
types=[
|
||||
IO.File3DGLB,
|
||||
IO.File3DGLTF,
|
||||
IO.File3DOBJ,
|
||||
IO.File3DFBX,
|
||||
IO.File3DSTL,
|
||||
IO.File3DUSDZ,
|
||||
IO.File3DAny,
|
||||
],
|
||||
tooltip="Mesh or 3D file to save",
|
||||
),
|
||||
IO.String.Input("filename_prefix", default="3d/ComfyUI"),
|
||||
],
|
||||
hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo]
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, mesh: Types.MESH | Types.File3D, filename_prefix: str) -> IO.NodeOutput:
|
||||
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(filename_prefix, folder_paths.get_output_directory())
|
||||
results = []
|
||||
|
||||
metadata = {}
|
||||
if not args.disable_metadata:
|
||||
if cls.hidden.prompt is not None:
|
||||
metadata["prompt"] = json.dumps(cls.hidden.prompt)
|
||||
if cls.hidden.extra_pnginfo is not None:
|
||||
for x in cls.hidden.extra_pnginfo:
|
||||
metadata[x] = json.dumps(cls.hidden.extra_pnginfo[x])
|
||||
|
||||
if isinstance(mesh, Types.File3D):
|
||||
# Handle File3D input - save BytesIO data to output folder
|
||||
ext = mesh.format or "glb"
|
||||
f = f"{filename}_{counter:05}_.{ext}"
|
||||
mesh.save_to(os.path.join(full_output_folder, f))
|
||||
results.append({
|
||||
"filename": f,
|
||||
"subfolder": subfolder,
|
||||
"type": "output"
|
||||
})
|
||||
counter += 1
|
||||
else:
|
||||
# Handle Mesh input - save vertices and faces as GLB; carry optional UVs / colors / texture.
|
||||
texture_b = getattr(mesh, "texture", None)
|
||||
texture_np = None
|
||||
if texture_b is not None:
|
||||
texture_np = (texture_b.clamp(0.0, 1.0).cpu().numpy() * 255).astype(np.uint8)
|
||||
assert texture_np.ndim == 4 and texture_np.shape[-1] == 3, (
|
||||
f"texture must be (B, H, W, 3) RGB, got shape {tuple(texture_np.shape)}"
|
||||
)
|
||||
for i in range(mesh.vertices.shape[0]):
|
||||
vertices_i, faces_i, v_colors, uvs_i = get_mesh_batch_item(mesh, i)
|
||||
if vertices_i.shape[0] == 0 or faces_i.shape[0] == 0:
|
||||
logging.warning(f"SaveGLB: skipping empty mesh at batch index {i}")
|
||||
continue
|
||||
tex_img = Image.fromarray(texture_np[i], mode="RGB") if texture_np is not None else None
|
||||
f = f"{filename}_{counter:05}_.glb"
|
||||
save_glb(vertices_i, faces_i, os.path.join(full_output_folder, f), metadata,
|
||||
uvs=uvs_i,
|
||||
vertex_colors=v_colors,
|
||||
texture_image=tex_img)
|
||||
results.append({
|
||||
"filename": f,
|
||||
"subfolder": subfolder,
|
||||
"type": "output"
|
||||
})
|
||||
counter += 1
|
||||
return IO.NodeOutput(ui={"3d": results})
|
||||
|
||||
|
||||
class Save3DExtension(ComfyExtension):
|
||||
@override
|
||||
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
|
||||
return [SaveGLB]
|
||||
|
||||
|
||||
async def comfy_entrypoint() -> Save3DExtension:
|
||||
return Save3DExtension()
|
||||
@@ -353,7 +353,8 @@ class SDPoseDrawKeypoints(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="SDPoseDrawKeypoints",
|
||||
category="image/preprocessors",
|
||||
display_name="SDPose Draw Keypoints",
|
||||
category="image/detection",
|
||||
search_aliases=["openpose", "pose detection", "preprocessor", "keypoints", "pose"],
|
||||
inputs=[
|
||||
io.Custom("POSE_KEYPOINT").Input("keypoints"),
|
||||
@@ -421,7 +422,8 @@ class SDPoseKeypointExtractor(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="SDPoseKeypointExtractor",
|
||||
category="image/preprocessors",
|
||||
display_name="SDPose Keypoint Extractor",
|
||||
category="image/detection",
|
||||
search_aliases=["openpose", "pose detection", "preprocessor", "keypoints", "sdpose"],
|
||||
description="Extract pose keypoints from images using the SDPose model: https://huggingface.co/Comfy-Org/SDPose/tree/main/checkpoints",
|
||||
inputs=[
|
||||
@@ -595,7 +597,8 @@ class SDPoseFaceBBoxes(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="SDPoseFaceBBoxes",
|
||||
category="image/preprocessors",
|
||||
display_name="SDPose Face Bounding Boxes",
|
||||
category="image/detection",
|
||||
search_aliases=["face bbox", "face bounding box", "pose", "keypoints"],
|
||||
inputs=[
|
||||
io.Custom("POSE_KEYPOINT").Input("keypoints"),
|
||||
@@ -652,7 +655,8 @@ class CropByBBoxes(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="CropByBBoxes",
|
||||
category="image/preprocessors",
|
||||
display_name="Crop By Bounding Boxes",
|
||||
category="image/transform",
|
||||
search_aliases=["crop", "face crop", "bbox crop", "pose", "bounding box"],
|
||||
description="Crop and resize regions from the input image batch based on provided bounding boxes.",
|
||||
inputs=[
|
||||
|
||||
@@ -1,10 +1,41 @@
|
||||
import re
|
||||
import json
|
||||
import string
|
||||
from typing_extensions import override
|
||||
|
||||
from comfy_api.latest import ComfyExtension, io
|
||||
|
||||
|
||||
class StringFormat(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls) -> io.Schema:
|
||||
autogrow = io.Autogrow.TemplateNames(
|
||||
input=io.AnyType.Input("value"),
|
||||
names=list(string.ascii_lowercase),
|
||||
min=0,
|
||||
)
|
||||
return io.Schema(
|
||||
node_id="StringFormat",
|
||||
display_name="Format Text",
|
||||
category="text",
|
||||
search_aliases=["string", "format"],
|
||||
description="Same as Python's string format method. Supports all of Python's format options and features.",
|
||||
inputs=[
|
||||
io.Autogrow.Input("values", template=autogrow),
|
||||
io.String.Input("f_string", default="{a}", multiline=True),
|
||||
],
|
||||
outputs=[
|
||||
io.String.Output(),
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(
|
||||
cls, values: io.Autogrow.Type, f_string: str
|
||||
) -> io.NodeOutput:
|
||||
return io.NodeOutput(f_string.format(**values))
|
||||
|
||||
|
||||
class StringConcatenate(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
@@ -413,6 +444,7 @@ class StringExtension(ComfyExtension):
|
||||
@override
|
||||
async def get_node_list(self) -> list[type[io.ComfyNode]]:
|
||||
return [
|
||||
StringFormat,
|
||||
StringConcatenate,
|
||||
StringSubstring,
|
||||
StringLength,
|
||||
|
||||
@@ -123,6 +123,7 @@ class CreateVideo(io.ComfyNode):
|
||||
search_aliases=["images to video"],
|
||||
display_name="Create Video",
|
||||
category="video",
|
||||
essentials_category="Video Tools",
|
||||
description="Create a video from images.",
|
||||
inputs=[
|
||||
io.Image.Input("images", tooltip="The images to create a video from."),
|
||||
|
||||
@@ -65,7 +65,7 @@ class VideoLinearCFGGuidance:
|
||||
RETURN_TYPES = ("MODEL",)
|
||||
FUNCTION = "patch"
|
||||
|
||||
CATEGORY = "sampling/video_models"
|
||||
CATEGORY = "sampling/guiders"
|
||||
|
||||
def patch(self, model, min_cfg):
|
||||
def linear_cfg(args):
|
||||
@@ -89,7 +89,7 @@ class VideoTriangleCFGGuidance:
|
||||
RETURN_TYPES = ("MODEL",)
|
||||
FUNCTION = "patch"
|
||||
|
||||
CATEGORY = "sampling/video_models"
|
||||
CATEGORY = "sampling/guiders"
|
||||
|
||||
def patch(self, model, min_cfg):
|
||||
def linear_cfg(args):
|
||||
@@ -157,5 +157,7 @@ NODE_CLASS_MAPPINGS = {
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ImageOnlyCheckpointLoader": "Image Only Checkpoint Loader (img2vid model)",
|
||||
"ImageOnlyCheckpointLoader": "Load Checkpoint Image Only (img2vid model)",
|
||||
"VideoLinearCFGGuidance": "Video Linear CFG Guidance",
|
||||
"VideoTriangleCFGGuidance": "Video Triangle CFG Guidance",
|
||||
}
|
||||
|
||||
@@ -122,7 +122,8 @@ class VOIDQuadmaskPreprocess(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="VOIDQuadmaskPreprocess",
|
||||
category="mask/video",
|
||||
display_name="VOID Quadmask Preprocessor",
|
||||
category="image/mask",
|
||||
inputs=[
|
||||
io.Mask.Input("mask"),
|
||||
io.Int.Input("dilate_width", default=0, min=0, max=50, step=1,
|
||||
@@ -392,7 +393,7 @@ class VOIDWarpedNoiseSource(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="VOIDWarpedNoiseSource",
|
||||
category="sampling/custom_sampling/noise",
|
||||
category="sampling/noise",
|
||||
inputs=[
|
||||
io.Latent.Input("warped_noise",
|
||||
tooltip="Warped noise latent from VOIDWarpedNoise"),
|
||||
@@ -454,7 +455,7 @@ class VOIDSampler(io.ComfyNode):
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="VOIDSampler",
|
||||
category="sampling/custom_sampling/samplers",
|
||||
category="sampling/samplers",
|
||||
inputs=[],
|
||||
outputs=[io.Sampler.Output()],
|
||||
)
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
# This file is automatically generated by the build process when version is
|
||||
# updated in pyproject.toml.
|
||||
__version__ = "0.21.0"
|
||||
__version__ = "0.22.0"
|
||||
|
||||
+11
-3
@@ -2,6 +2,7 @@ import copy
|
||||
import heapq
|
||||
import inspect
|
||||
import logging
|
||||
import psutil
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -626,7 +627,7 @@ async def execute(server, dynprompt, caches, current_item, extra_data, executed,
|
||||
|
||||
if comfy.model_management.is_oom(ex):
|
||||
tips = "This error means you ran out of memory on your GPU.\n\nTIPS: If the workflow worked before you might have accidentally set the batch_size to a large number."
|
||||
logging.info("Memory summary: {}".format(comfy.model_management.debug_memory_summary()))
|
||||
logging.info("Memory summary:\n{}".format(comfy.model_management.debug_memory_summary()))
|
||||
logging.error("Got an OOM, unloading all loaded models.")
|
||||
comfy.model_management.unload_all_models()
|
||||
elif isinstance(ex, RuntimeError) and ("mat1 and mat2 shapes" in str(ex)) and "Sampler" in class_type:
|
||||
@@ -727,6 +728,7 @@ class PromptExecutor:
|
||||
|
||||
self._notify_prompt_lifecycle("start", prompt_id)
|
||||
ram_headroom = int(self.cache_args["ram"] * (1024 ** 3))
|
||||
ram_inactive_headroom = int(self.cache_args["ram_inactive"] * (1024 ** 3))
|
||||
ram_release_callback = self.caches.outputs.ram_release if self.cache_type == CacheType.RAM_PRESSURE else None
|
||||
comfy.memory_management.set_ram_cache_release_state(ram_release_callback, ram_headroom)
|
||||
|
||||
@@ -780,8 +782,14 @@ class PromptExecutor:
|
||||
execution_list.complete_node_execution()
|
||||
|
||||
if self.cache_type == CacheType.RAM_PRESSURE:
|
||||
comfy.model_management.free_memory(0, None, pins_required=ram_headroom, ram_required=ram_headroom)
|
||||
ram_release_callback(ram_headroom, free_active=True)
|
||||
ram_release_callback(ram_inactive_headroom)
|
||||
ram_shortfall = ram_headroom - psutil.virtual_memory().available
|
||||
freed = comfy.model_management.free_pins(ram_shortfall + 512 * (1024 ** 2))
|
||||
if freed < ram_shortfall:
|
||||
if freed > 64 * (1024 ** 2):
|
||||
# AIMDO MEM_DECOMMIT can outrun psutil.available catching up.
|
||||
time.sleep(0.05)
|
||||
ram_release_callback(ram_headroom, free_active=True)
|
||||
else:
|
||||
# Only execute when the while-loop ends without break
|
||||
# Send cached UI for intermediate output nodes that weren't executed
|
||||
|
||||
@@ -56,8 +56,12 @@ folder_names_and_paths["background_removal"] = ([os.path.join(models_dir, "backg
|
||||
|
||||
folder_names_and_paths["frame_interpolation"] = ([os.path.join(models_dir, "frame_interpolation")], supported_pt_extensions)
|
||||
|
||||
folder_names_and_paths["geometry_estimation"] = ([os.path.join(models_dir, "geometry_estimation")], supported_pt_extensions)
|
||||
|
||||
folder_names_and_paths["optical_flow"] = ([os.path.join(models_dir, "optical_flow")], supported_pt_extensions)
|
||||
|
||||
folder_names_and_paths["detection"] = ([os.path.join(models_dir, "detection")], supported_pt_extensions)
|
||||
|
||||
output_directory = os.path.join(base_path, "output")
|
||||
temp_directory = os.path.join(base_path, "temp")
|
||||
input_directory = os.path.join(base_path, "input")
|
||||
|
||||
@@ -27,6 +27,7 @@ from utils.mime_types import init_mime_types
|
||||
import faulthandler
|
||||
import logging
|
||||
import sys
|
||||
import traceback
|
||||
from comfy_execution.progress import get_progress_state
|
||||
from comfy_execution.utils import get_executing_context
|
||||
from comfy_api import feature_flags
|
||||
@@ -135,7 +136,20 @@ def apply_custom_paths():
|
||||
folder_paths.set_user_directory(user_dir)
|
||||
|
||||
|
||||
# Buffer for prestartup failures. Recorded into `nodes.NODE_STARTUP_ERRORS`
|
||||
# only AFTER the normal `import nodes` line below, so a failing prestartup
|
||||
# script never triggers an early `import nodes` (and therefore `import torch`)
|
||||
# on the error path.
|
||||
_PRESTARTUP_FAILURES: list[dict] = []
|
||||
|
||||
|
||||
def execute_prestartup_script():
|
||||
"""Run every custom_nodes/*/prestartup_script.py once, before importing nodes.
|
||||
|
||||
Failures are buffered into the module-level ``_PRESTARTUP_FAILURES`` list and
|
||||
must be flushed via ``record_node_startup_error`` after ``import nodes`` has
|
||||
happened at its normal bootstrap point.
|
||||
"""
|
||||
if args.disable_all_custom_nodes and len(args.whitelist_custom_nodes) == 0:
|
||||
return
|
||||
|
||||
@@ -148,6 +162,15 @@ def execute_prestartup_script():
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to execute startup-script: {script_path} / {e}")
|
||||
# Buffer the failure - do NOT `import nodes` here, that would drag
|
||||
# torch in before the intended bootstrap point.
|
||||
_PRESTARTUP_FAILURES.append({
|
||||
"module_path": os.path.dirname(script_path),
|
||||
"source": "custom_nodes",
|
||||
"phase": "prestartup",
|
||||
"error": e,
|
||||
"tb": traceback.format_exc(),
|
||||
})
|
||||
return False
|
||||
|
||||
node_paths = folder_paths.get_folder_paths("custom_nodes")
|
||||
@@ -207,6 +230,16 @@ import execution
|
||||
import server
|
||||
from protocol import BinaryEventTypes
|
||||
import nodes
|
||||
|
||||
# Flush any prestartup failures that were buffered before `nodes` was
|
||||
# importable. Doing this here (rather than from the prestartup error
|
||||
# handler) keeps the bootstrap order deterministic: `nodes` (and torch)
|
||||
# import at this single line whether prestartup succeeded or failed.
|
||||
if _PRESTARTUP_FAILURES:
|
||||
for _failure in _PRESTARTUP_FAILURES:
|
||||
nodes.record_node_startup_error(**_failure)
|
||||
_PRESTARTUP_FAILURES.clear()
|
||||
|
||||
import comfy.model_management
|
||||
import comfyui_version
|
||||
import app.logger
|
||||
@@ -283,19 +316,25 @@ def _collect_output_absolute_paths(history_result: dict) -> list[str]:
|
||||
|
||||
def prompt_worker(q, server_instance):
|
||||
current_time: float = 0.0
|
||||
cache_ram = args.cache_ram
|
||||
if cache_ram < 0:
|
||||
cache_ram = 0
|
||||
cache_ram_inactive = 0
|
||||
if not args.cache_classic and not args.cache_none and args.cache_lru <= 0:
|
||||
cache_ram = min(32.0, max(4.0, comfy.model_management.total_ram * 0.25 / 1024.0))
|
||||
cache_ram_inactive = min(96.0, max(12.0, comfy.model_management.total_ram * 0.75 / 1024.0))
|
||||
if len(args.cache_ram) > 0:
|
||||
cache_ram = args.cache_ram[0]
|
||||
if len(args.cache_ram) > 1:
|
||||
cache_ram_inactive = args.cache_ram[1]
|
||||
|
||||
cache_type = execution.CacheType.CLASSIC
|
||||
if args.cache_lru > 0:
|
||||
cache_type = execution.CacheType.RAM_PRESSURE
|
||||
if args.cache_classic:
|
||||
cache_type = execution.CacheType.CLASSIC
|
||||
elif args.cache_lru > 0:
|
||||
cache_type = execution.CacheType.LRU
|
||||
elif cache_ram > 0:
|
||||
cache_type = execution.CacheType.RAM_PRESSURE
|
||||
elif args.cache_none:
|
||||
cache_type = execution.CacheType.NONE
|
||||
|
||||
e = execution.PromptExecutor(server_instance, cache_type=cache_type, cache_args={ "lru" : args.cache_lru, "ram" : cache_ram } )
|
||||
e = execution.PromptExecutor(server_instance, cache_type=cache_type, cache_args={ "lru" : args.cache_lru, "ram" : cache_ram, "ram_inactive" : cache_ram_inactive } )
|
||||
last_gc_collect = 0
|
||||
need_gc = False
|
||||
gc_collect_interval = 10.0
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user