Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f541a2fc8c | ||
|
|
be1ffd431c | ||
|
|
5b3eb0d2c0 | ||
|
|
ce7d20df86 | ||
|
|
a2bcc41bcf | ||
|
|
7b60ad5bea |
@@ -0,0 +1,5 @@
|
||||
# Left out of the package published to the Comfy Registry; they stay in the GitHub repository.
|
||||
# The test suite (fixtures, stubs and helper processes that the Registry's scanner reads as risky code)
|
||||
tests/
|
||||
# The scripts that build the docs' images, node cards and PDF guides
|
||||
docs/_tools/
|
||||
@@ -92,6 +92,17 @@ Restart ComfyUI afterwards. Requirements: a recent ComfyUI (tested with 0.41) an
|
||||
vton_mask: ComfyUI-Vton-Mask/ # VTON pose + parsing ONNX models
|
||||
sam3: sam3/ # SAM 3 checkpoint (you download sam3.pt yourself)
|
||||
```
|
||||
- **Image folders outside ComfyUI.** Load Images Folder, Load images matching and Save Image Path work inside
|
||||
ComfyUI's `input`, `output` and `temp` folders (saving: `output` only). To use a photo drive or a project folder,
|
||||
list it in the same file and restart ComfyUI
|
||||
([details](docs/en/image-io.md#folders-outside-comfyui)):
|
||||
|
||||
```yaml
|
||||
my_images:
|
||||
nh_image_folders: |
|
||||
D:/Photos/
|
||||
E:/Exports/
|
||||
```
|
||||
- **Requirements for SAM 3** (SAM3 Segment only): an NVIDIA GPU with CUDA, Meta's `sam3` package and the `sam3.pt`
|
||||
checkpoint. NH-Nodes installs and downloads none of them: you install the package yourself and request the weights
|
||||
from Meta. The steps, and what to do if another SAM 3 pack is already installed, are in
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Which folders the image nodes and routes of NH-Nodes may read and write.
|
||||
|
||||
A workflow is data: it can come from anyone, and ComfyUI can be reachable from the network. So the folder text of a
|
||||
node (or of a request) only works inside
|
||||
|
||||
* reading: ComfyUI's ``input``, ``output`` and ``temp`` folders,
|
||||
* writing: ComfyUI's ``output`` folder,
|
||||
|
||||
and the folders the owner of the machine listed on disk under ``nh_image_folders`` in ``extra_model_paths.yaml``
|
||||
(both reading and writing)::
|
||||
|
||||
nh_nodes_images:
|
||||
nh_image_folders: |
|
||||
D:/Photos/
|
||||
E:/Exports/
|
||||
|
||||
A path is checked twice. First as written (``..`` resolved, no disk access), so a path outside every allowed folder
|
||||
is refused before anything touches it: a ``\\\\server\\share`` path never makes Windows contact that server. Then
|
||||
with links and junctions resolved, so a link inside an allowed folder cannot lead out of it.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import folder_paths
|
||||
|
||||
FOLDER_KEY = "nh_image_folders"
|
||||
|
||||
_declared = []
|
||||
|
||||
|
||||
class FolderNotAllowed(PermissionError):
|
||||
"""The path lies outside every folder NH-Nodes may use. An OSError, so callers that skip unreadable paths skip it."""
|
||||
|
||||
|
||||
def _absorb_declared():
|
||||
# ComfyUI turns every key of extra_model_paths.yaml into a model folder name, and its /models routes list the
|
||||
# files of every such name. These folders hold photos, not models: take the locations and drop the name.
|
||||
entry = folder_paths.folder_names_and_paths.pop(FOLDER_KEY, None)
|
||||
if entry:
|
||||
for path in entry[0]:
|
||||
if path and path not in _declared:
|
||||
_declared.append(path)
|
||||
|
||||
|
||||
def declared_folders():
|
||||
"""The folders listed under ``nh_image_folders``; [] when the key is not used."""
|
||||
_absorb_declared()
|
||||
return list(_declared)
|
||||
|
||||
|
||||
def read_roots():
|
||||
return [
|
||||
folder_paths.get_input_directory(),
|
||||
folder_paths.get_output_directory(),
|
||||
folder_paths.get_temp_directory(),
|
||||
*declared_folders(),
|
||||
]
|
||||
|
||||
|
||||
def write_roots():
|
||||
return [folder_paths.get_output_directory(), *declared_folders()]
|
||||
|
||||
|
||||
def _contains(root, path):
|
||||
try:
|
||||
return os.path.commonpath([root, path]) == root
|
||||
except ValueError: # another drive, or a drive path against a network share
|
||||
return False
|
||||
|
||||
|
||||
def _written(path):
|
||||
return os.path.normcase(os.path.abspath(path))
|
||||
|
||||
|
||||
def _resolved(path):
|
||||
return os.path.normcase(os.path.realpath(path))
|
||||
|
||||
|
||||
def is_inside(path, roots):
|
||||
"""True when ``path`` is one of ``roots`` or below one, both as written and with links resolved."""
|
||||
roots = [root for root in roots if root]
|
||||
resolved_roots = [_resolved(root) for root in roots] # the roots are the owner's: resolving them is safe
|
||||
written = _written(path)
|
||||
# As written, the path must lie in a root under either of the root's spellings (a root that is itself a link
|
||||
# may be typed by its real location).
|
||||
if not any(_contains(root, written) for root in [*map(_written, roots), *resolved_roots]):
|
||||
return False
|
||||
resolved = _resolved(path)
|
||||
return any(_contains(root, resolved) for root in resolved_roots)
|
||||
|
||||
|
||||
def _refusal(path, what, action, defaults):
|
||||
return FolderNotAllowed(
|
||||
f"NH-Nodes: {what} is outside the folders NH-Nodes may {action}: {str(path)!r}. Allowed are {defaults} and "
|
||||
f"the folders listed under '{FOLDER_KEY}' in extra_model_paths.yaml. To allow another folder, add it there, "
|
||||
f"for example\n nh_nodes_images:\n {FOLDER_KEY}: D:/Photos/\nand restart ComfyUI."
|
||||
)
|
||||
|
||||
|
||||
def require_readable(path, what="The folder"):
|
||||
"""``path`` as an absolute path; FolderNotAllowed (naming ``what``) when NH-Nodes may not read there."""
|
||||
if not is_inside(path, read_roots()):
|
||||
raise _refusal(path, what, "read", "ComfyUI's input, output and temp folders")
|
||||
return os.path.abspath(path)
|
||||
|
||||
|
||||
def require_writable(path, what="The folder"):
|
||||
"""``path`` as an absolute path; FolderNotAllowed (naming ``what``) when NH-Nodes may not write there."""
|
||||
if not is_inside(path, write_roots()):
|
||||
raise _refusal(path, what, "write to", "ComfyUI's output folder")
|
||||
return os.path.abspath(path)
|
||||
|
||||
|
||||
def can_read(path):
|
||||
return is_inside(path, read_roots())
|
||||
|
||||
|
||||
_absorb_declared()
|
||||
@@ -12,6 +12,7 @@ import os
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .allowed_folders import FolderNotAllowed
|
||||
from .image_folders import collect_image_files, resolve_load_dir
|
||||
|
||||
COUNT_PATH = "/nh-nodes/load-images-folder/count"
|
||||
@@ -51,6 +52,9 @@ async def nh_load_images_folder_count(request):
|
||||
count = await asyncio.to_thread(_count_images, folder_path, recursive)
|
||||
except FileNotFoundError:
|
||||
return _error(404, "folder not found")
|
||||
except FolderNotAllowed:
|
||||
# Outside ComfyUI's own folders and the folders listed under nh_image_folders: refused before the disk is read.
|
||||
return _error(403, "folder is not allowed")
|
||||
except PermissionError:
|
||||
return _error(403, "folder is not readable")
|
||||
except Exception as exc:
|
||||
|
||||
+12
-3
@@ -10,6 +10,8 @@ from pathlib import Path
|
||||
|
||||
import folder_paths
|
||||
|
||||
from .allowed_folders import can_read, require_readable
|
||||
|
||||
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".avif", ".bmp", ".tif", ".tiff"}
|
||||
|
||||
|
||||
@@ -22,13 +24,15 @@ def resolve_load_dir(folder_path, image="", upload_probe=""):
|
||||
"""The folder a loader reads.
|
||||
|
||||
``folder_path`` when given (relative to ComfyUI's input folder unless absolute; a file stands for its folder),
|
||||
else the folder of the uploaded ``image`` / ``upload_probe``, else the input folder.
|
||||
else the folder of the uploaded ``image`` / ``upload_probe``, else the input folder. FolderNotAllowed for a
|
||||
``folder_path`` outside the folders NH-Nodes may read, before the disk is touched.
|
||||
"""
|
||||
folder_path = (folder_path or "").strip()
|
||||
if folder_path:
|
||||
resolved = folder_path
|
||||
if not os.path.isabs(resolved):
|
||||
resolved = os.path.join(folder_paths.get_input_directory(), resolved)
|
||||
resolved = require_readable(resolved, "folder_path")
|
||||
if os.path.isfile(resolved):
|
||||
resolved = os.path.dirname(resolved)
|
||||
return resolved
|
||||
@@ -59,9 +63,14 @@ def collect_image_files(folder_path, recursive=False):
|
||||
seen = set(files)
|
||||
models_dir = os.path.normcase(os.path.abspath(folder_paths.models_dir))
|
||||
for current_root, dir_names, file_names in os.walk(folder_path):
|
||||
# Natural order, and never into the ComfyUI models folder (a walk from one of its parents would crawl it).
|
||||
# Natural order, never into the ComfyUI models folder (a walk from one of its parents would crawl it), and
|
||||
# never through a link or junction that leads out of the folders NH-Nodes may read.
|
||||
dir_names[:] = sorted(
|
||||
(name for name in dir_names if os.path.normcase(os.path.abspath(os.path.join(current_root, name))) != models_dir),
|
||||
(
|
||||
name for name in dir_names
|
||||
if os.path.normcase(os.path.abspath(os.path.join(current_root, name))) != models_dir
|
||||
and can_read(os.path.join(current_root, name))
|
||||
),
|
||||
key=natural_key,
|
||||
)
|
||||
for file_name in sorted(file_names, key=natural_key):
|
||||
|
||||
@@ -43,7 +43,7 @@ MODEL_SPECS = {
|
||||
"min_res": (512, 512),
|
||||
"max_res": (2560, 2560),
|
||||
"latent_channels": 16,
|
||||
"note": "Qwen tự match aspect input image",
|
||||
"note": "Qwen-Image matches the aspect ratio of the input image",
|
||||
},
|
||||
"z_image": {
|
||||
"name": "Z-Image",
|
||||
|
||||
@@ -3,6 +3,32 @@
|
||||
All notable changes to NH-Nodes. Versions follow the Comfy Registry releases.
|
||||
"Behaviour change" marks fixes that can change the output of an existing workflow.
|
||||
|
||||
## 1.10.0 — Image folders are limited to ComfyUI's folders and the ones you list (2026-11-12)
|
||||
|
||||
No node is added (76 in total) and no input or output changes. The first entry is a behaviour change that stops workflows which read or save outside ComfyUI until you list their folders once, so read it before you update.
|
||||
|
||||
### Changed
|
||||
- (behaviour change) `Save Image Path (NH)`, `Load Images Folder (NH)` and `Load images matching` only work inside ComfyUI's own folders and the folders you list on disk. Reading is allowed in ComfyUI's `input`, `output` and `temp` folders, writing in its `output` folder. To use any other folder (a photo drive, a project folder), list it under the new key `nh_image_folders` in `extra_model_paths.yaml` and restart ComfyUI; every folder below a listed one is allowed, for reading and for writing:
|
||||
|
||||
```yaml
|
||||
my_images:
|
||||
nh_image_folders: |
|
||||
D:/Photos/
|
||||
E:/Exports/
|
||||
```
|
||||
|
||||
A path anywhere else stops the run with an error that names the input and shows these lines. Nothing is read, listed or created first, and a network path (`\\server\share`) outside the list is not even contacted. A relative path with `..` that leaves `input` or `output` is refused in the same way. So is a link or junction that leads out of the allowed folders: as the folder you type, or as part of `new_folder_name` or `filename_prefix`, it stops the run; as a sub-folder it is skipped when sub-folders are read or searched; an image file that is such a link is not opened. If a sub-folder of `output` or of a listed folder is a link to another disk, list the place it leads to as well. Before 1.10.0 an absolute path was used as it was, anywhere the ComfyUI process could reach, so a workflow from someone else could read or write there. Only the file on disk can widen the list: a workflow cannot. `NH Save Large Image` and `Load Image Info (NH)` are unchanged: they leave the choice of folder to ComfyUI's own save and input-file handling.
|
||||
- (behaviour change) The image count that `Load Images Folder (NH)` asks the server for (`GET /nh-nodes/load-images-folder/count`) follows the same rule and answers `403` for a folder outside the list, without touching it. On the node, `increment` and `decrement` then move the index without wrapping it, as they do for a folder that does not exist; the run itself stops with the error above.
|
||||
- (behaviour change) `Load images matching`: `source_folder_path` is read as text only and the node no longer looks at the disk for it. A source that ends in an image file name (`…/subject_01/portrait.jpg`) stands for the folder of that file; a source file with another extension is no longer replaced by its folder. With a root inside a context folder (`…/Nam`), the sibling context folder (`…/Nu`) is only searched when the folder that holds both is allowed.
|
||||
- `Image Label (NH)`: `font_name` only accepts a name from its list. A text wired into it used to be opened as a path below the Windows font folder, so `..` or a network path reached other places; any other value now falls back to the default font, as a font that is not installed always did.
|
||||
- `Image Label (NH)` reads the list of installed fonts when ComfyUI first asks for the node list, no longer while the pack is imported. The list and the result are the same.
|
||||
- `VTON Ultimate Processor (NH)`: the bundled DWPose code no longer sets the environment variable `KMP_DUPLICATE_LIB_OK` for the whole ComfyUI process.
|
||||
- The note that `NH Smart Resolution Picker` shows for Qwen-Image is in English ("Qwen-Image matches the aspect ratio of the input image"), and the comments in the code are in English throughout.
|
||||
|
||||
### Notes
|
||||
- Folders listed under `nh_image_folders` do not show up in ComfyUI's model lists: NH-Nodes takes them out of the model folder names when it loads.
|
||||
- Updating from 1.9.0 or older: if a workflow saves to or loads from a folder outside ComfyUI, add that folder (or a folder above it) to `nh_image_folders` first. The guide has a section on it: [Folders outside ComfyUI](en/image-io.md#folders-outside-comfyui).
|
||||
|
||||
## 1.9.0 — Vision: body parts by name, Garment Segment on FASHN, parts in segs (2026-10-18)
|
||||
|
||||
One node is added, `Body Segment (NH)` (76 nodes in total). `Garment Segment (NH)`, `Portrait Segment (NH)`, `Segs Compose (NH)` and `Segs Select (NH)` change. Entries marked "behaviour change" can change what a saved workflow produces, so read them before you update. Workflows saved with 1.8.x still load and run: every new input is optional and every new output comes last. The licence notes at the end matter: `Garment Segment (NH)` is now documented as non-commercial, and `Body Segment (NH)` comes with use restrictions.
|
||||
|
||||
+34
-9
@@ -11,7 +11,7 @@ These nodes move images in and out of a workflow. Load one image together with i
|
||||
| [Load Image Info (NH)](#load-image-info-nh) | Load one image and get its file name and path as text |
|
||||
| [Load Images Folder (NH)](#load-images-folder-nh) | Load images from a folder by position, and move to the next one on every run |
|
||||
| [Load images matching](#load-images-matching) | Find the folder that matches a subject name and load its images |
|
||||
| [Save Image Path (NH)](#save-image-path-nh) | Save to any folder with your own prefix, numbering, format and DPI |
|
||||
| [Save Image Path (NH)](#save-image-path-nh) | Save to a folder you choose with your own prefix, numbering, format and DPI |
|
||||
| [NH Save Large Image](#nh-save-large-image) | Save very large images without a heavy preview on the canvas |
|
||||
|
||||
## Paths and file types
|
||||
@@ -24,6 +24,28 @@ These nodes move images in and out of a workflow. Load one image together with i
|
||||
|
||||
The folder loaders read `.png`, `.jpg`, `.jpeg`, `.webp`, `.avif`, `.bmp`, `.tif` and `.tiff` files. They sort names in natural order, so `img2` comes before `img10`, and they return RGB images.
|
||||
|
||||
### Folders outside ComfyUI
|
||||
|
||||
A workflow can come from anyone, and ComfyUI can be reachable from the network. So the folder you type into a node only works in these places:
|
||||
|
||||
| | Allowed folders |
|
||||
|---|---|
|
||||
| Reading (Load Images Folder, Load images matching, the image count on the node) | ComfyUI's `input`, `output` and `temp` folders, and the folders you list |
|
||||
| Writing (Save Image Path) | ComfyUI's `output` folder, and the folders you list |
|
||||
|
||||
To use a folder anywhere else (a photo drive, a project folder), list it once under the key `nh_image_folders` in `extra_model_paths.yaml`, ComfyUI's file for extra model paths (in the ComfyUI folder; ComfyUI Desktop keeps its own copy with its settings), and restart ComfyUI:
|
||||
|
||||
```yaml
|
||||
my_images:
|
||||
nh_image_folders: |
|
||||
D:/Photos/
|
||||
E:/Exports/
|
||||
```
|
||||
|
||||
Every folder below a listed folder is allowed too, for reading and for writing. A path anywhere else stops the run with an error that names the input and shows these lines; nothing is read or created. `..` parts are refused in the same way. So is a link or junction that leads out of the allowed folders: as the folder you type (or part of `new_folder_name` or `filename_prefix`) it stops the run, as a sub-folder it is skipped when sub-folders are read or searched, and an image file that is such a link is not opened. If a sub-folder of `output` or of a listed folder is a link to another disk, list the place it leads to as well. Only the file on disk can widen the list: a workflow cannot.
|
||||
|
||||
`source_folder_path` of Load images matching is the exception: the node only reads the names in it and never opens that folder, so it can be any path.
|
||||
|
||||
---
|
||||
|
||||
## Load Image Info (NH)
|
||||
@@ -76,7 +98,7 @@ Loads one or more images from a folder by position, and can move to the next ima
|
||||
|
||||
| Input | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `folder_path` | STRING | empty | The folder to read. An absolute path, or a path relative to the `input` folder. Empty means the `input` folder. A path to a file uses the folder of that file. |
|
||||
| `folder_path` | STRING | empty | The folder to read. A path relative to the `input` folder, or an absolute path inside an [allowed folder](#folders-outside-comfyui). Empty means the `input` folder. A path to a file uses the folder of that file. |
|
||||
| `index` | INT | `0` | Position of the first image to load, from `0` to `999999`. It wraps around: with 10 images, `12` loads image `2`. |
|
||||
| `step` | INT | `1` | How many images to load per run (`1` to `9999`), one after the other from `index`. It is also the distance `index` moves between runs in `increment` and `decrement` modes. |
|
||||
| `seed_mode` | COMBO | `fixed` | What happens to `index` each time you queue: `fixed` (stays), `increment` (adds `step`), `decrement` (subtracts `step`; the images are also read backwards), `random` (a new random position). |
|
||||
@@ -95,7 +117,7 @@ Loads one or more images from a folder by position, and can move to the next ima
|
||||
- If the folder holds no images of its own, its sub-folders are searched even when `recursive` is off. A search of sub-folders skips the ComfyUI `models` folder.
|
||||
- The browser moves `index` when you queue the workflow from the ComfyUI interface. It counts the images with the node's own `recursive` setting, so `increment`, `decrement` and `random` wrap and pick over the same images that the node loads. If you send the workflow through the API, set `index` yourself.
|
||||
- The node runs again when one of the selected files changes on disk (name, size or date). If `folder_path` is wired from another node, the node cannot check the files in advance: it then runs again when the wired path or another setting changes, but not when a file inside the same folder changes.
|
||||
- A folder that does not exist, or has no images, stops the run with an error.
|
||||
- A folder that does not exist, or has no images, stops the run with an error. So does a folder outside the [allowed folders](#folders-outside-comfyui).
|
||||
|
||||
**Example — upscale a whole folder**
|
||||
|
||||
@@ -129,8 +151,8 @@ matching_root_path
|
||||
|
||||
| Input | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `source_folder_path` | STRING | empty | The source folder of the subject. Only the folder **names** in the path are used, so the folder does not have to exist. The last name is the match text, unless you fill in `match_text`. A context folder in the path (see `context_folder_names`) tells the node which branch to prefer. |
|
||||
| `matching_root_path` | STRING | empty | The root of the folder tree to search. An absolute path, or a path relative to the `input` folder. Required. |
|
||||
| `source_folder_path` | STRING | empty | The source folder of the subject. Only the folder **names** in the path are used, so the folder does not have to exist and can be anywhere. A path that ends in an image file name (such as `…/subject_01/portrait.jpg`) stands for the folder of that file. The last name is the match text, unless you fill in `match_text`. A context folder in the path (see `context_folder_names`) tells the node which branch to prefer. |
|
||||
| `matching_root_path` | STRING | empty | The root of the folder tree to search. A path relative to the `input` folder, or an absolute path inside an [allowed folder](#folders-outside-comfyui). Required. |
|
||||
| `match_text` | STRING | empty | The folder name to look for. Empty means: use the name of the source folder. |
|
||||
| `match_mode` | COMBO | `contains` | `contains`: the folder name contains the text. `exact`: the folder name equals the text. Both ignore case and accents, and treat `_` and `-` as spaces. |
|
||||
| `recursive` | BOOLEAN | `true` | `true`: search every folder below the root. `false`: check only the root and its direct sub-folders. |
|
||||
@@ -154,7 +176,7 @@ matching_root_path
|
||||
- **Match text:** `match_text`, or else the name of the folder that holds each `source_file_names` line, or else the name of the source folder. Fill in at least one of `source_folder_path`, `source_file_names` and `match_text`. With none of them, the node looks for the name of ComfyUI's `input` folder, which rarely matches.
|
||||
- **Context:** if the source path contains one of the `context_folder_names`, matches under the same context rank first. A source in `…/Nu/subject_01` picks `Nu/subject_01` before `Nam/subject_01`. If your root sits inside one context folder (`…/Nam`) and the source belongs to another (`Nu`), the same place under the other context folder is searched too.
|
||||
- **Ties:** among equal matches, the folder whose path is most like the source path wins, then natural name order.
|
||||
- **Limits:** the search stays inside the root. It does not scan parent folders, apart from the sibling context folders described above, and it never enters the ComfyUI `models` folder. With `recursive` on, a root that is a drive or a network share (such as `D:\` or `\\server\share`) is refused: it cannot be searched recursively. The `models` folder is refused as a root whatever `recursive` says.
|
||||
- **Limits:** the search stays inside the root. It does not scan parent folders, apart from the sibling context folders described above, and it never enters the ComfyUI `models` folder. With `recursive` on, a root that is a drive or a network share (such as `D:\` or `\\server\share`) is refused: it cannot be searched recursively. The `models` folder is refused as a root whatever `recursive` says. A root outside the [allowed folders](#folders-outside-comfyui) is refused, and a sibling context folder is only searched when the folder that holds it is allowed.
|
||||
- **Files:** the images of the matched folder are sorted in natural order. `index` and `step` choose which ones to load.
|
||||
- **Caching:** the result is reused until the matched folder or one of the selected files changes (name, size or date). A search that finds nothing is never remembered, so a folder you add later is found on the next run. If a setting is wired from another node, the node cannot know the folder in advance. It then watches the whole tree below the root, and in a very large tree it runs on every queue.
|
||||
|
||||
@@ -188,12 +210,12 @@ On Load Images Folder, set `path_output` to `folder_path`, so `path` is the fold
|
||||
|
||||
Saves an image batch to a folder you choose, with your own file name prefix and automatic numbering.
|
||||
|
||||
**Use it when** the results belong in a project folder outside the default `output` folder, or you need a format or a DPI value that the built-in Save Image does not offer.
|
||||
**Use it when** the results belong in a sub-folder of `output` or in a project folder you listed under `nh_image_folders`, or you need a format or a DPI value that the built-in Save Image does not offer.
|
||||
|
||||
| Input | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `images` | IMAGE | — | The images to save. Every image of the batch becomes a file. |
|
||||
| `folder_path` | STRING | empty | Where to save. Empty means the `output` folder. A relative path is placed inside `output`. An absolute path is used as it is, even outside ComfyUI. Missing folders are created. A path to an existing file uses the folder of that file. |
|
||||
| `folder_path` | STRING | empty | Where to save. Empty means the `output` folder. A relative path is placed inside `output`. An absolute path must lie inside `output` or a folder listed under `nh_image_folders` (see [Folders outside ComfyUI](#folders-outside-comfyui)). Missing folders are created. A path to an existing file uses the folder of that file. |
|
||||
| `create_new_folder` | BOOLEAN | `false` | Save into a sub-folder of `folder_path`. The `new_folder_name` field only shows while this is on. |
|
||||
| `new_folder_name` | STRING | empty | The sub-folder, such as `day1` or `batch/day1`. It must stay inside `folder_path`. |
|
||||
| `filename_prefix` | STRING | `NH` | The start of every file name. An empty value becomes `NH`. A `/` puts the files in a sub-folder, as in `renders/img`. |
|
||||
@@ -217,6 +239,8 @@ Saves an image batch to a folder you choose, with your own file name prefix and
|
||||
| `D:\Projects\ClientA` | on, `day1` | `portrait` | `D:\Projects\ClientA\day1\portrait0001.png` |
|
||||
| `D:\Projects\ClientA` | off | `final/portrait` | `D:\Projects\ClientA\final\portrait0001.png` |
|
||||
|
||||
The last two rows need `D:/Projects/` (or `D:/Projects/ClientA/`) under `nh_image_folders`.
|
||||
|
||||
**How numbering works**
|
||||
|
||||
The node never overwrites a file. It looks at the files that already use the prefix in the target folder (any of `png`, `jpg`, `webp`, `avif`) and takes the next free number.
|
||||
@@ -228,7 +252,8 @@ The node never overwrites a file. It looks at the files that already use the pre
|
||||
|
||||
**Rules for names**
|
||||
|
||||
- `filename_prefix` and `new_folder_name` must stay inside the target folder. A `..` part or an absolute path stops the run with an error before anything is written. An absolute `folder_path` is allowed.
|
||||
- `filename_prefix` and `new_folder_name` must stay inside the target folder. A `..` part or an absolute path stops the run with an error before anything is written.
|
||||
- `folder_path` must lie inside the `output` folder or a folder listed under `nh_image_folders`. Anything else, a relative path with `..` that leaves `output` included, stops the run before a folder is created.
|
||||
- On Windows, `filename_prefix` cannot contain `:`. It would select a drive instead of naming a file.
|
||||
|
||||
**How it works**
|
||||
|
||||
+34
-9
@@ -11,7 +11,7 @@ Các node này đưa ảnh vào và ra khỏi workflow. Bạn có thể tải m
|
||||
| [Load Image Info (NH)](#load-image-info-nh) | Tải một ảnh và lấy tên tệp, đường dẫn của nó dưới dạng văn bản |
|
||||
| [Load Images Folder (NH)](#load-images-folder-nh) | Tải ảnh từ thư mục theo vị trí, và chuyển sang ảnh kế tiếp sau mỗi lần chạy |
|
||||
| [Load images matching](#load-images-matching) | Tìm thư mục khớp với tên một đối tượng và tải ảnh của nó |
|
||||
| [Save Image Path (NH)](#save-image-path-nh) | Lưu vào thư mục bất kỳ với tiền tố, cách đánh số, định dạng và DPI do bạn chọn |
|
||||
| [Save Image Path (NH)](#save-image-path-nh) | Lưu vào thư mục bạn chọn với tiền tố, cách đánh số, định dạng và DPI riêng |
|
||||
| [NH Save Large Image](#nh-save-large-image) | Lưu ảnh rất lớn mà không có bản xem trước nặng trên canvas |
|
||||
|
||||
## Đường dẫn và loại tệp
|
||||
@@ -24,6 +24,28 @@ Các node này đưa ảnh vào và ra khỏi workflow. Bạn có thể tải m
|
||||
|
||||
Các loader thư mục đọc tệp `.png`, `.jpg`, `.jpeg`, `.webp`, `.avif`, `.bmp`, `.tif` và `.tiff`. Chúng sắp xếp tên theo thứ tự tự nhiên, nên `img2` đứng trước `img10`, và trả về ảnh RGB.
|
||||
|
||||
### Thư mục bên ngoài ComfyUI
|
||||
|
||||
Workflow có thể đến từ bất kỳ ai, và ComfyUI có thể được truy cập qua mạng. Vì vậy thư mục bạn gõ vào node chỉ dùng được ở những nơi sau:
|
||||
|
||||
| | Thư mục được phép |
|
||||
|---|---|
|
||||
| Đọc (Load Images Folder, Load images matching, số ảnh hiện trên node) | thư mục `input`, `output` và `temp` của ComfyUI, cùng các thư mục bạn khai báo |
|
||||
| Ghi (Save Image Path) | thư mục `output` của ComfyUI, cùng các thư mục bạn khai báo |
|
||||
|
||||
Muốn dùng thư mục ở nơi khác (ổ ảnh, thư mục dự án), hãy khai báo một lần dưới khóa `nh_image_folders` trong `extra_model_paths.yaml`, tệp khai báo đường dẫn model bổ sung của ComfyUI (nằm trong thư mục ComfyUI; ComfyUI Desktop giữ bản riêng cùng phần thiết lập của nó), rồi khởi động lại ComfyUI:
|
||||
|
||||
```yaml
|
||||
my_images:
|
||||
nh_image_folders: |
|
||||
D:/Photos/
|
||||
E:/Exports/
|
||||
```
|
||||
|
||||
Mọi thư mục nằm dưới một thư mục đã khai báo cũng được phép, cả đọc lẫn ghi. Đường dẫn ở nơi khác làm lần chạy dừng với thông báo lỗi nêu tên đầu vào và hiện các dòng khai báo này; không có gì được đọc hay tạo ra. Thành phần `..` cũng bị từ chối như vậy. Liên kết (symlink hoặc junction) dẫn ra ngoài các thư mục được phép cũng thế: nếu nó là thư mục bạn gõ (hoặc một phần của `new_folder_name` hay `filename_prefix`) thì lần chạy dừng lại, nếu nó là thư mục con thì bị bỏ qua khi đọc hoặc tìm trong các thư mục con, và tệp ảnh là liên kết như vậy sẽ không được mở. Nếu một thư mục con của `output` hoặc của thư mục đã khai báo là liên kết sang ổ khác, hãy khai báo cả nơi nó dẫn tới. Chỉ tệp trên ổ đĩa mới mở rộng được danh sách: workflow thì không.
|
||||
|
||||
`source_folder_path` của Load images matching là ngoại lệ: node chỉ đọc các tên trong đường dẫn và không bao giờ mở thư mục đó, nên nó có thể là đường dẫn bất kỳ.
|
||||
|
||||
---
|
||||
|
||||
## Load Image Info (NH)
|
||||
@@ -76,7 +98,7 @@ Tải một hoặc nhiều ảnh từ một thư mục theo vị trí, và có t
|
||||
|
||||
| Đầu vào | Kiểu | Mặc định | Mô tả |
|
||||
|---|---|---|---|
|
||||
| `folder_path` | `STRING` | trống | Thư mục cần đọc. Có thể là đường dẫn tuyệt đối, hoặc tương đối so với thư mục `input`. Để trống nghĩa là thư mục `input`. Nếu đường dẫn trỏ tới một tệp, node dùng thư mục chứa tệp đó. |
|
||||
| `folder_path` | `STRING` | trống | Thư mục cần đọc. Đường dẫn tương đối so với thư mục `input`, hoặc đường dẫn tuyệt đối nằm trong một [thư mục được phép](#thư-mục-bên-ngoài-comfyui). Để trống nghĩa là thư mục `input`. Nếu đường dẫn trỏ tới một tệp, node dùng thư mục chứa tệp đó. |
|
||||
| `index` | `INT` | `0` | Vị trí của ảnh đầu tiên cần tải, từ `0` đến `999999`. Giá trị này quay vòng: với 10 ảnh, `12` tải ảnh số `2`. |
|
||||
| `step` | `INT` | `1` | Số ảnh tải mỗi lần chạy (`1` đến `9999`), lấy nối tiếp nhau từ `index`. Đây cũng là khoảng `index` di chuyển giữa các lần chạy ở chế độ `increment` và `decrement`. |
|
||||
| `seed_mode` | `COMBO` | `fixed` | Điều gì xảy ra với `index` mỗi lần bạn queue: `fixed` (giữ nguyên), `increment` (cộng thêm `step`), `decrement` (trừ đi `step`; các ảnh cũng được đọc lùi), `random` (một vị trí ngẫu nhiên mới). |
|
||||
@@ -95,7 +117,7 @@ Tải một hoặc nhiều ảnh từ một thư mục theo vị trí, và có t
|
||||
- Nếu thư mục không có ảnh nào ở cấp của nó, các thư mục con vẫn được tìm kể cả khi `recursive` tắt. Việc tìm trong thư mục con bỏ qua thư mục `models` của ComfyUI.
|
||||
- Trình duyệt di chuyển `index` khi bạn queue workflow từ giao diện ComfyUI. Trình duyệt đếm ảnh theo đúng thiết lập `recursive` của node, nên `increment`, `decrement` và `random` quay vòng và chọn trên đúng những ảnh mà node tải. Nếu bạn gửi workflow qua API, hãy tự đặt `index`.
|
||||
- Node chạy lại khi một trong các tệp được chọn thay đổi trên ổ đĩa (tên, dung lượng hoặc ngày giờ). Nếu `folder_path` được nối từ một node khác, node không thể kiểm tra các tệp từ trước: khi đó nó chạy lại khi đường dẫn được nối hoặc một thiết lập khác thay đổi, nhưng không chạy lại khi một tệp trong cùng thư mục thay đổi.
|
||||
- Thư mục không tồn tại, hoặc không có ảnh nào, sẽ làm lần chạy dừng với một thông báo lỗi.
|
||||
- Thư mục không tồn tại, hoặc không có ảnh nào, sẽ làm lần chạy dừng với một thông báo lỗi. Thư mục nằm ngoài các [thư mục được phép](#thư-mục-bên-ngoài-comfyui) cũng vậy.
|
||||
|
||||
**Ví dụ — upscale cả thư mục**
|
||||
|
||||
@@ -129,8 +151,8 @@ matching_root_path
|
||||
|
||||
| Đầu vào | Kiểu | Mặc định | Mô tả |
|
||||
|---|---|---|---|
|
||||
| `source_folder_path` | `STRING` | trống | Thư mục nguồn của đối tượng. Chỉ **tên** các thư mục trong đường dẫn được dùng, nên thư mục không cần tồn tại. Tên cuối cùng là văn bản cần khớp, trừ khi bạn điền `match_text`. Một thư mục ngữ cảnh trong đường dẫn (xem `context_folder_names`) cho node biết nên ưu tiên nhánh nào. |
|
||||
| `matching_root_path` | `STRING` | trống | Thư mục gốc của cây thư mục cần tìm. Đường dẫn tuyệt đối, hoặc tương đối so với thư mục `input`. Bắt buộc. |
|
||||
| `source_folder_path` | `STRING` | trống | Thư mục nguồn của đối tượng. Chỉ **tên** các thư mục trong đường dẫn được dùng, nên thư mục không cần tồn tại và có thể ở bất kỳ đâu. Đường dẫn kết thúc bằng tên một tệp ảnh (như `…/subject_01/portrait.jpg`) được hiểu là thư mục chứa tệp đó. Tên cuối cùng là văn bản cần khớp, trừ khi bạn điền `match_text`. Một thư mục ngữ cảnh trong đường dẫn (xem `context_folder_names`) cho node biết nên ưu tiên nhánh nào. |
|
||||
| `matching_root_path` | `STRING` | trống | Thư mục gốc của cây thư mục cần tìm. Đường dẫn tương đối so với thư mục `input`, hoặc đường dẫn tuyệt đối nằm trong một [thư mục được phép](#thư-mục-bên-ngoài-comfyui). Bắt buộc. |
|
||||
| `match_text` | `STRING` | trống | Tên thư mục cần tìm. Để trống nghĩa là dùng tên của thư mục nguồn. |
|
||||
| `match_mode` | `COMBO` | `contains` | `contains`: tên thư mục chứa văn bản. `exact`: tên thư mục bằng đúng văn bản. Cả hai đều bỏ qua chữ hoa/thường và dấu, và coi `_` và `-` như dấu cách. |
|
||||
| `recursive` | `BOOLEAN` | `true` | `true`: tìm trong mọi thư mục dưới thư mục gốc. `false`: chỉ xem thư mục gốc và các thư mục con trực tiếp của nó. |
|
||||
@@ -154,7 +176,7 @@ matching_root_path
|
||||
- **Văn bản cần khớp:** `match_text`; nếu trống thì lấy tên thư mục chứa từng dòng của `source_file_names`; nếu cũng trống thì lấy tên thư mục nguồn. Hãy điền ít nhất một trong ba: `source_folder_path`, `source_file_names`, `match_text`. Nếu bỏ trống cả ba, node tìm theo tên thư mục `input` của ComfyUI, tên này hiếm khi khớp.
|
||||
- **Ngữ cảnh:** nếu đường dẫn nguồn chứa một trong các `context_folder_names`, kết quả cùng ngữ cảnh được xếp lên đầu. Nguồn ở `…/Nu/subject_01` sẽ chọn `Nu/subject_01` trước `Nam/subject_01`. Nếu thư mục gốc của bạn nằm trong một thư mục ngữ cảnh (`…/Nam`) còn nguồn thuộc ngữ cảnh khác (`Nu`), node cũng tìm đúng vị trí đó dưới thư mục ngữ cảnh còn lại.
|
||||
- **Khi ngang nhau:** thư mục có đường dẫn giống đường dẫn nguồn nhất thắng, rồi đến thứ tự tên tự nhiên.
|
||||
- **Giới hạn:** việc tìm kiếm nằm trong thư mục gốc. Node không quét các thư mục cha, ngoại trừ các thư mục ngữ cảnh anh em đã nêu ở trên, và không bao giờ vào thư mục `models` của ComfyUI. Khi `recursive` bật, node từ chối thư mục gốc là một ổ đĩa hoặc một chia sẻ mạng (như `D:\` hay `\\server\share`): không thể tìm đệ quy ở đó. Thư mục `models` cũng bị từ chối làm thư mục gốc, dù `recursive` đặt thế nào.
|
||||
- **Giới hạn:** việc tìm kiếm nằm trong thư mục gốc. Node không quét các thư mục cha, ngoại trừ các thư mục ngữ cảnh anh em đã nêu ở trên, và không bao giờ vào thư mục `models` của ComfyUI. Khi `recursive` bật, node từ chối thư mục gốc là một ổ đĩa hoặc một chia sẻ mạng (như `D:\` hay `\\server\share`): không thể tìm đệ quy ở đó. Thư mục `models` cũng bị từ chối làm thư mục gốc, dù `recursive` đặt thế nào. Thư mục gốc nằm ngoài các [thư mục được phép](#thư-mục-bên-ngoài-comfyui) bị từ chối, và thư mục ngữ cảnh anh em chỉ được tìm khi thư mục chứa nó được phép.
|
||||
- **Tệp:** ảnh trong thư mục khớp được sắp xếp theo thứ tự tự nhiên. `index` và `step` chọn ảnh nào được tải.
|
||||
- **Bộ nhớ đệm:** kết quả được dùng lại cho đến khi thư mục khớp hoặc một trong các tệp được chọn thay đổi (tên, dung lượng hoặc ngày giờ). Lần tìm không thấy gì không bao giờ được ghi nhớ, nên thư mục bạn thêm sau đó sẽ được tìm thấy ở lần chạy kế tiếp. Nếu một thiết lập được nối từ node khác, node không biết trước thư mục nào. Khi đó nó theo dõi cả cây thư mục dưới thư mục gốc, và với cây rất lớn thì chạy lại ở mọi lần queue.
|
||||
|
||||
@@ -188,12 +210,12 @@ Trên Load Images Folder, đặt `path_output` là `folder_path` để `path` l
|
||||
|
||||
Lưu một batch ảnh vào thư mục bạn chọn, với tiền tố tên tệp riêng và đánh số tự động.
|
||||
|
||||
**Dùng khi** kết quả cần nằm trong thư mục dự án bên ngoài thư mục `output` mặc định, hoặc bạn cần định dạng hay giá trị DPI mà Save Image có sẵn không có.
|
||||
**Dùng khi** kết quả cần nằm trong một thư mục con của `output` hoặc trong thư mục dự án bạn đã khai báo dưới `nh_image_folders`, hoặc bạn cần định dạng hay giá trị DPI mà Save Image có sẵn không có.
|
||||
|
||||
| Đầu vào | Kiểu | Mặc định | Mô tả |
|
||||
|---|---|---|---|
|
||||
| `images` | `IMAGE` | — | Các ảnh cần lưu. Mỗi ảnh trong batch thành một tệp. |
|
||||
| `folder_path` | `STRING` | trống | Nơi lưu. Để trống nghĩa là thư mục `output`. Đường dẫn tương đối được đặt bên trong `output`. Đường dẫn tuyệt đối được dùng nguyên như vậy, kể cả bên ngoài ComfyUI. Thư mục chưa có sẽ được tạo. Đường dẫn trỏ tới một tệp có sẵn sẽ dùng thư mục chứa tệp đó. |
|
||||
| `folder_path` | `STRING` | trống | Nơi lưu. Để trống nghĩa là thư mục `output`. Đường dẫn tương đối được đặt bên trong `output`. Đường dẫn tuyệt đối phải nằm trong `output` hoặc trong một thư mục khai báo dưới `nh_image_folders` (xem [Thư mục bên ngoài ComfyUI](#thư-mục-bên-ngoài-comfyui)). Thư mục chưa có sẽ được tạo. Đường dẫn trỏ tới một tệp có sẵn sẽ dùng thư mục chứa tệp đó. |
|
||||
| `create_new_folder` | `BOOLEAN` | `false` | Lưu vào một thư mục con của `folder_path`. Ô `new_folder_name` chỉ hiện khi tùy chọn này bật. |
|
||||
| `new_folder_name` | `STRING` | trống | Thư mục con, ví dụ `day1` hoặc `batch/day1`. Phải nằm bên trong `folder_path`. |
|
||||
| `filename_prefix` | `STRING` | `NH` | Phần đầu của mọi tên tệp. Giá trị trống sẽ thành `NH`. Dấu `/` đưa tệp vào thư mục con, như `renders/img`. |
|
||||
@@ -217,6 +239,8 @@ Lưu một batch ảnh vào thư mục bạn chọn, với tiền tố tên tệ
|
||||
| `D:\Projects\ClientA` | bật, `day1` | `portrait` | `D:\Projects\ClientA\day1\portrait0001.png` |
|
||||
| `D:\Projects\ClientA` | tắt | `final/portrait` | `D:\Projects\ClientA\final\portrait0001.png` |
|
||||
|
||||
Hai dòng cuối cần `D:/Projects/` (hoặc `D:/Projects/ClientA/`) dưới `nh_image_folders`.
|
||||
|
||||
**Cách đánh số**
|
||||
|
||||
Node không bao giờ ghi đè tệp. Nó xem các tệp đã dùng tiền tố đó trong thư mục đích (bất kỳ định dạng nào trong `png`, `jpg`, `webp`, `avif`) và lấy số trống kế tiếp.
|
||||
@@ -228,7 +252,8 @@ Node không bao giờ ghi đè tệp. Nó xem các tệp đã dùng tiền tố
|
||||
|
||||
**Quy tắc đặt tên**
|
||||
|
||||
- `filename_prefix` và `new_folder_name` phải nằm bên trong thư mục đích. Thành phần `..` hoặc đường dẫn tuyệt đối làm lần chạy dừng với lỗi trước khi ghi bất cứ thứ gì. `folder_path` tuyệt đối thì được phép.
|
||||
- `filename_prefix` và `new_folder_name` phải nằm bên trong thư mục đích. Thành phần `..` hoặc đường dẫn tuyệt đối làm lần chạy dừng với lỗi trước khi ghi bất cứ thứ gì.
|
||||
- `folder_path` phải nằm trong thư mục `output` hoặc trong một thư mục khai báo dưới `nh_image_folders`. Mọi đường dẫn khác, kể cả đường dẫn tương đối có `..` đi ra khỏi `output`, làm lần chạy dừng trước khi thư mục nào được tạo.
|
||||
- Trên Windows, `filename_prefix` không được chứa `:`. Ký tự này sẽ chọn ổ đĩa thay vì đặt tên tệp.
|
||||
|
||||
**Cách hoạt động**
|
||||
|
||||
@@ -5,16 +5,17 @@ import cv2
|
||||
|
||||
|
||||
class NH_AgnosticImageGenerator:
|
||||
"""Xóa vùng mask trên ảnh và fill bằng gray, noise, hoặc blur.
|
||||
"""Erase the mask region of the image and fill it with gray, noise or blur.
|
||||
|
||||
Output:
|
||||
- agnostic_img: ảnh đã fill vùng mask (dùng làm input cho inpaint/VTON)
|
||||
- masked_img: ảnh gốc với vùng mask bị xóa trắng (transparent)
|
||||
- composite: ảnh gốc với viền mask overlay để preview
|
||||
- agnostic_img: the image with the mask region filled (used as input for inpaint/VTON)
|
||||
- masked_img: the original image with the mask region blanked to white (transparent)
|
||||
- composite: the original image with the mask outline overlaid, for preview
|
||||
|
||||
Mask là alpha mềm: giá trị trung gian được blend một phần, feathering làm mềm thêm từ chính các giá trị đó.
|
||||
Mask 2-D [H,W] coi như [1,H,W]; mask khác kích thước ảnh được resize về kích thước ảnh.
|
||||
Fill "noise" tất định: ảnh thứ i trong batch dùng seed = i.
|
||||
The mask is a soft alpha: intermediate values are blended partially, and feathering softens further starting
|
||||
from those same values.
|
||||
A 2-D mask [H,W] is treated as [1,H,W]; a mask whose size differs from the image is resized to the image size.
|
||||
The "noise" fill is deterministic: the i-th image of the batch uses seed = i.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
@@ -41,7 +42,7 @@ class NH_AgnosticImageGenerator:
|
||||
results_masked = []
|
||||
results_composite = []
|
||||
|
||||
# Chuẩn hoá mask về [B,H,W] đúng kích thước ảnh (batch 1 được dùng lại cho mọi ảnh ở vòng lặp dưới).
|
||||
# Normalise the mask to [B,H,W] at the image size (a batch of 1 is reused for every image in the loop below).
|
||||
if mask.dim() == 2:
|
||||
mask = mask.unsqueeze(0)
|
||||
if mask.shape[-2:] != image.shape[1:3]:
|
||||
@@ -55,12 +56,13 @@ class NH_AgnosticImageGenerator:
|
||||
|
||||
h, w, c = img_np.shape
|
||||
|
||||
# Alpha giữ nguyên giá trị mềm của mask (không ngưỡng hoá), kẹp về [0,1]
|
||||
# Alpha keeps the soft values of the mask (no thresholding), clamped to [0,1]
|
||||
alpha = np.clip(m_np, 0.0, 1.0).astype(np.float32)
|
||||
# Viền preview cần hình dạng rõ nét: lấy ở mức 0.5 của mask gốc (như trước đây), không dùng alpha mềm
|
||||
# The preview outline needs a crisp shape: taken at the 0.5 level of the original mask (as before),
|
||||
# not from the soft alpha
|
||||
outline_mask = (m_np > 0.5).astype(np.float32)
|
||||
|
||||
# Feathering: làm mềm biên mask
|
||||
# Feathering: soften the mask edge
|
||||
if feathering > 0:
|
||||
kernel_size = feathering * 2 + 1
|
||||
alpha = cv2.GaussianBlur(alpha, (kernel_size, kernel_size), 0)
|
||||
@@ -73,25 +75,25 @@ class NH_AgnosticImageGenerator:
|
||||
fill = np.full_like(img_np, gray_value)
|
||||
|
||||
elif fill_mode == "noise":
|
||||
# Seed cố định theo index trong batch: cùng input luôn ra cùng noise
|
||||
# The seed is fixed by the index in the batch: the same input always gives the same noise
|
||||
noise = np.random.default_rng(i).random((h, w, c), dtype=np.float32)
|
||||
# Blend noise với gray trung tâm để bớt harsh
|
||||
# Blend the noise with the centre gray to make it less harsh
|
||||
fill = noise * noise_strength + gray_value * (1.0 - noise_strength)
|
||||
|
||||
elif fill_mode == "blur":
|
||||
# Blur radius phải lẻ
|
||||
# The blur radius must be odd
|
||||
kr = blur_radius if blur_radius % 2 == 1 else blur_radius + 1
|
||||
fill = cv2.GaussianBlur(img_np, (kr, kr), 0)
|
||||
|
||||
# --- agnostic_img: blend fill vào vùng mask ---
|
||||
# --- agnostic_img: blend the fill into the mask region ---
|
||||
agnostic = img_np * (1.0 - alpha_3ch) + fill * alpha_3ch
|
||||
agnostic = np.clip(agnostic, 0.0, 1.0)
|
||||
|
||||
# --- masked_img: vùng mask = trắng ---
|
||||
# --- masked_img: mask region = white ---
|
||||
masked = img_np * (1.0 - alpha_3ch) + np.ones_like(img_np) * alpha_3ch
|
||||
masked = np.clip(masked, 0.0, 1.0)
|
||||
|
||||
# --- composite: overlay viền mask lên ảnh gốc ---
|
||||
# --- composite: overlay the mask outline on the original image ---
|
||||
composite = self._create_composite(img_np, outline_mask, agnostic)
|
||||
|
||||
results_agnostic.append(agnostic)
|
||||
@@ -105,31 +107,31 @@ class NH_AgnosticImageGenerator:
|
||||
|
||||
@staticmethod
|
||||
def _create_composite(img_np, outline_mask, agnostic):
|
||||
"""Tạo preview: nửa trái = ảnh gốc với viền mask, nửa phải = agnostic."""
|
||||
"""Build the preview: left half = original image with the mask outline, right half = agnostic."""
|
||||
h, w = img_np.shape[:2]
|
||||
|
||||
# Tìm contour của mask để vẽ viền
|
||||
# Find the mask contours to draw the outline
|
||||
mask_uint8 = np.ascontiguousarray((outline_mask * 255).astype(np.uint8))
|
||||
contours, _ = cv2.findContours(mask_uint8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
# Nửa trái: ảnh gốc + viền xanh
|
||||
# Left half: original image + green outline
|
||||
left = np.ascontiguousarray((np.clip(img_np, 0.0, 1.0) * 255).astype(np.uint8))
|
||||
cv2.drawContours(left, contours, -1, (0, 255, 0), 2)
|
||||
|
||||
# Tô nhẹ vùng mask bằng màu xanh semi-transparent
|
||||
# Lightly tint the mask region with semi-transparent green
|
||||
overlay = np.ascontiguousarray(left.copy())
|
||||
cv2.drawContours(overlay, contours, -1, (0, 255, 0), -1)
|
||||
left = cv2.addWeighted(left, 0.7, overlay, 0.3, 0)
|
||||
|
||||
# Nửa phải: agnostic
|
||||
# Right half: agnostic
|
||||
right = np.ascontiguousarray((np.clip(agnostic, 0.0, 1.0) * 255).astype(np.uint8))
|
||||
|
||||
# Ghép 2 nửa
|
||||
# Join the 2 halves
|
||||
mid = w // 2
|
||||
composite = np.ascontiguousarray(left.copy())
|
||||
composite[:, mid:] = right[:, mid:]
|
||||
|
||||
# Vẽ đường chia giữa
|
||||
# Draw the dividing line in the middle
|
||||
cv2.line(composite, (mid, 0), (mid, h), (255, 255, 255), 1)
|
||||
|
||||
return composite.astype(np.float32) / 255.0
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import torch
|
||||
import numpy as np
|
||||
import cv2 # Cần thư viện OpenCV
|
||||
import cv2 # Requires the OpenCV library
|
||||
|
||||
from ...core.mask_ops import mask_bbox
|
||||
|
||||
def _to_uint8(image):
|
||||
# Clip trước khi ép uint8: giá trị lệch khỏi [0,1] (vd. do resample) sẽ bị wrap về gần 0 nếu không clip.
|
||||
# Clip before casting to uint8: values outside [0,1] (e.g. from resampling) would wrap to near 0 without it.
|
||||
return (np.clip(image.cpu().numpy(), 0.0, 1.0) * 255).astype(np.uint8)
|
||||
|
||||
def _as_mask_batch(mask):
|
||||
# MASK 2-D [H,W] không có chiều batch: coi là [1,H,W].
|
||||
# A 2-D MASK [H,W] has no batch dimension: treat it as [1,H,W].
|
||||
return mask.unsqueeze(0) if mask.dim() == 2 else mask
|
||||
|
||||
class NH_SimpleFacePaste:
|
||||
@@ -32,7 +32,7 @@ class NH_SimpleFacePaste:
|
||||
def paste_face(self, dest_image, dest_mask, source_image, source_mask, feathering):
|
||||
dest_mask = _as_mask_batch(dest_mask)
|
||||
source_mask = _as_mask_batch(source_mask)
|
||||
# Output theo batch của dest_image; input khác có batch 1 (hoặc ngắn hơn) được lặp lại theo vòng.
|
||||
# The output follows the batch of dest_image; other inputs with batch 1 (or a shorter one) repeat cyclically.
|
||||
output = dest_image.clone()
|
||||
for index in range(dest_image.shape[0]):
|
||||
pasted = self._paste_single(
|
||||
@@ -40,13 +40,14 @@ class NH_SimpleFacePaste:
|
||||
source_image[index % source_image.shape[0]], source_mask[index % source_mask.shape[0]],
|
||||
feathering,
|
||||
)
|
||||
if pasted is not None: # mask rỗng: giữ nguyên ảnh đích của item này
|
||||
if pasted is not None: # empty mask: keep this item's destination image as is
|
||||
output[index] = pasted
|
||||
return (output,)
|
||||
|
||||
@staticmethod
|
||||
def _paste_single(dest_image, dest_mask, source_image, source_mask, feathering):
|
||||
"""Dán mặt của một ảnh nguồn [H,W,C] lên một ảnh đích [H,W,C]; trả None nếu một trong hai mask rỗng."""
|
||||
"""Paste the face of one source image [H,W,C] onto one destination image [H,W,C]; returns None when either
|
||||
mask is empty."""
|
||||
src_img_np = _to_uint8(source_image)
|
||||
src_mask_np = source_mask.cpu().numpy()
|
||||
|
||||
@@ -85,13 +86,12 @@ class NH_SimpleFacePaste:
|
||||
|
||||
return torch.from_numpy(output_img_np.astype(np.float32) / 255.0)
|
||||
|
||||
# --- PHẦN BỊ THIẾU TRƯỚC ĐÂY ĐÃ ĐƯỢC THÊM VÀO ĐÂY ---
|
||||
# Ánh xạ để ComfyUI nhận diện class
|
||||
# Mapping that lets ComfyUI find the class
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"NH_SimpleFacePaste": NH_SimpleFacePaste
|
||||
}
|
||||
|
||||
# Tên sẽ hiển thị trong menu của ComfyUI
|
||||
# Name shown in the ComfyUI menu
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"NH_SimpleFacePaste": "NH Simple Face Paste"
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import folder_paths
|
||||
import node_helpers
|
||||
from comfy_execution.graph_utils import ExecutionBlocker
|
||||
|
||||
from ...core.allowed_folders import can_read, require_readable
|
||||
from ...core.image_folders import IMAGE_EXTENSIONS, collect_image_files, natural_key, resolve_load_dir
|
||||
from ...core.image_ops import pil_to_image_tensor, resize_pil
|
||||
|
||||
@@ -179,11 +180,32 @@ def _entry_is_dir(entry, follow_symlinks=True):
|
||||
|
||||
|
||||
def _absolute_root(root_path):
|
||||
"""The matching root as an absolute path; a relative one is taken relative to ComfyUI's input folder."""
|
||||
"""The matching root as an absolute path; a relative one is taken relative to ComfyUI's input folder.
|
||||
|
||||
FolderNotAllowed for a root outside the folders NH-Nodes may read, before the disk is touched.
|
||||
"""
|
||||
root_path = (root_path or "").strip()
|
||||
if root_path and not os.path.isabs(root_path):
|
||||
if not root_path:
|
||||
return root_path
|
||||
if not os.path.isabs(root_path):
|
||||
root_path = os.path.join(folder_paths.get_input_directory(), root_path)
|
||||
return root_path
|
||||
return require_readable(root_path, "matching_root_path")
|
||||
|
||||
|
||||
def _source_folder(source_path):
|
||||
"""The folder a source path names, worked out from the text alone: the source is only read for its folder names.
|
||||
|
||||
A path that ends in an image file name stands for the folder of that file. An empty path is ComfyUI's input folder,
|
||||
a relative one lies below it.
|
||||
"""
|
||||
source_path = (source_path or "").strip()
|
||||
if not source_path:
|
||||
return folder_paths.get_input_directory()
|
||||
if not os.path.isabs(source_path):
|
||||
source_path = os.path.join(folder_paths.get_input_directory(), source_path)
|
||||
if Path(source_path).suffix.lower() in IMAGE_EXTENSIONS:
|
||||
source_path = os.path.dirname(source_path)
|
||||
return source_path
|
||||
|
||||
|
||||
def _check_search_root(root_path, recursive):
|
||||
@@ -205,9 +227,13 @@ def _list_folder(folder_path):
|
||||
except OSError:
|
||||
return []
|
||||
models_dir = os.path.normcase(os.path.abspath(folder_paths.models_dir))
|
||||
# A sub-folder that is a link or junction out of the folders NH-Nodes may read is left out, like the models folder.
|
||||
return [
|
||||
entry for entry in entries
|
||||
if not (_entry_is_dir(entry) and os.path.normcase(os.path.normpath(entry.path)) == models_dir)
|
||||
if not (
|
||||
_entry_is_dir(entry)
|
||||
and (os.path.normcase(os.path.normpath(entry.path)) == models_dir or not can_read(entry.path))
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -250,7 +276,10 @@ def _context_twins(root_path, context_names, wanted=None):
|
||||
if position == 0 or own_name not in context:
|
||||
continue
|
||||
rest = parts[position + 1:]
|
||||
for sibling_path in _child_folders(str(Path(*parts[:position]))):
|
||||
parent = str(Path(*parts[:position]))
|
||||
if not can_read(parent):
|
||||
continue # a parent NH-Nodes may not read is never listed, so its other context folders are no twins
|
||||
for sibling_path in _child_folders(parent):
|
||||
sibling_name = _normalize_match_value(os.path.basename(sibling_path))
|
||||
if sibling_name == own_name or sibling_name not in context:
|
||||
continue
|
||||
@@ -407,7 +436,7 @@ def _matching_selection(
|
||||
|
||||
source_paths = [line.strip() for line in str(source_folder_path or "").splitlines() if line.strip()]
|
||||
first_source_path = source_paths[0] if source_paths else ""
|
||||
resolved_source_folder = resolve_load_dir(first_source_path)
|
||||
resolved_source_folder = _source_folder(first_source_path)
|
||||
selected_match_text = (match_text or "").strip()
|
||||
source_names = [line.strip() for line in str(source_file_names or "").splitlines() if line.strip()]
|
||||
context_names = _parse_context_folder_names(context_folder_names)
|
||||
@@ -499,6 +528,7 @@ def _load_image_batch(paths):
|
||||
images = []
|
||||
target_size = None
|
||||
for path in paths:
|
||||
require_readable(path, "The image file") # a file that is a link out of the allowed folders is not opened
|
||||
img = node_helpers.pillow(Image.open, path)
|
||||
img = node_helpers.pillow(ImageOps.exif_transpose, img).convert("RGB")
|
||||
if target_size is None:
|
||||
|
||||
@@ -14,7 +14,9 @@ from ...core.image_ops import RESAMPLING, parse_color, pil_to_image_tensor
|
||||
_FONT_EXTENSIONS = {".ttf", ".otf", ".ttc"}
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _font_choices():
|
||||
""""default" and the fonts of the Windows font folder. Read when the node list is first asked for, not at import."""
|
||||
fonts = ["default"]
|
||||
seen = set(fonts)
|
||||
font_dir = Path(os.environ.get("WINDIR", "C:\\Windows")) / "Fonts"
|
||||
@@ -26,9 +28,6 @@ def _font_choices():
|
||||
return fonts
|
||||
|
||||
|
||||
_FONT_CHOICES = _font_choices()
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=64)
|
||||
def _cached_truetype(path, size):
|
||||
"""Open a TrueType font once per (path, size), as the Label node asks for many sizes. None if it cannot be opened."""
|
||||
@@ -44,7 +43,8 @@ def _default_font():
|
||||
|
||||
|
||||
def _load_font(font_name, font_size):
|
||||
if font_name and font_name != "default":
|
||||
# Only a name from the list: a wired text could otherwise be any path, a network path included.
|
||||
if font_name and font_name != "default" and font_name in _font_choices():
|
||||
font_path = Path(os.environ.get("WINDIR", "C:\\Windows")) / "Fonts" / font_name
|
||||
if font_path.exists():
|
||||
font = _cached_truetype(str(font_path), font_size)
|
||||
@@ -96,7 +96,7 @@ class NH_ImageLabel:
|
||||
"fill_enabled": ("BOOLEAN", {"default": True}),
|
||||
"frame_color_hex": ("STRING", {"default": "#111111", "multiline": False}),
|
||||
"text_color_hex": ("STRING", {"default": "#FFFFFF", "multiline": False}),
|
||||
"font_name": (_FONT_CHOICES,),
|
||||
"font_name": (_font_choices(),),
|
||||
"font_size": ("INT", {"default": 32, "min": 6, "max": 512, "step": 1}),
|
||||
"fit_text": ("BOOLEAN", {"default": True}),
|
||||
"overlay_on_image": ("BOOLEAN", {"default": False}),
|
||||
|
||||
@@ -9,8 +9,8 @@ from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from ...core.utils import tensor_to_pil
|
||||
|
||||
# Cạnh dài nhất (px) mà từng định dạng ghi được; PNG/TIFF không bị giới hạn trong thực tế.
|
||||
# JPEG: spec cho tới 65535 nhưng libjpeg (Pillow) từ chối > 65500 (JPEG_MAX_DIMENSION).
|
||||
# Longest side (px) each format can write; PNG/TIFF have no limit in practice.
|
||||
# JPEG: the spec allows up to 65535 but libjpeg (Pillow) rejects > 65500 (JPEG_MAX_DIMENSION).
|
||||
_MAX_SIDE_BY_EXTENSION = {"webp": 16383, "jpg": 65500}
|
||||
|
||||
|
||||
@@ -91,9 +91,10 @@ class NH_SaveLargeImage:
|
||||
|
||||
def save(self, image, filename_prefix="NH_Large", file_format="png", quality=95, embed_metadata=True, prompt=None, extra_pnginfo=None):
|
||||
extension = _extension_for_save_format(file_format)
|
||||
# Kiểm tra giới hạn trước khi ghi bất kỳ file nào (mọi ảnh trong batch cùng kích thước), kể cả tạo thư mục.
|
||||
# Check the limit before writing any file, creating the folder included (every image of the batch has the
|
||||
# same size).
|
||||
_check_size_limit(extension, int(image.shape[2]), int(image.shape[1]))
|
||||
# --disable-metadata của ComfyUI thắng ô embed_metadata: không nhúng prompt/workflow.
|
||||
# ComfyUI's --disable-metadata overrides the embed_metadata toggle: no prompt/workflow is embedded.
|
||||
embed_metadata = bool(embed_metadata) and not args.disable_metadata
|
||||
first = image[0]
|
||||
full_output_folder, filename, counter, _subfolder, _filename_prefix = folder_paths.get_save_image_path(
|
||||
|
||||
@@ -26,8 +26,8 @@ class NH_LargePreviewCacheManager:
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, **kwargs):
|
||||
# Info/cleanup đọc và xoá file trên đĩa, input không đổi vẫn phải chạy lại mỗi lần queue;
|
||||
# NaN không bằng chính nó nên ComfyUI không bao giờ dùng lại output cache.
|
||||
# Info/cleanup read and delete files on disk, so the node must run again on every queue even with unchanged
|
||||
# inputs; NaN never equals itself, so ComfyUI never reuses the cached output.
|
||||
return float("nan")
|
||||
|
||||
def run(self, action="info", max_age_hours=24.0, max_total_mb=2048.0, cache_id=""):
|
||||
|
||||
@@ -6,11 +6,13 @@ from ...core.mask_ops import mask_bbox
|
||||
|
||||
|
||||
class NH_MaskResizeImage:
|
||||
"""Resize ảnh theo kích thước target, đảm bảo vùng mask không bị mất.
|
||||
"""Resize the image to the target size while making sure the mask region is not lost.
|
||||
|
||||
- Crop: scale để mask vừa khít target, crop phần ảnh thừa (pad nếu ảnh nhỏ hơn target).
|
||||
- Pad: scale vừa đủ để mask nằm trong target, pad phần thừa bằng màu tùy chọn.
|
||||
- Mask rỗng: coi như mask phủ cả ảnh (crop: lấp đầy target rồi cắt giữa; pad: cả ảnh nằm gọn trong target).
|
||||
- Crop: scale so the mask fits the target exactly, crop the surplus image (pad when the image is smaller than
|
||||
the target).
|
||||
- Pad: scale just enough for the mask to lie inside the target, pad the rest with the chosen colour.
|
||||
- Empty mask: treated as a mask covering the whole image (crop: fill the target, then centre-crop; pad: the
|
||||
whole image fits inside the target).
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
@@ -47,7 +49,7 @@ class NH_MaskResizeImage:
|
||||
results_img.append(out_img)
|
||||
results_mask.append(out_mask)
|
||||
|
||||
# Lanczos có thể vọt ra ngoài [0,1] (ringing) quanh cạnh tương phản cao -> kẹp lại
|
||||
# Lanczos can overshoot [0,1] (ringing) around high-contrast edges -> clamp
|
||||
out_images = torch.from_numpy(np.clip(np.stack(results_img, axis=0), 0.0, 1.0)).float()
|
||||
out_masks = torch.from_numpy(np.stack(results_mask, axis=0)).float()
|
||||
return (out_images, out_masks)
|
||||
@@ -55,34 +57,34 @@ class NH_MaskResizeImage:
|
||||
def _process_single(self, img_np, mask_np, tw, th, mode, pad_color):
|
||||
sh, sw = img_np.shape[:2]
|
||||
|
||||
# Tìm bounding box của mask
|
||||
# Find the bounding box of the mask
|
||||
bbox = mask_bbox(mask_np)
|
||||
if bbox is None:
|
||||
# Mask rỗng: resize bình thường
|
||||
# Empty mask: plain resize
|
||||
return self._simple_resize(img_np, mask_np, tw, th, mode, pad_color)
|
||||
|
||||
mx1, my1, mx2, my2 = bbox
|
||||
mw = mx2 - mx1 + 1
|
||||
mh = my2 - my1 + 1
|
||||
|
||||
# Tính scale cần thiết để vùng mask vừa trong target
|
||||
# Compute the scale needed for the mask region to fit the target
|
||||
scale_for_mask_w = tw / mw
|
||||
scale_for_mask_h = th / mh
|
||||
|
||||
# Cả hai mode: scale lớn nhất mà toàn bộ mask vẫn nằm trong target.
|
||||
# (Dùng max ở đây sẽ làm mask tràn target ở một chiều và bị crop mất.)
|
||||
# Both modes: the largest scale at which the whole mask still lies inside the target.
|
||||
# (Using max here would make the mask overflow the target in one dimension and get cropped away.)
|
||||
scale = min(scale_for_mask_w, scale_for_mask_h)
|
||||
|
||||
# Kích thước ảnh sau khi scale
|
||||
# Image size after scaling
|
||||
new_sw = max(1, round(sw * scale))
|
||||
new_sh = max(1, round(sh * scale))
|
||||
|
||||
# Resize ảnh và mask
|
||||
# Resize the image and the mask
|
||||
img_scaled = cv2.resize(img_np, (new_sw, new_sh), interpolation=cv2.INTER_LANCZOS4)
|
||||
mask_scaled = cv2.resize(mask_np, (new_sw, new_sh), interpolation=cv2.INTER_NEAREST)
|
||||
|
||||
# Vị trí mask center sau khi scale
|
||||
# Pixel mask chiếm [mx1, mx2 + 1) -> tâm là (mx1 + mx2 + 1) / 2
|
||||
# Position of the mask centre after scaling
|
||||
# The mask pixels span [mx1, mx2 + 1) -> the centre is (mx1 + mx2 + 1) / 2
|
||||
mask_cx = round((mx1 + mx2 + 1) / 2.0 * scale)
|
||||
mask_cy = round((my1 + my2 + 1) / 2.0 * scale)
|
||||
|
||||
@@ -94,15 +96,15 @@ class NH_MaskResizeImage:
|
||||
def _crop_around_center(self, img, mask, tw, th, cx, cy, pad_color):
|
||||
h, w = img.shape[:2]
|
||||
|
||||
# Ảnh scaled nhỏ hơn target: không thể crop mà giữ đủ mask -> pad bằng màu user chọn
|
||||
# Scaled image smaller than the target: cropping cannot keep the whole mask -> pad with the user's colour
|
||||
if w < tw or h < th:
|
||||
return self._pad_around_center(img, mask, tw, th, cx, cy, pad_color)
|
||||
|
||||
# Tính vùng crop, giữ mask center ở giữa target
|
||||
# Compute the crop region, keeping the mask centre in the middle of the target
|
||||
x1 = cx - tw // 2
|
||||
y1 = cy - th // 2
|
||||
|
||||
# Clamp để không vượt ra ngoài ảnh
|
||||
# Clamp so the crop does not leave the image
|
||||
x1 = max(0, min(x1, w - tw))
|
||||
y1 = max(0, min(y1, h - th))
|
||||
x2 = x1 + tw
|
||||
@@ -113,15 +115,15 @@ class NH_MaskResizeImage:
|
||||
def _pad_around_center(self, img, mask, tw, th, cx, cy, pad_color):
|
||||
h, w = img.shape[:2]
|
||||
|
||||
# Vị trí đặt ảnh trên canvas target, giữ mask center ở giữa target
|
||||
# Where the image goes on the target canvas, keeping the mask centre in the middle of the target
|
||||
paste_x = tw // 2 - cx
|
||||
paste_y = th // 2 - cy
|
||||
|
||||
# Clamp: đảm bảo ảnh không bị đặt quá xa khiến vùng mask bị cắt
|
||||
# Clamp: make sure the image is not placed so far off that the mask region gets cut
|
||||
paste_x = max(min(paste_x, tw - 1), -(w - 1))
|
||||
paste_y = max(min(paste_y, th - 1), -(h - 1))
|
||||
|
||||
# Tạo canvas
|
||||
# Create the canvas
|
||||
canvas_img = np.zeros((th, tw, img.shape[2]), dtype=img.dtype)
|
||||
r, g, b = pad_color
|
||||
canvas_img[:, :, 0] = r / 255.0
|
||||
@@ -130,7 +132,7 @@ class NH_MaskResizeImage:
|
||||
|
||||
canvas_mask = np.zeros((th, tw), dtype=mask.dtype)
|
||||
|
||||
# Tính vùng overlap
|
||||
# Compute the overlap region
|
||||
src_x1 = max(0, -paste_x)
|
||||
src_y1 = max(0, -paste_y)
|
||||
dst_x1 = max(0, paste_x)
|
||||
@@ -147,9 +149,10 @@ class NH_MaskResizeImage:
|
||||
return canvas_img, canvas_mask
|
||||
|
||||
def _simple_resize(self, img_np, mask_np, tw, th, mode, pad_color):
|
||||
"""Fallback khi mask rỗng: coi như mask phủ cả ảnh, không méo ảnh.
|
||||
"""Fallback for an empty mask: treat it as a mask covering the whole image, without distorting the image.
|
||||
|
||||
crop = scale lớn nhất (lấp đầy target) rồi cắt giữa; pad = scale nhỏ nhất (cả ảnh nằm trong target), phần thừa pad_color.
|
||||
crop = the larger scale (fills the target), then centre-crop; pad = the smaller scale (the whole image fits
|
||||
inside the target), the rest is pad_color.
|
||||
"""
|
||||
sh, sw = img_np.shape[:2]
|
||||
scale = max(tw / sw, th / sh) if mode == "crop" else min(tw / sw, th / sh)
|
||||
@@ -158,7 +161,7 @@ class NH_MaskResizeImage:
|
||||
img_scaled = cv2.resize(img_np, (new_sw, new_sh), interpolation=cv2.INTER_LANCZOS4)
|
||||
mask_scaled = cv2.resize(mask_np, (new_sw, new_sh), interpolation=cv2.INTER_NEAREST)
|
||||
|
||||
# Tâm ảnh sau khi scale
|
||||
# Image centre after scaling
|
||||
cx = round(sw / 2.0 * scale)
|
||||
cy = round(sh / 2.0 * scale)
|
||||
if mode == "crop":
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Save Image Path (NH): numbered image files in any folder, with an optional preview."""
|
||||
"""Save Image Path (NH): numbered image files in a folder NH-Nodes may write to, with an optional preview."""
|
||||
|
||||
import os
|
||||
import re
|
||||
@@ -6,6 +6,7 @@ import uuid
|
||||
|
||||
import folder_paths
|
||||
|
||||
from ...core.allowed_folders import require_writable
|
||||
from ...core.utils import tensor_to_pil
|
||||
|
||||
_WINDOWS = os.name == "nt"
|
||||
@@ -15,11 +16,13 @@ def _save_dir_from_text(folder_path):
|
||||
folder_path = (folder_path or "").strip()
|
||||
if not folder_path:
|
||||
return folder_paths.get_output_directory()
|
||||
if not os.path.isabs(folder_path):
|
||||
folder_path = os.path.join(folder_paths.get_output_directory(), folder_path)
|
||||
# Checked before the disk is touched: ComfyUI's output folder and the folders the machine's owner listed.
|
||||
folder_path = require_writable(folder_path, "folder_path")
|
||||
if os.path.isfile(folder_path):
|
||||
return os.path.dirname(folder_path)
|
||||
if os.path.isabs(folder_path):
|
||||
return folder_path
|
||||
return os.path.join(folder_paths.get_output_directory(), folder_path)
|
||||
return folder_path
|
||||
|
||||
|
||||
def _contained_path(base_dir, relative, what):
|
||||
@@ -130,6 +133,8 @@ class NH_SaveImagePath:
|
||||
# A prefix may name subfolders ("renders/img"); they live below save_dir and are where files are counted.
|
||||
prefix = (filename_prefix or "NH").strip() or "NH"
|
||||
prefix_folder, prefix_name, target_dir = _split_filename_prefix(save_dir, prefix)
|
||||
# Again for the final folder: new_folder_name or the prefix may pass through a link that leads elsewhere.
|
||||
require_writable(target_dir, "The target folder")
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
|
||||
numbered_pattern = re.compile(rf"^{re.escape(prefix_name)}(\d+)\.(?:png|jpe?g|webp|avif)$", re.IGNORECASE)
|
||||
|
||||
@@ -5,14 +5,14 @@ from ...core.mask_ops import mask_bbox
|
||||
|
||||
|
||||
def _fit_window(start, length, limit):
|
||||
"""Cửa sổ [start, start + length) trượt vào trong [0, limit); lớn hơn limit thì cắt ở mép cuối."""
|
||||
"""Slide the window [start, start + length) inside [0, limit); one longer than limit is cut at the far edge."""
|
||||
start = max(0, min(start, limit - length))
|
||||
return start, min(limit, start + length)
|
||||
|
||||
|
||||
def _adjust_mask(mask_np, bbox, target_size, mode):
|
||||
"""Chỉnh hộp (bbox) của một mask [H, W] về đúng tỷ lệ target_size = (rộng, cao).
|
||||
Kích thước hộp mới tính bằng số nguyên nên không bị lệch 1px do số thực. Ngoài hộp luôn là 0."""
|
||||
"""Adjust the box (bbox) of one mask [H, W] to the aspect ratio of target_size = (width, height).
|
||||
The new box size is computed in integers, so there is no 1px drift from floats. Outside the box is always 0."""
|
||||
height, width = mask_np.shape
|
||||
xmin, ymin, xmax, ymax = (int(v) for v in bbox)
|
||||
cur_w, cur_h = xmax - xmin + 1, ymax - ymin + 1
|
||||
@@ -21,31 +21,31 @@ def _adjust_mask(mask_np, bbox, target_size, mode):
|
||||
output = np.zeros_like(mask_np)
|
||||
|
||||
if mode in ("stretch", "pad"):
|
||||
# Nới một chiều: target rộng hơn thì nới ngang, ngược lại nới dọc
|
||||
# Grow one dimension: horizontally when the target is wider, vertically otherwise
|
||||
new_w, new_h = cur_w, cur_h
|
||||
if target_is_wider:
|
||||
new_w = max(1, cur_h * target_w // target_h)
|
||||
else:
|
||||
new_h = max(1, cur_w * target_h // target_w)
|
||||
|
||||
# Hộp mới nới đối xứng quanh hộp cũ. Chạm mép ảnh thì trượt vào trong để vẫn đủ kích thước.
|
||||
# The new box grows symmetrically around the old one. At an image edge it slides inside to keep its full size.
|
||||
x0, x1 = _fit_window(xmin - (new_w - cur_w) // 2, new_w, width)
|
||||
y0, y1 = _fit_window(ymin - (new_h - cur_h) // 2, new_h, height)
|
||||
|
||||
if mode == "stretch":
|
||||
# Kéo dãn nội dung (nearest) cho vừa hộp mới. Hộp lớn hơn cả ảnh thì chỉ hiện phần giữa.
|
||||
# Stretch the content (nearest) to fit the new box. A box larger than the image shows only its middle part.
|
||||
off_x = (new_w - (x1 - x0)) // 2
|
||||
off_y = (new_h - (y1 - y0)) // 2
|
||||
cols = (np.arange(x1 - x0) + off_x) * cur_w // new_w
|
||||
rows = (np.arange(y1 - y0) + off_y) * cur_h // new_h
|
||||
output[y0:y1, x0:x1] = mask_np[ymin:ymax + 1, xmin:xmax + 1][np.ix_(rows, cols)]
|
||||
else:
|
||||
# Pad: phần đệm thuộc vùng mask (1.0), nội dung gốc giữ nguyên vị trí cũ
|
||||
# Pad: the padding belongs to the mask (1.0), the original content keeps its position
|
||||
output[y0:y1, x0:x1] = 1.0
|
||||
output[ymin:ymax + 1, xmin:xmax + 1] = mask_np[ymin:ymax + 1, xmin:xmax + 1]
|
||||
|
||||
elif mode == "crop":
|
||||
# Cắt bớt một chiều quanh tâm; phần giữ lại không bị dịch chỗ
|
||||
# Trim one dimension around the centre; the part that is kept does not move
|
||||
new_w, new_h = cur_w, cur_h
|
||||
if target_is_wider:
|
||||
new_h = max(1, cur_w * target_h // target_w)
|
||||
@@ -66,9 +66,8 @@ class NH_MaskAspectRatioMatch:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"target_ratio_mask": ("MASK",), # Mask A (Cái áo)
|
||||
"mask_to_adjust": ("MASK",), # Mask B (Hộp vuông)
|
||||
# THÊM LỰA CHỌN MỚI Ở ĐÂY
|
||||
"target_ratio_mask": ("MASK",), # Mask A (the garment)
|
||||
"mask_to_adjust": ("MASK",), # Mask B (the square box)
|
||||
"mode": (["stretch", "pad", "crop"],),
|
||||
}
|
||||
}
|
||||
@@ -86,7 +85,7 @@ class NH_MaskAspectRatioMatch:
|
||||
)
|
||||
|
||||
def match_aspect_ratio(self, target_ratio_mask, mask_to_adjust, mode):
|
||||
# Chấp nhận mask [H, W] hoặc [B, H, W]; xử lý từng mask trong batch
|
||||
# Accepts a mask [H, W] or [B, H, W]; every mask of the batch is processed
|
||||
targets = target_ratio_mask.reshape(-1, *target_ratio_mask.shape[-2:])
|
||||
masks = mask_to_adjust.reshape(-1, *mask_to_adjust.shape[-2:])
|
||||
if targets.shape[0] == 0 or masks.shape[0] == 0:
|
||||
@@ -97,12 +96,12 @@ class NH_MaskAspectRatioMatch:
|
||||
mask_np = masks[index].cpu().numpy()
|
||||
bbox_b = mask_bbox(mask_np)
|
||||
if bbox_b is None:
|
||||
adjusted.append(masks[index]) # Mask B rỗng: giữ nguyên
|
||||
adjusted.append(masks[index]) # Empty mask B: keep it as is
|
||||
continue
|
||||
|
||||
# Batch của mask A ngắn hơn thì lặp lại vòng tròn
|
||||
# A shorter mask A batch repeats cyclically
|
||||
bbox_a = mask_bbox(targets[index % targets.shape[0]].cpu().numpy())
|
||||
# Mask A rỗng thì coi như tỷ lệ 1:1
|
||||
# An empty mask A counts as a 1:1 ratio
|
||||
target_size = (1, 1) if bbox_a is None else (bbox_a[2] - bbox_a[0] + 1, bbox_a[3] - bbox_a[1] + 1)
|
||||
output_np = _adjust_mask(mask_np, bbox_b, target_size, mode)
|
||||
adjusted.append(torch.from_numpy(output_np).to(masks.device))
|
||||
@@ -110,12 +109,12 @@ class NH_MaskAspectRatioMatch:
|
||||
return (torch.stack(adjusted),)
|
||||
|
||||
|
||||
# Ánh xạ để ComfyUI nhận diện class
|
||||
# Mapping that lets ComfyUI find the class
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"NH_MaskAspectRatioMatch": NH_MaskAspectRatioMatch
|
||||
}
|
||||
|
||||
# Tên sẽ hiển thị trong menu của ComfyUI
|
||||
# Name shown in the ComfyUI menu
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"NH_MaskAspectRatioMatch": "NH Mask Aspect Ratio Match"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from ...core.mask_ops import mask_bbox
|
||||
|
||||
# File này chứa logic cho node Mask Properties (phiên bản gốc).
|
||||
# This file holds the logic of the Mask Properties node (original version).
|
||||
|
||||
class NH_MaskProperties:
|
||||
@classmethod
|
||||
@@ -17,28 +17,28 @@ class NH_MaskProperties:
|
||||
CATEGORY = "NH-Nodes/Mask"
|
||||
|
||||
def get_properties(self, mask):
|
||||
# Chỉ xử lý mask đầu tiên trong batch; hộp bao quanh các pixel > 0.5
|
||||
# Only the first mask of the batch is processed; the box encloses the pixels > 0.5
|
||||
bbox = mask_bbox(mask[0].cpu().numpy())
|
||||
|
||||
# Xử lý trường hợp mask rỗng (toàn màu đen)
|
||||
# Handle an empty mask (all black)
|
||||
if bbox is None:
|
||||
return (0, 0, 0, 0, 0, 0, (0, 0, 0, 0))
|
||||
|
||||
x1, y1, x2, y2 = bbox
|
||||
|
||||
# Tính toán chiều rộng và chiều cao
|
||||
# Compute the width and height
|
||||
width = x2 - x1 + 1
|
||||
height = y2 - y1 + 1
|
||||
|
||||
# Trả về tất cả các giá trị theo thứ tự đã định nghĩa
|
||||
# Return all values in the declared order
|
||||
return (width, height, x1, y1, x2, y2, bbox)
|
||||
|
||||
# Ánh xạ để ComfyUI nhận diện class
|
||||
# Mapping that lets ComfyUI find the class
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"NH_MaskProperties": NH_MaskProperties
|
||||
}
|
||||
|
||||
# Tên sẽ hiển thị trong menu của ComfyUI
|
||||
# Name shown in the ComfyUI menu
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"NH_MaskProperties": "Mask Properties (NH)"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ class NH_MaskToBBox:
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MASK",)
|
||||
# THAY ĐỔI Ở ĐÂY: Thêm tên cho đầu ra
|
||||
RETURN_NAMES = ("box_mask",)
|
||||
FUNCTION = "create_bbox_mask"
|
||||
CATEGORY = "NH-Nodes/Mask"
|
||||
@@ -39,12 +38,11 @@ class NH_MaskToBBox:
|
||||
|
||||
return (final_output,)
|
||||
|
||||
# Ánh xạ class không đổi
|
||||
# The class mapping key stays unchanged
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"NH_MaskToBBox": NH_MaskToBBox
|
||||
}
|
||||
|
||||
# THAY ĐỔI Ở ĐÂY: Đổi tên hiển thị của node
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"NH_MaskToBBox": "NH Create Box Mask"
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ def _needs_warning(entry, extra_warnings):
|
||||
if entry is None:
|
||||
return False
|
||||
text = f"{entry.get('tier', '')} {entry.get('reliability', '')} {entry.get('warning', '')}".casefold()
|
||||
return any(marker in text for marker in ("community", "extrapolation", "rủi ro", "risk"))
|
||||
return any(marker in text for marker in ("community", "extrapolation", "risk"))
|
||||
|
||||
|
||||
def _build_info(model_spec, tier, ratio, width, height, entry=None, extra_warnings=None, warnings=None):
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
#
|
||||
# --- HƯỚNG DẪN TÙY CHỈNH ---
|
||||
# Để thay đổi tên của một thanh trượt, hãy chỉnh sửa 2 nơi:
|
||||
# 1. Trong `INPUT_TYPES`: Thay đổi giá trị của "label". Đây là tên sẽ hiển thị trên node.
|
||||
# 2. Trong `RETURN_NAMES`: Thay đổi tên của đầu ra (output) tương ứng.
|
||||
# --- CUSTOMISATION GUIDE ---
|
||||
# To rename a slider, edit 2 places:
|
||||
# 1. In `INPUT_TYPES`: change the value of "label". This is the name shown on the node.
|
||||
# 2. In `RETURN_NAMES`: change the name of the matching output.
|
||||
#
|
||||
# Ví dụ, để đổi "Float 1" thành "Strength":
|
||||
# For example, to rename "Float 1" to "Strength":
|
||||
# - "float_1": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01, "label": "Strength"}),
|
||||
# - RETURN_NAMES = ("Strength", "Float 2", ...)
|
||||
#
|
||||
# Sau khi chỉnh sửa, hãy lưu file và khởi động lại ComfyUI.
|
||||
# After editing, save the file and restart ComfyUI.
|
||||
#
|
||||
|
||||
class NH_MultiSliderFloat:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Kiểu dữ liệu tùy chỉnh cho đường ống đa năng
|
||||
# Custom data type for the universal pipe
|
||||
NH_UNIVERSAL_PIPE = ("NH_UNIVERSAL_PIPE",)
|
||||
|
||||
# Số ô input của Pack (input_0 .. input_9) cũng là độ dài của package
|
||||
# The number of Pack input slots (input_0 .. input_9) is also the length of the package
|
||||
_PIPE_SLOTS = 10
|
||||
|
||||
class NH_PackUniversal:
|
||||
@@ -9,7 +9,7 @@ class NH_PackUniversal:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {},
|
||||
# Cung cấp sẵn 10 đầu vào tùy chọn
|
||||
# Provides 10 optional inputs
|
||||
"optional": {
|
||||
"input_0": ("*",), "input_1": ("*",), "input_2": ("*",), "input_3": ("*",), "input_4": ("*",),
|
||||
"input_5": ("*",), "input_6": ("*",), "input_7": ("*",), "input_8": ("*",), "input_9": ("*",),
|
||||
@@ -22,8 +22,8 @@ class NH_PackUniversal:
|
||||
CATEGORY = "NH-Nodes/Utils/Pipe"
|
||||
|
||||
def pack(self, **kwargs):
|
||||
# Đóng gói theo số ô: package[i] là input_i, ô chưa nối là None.
|
||||
# (list(kwargs.values()) chỉ giữ ô đã nối nên index bị lệch khi bỏ trống input_0.)
|
||||
# Pack by slot number: package[i] is input_i, an unconnected slot is None.
|
||||
# (list(kwargs.values()) keeps only connected slots, so the indices shift when input_0 is left empty.)
|
||||
pipe = [kwargs.get(f"input_{i}") for i in range(_PIPE_SLOTS)]
|
||||
return (pipe,)
|
||||
|
||||
@@ -40,7 +40,7 @@ class NH_UnpackUniversal:
|
||||
},
|
||||
}
|
||||
|
||||
# Đầu ra là kiểu dữ liệu bất kỳ
|
||||
# The output can be any data type
|
||||
RETURN_TYPES = ("*",)
|
||||
RETURN_NAMES = ("item",)
|
||||
FUNCTION = "unpack"
|
||||
@@ -51,14 +51,14 @@ class NH_UnpackUniversal:
|
||||
if pipe_data is None:
|
||||
return (None,)
|
||||
|
||||
# index = số ô input của Pack; ngoài phạm vi (kể cả âm) thì trả None
|
||||
# index = the Pack input slot number; out of range (negative included) returns None
|
||||
if 0 <= index < len(pipe_data):
|
||||
return (pipe_data[index],)
|
||||
else:
|
||||
# Trả về None nếu index nằm ngoài phạm vi
|
||||
# Return None when the index is out of range
|
||||
return (None,)
|
||||
|
||||
# --- Đăng ký các node ---
|
||||
# --- Node registration ---
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"NH_PackUniversal": NH_PackUniversal,
|
||||
"NH_UnpackUniversal": NH_UnpackUniversal,
|
||||
|
||||
@@ -16,14 +16,14 @@ class NH_UniversalSliderBuilder:
|
||||
}
|
||||
}
|
||||
|
||||
# Node này sẽ tự động tạo ra các đầu ra
|
||||
# This node creates its outputs automatically
|
||||
RETURN_TYPES = ()
|
||||
RETURN_NAMES = ()
|
||||
FUNCTION = "build_sliders"
|
||||
CATEGORY = "NH-Nodes/Utils"
|
||||
OUTPUT_NODE = True # Đánh dấu đây là node có đầu ra động
|
||||
# Node này không có input/output thật nên không dùng được. DEPRECATED ẩn nó khỏi search,
|
||||
# còn workflow cũ đã lưu vẫn load được. Dùng Multi-Slider (FLOAT/INT) thay thế.
|
||||
OUTPUT_NODE = True # Marks this as a node with dynamic outputs
|
||||
# This node has no real inputs/outputs, so it cannot be used. DEPRECATED hides it from search,
|
||||
# while saved old workflows still load. Use Multi-Slider (FLOAT/INT) instead.
|
||||
DEPRECATED = True
|
||||
|
||||
def build_sliders(self, config, **kwargs):
|
||||
@@ -31,11 +31,11 @@ class NH_UniversalSliderBuilder:
|
||||
cls.RETURN_TYPES = ()
|
||||
cls.RETURN_NAMES = ()
|
||||
|
||||
# Phân tích config để xác định các widgets và outputs
|
||||
# Parse the config to determine the widgets and outputs
|
||||
lines = [line.strip() for line in config.split('\n') if line.strip() and not line.strip().startswith('#')]
|
||||
|
||||
output_values = []
|
||||
# Cần một dictionary riêng cho UI để tránh lỗi
|
||||
# The UI needs its own dictionary to avoid errors
|
||||
ui_widgets = {}
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
@@ -55,7 +55,7 @@ class NH_UniversalSliderBuilder:
|
||||
cls.RETURN_TYPES += (type_str,)
|
||||
cls.RETURN_NAMES += (name,)
|
||||
|
||||
# Lấy giá trị thực tế từ kwargs nếu có, nếu không dùng giá trị mặc định
|
||||
# Take the actual value from kwargs when present, otherwise use the default
|
||||
cast_fn = float if type_str == "FLOAT" else int
|
||||
output_values.append(kwargs.get(widget_name, cast_fn(default)))
|
||||
|
||||
@@ -75,7 +75,7 @@ class NH_BooleanSwitch:
|
||||
def get_value(self, boolean_switch):
|
||||
return (boolean_switch,)
|
||||
|
||||
# --- Đăng ký các node ---
|
||||
# --- Node registration ---
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"NH_UniversalSliderBuilder": NH_UniversalSliderBuilder,
|
||||
"NH_BooleanSwitch": NH_BooleanSwitch
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
# 2nd Edited by https://github.com/Hzzone/pytorch-openpose
|
||||
# 3rd Edited by ControlNet
|
||||
# 4th Edited by ControlNet (added face and correct hands)
|
||||
|
||||
import os
|
||||
os.environ["KMP_DUPLICATE_LIB_OK"]="TRUE"
|
||||
# NH-Nodes: the upstream file set KMP_DUPLICATE_LIB_OK here; a custom node must not change the environment of the
|
||||
# whole ComfyUI process, so that line is gone.
|
||||
|
||||
import torch
|
||||
import numpy as np
|
||||
|
||||
@@ -163,7 +163,7 @@ class NH_VTonUltimateProcessor:
|
||||
|
||||
def process(self, human_image, category, mask_feathering, cover_shoes, refine_hands, refine_hair, device,
|
||||
offset_top=0, offset_bottom=0, offset_left=0, offset_right=0, keep_model_loaded=False):
|
||||
# Import trước load_model: thiếu scikit-image thì báo lỗi ngay, không phải tải model xong mới lỗi.
|
||||
# Import before load_model: a missing scikit-image fails right away, not after the models have been loaded.
|
||||
from ...core.utils_mask import get_mask_location
|
||||
|
||||
if human_image.shape[0] == 0:
|
||||
@@ -190,7 +190,8 @@ class NH_VTonUltimateProcessor:
|
||||
refine_hair, device, offset_top, offset_bottom, offset_left, offset_right):
|
||||
# The models are only referenced from this frame and _process_image's, so release_vton_models() can free them.
|
||||
vton_model = self.load_model(device)
|
||||
# Mỗi ảnh trong batch chạy độc lập với cùng setting, rồi nối các output theo chiều batch.
|
||||
# Each image of the batch runs independently with the same settings; the outputs are then joined along the
|
||||
# batch dimension.
|
||||
results = [
|
||||
self._process_image(
|
||||
vton_model, get_mask_location, human_image[index], index, category, mask_feathering, cover_shoes,
|
||||
@@ -202,13 +203,15 @@ class NH_VTonUltimateProcessor:
|
||||
|
||||
def _process_image(self, vton_model, get_mask_location, human_image, index, category, mask_feathering, cover_shoes,
|
||||
refine_hands, refine_hair, offset_top, offset_bottom, offset_left, offset_right):
|
||||
"""Xử lý đúng một ảnh [H,W,C]; `index` là vị trí trong batch (chỉ dùng cho thông báo lỗi)."""
|
||||
# DWPose và parsing cần RGB 3 kênh: ảnh xám làm hỏng bước đảo BGR, RGBA đưa 4 kênh vào DWPose (alpha bị bỏ).
|
||||
"""Process exactly one image [H,W,C]; `index` is its position in the batch (used only for error messages)."""
|
||||
# DWPose and parsing need 3-channel RGB: a grayscale image breaks the BGR flip, and RGBA would feed 4 channels
|
||||
# to DWPose (the alpha is dropped).
|
||||
human_img_pil = tensor_to_pil(human_image).convert("RGB")
|
||||
human_img_resized = resize_image(human_img_pil)
|
||||
|
||||
pose_img_np, _, _, candidate = vton_model['dwprocessor'](np.array(human_img_resized)[:,:,::-1])
|
||||
# DWPose không trả mảng rỗng khi không thấy người: nó vẫn chạy pose trên cả ảnh và đánh dấu mọi keypoint là -1.
|
||||
# DWPose does not return an empty array when it sees no person: it still runs pose on the whole image and marks
|
||||
# every keypoint as -1.
|
||||
if len(candidate) == 0 or not np.any(candidate[0] != -1):
|
||||
raise ValueError(
|
||||
f"VTON Ultimate Processor: DWPose found no person in the image at batch index {index}. "
|
||||
@@ -237,7 +240,7 @@ class NH_VTonUltimateProcessor:
|
||||
if mask_np.ndim == 3:
|
||||
mask_np = mask_np[:,:,0]
|
||||
|
||||
# --- CÁC LOGIC TINH CHỈNH ---
|
||||
# --- REFINEMENT LOGIC ---
|
||||
|
||||
if category in ["Lower-body", "Lower-body (Shorts/Skirt)"]:
|
||||
body_cols = np.any((parsing_map_full_np > 0), axis=0)
|
||||
@@ -289,7 +292,7 @@ class NH_VTonUltimateProcessor:
|
||||
feathered_pil = feathered_pil.filter(ImageFilter.GaussianBlur(radius=mask_feathering))
|
||||
mask_np = np.array(feathered_pil).astype(np.float32) / 255.0
|
||||
|
||||
# --- Tạo các output cuối cùng ---
|
||||
# --- Build the final outputs ---
|
||||
person_silhouette_np = (parsing_map_full_np > 0).astype(np.float32)
|
||||
agnostic_mask_np = np.clip(person_silhouette_np - mask_np, 0, 1)
|
||||
agnostic_mask_pil = Image.fromarray((agnostic_mask_np * 255).astype(np.uint8))
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "nh-nodes"
|
||||
description = "ComfyUI custom nodes for image workflows, tiling, masks, smart resize, logic routing, prompt and text tools, batch automation, SAM 3 object segmentation from mask formulas (Vietnamese words too), instance selection, close-up garment/portrait parsing, body-part masks by name (Sapiens2), and VTON preprocessing."
|
||||
version = "1.9.0"
|
||||
version = "1.10.0"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = [
|
||||
"numpy",
|
||||
|
||||
@@ -19,3 +19,18 @@ if not torch.cuda.is_available():
|
||||
from comfy.cli_args import args # noqa: E402
|
||||
|
||||
args.cpu = True
|
||||
|
||||
import importlib # noqa: E402
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _pytest_folders_are_declared(tmp_path_factory, monkeypatch):
|
||||
"""Every test runs as if pytest's temp folder were listed under nh_image_folders.
|
||||
|
||||
The image nodes only read and write inside ComfyUI's folders and the declared ones. A test of that limit
|
||||
replaces ``_declared`` itself.
|
||||
"""
|
||||
allowed_folders = importlib.import_module("NH-Nodes.core.allowed_folders")
|
||||
monkeypatch.setattr(allowed_folders, "_declared", [str(tmp_path_factory.getbasetemp())])
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
"""The image nodes and the folder-count route only read and write inside ComfyUI's folders and the declared ones.
|
||||
|
||||
core/allowed_folders.py decides; Save Image Path, Load Images Folder, Load images matching and
|
||||
GET /nh-nodes/load-images-folder/count ask it before they touch the disk.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
from PIL import Image
|
||||
|
||||
import folder_paths
|
||||
|
||||
allowed_folders = importlib.import_module("NH-Nodes.core.allowed_folders")
|
||||
image_folders = importlib.import_module("NH-Nodes.core.image_folders")
|
||||
folder_routes = importlib.import_module("NH-Nodes.core.folder_routes")
|
||||
folder_loaders = importlib.import_module("NH-Nodes.nodes.image.folder_loaders")
|
||||
save_image_path = importlib.import_module("NH-Nodes.nodes.image.save_image_path")
|
||||
|
||||
FolderNotAllowed = allowed_folders.FolderNotAllowed
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def machine(tmp_path, monkeypatch):
|
||||
"""A ComfyUI with input/output/temp folders, one declared folder (photos) and one that is not (private)."""
|
||||
for name in ("input", "output", "temp", "photos", "private"):
|
||||
(tmp_path / name).mkdir()
|
||||
monkeypatch.setattr(folder_paths, "get_input_directory", lambda: str(tmp_path / "input"))
|
||||
monkeypatch.setattr(folder_paths, "get_output_directory", lambda: str(tmp_path / "output"))
|
||||
monkeypatch.setattr(folder_paths, "get_temp_directory", lambda: str(tmp_path / "temp"))
|
||||
monkeypatch.setattr(allowed_folders, "_declared", [str(tmp_path / "photos")])
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _png(path):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
Image.new("RGB", (4, 4), (10, 20, 30)).save(path)
|
||||
return path
|
||||
|
||||
|
||||
def _link_or_skip(link, target):
|
||||
"""A directory link ``link`` -> ``target``: a symlink, or a junction where Windows refuses symlinks."""
|
||||
try:
|
||||
os.symlink(target, link, target_is_directory=True)
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
if os.name != "nt":
|
||||
pytest.skip("cannot create a directory link here")
|
||||
import subprocess
|
||||
|
||||
done = subprocess.run(["cmd", "/c", "mklink", "/J", str(link), str(target)], capture_output=True)
|
||||
if done.returncode != 0:
|
||||
pytest.skip("cannot create a directory link here")
|
||||
|
||||
|
||||
# --- the rule ----------------------------------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("name", ["input", "output", "temp", "photos"])
|
||||
def test_comfyui_folders_and_declared_folders_are_readable(machine, name):
|
||||
assert allowed_folders.can_read(str(machine / name))
|
||||
assert allowed_folders.can_read(str(machine / name / "sub" / "deeper")) # need not exist
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name, writable", [("output", True), ("photos", True), ("input", False), ("temp", False)])
|
||||
def test_only_output_and_declared_folders_are_writable(machine, name, writable):
|
||||
target = str(machine / name / "sub")
|
||||
if writable:
|
||||
assert allowed_folders.require_writable(target) == os.path.abspath(target)
|
||||
else:
|
||||
with pytest.raises(FolderNotAllowed):
|
||||
allowed_folders.require_writable(target)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("relative", ["private", "output/../private", "photos/../../x", "outputs", "photos2"])
|
||||
def test_other_folders_are_refused(machine, relative):
|
||||
path = os.path.join(str(machine), *relative.split("/"))
|
||||
assert not allowed_folders.can_read(path)
|
||||
with pytest.raises(FolderNotAllowed):
|
||||
allowed_folders.require_readable(path)
|
||||
with pytest.raises(FolderNotAllowed):
|
||||
allowed_folders.require_writable(path)
|
||||
|
||||
|
||||
def test_a_refusal_is_a_permission_error_that_says_how_to_allow_the_folder(machine):
|
||||
with pytest.raises(PermissionError) as error:
|
||||
allowed_folders.require_writable(str(machine / "private"), "folder_path")
|
||||
message = str(error.value)
|
||||
assert "folder_path" in message and "nh_image_folders" in message and "extra_model_paths.yaml" in message
|
||||
assert "restart ComfyUI" in message
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows paths")
|
||||
def test_windows_spellings_of_an_allowed_folder_are_the_same_folder(machine):
|
||||
assert allowed_folders.can_read(str(machine / "PHOTOS" / "a").upper())
|
||||
assert allowed_folders.can_read(str(machine / "photos").replace("\\", "/") + "/a")
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows paths")
|
||||
@pytest.mark.parametrize("path", ["\\\\server\\share\\x", "//server/share/x", "\\\\?\\UNC\\server\\share\\x"])
|
||||
def test_a_network_path_is_refused_without_resolving_it(machine, monkeypatch, path):
|
||||
# Resolving a \\server\share path makes Windows contact that server, so a refused path is never resolved.
|
||||
# (The allowed folders themselves are resolved: they are the owner's.)
|
||||
real_realpath = os.path.realpath
|
||||
|
||||
def no_network_resolving(value, *args, **kwargs):
|
||||
if "server" in str(value).lower():
|
||||
raise AssertionError(f"resolved {value!r}")
|
||||
return real_realpath(value, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(os.path, "realpath", no_network_resolving)
|
||||
assert not allowed_folders.can_read(path)
|
||||
|
||||
|
||||
def test_another_drive_is_refused(machine):
|
||||
other = "Q:\\x" if os.name == "nt" else "/nh-nodes-test-other-root/x"
|
||||
assert not allowed_folders.can_read(other)
|
||||
|
||||
|
||||
def test_a_link_inside_an_allowed_folder_cannot_lead_out_of_it(machine):
|
||||
_link_or_skip(machine / "photos" / "way_out", machine / "private")
|
||||
assert not allowed_folders.can_read(str(machine / "photos" / "way_out"))
|
||||
assert not allowed_folders.can_read(str(machine / "photos" / "way_out" / "deeper"))
|
||||
with pytest.raises(FolderNotAllowed):
|
||||
allowed_folders.require_writable(str(machine / "photos" / "way_out"))
|
||||
|
||||
|
||||
def test_an_allowed_folder_reached_through_a_link_still_works(machine, monkeypatch):
|
||||
# ComfyUI's own folders are often links (a models or output folder on another disk).
|
||||
_link_or_skip(machine / "linked_output", machine / "output")
|
||||
monkeypatch.setattr(folder_paths, "get_output_directory", lambda: str(machine / "linked_output"))
|
||||
assert allowed_folders.require_writable(str(machine / "linked_output" / "day1"))
|
||||
|
||||
|
||||
def test_nothing_is_declared_by_default(machine, monkeypatch):
|
||||
monkeypatch.setattr(allowed_folders, "_declared", [])
|
||||
assert allowed_folders.declared_folders() == []
|
||||
assert not allowed_folders.can_read(str(machine / "photos"))
|
||||
assert allowed_folders.can_read(str(machine / "input"))
|
||||
|
||||
|
||||
def test_folders_of_extra_model_paths_are_taken_and_not_left_as_a_model_folder(machine, monkeypatch):
|
||||
# ComfyUI registers every key of extra_model_paths.yaml as a model folder and lists such folders at /models.
|
||||
registry = dict(folder_paths.folder_names_and_paths)
|
||||
registry[allowed_folders.FOLDER_KEY] = ([str(machine / "private"), str(machine / "photos")], set())
|
||||
monkeypatch.setattr(folder_paths, "folder_names_and_paths", registry)
|
||||
monkeypatch.setattr(allowed_folders, "_declared", [])
|
||||
|
||||
assert allowed_folders.declared_folders() == [str(machine / "private"), str(machine / "photos")]
|
||||
assert allowed_folders.FOLDER_KEY not in folder_paths.folder_names_and_paths
|
||||
assert allowed_folders.can_read(str(machine / "private"))
|
||||
assert allowed_folders.declared_folders() == [str(machine / "private"), str(machine / "photos")] # kept
|
||||
|
||||
|
||||
# --- Save Image Path ---------------------------------------------------------------------------------------------
|
||||
|
||||
def _save(folder_path, **kwargs):
|
||||
settings = dict(filename_prefix="img", create_new_folder=False, new_folder_name="")
|
||||
settings.update(kwargs)
|
||||
return save_image_path.NH_SaveImagePath().save_images(torch.rand((1, 4, 4, 3)), folder_path=folder_path, **settings)
|
||||
|
||||
|
||||
def test_save_refuses_a_folder_that_is_not_allowed_and_creates_nothing(machine):
|
||||
target = machine / "private" / "new" / "deeper"
|
||||
with pytest.raises(FolderNotAllowed, match="folder_path"):
|
||||
_save(str(target))
|
||||
assert not (machine / "private" / "new").exists()
|
||||
assert list(machine.rglob("*.png")) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("folder_path", [
|
||||
"../private",
|
||||
"a/../../private",
|
||||
# A backslash separates folders on Windows only; elsewhere this is one odd folder name inside output.
|
||||
pytest.param("..\\private", marks=pytest.mark.skipif(os.name != "nt", reason="Windows separator")),
|
||||
])
|
||||
def test_save_refuses_a_relative_path_that_leaves_the_output_folder(machine, folder_path):
|
||||
with pytest.raises(FolderNotAllowed):
|
||||
_save(folder_path)
|
||||
assert list(machine.rglob("*.png")) == []
|
||||
|
||||
|
||||
def test_save_refuses_the_input_folder(machine):
|
||||
with pytest.raises(FolderNotAllowed):
|
||||
_save(str(machine / "input"))
|
||||
|
||||
|
||||
def test_save_works_in_the_output_folder_and_in_a_declared_folder(machine):
|
||||
assert _save("")[1] == str(machine / "output")
|
||||
assert _save("day1")[1] == str(machine / "output" / "day1")
|
||||
assert _save(str(machine / "photos" / "exports"))[1] == str(machine / "photos" / "exports")
|
||||
saved = sorted(str(path.relative_to(machine)).replace("\\", "/") for path in machine.rglob("*.png"))
|
||||
assert saved == ["output/day1/img0001.png", "output/img0001.png", "photos/exports/img0001.png"]
|
||||
|
||||
|
||||
def test_save_next_to_an_existing_file_of_an_allowed_folder(machine):
|
||||
source = _png(machine / "photos" / "shoot" / "a.png")
|
||||
assert _save(str(source))[1] == str(machine / "photos" / "shoot")
|
||||
|
||||
|
||||
def test_save_never_looks_at_a_file_outside_the_allowed_folders(machine, monkeypatch):
|
||||
secret = _png(machine / "private" / "a.png")
|
||||
looked = []
|
||||
real_isfile = os.path.isfile
|
||||
monkeypatch.setattr(os.path, "isfile", lambda path: looked.append(str(path)) or real_isfile(path))
|
||||
with pytest.raises(FolderNotAllowed):
|
||||
_save(str(secret))
|
||||
assert looked == []
|
||||
|
||||
|
||||
# --- Load Images Folder ------------------------------------------------------------------------------------------
|
||||
|
||||
def _load_folder(folder_path, **kwargs):
|
||||
settings = dict(index=0, step=1, seed_mode="fixed")
|
||||
settings.update(kwargs)
|
||||
return folder_loaders.NH_LoadImagesFromFolder().load_images(folder_path, **settings)
|
||||
|
||||
|
||||
def test_load_folder_refuses_a_folder_that_is_not_allowed(machine, monkeypatch):
|
||||
_png(machine / "private" / "a.png")
|
||||
listed = []
|
||||
real_listdir = os.listdir
|
||||
monkeypatch.setattr(os, "listdir", lambda path: listed.append(str(path)) or real_listdir(path))
|
||||
with pytest.raises(FolderNotAllowed, match="folder_path"):
|
||||
_load_folder(str(machine / "private"))
|
||||
with pytest.raises(FolderNotAllowed):
|
||||
_load_folder("../private")
|
||||
assert listed == []
|
||||
|
||||
|
||||
def test_load_folder_change_check_does_not_raise_for_a_refused_folder(machine):
|
||||
_png(machine / "private" / "a.png")
|
||||
result = folder_loaders.NH_LoadImagesFromFolder.IS_CHANGED(str(machine / "private"), 0, 1, "fixed")
|
||||
assert isinstance(result, str) and result.startswith("error:FolderNotAllowed")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["input", "output", "temp", "photos"])
|
||||
def test_load_folder_reads_comfyui_folders_and_declared_folders(machine, name):
|
||||
_png(machine / name / "set" / "a.png")
|
||||
images, filename, path = _load_folder(str(machine / name / "set"))
|
||||
assert images.shape == (1, 4, 4, 3) and filename == "a.png"
|
||||
assert path == str(machine / name / "set" / "a.png")
|
||||
|
||||
|
||||
def test_load_folder_relative_path_stays_below_the_input_folder(machine):
|
||||
_png(machine / "input" / "set" / "a.png")
|
||||
assert _load_folder("set")[1] == "a.png"
|
||||
|
||||
|
||||
# --- Load images matching ----------------------------------------------------------------------------------------
|
||||
|
||||
def _matching_args(**overrides):
|
||||
settings = dict(
|
||||
source_folder_path="", matching_root_path="", match_text="", match_mode="contains", recursive=True,
|
||||
index=0, step=1, repeat_count=1, context_folder_names="Nam\nNu", source_file_names="",
|
||||
)
|
||||
settings.update(overrides)
|
||||
return settings
|
||||
|
||||
|
||||
def test_matching_refuses_a_root_that_is_not_allowed(machine):
|
||||
_png(machine / "private" / "Anh" / "a.png")
|
||||
node = folder_loaders.NH_LoadImagesMatching()
|
||||
args = _matching_args(matching_root_path=str(machine / "private"), match_text="Anh")
|
||||
with pytest.raises(FolderNotAllowed, match="matching_root_path"):
|
||||
node.load_matching(**args)
|
||||
assert folder_loaders.NH_LoadImagesMatching.IS_CHANGED(**args) != folder_loaders.NH_LoadImagesMatching.IS_CHANGED(**args) # NaN
|
||||
assert node.check_lazy_status(fallback_images=None, **args) == []
|
||||
|
||||
|
||||
def test_matching_reads_a_declared_root_and_the_source_is_names_only(machine, monkeypatch):
|
||||
_png(machine / "photos" / "library" / "Anh" / "a.png")
|
||||
probed = []
|
||||
real_isfile = os.path.isfile
|
||||
monkeypatch.setattr(os.path, "isfile", lambda path: probed.append(str(path)) or real_isfile(path))
|
||||
source = machine / "private" / "shoot" / "Anh" # not allowed, and never touched: only its names count
|
||||
_, filename, path, matched_text, found = folder_loaders.NH_LoadImagesMatching().load_matching(
|
||||
**_matching_args(source_folder_path=str(source), matching_root_path=str(machine / "photos" / "library"))
|
||||
)
|
||||
assert found and filename == "a.png" and matched_text == "Anh"
|
||||
assert path == str(machine / "photos" / "library" / "Anh")
|
||||
assert [item for item in probed if "private" in item] == []
|
||||
|
||||
|
||||
def test_matching_takes_the_folder_of_a_source_image_path_from_the_text(machine):
|
||||
_png(machine / "photos" / "library" / "Anh" / "a.png")
|
||||
source_file = machine / "private" / "shoot" / "Anh" / "portrait.JPG" # does not exist
|
||||
_, _, _, matched_text, found = folder_loaders.NH_LoadImagesMatching().load_matching(
|
||||
**_matching_args(source_folder_path=str(source_file), matching_root_path=str(machine / "photos" / "library"))
|
||||
)
|
||||
assert found and matched_text == "Anh"
|
||||
|
||||
|
||||
def test_matching_does_not_list_a_parent_that_is_not_allowed_for_twins(machine, monkeypatch):
|
||||
# The root sits in a context folder (Nam) whose parent is not allowed: the sibling Nu is not looked for.
|
||||
monkeypatch.setattr(allowed_folders, "_declared", [str(machine / "private" / "Nam")])
|
||||
_png(machine / "private" / "Nam" / "Anh" / "a.png")
|
||||
_png(machine / "private" / "Nu" / "Anh" / "b.png")
|
||||
scanned = []
|
||||
real_scandir = os.scandir
|
||||
monkeypatch.setattr(os, "scandir", lambda path: scanned.append(os.path.normcase(str(path))) or real_scandir(path))
|
||||
_, filename, _, _, found = folder_loaders.NH_LoadImagesMatching().load_matching(
|
||||
**_matching_args(source_folder_path="x/Nu/Anh", matching_root_path=str(machine / "private" / "Nam"))
|
||||
)
|
||||
assert found and filename == "a.png"
|
||||
assert os.path.normcase(str(machine / "private")) not in scanned
|
||||
assert not any(os.path.normcase(str(machine / "private" / "Nu")) in item for item in scanned)
|
||||
|
||||
|
||||
# --- the count route ---------------------------------------------------------------------------------------------
|
||||
|
||||
def _count(**params):
|
||||
async def run():
|
||||
routes = web.RouteTableDef()
|
||||
folder_routes.register_routes(routes)
|
||||
app = web.Application()
|
||||
app.add_routes(routes)
|
||||
async with TestClient(TestServer(app)) as client:
|
||||
response = await client.get(folder_routes.COUNT_PATH, params=params)
|
||||
return response.status, await response.read()
|
||||
|
||||
status, body = asyncio.run(run())
|
||||
return status, json.loads(body), body.decode("utf-8")
|
||||
|
||||
|
||||
def test_count_route_refuses_a_folder_that_is_not_allowed(machine):
|
||||
_png(machine / "private" / "a.png")
|
||||
for folder_path in (str(machine / "private"), "../private", str(machine / "private" / "missing")):
|
||||
status, body, raw = _count(folder_path=folder_path)
|
||||
assert (status, body) == (403, {"error": "folder is not allowed"})
|
||||
assert "private" not in raw and str(machine) not in raw
|
||||
|
||||
|
||||
def test_count_route_counts_in_allowed_folders(machine):
|
||||
_png(machine / "photos" / "a.png")
|
||||
_png(machine / "input" / "b.png")
|
||||
assert _count(folder_path=str(machine / "photos"))[:2] == (200, {"count": 1})
|
||||
assert _count(folder_path="")[:2] == (200, {"count": 1})
|
||||
|
||||
|
||||
# --- nothing outside the pack is touched at import ----------------------------------------------------------------
|
||||
|
||||
PACK_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def test_image_label_reads_the_font_folder_only_when_the_node_list_is_built(monkeypatch):
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
def no_listing(self):
|
||||
raise AssertionError(f"listed {self} at import")
|
||||
|
||||
# A second copy of the module, so the registered node class is left alone.
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"NH-Nodes.nodes.image.image_label_import_probe", os.path.join(PACK_DIR, "nodes", "image", "image_label.py")
|
||||
)
|
||||
probe = importlib.util.module_from_spec(spec)
|
||||
with monkeypatch.context() as patch:
|
||||
patch.setattr(Path, "iterdir", no_listing)
|
||||
spec.loader.exec_module(probe) # importing the module lists no folder
|
||||
fonts = probe.NH_ImageLabel.INPUT_TYPES()["required"]["font_name"][0]
|
||||
assert fonts[0] == "default" and len(fonts) == len(set(fonts))
|
||||
|
||||
|
||||
def test_the_pack_does_not_change_the_environment_of_the_process():
|
||||
import re
|
||||
|
||||
changes = re.compile(r"os\.environ\[[^\]]*\]\s*=(?!=)|os\.environ\.(setdefault|update|pop)\(|os\.putenv\(")
|
||||
offenders = []
|
||||
for folder in ("core", "nodes"):
|
||||
for root, _dirs, files in os.walk(os.path.join(PACK_DIR, folder)):
|
||||
for name in files:
|
||||
if name.endswith(".py"):
|
||||
with open(os.path.join(root, name), encoding="utf-8") as handle:
|
||||
if changes.search(handle.read()):
|
||||
offenders.append(os.path.relpath(os.path.join(root, name), PACK_DIR))
|
||||
assert offenders == []
|
||||
|
||||
|
||||
def test_resolution_notes_shown_in_the_ui_are_english():
|
||||
resolution_data = importlib.import_module("NH-Nodes.core.resolution_data")
|
||||
notes = [spec.get("note", "") for spec in resolution_data.MODEL_SPECS.values()]
|
||||
assert any(notes) and all(note.isascii() for note in notes)
|
||||
|
||||
|
||||
# --- links below an allowed folder ---------------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def way_out(machine):
|
||||
"""photos/way_out is a link to private/, which holds a picture in a folder named like a match."""
|
||||
_png(machine / "private" / "secret.png")
|
||||
_png(machine / "private" / "Anh" / "secret.png")
|
||||
_png(machine / "photos" / "own.png")
|
||||
_link_or_skip(machine / "photos" / "way_out", machine / "private")
|
||||
return machine
|
||||
|
||||
|
||||
def test_reading_sub_folders_skips_a_link_that_leads_out(way_out):
|
||||
files = image_folders.collect_image_files(str(way_out / "photos"), recursive=True)
|
||||
assert files == ["own.png"]
|
||||
assert _count(folder_path=str(way_out / "photos"), recursive="true")[:2] == (200, {"count": 1})
|
||||
|
||||
|
||||
def test_matching_does_not_search_through_a_link_that_leads_out(way_out):
|
||||
node = folder_loaders.NH_LoadImagesMatching()
|
||||
for match_text in ("way", "Anh"):
|
||||
result = node.load_matching(**_matching_args(matching_root_path=str(way_out / "photos"), match_text=match_text))
|
||||
assert result[4] is False # found
|
||||
scanned = []
|
||||
real_scandir = os.scandir
|
||||
with pytest.MonkeyPatch.context() as patch:
|
||||
patch.setattr(os, "scandir", lambda path: scanned.append(os.path.normcase(str(path))) or real_scandir(path))
|
||||
assert folder_loaders._tree_signature([str(way_out / "photos")]) is not None
|
||||
assert scanned == [os.path.normcase(str(way_out / "photos"))] # the wired-input change check stays out too
|
||||
|
||||
|
||||
def test_a_twin_context_folder_that_is_a_link_out_is_skipped_not_an_error(machine):
|
||||
_png(machine / "photos" / "Nam" / "Anh" / "a.png")
|
||||
_png(machine / "private" / "Anh" / "b.png")
|
||||
_link_or_skip(machine / "photos" / "Nu", machine / "private")
|
||||
_, filename, _, _, found = folder_loaders.NH_LoadImagesMatching().load_matching(
|
||||
**_matching_args(source_folder_path="x/Nu/Anh", matching_root_path=str(machine / "photos" / "Nam"))
|
||||
)
|
||||
assert found and filename == "a.png"
|
||||
|
||||
|
||||
def test_an_image_file_that_is_a_link_out_is_not_opened(machine):
|
||||
secret = _png(machine / "private" / "secret.png")
|
||||
try:
|
||||
os.symlink(secret, machine / "photos" / "linked.png")
|
||||
except OSError:
|
||||
pytest.skip("cannot create a file link here")
|
||||
with pytest.raises(FolderNotAllowed):
|
||||
_load_folder(str(machine / "photos"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("settings", [
|
||||
dict(create_new_folder=True, new_folder_name="way_out"),
|
||||
dict(filename_prefix="way_out/c"),
|
||||
dict(filename_prefix="way_out\\deeper\\c"),
|
||||
])
|
||||
def test_save_does_not_write_through_a_link_in_the_new_folder_or_the_prefix(way_out, settings):
|
||||
before = sorted(way_out.joinpath("private").rglob("*"))
|
||||
with pytest.raises(FolderNotAllowed):
|
||||
_save(str(way_out / "photos"), **settings)
|
||||
assert sorted(way_out.joinpath("private").rglob("*")) == before
|
||||
|
||||
|
||||
# --- more spellings ------------------------------------------------------------------------------------------------
|
||||
|
||||
def test_a_declared_folder_may_end_in_a_slash_or_differ_in_case(machine, monkeypatch):
|
||||
spelled = str(machine / "photos") + "/"
|
||||
if os.name == "nt":
|
||||
spelled = spelled.upper()
|
||||
monkeypatch.setattr(allowed_folders, "_declared", [spelled])
|
||||
assert allowed_folders.can_read(str(machine / "photos" / "set"))
|
||||
assert _save(str(machine / "photos" / "exports"))[2] == 1
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows paths")
|
||||
@pytest.mark.parametrize("path", ["Q:foo", "\\\\?\\C:\\Windows", "\\\\.\\C:\\Windows", "\\\\?\\GLOBALROOT\\Device\\x"])
|
||||
def test_windows_device_and_drive_relative_paths_are_refused(machine, path):
|
||||
assert not allowed_folders.can_read(path)
|
||||
with pytest.raises(FolderNotAllowed):
|
||||
_save(path)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows paths")
|
||||
def test_a_drive_relative_path_lands_in_the_output_folder_or_is_refused(machine):
|
||||
# "C:foo" is no absolute path: on the drive of the output folder it is the sub-folder foo of it, on another
|
||||
# drive it is refused. Nothing is written anywhere else either way.
|
||||
drive = os.path.splitdrive(str(machine))[0]
|
||||
assert _save(f"{drive}foo")[1] == str(machine / "output" / "foo")
|
||||
assert [path.relative_to(machine).parts[0] for path in machine.rglob("*.png")] == ["output"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows paths")
|
||||
def test_a_device_prefix_cannot_hide_dots_that_leave_an_allowed_folder(machine):
|
||||
path = "\\\\?\\" + str(machine / "photos") + "\\..\\private"
|
||||
assert not allowed_folders.can_read(path)
|
||||
|
||||
|
||||
def test_matching_change_check_with_wired_inputs_does_not_raise_for_a_refused_root(machine):
|
||||
args = _matching_args(matching_root_path=str(machine / "private"), match_text=None) # match_text is wired
|
||||
result = folder_loaders.NH_LoadImagesMatching.IS_CHANGED(**args)
|
||||
assert result != result # NaN: unknown, run again
|
||||
|
||||
|
||||
# --- Image Label fonts ---------------------------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("font_name", ["..\\..\\secret.ttf", "C:\\secret.ttf", "\\\\server\\share\\x.ttf", "missing.ttf"])
|
||||
def test_image_label_only_opens_fonts_of_its_list(monkeypatch, font_name):
|
||||
image_label = importlib.import_module("NH-Nodes.nodes.image.image_label")
|
||||
opened = []
|
||||
real = image_label._cached_truetype
|
||||
monkeypatch.setattr(image_label, "_cached_truetype", lambda path, size: opened.append(path) or real(path, size))
|
||||
assert image_label._load_font(font_name, 20) is not None
|
||||
assert opened == ["arial.ttf"]
|
||||
@@ -245,6 +245,8 @@ def test_search_stays_inside_the_matching_root_and_its_context_siblings(tmp_path
|
||||
|
||||
def test_a_drive_root_is_never_walked(studio, monkeypatch):
|
||||
drive = os.path.abspath(os.sep)
|
||||
# Even when the owner of the machine declared a whole drive as an image folder.
|
||||
monkeypatch.setattr(importlib.import_module("NH-Nodes.core.allowed_folders"), "_declared", [drive])
|
||||
fs = _Fs(monkeypatch)
|
||||
with pytest.raises(ValueError, match="drive"):
|
||||
_load(source_folder_path=str(studio.source), matching_root_path=drive, match_text="Anh")
|
||||
|
||||
@@ -283,7 +283,7 @@ def test_new_subfolder_inside_the_target_stays_allowed(tmp_path):
|
||||
assert (tmp_path / "out" / "batch" / "day1" / "img0001.png").is_file()
|
||||
|
||||
|
||||
def test_absolute_folder_path_is_still_allowed(tmp_path):
|
||||
def test_absolute_folder_path_in_a_declared_folder_is_allowed(tmp_path):
|
||||
target = tmp_path / "somewhere" / "else"
|
||||
_, path, _ = _save(tmp_path, "img", folder_path=str(target))
|
||||
assert path == str(target) and (target / "img0001.png").is_file()
|
||||
|
||||
Reference in New Issue
Block a user