Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f541a2fc8c | ||
|
|
be1ffd431c | ||
|
|
5b3eb0d2c0 | ||
|
|
ce7d20df86 | ||
|
|
a2bcc41bcf | ||
|
|
7b60ad5bea | ||
|
|
57e2476fb5 | ||
|
|
12519a4447 | ||
|
|
c1ad6b7a14 | ||
|
|
3f37f2da3f | ||
|
|
e3c68d48ef | ||
|
|
5e000c56ff | ||
|
|
0a56ae8372 | ||
|
|
67b0d14b2d | ||
|
|
1101bd91b6 |
@@ -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/
|
||||
@@ -11,11 +11,12 @@
|
||||
|
||||
# NH-Nodes for ComfyUI
|
||||
|
||||
**75 production-focused nodes** for ComfyUI: load and save image sets with their metadata, tile and composite large
|
||||
**76 production-focused nodes** for ComfyUI: load and save image sets with their metadata, tile and composite large
|
||||
images, pick the right resolution for current models and put results back into the original frame, refine masks,
|
||||
segment garments and faces (also in half-body and group photos), get the mask of anything you name with SAM 3 from a
|
||||
short formula such as `person - bag` or `background`, build VTON masks, route workflows with lazy switches, generate
|
||||
prompts, and test many LoRAs or models from one node.
|
||||
segment garments and faces (also in half-body and group photos), mask the skin, hair, lips, teeth and clothes of people
|
||||
by typing their names, get the mask of anything you name with SAM 3 from a short formula such as `person - bag` or
|
||||
`background`, build VTON masks, route workflows with lazy switches, generate prompts, and test many LoRAs or models from
|
||||
one node.
|
||||
|
||||
Every node is documented with a visual card, its inputs and outputs, and examples — in
|
||||
[English](docs/en/README.md) and [Vietnamese](docs/vi/README.md).
|
||||
@@ -35,8 +36,9 @@ Every node is documented with a visual card, its inputs and outputs, and example
|
||||
<td><a href="docs/en/vision.md"><img src="docs/assets/examples/garment-segment.jpg" alt="Garment segmentation"></a><br>
|
||||
<b>Segmentation</b> — name what you want (people, windows, the sky) or write a formula such as
|
||||
<code>person - bag</code> or <code>background</code>, and SAM 3 returns the mask, with crisp edges on small and
|
||||
medium objects even in 4K–8K photos, and shows it on the node; garment and face-part masks from SegFormer models
|
||||
that parse faces and people close up; VTON garment masks with pose and parsing.</td>
|
||||
medium objects even in 4K–8K photos, and shows it on the node; type <code>skin</code>, <code>hair, face</code> or
|
||||
<code>clothes - shoes</code> and Body Segment masks those parts of every person; garment and face-part masks from
|
||||
models that parse faces and people close up; VTON garment masks with pose and parsing.</td>
|
||||
<td><a href="docs/en/mask.md"><img src="docs/assets/examples/mask-morphology.jpg" alt="Mask morphology"></a><br>
|
||||
<b>Mask tools</b> — fast grow / shrink on the CPU or the GPU, hole filling, soft edges, bounding boxes and
|
||||
aspect-ratio matching.</td>
|
||||
@@ -69,25 +71,38 @@ cd NH-Nodes
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Restart ComfyUI afterwards. Requirements: a recent ComfyUI (tested with 0.41) and Python 3.10+. **SAM3 Segment** has
|
||||
more requirements, listed below.
|
||||
Restart ComfyUI afterwards. Requirements: a recent ComfyUI (tested with 0.41) and Python 3.10+. **SAM3 Segment** and
|
||||
**Body Segment** have more requirements, listed below.
|
||||
|
||||
### Optional: VTON and segmentation models
|
||||
|
||||
- **VTON Ultimate Processor** needs ONNX Runtime, which is not installed automatically (the CPU wheel would overwrite an
|
||||
existing GPU build). Install one of them into ComfyUI's Python:
|
||||
`python -m pip install onnxruntime-gpu` (NVIDIA) or `python -m pip install onnxruntime` (CPU).
|
||||
- **Garment Segment**, **Portrait Segment** and **VTON** download their models from Hugging Face on first use (pinned
|
||||
revisions, only the needed files). Garment Segment and Portrait Segment also fetch YuNet, a 230 KB face detector
|
||||
(MIT) that runs on the CPU. To keep models elsewhere, add these keys to `extra_model_paths.yaml`:
|
||||
- **Garment Segment**, **Portrait Segment**, **Body Segment** and **VTON** download their models from Hugging Face on
|
||||
first use (pinned revisions, only the needed files): about 256 MB for Garment Segment, and 1.6 GB for Body Segment
|
||||
(3.3 GB and 5.9 GB for its larger sizes). Garment Segment, Portrait Segment and Body Segment also fetch YuNet, a
|
||||
230 KB face detector (MIT) that runs on the CPU. **Body Segment needs PyTorch 2.4 or newer.** To keep models
|
||||
elsewhere, add these keys to `extra_model_paths.yaml`:
|
||||
|
||||
```yaml
|
||||
my_models:
|
||||
base_path: D:/ai-models/
|
||||
nh_nodes: NH-Nodes/ # SegFormer models (garment, face parsing) and the YuNet face detector
|
||||
nh_nodes: NH-Nodes/ # garment, face and body-part models, and the YuNet face detector
|
||||
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
|
||||
@@ -100,6 +115,7 @@ more requirements, listed below.
|
||||
| Upscale or refine a huge image piece by piece | Load Image → **Image Tile** → your sampler/upscaler per tile → **Image Untile** | [image-compose](docs/en/image-compose.md#image-tile-nh) |
|
||||
| Generate at the model's native size, then restore the original framing | Load Image → **NH Smart Ratio Image Resize** → edit → **NH Restore Image Geometry** | [resolution](docs/en/resolution.md) |
|
||||
| Inpaint only the clothes | Load Image → **Garment Segment** → **Mask Morphology** (grow) → inpaint | [vision](docs/en/vision.md) |
|
||||
| Mask the skin, hair, lips, teeth or clothes of the people in a photo by name | Load Image → **Body Segment** (`regions`: `skin`, `hair, face` or `clothes - shoes`; Vietnamese works: `da`, `tóc, môi`) → mask → retouch or inpaint | [vision](docs/en/vision.md#body-segment-nh) |
|
||||
| Mask every person, window or the sky in a large photo, or everything except the person | Load Image → **SAM3 Segment** (`concepts`: `window`, `person - bag` or `background`) → mask → edit or inpaint; add **Segs Select** to pick single instances | [vision](docs/en/vision.md#sam-3-workflows) |
|
||||
| Compare many LoRAs, one per queue | **Counter** → **Load LoRA Model Index** (index) → sampler → **Image Label** (prints the LoRA name) → Save Image | [loaders](docs/en/loaders.md) |
|
||||
| Run one of two branches without executing the other | condition → **If/Else** (lazy) → … | [logic](docs/en/logic.md#ifelse-nh) |
|
||||
@@ -114,7 +130,7 @@ more requirements, listed below.
|
||||
| Image — mask-based retouch | NH Mask-Aware Resize, NH Agnostic Image Generator, NH Simple Face Paste | [EN](docs/en/image-retouch.md) · [VI](docs/vi/image-retouch.md) |
|
||||
| Resolution | NH Smart Resolution Picker, NH Smart Ratio Image Resize, NH Ratio Preset Image Resize, NH Remove Resize Padding, NH Restore Image Geometry | [EN](docs/en/resolution.md) · [VI](docs/vi/resolution.md) |
|
||||
| Mask | Mask Morphology, Mask Properties, NH Create Box Mask, NH Mask Aspect Ratio Match | [EN](docs/en/mask.md) · [VI](docs/vi/mask.md) |
|
||||
| Vision | Garment Segment, Portrait Segment, SAM3 Segment, Segs Select, Segs Compose, Segs To Masks, Segs To Impact SEGS, Segs Preview | [EN](docs/en/vision.md) · [VI](docs/vi/vision.md) |
|
||||
| Vision | Garment Segment, Portrait Segment, Body Segment, SAM3 Segment, Segs Select, Segs Compose, Segs To Masks, Segs To Impact SEGS, Segs Preview | [EN](docs/en/vision.md) · [VI](docs/vi/vision.md) |
|
||||
| VTON | VTON Ultimate Processor | [EN](docs/en/vton.md) · [VI](docs/vi/vton.md) |
|
||||
| Logic | Compare, Logic Gate, If/Else, Switch N, Any Switch, Any Branch Switch, Gate Switch, Value Match Index, Math Eval, Random Choice | [EN](docs/en/logic.md) · [VI](docs/vi/logic.md) |
|
||||
| Text | Prompt Template, Prompt Scheduler, String Operations, Prompt Join, Text Split, Regex Extract, Text Index, Text Concatenate, Text Split Lines, Text Random Line | [EN](docs/en/text.md) · [VI](docs/vi/text.md) |
|
||||
@@ -138,7 +154,13 @@ NH-Nodes' own code is [MIT](LICENSE). Some features rely on third-party code or
|
||||
|
||||
- **VTON Ultimate Processor** and **Portrait Segment** are for **non-commercial use only** (CC BY-NC-SA 4.0 code and
|
||||
parsing models; non-commercial face-parsing model).
|
||||
- **Garment Segment**'s model does not declare a licence.
|
||||
- **Garment Segment** is for **non-commercial use only, and NVIDIA's licence defines that as research or evaluation
|
||||
purposes only**: its default model (FASHN Human Parser) is under the NVIDIA Source Code License for SegFormer. The
|
||||
other model, `fashionpedia`, declares no licence but is fine-tuned from NVIDIA's `mit-b3`, so treat it the same way.
|
||||
- **Body Segment** uses Meta's Sapiens2 under the Sapiens2 License: royalty-free, with no non-commercial clause, but
|
||||
it prohibits surveillance, biometric processing, identifying people, deepfakes and other uses, requires you to follow
|
||||
privacy, biometric and trade-control laws, and lets Meta audit your use, end the licence and change its terms. The
|
||||
model code is included with its licence; the weights are downloaded from Meta's Hugging Face repositories.
|
||||
- **SAM3 Segment** uses Meta's SAM 3 under the SAM License: royalty-free, with no non-commercial clause, but it bars
|
||||
military or warfare, nuclear, espionage, and gun or illegal-weapon uses. The weights and the `sam3` package are not
|
||||
included; you get them from Meta.
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
NH-Nodes' own code is released under the [MIT License](LICENSE). The components
|
||||
listed here come from other projects and keep their own licenses. Some of them are
|
||||
**non-commercial**, so the features that depend on them are not covered by the
|
||||
MIT license.
|
||||
**non-commercial** and others restrict how they may be used, so the features that
|
||||
depend on them are not covered by the MIT license.
|
||||
|
||||
> This is a good-faith summary, not legal advice. Check the upstream licenses
|
||||
> yourself before using these features commercially.
|
||||
@@ -15,7 +15,8 @@ MIT license.
|
||||
| Every node not listed below | Yes, under MIT | Original NH-Nodes code |
|
||||
| `VTON Ultimate Processor (NH)` | **No** | Its code and parsing models are CC BY-NC-SA 4.0 (FitDiT / OOTDiffusion) |
|
||||
| `Portrait Segment (NH)` | **No** | The face-parsing model card restricts use to non-commercial research and education |
|
||||
| `Garment Segment (NH)` | Unclear | The model uploader does not declare a license |
|
||||
| `Garment Segment (NH)` | **No** | Non-commercial, which its license defines as research or evaluation only (see below). The default model, FASHN Human Parser, is under the NVIDIA Source Code License for SegFormer. The `fashionpedia` model declares no license, but it is fine-tuned from `nvidia/mit-b3`, so the same license plausibly applies |
|
||||
| `Body Segment (NH)` | Yes, with use restrictions | Meta's Sapiens2 License is royalty-free and has no non-commercial clause, but it prohibits surveillance, biometric processing, identifying people, deepfakes and other uses (listed below), requires you to follow privacy, biometric and trade-control laws, and lets Meta audit your use, end the license and change its terms |
|
||||
| `SAM3 Segment (NH)` | Yes, within trade-control limits | Meta's SAM License is royalty-free and has no non-commercial clause, but it forbids ITAR-regulated activities and uses that trade controls prohibit (military or warfare, nuclear, espionage, guns and illegal weapons). The model and the `sam3` package are not part of NH-Nodes: you get them from Meta |
|
||||
|
||||
## Source code bundled in this repository
|
||||
@@ -27,6 +28,8 @@ MIT license.
|
||||
| `core/utils_mask.py` | FitDiT `src/utils_mask.py`, received via ComfyUI-Vton-Mask | CC BY-NC-SA 4.0 |
|
||||
| `nodes/vton/vton_preprocessor_nodes.py` | NH-Nodes node built on ComfyUI-Vton-Mask's mask generator. That project's `LICENSE` is MIT and says it is based on FitDiT; its README says it follows FitDiT's licensing terms. | Treat as CC BY-NC-SA 4.0 together with the code above |
|
||||
| `core/utils.py` (`resize_image`, `tensor_to_pil`, `pil_to_tensor`) | Small helpers adapted from ComfyUI-Vton-Mask `utils.py` | MIT |
|
||||
| `core/sapiens2/` | Meta's [Sapiens2](https://github.com/facebookresearch/sapiens2) `sapiens/backbones/standalone/sapiens2.py` at commit `7e5bae8`, kept as `backbone.py` with a `Modified by NH-Nodes` header line | Sapiens2 License — the copy is [`core/sapiens2/LICENSE.md`](core/sapiens2/LICENSE.md) |
|
||||
| `core/sapiens2_backend.py` (`SegHead`) | NH-Nodes' inference version of Meta's `SegHead` (`sapiens/dense/src/models/heads/seg_head.py` at the same commit): the same layers and layer names, so it loads Meta's released weights | Treated like the file above: Sapiens2 License |
|
||||
|
||||
Every file NH-Nodes changed carries a `Modified by NH-Nodes` header line (for
|
||||
example, ONNX Runtime sessions in the vendored code are created through
|
||||
@@ -41,6 +44,7 @@ example, ONNX Runtime sessions in the vendored code are created through
|
||||
| [`licenses/Self-Correction-Human-Parsing-MIT.txt`](licenses/Self-Correction-Human-Parsing-MIT.txt) | MIT, Peike Li |
|
||||
| [`licenses/Microsoft-human-pose-estimation-MIT.txt`](licenses/Microsoft-human-pose-estimation-MIT.txt) | MIT, Microsoft (HRNet transforms) |
|
||||
| [`licenses/ComfyUI-Vton-Mask-MIT.txt`](licenses/ComfyUI-Vton-Mask-MIT.txt) | MIT, ComfyUI-Vton-Mask (`core/utils.py` helpers) |
|
||||
| [`core/sapiens2/LICENSE.md`](core/sapiens2/LICENSE.md) | Sapiens2 License (Meta): the bundled Sapiens2 model code and the Sapiens2 weights that Body Segment downloads |
|
||||
|
||||
## Models downloaded at runtime
|
||||
|
||||
@@ -54,12 +58,88 @@ They are looked up first in the folders registered as `vton_mask` and `nh_nodes`
|
||||
| VTON Ultimate Processor | [`kg-09/kg-vton-mask`](https://huggingface.co/kg-09/kg-vton-mask) @ `4648778` | `dwpose/yolox_l.onnx`, `dwpose/dw-ll_ucoco_384.onnx` | DWPose / YOLOX weights, Apache-2.0 upstream |
|
||||
| VTON Ultimate Processor | same | `humanparsing/parsing_atr.onnx`, `humanparsing/parsing_lip.onnx` | From the OOTDiffusion/FitDiT checkpoints (CC BY-NC-SA 4.0); trained on the ATR and LIP datasets, which are distributed for research use |
|
||||
| Portrait Segment | [`jonathandinu/face-parsing`](https://huggingface.co/jonathandinu/face-parsing) @ `758b82e` | SegFormer fine-tuned on CelebAMask-HQ | Model card: "non-commercial research and educational purposes" |
|
||||
| Garment Segment | [`1038lab/segformer_fashion`](https://huggingface.co/1038lab/segformer_fashion) @ `e5bae2d` | SegFormer trained for Fashionpedia classes | No license declared by the uploader (Fashionpedia annotations are CC BY 4.0) |
|
||||
| Portrait Segment, Garment Segment | [`opencv/face_detection_yunet`](https://huggingface.co/opencv/face_detection_yunet) @ `3cc26e7` | `yunet/face_detection_yunet_2023mar.onnx`, the YuNet face detector (about 230 KB), and its `LICENSE` file | MIT, Copyright (c) 2020 Shiqi Yu (OpenCV Zoo) |
|
||||
| Garment Segment (`model`: `fashn`, the default) | [`fashn-ai/fashn-human-parser`](https://huggingface.co/fashn-ai/fashn-human-parser) @ `1f80c34` | SegFormer-B4 fine-tuned from `nvidia/mit-b4` for 18 human-parsing classes (about 256 MB) | NVIDIA Source Code License for SegFormer: research or evaluation only (see below). Model card: "This model inherits the NVIDIA Source Code License for SegFormer." |
|
||||
| Garment Segment (`model`: `fashionpedia`) | [`1038lab/segformer_fashion`](https://huggingface.co/1038lab/segformer_fashion) @ `e5bae2d` | SegFormer trained for Fashionpedia classes (about 190 MB); its `config.json` records `_name_or_path: nvidia/mit-b3` | No license declared by the uploader (Fashionpedia annotations are CC BY 4.0). The NVIDIA license plausibly applies, so treat it as research or evaluation only (see below) |
|
||||
| Body Segment | [`facebook/sapiens2-seg-0.4b`](https://huggingface.co/facebook/sapiens2-seg-0.4b) @ `449b3c5`, [`-0.8b`](https://huggingface.co/facebook/sapiens2-seg-0.8b) @ `196a627`, [`-1b`](https://huggingface.co/facebook/sapiens2-seg-1b) @ `60cf0b3` | `sapiens2_<size>_seg.safetensors` (1.6 / 3.3 / 5.9 GB): Sapiens2 body-part segmentation, 28 classes plus background | Sapiens2 License (see below) |
|
||||
| Portrait Segment, Garment Segment, Body Segment | [`opencv/face_detection_yunet`](https://huggingface.co/opencv/face_detection_yunet) @ `3cc26e7` | `yunet/face_detection_yunet_2023mar.onnx`, the YuNet face detector (about 230 KB), and its `LICENSE` file | MIT, Copyright (c) 2020 Shiqi Yu (OpenCV Zoo) |
|
||||
|
||||
YuNet is the face detector that Portrait Segment and Garment Segment use to find the people in a
|
||||
picture. It runs on the CPU through OpenCV, and its MIT license does not change the "At a glance"
|
||||
table: the terms of those two nodes come from their SegFormer models.
|
||||
YuNet is the face detector that Portrait Segment, Garment Segment and Body Segment use to find the
|
||||
people in a picture. It runs on the CPU through OpenCV, and its MIT license does not change the "At a
|
||||
glance" table: the terms of those nodes come from their models.
|
||||
|
||||
### NVIDIA Source Code License for SegFormer (Garment Segment)
|
||||
|
||||
Garment Segment's default model, FASHN Human Parser, is a SegFormer-B4 fine-tuned from
|
||||
`nvidia/mit-b4`. Its [model card](https://huggingface.co/fashn-ai/fashn-human-parser) says: "This
|
||||
model inherits the NVIDIA Source Code License for SegFormer." Section 3.3 of that
|
||||
[license](https://github.com/NVlabs/SegFormer/blob/master/LICENSE) reads:
|
||||
|
||||
> The Work and any derivative works thereof only may be used or intended for use non-commercially.
|
||||
> Notwithstanding the foregoing, NVIDIA and its affiliates may use the Work and any derivative works
|
||||
> commercially. As used herein, "non-commercially" means for research or evaluation purposes only.
|
||||
|
||||
This is narrower than the everyday meaning of "non-commercial": the license allows research or
|
||||
evaluation, and nothing else. NH-Nodes' MIT license does not change it.
|
||||
|
||||
The `fashionpedia` model (`1038lab/segformer_fashion`) has no model card and its uploader declares no
|
||||
license, so there is nothing to quote. Its `config.json` records `_name_or_path: nvidia/mit-b3`,
|
||||
which points to a fine-tune of NVIDIA's SegFormer encoder, so the same license plausibly applies.
|
||||
Treat it as research or evaluation only too.
|
||||
|
||||
### Sapiens2 License (Body Segment)
|
||||
|
||||
Body Segment runs Meta's Sapiens2 (April 2026). NH-Nodes bundles the model code with the license
|
||||
text (`core/sapiens2/`) and downloads the weights from Meta's repositories on first use. It does not
|
||||
redistribute the weights. The [Sapiens2 License](https://github.com/facebookresearch/sapiens2/blob/main/LICENSE.md)
|
||||
(`core/sapiens2/LICENSE.md` is identical to Meta's file at commit `7e5bae8`) is a non-exclusive,
|
||||
worldwide, non-transferable, royalty-free limited license to use, reproduce, distribute, copy, create
|
||||
derivative works of and modify the "Sapiens Materials": Meta's models, code and trained weights. It
|
||||
has no non-commercial clause. You agree to it by using or distributing any part of the materials.
|
||||
What matters to a user of NH-Nodes:
|
||||
|
||||
- **Prohibited uses (section 1.b.vi).** Your use of the materials must not involve or encourage
|
||||
others to use them:
|
||||
- for purposes of surveillance, including any research or development relating to surveillance;
|
||||
- for biometric processing;
|
||||
- in any manner that infringes, misappropriates or otherwise violates third-party rights;
|
||||
- for attempting to identify or re-identify any individual included in the Sapiens Materials;
|
||||
- to create deepfakes, surveillance, or other misleading, deceptive or inappropriate content,
|
||||
including impersonating any individual or facilitating false online engagement;
|
||||
- to collect, process, disclose, generate or infer health, demographic or other sensitive personal
|
||||
or private information about individuals without the rights and consents that applicable laws
|
||||
require;
|
||||
- in a pornographic or defamatory manner;
|
||||
- to engage in the unauthorized or unlicensed practice of any profession, including but not
|
||||
limited to financial, legal, medical/health, or related professional practices;
|
||||
- to engage in, promote, incite, facilitate, or assist in the planning or development of any
|
||||
activities subject to the International Traffic in Arms Regulations (ITAR) or end uses prohibited
|
||||
by trade controls, or any other activities that present a risk of death or bodily harm to
|
||||
individuals, including those related to military or warfare purposes, nuclear industries or
|
||||
applications, espionage, the development or use of guns or illegal weapons or illegal drugs, and
|
||||
the operation of critical infrastructure or machinery.
|
||||
- **Laws (sections 1.b.iii and 1.b.v).** Your use must comply with applicable laws, including privacy,
|
||||
data protection and security laws: the EU General Data Protection Regulation, the California
|
||||
Consumer Privacy Act, the US data security regulations at 28 C.F.R. Part 202, Brazil's Lei Geral de
|
||||
Proteção de Dados, and any law governing the processing of biometric information. It must comply
|
||||
with trade controls too, and you must not be the target of trade controls.
|
||||
- **No reverse engineering (1.b.iv).** Your use must not involve or encourage others to reverse
|
||||
engineer, decompile or discover the underlying components of the materials.
|
||||
- **What Meta may do.** If Meta reasonably believes that you are or are likely to be in violation, it
|
||||
may audit your use, storage and distribution of the materials and require you to delete every copy
|
||||
(1.c). It may terminate the license if you breach it, and you must then delete the materials and
|
||||
stop using them (6). It may also change the terms from time to time, provided the changes are
|
||||
similar in spirit to the current ones: they take effect immediately, and continuing to use the
|
||||
materials counts as agreeing (8).
|
||||
- **Redistribution and publications (1.b.i and 1.b.ii).** Whoever distributes the materials, or
|
||||
derivative works of them, must do so under this license and include a copy of it. Research
|
||||
publications that use the materials must acknowledge it.
|
||||
- **Liability (2 to 5).** The materials and any output or results from them are provided "as is", and
|
||||
you are solely responsible for deciding whether using them is appropriate. If you start proceedings
|
||||
against Meta or any entity alleging that the materials or their outputs infringe your intellectual
|
||||
property, the license ends, and you must indemnify Meta against third-party claims that arise from
|
||||
your use or distribution of the materials.
|
||||
- **Governing law (7).** California law applies, and the courts of California have exclusive
|
||||
jurisdiction.
|
||||
|
||||
## Software and models you install yourself
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -206,6 +206,14 @@ def _grown(region, sides, aspect, image_size):
|
||||
|
||||
|
||||
COARSE_TRUST = 0.5 # the first look's confidence counts this much against a crop's: a crop sees people sharper
|
||||
SURE = 0.9 # a parser's confidence counted as sure, for the floor of people_labels
|
||||
# Least overlap (IoU) of the first look's people with those of a look at the mirrored picture, when no face is found:
|
||||
# people seen without a face come back in the mirror (0.83 to 0.99 on 32 photos; edited copies and small people pasted
|
||||
# on a plain ground go down to 0.64), what a parser invents on a facade does not (0.02 to 0.03 in 50 variants).
|
||||
MIRROR_AGREEMENT = 0.6
|
||||
# A region shorter than this (px) is not parsed again: blown up to a parser's input (4 to 8 times) so little is mostly
|
||||
# made up (a speck of a held helmet came back as salt-and-pepper labels); the first look's labels stay there.
|
||||
MIN_CROP = 128
|
||||
|
||||
|
||||
def person_box(face, image_size):
|
||||
@@ -219,7 +227,7 @@ def person_box(face, image_size):
|
||||
min(width, int(np.ceil(centre + 2.5 * face_w))), min(height, int(np.ceil(y1 + 8.0 * face_h))))
|
||||
|
||||
|
||||
def people_labels(predict, image, aspect, margin=0.08, people=()):
|
||||
def people_labels(predict, image, aspect, margin=0.08, people=(), floor=None):
|
||||
"""Label map of the people in the uint8 RGB ``image``, from ``predict`` (picture -> (label map with 0 for
|
||||
background, confidence map)), a parser trained on pictures of the shape ``aspect`` (width / height).
|
||||
|
||||
@@ -230,11 +238,24 @@ def people_labels(predict, image, aspect, margin=0.08, people=()):
|
||||
surer and away from the region's cut edges, for the parts of it that lie on people already found (see anchored)
|
||||
and for background near them; so nothing is invented on an empty backdrop and overlapping regions meet where their
|
||||
answers do, not along a straight line.
|
||||
|
||||
With ``floor`` and no ``people`` boxes, a first look that is sure (SURE or more) of less than ``floor`` of what it
|
||||
labels means nobody is there, and the map is all background: a parser trained on people sees them in a picture
|
||||
without any, but is unsure of most of it (a facade or a cafe's shelves come out as faint arms and clothes), and its
|
||||
crops of such a picture would find more. So does a first look that a look at the mirrored picture does not repeat
|
||||
(MIRROR_AGREEMENT): what is invented is not where the mirror would put it.
|
||||
"""
|
||||
height, width = image.shape[:2]
|
||||
coarse, trust = predict(image)
|
||||
if floor is not None and not len(people):
|
||||
labelled = trust[coarse != 0]
|
||||
if not labelled.size or np.count_nonzero(labelled >= labelled.dtype.type(SURE)) < floor * labelled.size:
|
||||
return np.zeros_like(coarse)
|
||||
found, mirrored = coarse != 0, predict(np.ascontiguousarray(image[:, ::-1]))[0][:, ::-1] != 0
|
||||
if np.count_nonzero(found & mirrored) < MIRROR_AGREEMENT * np.count_nonzero(found | mirrored):
|
||||
return np.zeros_like(coarse)
|
||||
regions = [region for box in foreground_clusters(coarse, extra=people)
|
||||
for region in cover_box(box, aspect, margin, (height, width))]
|
||||
for region in cover_box(box, aspect, margin, (height, width)) if region[3] - region[1] >= MIN_CROP]
|
||||
if not regions or regions == [(0, 0, width, height)]:
|
||||
return coarse # nobody found, or the first look already saw the whole picture in the parser's own shape
|
||||
anchor = coarse != 0 # where the first look (or a face) puts people
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Sapiens2 License
|
||||
|
||||
## Definitions
|
||||
|
||||
**"Agreement"** means the terms and conditions for use, reproduction, distribution and modification of the Sapiens Materials set forth herein.
|
||||
|
||||
**"Sapiens Materials"** means, collectively, Documentation and the models, software and algorithms, including machine-learning model code, trained model weights, inference-enabling code, training-enabling code, fine-tuning enabling code, and other elements of the foregoing distributed by Meta and made available under this Agreement.
|
||||
|
||||
**"Documentation"** means the specifications, manuals and documentation accompanying Sapiens Materials distributed by Meta.
|
||||
|
||||
**"Licensee"** or **"you"** means you, or your employer or any other person or entity (if you are entering into this Agreement on such person or entity's behalf), of the age required under applicable laws, rules or regulations to provide legal consent and that has legal authority to bind your employer or such other person or entity if you are entering in this Agreement on their behalf.
|
||||
|
||||
**"Meta"** or **"we"** means Meta Platforms Ireland Limited (if you are located in or, if you are an entity, your principal place of business is in the EEA or Switzerland) or Meta Platforms, Inc. (if you are located outside of the EEA or Switzerland).
|
||||
|
||||
**"Sanctions"** means any economic or trade sanctions or restrictions administered or enforced by the United States (including the Office of Foreign Assets Control of the U.S. Department of the Treasury ("OFAC"), the U.S. Department of State and the U.S. Department of Commerce), the United Nations, the European Union, or the United Kingdom.
|
||||
|
||||
**"Trade Controls"** means any of the following: Sanctions and applicable export and import controls.
|
||||
|
||||
By clicking **"I Accept"** below or by using or distributing any portion or element of the Sapiens Materials, you agree to be bound by this Agreement.
|
||||
|
||||
## 1. License Rights and Redistribution
|
||||
|
||||
**a. Grant of Rights.** You are granted a non-exclusive, worldwide, non-transferable and royalty-free limited license under Meta's intellectual property or other rights owned by Meta embodied in the Sapiens Materials to use, reproduce, distribute, copy, create derivative works of, and make modifications to the Sapiens Materials.
|
||||
|
||||
**b. Redistribution and Use.**
|
||||
|
||||
i. Distribution of Sapiens Materials, and any derivative works thereof, are subject to the terms of this Agreement. If you distribute or make the Sapiens Materials, or any derivative works thereof, available to a third party, you may only do so under the terms of this Agreement and you shall provide a copy of this Agreement with any such Sapiens Materials.
|
||||
|
||||
ii. If you submit for publication the results of research you perform on, using, or otherwise in connection with Sapiens Materials, you must acknowledge the use of Sapiens Materials in your publication.
|
||||
|
||||
iii. Your use of the Sapiens Materials must comply with applicable laws and regulations, including Trade Control Laws and applicable privacy, data protection and security laws, rules, regulations, directives, or governmental requirements including the General Data Privacy Regulation (Regulation (EU) 2016/679), the California Consumer Privacy Act, U.S. data security regulations set forth at 28 C.F.R. Part 202, the Lei Geral de Proteção de Dados, and any and all laws governing the processing of biometric information, as well as all amendments and successor laws to any of the foregoing.
|
||||
|
||||
iv. Your use of the Sapiens Materials will not involve or encourage others to reverse engineer, decompile or discover the underlying components of the Sapiens Materials.
|
||||
|
||||
v. You are not the target of Trade Controls and your use of Sapiens Materials must comply with Trade Controls.
|
||||
|
||||
vi. Your use of the Sapiens Materials will not involve or encourage others to use for:
|
||||
|
||||
- (i) for purposes of surveillance, including any research or development relating to surveillance;
|
||||
- (ii) for biometric processing;
|
||||
- (iii) in any manner that infringes, misappropriates, or otherwise violates any third-party rights;
|
||||
- (iv) for attempting to identify or re-identify any individual included in the Sapiens Materials;
|
||||
- (v) to create deepfakes, surveillance, or other misleading, deceptive, or inappropriate content including but not limited to impersonating any individual or facilitating false online engagement;
|
||||
- (vi) to collect, process, disclose, generate, or infer health, demographic, or other sensitive personal or private information about individuals without rights and consents required by applicable laws;
|
||||
- (vii) in a pornographic or defamatory manner;
|
||||
- (viii) to engage in the unauthorized or unlicensed practice of any profession including but not limited to financial, legal, medical/health, or related professional practices; or
|
||||
- (ix) to engage in, promote, incite, facilitate, or assist in the planning or development of any activities subject to the International Traffic in Arms Regulations (ITAR) or end uses prohibited by Trade Controls or any other activities that present a risk of death or bodily harm to individuals, including those related to military or warfare purposes, nuclear industries or applications, espionage, the development or use of guns or illegal weapons or illegal drugs, and the operation of critical infrastructure or machinery.
|
||||
|
||||
**c.** If Meta reasonably believes, as determined by Meta in its sole discretion, that you are or are likely to be in violation of the terms of this Agreement, Meta or Meta's designee, at Meta's sole expense, may audit your use, storage, and distribution of the Sapiens Materials including, without limitation, any and all records and files associated with the Licensed Content and this Agreement. You hereby agree to cooperate with such an audit. Meta may also require You to delete all copies of the Sapiens Materials (in whole or in part) in Your possession and control. You will promptly comply with any and all such requests. Upon Meta's request, You shall provide Meta with written confirmation of Your compliance with such requirement.
|
||||
|
||||
## 2. User Support
|
||||
|
||||
Your use of the Sapiens Materials is done at your own discretion; Meta does not process any information nor provide any service in relation to such use. Meta is under no obligation to provide any support services for the Sapiens Materials. Any support provided is "as is", "with all faults", and without warranty of any kind.
|
||||
|
||||
## 3. Disclaimer of Warranty
|
||||
|
||||
UNLESS REQUIRED BY APPLICABLE LAW, THE SAPIENS MATERIALS AND ANY OUTPUT AND RESULTS THEREFROM ARE PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, AND META DISCLAIMS ALL WARRANTIES OF ANY KIND, BOTH EXPRESS AND IMPLIED, INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE. YOU ARE SOLELY RESPONSIBLE FOR DETERMINING THE APPROPRIATENESS OF USING OR REDISTRIBUTING THE SAPIENS MATERIALS AND ASSUME ANY RISKS ASSOCIATED WITH YOUR USE OF THE SAPIENS MATERIALS AND ANY OUTPUT AND RESULTS.
|
||||
|
||||
## 4. Limitation of Liability
|
||||
|
||||
IN NO EVENT WILL META OR ITS AFFILIATES BE LIABLE UNDER ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, TORT, NEGLIGENCE, PRODUCTS LIABILITY, OR OTHERWISE, ARISING OUT OF THIS AGREEMENT, FOR ANY LOST PROFITS OR ANY DIRECT OR INDIRECT, SPECIAL, CONSEQUENTIAL, INCIDENTAL, EXEMPLARY OR PUNITIVE DAMAGES, EVEN IF META OR ITS AFFILIATES HAVE BEEN ADVISED OF THE POSSIBILITY OF ANY OF THE FOREGOING.
|
||||
|
||||
## 5. Intellectual Property
|
||||
|
||||
**a.** Subject to Meta's ownership of Sapiens Materials and derivatives made by or for Meta, with respect to any derivative works and modifications of the Sapiens Materials that are made by you, as between you and Meta, you are and will be the owner of such derivative works and modifications.
|
||||
|
||||
**b.** If you institute litigation or other proceedings against Meta or any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Sapiens Materials, outputs or results, or any portion of any of the foregoing, constitutes infringement of intellectual property or other rights owned or licensable by you, then any licenses granted to you under this Agreement shall terminate as of the date such litigation or claim is filed or instituted. You will indemnify and hold harmless Meta from and against any claim by any third party arising out of or related to your use or distribution of the Sapiens Materials.
|
||||
|
||||
## 6. Term and Termination
|
||||
|
||||
The term of this Agreement will commence upon your acceptance of this Agreement or access to the Sapiens Materials and will continue in full force and effect until terminated in accordance with the terms and conditions herein. Meta may terminate this Agreement if you are in breach of any term or condition of this Agreement. Upon termination of this Agreement, you shall delete and cease use of the Sapiens Materials. Sections 3, 4 and 7 shall survive the termination of this Agreement.
|
||||
|
||||
## 7. Governing Law and Jurisdiction
|
||||
|
||||
This Agreement will be governed and construed under the laws of the State of California without regard to choice of law principles, and the UN Convention on Contracts for the International Sale of Goods does not apply to this Agreement. The courts of California shall have exclusive jurisdiction of any dispute arising out of this Agreement.
|
||||
|
||||
## 8. Modifications and Amendments
|
||||
|
||||
Meta may modify this Agreement from time to time; provided that they are similar in spirit to the current version of the Agreement, but may differ in detail to address new problems or concerns. All such changes will be effective immediately. Your continued use of the Sapiens Materials after any modification to this Agreement constitutes your agreement to such modification. Except as provided in this Agreement, no modification or addition to any provision of this Agreement will be binding unless it is in writing and signed by an authorized representative of both you and Meta.
|
||||
@@ -0,0 +1 @@
|
||||
"""Meta Sapiens2 model code, vendored (see LICENSE.md here and backbone.py)."""
|
||||
@@ -0,0 +1,915 @@
|
||||
# Vendored from https://github.com/facebookresearch/sapiens2 (commit 7e5bae88456ac418ff0e58e74106c9fe192055d4),
|
||||
# file sapiens/backbones/standalone/sapiens2.py, under the Sapiens2 License (LICENSE.md in this folder).
|
||||
# Modified by NH-Nodes: this note, the numpy import that the training-only coordinate jitter uses, and the
|
||||
# line below, which keeps the linter off this upstream file.
|
||||
# ruff: noqa
|
||||
|
||||
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
# All rights reserved.
|
||||
#
|
||||
# This source code is licensed under the license found in the
|
||||
# LICENSE file in the root directory of this source tree.
|
||||
|
||||
import math
|
||||
from typing import Any, Dict, List, Literal, Optional, Sequence, Tuple, Union
|
||||
import numpy as np
|
||||
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
from torch import Tensor
|
||||
from torch.nn.init import trunc_normal_
|
||||
from torch.utils.checkpoint import checkpoint
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
def to_2tuple(x):
|
||||
if isinstance(x, (str, bytes)):
|
||||
return (x, x)
|
||||
if isinstance(x, Sequence):
|
||||
x = tuple(x)
|
||||
if len(x) == 2:
|
||||
return x
|
||||
raise ValueError("Expected scalar or length-2 iterable")
|
||||
return (x, x)
|
||||
|
||||
|
||||
class RopePositionEmbedding(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
embed_dim: int,
|
||||
*,
|
||||
num_heads: int,
|
||||
base: float | None = 100.0,
|
||||
min_period: float | None = None,
|
||||
max_period: float | None = None,
|
||||
normalize_coords: Literal["min", "max", "separate"] = "separate",
|
||||
shift_coords: float | None = None,
|
||||
jitter_coords: float | None = None,
|
||||
rescale_coords: float | None = None,
|
||||
dtype: torch.dtype | None = None,
|
||||
device: torch.device | None = None,
|
||||
):
|
||||
super().__init__()
|
||||
assert embed_dim % (4 * num_heads) == 0
|
||||
both_periods = min_period is not None and max_period is not None
|
||||
if (base is None and not both_periods) or (base is not None and both_periods):
|
||||
raise ValueError(
|
||||
"Either `base` or `min_period`+`max_period` must be provided."
|
||||
)
|
||||
|
||||
D_head = embed_dim // num_heads
|
||||
self.base = base
|
||||
self.min_period = min_period
|
||||
self.max_period = max_period
|
||||
self.D_head = D_head
|
||||
self.normalize_coords = normalize_coords
|
||||
self.shift_coords = shift_coords
|
||||
self.jitter_coords = jitter_coords
|
||||
self.rescale_coords = rescale_coords
|
||||
|
||||
# Needs persistent=True because we do teacher.load_state_dict(student.state_dict()) to initialize the teacher
|
||||
self.dtype = dtype or torch.float32 # Don't rely on self.periods.dtype
|
||||
self.register_buffer(
|
||||
"periods",
|
||||
torch.empty(D_head // 4, device=device, dtype=self.dtype),
|
||||
persistent=True,
|
||||
)
|
||||
self._init_weights()
|
||||
|
||||
def forward(self, *, H: int, W: int) -> tuple[Tensor, Tensor]:
|
||||
device = self.periods.device
|
||||
dtype = self.dtype
|
||||
dd = {"device": device, "dtype": dtype}
|
||||
# Prepare coords in range [-1, +1]
|
||||
if self.normalize_coords == "max":
|
||||
max_HW = max(H, W)
|
||||
coords_h = torch.arange(0.5, H, **dd) / max_HW # [H]
|
||||
coords_w = torch.arange(0.5, W, **dd) / max_HW # [W]
|
||||
elif self.normalize_coords == "min":
|
||||
min_HW = min(H, W)
|
||||
coords_h = torch.arange(0.5, H, **dd) / min_HW # [H]
|
||||
coords_w = torch.arange(0.5, W, **dd) / min_HW # [W]
|
||||
elif self.normalize_coords == "separate":
|
||||
coords_h = torch.arange(0.5, H, **dd) / H # [H]
|
||||
coords_w = torch.arange(0.5, W, **dd) / W # [W]
|
||||
else:
|
||||
raise ValueError(f"Unknown normalize_coords: {self.normalize_coords}")
|
||||
coords = torch.stack(
|
||||
torch.meshgrid(coords_h, coords_w, indexing="ij"), dim=-1
|
||||
) # [H, W, 2]
|
||||
coords = coords.flatten(0, 1) # [HW, 2]
|
||||
coords = 2.0 * coords - 1.0 # Shift range [0, 1] to [-1, +1]
|
||||
|
||||
# Shift coords by adding a uniform value in [-shift, shift]
|
||||
if self.training and self.shift_coords is not None:
|
||||
shift_hw = torch.empty(2, **dd).uniform_(
|
||||
-self.shift_coords, self.shift_coords
|
||||
)
|
||||
coords += shift_hw[None, :]
|
||||
|
||||
# Jitter coords by multiplying the range [-1, 1] by a log-uniform value in [1/jitter, jitter]
|
||||
if self.training and self.jitter_coords is not None:
|
||||
jitter_max = np.log(self.jitter_coords)
|
||||
jitter_min = -jitter_max
|
||||
jitter_hw = torch.empty(2, **dd).uniform_(jitter_min, jitter_max).exp()
|
||||
coords *= jitter_hw[None, :]
|
||||
|
||||
# Rescale coords by multiplying the range [-1, 1] by a log-uniform value in [1/rescale, rescale]
|
||||
if self.training and self.rescale_coords is not None:
|
||||
rescale_max = np.log(self.rescale_coords)
|
||||
rescale_min = -rescale_max
|
||||
rescale_hw = torch.empty(1, **dd).uniform_(rescale_min, rescale_max).exp()
|
||||
coords *= rescale_hw
|
||||
|
||||
# Prepare angles and sin/cos
|
||||
angles = (
|
||||
2 * math.pi * coords[:, :, None] / self.periods[None, None, :]
|
||||
) # [HW, 2, D//4]
|
||||
angles = angles.flatten(1, 2) # [HW, D//2]
|
||||
angles = angles.tile(2) # [HW, D]
|
||||
cos = torch.cos(angles) # [HW, D]
|
||||
sin = torch.sin(angles) # [HW, D]
|
||||
|
||||
return (sin, cos) # 2 * [HW, D]
|
||||
|
||||
def _init_weights(self):
|
||||
device = self.periods.device
|
||||
dtype = self.dtype
|
||||
if self.base is not None:
|
||||
periods = self.base ** (
|
||||
2
|
||||
* torch.arange(self.D_head // 4, device=device, dtype=dtype)
|
||||
/ (self.D_head // 2)
|
||||
) # [D//4]
|
||||
else:
|
||||
base = self.max_period / self.min_period
|
||||
exponents = torch.linspace(
|
||||
0, 1, self.D_head // 4, device=device, dtype=dtype
|
||||
) # [D//4] range [0, 1]
|
||||
periods = base**exponents # range [1, max_period / min_period]
|
||||
periods = periods / base # range [min_period / max_period, 1]
|
||||
periods = periods * self.max_period # range [min_period, max_period]
|
||||
self.periods.data = periods
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------------
|
||||
class Tokenizer(nn.Module):
|
||||
"""Stacked window self‑attention that emits one token per window
|
||||
by re‑using TransformerEncoderLayer blocks."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
embed_dims: int,
|
||||
window_size: int = 4,
|
||||
num_heads: int = 4,
|
||||
num_tokenizer_layers: int = 1,
|
||||
qkv_bias: bool = True,
|
||||
use_qk_norm: bool = False,
|
||||
chunk_size: int = 1024, # max windows per chunk
|
||||
):
|
||||
super().__init__()
|
||||
self.ws = window_size
|
||||
self.chunk_size = chunk_size
|
||||
|
||||
# local absolute positional embeddings for [CLS] + patch tokens
|
||||
self.local_pos_embed = nn.Parameter(
|
||||
torch.zeros(1, 1 + window_size * window_size, embed_dims)
|
||||
)
|
||||
trunc_normal_(self.local_pos_embed, std=0.02)
|
||||
|
||||
# build N identical TransformerEncoderLayer blocks
|
||||
self.blocks = nn.ModuleList(
|
||||
[
|
||||
TransformerEncoderLayer2(
|
||||
embed_dims=embed_dims,
|
||||
num_heads=num_heads,
|
||||
feedforward_channels=embed_dims * 4, # standard FFN size
|
||||
qkv_bias=qkv_bias,
|
||||
use_qk_norm=use_qk_norm,
|
||||
)
|
||||
for _ in range(num_tokenizer_layers)
|
||||
]
|
||||
)
|
||||
|
||||
# shared CLS token for pooling
|
||||
self.w_cls = nn.Parameter(torch.zeros(1, 1, embed_dims))
|
||||
trunc_normal_(self.w_cls, std=0.02)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
x: torch.Tensor,
|
||||
hw: Tuple[int, int],
|
||||
) -> Tuple[torch.Tensor, Tuple[int, int]]:
|
||||
"""Args:
|
||||
x : B, N, C (N = H*W)
|
||||
hw : (H, W) before reduction
|
||||
Returns:
|
||||
x_ : B, (H/ws)*(W/ws), C
|
||||
hw_: (H/ws, W/ws)
|
||||
"""
|
||||
B, N, C = x.shape
|
||||
H, W = hw
|
||||
ws = self.ws
|
||||
assert H % ws == 0 and W % ws == 0, (
|
||||
f"Image size {H}×{W} must be divisible by window {ws}."
|
||||
)
|
||||
|
||||
# reshape tokens → non‑overlapping windows
|
||||
x = x.view(B, H, W, C)
|
||||
|
||||
ph, pw = H // ws, W // ws ## ints in eager mode
|
||||
ph, pw = int(ph), int(pw) ## ints in scripting mode
|
||||
x = x.view(B, ph, ws, pw, ws, C) # B, H/ws, ws, W/ws, ws, C
|
||||
x = x.permute(0, 1, 3, 2, 4, 5) # B, H/ws, W/ws, ws, ws, C
|
||||
x = x.contiguous().view(B * ph * pw, ws * ws, C) # (B*H/ws*W/ws), ws², C))
|
||||
|
||||
total_windows = x.size(0)
|
||||
chunk_size = int(min(self.chunk_size, total_windows))
|
||||
token_out = x.new_empty(total_windows, C)
|
||||
|
||||
use_ckpt = self.training and torch.is_grad_enabled()
|
||||
|
||||
def _run_blocks(t: torch.Tensor) -> torch.Tensor:
|
||||
for blk in self.blocks:
|
||||
t = blk(t)
|
||||
return t
|
||||
|
||||
for i in range(0, total_windows, chunk_size):
|
||||
chunk = x[i : i + chunk_size] # (m, ws², C)
|
||||
m = chunk.size(0)
|
||||
cls = self.w_cls.expand(m, -1, -1) # (m, 1, C)
|
||||
chunk = torch.cat([cls, chunk], dim=1) # (m, 1+ws², C)
|
||||
chunk = chunk + self.local_pos_embed # add local PE
|
||||
|
||||
if use_ckpt:
|
||||
chunk = checkpoint(_run_blocks, chunk, use_reentrant=False)
|
||||
else:
|
||||
chunk = _run_blocks(chunk)
|
||||
|
||||
token_out[i : i + m] = chunk[:, 0] # take CLS out
|
||||
|
||||
token = token_out.view(B, ph * pw, C) # (B, (H/ws)*(W
|
||||
return token, (ph, pw)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------------
|
||||
class GroupedQueryAttention(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
embed_dims,
|
||||
num_heads,
|
||||
num_kv_heads=None,
|
||||
input_dims=None,
|
||||
attn_drop=0.0,
|
||||
proj_drop=0.0,
|
||||
qkv_bias=True,
|
||||
qk_scale=None,
|
||||
proj_bias=True,
|
||||
use_qk_norm=True,
|
||||
v_shortcut=False,
|
||||
layer_scale_init_value=0.0,
|
||||
):
|
||||
super().__init__()
|
||||
# Core dims
|
||||
self.embed_dims = embed_dims
|
||||
self.num_heads = num_heads
|
||||
self.num_kv_heads = num_kv_heads or num_heads
|
||||
assert self.num_heads % self.num_kv_heads == 0, (
|
||||
"num_kv_heads must divide num_heads"
|
||||
)
|
||||
self.head_dim = embed_dims // num_heads
|
||||
self.input_dims = input_dims or embed_dims
|
||||
# Features
|
||||
self.attn_drop = attn_drop
|
||||
self.v_shortcut = v_shortcut
|
||||
self.use_qk_norm = use_qk_norm
|
||||
|
||||
# Attention operation selection
|
||||
if qk_scale is not None:
|
||||
scale = qk_scale
|
||||
else:
|
||||
scale = self.head_dim**-0.5
|
||||
|
||||
assert qk_scale is None, "qk_scale is not supported"
|
||||
self.attn_op = F.scaled_dot_product_attention
|
||||
|
||||
# Q/K/V projections
|
||||
self.wq = nn.Linear(self.input_dims, embed_dims, bias=qkv_bias)
|
||||
self.wk = nn.Linear(
|
||||
self.input_dims, self.num_kv_heads * self.head_dim, bias=qkv_bias
|
||||
)
|
||||
self.wv = nn.Linear(
|
||||
self.input_dims, self.num_kv_heads * self.head_dim, bias=qkv_bias
|
||||
)
|
||||
|
||||
if self.use_qk_norm:
|
||||
self.q_norm = nn.RMSNorm(self.head_dim, eps=1e-6)
|
||||
self.k_norm = nn.RMSNorm(self.head_dim, eps=1e-6)
|
||||
|
||||
# Output projection + dropout
|
||||
self.proj = nn.Linear(embed_dims, embed_dims, bias=proj_bias)
|
||||
self.proj_drop = nn.Dropout(proj_drop)
|
||||
|
||||
# Optional LayerScale
|
||||
if layer_scale_init_value > 0:
|
||||
self.gamma = LayerScale(embed_dims, scale=layer_scale_init_value)
|
||||
else:
|
||||
self.gamma = nn.Identity()
|
||||
|
||||
def apply_rope(
|
||||
self, q: Tensor, k: Tensor, rope: Tensor | Tuple[Tensor, Tensor]
|
||||
) -> Tuple[Tensor, Tensor]:
|
||||
# All operations will use the dtype of rope, the output is cast back to the dtype of q and k
|
||||
q_dtype = q.dtype
|
||||
k_dtype = k.dtype
|
||||
sin, cos = rope
|
||||
rope_dtype = sin.dtype
|
||||
q = q.to(dtype=rope_dtype)
|
||||
k = k.to(dtype=rope_dtype)
|
||||
N = q.shape[-2]
|
||||
prefix = N - sin.shape[-2] ## extra tokens
|
||||
assert prefix >= 0
|
||||
q_prefix = q[:, :, :prefix, :]
|
||||
q = self._rope_apply(q[:, :, prefix:, :], sin, cos) # [B, head, hw, D//head]
|
||||
q = torch.cat((q_prefix, q), dim=-2) # [B, head, N, D//head]
|
||||
k_prefix = k[:, :, :prefix, :]
|
||||
k = self._rope_apply(k[:, :, prefix:, :], sin, cos) # [B, head, hw, D//head]
|
||||
k = torch.cat((k_prefix, k), dim=-2) # [B, head, N, D//head]
|
||||
q = q.to(dtype=q_dtype)
|
||||
k = k.to(dtype=k_dtype)
|
||||
return q, k
|
||||
|
||||
def _rope_rotate_half(self, x: Tensor) -> Tensor:
|
||||
# x: [ x0 x1 x2 x3 x4 x5]
|
||||
# out: [-x3 -x4 -x5 x0 x1 x2]
|
||||
x1, x2 = x.chunk(2, dim=-1)
|
||||
return torch.cat([-x2, x1], dim=-1)
|
||||
|
||||
def _rope_apply(self, x: Tensor, sin: Tensor, cos: Tensor) -> Tensor:
|
||||
# x: [..., D], eg [x0, x1, x2, x3, x4, x5]
|
||||
# sin: [..., D], eg [sin0, sin1, sin2, sin0, sin1, sin2]
|
||||
# cos: [..., D], eg [cos0, cos1, cos2, cos0, cos1, cos2]
|
||||
return (x * cos) + (self._rope_rotate_half(x) * sin)
|
||||
|
||||
def forward(self, x, rope=None):
|
||||
B, N, _ = x.shape
|
||||
# Q: (B, N, num_heads, head_dim)
|
||||
q = self.wq(x).view(B, N, self.num_heads, self.head_dim)
|
||||
# K/V: (B, N, num_kv_heads, head_dim)
|
||||
k = self.wk(x).view(B, N, self.num_kv_heads, self.head_dim)
|
||||
v = self.wv(x).view(B, N, self.num_kv_heads, self.head_dim)
|
||||
|
||||
# (B, heads, N, head_dim)
|
||||
q = q.permute(0, 2, 1, 3)
|
||||
k = k.permute(0, 2, 1, 3)
|
||||
v = v.permute(0, 2, 1, 3)
|
||||
|
||||
if self.use_qk_norm:
|
||||
q = self.q_norm(q)
|
||||
k = self.k_norm(k)
|
||||
|
||||
# Repeat KV heads if group ratio >1
|
||||
if self.num_kv_heads != self.num_heads:
|
||||
factor = self.num_heads // self.num_kv_heads
|
||||
k = k.repeat_interleave(factor, dim=1)
|
||||
v = v.repeat_interleave(factor, dim=1)
|
||||
|
||||
if rope is not None:
|
||||
q, k = self.apply_rope(q, k, rope)
|
||||
|
||||
# Scaled dot-product attention
|
||||
attn_out = self.attn_op(
|
||||
q, k, v, dropout_p=self.attn_drop if self.training else 0.0
|
||||
) # (B, num_heads, N, head_dim)
|
||||
|
||||
# Merge heads -> (B, N, embed_dims)
|
||||
out = attn_out.permute(0, 2, 1, 3).reshape(B, N, self.embed_dims)
|
||||
|
||||
# Output projection + drop + layer scale
|
||||
out = self.proj(out)
|
||||
out = self.gamma(self.proj_drop(out))
|
||||
|
||||
# Optional V-shortcut (only when MQA)
|
||||
if self.v_shortcut and self.num_kv_heads == 1:
|
||||
raise NotImplementedError
|
||||
return out
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------------
|
||||
class TransformerEncoderLayer2(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
embed_dims,
|
||||
num_heads,
|
||||
num_kv_heads=None,
|
||||
feedforward_channels=None,
|
||||
drop_rate=0.0,
|
||||
attn_drop_rate=0.0,
|
||||
layer_scale_init_value=0.0,
|
||||
use_qk_norm=True,
|
||||
qkv_bias=True,
|
||||
):
|
||||
super(TransformerEncoderLayer2, self).__init__()
|
||||
|
||||
self.embed_dims = embed_dims
|
||||
self.ln1 = nn.RMSNorm(self.embed_dims, eps=1e-6)
|
||||
self.attn = GroupedQueryAttention(
|
||||
embed_dims=embed_dims,
|
||||
num_heads=num_heads,
|
||||
num_kv_heads=num_kv_heads,
|
||||
attn_drop=attn_drop_rate,
|
||||
proj_drop=drop_rate,
|
||||
qkv_bias=qkv_bias,
|
||||
layer_scale_init_value=layer_scale_init_value,
|
||||
use_qk_norm=use_qk_norm,
|
||||
)
|
||||
|
||||
self.ln2 = nn.RMSNorm(self.embed_dims, eps=1e-6)
|
||||
self.ffn = SwiGLUFFN(
|
||||
embed_dims=embed_dims,
|
||||
feedforward_channels=feedforward_channels,
|
||||
)
|
||||
|
||||
@property
|
||||
def norm1(self):
|
||||
return self.ln1
|
||||
|
||||
@property
|
||||
def norm2(self):
|
||||
return self.ln2
|
||||
|
||||
def forward(self, x, rope=None):
|
||||
x = x + self.attn(self.ln1(x), rope=rope)
|
||||
x = self.ffn(self.ln2(x), identity=x)
|
||||
return x
|
||||
|
||||
|
||||
##-----------------------------------
|
||||
class Sapiens2(nn.Module):
|
||||
arch_zoo = {
|
||||
**dict.fromkeys(
|
||||
["sapiens2_0.1b"],
|
||||
{
|
||||
"embed_dims": 768,
|
||||
"num_layers": 12,
|
||||
"num_heads": 12,
|
||||
"feedforward_channels": 768 * 4,
|
||||
"num_tokenizer_layers": 2,
|
||||
},
|
||||
),
|
||||
**dict.fromkeys(
|
||||
["sapiens2_0.4b"],
|
||||
{
|
||||
"embed_dims": 1024,
|
||||
"num_layers": 24,
|
||||
"num_heads": 16,
|
||||
"feedforward_channels": 1024 * 4,
|
||||
"num_tokenizer_layers": 2,
|
||||
},
|
||||
),
|
||||
**dict.fromkeys(
|
||||
["sapiens2_0.8b"],
|
||||
{
|
||||
"embed_dims": 1280,
|
||||
"num_layers": 32,
|
||||
"num_heads": 16,
|
||||
"feedforward_channels": 1280 * 4,
|
||||
"num_tokenizer_layers": 3,
|
||||
},
|
||||
),
|
||||
**dict.fromkeys(
|
||||
["sapiens2_1b"],
|
||||
{
|
||||
"embed_dims": 1536,
|
||||
"num_layers": 40,
|
||||
"num_heads": 24,
|
||||
"feedforward_channels": 1536 * 4,
|
||||
"num_tokenizer_layers": 4,
|
||||
},
|
||||
),
|
||||
**dict.fromkeys(
|
||||
["sapiens2_5b"],
|
||||
{
|
||||
"embed_dims": 2432,
|
||||
"num_layers": 56,
|
||||
"num_heads": 32,
|
||||
"feedforward_channels": 2432 * 4,
|
||||
"num_tokenizer_layers": 6,
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
num_extra_tokens = 1 # class token
|
||||
OUT_TYPES = {"raw", "cls_token", "featmap"}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
arch="sapiens2_1b",
|
||||
img_size=(1024, 768),
|
||||
patch_size=16,
|
||||
in_channels=3,
|
||||
out_indices=-1,
|
||||
drop_rate=0.0,
|
||||
window_size=4,
|
||||
use_tokenizer=False, ## 4k resolution
|
||||
use_qk_norm=True,
|
||||
qkv_bias=True,
|
||||
final_norm=True,
|
||||
out_type="raw",
|
||||
with_cls_token=True,
|
||||
layer_scale_init_value=1e-4, ## non zero init to activate layerscale
|
||||
frozen_stages=-1,
|
||||
patch_cfg=dict(),
|
||||
layer_cfgs=dict(),
|
||||
pos_embed_rope_base: float = 100.0,
|
||||
pos_embed_rope_min_period: float | None = None,
|
||||
pos_embed_rope_max_period: float | None = None,
|
||||
pos_embed_rope_normalize_coords: Literal["min", "max", "separate"] = "separate",
|
||||
pos_embed_rope_shift_coords: float | None = None,
|
||||
pos_embed_rope_jitter_coords: float | None = None,
|
||||
pos_embed_rope_rescale_coords: float | None = None,
|
||||
pos_embed_rope_dtype: str = "bf16",
|
||||
n_storage_tokens: int = 8,
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
arch = arch.lower()
|
||||
assert arch in set(self.arch_zoo), (
|
||||
f"Arch {arch} is not in default archs {set(self.arch_zoo)}"
|
||||
)
|
||||
self.arch_settings = self.arch_zoo[arch]
|
||||
|
||||
self.embed_dims = self.arch_settings["embed_dims"]
|
||||
self.num_layers = self.arch_settings["num_layers"]
|
||||
self.patch_size = patch_size
|
||||
|
||||
self.window_size = window_size
|
||||
img_size = to_2tuple(img_size)
|
||||
encoder_img_size = (
|
||||
(img_size[0] // window_size, img_size[1] // window_size)
|
||||
if use_tokenizer
|
||||
else img_size
|
||||
)
|
||||
self.img_size = to_2tuple(encoder_img_size)
|
||||
|
||||
# Set patch embedding
|
||||
_patch_cfg = dict(
|
||||
in_channels=in_channels,
|
||||
input_size=self.img_size,
|
||||
embed_dims=self.embed_dims,
|
||||
kernel_size=patch_size,
|
||||
stride=patch_size,
|
||||
bias=True,
|
||||
)
|
||||
_patch_cfg.update(patch_cfg)
|
||||
self.patch_embed = PatchEmbed(**_patch_cfg)
|
||||
self.patch_resolution = self.patch_embed.init_out_size
|
||||
num_patches = self.patch_resolution[0] * self.patch_resolution[1]
|
||||
|
||||
self.rope_embed = RopePositionEmbedding(
|
||||
embed_dim=self.embed_dims,
|
||||
num_heads=self.arch_settings["num_heads"],
|
||||
base=pos_embed_rope_base,
|
||||
min_period=pos_embed_rope_min_period,
|
||||
max_period=pos_embed_rope_max_period,
|
||||
normalize_coords=pos_embed_rope_normalize_coords,
|
||||
shift_coords=pos_embed_rope_shift_coords,
|
||||
jitter_coords=pos_embed_rope_jitter_coords,
|
||||
rescale_coords=pos_embed_rope_rescale_coords,
|
||||
dtype=torch.bfloat16 if pos_embed_rope_dtype == "bf16" else torch.float32,
|
||||
)
|
||||
|
||||
# Set out type
|
||||
if out_type not in self.OUT_TYPES:
|
||||
raise ValueError(
|
||||
f"Unsupported `out_type` {out_type}, please "
|
||||
f"choose from {self.OUT_TYPES}"
|
||||
)
|
||||
self.out_type = out_type
|
||||
|
||||
if use_tokenizer == True:
|
||||
self.tokenizer = Tokenizer(
|
||||
embed_dims=self.embed_dims,
|
||||
window_size=self.window_size,
|
||||
num_heads=self.arch_settings["num_heads"],
|
||||
num_tokenizer_layers=self.arch_settings["num_tokenizer_layers"],
|
||||
qkv_bias=True,
|
||||
use_qk_norm=False,
|
||||
)
|
||||
else:
|
||||
self.tokenizer = None
|
||||
|
||||
# Set cls + storage tokens
|
||||
self.with_cls_token = with_cls_token
|
||||
if with_cls_token:
|
||||
self.cls_token = nn.Parameter(torch.zeros(1, 1, self.embed_dims))
|
||||
elif out_type != "cls_token":
|
||||
self.cls_token = None
|
||||
self.num_extra_tokens = 0
|
||||
else:
|
||||
raise ValueError('with_cls_token must be True when `out_type="cls_token"`.')
|
||||
|
||||
## registers
|
||||
self.n_storage_tokens = int(n_storage_tokens)
|
||||
self.storage_tokens = (
|
||||
nn.Parameter(torch.zeros(1, self.n_storage_tokens, self.embed_dims))
|
||||
if self.n_storage_tokens > 0
|
||||
else None
|
||||
)
|
||||
# how many non-patch tokens are at the front
|
||||
self.num_extra_tokens = (
|
||||
1 if self.cls_token is not None else 0
|
||||
) + self.n_storage_tokens
|
||||
|
||||
if isinstance(out_indices, int):
|
||||
out_indices = [out_indices]
|
||||
assert isinstance(out_indices, Sequence), (
|
||||
f'"out_indices" must by a sequence or int, get {type(out_indices)} instead.'
|
||||
)
|
||||
for i, index in enumerate(out_indices):
|
||||
if index < 0:
|
||||
out_indices[i] = self.num_layers + index
|
||||
assert 0 <= out_indices[i] <= self.num_layers, (
|
||||
f"Invalid out_indices {index}"
|
||||
)
|
||||
self.out_indices = out_indices
|
||||
|
||||
self.blocks = nn.Sequential()
|
||||
if isinstance(layer_cfgs, dict):
|
||||
layer_cfgs = [layer_cfgs] * self.num_layers
|
||||
|
||||
mhsa_early, mhsa_late = 8, 8
|
||||
for i in range(self.num_layers):
|
||||
if i < mhsa_early or i >= self.num_layers - mhsa_late:
|
||||
num_kv_heads = None ## use MHSA
|
||||
else:
|
||||
num_kv_heads = self.arch_settings["num_heads"] // 2 # Use GQA
|
||||
|
||||
_layer_cfg = dict(
|
||||
embed_dims=self.embed_dims,
|
||||
num_heads=self.arch_settings["num_heads"],
|
||||
num_kv_heads=num_kv_heads,
|
||||
feedforward_channels=self.arch_settings["feedforward_channels"],
|
||||
use_qk_norm=use_qk_norm,
|
||||
layer_scale_init_value=layer_scale_init_value,
|
||||
drop_rate=drop_rate,
|
||||
qkv_bias=qkv_bias,
|
||||
)
|
||||
_layer_cfg.update(layer_cfgs[i])
|
||||
self.blocks.append(TransformerEncoderLayer2(**_layer_cfg))
|
||||
|
||||
self.frozen_stages = frozen_stages
|
||||
|
||||
self.final_norm = final_norm
|
||||
if final_norm:
|
||||
self.ln1 = nn.RMSNorm(self.embed_dims, eps=1e-6)
|
||||
|
||||
# freeze stages only when self.frozen_stages > 0
|
||||
if self.frozen_stages > 0:
|
||||
self._freeze_stages()
|
||||
|
||||
## load init weights
|
||||
self.init_weights()
|
||||
|
||||
return
|
||||
|
||||
def init_weights(self):
|
||||
# Initialize class token and storagr token embeddings
|
||||
if self.with_cls_token:
|
||||
trunc_normal_(self.cls_token, std=0.02)
|
||||
|
||||
if self.storage_tokens is not None:
|
||||
trunc_normal_(self.storage_tokens, std=0.02)
|
||||
|
||||
# Apply custom initialization to all submodules
|
||||
self.apply(self._init_weights)
|
||||
|
||||
def _init_weights(self, m):
|
||||
if isinstance(m, nn.Linear):
|
||||
# Use a truncated normal distribution for linear layer weights
|
||||
trunc_normal_(m.weight, std=0.02)
|
||||
if m.bias is not None:
|
||||
nn.init.constant_(m.bias, 0)
|
||||
|
||||
elif isinstance(m, (nn.LayerNorm, nn.RMSNorm)):
|
||||
# Initialize normalization layers to act as an identity function
|
||||
if hasattr(m, "bias") and m.bias is not None:
|
||||
nn.init.constant_(m.bias, 0)
|
||||
if hasattr(m, "weight") and m.weight is not None:
|
||||
nn.init.constant_(m.weight, 1.0)
|
||||
|
||||
elif isinstance(m, nn.Conv2d):
|
||||
# Initialize conv layer weights like linear layers
|
||||
trunc_normal_(m.weight, std=0.02)
|
||||
if m.bias is not None:
|
||||
nn.init.constant_(m.bias, 0)
|
||||
|
||||
def _freeze_stages(self):
|
||||
## freeze tokenizer
|
||||
if self.frozen_stages >= 1 and self.tokenizer is not None:
|
||||
self.tokenizer.eval()
|
||||
for param in self.tokenizer.parameters():
|
||||
param.requires_grad = False
|
||||
|
||||
# freeze patch embedding
|
||||
self.patch_embed.eval()
|
||||
for param in self.patch_embed.parameters():
|
||||
param.requires_grad = False
|
||||
# freeze cls_token
|
||||
if self.cls_token is not None:
|
||||
self.cls_token.requires_grad = False
|
||||
if self.storage_tokens is not None:
|
||||
self.storage_tokens.requires_grad = False
|
||||
# freeze layers
|
||||
for i in range(1, self.frozen_stages + 1):
|
||||
m = self.blocks[i - 1]
|
||||
m.eval()
|
||||
for param in m.parameters():
|
||||
param.requires_grad = False
|
||||
|
||||
# freeze the last layer norm
|
||||
if self.frozen_stages == len(self.blocks):
|
||||
if self.final_norm:
|
||||
self.ln1.eval()
|
||||
for param in self.ln1.parameters():
|
||||
param.requires_grad = False
|
||||
|
||||
def forward(self, x):
|
||||
B = x.shape[0]
|
||||
|
||||
x, patch_resolution = self.patch_embed(x) # (B, 256*256, C)
|
||||
if self.tokenizer is not None:
|
||||
x, patch_resolution = self.tokenizer(x, patch_resolution)
|
||||
|
||||
# prepend [CLS] and storage tokens
|
||||
prepend = []
|
||||
if self.cls_token is not None:
|
||||
prepend.append(self.cls_token.expand(B, -1, -1))
|
||||
if self.storage_tokens is not None:
|
||||
prepend.append(self.storage_tokens.expand(B, -1, -1))
|
||||
if len(prepend) > 0:
|
||||
x = torch.cat(prepend + [x], dim=1)
|
||||
|
||||
rope_sincos = self.rope_embed(H=patch_resolution[0], W=patch_resolution[1])
|
||||
outs = []
|
||||
for i, layer in enumerate(self.blocks):
|
||||
x = layer(x, rope=rope_sincos)
|
||||
|
||||
if i == len(self.blocks) - 1 and self.final_norm:
|
||||
x = self.ln1(x)
|
||||
|
||||
if i in self.out_indices:
|
||||
outs.append(self._format_output(x, patch_resolution))
|
||||
|
||||
return tuple(outs)
|
||||
|
||||
def _format_output(self, x, hw):
|
||||
if self.out_type == "raw":
|
||||
return x
|
||||
if self.out_type == "cls_token":
|
||||
return x[:, 0]
|
||||
|
||||
patch_token = x[:, self.num_extra_tokens :]
|
||||
if self.out_type == "featmap":
|
||||
B = x.size(0)
|
||||
# (B, N, C) -> (B, H, W, C) -> (B, C, H, W)
|
||||
return patch_token.reshape(B, *hw, -1).permute(0, 3, 1, 2)
|
||||
|
||||
@property
|
||||
def norm1(self):
|
||||
return self.ln1
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
class LayerScale(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
dim: int,
|
||||
inplace: bool = False,
|
||||
data_format: str = "channels_last",
|
||||
scale: float = 1e-5,
|
||||
):
|
||||
super().__init__()
|
||||
assert data_format in (
|
||||
"channels_last",
|
||||
"channels_first",
|
||||
), "'data_format' could only be channels_last or channels_first."
|
||||
self.inplace = inplace
|
||||
self.data_format = data_format
|
||||
self.weight = nn.Parameter(torch.ones(dim) * scale)
|
||||
|
||||
def forward(self, x) -> torch.Tensor:
|
||||
if self.data_format == "channels_first":
|
||||
shape = tuple((1, -1, *(1 for _ in range(x.dim() - 2))))
|
||||
else:
|
||||
shape = tuple((*(1 for _ in range(x.dim() - 1)), -1))
|
||||
if self.inplace:
|
||||
return x.mul_(self.weight.view(*shape))
|
||||
else:
|
||||
return x * self.weight.view(*shape)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
class PatchEmbed(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
in_channels=3,
|
||||
embed_dims=768,
|
||||
kernel_size=16,
|
||||
stride=16,
|
||||
padding="corner",
|
||||
dilation=1,
|
||||
bias=True,
|
||||
input_size=None,
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
self.embed_dims = embed_dims
|
||||
if stride is None:
|
||||
stride = kernel_size
|
||||
|
||||
kernel_size = to_2tuple(kernel_size)
|
||||
stride = to_2tuple(stride)
|
||||
dilation = to_2tuple(dilation)
|
||||
padding = 0
|
||||
padding = to_2tuple(padding)
|
||||
|
||||
self.projection = nn.Conv2d(
|
||||
in_channels=in_channels,
|
||||
out_channels=embed_dims,
|
||||
kernel_size=kernel_size,
|
||||
stride=stride,
|
||||
padding=padding,
|
||||
dilation=dilation,
|
||||
bias=bias,
|
||||
)
|
||||
|
||||
if input_size:
|
||||
input_size = to_2tuple(input_size)
|
||||
self.init_input_size = input_size
|
||||
h_out = (
|
||||
input_size[0] + 2 * padding[0] - dilation[0] * (kernel_size[0] - 1) - 1
|
||||
) // stride[0] + 1
|
||||
w_out = (
|
||||
input_size[1] + 2 * padding[1] - dilation[1] * (kernel_size[1] - 1) - 1
|
||||
) // stride[1] + 1
|
||||
self.init_out_size = (h_out, w_out)
|
||||
else:
|
||||
self.init_input_size = None
|
||||
self.init_out_size = None
|
||||
|
||||
def forward(self, x):
|
||||
x = self.projection(x)
|
||||
out_size = (x.shape[2], x.shape[3])
|
||||
x = x.flatten(2).transpose(1, 2)
|
||||
return x, out_size
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
class SwiGLUFFN(nn.Module):
|
||||
"""SwiGLU FFN layer.
|
||||
https://github.com/facebookresearch/dinov2/blob/main/dinov2/layers/swiglu_ffn.py
|
||||
""" # noqa
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
embed_dims: int,
|
||||
feedforward_channels: Optional[int] = None,
|
||||
out_dims: Optional[int] = None,
|
||||
layer_scale_init_value: float = 0.0,
|
||||
bias: bool = True,
|
||||
add_identity: bool = True,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.embed_dims = embed_dims
|
||||
self.out_dims = out_dims or embed_dims
|
||||
hidden_dims = feedforward_channels or embed_dims
|
||||
|
||||
self.w12 = nn.Linear(self.embed_dims, 2 * hidden_dims, bias=bias)
|
||||
self.w3 = nn.Linear(hidden_dims, self.out_dims, bias=bias)
|
||||
|
||||
if layer_scale_init_value > 0:
|
||||
self.gamma2 = LayerScale(dim=embed_dims, scale=layer_scale_init_value)
|
||||
else:
|
||||
self.gamma2 = nn.Identity()
|
||||
|
||||
self.add_identity = add_identity
|
||||
|
||||
def forward(
|
||||
self, x: torch.Tensor, identity: Optional[torch.Tensor] = None
|
||||
) -> torch.Tensor:
|
||||
x12 = self.w12(x)
|
||||
x1, x2 = x12.chunk(2, dim=-1)
|
||||
hidden = F.silu(x1) * x2
|
||||
out = self.w3(hidden)
|
||||
out = self.gamma2(out)
|
||||
|
||||
if self.out_dims != self.embed_dims or not self.add_identity:
|
||||
# due to the dimension inconsistence or user setting
|
||||
# not to apply residual operation
|
||||
return out
|
||||
|
||||
if identity is None:
|
||||
identity = x
|
||||
return identity + out
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Sapiens2 body-part segmentation (Meta, April 2026) for Body Segment (NH).
|
||||
|
||||
The model labels every pixel of a person with one of 28 body parts and clothes (plus background), predicted at
|
||||
1024 x 768. The model code is Meta's standalone file, vendored in core/sapiens2/ with its licence (the Sapiens2
|
||||
License: royalty-free and commercial use allowed, with prohibited uses that include surveillance, biometric processing,
|
||||
identifying people, deepfakes and inferring sensitive data; see LICENSE.md there and THIRD_PARTY_NOTICES.md). The
|
||||
weights download once from Hugging Face, pinned to a fixed revision, into the ``nh_nodes`` model folder
|
||||
(models/NH-Nodes/sapiens2-seg-<size>/ by default), like the SegFormer parsers. It needs PyTorch 2.4 or newer.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
|
||||
from .model_files import download_pinned, make_room_on_gpu, register_model_folder
|
||||
from .segformer_backend import FOLDER_EXTENSIONS, FOLDER_NAME, default_folder
|
||||
|
||||
CLASSES = (
|
||||
"background", "apparel", "eyeglass", "face_neck", "hair", "left_foot", "left_hand", "left_lower_arm",
|
||||
"left_lower_leg", "left_shoe", "left_sock", "left_upper_arm", "left_upper_leg", "lower_clothing", "right_foot",
|
||||
"right_hand", "right_lower_arm", "right_lower_leg", "right_shoe", "right_sock", "right_upper_arm",
|
||||
"right_upper_leg", "torso", "upper_clothing", "lower_lip", "upper_lip", "lower_teeth", "upper_teeth", "tongue",
|
||||
)
|
||||
INPUT_SIZE = (1024, 768) # height, width the model was trained on (3:4)
|
||||
MODELS = {
|
||||
"0.4b": {"repo_id": "facebook/sapiens2-seg-0.4b", "revision": "449b3c5335e6722bb94990abdd1aa6e612432f22",
|
||||
"file": "sapiens2_0.4b_seg.safetensors", "arch": "sapiens2_0.4b", "dims": 1024},
|
||||
"0.8b": {"repo_id": "facebook/sapiens2-seg-0.8b", "revision": "196a627b928676c4429b738ed76f78a21d96c4eb",
|
||||
"file": "sapiens2_0.8b_seg.safetensors", "arch": "sapiens2_0.8b", "dims": 1280},
|
||||
"1b": {"repo_id": "facebook/sapiens2-seg-1b", "revision": "60cf0b3584d68dccca9adb3b59677b72a5611378",
|
||||
"file": "sapiens2_1b_seg.safetensors", "arch": "sapiens2_1b", "dims": 1536},
|
||||
}
|
||||
_MEAN = (123.675, 116.28, 103.53) # ImageNet, RGB on 0..255
|
||||
_STD = (58.395, 57.12, 57.375)
|
||||
_MAX_LOGIT_SIDE = 2048 # logits are resized at most to this size before argmax, like the SegFormer parsers
|
||||
|
||||
_LOADED = {}
|
||||
_said_cpu = False
|
||||
|
||||
|
||||
class SegHead(nn.Module):
|
||||
"""Meta's SegHead at inference: four 2x deconvolutions (16x, so 64 x 48 patches give 1024 x 768) and two 1x1
|
||||
convolutions, then one score per class. Layer names match the released checkpoints."""
|
||||
|
||||
def __init__(self, in_channels, deconv_channels=(512, 256, 128, 64), conv_channels=(64, 64), classes=29):
|
||||
super().__init__()
|
||||
layers = []
|
||||
for channels in deconv_channels:
|
||||
layers += [nn.ConvTranspose2d(in_channels, channels, 4, stride=2, padding=1, bias=False),
|
||||
nn.InstanceNorm2d(channels), nn.SiLU(inplace=True)]
|
||||
in_channels = channels
|
||||
self.deconv_layers = nn.Sequential(*layers)
|
||||
layers = []
|
||||
for channels in conv_channels:
|
||||
layers += [nn.Conv2d(in_channels, channels, 1), nn.InstanceNorm2d(channels), nn.SiLU(inplace=True)]
|
||||
in_channels = channels
|
||||
self.conv_layers = nn.Sequential(*layers)
|
||||
self.conv_seg = nn.Conv2d(in_channels, classes, 1)
|
||||
|
||||
def forward(self, features):
|
||||
return self.conv_seg(self.conv_layers(self.deconv_layers(features)))
|
||||
|
||||
|
||||
class Sapiens2Seg(nn.Module):
|
||||
def __init__(self, arch, dims):
|
||||
super().__init__()
|
||||
from .sapiens2.backbone import Sapiens2
|
||||
|
||||
self.backbone = Sapiens2(arch=arch, img_size=INPUT_SIZE, patch_size=16, final_norm=True, use_tokenizer=False,
|
||||
with_cls_token=True, out_type="featmap")
|
||||
self.decode_head = SegHead(dims, classes=len(CLASSES))
|
||||
|
||||
def forward(self, pixels):
|
||||
return self.decode_head(self.backbone(pixels)[0])
|
||||
|
||||
|
||||
def model_path(size, download=True):
|
||||
"""Path of the weights for ``size`` from any registered ``nh_nodes`` folder; downloaded if absent."""
|
||||
spec = MODELS[size]
|
||||
folder = f"sapiens2-seg-{size}"
|
||||
for base in register_model_folder(FOLDER_NAME, default_folder(), FOLDER_EXTENSIONS):
|
||||
candidate = os.path.join(base, folder, spec["file"])
|
||||
if os.path.isfile(candidate):
|
||||
return candidate
|
||||
if not download:
|
||||
return None
|
||||
target = os.path.join(default_folder(), folder)
|
||||
download_pinned(spec["repo_id"], spec["revision"], target, (spec["file"],))
|
||||
path = os.path.join(target, spec["file"])
|
||||
if not os.path.isfile(path):
|
||||
raise RuntimeError(f"[NH-Nodes] Sapiens2 weights missing after download: {path}")
|
||||
return path
|
||||
|
||||
|
||||
def torch_device(name):
|
||||
"""The torch device for the node's ``device`` choice; "cuda" without a CUDA GPU falls back to the CPU (said once)."""
|
||||
global _said_cpu
|
||||
if name == "cuda" and torch.cuda.is_available():
|
||||
return torch.device("cuda")
|
||||
if name == "cuda" and not _said_cpu:
|
||||
_said_cpu = True
|
||||
print("[NH-Nodes] CUDA not available, Body Segment runs on the CPU (slow)")
|
||||
return torch.device("cpu")
|
||||
|
||||
|
||||
def _native_bf16(device):
|
||||
if device.type != "cuda":
|
||||
return False
|
||||
try:
|
||||
return torch.cuda.is_bf16_supported(including_emulation=False)
|
||||
except TypeError: # PyTorch before 2.3 has no including_emulation
|
||||
return torch.cuda.get_device_capability(device)[0] >= 8
|
||||
|
||||
|
||||
def load(size, device):
|
||||
"""The model for ``size`` ("0.4b", "0.8b" or "1b") with the device it runs on; read from disk once and kept in
|
||||
system memory, one size at a time. Built without random initialisation: the layers are created empty and take the
|
||||
checkpoint's tensors, which a mismatching file fails loudly."""
|
||||
from safetensors.torch import load_file
|
||||
|
||||
if not hasattr(nn, "RMSNorm"):
|
||||
raise RuntimeError(f"[NH-Nodes] Body Segment needs PyTorch 2.4 or newer (this is {torch.__version__}).")
|
||||
bundle = _LOADED.get(size)
|
||||
if bundle is None:
|
||||
_LOADED.clear() # the 1b alone keeps 3 to 6 GB of system memory
|
||||
spec = MODELS[size]
|
||||
path = model_path(size)
|
||||
print(f"[NH-Nodes] Loading Sapiens2 {size} from {path}")
|
||||
try:
|
||||
state = load_file(path)
|
||||
except Exception as exc: # noqa: BLE001 - a damaged or cut-short download
|
||||
raise RuntimeError(f"[NH-Nodes] {path} cannot be read ({exc}); delete it so it is downloaded again.") \
|
||||
from None
|
||||
# own copies, in bfloat16 on GPUs that compute it natively (what it runs in there; half the system memory):
|
||||
# a tensor left on the file's memory map keeps the file open, and Windows then cannot replace or delete it
|
||||
dtype = torch.bfloat16 if _native_bf16(device) else None
|
||||
state = {name: tensor.to(dtype) if dtype is not None and tensor.is_floating_point() and tensor.dtype != dtype
|
||||
else tensor.clone() for name, tensor in state.items()}
|
||||
with torch.device("meta"):
|
||||
model = Sapiens2Seg(spec["arch"], spec["dims"])
|
||||
try:
|
||||
model.load_state_dict(state, strict=True, assign=True)
|
||||
except RuntimeError as exc:
|
||||
raise RuntimeError(f"[NH-Nodes] {path} is not the Sapiens2 {size} segmentation checkpoint; delete it so "
|
||||
f"the right one is downloaded.\n{exc}") from None
|
||||
model.eval().requires_grad_(False)
|
||||
bundle = {"model": model, "size": size}
|
||||
_LOADED[size] = bundle
|
||||
bundle["device"] = device
|
||||
return bundle
|
||||
|
||||
|
||||
def _ensure_on_device(bundle):
|
||||
"""Move the model to its device, in bfloat16 on GPUs that compute it natively and in float32 elsewhere."""
|
||||
model, device = bundle["model"], bundle["device"]
|
||||
dtype = torch.bfloat16 if _native_bf16(device) else torch.float32
|
||||
current = next(model.parameters())
|
||||
if (current.device.type == device.type and (device.index is None or current.device.index == device.index)
|
||||
and current.dtype == dtype):
|
||||
return
|
||||
if device.type == "cuda":
|
||||
make_room_on_gpu(sum(p.numel() for p in model.parameters()) * dtype.itemsize * 2)
|
||||
model.to(device=device, dtype=dtype)
|
||||
|
||||
|
||||
def offload(bundle):
|
||||
"""Weights back to system memory, so they never hold VRAM between runs."""
|
||||
model = bundle["model"]
|
||||
if next(model.parameters()).device.type != "cpu":
|
||||
model.cpu()
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
|
||||
def predict_labels(bundle, image, confidence=False):
|
||||
"""Class index (into CLASSES) of every pixel of the uint8 RGB ``image``, at its size; with ``confidence`` also the
|
||||
float16 probability of each pixel's class, to merge crops by how sure each one is.
|
||||
|
||||
The picture is resized to 768 x 1024 as the model was trained (callers hand it crops of that 3:4 shape, see
|
||||
parse_crops.people_labels), so a crop gets the model's full resolution.
|
||||
"""
|
||||
_ensure_on_device(bundle)
|
||||
model = bundle["model"]
|
||||
parameter = next(model.parameters())
|
||||
device, dtype = parameter.device, parameter.dtype
|
||||
height, width = image.shape[:2]
|
||||
target_h, target_w = INPUT_SIZE
|
||||
shrink = height > target_h or width > target_w
|
||||
small = cv2.resize(image, (target_w, target_h), interpolation=cv2.INTER_AREA if shrink else cv2.INTER_LINEAR)
|
||||
pixels = torch.from_numpy(np.ascontiguousarray(small)).to(device).permute(2, 0, 1).float()
|
||||
mean = torch.tensor(_MEAN, device=device)[:, None, None]
|
||||
std = torch.tensor(_STD, device=device)[:, None, None]
|
||||
pixels = ((pixels - mean) / std)[None].to(dtype)
|
||||
scale = min(1.0, _MAX_LOGIT_SIDE / max(height, width))
|
||||
size = (max(1, round(height * scale)), max(1, round(width * scale)))
|
||||
with torch.inference_mode():
|
||||
logits = model(pixels).float()
|
||||
logits = F.interpolate(logits, size=size, mode="bilinear", align_corners=False)
|
||||
best, labels = logits.softmax(dim=1)[0].max(dim=0)
|
||||
labels = labels.to(torch.int32).cpu().numpy()
|
||||
best = best.cpu().numpy() if confidence else None
|
||||
if labels.shape != (height, width):
|
||||
labels = cv2.resize(labels, (width, height), interpolation=cv2.INTER_NEAREST)
|
||||
if best is not None:
|
||||
best = cv2.resize(best, (width, height), interpolation=cv2.INTER_LINEAR)
|
||||
if best is not None:
|
||||
return labels.astype(np.int64), best.astype(np.float16)
|
||||
return labels.astype(np.int64)
|
||||
@@ -50,6 +50,14 @@ MODEL_SPECS = {
|
||||
"legacy_dirs": ("face_parsing", "RMBG/segformer_face"),
|
||||
"signature": {1: "skin", 13: "hair", 17: "neck"},
|
||||
},
|
||||
# FASHN Human Parser: SegFormer-B4 for fashion, 18 classes (clothes, accessories and body parts), 576 x 384
|
||||
"fashn_human_parser": {
|
||||
"repo_id": "fashn-ai/fashn-human-parser",
|
||||
"revision": "1f80c34dbab321c5730dda5c3fea279fd3e97498",
|
||||
"legacy_dirs": (),
|
||||
"signature": {3: "top", 8: "bag", 17: "jewelry"},
|
||||
"area_resize": True, # trained on pictures brought to 384 x 576 with cv2.INTER_AREA
|
||||
},
|
||||
}
|
||||
|
||||
_LOADED = {}
|
||||
@@ -141,7 +149,7 @@ def load_segformer(model_name, device="cuda"):
|
||||
param.requires_grad = False
|
||||
id2label = {int(k): v for k, v in model.config.id2label.items()}
|
||||
_check_signature(model_name, id2label)
|
||||
bundle = {"processor": processor, "model": model, "id2label": id2label}
|
||||
bundle = {"processor": processor, "model": model, "id2label": id2label, "name": model_name}
|
||||
_LOADED[model_name] = bundle
|
||||
bundle["device"] = target_device
|
||||
return bundle
|
||||
@@ -183,11 +191,14 @@ def predict_labels(bundle, image_uint8, confidence=False):
|
||||
logit_size = (max(1, round(height * scale)), max(1, round(width * scale)))
|
||||
|
||||
# The processor squeezes the picture to its fixed size anyway; doing that here first, with area averaging, is
|
||||
# many times faster on a big photo than its own resize and keeps fine detail from aliasing.
|
||||
# many times faster on a big photo than its own resize and keeps fine detail from aliasing. A model trained on
|
||||
# area-averaged pictures gets them at every size.
|
||||
size = getattr(processor, "size", None)
|
||||
if isinstance(size, dict) and "height" in size and "width" in size and (
|
||||
height > 2 * size["height"] or width > 2 * size["width"]):
|
||||
image_uint8 = cv2.resize(image_uint8, (size["width"], size["height"]), interpolation=cv2.INTER_AREA)
|
||||
if isinstance(size, dict) and "height" in size and "width" in size:
|
||||
target = (size["width"], size["height"])
|
||||
area = MODEL_SPECS.get(bundle.get("name"), {}).get("area_resize") and (width, height) != target
|
||||
if area or height > 2 * size["height"] or width > 2 * size["width"]:
|
||||
image_uint8 = cv2.resize(image_uint8, target, interpolation=cv2.INTER_AREA)
|
||||
inputs = processor(images=image_uint8, return_tensors="pt")
|
||||
inputs = {k: v.to(model.device) for k, v in inputs.items()}
|
||||
with torch.no_grad():
|
||||
|
||||
@@ -9,8 +9,9 @@ Plain torch/numpy, no model: the select, compose, preview and convert nodes and
|
||||
|
||||
import fnmatch
|
||||
import functools
|
||||
import unicodedata
|
||||
from collections import namedtuple
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -50,14 +51,16 @@ class Segment:
|
||||
|
||||
@dataclass(frozen=True, eq=False)
|
||||
class SegsFrame:
|
||||
"""The instances found in one image of ``height`` x ``width`` pixels, in the order they were found."""
|
||||
"""The instances found in one image of ``height`` x ``width`` pixels, in the order they were found. ``picture`` is
|
||||
a small grey print of that image (see picture_print), or None when the node that found them kept none."""
|
||||
|
||||
height: int
|
||||
width: int
|
||||
segments: tuple = ()
|
||||
picture: object = None
|
||||
|
||||
def with_segments(self, segments):
|
||||
return SegsFrame(self.height, self.width, tuple(segments))
|
||||
return SegsFrame(self.height, self.width, tuple(segments), self.picture)
|
||||
|
||||
def _comfy_cache_tensors(self):
|
||||
"""The tensors this frame holds, so ComfyUI's RAM-pressure cache can size it."""
|
||||
@@ -133,6 +136,65 @@ def instance_masks(segs):
|
||||
return masks, labels
|
||||
|
||||
|
||||
PRINT_SIDE = 32 # a frame keeps a PRINT_SIDE x PRINT_SIDE grey print of its picture
|
||||
# Least correlation between two prints of one picture. Tone, colour and blur edits keep it high (a strong
|
||||
# brightening that clips the highlights: 0.64, contrast 0.85, blur 0.87, on 230 photos), and most other photos of the
|
||||
# same size fall below it (36 of 40). It is a guard against wiring mistakes, not a proof: a copy shifted or zoomed a
|
||||
# little usually passes, and a new background behind the same person can fail (Segs Compose can turn it off).
|
||||
SAME_PICTURE = 0.5
|
||||
|
||||
|
||||
def picture_print(image):
|
||||
"""PRINT_SIDE x PRINT_SIDE float32 grey print of one picture (a ComfyUI [H, W, C] image or an HxWxC array): what
|
||||
a frame keeps to tell two photos of the same size apart."""
|
||||
pixels = image.detach().cpu().float().numpy() if torch.is_tensor(image) else np.asarray(image)
|
||||
if pixels.dtype not in (np.uint8, np.uint16, np.float32):
|
||||
pixels = pixels.astype(np.float32) # cv2 has no half precision
|
||||
small = cv2.resize(np.ascontiguousarray(pixels), (PRINT_SIDE, PRINT_SIDE), interpolation=cv2.INTER_AREA)
|
||||
small = np.nan_to_num(small.astype(np.float32), nan=0.0, posinf=1.0, neginf=0.0) # a broken pixel, not a photo
|
||||
return small[..., :3].mean(axis=2) if small.ndim == 3 else small
|
||||
|
||||
|
||||
def same_picture(a, b):
|
||||
"""Whether two picture prints come from the same photo (see SAME_PICTURE). A flat print (a blank picture) tells
|
||||
nothing, so it never refuses (flat up to the rounding of the area averaging)."""
|
||||
if any(float(p.std()) <= 1e-4 * float(np.abs(p).max()) for p in (a, b)):
|
||||
return True
|
||||
a, b = a - a.mean(), b - b.mean()
|
||||
return float((a * b).sum()) / float(np.sqrt((a * a).sum() * (b * b).sum())) >= SAME_PICTURE
|
||||
|
||||
|
||||
def stamped(segs, image):
|
||||
"""``segs`` with each frame keeping the print of its picture of the batch ``image`` (left as they are when the
|
||||
counts differ, so no frame is ever lost)."""
|
||||
if len(segs) != len(image):
|
||||
return tuple(segs)
|
||||
return tuple(replace(frame, picture=picture_print(picture)) for frame, picture in zip(segs, image))
|
||||
|
||||
|
||||
def merge_segs(*all_segs, check_prints=True):
|
||||
"""One NH_SEGS from several of the same pictures (None entries are skipped): each frame gets the instances of
|
||||
all of them, in order. Raises when they do not belong to the same pictures: another count or size of images, or
|
||||
(with ``check_prints``) the prints of different photos."""
|
||||
present = [segs for segs in all_segs if segs is not None]
|
||||
first = present[0]
|
||||
for other in present[1:]:
|
||||
if len(other) != len(first) or any((a.height, a.width) != (b.height, b.width) for a, b in zip(first, other)):
|
||||
raise ValueError("[NH-Nodes] The segs to mix come from different pictures: connect segs found on the same "
|
||||
"image batch.")
|
||||
for index in range(len(first) if check_prints else 0):
|
||||
prints = [segs[index].picture for segs in present if segs[index].picture is not None]
|
||||
if not all(same_picture(prints[0], other) for other in prints[1:]):
|
||||
which = f" (image {index + 1} of the batch)" if len(first) > 1 else ""
|
||||
raise ValueError(f"[NH-Nodes] The segs to mix look like different photos of the same size{which}. "
|
||||
"Connect segs found on the same image; if they are edits of one photo (a new "
|
||||
"background, a relight), turn check_same_photo off.")
|
||||
if len(present) == 1:
|
||||
return first
|
||||
return tuple(frame.with_segments([seg for segs in present for seg in segs[index].segments])
|
||||
for index, frame in enumerate(first))
|
||||
|
||||
|
||||
def count_segments(segs):
|
||||
return sum(len(frame.segments) for frame in segs)
|
||||
|
||||
@@ -141,13 +203,80 @@ def count_segments(segs):
|
||||
# Labels
|
||||
# ---------------------------------------------------------------------------
|
||||
def normalize_label(text):
|
||||
"""Lower case, '_' as a space, single spaces: "Red_Car " and "red car" both become "red car"."""
|
||||
return " ".join(str(text).replace("_", " ").lower().split())
|
||||
"""Lower case, '_' as a space, single spaces, accents in one form (keyboards differ): "Red_Car " and "red car"
|
||||
both become "red car"."""
|
||||
return " ".join(unicodedata.normalize("NFC", str(text)).replace("_", " ").lower().split())
|
||||
|
||||
|
||||
# Plurals the usual rules miss (see _singular), and "glasses" kept apart from a drinking glass
|
||||
_IRREGULAR = {"feet": "foot", "teeth": "tooth", "people": "person", "children": "child", "men": "man",
|
||||
"women": "woman", "mice": "mouse", "geese": "goose",
|
||||
"leaves": "leaf", "knives": "knife", "shelves": "shelf", "calves": "calf", "halves": "half",
|
||||
"scarves": "scarf", "wolves": "wolf", "wives": "wife", "loaves": "loaf", "thieves": "thief",
|
||||
"hoodies": "hoodie", "movies": "movie", "cookies": "cookie", "selfies": "selfie", "brownies": "brownie",
|
||||
"zombies": "zombie", "pies": "pie", "ties": "tie", "glasses": "glasses"}
|
||||
|
||||
|
||||
def _singular(word):
|
||||
"""The singular of one English word by the usual rules: arms -> arm, glasses -> glass, accessories -> accessory."""
|
||||
if word in _IRREGULAR:
|
||||
return _IRREGULAR[word]
|
||||
if len(word) > 4 and word.endswith("ies"):
|
||||
return word[:-3] + "y"
|
||||
if word.endswith(("sses", "shes", "ches", "xes", "zes")):
|
||||
return word[:-2]
|
||||
if len(word) > 3 and word.endswith("s") and not word.endswith(("ss", "us", "is")):
|
||||
return word[:-1]
|
||||
return word
|
||||
|
||||
|
||||
def fold_label(text):
|
||||
"""A label in lower case with every word made singular, so "Arms" and "arm" are the same label."""
|
||||
return " ".join(_singular(word) for word in normalize_label(text).split())
|
||||
|
||||
|
||||
def label_matches(label, pattern):
|
||||
"""True when ``label`` matches ``pattern``, ignoring case; the pattern may use * and ? wildcards."""
|
||||
return fnmatch.fnmatchcase(normalize_label(label), normalize_label(pattern))
|
||||
"""True when ``label`` matches ``pattern``, ignoring case and singular or plural ("arm" finds "arms"); a pattern
|
||||
with * and ? wildcards is matched as written."""
|
||||
label, pattern = normalize_label(label), normalize_label(pattern)
|
||||
if fnmatch.fnmatchcase(label, pattern):
|
||||
return True
|
||||
return not any(ch in pattern for ch in "*?[") and fold_label(label) == fold_label(pattern)
|
||||
|
||||
|
||||
_ALIASES = {} # (source of the instances, folded word) -> folded labels of that source's parts the word stands for
|
||||
|
||||
|
||||
def add_aliases(source, words, parts):
|
||||
"""Let a node's own words (word -> class ids) name the parts of the segs it makes (part label -> class ids, with
|
||||
``source``): a word stands for the parts whose classes it covers. So Segs Compose and Segs Select read "skin" on
|
||||
Body Segment's segs as its face, torso, arms... parts. A word that is the name of one of the node's parts is left
|
||||
out: the part keeps its name, whatever else a picture holds."""
|
||||
named = {fold_label(part) for part in parts}
|
||||
for word, ids in words.items():
|
||||
labels = frozenset(fold_label(part) for part, part_ids in parts.items() if part_ids and set(part_ids) <= set(ids))
|
||||
if labels and fold_label(word) not in named:
|
||||
_ALIASES[(source, fold_label(word))] = labels
|
||||
|
||||
|
||||
def matching(segments, pattern, written=None):
|
||||
"""The instances a label pattern names, node by node (the instances one source made): that node's instances
|
||||
carrying the label (see label_matches), or when none does, the parts the pattern stands for among that node's own
|
||||
words (see add_aliases). ``written`` is the Vietnamese word the pattern was translated from, which a node may read
|
||||
its own way (Body Segment's "tay" is the arm with the hand, not the arm alone)."""
|
||||
if any(ch in str(pattern) for ch in "*?["):
|
||||
return [seg for seg in segments if label_matches(seg.label, pattern)]
|
||||
word, said = fold_label(pattern), fold_label(written) if written else None
|
||||
kept = set()
|
||||
for source in dict.fromkeys(seg.source for seg in segments):
|
||||
own = [seg for seg in segments if seg.source == source]
|
||||
parts = _ALIASES.get((source, said)) if said else None
|
||||
hits = [] if parts else [seg for seg in own if label_matches(seg.label, pattern)]
|
||||
if not hits:
|
||||
parts = parts or _ALIASES.get((source, word), ())
|
||||
hits = [seg for seg in own if fold_label(seg.label) in parts]
|
||||
kept.update(id(seg) for seg in hits)
|
||||
return [seg for seg in segments if id(seg) in kept]
|
||||
|
||||
|
||||
def parse_label_list(text):
|
||||
@@ -204,9 +333,10 @@ def select_segments(frame, labels=(), min_score=0.0, min_area=0.0, max_area=1.0,
|
||||
keeps every segment from ``start`` on.
|
||||
"""
|
||||
total = max(1, frame.height * frame.width)
|
||||
named = {id(seg) for pattern in labels for seg in matching(frame.segments, pattern)}
|
||||
kept = []
|
||||
for seg in frame.segments:
|
||||
if labels and not any(label_matches(seg.label, pattern) for pattern in labels):
|
||||
if labels and id(seg) not in named:
|
||||
continue
|
||||
if seg.score < min_score:
|
||||
continue
|
||||
@@ -461,7 +591,7 @@ def evaluate(tree, frame):
|
||||
"""
|
||||
kind = tree[0]
|
||||
if kind == "label":
|
||||
return frame_mask(frame, [seg for seg in frame.segments if label_matches(seg.label, tree[1])])
|
||||
return frame_mask(frame, matching(frame.segments, tree[1], tree[2] if tree[3] == "translated" else None))
|
||||
if kind == "keyword":
|
||||
if tree[1] == "all":
|
||||
return frame_mask(frame)
|
||||
@@ -613,7 +743,7 @@ def scale_frame(frame, scale):
|
||||
if not small.any():
|
||||
small[small.shape[0] // 2, small.shape[1] // 2] = True
|
||||
segments.append(make_segment(seg.label, seg.score, small, (nx1, ny1), seg.source))
|
||||
return SegsFrame(height, width, tuple(segments))
|
||||
return SegsFrame(height, width, tuple(segments), frame.picture)
|
||||
|
||||
|
||||
def _caption(picture, notes):
|
||||
@@ -679,7 +809,7 @@ def describe_request(tree, segs, threshold=None, misses=None, added=()):
|
||||
instances = [seg for frame in segs for seg in frame.segments]
|
||||
lines, seen = [], set()
|
||||
for written, english, status in expression_terms(tree):
|
||||
key = (status == "keyword", normalize_label(english))
|
||||
key = (status == "keyword", fold_label(english))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
|
||||
@@ -25,7 +25,11 @@ NOUNS = {
|
||||
"mũi": "nose", "miệng": "mouth", "môi": "lips", "đôi môi": "lips", "răng": "teeth", "lưỡi": "tongue",
|
||||
"tai": "ear", "cổ": "neck", "vai": "shoulder", "ngực": "chest", "bụng": "belly",
|
||||
"tay": "arm", "cánh tay": "arm", "bàn tay": "hand", "ngón tay": "finger", "móng tay": "fingernail",
|
||||
"đôi tay": "arm", "hai tay": "arm", "tay trái": "left arm", "tay phải": "right arm", "bắp tay": "upper arm",
|
||||
"cẳng tay": "forearm",
|
||||
"chân": "leg", "đôi chân": "leg", "bàn chân": "foot", "móng chân": "toenail",
|
||||
"hai chân": "leg", "chân trái": "left leg", "chân phải": "right leg", "đùi": "thigh", "bắp đùi": "thigh",
|
||||
"bắp chân": "calf", "cẳng chân": "lower leg",
|
||||
"râu": "beard", "ria mép": "mustache",
|
||||
# clothes and accessories
|
||||
"quần áo": "clothes", "trang phục": "outfit", "áo": "top", "áo sơ mi": "shirt", "áo thun": "t-shirt",
|
||||
|
||||
@@ -3,6 +3,68 @@
|
||||
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.
|
||||
|
||||
### Added
|
||||
- `Body Segment (NH)` (NH-Nodes → Vision) masks the parts of the people in a picture that you name. Sapiens2 (Meta, 2026) labels every pixel of each person with one of 28 classes (face and neck, hair, torso, arms, hands, legs, feet, lips, teeth, tongue, glasses, upper and lower clothes, shoes, socks, accessories) or as background. It parses people like `Garment Segment (NH)` does: the whole picture first, then crops of the model's 3:4 shape around each person or group, parsed at 1024 × 768 (a person gets about 1024 px whatever the size of the photo), YuNet faces help to find people, and the work is done on a copy of at most 4096 px that is enlarged at the end.
|
||||
- `Body Segment (NH)` reads `regions` in the formula language of `SAM3 Segment (NH)`: commas or new lines list several words, `+` and `|` join, `-` removes, `&` keeps the overlap, `~` inverts, parentheses group, and `background` (everything outside the people), `all`, `image` and `none` are keywords: `skin`, `hair, face`, `clothes - shoes`, `~person`, `skin - hands`. `and`, `or` and `not` are not operators here: they are refused with advice. The words are English (`skin`, `hair`, `face`, `arms`, `hands`, `legs`, `feet`, `torso`, `lips`, `teeth`, `tongue`, `mouth`, `glasses`, `clothes`, `top`, `bottom`, `shoes`, `socks`, `accessories`, `person`, with plurals, synonyms and `left` / `right` forms) or the Vietnamese words of the shared word list (`da`, `tóc`, `mặt`, `môi`, `răng`, `áo`, `quần`, `giày`…). In Vietnamese, `tay`, `đôi tay`, `hai tay` and `chân`, `đôi chân`, `hai chân` are the whole limb with the hand and the foot, `tay trái`, `tay phải`, `chân trái` and `chân phải` the whole limb of one side, and `cánh tay`, `bàn tay`, `bàn chân`, `bắp tay`, `cẳng tay`, `đùi`, `bắp chân` and `cẳng chân` the parts. Singular and plural are the same word (`lower leg` or `lower legs`). A word that has no region (`eyes`, `hat`, `bag`, `shoulders`, a wildcard, a Vietnamese word that is not in the list…) is refused with an error before the model loads, and the error says what to write instead.
|
||||
- `Body Segment (NH)` returns `mask`, `cutout_rgba`, `mask_image`, `segs` and `report`. `segs` holds one instance per part found: hair, face, torso, arms, hands, legs, feet, lips, teeth, tongue, glasses, top, bottom, shoes, socks, accessories. `report` has one line per word with its share of the picture ("under 0.1%" for a tiny share, "of the pictures" for a batch), then notes where Sapiens2 differs from everyday use, and a line "no person found (none Sapiens2 was sure of, and no face)" for a picture with nobody in it (in a batch, "... in image(s) 2, 3"). The preview says it too. `tay - cánh tay` leaves the hands: the whole arm minus the arm alone. The node shows its result on itself (new optional input `show_preview`, on by default: the mask bright and the rest darkened, the parts it keeps outlined and labelled, and the report lines below; at most 16 pictures) and is an output node, so ComfyUI runs it even when nothing is connected to it.
|
||||
- `Body Segment (NH)` has three model sizes in `model_size`: `0.4b` (the default; 1.6 GB download, about 1.5 to 2 GB of VRAM at the peak), `0.8b` (3.3 GB) and `1b` (5.9 GB). The weights are downloaded once from Hugging Face (`facebook/sapiens2-seg-<size>`, pinned revisions) into `models/NH-Nodes/sapiens2-seg-<size>/`, and only one size is kept in memory at a time. It needs PyTorch 2.4 or newer.
|
||||
- `Body Segment (NH)` empties the map of a picture with nobody in it. When no face is found, two tests must pass: Sapiens2's first look must be sure (a confidence of 0.9 or more) of at least half of what it labels as people, and a second look at the mirrored picture must find the same people (the overlap, IoU, of the two looks must be at least 0.6). Otherwise the picture has nobody in it, and the report and the preview say "no person found (none Sapiens2 was sure of, and no face)". The second look costs one more look, only when no face is found. Before this, a facade or a café interior got faint "people", and everyday edits of a facade (exposure ×1.03 to ×1.15, contrast, saturation, sharpening) could lift the share of the first test above one half, which is why the second test was added. Measured with ComfyUI's decoder: people without a face overlapped 0.83 to 0.99 with their mirror (edited copies and small people on a plain background went down to 0.64), what the model invents on a facade 0.02 to 0.03. On 49 pictures without a face, 32 photos of people (backs, bodies without a head, a mannequin) were kept and 17 pictures of no one (a facade, a café, an elephant, flat lays of products, masks) were emptied. On 21 everyday edits each of the facade, the café and the elephant, the map was always empty. On 21 edits each of four faceless photos of people (a mannequin, a headless product shot, a dancer, a product on a body), it was always kept. A person whose face is found is never dropped.
|
||||
- `Garment Segment (NH)` has a new optional input `model`: `fashn` (the default) or `fashionpedia`. `fashn` is FASHN Human Parser, a SegFormer-B4 for fashion with 18 classes (256 MB): clean tops, dresses, skirts, pants, bags, hats, scarves, glasses and jewelry. `fashionpedia` is the model of 1.8.0 and before, with shoes, socks, headbands and small parts (sleeves, collars, pockets).
|
||||
- `Garment Segment (NH)` has new choices for `part`, next to `upper_garment`, `lower_garment`, `footwear`, `headwear` and `all_garments`: `dress`, `bag`, `belt`, `scarf`, `glasses` and `jewelry`. `jewelry` needs `fashn`: `fashionpedia` has no jewelry class, so the mask is empty and the console says why. With `fashn`, `footwear` is FASHN's "feet" class (the feet with whatever they wear, so bare feet too), `all_garments` is the upper, lower, footwear and headwear groups (bare feet included, as shoes were with Fashionpedia), `headwear` is the hat (there is no headband class) and `attach_parts` does nothing, because FASHN counts pockets, zips and patches as part of the garment. `attach_parts` works with `fashionpedia` only.
|
||||
- `Garment Segment (NH)` and `Portrait Segment (NH)` have a new output, `segs` (NH_SEGS, last), with one instance per part found. Garment with `fashn`: face, hair, top, dress, skirt, pants, belt, bag, hat, scarf, glasses, arms, hands, legs, feet, torso, jewelry; with `fashionpedia`: its class names (shirt, top, sweater, …, sleeve, pocket, …). Portrait: skin, nose, glasses, eyes, eyebrows, ears, mouth, lips, hair, hat, earrings, necklace, neck, clothes. An instance covers its part on every person in the picture.
|
||||
- `Segs Compose (NH)` has two new optional inputs, `segs_2` and `segs_3`: the instances of up to three nodes (`SAM3 Segment (NH)`, `Garment Segment (NH)`, `Portrait Segment (NH)`, `Body Segment (NH)`) of the same picture are mixed into one formula. For example, `bag` from SAM 3 in `segs`, the Garment segs in `segs_2` and the Body segs in `segs_3`, then `dress - bag - lips`.
|
||||
- `Segs Compose (NH)` refuses segs of different pictures: another image count or size, or what looks like a different photo of the same size. Every frame of segs keeps a small grey print of its picture for this check. Edits of the same photo (brightness, contrast, colour, blur) are still accepted. The check is a guard against wiring mistakes, not a proof: it catches most other photos of the same size, a copy shifted or zoomed a little usually passes, and a new background behind the same person can fail. The new optional input `check_same_photo` (on by default) turns it off for edits like that. Segs made by older versions have no print and are mixed on size alone.
|
||||
- `Segs Compose (NH)` and `Segs Select (NH)` understand each node's own words on that node's segs. Each node is read on its own: where a node has no instance carrying the word as its label, the word is read as one of that node's own words and takes the parts it stands for, and an instance of one node that carries the word does not hide another node's word (with the segs of `Portrait Segment (NH)` and `Body Segment (NH)` together, `skin` is the skin instance of the first and the skin parts of the second). Only group words stand for several parts. `Body Segment (NH)`: `skin`, `body skin`, `head`, `mouth`, `clothes`, `clothing`, `outfit`, `person`, `body`, and the exact other names `upper clothes`, `lower clothes`, `apparel` (`skin` is its face, torso, arms, hands, legs and feet parts, `clothes` its top, bottom, shoes, socks and accessories parts), their Vietnamese words (`da`, `quần áo`, `người`…), and, in `Segs Compose (NH)`, the whole limbs: `tay`, `đôi tay`, `hai tay` are the arms with the hands, `chân`, `đôi chân`, `hai chân` the legs with the feet, while `cánh tay` stays the arms alone (the side forms `tay trái`… and the pieces `left hand`, `thigh` find nothing there). `Garment Segment (NH)`: the groups of `part` (`upper_garment`, `lower_garment`, `footwear`, `headwear`, `all_garments`; with `fashn`, `upper_garment` is top, dress and scarf and `footwear` the feet). `Portrait Segment (NH)`: `face` (skin, nose, glasses, eyes, eyebrows, mouth, lips), `face skin` (the skin part) and `mouth interior`. A word that is the name of one of a node's parts never stands for other parts (Fashionpedia's `belt` is only its belt). The near synonyms of `Body Segment (NH)` (`dress`, `pants`, `skirt`, `jeans`, `shirt`, `neck`…) are not read: write `top` or `bottom`, or mix in the Garment segs, which label dress, skirt and pants. For example, `bag` from `SAM3 Segment (NH)` in `segs` and the Body segs in `segs_2`, then `skin - bag`.
|
||||
|
||||
### Changed
|
||||
- (behaviour change) `Garment Segment (NH)` runs `fashn` by default. A saved workflow or API prompt without `model` (everything made before 1.9.0) now runs `fashn`, so its masks change. They are better: `all_garments` on an 8K group photo went from 7% to 33% of the picture, because Fashionpedia missed the dresses. `footwear` is now the feet (bare feet included) instead of shoes and socks, `headwear` has no headbands, and `attach_parts` does nothing. The first run downloads 256 MB, so a machine that is offline fails until the model is downloaded. To get the 1.8.0 result, set `model` to `fashionpedia`.
|
||||
- (behaviour change) `Garment Segment (NH)`: a region shorter than 128 px is no longer parsed again close up, and the labels of the first look stay there. This removed the salt-and-pepper labels that the models gave on small, ambiguous things (a helmet held in the hand). `Body Segment (NH)` follows the same rule.
|
||||
- (behaviour change) `Segs Compose (NH)` and `Segs Select (NH)` treat singular and plural as the same label: `arm` finds `arms`, `foot` finds `feet`, `shoe` finds `shoes`, `scarf` finds `scarves`, `calf` finds `calves`. A pattern now also finds a label that differs from it only by its plural (a SAM 3 concept written `people` is found by `person`). `glasses` (eyewear) and `glass` (a drinking glass) stay two labels. Wildcards are matched as written.
|
||||
- `SAM3 Segment (NH)` asks SAM 3 once for a concept that a list names in singular and plural (`arm, arms`), and its report lists it once. `glass, glasses` stay two concepts.
|
||||
- The console message of `Segs Compose (NH)` for a label that finds nothing also lists the labels that the segs hold.
|
||||
- `Body Segment (NH)` refuses `beard` and `mustache` with "a beard is part of 'face' here, and no NH-Nodes parser has a beard class (SAM3 Segment can try 'beard')". Its report notes about the face class, a dress and a skirt appear only when the picture has some of that region.
|
||||
- The `fill_holes` tooltip now differs per node: off for `Garment Segment (NH)` (an arm across the torso would be filled in), on for `Portrait Segment (NH)` (a face stays solid where glasses, earrings or hair cut into it).
|
||||
|
||||
### Notes
|
||||
- Licences. Read `THIRD_PARTY_NOTICES.md` before you use these nodes for paid work.
|
||||
- `Garment Segment (NH)` is for non-commercial use only. FASHN Human Parser is under the NVIDIA Source Code License for SegFormer, whose section 3.3 says the work "only may be used or intended for use non-commercially" and defines "non-commercially" as "for research or evaluation purposes only". The `fashionpedia` model declares no licence, but it is fine-tuned from `nvidia/mit-b3`, so the same licence plausibly applies. The notices of 1.8.0 and before listed this node as "unclear"; they now say "No".
|
||||
- `Body Segment (NH)` uses Meta's Sapiens2 under the Sapiens2 License: royalty-free, with no non-commercial clause, but it prohibits surveillance, biometric processing, identifying or re-identifying people, deepfakes, impersonation and misleading content, inferring health, demographic or other sensitive data without the rights and consents the law requires, pornographic or defamatory use, unlicensed professional practice, and ITAR, military, weapons, drugs and critical-infrastructure uses. You must comply with privacy and biometric laws (GDPR, CCPA, LGPD, 28 CFR 202) and with trade controls, and you may not reverse engineer the materials. Meta may audit your use, require deletion, terminate the licence and change the terms, and California law governs. The model code is bundled in `core/sapiens2/` with its licence; the weights are downloaded from Meta's Hugging Face repositories.
|
||||
- `Portrait Segment (NH)` is unchanged: non-commercial research and education only.
|
||||
- Known limit: FASHN can miss a pale garment close to the colour of the skin (a pale pink sleeveless top was only partly found). The model itself misses it, not the crops. `top` or `clothes` in `Body Segment (NH)` covers it better. FASHN can also label things held near the body as a top (champagne glasses).
|
||||
- Known limit: `Body Segment (NH)` on old, faded or sepia scans. Sapiens2 can take the whole backdrop for clothes (a scanned group photo came out 96% "person"). Use `Garment Segment (NH)` with `fashn`, or `SAM3 Segment (NH)` with `person`, there.
|
||||
- Known limits of the "nobody" tests in `Body Segment (NH)`: a garment alone (a flat lay or a product shot without a body) counts as nobody, so use `Garment Segment (NH)` for that, and a very stylised or blurred figure without a face can be dropped. A person whose face is found is never dropped.
|
||||
- In `Body Segment (NH)`, `face` is one Sapiens2 class with the neck, the eyes, the eyebrows and the beard, and `skin` includes it, so `skin - face` also removes the neck and `face - neck` is empty. A dress is labelled `top` (upper clothes): `dress - top` is empty. Left and right are the person's own sides. The report adds a note when you use such a word (for the face class, a dress and a skirt only when the picture has some of that region).
|
||||
|
||||
## 1.8.0 — Vision: masks from one node, faces and clothes parsed close up (2026-10-18)
|
||||
|
||||
No node is added (75 in total). `SAM3 Segment (NH)`, `Segs Compose (NH)`, `Segs Select (NH)`, `Portrait Segment (NH)` and `Garment Segment (NH)` change. Entries marked "behaviour change" can change what a saved workflow produces, so read them before you update. Workflows saved with 1.7.x still load and run: the new input of `SAM3 Segment (NH)` is optional and its new output comes last.
|
||||
|
||||
@@ -861,12 +861,12 @@ def as_mask(array):
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def garment_run(part="upper_garment", attach_parts=True):
|
||||
def garment_run(part="upper_garment", attach_parts=True, model="fashn"):
|
||||
"""The real Garment Segment node on the astronaut: (mask [H, W] float, cutout_rgba PIL RGBA)."""
|
||||
require_segformer("segformer_fashion")
|
||||
mask, cutout, _mask_image = run(
|
||||
require_segformer({"fashn": "fashn_human_parser", "fashionpedia": "segformer_fashion"}[model])
|
||||
mask, cutout, _mask_image, _segs = run(
|
||||
"NH_GarmentSegment", image=to_tensor(photo("astronaut")), part=part, attach_parts=attach_parts,
|
||||
mask_expand=0, mask_blur=0, fill_holes=False, device="cpu",
|
||||
mask_expand=0, mask_blur=0, fill_holes=False, device="cpu", model=model,
|
||||
)
|
||||
return mask_array(mask), to_pil(cutout)
|
||||
|
||||
@@ -979,20 +979,23 @@ def ex_mask_aspect():
|
||||
@example("garment-segment.jpg")
|
||||
def ex_garment_segment():
|
||||
image = photo("astronaut")
|
||||
plain, _cutout = garment_run("upper_garment", False)
|
||||
attached, cutout = garment_run("upper_garment", True)
|
||||
garment, cutout = garment_run("upper_garment") # the default model, fashn
|
||||
# attach_parts only works with fashionpedia, which labels pockets, zips and patches apart from the garment
|
||||
plain, _cutout = garment_run("upper_garment", False, "fashionpedia")
|
||||
attached, _cutout = garment_run("upper_garment", True, "fashionpedia")
|
||||
merged = np.clip(attached - plain, 0.0, 1.0)
|
||||
on_layers = (Layer(attached, TEAL, 0.45, 0.0), Layer(plain, TEAL, 0.0, 2.0), Layer(merged, AMBER, 0.75, 0.0))
|
||||
HERO["garment"] = overlay_masks(image, on_layers)
|
||||
HERO["garment"] = overlay_masks(image, [Layer(garment, TEAL, 0.45)])
|
||||
tiles = [
|
||||
Tile(image, "image", "input photo"),
|
||||
Tile(masked(image, Layer(plain, TEAL, 0.45)), "part = upper_garment", "attach_parts = off", aspect=1.0),
|
||||
Tile(masked(image, *on_layers), "part = upper_garment", "attach_parts = on · amber = merged parts",
|
||||
Tile(masked(image, Layer(garment, TEAL, 0.45)), "part = upper_garment", "model = fashn (default)",
|
||||
aspect=1.0),
|
||||
Tile(over_checker(cutout), "cutout_rgba", "the mask as the alpha channel"),
|
||||
Tile(masked(image, *on_layers), "model = fashionpedia", "attach_parts = on · amber = merged parts",
|
||||
aspect=1.0),
|
||||
Tile(over_checker(cutout), "cutout_rgba", "the fashn mask as the alpha channel"),
|
||||
]
|
||||
return render_panel([Row([tiles[0], ARROW, *tiles[1:]])], "NH Garment Segment",
|
||||
"mask of the upper garment, lower garment, footwear or headwear of a person")
|
||||
"mask of a garment group or a part (dress, bag, belt...) of the people in a photo")
|
||||
|
||||
|
||||
SLATE = (148, 163, 184)
|
||||
@@ -1015,8 +1018,8 @@ def ex_portrait_segment():
|
||||
if isinstance(value, tuple) and value[0] == "BOOLEAN" and name != "fill_holes"]
|
||||
|
||||
def segment(**flags):
|
||||
mask, cutout, _mask_image = run("NH_PortraitSegment", image=head_t, mask_expand=0, mask_blur=0,
|
||||
fill_holes=True, device="cpu", **flags)
|
||||
mask, cutout, _mask_image, _segs = run("NH_PortraitSegment", image=head_t, mask_expand=0, mask_blur=0,
|
||||
fill_holes=True, device="cpu", **flags)
|
||||
return mask_array(mask), to_pil(cutout)
|
||||
|
||||
default_mask, cutout = segment() # the default toggles
|
||||
@@ -1151,6 +1154,59 @@ def ex_sam3_formula():
|
||||
return render_panel([top, bottom])
|
||||
|
||||
|
||||
def require_sapiens2():
|
||||
"""Skip the Body Segment example unless the Sapiens2 0.4b weights are on disk (this script never downloads)."""
|
||||
try:
|
||||
found = importlib.import_module("NH-Nodes.core.sapiens2_backend").model_path("0.4b", download=False)
|
||||
except Exception as error:
|
||||
raise Skip(f"cannot look for the Sapiens2 weights: {error}") from error
|
||||
if not found:
|
||||
raise Skip("the Sapiens2 0.4b weights are not installed (this script never downloads models)")
|
||||
|
||||
|
||||
BODY_FORMULAS = ("skin", "tóc, môi", "clothes", "background")
|
||||
|
||||
|
||||
@example("body-segment.jpg")
|
||||
def ex_body_segment():
|
||||
"""What Body Segment shows on the node itself, for a few region formulas (one node run each)."""
|
||||
require_sapiens2()
|
||||
import tempfile
|
||||
|
||||
import folder_paths
|
||||
|
||||
image = photo("astronaut")
|
||||
device = "cuda" if GPU and torch.cuda.is_available() else "cpu"
|
||||
shown = {}
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
previous = folder_paths.get_temp_directory()
|
||||
folder_paths.set_temp_directory(folder) # the node saves its preview into ComfyUI's temp folder: use ours
|
||||
try:
|
||||
for regions in BODY_FORMULAS:
|
||||
result = run("NH_BodySegment", image=to_tensor(image), regions=regions, model_size="0.4b",
|
||||
mask_expand=0, mask_blur=0, fill_holes=False, device=device, show_preview=True)
|
||||
with Image.open(Path(folder) / result["ui"]["images"][0]["filename"]) as picture:
|
||||
shown[regions] = picture.convert("RGB")
|
||||
finally:
|
||||
folder_paths.set_temp_directory(previous)
|
||||
|
||||
def picture_only(regions): # the report is written on a strip under the picture: keep the picture
|
||||
return shown[regions].crop((0, 0, image.width, image.height))
|
||||
|
||||
top = Row(
|
||||
[Tile(image, "image", "input photo"), ARROW,
|
||||
Tile(shown["skin"], "regions: skin", "the face (eyes and brows in, lips apart) and the hands; the report is underneath")],
|
||||
"Body Segment (NH)", "what the node shows on itself: the mask is bright, the rest darkened", max_h=470,
|
||||
)
|
||||
bottom = Row(
|
||||
[Tile(picture_only("tóc, môi"), "tóc, môi", "Vietnamese works: hair and lips", aspect=1.0),
|
||||
Tile(picture_only("clothes"), "clothes", "everything the person wears", aspect=1.0),
|
||||
Tile(picture_only("background"), "background", "everything outside the person", aspect=1.0)],
|
||||
"More regions", "one Body Segment (NH) each; the bright part is its mask output", max_h=330,
|
||||
)
|
||||
return render_panel([top, bottom])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------------------------
|
||||
# Banner
|
||||
# ---------------------------------------------------------------------------------------------------------------
|
||||
|
||||
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 137 KiB |
@@ -0,0 +1,50 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="411" viewBox="0 0 360 411" font-family="Inter, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" role="img" aria-label="Body Segment (NH) node">
|
||||
<rect x="0.5" y="0.5" width="359" height="410" rx="10" fill="#26282c" stroke="#45484f"/>
|
||||
<path d="M0.5 10.5 a10 10 0 0 1 10 -10 h339 a10 10 0 0 1 10 10 v36 h-359 z" fill="#34373d"/>
|
||||
<circle cx="17" cy="17" r="4.5" fill="#F6AD55"/>
|
||||
<text x="28" y="22" font-size="14" font-weight="600" fill="#EEF0F3">Body Segment (NH)</text>
|
||||
<text x="28" y="38" font-size="10.5" fill="#9AA0A8" font-family="'JetBrains Mono', Consolas, 'Courier New', monospace">NH_BodySegment · Vision</text>
|
||||
<circle cx="12" cy="67.0" r="5" fill="#64B5F6" stroke="#1b1c1f" stroke-width="1.5"/>
|
||||
<text x="24" y="71.0" font-size="12" fill="#D7DADF">image</text>
|
||||
<text x="336" y="71.0" font-size="12" fill="#D7DADF" text-anchor="end">mask</text>
|
||||
<circle cx="348" cy="67.0" r="5" fill="#81C784" stroke="#1b1c1f" stroke-width="1.5"/>
|
||||
<text x="336" y="93.0" font-size="12" fill="#D7DADF" text-anchor="end">cutout_rgba</text>
|
||||
<circle cx="348" cy="89.0" r="5" fill="#64B5F6" stroke="#1b1c1f" stroke-width="1.5"/>
|
||||
<text x="336" y="115.0" font-size="12" fill="#D7DADF" text-anchor="end">mask_image</text>
|
||||
<circle cx="348" cy="111.0" r="5" fill="#64B5F6" stroke="#1b1c1f" stroke-width="1.5"/>
|
||||
<text x="336" y="137.0" font-size="12" fill="#D7DADF" text-anchor="end">segs</text>
|
||||
<circle cx="348" cy="133.0" r="5" fill="#E0B272" stroke="#1b1c1f" stroke-width="1.5"/>
|
||||
<text x="336" y="159.0" font-size="12" fill="#D7DADF" text-anchor="end">report</text>
|
||||
<circle cx="348" cy="155.0" r="5" fill="#C5E1A5" stroke="#1b1c1f" stroke-width="1.5"/>
|
||||
<rect x="12" y="174" width="336" height="46" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="190" font-size="11.5" fill="#9AA0A8">regions</text>
|
||||
<text x="22" y="210" font-size="11.5" fill="#E6E8EB" font-family="'JetBrains Mono', Consolas, 'Courier New', monospace">skin</text>
|
||||
<rect x="12" y="225" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="20" y="241" font-size="10" fill="#6f747c">◀</text>
|
||||
<text x="332" y="241" font-size="10" fill="#6f747c">▶</text>
|
||||
<text x="34" y="241" font-size="11.5" fill="#9AA0A8">model_size</text>
|
||||
<text x="326" y="241" font-size="11.5" fill="#E6E8EB" text-anchor="end">0.4b</text>
|
||||
<rect x="12" y="254" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="20" y="270" font-size="10" fill="#6f747c">◀</text>
|
||||
<text x="332" y="270" font-size="10" fill="#6f747c">▶</text>
|
||||
<text x="34" y="270" font-size="11.5" fill="#9AA0A8">mask_expand</text>
|
||||
<text x="326" y="270" font-size="11.5" fill="#E6E8EB" text-anchor="end">0</text>
|
||||
<rect x="12" y="283" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="20" y="299" font-size="10" fill="#6f747c">◀</text>
|
||||
<text x="332" y="299" font-size="10" fill="#6f747c">▶</text>
|
||||
<text x="34" y="299" font-size="11.5" fill="#9AA0A8">mask_blur</text>
|
||||
<text x="326" y="299" font-size="11.5" fill="#E6E8EB" text-anchor="end">0</text>
|
||||
<rect x="12" y="312" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="328" font-size="11.5" fill="#9AA0A8">fill_holes</text>
|
||||
<rect x="308" y="318" width="30" height="13" rx="6.5" fill="#4a4d53"/>
|
||||
<circle cx="315" cy="324.5" r="5" fill="#F5F6F7"/>
|
||||
<rect x="12" y="341" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="20" y="357" font-size="10" fill="#6f747c">◀</text>
|
||||
<text x="332" y="357" font-size="10" fill="#6f747c">▶</text>
|
||||
<text x="34" y="357" font-size="11.5" fill="#9AA0A8">device</text>
|
||||
<text x="326" y="357" font-size="11.5" fill="#E6E8EB" text-anchor="end">cuda</text>
|
||||
<rect x="12" y="370" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="386" font-size="11.5" fill="#9AA0A8">show_preview <tspan fill="#6f747c" font-style="italic">opt</tspan></text>
|
||||
<rect x="308" y="376" width="30" height="13" rx="6.5" fill="#4C8BF5"/>
|
||||
<circle cx="331" cy="382.5" r="5" fill="#F5F6F7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="316" viewBox="0 0 360 316" font-family="Inter, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" role="img" aria-label="Garment Segment (NH) node">
|
||||
<rect x="0.5" y="0.5" width="359" height="315" rx="10" fill="#26282c" stroke="#45484f"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="367" viewBox="0 0 360 367" font-family="Inter, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" role="img" aria-label="Garment Segment (NH) node">
|
||||
<rect x="0.5" y="0.5" width="359" height="366" rx="10" fill="#26282c" stroke="#45484f"/>
|
||||
<path d="M0.5 10.5 a10 10 0 0 1 10 -10 h339 a10 10 0 0 1 10 10 v36 h-359 z" fill="#34373d"/>
|
||||
<circle cx="17" cy="17" r="4.5" fill="#8FD18F"/>
|
||||
<text x="28" y="22" font-size="14" font-weight="600" fill="#EEF0F3">Garment Segment (NH)</text>
|
||||
@@ -12,32 +12,39 @@
|
||||
<circle cx="348" cy="89.0" r="5" fill="#64B5F6" stroke="#1b1c1f" stroke-width="1.5"/>
|
||||
<text x="336" y="115.0" font-size="12" fill="#D7DADF" text-anchor="end">mask_image</text>
|
||||
<circle cx="348" cy="111.0" r="5" fill="#64B5F6" stroke="#1b1c1f" stroke-width="1.5"/>
|
||||
<rect x="12" y="130" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="20" y="146" font-size="10" fill="#6f747c">◀</text>
|
||||
<text x="332" y="146" font-size="10" fill="#6f747c">▶</text>
|
||||
<text x="34" y="146" font-size="11.5" fill="#9AA0A8">part</text>
|
||||
<text x="326" y="146" font-size="11.5" fill="#E6E8EB" text-anchor="end">upper_garment</text>
|
||||
<rect x="12" y="159" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="175" font-size="11.5" fill="#9AA0A8">attach_parts</text>
|
||||
<rect x="308" y="165" width="30" height="13" rx="6.5" fill="#4C8BF5"/>
|
||||
<circle cx="331" cy="171.5" r="5" fill="#F5F6F7"/>
|
||||
<rect x="12" y="188" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="20" y="204" font-size="10" fill="#6f747c">◀</text>
|
||||
<text x="332" y="204" font-size="10" fill="#6f747c">▶</text>
|
||||
<text x="34" y="204" font-size="11.5" fill="#9AA0A8">mask_expand</text>
|
||||
<text x="326" y="204" font-size="11.5" fill="#E6E8EB" text-anchor="end">0</text>
|
||||
<rect x="12" y="217" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="20" y="233" font-size="10" fill="#6f747c">◀</text>
|
||||
<text x="332" y="233" font-size="10" fill="#6f747c">▶</text>
|
||||
<text x="34" y="233" font-size="11.5" fill="#9AA0A8">mask_blur</text>
|
||||
<text x="326" y="233" font-size="11.5" fill="#E6E8EB" text-anchor="end">0</text>
|
||||
<rect x="12" y="246" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="262" font-size="11.5" fill="#9AA0A8">fill_holes</text>
|
||||
<rect x="308" y="252" width="30" height="13" rx="6.5" fill="#4a4d53"/>
|
||||
<circle cx="315" cy="258.5" r="5" fill="#F5F6F7"/>
|
||||
<rect x="12" y="275" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="20" y="291" font-size="10" fill="#6f747c">◀</text>
|
||||
<text x="332" y="291" font-size="10" fill="#6f747c">▶</text>
|
||||
<text x="34" y="291" font-size="11.5" fill="#9AA0A8">device</text>
|
||||
<text x="326" y="291" font-size="11.5" fill="#E6E8EB" text-anchor="end">cuda</text>
|
||||
<text x="336" y="137.0" font-size="12" fill="#D7DADF" text-anchor="end">segs</text>
|
||||
<circle cx="348" cy="133.0" r="5" fill="#E0B272" stroke="#1b1c1f" stroke-width="1.5"/>
|
||||
<rect x="12" y="152" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="20" y="168" font-size="10" fill="#6f747c">◀</text>
|
||||
<text x="332" y="168" font-size="10" fill="#6f747c">▶</text>
|
||||
<text x="34" y="168" font-size="11.5" fill="#9AA0A8">part</text>
|
||||
<text x="326" y="168" font-size="11.5" fill="#E6E8EB" text-anchor="end">upper_garment</text>
|
||||
<rect x="12" y="181" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="197" font-size="11.5" fill="#9AA0A8">attach_parts</text>
|
||||
<rect x="308" y="187" width="30" height="13" rx="6.5" fill="#4C8BF5"/>
|
||||
<circle cx="331" cy="193.5" r="5" fill="#F5F6F7"/>
|
||||
<rect x="12" y="210" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="20" y="226" font-size="10" fill="#6f747c">◀</text>
|
||||
<text x="332" y="226" font-size="10" fill="#6f747c">▶</text>
|
||||
<text x="34" y="226" font-size="11.5" fill="#9AA0A8">mask_expand</text>
|
||||
<text x="326" y="226" font-size="11.5" fill="#E6E8EB" text-anchor="end">0</text>
|
||||
<rect x="12" y="239" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="20" y="255" font-size="10" fill="#6f747c">◀</text>
|
||||
<text x="332" y="255" font-size="10" fill="#6f747c">▶</text>
|
||||
<text x="34" y="255" font-size="11.5" fill="#9AA0A8">mask_blur</text>
|
||||
<text x="326" y="255" font-size="11.5" fill="#E6E8EB" text-anchor="end">0</text>
|
||||
<rect x="12" y="268" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="284" font-size="11.5" fill="#9AA0A8">fill_holes</text>
|
||||
<rect x="308" y="274" width="30" height="13" rx="6.5" fill="#4a4d53"/>
|
||||
<circle cx="315" cy="280.5" r="5" fill="#F5F6F7"/>
|
||||
<rect x="12" y="297" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="20" y="313" font-size="10" fill="#6f747c">◀</text>
|
||||
<text x="332" y="313" font-size="10" fill="#6f747c">▶</text>
|
||||
<text x="34" y="313" font-size="11.5" fill="#9AA0A8">device</text>
|
||||
<text x="326" y="313" font-size="11.5" fill="#E6E8EB" text-anchor="end">cuda</text>
|
||||
<rect x="12" y="326" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="20" y="342" font-size="10" fill="#6f747c">◀</text>
|
||||
<text x="332" y="342" font-size="10" fill="#6f747c">▶</text>
|
||||
<text x="34" y="342" font-size="11.5" fill="#9AA0A8">model <tspan fill="#6f747c" font-style="italic">opt</tspan></text>
|
||||
<text x="326" y="342" font-size="11.5" fill="#E6E8EB" text-anchor="end">fashn</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.9 KiB |
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="519" viewBox="0 0 360 519" font-family="Inter, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" role="img" aria-label="Portrait Segment (NH) node">
|
||||
<rect x="0.5" y="0.5" width="359" height="518" rx="10" fill="#26282c" stroke="#45484f"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="541" viewBox="0 0 360 541" font-family="Inter, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" role="img" aria-label="Portrait Segment (NH) node">
|
||||
<rect x="0.5" y="0.5" width="359" height="540" rx="10" fill="#26282c" stroke="#45484f"/>
|
||||
<path d="M0.5 10.5 a10 10 0 0 1 10 -10 h339 a10 10 0 0 1 10 10 v36 h-359 z" fill="#34373d"/>
|
||||
<circle cx="17" cy="17" r="4.5" fill="#8FD18F"/>
|
||||
<text x="28" y="22" font-size="14" font-weight="600" fill="#EEF0F3">Portrait Segment (NH)</text>
|
||||
@@ -12,59 +12,61 @@
|
||||
<circle cx="348" cy="89.0" r="5" fill="#64B5F6" stroke="#1b1c1f" stroke-width="1.5"/>
|
||||
<text x="336" y="115.0" font-size="12" fill="#D7DADF" text-anchor="end">mask_image</text>
|
||||
<circle cx="348" cy="111.0" r="5" fill="#64B5F6" stroke="#1b1c1f" stroke-width="1.5"/>
|
||||
<rect x="12" y="130" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="146" font-size="11.5" fill="#9AA0A8">face</text>
|
||||
<rect x="308" y="136" width="30" height="13" rx="6.5" fill="#4C8BF5"/>
|
||||
<circle cx="331" cy="142.5" r="5" fill="#F5F6F7"/>
|
||||
<rect x="12" y="159" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="175" font-size="11.5" fill="#9AA0A8">neck</text>
|
||||
<rect x="308" y="165" width="30" height="13" rx="6.5" fill="#4C8BF5"/>
|
||||
<circle cx="331" cy="171.5" r="5" fill="#F5F6F7"/>
|
||||
<rect x="12" y="188" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="204" font-size="11.5" fill="#9AA0A8">hair</text>
|
||||
<rect x="308" y="194" width="30" height="13" rx="6.5" fill="#4C8BF5"/>
|
||||
<circle cx="331" cy="200.5" r="5" fill="#F5F6F7"/>
|
||||
<rect x="12" y="217" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="233" font-size="11.5" fill="#9AA0A8">eyes</text>
|
||||
<rect x="308" y="223" width="30" height="13" rx="6.5" fill="#4C8BF5"/>
|
||||
<circle cx="331" cy="229.5" r="5" fill="#F5F6F7"/>
|
||||
<rect x="12" y="246" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="262" font-size="11.5" fill="#9AA0A8">lips</text>
|
||||
<rect x="308" y="252" width="30" height="13" rx="6.5" fill="#4C8BF5"/>
|
||||
<circle cx="331" cy="258.5" r="5" fill="#F5F6F7"/>
|
||||
<rect x="12" y="275" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="291" font-size="11.5" fill="#9AA0A8">nose</text>
|
||||
<rect x="308" y="281" width="30" height="13" rx="6.5" fill="#4C8BF5"/>
|
||||
<circle cx="331" cy="287.5" r="5" fill="#F5F6F7"/>
|
||||
<rect x="12" y="304" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="320" font-size="11.5" fill="#9AA0A8">eyebrows</text>
|
||||
<rect x="308" y="310" width="30" height="13" rx="6.5" fill="#4C8BF5"/>
|
||||
<circle cx="331" cy="316.5" r="5" fill="#F5F6F7"/>
|
||||
<rect x="12" y="333" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="349" font-size="11.5" fill="#9AA0A8">ears</text>
|
||||
<rect x="308" y="339" width="30" height="13" rx="6.5" fill="#4C8BF5"/>
|
||||
<circle cx="331" cy="345.5" r="5" fill="#F5F6F7"/>
|
||||
<rect x="12" y="362" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="378" font-size="11.5" fill="#9AA0A8">mouth_interior</text>
|
||||
<rect x="308" y="368" width="30" height="13" rx="6.5" fill="#4a4d53"/>
|
||||
<circle cx="315" cy="374.5" r="5" fill="#F5F6F7"/>
|
||||
<rect x="12" y="391" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="20" y="407" font-size="10" fill="#6f747c">◀</text>
|
||||
<text x="332" y="407" font-size="10" fill="#6f747c">▶</text>
|
||||
<text x="34" y="407" font-size="11.5" fill="#9AA0A8">mask_expand</text>
|
||||
<text x="326" y="407" font-size="11.5" fill="#E6E8EB" text-anchor="end">0</text>
|
||||
<rect x="12" y="420" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="20" y="436" font-size="10" fill="#6f747c">◀</text>
|
||||
<text x="332" y="436" font-size="10" fill="#6f747c">▶</text>
|
||||
<text x="34" y="436" font-size="11.5" fill="#9AA0A8">mask_blur</text>
|
||||
<text x="326" y="436" font-size="11.5" fill="#E6E8EB" text-anchor="end">0</text>
|
||||
<rect x="12" y="449" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="465" font-size="11.5" fill="#9AA0A8">fill_holes</text>
|
||||
<rect x="308" y="455" width="30" height="13" rx="6.5" fill="#4C8BF5"/>
|
||||
<circle cx="331" cy="461.5" r="5" fill="#F5F6F7"/>
|
||||
<rect x="12" y="478" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="20" y="494" font-size="10" fill="#6f747c">◀</text>
|
||||
<text x="332" y="494" font-size="10" fill="#6f747c">▶</text>
|
||||
<text x="34" y="494" font-size="11.5" fill="#9AA0A8">device</text>
|
||||
<text x="326" y="494" font-size="11.5" fill="#E6E8EB" text-anchor="end">cuda</text>
|
||||
<text x="336" y="137.0" font-size="12" fill="#D7DADF" text-anchor="end">segs</text>
|
||||
<circle cx="348" cy="133.0" r="5" fill="#E0B272" stroke="#1b1c1f" stroke-width="1.5"/>
|
||||
<rect x="12" y="152" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="168" font-size="11.5" fill="#9AA0A8">face</text>
|
||||
<rect x="308" y="158" width="30" height="13" rx="6.5" fill="#4C8BF5"/>
|
||||
<circle cx="331" cy="164.5" r="5" fill="#F5F6F7"/>
|
||||
<rect x="12" y="181" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="197" font-size="11.5" fill="#9AA0A8">neck</text>
|
||||
<rect x="308" y="187" width="30" height="13" rx="6.5" fill="#4C8BF5"/>
|
||||
<circle cx="331" cy="193.5" r="5" fill="#F5F6F7"/>
|
||||
<rect x="12" y="210" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="226" font-size="11.5" fill="#9AA0A8">hair</text>
|
||||
<rect x="308" y="216" width="30" height="13" rx="6.5" fill="#4C8BF5"/>
|
||||
<circle cx="331" cy="222.5" r="5" fill="#F5F6F7"/>
|
||||
<rect x="12" y="239" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="255" font-size="11.5" fill="#9AA0A8">eyes</text>
|
||||
<rect x="308" y="245" width="30" height="13" rx="6.5" fill="#4C8BF5"/>
|
||||
<circle cx="331" cy="251.5" r="5" fill="#F5F6F7"/>
|
||||
<rect x="12" y="268" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="284" font-size="11.5" fill="#9AA0A8">lips</text>
|
||||
<rect x="308" y="274" width="30" height="13" rx="6.5" fill="#4C8BF5"/>
|
||||
<circle cx="331" cy="280.5" r="5" fill="#F5F6F7"/>
|
||||
<rect x="12" y="297" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="313" font-size="11.5" fill="#9AA0A8">nose</text>
|
||||
<rect x="308" y="303" width="30" height="13" rx="6.5" fill="#4C8BF5"/>
|
||||
<circle cx="331" cy="309.5" r="5" fill="#F5F6F7"/>
|
||||
<rect x="12" y="326" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="342" font-size="11.5" fill="#9AA0A8">eyebrows</text>
|
||||
<rect x="308" y="332" width="30" height="13" rx="6.5" fill="#4C8BF5"/>
|
||||
<circle cx="331" cy="338.5" r="5" fill="#F5F6F7"/>
|
||||
<rect x="12" y="355" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="371" font-size="11.5" fill="#9AA0A8">ears</text>
|
||||
<rect x="308" y="361" width="30" height="13" rx="6.5" fill="#4C8BF5"/>
|
||||
<circle cx="331" cy="367.5" r="5" fill="#F5F6F7"/>
|
||||
<rect x="12" y="384" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="400" font-size="11.5" fill="#9AA0A8">mouth_interior</text>
|
||||
<rect x="308" y="390" width="30" height="13" rx="6.5" fill="#4a4d53"/>
|
||||
<circle cx="315" cy="396.5" r="5" fill="#F5F6F7"/>
|
||||
<rect x="12" y="413" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="20" y="429" font-size="10" fill="#6f747c">◀</text>
|
||||
<text x="332" y="429" font-size="10" fill="#6f747c">▶</text>
|
||||
<text x="34" y="429" font-size="11.5" fill="#9AA0A8">mask_expand</text>
|
||||
<text x="326" y="429" font-size="11.5" fill="#E6E8EB" text-anchor="end">0</text>
|
||||
<rect x="12" y="442" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="20" y="458" font-size="10" fill="#6f747c">◀</text>
|
||||
<text x="332" y="458" font-size="10" fill="#6f747c">▶</text>
|
||||
<text x="34" y="458" font-size="11.5" fill="#9AA0A8">mask_blur</text>
|
||||
<text x="326" y="458" font-size="11.5" fill="#E6E8EB" text-anchor="end">0</text>
|
||||
<rect x="12" y="471" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="487" font-size="11.5" fill="#9AA0A8">fill_holes</text>
|
||||
<rect x="308" y="477" width="30" height="13" rx="6.5" fill="#4C8BF5"/>
|
||||
<circle cx="331" cy="483.5" r="5" fill="#F5F6F7"/>
|
||||
<rect x="12" y="500" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="20" y="516" font-size="10" fill="#6f747c">◀</text>
|
||||
<text x="332" y="516" font-size="10" fill="#6f747c">▶</text>
|
||||
<text x="34" y="516" font-size="11.5" fill="#9AA0A8">device</text>
|
||||
<text x="326" y="516" font-size="11.5" fill="#E6E8EB" text-anchor="end">cuda</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.3 KiB |
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="149" viewBox="0 0 360 149" font-family="Inter, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" role="img" aria-label="Segs Compose (NH) node">
|
||||
<rect x="0.5" y="0.5" width="359" height="148" rx="10" fill="#26282c" stroke="#45484f"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="222" viewBox="0 0 360 222" font-family="Inter, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" role="img" aria-label="Segs Compose (NH) node">
|
||||
<rect x="0.5" y="0.5" width="359" height="221" rx="10" fill="#26282c" stroke="#45484f"/>
|
||||
<path d="M0.5 10.5 a10 10 0 0 1 10 -10 h339 a10 10 0 0 1 10 10 v36 h-359 z" fill="#34373d"/>
|
||||
<circle cx="17" cy="17" r="4.5" fill="#8FD18F"/>
|
||||
<text x="28" y="22" font-size="14" font-weight="600" fill="#EEF0F3">Segs Compose (NH)</text>
|
||||
@@ -8,7 +8,15 @@
|
||||
<text x="24" y="71.0" font-size="12" fill="#D7DADF">segs</text>
|
||||
<text x="336" y="71.0" font-size="12" fill="#D7DADF" text-anchor="end">mask</text>
|
||||
<circle cx="348" cy="67.0" r="5" fill="#81C784" stroke="#1b1c1f" stroke-width="1.5"/>
|
||||
<rect x="12" y="86" width="336" height="46" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="102" font-size="11.5" fill="#9AA0A8">expression</text>
|
||||
<text x="22" y="122" font-size="11.5" fill="#E6E8EB" font-family="'JetBrains Mono', Consolas, 'Courier New', monospace">all</text>
|
||||
<circle cx="12" cy="89.0" r="5" fill="#E0B272" stroke="#1b1c1f" stroke-width="1.5"/>
|
||||
<text x="24" y="93.0" font-size="12" fill="#D7DADF">segs_2 <tspan fill="#7d838c" font-style="italic">(optional)</tspan></text>
|
||||
<circle cx="12" cy="111.0" r="5" fill="#E0B272" stroke="#1b1c1f" stroke-width="1.5"/>
|
||||
<text x="24" y="115.0" font-size="12" fill="#D7DADF">segs_3 <tspan fill="#7d838c" font-style="italic">(optional)</tspan></text>
|
||||
<rect x="12" y="130" width="336" height="46" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="146" font-size="11.5" fill="#9AA0A8">expression</text>
|
||||
<text x="22" y="166" font-size="11.5" fill="#E6E8EB" font-family="'JetBrains Mono', Consolas, 'Courier New', monospace">all</text>
|
||||
<rect x="12" y="181" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
|
||||
<text x="22" y="197" font-size="11.5" fill="#9AA0A8">check_same_photo <tspan fill="#6f747c" font-style="italic">opt</tspan></text>
|
||||
<rect x="308" y="187" width="30" height="13" rx="6.5" fill="#4C8BF5"/>
|
||||
<circle cx="331" cy="193.5" r="5" fill="#F5F6F7"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.0 KiB |
@@ -35,6 +35,12 @@
|
||||
"category": "NH-Nodes/Batch",
|
||||
"deprecated": false
|
||||
},
|
||||
{
|
||||
"id": "NH_BodySegment",
|
||||
"name": "Body Segment (NH)",
|
||||
"category": "NH-Nodes/Vision",
|
||||
"deprecated": false
|
||||
},
|
||||
{
|
||||
"id": "NH_BooleanSwitch",
|
||||
"name": "Boolean Switch (NH)",
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
|
||||
# NH-Nodes guide
|
||||
|
||||
NH-Nodes adds **75 nodes** to ComfyUI for photo and production work: loading and saving image sets, tiling and compositing,
|
||||
resolution management for current models, mask tools, garment and portrait segmentation, SAM 3 masks for anything you
|
||||
can name from a short formula, VTON masks, logic routing, prompt and text tools, batch helpers and indexed model loaders.
|
||||
NH-Nodes adds **76 nodes** to ComfyUI for photo and production work: loading and saving image sets, tiling and compositing,
|
||||
resolution management for current models, mask tools, garment, portrait and body-part segmentation (the skin, hair and
|
||||
clothes of people by name), SAM 3 masks for anything you can name from a short formula, VTON masks, logic routing, prompt
|
||||
and text tools, batch helpers and indexed model loaders.
|
||||
|
||||
This guide explains every node — what it is for, each input and output, and how it behaves in the details that matter.
|
||||
Installation and a quick start are on the [project page](../../README.md#installation).
|
||||
@@ -40,7 +41,7 @@ Every node section starts with a card drawn from the node's real definition:
|
||||
| Image — mask-based retouch | [image-retouch](image-retouch.md) | NH Mask-Aware Resize · NH Agnostic Image Generator · NH Simple Face Paste |
|
||||
| Resolution | [resolution](resolution.md) | NH Smart Resolution Picker · NH Smart Ratio Image Resize · NH Ratio Preset Image Resize · NH Remove Resize Padding · NH Restore Image Geometry |
|
||||
| Mask | [mask](mask.md) | Mask Morphology · Mask Properties · NH Create Box Mask · NH Mask Aspect Ratio Match |
|
||||
| Vision | [vision](vision.md) | Garment Segment · Portrait Segment · SAM3 Segment · Segs Select · Segs Compose · Segs To Masks · Segs To Impact SEGS · Segs Preview |
|
||||
| Vision | [vision](vision.md) | Garment Segment · Portrait Segment · Body Segment · SAM3 Segment · Segs Select · Segs Compose · Segs To Masks · Segs To Impact SEGS · Segs Preview |
|
||||
| VTON | [vton](vton.md) | VTON Ultimate Processor |
|
||||
| Logic | [logic](logic.md) | Compare · Logic Gate · If/Else · Switch N · Any Switch · Any Branch Switch · Gate Switch · Value Match Index · Math Eval · Random Choice |
|
||||
| Text | [text](text.md) | Prompt Template · Prompt Scheduler · String Operations · Prompt Join · Text Split · Regex Extract · Text Index · Text Concatenate · Text Split Lines · Text Random Line |
|
||||
@@ -63,11 +64,14 @@ Every node section starts with a card drawn from the node's real definition:
|
||||
- **Resize data.** The resolution nodes return a `resize_data` record of how an image was cropped or padded, so a result
|
||||
can be put back into the original frame with [NH Restore Image Geometry](resolution.md#nh-restore-image-geometry).
|
||||
- **Instances.** SAM3 Segment returns every thing it finds as its own instance, with a label, a score and a mask, in an
|
||||
`NH_SEGS` value. The Segs nodes pick instances, combine them with a formula and turn them into masks; see
|
||||
[Instances: the NH_SEGS type](vision.md#instances-the-nh_segs-type).
|
||||
`NH_SEGS` value. Garment Segment, Portrait Segment and Body Segment return the parts they find (hair, top, lips…) in
|
||||
the same type. The Segs nodes pick instances, combine them with a formula and turn them into masks, and Segs Compose
|
||||
mixes the instances of several of these nodes; see [Instances: the NH_SEGS type](vision.md#instances-the-nh_segs-type).
|
||||
- **Licences.** All nodes are MIT except the parts listed in [THIRD_PARTY_NOTICES](../../THIRD_PARTY_NOTICES.md):
|
||||
VTON Ultimate Processor and Portrait Segment are for **non-commercial** use, and Garment Segment's model declares no
|
||||
licence. SAM3 Segment uses Meta's SAM 3 under the SAM License, which has no non-commercial clause but bars military,
|
||||
VTON Ultimate Processor and Portrait Segment are for **non-commercial** use, and so is Garment Segment, where NVIDIA's
|
||||
licence defines that as research or evaluation only. Body Segment uses Meta's Sapiens2 under the Sapiens2 License, which
|
||||
has no non-commercial clause but prohibits surveillance, biometric processing, identifying people, deepfakes and other
|
||||
uses. SAM3 Segment uses Meta's SAM 3 under the SAM License, which has no non-commercial clause but bars military,
|
||||
nuclear, espionage, and gun or illegal-weapon uses; you install the package and download the checkpoint yourself.
|
||||
|
||||
## More
|
||||
@@ -75,4 +79,4 @@ Every node section starts with a card drawn from the node's real definition:
|
||||
- [Changelog](../CHANGELOG.md) — what changed in each release, including behaviour changes.
|
||||
- [Logic gate diagram](../logic_gate_diagram.html) — a visual truth-table sheet for the Logic Gate node (download and open
|
||||
it in a browser).
|
||||
- [Third-party notices](../../THIRD_PARTY_NOTICES.md) — licences of bundled code, downloaded models and SAM 3.
|
||||
- [Third-party notices](../../THIRD_PARTY_NOTICES.md) — licences of bundled code, downloaded models, Sapiens2 and SAM 3.
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -12,7 +12,7 @@ Three nodes that work with a mask. One resizes an image to an exact size and kee
|
||||
| [NH Agnostic Image Generator](#nh-agnostic-image-generator) | Erase the masked area with grey, noise or blur |
|
||||
| [NH Simple Face Paste](#nh-simple-face-paste) | Paste the masked face of one image onto the masked area of another |
|
||||
|
||||
A `MASK` is white (`1`) where the node should work and black (`0`) elsewhere. Get one from a segmentation node such as [SAM3 Segment (NH)](./vision.md#sam3-segment-nh) (any object you can name, or a formula such as `person - bag` or `background`), [Garment Segment (NH)](./vision.md#garment-segment-nh) or [Portrait Segment (NH)](./vision.md#portrait-segment-nh), or from any mask tool in ComfyUI.
|
||||
A `MASK` is white (`1`) where the node should work and black (`0`) elsewhere. Get one from a segmentation node such as [SAM3 Segment (NH)](./vision.md#sam3-segment-nh) (any object you can name, or a formula such as `person - bag` or `background`), [Garment Segment (NH)](./vision.md#garment-segment-nh), [Portrait Segment (NH)](./vision.md#portrait-segment-nh) or [Body Segment (NH)](./vision.md#body-segment-nh) (the skin, hair or clothes of people, by name), or from any mask tool in ComfyUI.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,25 +2,26 @@
|
||||
|
||||
# Vision
|
||||
|
||||
Eight nodes that cut parts out of a photo. Two use SegFormer models that know people: clothing with Garment Segment, and face, hair and neck with Portrait Segment. Each returns a mask at the size of the input image, a cutout with transparency and a mask preview. The other six work with SAM 3, Meta's segmentation model: you type what you want into SAM3 Segment (people, a wine glass, windows, the sky, or a formula such as `person - bag` or `background`) and it returns the mask and shows it on the node. It also keeps every instance it found, and five Segs nodes pick, combine, preview and convert them. Find them under **NH-Nodes → Vision**.
|
||||
Nine nodes that cut parts out of a photo. Three of them parse people with models that know them: clothing with Garment Segment, face, hair and neck with Portrait Segment, and the skin, hair, lips, teeth, arms, legs and clothes of every person with Body Segment, where you type the names of the parts you want. Each returns a mask at the size of the input image, a cutout with transparency, a mask preview and the parts it found as instances. The other six work with SAM 3, Meta's segmentation model: you type what you want into SAM3 Segment (people, a wine glass, windows, the sky, or a formula such as `person - bag` or `background`) and it returns the mask and shows it on the node. It also keeps every instance it found, and five Segs nodes pick, combine, preview and convert them, also together with the parts that the three people nodes found. Find them under **NH-Nodes → Vision**.
|
||||
|
||||
**On this page**
|
||||
|
||||
- [Which node do I need?](#which-node-do-i-need) — the SegFormer nodes or SAM 3
|
||||
- **SegFormer nodes: garments and portraits**
|
||||
- [Which node do I need?](#which-node-do-i-need) — the people nodes or SAM 3
|
||||
- **People nodes: garments, portraits and body parts**
|
||||
- [Before you start](#before-you-start) — models, the face detector, first run, licences, memory
|
||||
- [How the SegFormer nodes look at a photo](#how-the-segformer-nodes-look-at-a-photo) — why small faces and people get close-ups
|
||||
- [Refining the mask](#refining-the-mask) — the options both nodes share
|
||||
- [Garment Segment (NH)](#garment-segment-nh) — mask upper garments, lower garments, footwear or headwear
|
||||
- [How the SegFormer nodes look at a photo](#how-the-segformer-nodes-look-at-a-photo) — why small faces and people get close-ups, Body Segment included
|
||||
- [Refining the mask](#refining-the-mask) — the options all three nodes share
|
||||
- [Garment Segment (NH)](#garment-segment-nh) — mask upper garments, lower garments, footwear, headwear, or a dress, bag, scarf…; two models to choose from
|
||||
- [Portrait Segment (NH)](#portrait-segment-nh) — mask face, hair, neck and facial features
|
||||
- [Body Segment (NH)](#body-segment-nh) — type the parts you want: skin, hair, lips, teeth, arms, legs, clothes
|
||||
- **SAM 3 nodes: anything you can name**
|
||||
- [Before you start with SAM 3](#before-you-start-with-sam-3) — GPU, package, checkpoint, memory, licence
|
||||
- [How the two passes work](#how-the-two-passes-work) — what `balanced` does on 4K to 8K photos, and where it stops
|
||||
- [Instances: the NH_SEGS type](#instances-the-nh_segs-type) — what flows between the nodes
|
||||
- [Writing a formula](#writing-a-formula) — what `concepts` and `expression` accept: lists, `-`, `&`, `~`, `background`, Vietnamese words
|
||||
- [Instances: the NH_SEGS type](#instances-the-nh_segs-type) — what flows between the nodes, and which nodes make it
|
||||
- [Writing a formula](#writing-a-formula) — what `concepts`, `expression` and `regions` accept: lists, `-`, `&`, `~`, `background`, Vietnamese words
|
||||
- [SAM3 Segment (NH)](#sam3-segment-nh) — type what you want, get its mask and see it on the node
|
||||
- [Segs Select (NH)](#segs-select-nh) — keep instances by label, score, size and position
|
||||
- [Segs Compose (NH)](#segs-compose-nh) — build a mask from labels with `|`, `-`, `&` and `~`
|
||||
- [Segs Compose (NH)](#segs-compose-nh) — build a mask from labels with `|`, `-`, `&` and `~`, also from several nodes at once
|
||||
- [Segs To Masks (NH)](#segs-to-masks-nh) — one mask per instance, the union and the labels
|
||||
- [Segs To Impact SEGS (NH)](#segs-to-impact-segs-nh) — hand the instances to Impact Pack's detailers
|
||||
- [Segs Preview (NH)](#segs-preview-nh) — draw instances on their picture
|
||||
@@ -31,57 +32,62 @@ Eight nodes that cut parts out of a photo. Two use SegFormer models that know pe
|
||||
|
||||
| You want | Use | What it needs |
|
||||
|---|---|---|
|
||||
| The clothing of one person or of a group: shirt, trousers, shoes, hat | [Garment Segment](#garment-segment-nh) | A SegFormer model and a small face detector that download themselves on the first run. Runs on the CPU or the GPU. |
|
||||
| The face, hair, neck and facial features of a portrait, a half-body shot or every face of a group photo | [Portrait Segment](#portrait-segment-nh) | The same. |
|
||||
| The clothing of one person or of a group: shirt, dress, trousers, shoes, hat, bag, scarf | [Garment Segment](#garment-segment-nh) | A SegFormer model (256 MB) and a small face detector that download themselves on the first run. Runs on the CPU or the GPU. |
|
||||
| The face, hair, neck and facial features of a portrait, a half-body shot or every face of a group photo | [Portrait Segment](#portrait-segment-nh) | The same, with a 340 MB model. |
|
||||
| The skin, hair, lips, teeth, arms, hands, legs or clothes of the people in a photo, by the names of the parts (`skin`, `hair, face`, `clothes - shoes`) | [Body Segment](#body-segment-nh) | A Sapiens2 model (1.6 GB) and the same face detector, which download themselves on the first run, and PyTorch 2.4 or newer. Meant for a GPU: the CPU is slow. |
|
||||
| Anything you can name (people, objects, sky, walls, windows) as a mask from a short formula (`person - bag`, `background`), or every instance on its own, also on 4K to 8K photos | [SAM3 Segment](#sam3-segment-nh) and the Segs nodes | An NVIDIA GPU, Meta's `sam3` package and a checkpoint that you install and download yourself. See [Before you start with SAM 3](#before-you-start-with-sam-3). |
|
||||
|
||||
You can ask SAM3 Segment for `dress` or `hair` too. The SegFormer nodes stay the easier choice for their fixed sets of clothing and face parts: they set themselves up and also run without a GPU.
|
||||
You can ask SAM3 Segment for `dress` or `hair` too. The people nodes stay the easier choice for their fixed sets of parts: they set themselves up, parse every person of the picture at once, and Garment Segment and Portrait Segment also run without a GPU.
|
||||
|
||||
**Choosing between the people nodes.** Garment Segment tells garments apart (a top from a dress, a skirt from trousers) and also finds bags, hats, scarves, glasses and jewelry. Body Segment names the body: skin, hair, lips, teeth, arms, legs, and clothes as a whole. Portrait Segment is the one that knows the eyes, eyebrows, nose and ears. [Segs Compose](#segs-compose-nh) mixes what they found, for example `face - eyes - eyebrows` (the face of Body Segment and of Portrait Segment, without the eyes and eyebrows of Portrait Segment).
|
||||
|
||||
## Before you start
|
||||
|
||||
This section is about Garment Segment and Portrait Segment. SAM3 Segment and the Segs nodes have their own: [Before you start with SAM 3](#before-you-start-with-sam-3).
|
||||
This section is about the three people nodes: Garment Segment, Portrait Segment and Body Segment. SAM3 Segment and the Segs nodes have their own: [Before you start with SAM 3](#before-you-start-with-sam-3).
|
||||
|
||||
| | Garment Segment (NH) | Portrait Segment (NH) |
|
||||
|---|---|---|
|
||||
| Model | SegFormer trained for Fashionpedia clothing classes | SegFormer fine-tuned on CelebAMask-HQ face parts |
|
||||
| Hugging Face repository | `1038lab/segformer_fashion` | `jonathandinu/face-parsing` |
|
||||
| Download | about 190 MB | about 340 MB |
|
||||
| Licence | The uploader declares none | Non-commercial research and education only |
|
||||
| Node and model | Hugging Face repository | Download | Licence |
|
||||
|---|---|---|---|
|
||||
| Garment Segment (NH), `model` `fashn` (the default): FASHN Human Parser, a SegFormer-B4 fine-tuned from `nvidia/mit-b4`, with 18 classes for fashion | `fashn-ai/fashn-human-parser` | about 256 MB | NVIDIA Source Code License for SegFormer: research or evaluation only |
|
||||
| Garment Segment (NH), `model` `fashionpedia`: SegFormer trained for Fashionpedia clothing classes, the model of 1.8.0 and before | `1038lab/segformer_fashion` | about 190 MB | The uploader declares none, but it is fine-tuned from `nvidia/mit-b3`: treat it like `fashn` |
|
||||
| Portrait Segment (NH): SegFormer fine-tuned on CelebAMask-HQ face parts | `jonathandinu/face-parsing` | about 340 MB | Non-commercial research and education only |
|
||||
| Body Segment (NH): Sapiens2 (Meta, 2026), with 28 body-part and clothing classes plus background | `facebook/sapiens2-seg-0.4b`, `-0.8b` or `-1b` | 1.6 GB (`0.4b`), 3.3 GB (`0.8b`) or 5.9 GB (`1b`) | Sapiens2 License (Meta): royalty-free, no non-commercial clause, but with use restrictions |
|
||||
|
||||
**First run.** The first time a node runs, it downloads its model from Hugging Face, together with the face detector described below. Each download is pinned to a fixed revision of the repository, so a later change upstream cannot alter what you get. The files go to `ComfyUI/models/NH-Nodes/`, one sub-folder per model. A complete copy that already exists is used instead: in `models/RMBG/segformer_fashion` for the garment model, in `models/face_parsing` or `models/RMBG/segformer_face` for the face model, or in any folder you add under the `nh_nodes` key in `extra_model_paths.yaml` (see [Installation](../../README.md#installation) for an example). If a download is interrupted, run the node again: finished files are kept.
|
||||
**First run.** The first time a node runs, it downloads its model from Hugging Face, together with the face detector described below: the model chosen in `model` for Garment Segment, the size chosen in `model_size` for Body Segment. Each download is pinned to a fixed revision of the repository, so a later change upstream cannot alter what you get. The files go to `ComfyUI/models/NH-Nodes/`, one sub-folder per model (`fashn_human_parser`, `segformer_fashion`, `face_parsing`, `sapiens2-seg-<size>`). A complete copy that already exists is used instead: in `models/RMBG/segformer_fashion` for the Fashionpedia model, in `models/face_parsing` or `models/RMBG/segformer_face` for the face model, or in any folder you add under the `nh_nodes` key in `extra_model_paths.yaml` (see [Installation](../../README.md#installation) for an example). If a download is interrupted, run the node again: finished files are kept. A machine that is offline fails on the first run of a model until that model is downloaded.
|
||||
|
||||
**Face detector.** Both nodes also use YuNet, a small face detector (about 230 KB, MIT licence, from OpenCV Zoo), to find where the people are before they parse a picture. It is downloaded once, pinned to a fixed revision of `opencv/face_detection_yunet`, into `ComfyUI/models/NH-Nodes/yunet/`. It runs on the CPU with the OpenCV that NH-Nodes already needs, so it takes no VRAM. A copy that already sits in a `yunet` sub-folder of any `nh_nodes` folder (`models/NH-Nodes/`, or one you add in `extra_model_paths.yaml`) is used instead; to run offline, put `face_detection_yunet_2023mar.onnx` there yourself. If the detector cannot be loaded (offline on the first run, or an OpenCV that cannot run it), the node prints one warning in the console and parses whole pictures, without the close-ups described in [How the SegFormer nodes look at a photo](#how-the-segformer-nodes-look-at-a-photo). Restart ComfyUI to try again.
|
||||
**Face detector.** All three nodes also use YuNet, a small face detector (about 230 KB, MIT licence, from OpenCV Zoo), to find where the people are before they parse a picture. It is downloaded once, pinned to a fixed revision of `opencv/face_detection_yunet`, into `ComfyUI/models/NH-Nodes/yunet/`. It runs on the CPU with the OpenCV that NH-Nodes already needs, so it takes no VRAM. A copy that already sits in a `yunet` sub-folder of any `nh_nodes` folder (`models/NH-Nodes/`, or one you add in `extra_model_paths.yaml`) is used instead; to run offline, put `face_detection_yunet_2023mar.onnx` there yourself. If the detector cannot be loaded (offline on the first run, or an OpenCV that cannot run it), the node prints one warning in the console and goes on without it. Portrait Segment then parses the whole picture, without the close-ups described in [How the SegFormer nodes look at a photo](#how-the-segformer-nodes-look-at-a-photo). Garment Segment and Body Segment still take close-ups of the people that their first look at the whole picture finds, but they can miss a person whom that first look overlooks. Restart ComfyUI to try again.
|
||||
|
||||
**Memory.** A model is read from disk once and kept in system memory. It moves to the GPU only while a node runs, and goes back to system memory afterwards, so it does not hold VRAM between runs. Before it moves to the GPU, the node asks ComfyUI to unload some of its own models if VRAM is tight.
|
||||
**Memory.** A model is read from disk once and kept in system memory. It moves to the GPU only while a node runs, and goes back to system memory afterwards, so it does not hold VRAM between runs. Before it moves to the GPU, the node asks ComfyUI to unload some of its own models if VRAM is tight. Body Segment keeps one Sapiens2 size in memory at a time: when you ask for another size, the first one is dropped.
|
||||
|
||||
> **Note:** If a node reports unexpected labels, a different checkpoint sits in one of the folders it searches. Remove that copy and the right model is downloaded.
|
||||
> **Note:** If a node reports unexpected labels, a different checkpoint sits in one of the folders it searches. Remove that copy and the right model is downloaded. Body Segment says so in the same way when a file is not the Sapiens2 checkpoint of the size you chose.
|
||||
|
||||
> **Licence:** The model behind Portrait Segment may only be used for non-commercial research and education. The model behind Garment Segment declares no licence, so its terms are unclear. NH-Nodes' own code is MIT. The YuNet face detector is MIT-licensed and changes none of this. Read [THIRD_PARTY_NOTICES.md](../../THIRD_PARTY_NOTICES.md) before you use either node commercially.
|
||||
> **Licence:** These models come with terms that limit how you may use them. The model behind Portrait Segment may only be used for non-commercial research and education. Both models behind Garment Segment are built on NVIDIA's SegFormer. FASHN Human Parser is under the NVIDIA Source Code License for SegFormer, whose section 3.3 says the work "only may be used or intended for use non-commercially" and defines that as "for research or evaluation purposes only": narrower than "any use that earns no money", it is research or evaluation and nothing else. The Fashionpedia model declares no licence, but it is fine-tuned from `nvidia/mit-b3`, so the same licence plausibly applies. Sapiens2, behind Body Segment, is under Meta's Sapiens2 License: royalty-free, with no non-commercial clause, but it prohibits surveillance, biometric processing, identifying people, deepfakes and other uses, and it requires you to follow privacy, biometric and trade-control laws. NH-Nodes' own code is MIT. The YuNet face detector is MIT-licensed and changes none of this. Read [THIRD_PARTY_NOTICES.md](../../THIRD_PARTY_NOTICES.md) before you use any of these nodes for paid work.
|
||||
|
||||
## How the SegFormer nodes look at a photo
|
||||
|
||||
Both models look at a 512 × 512 picture. On a 6000 px photo, a face that is 600 px wide is only about 50 px wide to the model, so the nodes do not hand it the photo in one piece.
|
||||
The models behind these nodes look at a small picture of a fixed size (width × height): 512 × 512 for the face model and the Fashionpedia model, 384 × 576 for FASHN, 768 × 1024 for Sapiens2. On a 6000 px photo, a face that is 600 px wide is only about 50 px wide to a 512 px model, so the nodes do not hand it the photo in one piece.
|
||||
|
||||
1. **The whole picture first.** A picture with a side longer than twice the model's 512 px is shrunk with area averaging, which is faster than the model's own resize and keeps fine detail clean, and then parsed. This first answer is kept wherever nothing better is found, so whatever the later steps miss keeps at least this result.
|
||||
2. **People are found.** The face detector looks for faces on two sizes of the picture. Garment Segment also takes the people that the first parse found.
|
||||
3. **Close-ups refine the answer.** Each face (Portrait Segment) or group of people (Garment Segment) is parsed again on a crop that the model sees at its full 512 px. The crop's labels go into the first answer, but not along the crop's cut edges, where the model sees things cut off, so no straight edge shows in hair or clothes. How far a crop is trusted differs by node, as the Garment Segment and Portrait Segment sections describe.
|
||||
1. **The whole picture first.** A big picture is shrunk with area averaging, which is faster than the model's own resize and keeps fine detail clean, and then parsed. This first answer is kept wherever nothing better is found, so whatever the later steps miss keeps at least this result.
|
||||
2. **People are found.** The face detector looks for faces on two sizes of the picture. Garment Segment and Body Segment also take the people that the first parse found.
|
||||
3. **Close-ups refine the answer.** Each face (Portrait Segment) or group of people (Garment Segment, Body Segment) is parsed again on a crop that the model sees at its full resolution. The crop's labels go into the first answer, but not along the crop's cut edges, where the model sees things cut off, so no straight edge shows in hair or clothes. How far a crop is trusted differs by node, as the sections of the nodes describe. A crop shorter than 128 px is not parsed again: it would be blown up 4 to 8 times to reach the model's input, so most of what the model returns is made up, and it came back as scattered specks (a small thing held in the hand, for example). The labels of the first look stay there.
|
||||
|
||||
On a photo longer than 4096 px, all of this is worked out on a copy 4096 px long and the label map is enlarged to the photo at the end: the model sees 512 px at a time, so its labels hold no finer detail, and merging crops at full 8K size would take seconds.
|
||||
On a photo longer than 4096 px, all of this is worked out on a copy 4096 px long and the label map is enlarged to the photo at the end: the models see 384 to 1024 px at a time, so their labels hold no finer detail, and merging crops at full 8K size would take seconds.
|
||||
|
||||
What each node does with this is in its own section: [Garment Segment](#garment-segment-nh) and [Portrait Segment](#portrait-segment-nh).
|
||||
Body Segment runs Sapiens2, not a SegFormer, and follows the same three steps with crops of its own 3:4 shape. It sees each crop at 768 × 1024, so a person gets about 1024 px whatever the size of the photo. It adds a check at the start: when no face is found, it takes the picture to have nobody in it if Sapiens2 is unsure of most of what it labels as people, or if a look at the mirrored picture does not find the same people (see [Body Segment](#body-segment-nh)).
|
||||
|
||||
What each node does with this is in its own section: [Garment Segment](#garment-segment-nh), [Portrait Segment](#portrait-segment-nh) and [Body Segment](#body-segment-nh).
|
||||
|
||||
## Refining the mask
|
||||
|
||||
Garment Segment and Portrait Segment turn the model's label map into a mask, then refine it in this order: `fill_holes`, `mask_expand`, `mask_blur`. The mask always has the size of the input image.
|
||||
Garment Segment, Portrait Segment and Body Segment turn the model's label map into a mask, then refine it in this order: `fill_holes`, `mask_expand`, `mask_blur`. The mask always has the size of the input image.
|
||||
|
||||
| Option | Range | Garment Segment | Portrait Segment | What it does |
|
||||
|---|---|---|---|---|
|
||||
| `fill_holes` | on / off | off | on | Closes holes that the mask completely encloses. It is off for garments by default because an arm across the torso would otherwise be filled in. |
|
||||
| `mask_expand` | -64 to 64 | `0` | `0` | Grows (positive) or shrinks (negative) the mask by that many pixels. |
|
||||
| `mask_blur` | 0 to 64 | `0` | `0` | Feathers the edge by about that many pixels. |
|
||||
| `device` | `cuda`, `cpu` | `cuda` | `cuda` | Where the model runs. Without CUDA the node falls back to the CPU and prints a note once in the console. |
|
||||
| Option | Range | Garment Segment | Portrait Segment | Body Segment | What it does |
|
||||
|---|---|---|---|---|---|
|
||||
| `fill_holes` | on / off | off | on | off | Closes holes that the mask completely encloses. It is off for garments by default because an arm across the torso would otherwise be filled in. It is on for portraits because a face stays solid where glasses, earrings or hair cut into it. |
|
||||
| `mask_expand` | -64 to 64 | `0` | `0` | `0` | Grows (positive) or shrinks (negative) the mask by that many pixels. |
|
||||
| `mask_blur` | 0 to 64 | `0` | `0` | `0` | Feathers the edge by about that many pixels. |
|
||||
| `device` | `cuda`, `cpu` | `cuda` | `cuda` | `cuda` | Where the model runs. Without CUDA the node falls back to the CPU and prints a note once in the console. |
|
||||
|
||||
All three outputs are shared by both nodes:
|
||||
These three outputs are shared by the three nodes. Each node also returns `segs` (the parts it found as instances, described in its own section), and Body Segment a `report`.
|
||||
|
||||
| Output | Type | Description |
|
||||
|---|---|---|
|
||||
@@ -97,45 +103,61 @@ Batches work: every image is segmented in turn and the outputs keep the batch or
|
||||
|
||||
<p align="center"><img src="../assets/nodes/NH_GarmentSegment.svg" alt="Garment Segment (NH) node" width="360"></p>
|
||||
|
||||
Masks one group of clothing, or all groups together, on the people in the image.
|
||||
Masks one group of clothing, one garment (a dress, a bag, a scarf…), or all groups together, on the people in the image. Two models do the work: `fashn` (the default) and `fashionpedia`.
|
||||
|
||||
**Use it when** you want a clean mask of a shirt, trousers, shoes or a hat for inpainting, recolouring or a try-on workflow.
|
||||
**Use it when** you want a clean mask of a shirt, a dress, trousers, shoes, a hat or a bag for inpainting, recolouring or a try-on workflow.
|
||||
|
||||
| Input | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `image` | IMAGE | — | A photo of a person or of a group of people. |
|
||||
| `part` | COMBO | `upper_garment` | `upper_garment`, `lower_garment`, `footwear`, `headwear` or `all_garments`. See the table below. |
|
||||
| `attach_parts` | BOOLEAN | `true` | Merges pockets, zippers, bows, ruffles and similar decorations that touch the selected garment. |
|
||||
| `part` | COMBO | `upper_garment` | A group: `upper_garment`, `lower_garment`, `footwear`, `headwear` or `all_garments`; or one garment: `dress`, `bag`, `belt`, `scarf`, `glasses` or `jewelry`. See the table below. |
|
||||
| `attach_parts` | BOOLEAN | `true` | With `fashionpedia` only: merges pockets, zippers, bows, ruffles and similar decorations that touch the selected garment. It does nothing with `fashn`, which counts them as part of the garment. |
|
||||
| `mask_expand` | INT | `0` | Range -64 to 64. Grows (+) or shrinks (-) the mask by N pixels. |
|
||||
| `mask_blur` | INT | `0` | Range 0 to 64. Feathers the mask edge by N pixels. |
|
||||
| `fill_holes` | BOOLEAN | `false` | Fills enclosed holes. |
|
||||
| `fill_holes` | BOOLEAN | `false` | Fills enclosed holes. Off by default: an arm across the torso would be filled in. |
|
||||
| `device` | COMBO | `cuda` | `cuda` or `cpu`. |
|
||||
| `model` | COMBO | `fashn` | Optional, so that workflows saved before it existed still load: they run `fashn`. `fashn` or `fashionpedia`. See the table below. |
|
||||
|
||||
| Output | Type | Description |
|
||||
|---|---|---|
|
||||
| `mask` | MASK | The mask of the selected part. |
|
||||
| `cutout_rgba` | IMAGE | The input with the mask as alpha. |
|
||||
| `mask_image` | IMAGE | The mask as a grey RGB preview. |
|
||||
| `segs` | NH_SEGS | One instance per part the model found, whatever `part` is, for [Segs Compose](#segs-compose-nh) and the other Segs nodes. With `fashn`: face, hair, top, dress, skirt, pants, belt, bag, hat, scarf, glasses, arms, hands, legs, feet, torso, jewelry. With `fashionpedia`: its class names (shirt, top, sweater, …, sleeve, pocket, …). An instance covers its part on every person in the picture. |
|
||||
|
||||
**Which `model`**
|
||||
|
||||
| `model` | What it is | Good at | Weaker at |
|
||||
|---|---|---|---|
|
||||
| `fashn` (default) | FASHN Human Parser, a SegFormer-B4 for fashion with 18 classes, 256 MB | Clean tops, dresses, skirts, pants, bags, hats, scarves, glasses and jewelry. | No shoe, sock or headband classes. A pale garment close to the colour of the skin. |
|
||||
| `fashionpedia` | `1038lab/segformer_fashion`, the model of 1.8.0 and before, 190 MB | Shoes, socks, headbands and small parts (sleeves, collars, pockets). | Whole garments: it misses some dresses and tops. No jewelry class. |
|
||||
|
||||
**What each `part` covers**
|
||||
|
||||
| `part` | Included |
|
||||
|---|---|
|
||||
| `upper_garment` | Shirts and blouses, tops and T-shirts, sweaters, cardigans, jackets, vests, coats, dresses, jumpsuits, capes, ties, scarves, hoods, collars, lapels, epaulettes, sleeves and necklines. |
|
||||
| `lower_garment` | Pants, shorts, skirts, belts, buckles, leg warmers, tights and stockings. Shoes are not included. |
|
||||
| `footwear` | Shoes and socks. |
|
||||
| `headwear` | Hats, headbands, head coverings and hair accessories. |
|
||||
| `all_garments` | All four groups together in one mask. |
|
||||
| `part` | With `fashn` | With `fashionpedia` |
|
||||
|---|---|---|
|
||||
| `upper_garment` | Tops, dresses and scarves. | Shirts and blouses, tops and T-shirts, sweaters, cardigans, jackets, vests, coats, dresses, jumpsuits, capes, ties, scarves, hoods, collars, lapels, epaulettes, sleeves and necklines. |
|
||||
| `lower_garment` | Skirts, pants and belts. | Pants, shorts, skirts, belts, buckles, leg warmers, tights and stockings. Shoes are not included. |
|
||||
| `footwear` | FASHN's "feet" class: the feet with whatever they wear, so bare feet too. | Shoes and socks. |
|
||||
| `headwear` | Hats. There is no headband class. | Hats, headbands, head coverings and hair accessories. |
|
||||
| `all_garments` | The four groups above together in one mask, bare feet included. | The four groups above together in one mask. |
|
||||
| `dress` | Dresses. | Dresses and jumpsuits. |
|
||||
| `bag` | Bags. | Bags and wallets. |
|
||||
| `belt` | Belts. | Belts and buckles. |
|
||||
| `scarf` | Scarves. | Scarves. |
|
||||
| `glasses` | Glasses. | Glasses. |
|
||||
| `jewelry` | Jewelry. | An empty mask: the model has no jewelry class, and the console says so. Choose `fashn`. |
|
||||
|
||||
**How it works**
|
||||
|
||||
- **Dresses and jumpsuits** belong to `upper_garment`. There is no separate dress option. For a shirt and trousers together, or a dress and shoes, choose `all_garments`.
|
||||
- **Attached parts** are pockets, zippers, appliqués, beads, bows, flowers, fringe, ribbons, rivets, ruffles, sequins and tassels. With `attach_parts` on, a piece is added only when it touches the selected garment (within a few pixels). A pocket on a jacket joins an `upper_garment` mask, but a bow on a bag does not.
|
||||
- **Never selected:** glasses, gloves, watches, bags, wallets and umbrellas have no `part`.
|
||||
- **Close-ups of people.** The node parses the whole picture first and keeps that answer. It then finds the people (in that parse and with the face detector) and cuts square crops, the model's own input shape, around each group of them: a row of overlapping crops for a wide group, one crop for a person or a tight group. Each crop is parsed at the model's full 512 px, and a crop grows when people run past one of its edges. This helps people who are small in a large photo. See [How the SegFormer nodes look at a photo](#how-the-segformer-nodes-look-at-a-photo).
|
||||
- **Dresses and jumpsuits** belong to `upper_garment`, with the tops. `dress` gives the dresses alone. For a shirt and trousers together, or a dress and shoes, choose `all_garments`.
|
||||
- **Footwear with `fashn`** is FASHN's "feet" class, not shoes: the feet with whatever they wear. A bare foot is in `footwear` and in `all_garments` too.
|
||||
- **Attached parts** (`fashionpedia` only) are pockets, zippers, appliqués, beads, bows, flowers, fringe, ribbons, rivets, ruffles, sequins and tassels. With `attach_parts` on, a piece is added only when it touches the selected garment (within a few pixels). A pocket on a jacket joins an `upper_garment` mask, but a bow on a bag does not. `fashn` has no such classes: it counts pockets, zips and patches as part of the garment.
|
||||
- **Not reachable through `part`:** with `fashn`, the face, hair, arms, hands, legs and torso; with `fashionpedia`, gloves, watches and umbrellas. They are in `segs`.
|
||||
- **Close-ups of people.** The node parses the whole picture first and keeps that answer. It then finds the people (in that parse and with the face detector) and cuts crops of the model's own input shape (square for `fashionpedia`, 2:3 for `fashn`) around each group of them: a row of overlapping crops for a wide group, one crop for a person or a tight group. Each crop is parsed at the model's full resolution, and a crop grows when people run past one of its edges. This helps people who are small in a large photo. See [How the SegFormer nodes look at a photo](#how-the-segformer-nodes-look-at-a-photo).
|
||||
- **A crop only refines.** Its labels replace the first answer only where the model is surer, away from the crop's cut edges, and only for parts on people already found. Nothing is invented on an empty backdrop, and no straight crop edge cuts through a garment.
|
||||
|
||||
<p align="center"><img src="../assets/examples/garment-segment.jpg" alt="Garment Segment masks overlaid on a sample photo" width="720"><br><em>Garment Segment on a sample photo: the coloured areas are the masks of the selected parts.</em></p>
|
||||
<p align="center"><img src="../assets/examples/garment-segment.jpg" alt="Garment Segment masks overlaid on a sample photo" width="720"><br><em>Garment Segment on a sample photo: the coloured areas are the masks of the selected part, from the fashn model (the default) and from the fashionpedia model (amber: the parts that attach_parts merged), and the cutout made from the fashn mask.</em></p>
|
||||
|
||||
**Example**
|
||||
|
||||
@@ -145,9 +167,9 @@ Load Image → Garment Segment (NH, upper_garment) → mask → Mask Morphology
|
||||
|
||||
Grow the mask a few pixels with [Mask Morphology (NH)](./mask.md#mask-morphology-nh) so the inpainted garment can overlap the old outline.
|
||||
|
||||
> **Note:** The Fashionpedia model misses some tops, for example a blouse under a big hat. A better garment model is planned.
|
||||
> **Note:** `fashn` can miss a pale garment close to the colour of the skin: a pale pink sleeveless top was only partly found. The model itself misses it, not the crops, and Body Segment's `top` or `clothes` covers it better. It can also label things held near the body as a top, for example champagne glasses. `fashionpedia` misses some tops too, for example a blouse under a big hat.
|
||||
|
||||
> **Licence:** the garment model declares no licence. See [Before you start](#before-you-start).
|
||||
> **Licence:** both garment models are for research or evaluation only. FASHN Human Parser is under the NVIDIA Source Code License for SegFormer, and the Fashionpedia model, which declares no licence, is fine-tuned from NVIDIA's `mit-b3`. See [Before you start](#before-you-start).
|
||||
|
||||
---
|
||||
|
||||
@@ -181,12 +203,13 @@ Masks the face, hair, neck and facial features of a portrait, or of every face i
|
||||
| `mask` | MASK | The combined mask of every ticked region, of every face found. |
|
||||
| `cutout_rgba` | IMAGE | The input with the mask as alpha. |
|
||||
| `mask_image` | IMAGE | The mask as a grey RGB preview. |
|
||||
| `segs` | NH_SEGS | One instance per part the model found, whatever you ticked: skin, nose, glasses, eyes, eyebrows, ears, mouth (the inside of the mouth), lips, hair, hat, earrings, necklace, neck, clothes. An instance covers its part on every person in the picture. For [Segs Compose](#segs-compose-nh) and the other Segs nodes. |
|
||||
|
||||
**How it works**
|
||||
|
||||
- The ticked regions are combined into one mask. With nothing ticked, the mask is empty.
|
||||
- `face` already contains eyes, eyebrows, nose, lips and the inside of the mouth. Those options change the result only when `face` is off.
|
||||
- Hats, earrings, necklaces and clothing exist in the model, but the node does not offer them.
|
||||
- Hats, earrings, necklaces and clothing exist in the model, but the node has no switch for them. They are in `segs`, where [Segs Compose](#segs-compose-nh) can use them.
|
||||
- **The whole picture first.** It is parsed as a padded square (it keeps its proportions, grey fills the rest) instead of being squeezed into the model's square. This answer stays wherever the crops below do not reach, so a picture in which no face is found, or in which the detector is not available, keeps it as the result. Once faces are found, the face parts (skin, eyes, eyebrows, nose, lips, ears) that this answer shows far from all of them are dropped as mistakes, such as a reflection or a pattern; its hair, neck and clothes stay.
|
||||
- **Faces.** The face detector finds the faces (at most 32, the largest ones). When it finds none, the picture gets a grey margin and is searched once more, which finds a face that the frame cuts. The parser then checks each face on a tight crop: a hand or a pattern that the detector took for a face is turned down and changes nothing.
|
||||
- **Two crops per face.** A wide crop shows hair and neck whole. A tight crop, framed like the photos the model learnt on, gives eyes, eyebrows, nose and lips. The wide crop refines what is inside it away from its cut edges, and the tight crop decides only inside an ellipse around the face, so no straight crop edge shows in long hair. Where the crops of two faces overlap, each pixel goes to the nearer face. In a group photo the mask therefore holds the ticked regions of every person.
|
||||
@@ -208,6 +231,171 @@ Switch off every region except `hair`. With the defaults you get face, ears, nec
|
||||
|
||||
---
|
||||
|
||||
## Body Segment (NH)
|
||||
|
||||
<p align="center"><img src="../assets/nodes/NH_BodySegment.svg" alt="Body Segment (NH) node" width="360"></p>
|
||||
|
||||
Type the parts of the body you want, by name (`skin`, `hair, face`, `clothes - shoes`), and get their mask. The model is Sapiens2 (Meta, 2026). It labels every pixel of each person as one of 28 parts, or as background: the face and neck, hair, torso, upper and lower arms, hands, upper and lower legs, feet, lips, teeth, tongue, glasses, upper and lower clothing, shoes, socks and "apparel" (accessories). The node turns the words you type into one mask and shows it on itself.
|
||||
|
||||
**Use it when** you want the skin, hair, lips, teeth, hands or clothes of the people in a photo as a mask from one node, without painting: to retouch skin, recolour hair or lips, protect the hands, or change clothes. Garment Segment is finer for a single garment (a dress, a bag, a scarf), Portrait Segment for the eyes, eyebrows, nose and ears, and SAM3 Segment for anything that is not a person.
|
||||
|
||||
| Input | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `image` | IMAGE | — | A photo with people. Every image of a batch is processed in turn. |
|
||||
| `regions` | STRING | `skin` | The parts you want, by name, written as a formula (see [Writing a formula](#writing-a-formula)): `skin`, `hair, face`, `clothes - shoes`, `~person`, `background`. Commas or new lines list several. The words are in the tables below, in English and in Vietnamese. Write at least one. |
|
||||
| `model_size` | COMBO | `0.4b` | `0.4b`, `0.8b` or `1b`. The larger sizes are slower and need more VRAM. See "Sizes, memory and speed" below. |
|
||||
| `mask_expand` | INT | `0` | Range -64 to 64. Grows (+) or shrinks (-) the mask by N pixels. |
|
||||
| `mask_blur` | INT | `0` | Range 0 to 64. Feathers the mask edge by N pixels. |
|
||||
| `fill_holes` | BOOLEAN | `false` | Fills enclosed holes. |
|
||||
| `device` | COMBO | `cuda` | `cuda` or `cpu`. The CPU works, but it is slow. |
|
||||
| `show_preview` | BOOLEAN | `true` | Optional. Shows the result on the node: the mask bright and the rest darkened, the parts that the mask keeps outlined and labelled, and the report lines below. At most 1536 px on the longer side, and the first 16 pictures of a batch. Off: nothing is drawn, and the node still runs (see "How it works" below). |
|
||||
|
||||
| Output | Type | Description |
|
||||
|---|---|---|
|
||||
| `mask` | MASK | The mask of your formula, one per image, at the size of the image. |
|
||||
| `cutout_rgba` | IMAGE | The input with the mask as alpha. |
|
||||
| `mask_image` | IMAGE | The mask as a grey RGB preview. |
|
||||
| `segs` | NH_SEGS | One instance per part found, whatever `regions` says: hair, face, torso, arms, hands, legs, feet, lips, teeth, tongue, glasses, top, bottom, shoes, socks, accessories. An instance covers its part on every person in the picture. For [Segs Compose](#segs-compose-nh) and the other Segs nodes. |
|
||||
| `report` | STRING | One line per word of `regions` with its share of the picture ("under 0.1%" for a tiny share, "not in the picture" when there is none, "of the pictures" for a batch). Notes follow when a word means something else in Sapiens2 than in everyday use. |
|
||||
|
||||
**The words you can type**
|
||||
|
||||
The words are English, or the common Vietnamese words of the shared list. Case does not matter, singular and plural are the same word (`lower leg` or `lower legs`, `accessory` or `accessories`), and several words in a row are one label (`upper arm` needs no quotes). The tables follow `REGIONS` and `WHOLE_LIMBS` in `nodes/vision/body_segment_nodes.py`, which have the complete lists.
|
||||
|
||||
| Write | You get |
|
||||
|---|---|
|
||||
| `skin`, `body skin` | The skin of the face and neck, torso, arms, hands, legs and feet. Not the lips, teeth, tongue or hair. |
|
||||
| `face`, `neck`, `face skin` | The face and the neck together: one class, with the eyes, eyebrows and beard. |
|
||||
| `head` | The face and the hair. |
|
||||
| `hair` | The hair. |
|
||||
| `arm`, `arms`, `upper arm`, `upper arms`, `lower arm`, `lower arms`, `forearm`, `forearms` | The arms without the hands. `upper arm` is the part above the elbow, `lower arm` and `forearm` the part below it. |
|
||||
| `hand`, `hands` | The hands. |
|
||||
| `leg`, `legs`, `thigh`, `thighs`, `upper legs`, `calf`, `calves`, `lower legs` | The legs without the feet. `thigh` and `upper legs` are the part above the knee, `calf`, `calves` and `lower legs` the part below it. |
|
||||
| `foot`, `feet` | The feet. |
|
||||
| `torso`, `chest`, `belly` | The torso: one class, so the three words are the same. |
|
||||
| `lips`, `lip`, `upper lip`, `lower lip` | The lips (both, or one). |
|
||||
| `teeth`, `tooth`, `upper teeth`, `lower teeth` | The teeth (all, or one row). |
|
||||
| `tongue` | The tongue. |
|
||||
| `mouth` | Lips, teeth and tongue together. |
|
||||
| `glasses`, `eyeglasses`, `sunglasses` | Glasses. |
|
||||
| `top`, `upper clothes`, `shirt`, `t-shirt`, `blouse`, `jacket`, `suit jacket`, `sweater`, `hoodie`, `coat`, `vest`, `bra`, `dress`, `wedding dress`, `ao dai` | Upper clothing. All these words are the same class. |
|
||||
| `bottom`, `lower clothes`, `pants`, `trousers`, `jeans`, `shorts`, `skirt` | Lower clothing. All these words are the same class. |
|
||||
| `shoe`, `shoes`, `high heels`, `sandal`, `sandals`, `sneakers`, `boots` | Shoes. |
|
||||
| `sock`, `socks` | Socks. |
|
||||
| `accessories`, `apparel` | The model's one class for accessories: hats, bags and the like. |
|
||||
| `clothes`, `clothing`, `outfit` | Upper and lower clothing, shoes, socks and accessories. |
|
||||
| `person`, `body` | Everything the model labels as a person. |
|
||||
| `left hand`, `right hand`, `left arm`, `right arm`, `left leg`, `right leg`, `left foot`, `right foot`, `left shoe`, `right shoe` | One side only. See below for which side. |
|
||||
|
||||
| Vietnamese | You get |
|
||||
|---|---|
|
||||
| `da` | Skin (`skin`). |
|
||||
| `tóc` | Hair. |
|
||||
| `mặt`, `da mặt`, `cổ` | The face and the neck, one class. |
|
||||
| `đầu` | The head: the face and the hair. |
|
||||
| `môi`, `răng`, `lưỡi`, `miệng` | The lips, the teeth, the tongue, and the mouth (all three). |
|
||||
| `tay`, `đôi tay`, `hai tay` | The whole arm with the hand, as Vietnamese means it. |
|
||||
| `tay trái`, `tay phải` | The whole left or right arm with its hand. |
|
||||
| `cánh tay` | The arm without the hand. |
|
||||
| `bắp tay`, `cẳng tay` | The upper arm, the forearm. |
|
||||
| `bàn tay` | The hands. |
|
||||
| `chân`, `đôi chân`, `hai chân` | The whole leg with the foot. |
|
||||
| `chân trái`, `chân phải` | The whole left or right leg with its foot. |
|
||||
| `đùi`, `bắp đùi` | The thigh. |
|
||||
| `bắp chân`, `cẳng chân` | The lower leg, below the knee. |
|
||||
| `bàn chân` | The feet. |
|
||||
| `ngực`, `bụng` | The torso. |
|
||||
| `áo`, `áo sơ mi`, `áo thun`, `áo khoác`, `áo len` | Upper clothing. |
|
||||
| `váy`, `đầm`, `váy cưới`, `áo dài` | A dress, which the model labels as upper clothing. |
|
||||
| `quần`, `quần jean`, `quần short` | Lower clothing. |
|
||||
| `chân váy` | A skirt: lower clothing. |
|
||||
| `giày`, `giày cao gót`, `dép` | Shoes. |
|
||||
| `tất`, `vớ` | Socks. |
|
||||
| `kính`, `kính mát` | Glasses. |
|
||||
| `quần áo`, `trang phục` | Clothes. |
|
||||
| `người`, `cơ thể` | The person. |
|
||||
| `nền` | The keyword `background`: everything outside the people. |
|
||||
|
||||
For one side only, write `left hand`, `right foot`… in English, or `tay trái`, `tay phải`, `chân trái` and `chân phải` in Vietnamese. A colour or a size after a Vietnamese word (`áo đỏ`) is not understood here: Body Segment names parts, it does not search by colour.
|
||||
|
||||
**What the words mean**
|
||||
|
||||
Sapiens2 has fewer classes than the words people use, so some words share one. The report adds a note when you use one of these. The notes about the face class, a dress and a skirt appear only when the picture has some of that region.
|
||||
|
||||
- **Left and right** are the person's own sides: `left hand` is on the right of the picture when the person faces the camera.
|
||||
- **`face` is one class**, with the neck, the eyes, the eyebrows and the beard, and `skin` includes it. So `skin - face` also removes the neck, `face - neck` is empty, and a skin mask has no holes for the eyes. For a face mask without eyes and eyebrows, use Portrait Segment, or take its `eyes` and `eyebrows` out with Segs Compose (see below).
|
||||
- **A dress is a top.** Sapiens2 has one class for what is worn on the upper body: `dress - top` is empty, and the skirt of a dress counts as `top`, not as `bottom`. `skirt` finds a separate skirt only.
|
||||
- **`skin` has no lips, teeth or tongue.** They are classes of their own, so `skin - lips` changes nothing, and `skin + lips` is the skin with the lips.
|
||||
- **`clothes` includes the accessories** (bags, hats), the shoes and the socks.
|
||||
- **`tay` and `chân` are the whole limb**, hand and foot included. `arm` and `leg` are the limb without them, so `tay - cánh tay` leaves the hands.
|
||||
|
||||
**Words it refuses**
|
||||
|
||||
A word that Body Segment has no region for stops the node with an error before the model loads, and the error says what to write instead:
|
||||
|
||||
| You write | What to write instead |
|
||||
|---|---|
|
||||
| `eyes`, `eyebrows`, `nose`, `ears` (also `mắt`, `mũi`, `tai`) | They are part of `face` here. Portrait Segment masks them one by one. |
|
||||
| `beard`, `mustache` (also `râu`, `ria mép`) | A beard is part of `face` here, and no NH-Nodes parser has a beard class. SAM3 Segment can try `beard`. |
|
||||
| `hat`, `bag`, `scarf`, `belt`, `jewelry`, `necklace`, `watch`, `gloves`, `tie` (also `mũ`, `túi`, `khăn`) | Sapiens2 has one class for accessories: `accessories`. For one of them, use Garment Segment or SAM3 Segment. |
|
||||
| `shoulders` | They are part of `torso` and `upper arms`. |
|
||||
| `fingers` | They are part of `hands`. |
|
||||
| `skin and hair`, `skin or hair`, `not skin` | `and`, `or` and `not` are not operators here: write `skin + hair` (or `skin, hair`), `skin - hair` and `~skin`. |
|
||||
| A wildcard (`*`, `?`) | Not allowed: write region names. |
|
||||
| A Vietnamese word that is not in the list | Write it in English, or use `da`, `tóc`, `mặt`, `môi`, `răng`, `tay`, `chân`, `áo`, `quần`, `giày`. |
|
||||
| An empty field | Write at least one region, for example `skin`. |
|
||||
|
||||
**How it works**
|
||||
|
||||
- **People, close up.** The node parses the whole picture first, finds the people (in that parse and with the face detector), and parses crops of the model's 3:4 shape around each person or group at 1024 × 768, so a person gets about 1024 px of the model whatever the size of the photo. As in Garment Segment, a crop only refines the first answer, and on a photo longer than 4096 px the work is done on a 4096 px copy that is enlarged at the end. See [How the SegFormer nodes look at a photo](#how-the-segformer-nodes-look-at-a-photo).
|
||||
- **The formula.** Each word picks classes of the model, and the formula combines them as in [Writing a formula](#writing-a-formula). `background` is everything outside the people, the same as `~person` here; `all` is every part found, which is the people; `image` is the whole picture and `none` is nothing. `fill_holes`, `mask_expand` and `mask_blur` then refine the result, in this order.
|
||||
- **A picture with nobody.** When no face is found, the node checks that people are really there, with two tests. First, Sapiens2's first look must be sure of at least half of what it labels as people (it counts a pixel as sure from a confidence of 0.9). Second, a look at the mirrored picture must find the same people: the people of the two looks must overlap by at least 0.6 (IoU). People come back in the mirror; what a model invents on a wall does not. If a test fails, the node takes the picture to have nobody in it: `skin` is empty, `background` is the whole picture, and the report and the preview say "no person found (none Sapiens2 was sure of, and no face)", with the numbers of the images in a batch. The second test costs one more look, only when no face is found. Without these tests, a building facade or an interior with shelves got faint "people" in its walls and shelves, and everyday edits of a facade (a little more exposure, contrast, saturation or sharpening) could lift the first share above one half, which is why the second test exists. A person whose face is found is never dropped. A garment alone (a flat lay or a product shot without a body) counts as nobody, so use Garment Segment for it, and a very stylised or blurred figure without a face can be dropped too.
|
||||
- **How the nobody tests were measured.** On 49 pictures without a face, 32 photos of people (backs, bodies without a head, a mannequin) were kept and 17 pictures of no one (a facade, a café, an elephant, flat lays of products, masks) were emptied. The people overlapped 0.83 to 0.99 with their mirror (edited copies and small people on a plain background went down to 0.64, still above the 0.6 limit), what the model invents on a facade 0.02 to 0.03. On 21 everyday edits each of a facade, a café and an elephant, the map was always empty. On 21 edits each of four faceless photos of people (a mannequin, a headless product shot, a dancer, a product on a body) it was always kept.
|
||||
- **The report.** One line per word, then the notes described above. For a batch, the shares are for all the pictures together, and the preview under each picture shows its own.
|
||||
- **Seeing the result.** With `show_preview` on, the node draws the preview itself: no other node is needed. It outlines the parts the mask keeps: with `skin - hands` it outlines the skin parts and not the hands. To see the parts at the full size of the picture, connect `segs` and the same picture to [Segs Preview](#segs-preview-nh).
|
||||
- **It runs on its own.** Body Segment is an output node (the dot on its card is orange): ComfyUI runs it even when nothing is connected to its outputs. A copy that you no longer need therefore keeps running Sapiens2 whenever its inputs change, and turning `show_preview` off does not stop it. Mute or delete it.
|
||||
- **Mixing with other nodes.** `segs` holds the same parts whatever you typed in `regions`. Connect it to [Segs Compose](#segs-compose-nh), together with the `segs` of SAM3 Segment, Garment Segment or Portrait Segment run on the same picture: for example Body Segment in `segs`, Portrait Segment in `segs_2` and `face - eyes - eyebrows` as the expression.
|
||||
|
||||
**Sizes, memory and speed**
|
||||
|
||||
| `model_size` | Download | Notes |
|
||||
|---|---|---|
|
||||
| `0.4b` (default) | 1.6 GB | Fast. About 1.5 to 2 GB of VRAM at the peak. |
|
||||
| `0.8b` | 3.3 GB | A larger model: slower, and it needs more VRAM. |
|
||||
| `1b` | 5.9 GB | The largest: slower still, and it needs more VRAM. |
|
||||
|
||||
The weights are downloaded once from Hugging Face (`facebook/sapiens2-seg-<size>`, pinned to a fixed revision), the first time you pick a size, into `ComfyUI/models/NH-Nodes/sapiens2-seg-<size>/`, the `nh_nodes` model folder. They stay in system memory, one size at a time, and move to the GPU only while the node runs. The node needs PyTorch 2.4 or newer and stops with a message when yours is older.
|
||||
|
||||
**Measured on one machine** (an NVIDIA RTX 5090) with `0.4b`: about 1 s on a photo of 1 to 2K px and 4 to 7 s on an 8K photo. Only `0.4b` was timed. On the CPU the node works, but slowly: about 8 s for each crop with 8 threads. Other machines will differ.
|
||||
|
||||
<p align="center"><img src="../assets/examples/body-segment.jpg" alt="Body Segment masks on a sample photo: the skin, the hair and the lips, the clothes, and the background" width="720"><br><em>Body Segment on a sample photo, as the node shows it. Top: "skin", with the report under the picture. Bottom: "tóc, môi" (Vietnamese words), "clothes" and "background"; the bright part is each one's mask output.</em></p>
|
||||
|
||||
**Formulas to start with**
|
||||
|
||||
| `regions` | You get |
|
||||
|---|---|
|
||||
| `skin` | The skin of the face and neck, torso, arms, hands, legs and feet. |
|
||||
| `hair, face` | The hair, and the face with the neck. |
|
||||
| `tóc, môi` | The hair and the lips (Vietnamese words). |
|
||||
| `clothes - shoes` | What the people wear, without the shoes. |
|
||||
| `skin - hands` | The skin without the hands. |
|
||||
| `skin + lips` | The skin with the lips. |
|
||||
| `background` | Everything outside the people. |
|
||||
|
||||
**Example**
|
||||
|
||||
```text
|
||||
Load Image → Body Segment (NH, regions: skin) → mask → Mask Morphology (NH, shrink) → retouch
|
||||
```
|
||||
|
||||
Shrink the mask a few pixels with [Mask Morphology (NH)](./mask.md#mask-morphology-nh) so that the edit stays inside the skin, away from the edge of the hair and the clothes.
|
||||
|
||||
> **Note:** Old, faded or sepia scans are a known limit. Sapiens2 can take the whole backdrop for clothes: a scanned group photo came out 96% "person". Use Garment Segment (`fashn`) or SAM3 Segment with `person` there.
|
||||
|
||||
> **Licence:** Sapiens2 is under Meta's Sapiens2 License. It is royalty-free and has no non-commercial clause, but it prohibits surveillance, biometric processing, identifying or re-identifying people, deepfakes, impersonation and misleading content, inferring sensitive data about people without the rights and consents that the law requires, and some other uses. It requires you to follow privacy, biometric and trade-control laws, and it lets Meta audit your use, end the licence and change its terms. NH-Nodes' own code is MIT, and it includes Meta's model code with the licence text but not the weights, which are downloaded from Meta's Hugging Face repositories. Read [THIRD_PARTY_NOTICES.md](../../THIRD_PARTY_NOTICES.md) before you use the node.
|
||||
|
||||
---
|
||||
|
||||
## Before you start with SAM 3
|
||||
|
||||
SAM 3 is Meta's model for finding things by name: you write what you want (`person`, `window`, `wine glass`) and it returns every instance of it, each with a score and its own mask. NH-Nodes does not include the model and never downloads it, so SAM3 Segment needs three things from you, once. If another SAM 3 pack already works in your ComfyUI, steps 2 and 3 are usually done: that pack has installed the package and put `sam3.pt` where this node looks.
|
||||
@@ -260,21 +448,24 @@ SAM 3 looks at a picture of about 1000 px. On a 6000 px photo that is a sixth of
|
||||
|
||||
## Instances: the NH_SEGS type
|
||||
|
||||
`NH_SEGS` is the data that SAM3 Segment hands to the Segs nodes. For every image of a batch it holds a list of instances. Each instance keeps its **label** (the English words SAM 3 was asked for: `áo đỏ` is stored as `red top`), its **score**, a tight **box** and the mask **only inside that box**. A full-size mask is built only when a node outputs a `MASK`, so dozens of instances on an 8K photo stay light.
|
||||
`NH_SEGS` is the data that SAM3 Segment, Garment Segment, Portrait Segment and Body Segment hand to the Segs nodes. For every image of a batch it holds a list of instances. Each instance keeps its **label** (in SAM3 Segment the English words SAM 3 was asked for: `áo đỏ` is stored as `red top`; in the other three the name of the part), its **score** (always 1.0 in the other three), a tight **box** and the mask **only inside that box**. A full-size mask is built only when a node outputs a `MASK`, so dozens of instances on an 8K photo stay light.
|
||||
|
||||
- Instances come in the order the labels appear in your concepts, and by score within a label.
|
||||
- **Objects and parts.** SAM3 Segment returns one instance per thing it finds: three people are three instances. Garment Segment, Portrait Segment and Body Segment return one instance per part for the whole picture: with three people, `hair` is the hair of the three together, in one instance. To pick one person among several, use SAM3 Segment.
|
||||
- **The labels of the three people nodes** are listed in the output table of each node: [Garment Segment](#garment-segment-nh), [Portrait Segment](#portrait-segment-nh) and [Body Segment](#body-segment-nh). They do not all use the same words: `mouth` is the inside of the mouth in Portrait Segment, while Body Segment has `lips`, `teeth` and `tongue`. The Segs nodes also read the words of the node that made the segs (`skin` on Body Segment's segs): see [Segs Compose](#segs-compose-nh). When a label finds nothing, look at the labels in the preview or in [Segs To Masks](#segs-to-masks-nh).
|
||||
- Instances of SAM3 Segment come in the order the labels appear in your concepts, and by score within a label. Those of the other three come in the order of their list of parts.
|
||||
- The Segs nodes treat every image of a batch separately.
|
||||
- The instances of an image also keep a small grey print of the picture they were found in. [Segs Compose](#segs-compose-nh) uses it to refuse instances of another photo.
|
||||
- ComfyUI's own nodes do not read `NH_SEGS`. To get masks out, use the `mask` outputs, [Segs Compose](#segs-compose-nh) or [Segs To Masks](#segs-to-masks-nh). To use Impact Pack's detailers, convert with [Segs To Impact SEGS](#segs-to-impact-segs-nh).
|
||||
|
||||
---
|
||||
|
||||
## Writing a formula
|
||||
|
||||
SAM3 Segment's `concepts` and Segs Compose's `expression` read the same small language. You name the things you want, combine them with operators, and the result is one mask per image. In SAM3 Segment the words you write are also what SAM 3 looks for. In Segs Compose they pick instances that were found already.
|
||||
SAM3 Segment's `concepts`, Segs Compose's `expression` and Body Segment's `regions` read the same small language. You name the things you want, combine them with operators, and the result is one mask per image. In SAM3 Segment the words you write are also what SAM 3 looks for. In Segs Compose they pick instances that were found already. In Body Segment they pick parts of the body that its model labels.
|
||||
|
||||
| Write | Meaning |
|
||||
|---|---|
|
||||
| `person` | A label: the instances with that name. Case does not matter. |
|
||||
| `person` | A label: the instances with that name. Case does not matter, and singular and plural are the same when instances are matched (`arm` finds `arms`; `glasses` and `glass` stay apart). |
|
||||
| `red car`, `women's dress` | Several words in a row are one label, with no quotes. A hyphen between two letters or digits belongs to the label (`t-shirt`, `x-ray`), and so does an apostrophe inside a word. |
|
||||
| `person, hair` | A list: the union of its items. A new line works like a comma. A list is read after every other operator, so `person - hair, dress` is `person - hair` plus `dress`. Empty items are skipped. |
|
||||
| `a + b`, `a \| b` | Union: everything in a or in b. |
|
||||
@@ -317,6 +508,8 @@ SAM3 Segment's `concepts` and Segs Compose's `expression` read the same small la
|
||||
| What a label does | It is what SAM 3 is asked to find. | It picks the instances that have that label. A label that no instance has gives an empty part. |
|
||||
| An empty field | Stops with a message to write at least one concept. A field with only comments, or only `all`, `image` or `none`, counts as empty. | Stops with a message to use `all` for every instance. |
|
||||
|
||||
**Body Segment's `regions`** follows SAM3 Segment's rules, with three differences. `or`, `and` and `not` are not operators there (they are refused with advice: write `+`, `-` or `~`). Every word must name a region of the body that its model has, such as `skin`, `hair` or `clothes`: other words, and wildcards, are refused before the model loads. And the Vietnamese words go through the same list, so `tay` and `chân` mean the whole limb there. See [Body Segment](#body-segment-nh).
|
||||
|
||||
---
|
||||
|
||||
## SAM3 Segment (NH)
|
||||
@@ -366,7 +559,7 @@ Combo inputs without a stated default start on their first option.
|
||||
- **Labels.** The English words SAM 3 was asked for are the labels of the instances: `áo đỏ` is looked for as `red top`, and its instances are labelled `red top`. The Segs nodes ignore case when they match labels, so spelling is not a problem.
|
||||
- **The mask.** `mask` is the result of the formula. For `person - bag`, SAM 3 looks for `person` and for `bag`, `mask` is the people without the bags, and `segs` still holds both kinds of instance.
|
||||
- **`background`.** It is everything outside every instance that was found, so the node needs to know where the subject is. When the formula uses it and does not name `person`, SAM 3 also looks for `person`; those instances are part of `segs` and `count` and are outlined in the preview. To keep other things out of the background too, name them: `~(person + wine glass + chair)`. A list is a union, so `person, background` is the whole picture.
|
||||
- **Order and duplicates.** Instances come in the order the labels appear in your formula (the `person` that `background` added comes last), best score first within a label. Two instances of the same label whose masks overlap almost completely (90% or more) count as one, and the better score stays. `max_per_concept` then keeps the best of what is left. Instances with exactly equal scores keep the order SAM 3 gave them.
|
||||
- **Order and duplicates.** Instances come in the order the labels appear in your formula (the `person` that `background` added comes last), best score first within a label. Two instances of the same label whose masks overlap almost completely (90% or more) count as one, and the better score stays. `max_per_concept` then keeps the best of what is left. Instances with exactly equal scores keep the order SAM 3 gave them. A concept written in singular and plural (`arm, arms`) is one search, and the report lists it once; `glass` and `glasses` stay two concepts.
|
||||
- **Seeing the result.** With `show_preview` on, the node draws the preview itself: no other node is needed. To see the picture at full size (the preview is at most 1536 px), or to see what Segs Select works with, connect `segs` and the same picture to [Segs Preview](#segs-preview-nh).
|
||||
- **It runs on its own.** SAM3 Segment is an output node (the dot on its card is orange): ComfyUI runs it even when nothing is connected to its outputs. A copy that you no longer need therefore keeps running SAM 3 whenever its inputs change, and turning `show_preview` off does not stop it. Mute or delete it. While its inputs stay the same, ComfyUI reuses the last result.
|
||||
- **Nothing found** is not an error: you get no instances and a `count` of 0. The mask is empty for a plain list, and the whole picture for a formula such as `background` or `~person`, because nothing was found to leave out.
|
||||
@@ -393,8 +586,8 @@ Keeps the instances you want, by label, score, size and position, in the order y
|
||||
|
||||
| Input | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `segs` | NH_SEGS | — | The instances, from SAM3 Segment or another Segs node. |
|
||||
| `labels` | STRING | empty | The labels to keep, separated by commas. `*` stands for any run of characters and `?` for one character (`*shirt`). Case does not matter, and `_` counts as a space (`red_car` finds `red car`). Labels with spaces need no quotes here: `wine glass, red car`. Common Vietnamese words are translated (`tóc` finds `hair`); the formula words `nền` and `tất cả` are not labels here. Empty keeps every label. |
|
||||
| `segs` | NH_SEGS | — | The instances, from SAM3 Segment, Garment Segment, Portrait Segment, Body Segment or another Segs node. |
|
||||
| `labels` | STRING | empty | The labels to keep, separated by commas. `*` stands for any run of characters and `?` for one character (`*shirt`). Case does not matter, singular and plural are the same (`arm` finds `arms`), and `_` counts as a space (`red_car` finds `red car`). Labels with spaces need no quotes here: `wine glass, red car`. Common Vietnamese words are translated (`tóc` finds `hair`); the formula words `nền` and `tất cả` are not labels here. The words of the node that made the segs work too: `skin` on Body Segment's segs keeps its face, torso, arms, hands, legs and feet parts (see [Segs Compose](#segs-compose-nh)). Empty keeps every label. |
|
||||
| `min_score` | FLOAT | `0.0` | Range 0 to 1. Drops instances with a lower score. |
|
||||
| `min_area` | FLOAT | `0.0` | Range 0 to 1. Drops instances smaller than this fraction of the image (`0.01` is 1%). |
|
||||
| `max_area` | FLOAT | `1.0` | Range 0 to 1. Drops instances larger than this fraction of the image. |
|
||||
@@ -438,14 +631,17 @@ Keeps the instances you want, by label, score, size and position, in the order y
|
||||
|
||||
<p align="center"><img src="../assets/nodes/NH_SegsCompose.svg" alt="Segs Compose (NH) node" width="360"></p>
|
||||
|
||||
Builds one mask from the labels of the instances, with the same small formula as SAM3 Segment's `concepts`: union, minus, overlap and "everything except".
|
||||
Builds one mask from the labels of the instances, with the same small formula as SAM3 Segment's `concepts`: union, minus, overlap and "everything except". The instances can come from up to three nodes at once.
|
||||
|
||||
**Use it when** you already have instances and want "the background", "the person without the hair", "the wall without the windows", or another combination of what SAM3 Segment found: after [Segs Select](#segs-select-nh), or to make several masks from one SAM 3 run. For a single mask you can also write the same formula straight into SAM3 Segment's `concepts`.
|
||||
**Use it when** you already have instances and want "the background", "the person without the hair", "the wall without the windows", or another combination of what SAM3 Segment found: after [Segs Select](#segs-select-nh), or to make several masks from one SAM 3 run. Use it also to mix what several nodes found in the same photo: the bag from SAM 3, the dress from Garment Segment, the lips from Body Segment. For a single mask from SAM 3 alone you can also write the same formula straight into SAM3 Segment's `concepts`.
|
||||
|
||||
| Input | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `segs` | NH_SEGS | — | The instances, from SAM3 Segment or Segs Select. |
|
||||
| `segs` | NH_SEGS | — | The instances, from SAM3 Segment, Garment Segment, Portrait Segment, Body Segment or Segs Select. |
|
||||
| `expression` | STRING | `all` | The formula. See [Writing a formula](#writing-a-formula). The field has several lines: a line break lists, like a comma, and joins when it is next to an operator. |
|
||||
| `segs_2` | NH_SEGS | — | Optional. More instances of the same picture, from another of these nodes. They are mixed into the formula with `segs`. |
|
||||
| `segs_3` | NH_SEGS | — | Optional. And more. |
|
||||
| `check_same_photo` | BOOLEAN | `true` | Optional. Refuses segs that look like different photos of the same size. Turn it off to mix segs of edits of one photo that differ a lot, such as a new background or a relight. See "The same photo only" below. |
|
||||
|
||||
| Output | Type | Description |
|
||||
|---|---|---|
|
||||
@@ -454,7 +650,7 @@ Builds one mask from the labels of the instances, with the same small formula as
|
||||
**What is different here.** The language is the one in [Writing a formula](#writing-a-formula), with these differences from SAM3 Segment:
|
||||
|
||||
- `or`, `and` and `not` are operators: `a or b` is `a | b`, `a and b` is `a & b`, `not a` is `~a`.
|
||||
- A label picks the instances that have it. Case does not matter, `_` counts as a space (`red_car` finds `red car`), and `*` and `?` are wildcards (`*shirt`).
|
||||
- A label picks the instances that have it. Case does not matter, singular and plural are the same (`arm` finds `arms`, `foot` finds `feet`, `shoe` finds `shoes`, `scarf` finds `scarves`; `glasses` and `glass` are kept apart), `_` counts as a space (`red_car` finds `red car`), and `*` and `?` are wildcards, matched as written (`*shirt`).
|
||||
- Put a label that contains an operator character in quotes, `"` or `'` (`"salt & pepper"`), and use double quotes when it also has an apostrophe. Several words in a row need no quotes.
|
||||
- Vietnamese labels are translated (`tóc` finds `hair`), also inside quotes. `nền` is the keyword `background`.
|
||||
|
||||
@@ -466,12 +662,31 @@ Builds one mask from the labels of the instances, with the same small formula as
|
||||
| `(wall \| ceiling) - window` | The walls and the ceiling with the window openings cut out. |
|
||||
| `wine glass, person` | The glasses and the people together (a list, the same as `wine glass \| person`). |
|
||||
| `all - person` | Everything that was found except the people. Unlike `~person`, it leaves out the background that nothing found. |
|
||||
| `dress - bag - lips` | With three nodes on the same photo: SAM3 Segment (`bag`) in `segs`, Garment Segment in `segs_2` and Body Segment in `segs_3`. The dress without any bag and without the lips. |
|
||||
| `face - eyes - eyebrows` | Body Segment in `segs` and Portrait Segment in `segs_2`. `face` takes the face of both nodes (see the next table), and the eyes and eyebrows of Portrait Segment come out. |
|
||||
| `skin - bag` | SAM3 Segment (`bag`) in `segs` and Body Segment in `segs_2`. The skin without the bag: `skin` is one of Body Segment's own words (see the next table). |
|
||||
| `skin - hands` | Body Segment's segs alone, with the same words. The skin without the hands. |
|
||||
|
||||
**A node's own words.** A label picks the instances that carry it. Each node is read on its own: where a node has no instance with the word as its label, Segs Compose reads the word as one of that node's own words and takes the parts it stands for. An instance of one node that carries the word does not hide another node's word: with Portrait Segment's and Body Segment's segs together, `skin` is Portrait Segment's skin instance and Body Segment's skin parts. [Segs Select](#segs-select-nh) reads the same words in `labels`. Only group words stand for several parts:
|
||||
|
||||
| On the segs of | Group words | What they take |
|
||||
|---|---|---|
|
||||
| Body Segment | `skin` (`body skin`), `head`, `mouth`, `clothes` (`clothing`, `outfit`), `person` (`body`), `upper clothes` (top), `lower clothes` (bottom), `apparel` (accessories), the Vietnamese words for them (`da`, `đầu`, `miệng`, `quần áo`, `người`), and the whole limbs `tay`, `đôi tay`, `hai tay`, `chân`, `đôi chân`, `hai chân` | `skin`: the face, torso, arms, hands, legs and feet parts. `head`: face and hair. `mouth`: lips, teeth and tongue. `clothes`: top, bottom, shoes, socks and accessories. `person`: every part. `tay`, `đôi tay`, `hai tay`: arms and hands. `chân`, `đôi chân`, `hai chân`: legs and feet. |
|
||||
| Garment Segment | The groups of its `part`: `upper_garment`, `lower_garment`, `footwear`, `headwear`, `all_garments` | With `fashn`: `upper_garment` is top, dress and scarf, `lower_garment` skirt, pants and belt, `footwear` the feet, `headwear` the hat, `all_garments` all of these. With `fashionpedia`: its own groups (`footwear` is shoe and sock, `headwear` hat and headband…). |
|
||||
| Portrait Segment | `face`, `face skin`, `mouth interior` | `face`: skin, nose, glasses, eyes, eyebrows, mouth and lips. `face skin`: the skin part. `mouth interior`: the mouth part, the inside of the mouth. |
|
||||
|
||||
- A word that is the name of one of a node's own parts never stands for other parts: Fashionpedia's `belt` is only its belt.
|
||||
- The near synonyms of Body Segment's `regions` (`dress`, `pants`, `skirt`, `jeans`, `shirt`, `neck`…) are not read here: with Body Segment's segs alone, `dress` or `váy` and `pants` or `quần` find nothing. Write the part names (`top`, `bottom`) instead, or mix in Garment Segment's segs, which label dress, skirt and pants for real.
|
||||
- A word that covers only a piece of a part (`left hand`, `upper arm`, `thigh`) finds nothing here, and neither do the side forms `tay trái`, `tay phải`, `chân trái` and `chân phải`: Body Segment's segs hold no instance of one side. Body Segment's `regions` has those. `cánh tay` is still the arms alone.
|
||||
- Segs Select reads the Vietnamese `tay` and `chân` as `arm` and `leg`, the arms and the legs without the hands and the feet.
|
||||
|
||||
**How it works**
|
||||
|
||||
- **A label that no instance has** gives an empty part, and the console says so (`no instance is labelled 'x'; those parts are empty`). A typo in `~persn` therefore gives the whole picture: check the console when a mask looks wrong.
|
||||
- **A label that no instance has** gives an empty part, and the console says so and lists the labels the segs hold (`no instance is labelled 'x'; those parts are empty. The segs hold: hair, face, …`). A typo in `~persn` therefore gives the whole picture: check the console when a mask looks wrong.
|
||||
- **Words in a row are one label.** When no instance has that label but each word is the label of an instance (`person hair`), the node stops with an error that suggests `person + hair`. The same goes for a hyphen: when both `person` and `hair` were found, `person-hair` stops with an error that shows `person - hair`.
|
||||
- **Every image** of a batch gets its own mask, built from its own instances.
|
||||
- **Mixing nodes.** `segs`, `segs_2` and `segs_3` are put together before the formula is read: every image gets the instances of all of them, and a label picks the instances of every node that has it. When two nodes use the same label (`hair` in Portrait Segment and in Body Segment, `glasses` in three nodes), that label is the union of their masks. Each node cuts the picture with its own model, so masks from different nodes do not line up edge to edge.
|
||||
- **The same photo only.** The node stops when the segs come from different pictures: another number of images, or another size. With `check_same_photo` on, it also stops when they look like different photos of the same size. Every frame of segs keeps a small grey print of its picture, and the prints must agree. Edits of the same photo (brightness, contrast, colour, blur) are accepted. This is a guard against wiring mistakes, not a proof: it catches most other photos of the same size, a copy shifted or zoomed a little usually passes, and a new background behind the same person can fail. For edits like that, turn `check_same_photo` off. Segs made by older versions have no print and are mixed on size alone.
|
||||
- **A formula that cannot be read** stops the node with a message that gives the position of the problem, for example `unexpected '(' at position 8` for `person (adult)`.
|
||||
|
||||
<p align="center"><img src="../assets/examples/sam3-segment.jpg" alt="SAM3 Segment instances drawn by Segs Preview, and three masks built from them with Segs Compose and Segs Select on a sample photo" width="720"><br><em>The instances of "person, helmet, face" on a sample photo, drawn by Segs Preview, then three masks made from them with Segs Compose and Segs Select.</em></p>
|
||||
@@ -488,7 +703,7 @@ Turns the instances into ordinary masks: one mask per instance, the union per im
|
||||
|
||||
| Input | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `segs` | NH_SEGS | — | The instances, from SAM3 Segment or Segs Select. |
|
||||
| `segs` | NH_SEGS | — | The instances, from SAM3 Segment, Garment Segment, Portrait Segment, Body Segment or Segs Select. |
|
||||
|
||||
| Output | Type | Description |
|
||||
|---|---|---|
|
||||
@@ -511,7 +726,7 @@ Converts the instances of one image to Impact Pack's `SEGS`, so that its detaile
|
||||
|
||||
| Input | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `segs` | NH_SEGS | — | The instances, from SAM3 Segment or Segs Select. |
|
||||
| `segs` | NH_SEGS | — | The instances, from SAM3 Segment, Garment Segment, Portrait Segment, Body Segment or Segs Select. |
|
||||
| `crop_factor` | FLOAT | `3.0` | Range 1 to 10. The size of the region a detailer works on around each instance, as a multiple of its box (Impact Pack's `crop_factor`). The region is moved to stay inside the image and always contains the box. |
|
||||
| `batch_index` | INT | `0` | Range 0 to 4095. Impact Pack's SEGS hold one image, so this picks which image of the batch to convert. A number past the last image stops the node with a message. |
|
||||
|
||||
@@ -604,7 +819,9 @@ Load Image → SAM3 Segment (concepts: building - (window + door))
|
||||
| An error from Segs Preview about sizes or the number of images | Connect the picture that the segs were found in. |
|
||||
| An error from Segs Compose that gives a position | Check the formula at that position. See [Writing a formula](#writing-a-formula). |
|
||||
| An error from Segs Compose that no instance is labelled `person hair`, but `person` and `hair` are | Words in a row are one label. Join them: `person + hair`. |
|
||||
| A Segs Compose mask is empty, and the console says no instance has a label | Check the spelling of the label. Put spaces around a minus: `person - hair`, not `person-hair`. |
|
||||
| A Segs Compose mask is empty, and the console says no instance has a label | Check the spelling of the label, and read the labels the console lists under "The segs hold". Put spaces around a minus: `person - hair`, not `person-hair`. Labels differ between nodes: see the output table of the node that made the segs, and its own words under "A node's own words" in [Segs Compose](#segs-compose-nh). |
|
||||
| An error from Segs Compose that the segs to mix come from different pictures | `segs`, `segs_2` and `segs_3` must come from the same image batch: the same number of images, of the same sizes. Run every node on the same Load Image. |
|
||||
| An error from Segs Compose that the segs to mix look like different photos of the same size | One of the nodes ran on another photo of the same size. Run every node on the same photo. If they are edits of one photo that differ a lot (a new background, a relight), turn `check_same_photo` off. A copy with only brightness, contrast, colour or blur changed is accepted. |
|
||||
| An error from Segs To Masks that the full-size instance masks would need more memory than is free | There are too many instances for the size of the picture. Use Segs Select to keep only the ones you need, or Segs Compose to get one mask. |
|
||||
| The console says that SAM 3 sees the picture squeezed into a square and can miss small things | The picture is wider or taller than 4:1. Split it into tiles with Image Tile (NH) and run the tiles. |
|
||||
| ComfyUI runs out of VRAM after the node | Leave `keep_model_loaded` off, so the weights leave the GPU after each run. |
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
|
||||
# Hướng dẫn NH-Nodes
|
||||
|
||||
NH-Nodes bổ sung **75 node** cho ComfyUI, phục vụ công việc ảnh và sản xuất: nạp và lưu bộ ảnh, chia ô (tile) và ghép ảnh,
|
||||
quản lý độ phân giải cho các model hiện nay, công cụ mask, phân đoạn trang phục và khuôn mặt, mask của bất cứ thứ gì bạn gọi tên
|
||||
bằng SAM 3 qua một công thức ngắn, tạo mask VTON, rẽ nhánh logic, xử lý prompt và văn bản, tiện ích batch và bộ nạp model theo
|
||||
chỉ số.
|
||||
NH-Nodes bổ sung **76 node** cho ComfyUI, phục vụ công việc ảnh và sản xuất: nạp và lưu bộ ảnh, chia ô (tile) và ghép ảnh,
|
||||
quản lý độ phân giải cho các model hiện nay, công cụ mask, phân đoạn trang phục, khuôn mặt và các bộ phận cơ thể (da, tóc,
|
||||
quần áo của người trong ảnh, chỉ cần gõ tên), mask của bất cứ thứ gì bạn gọi tên bằng SAM 3 qua một công thức ngắn, tạo mask
|
||||
VTON, rẽ nhánh logic, xử lý prompt và văn bản, tiện ích batch và bộ nạp model theo chỉ số.
|
||||
|
||||
Tài liệu này giải thích từng node: dùng để làm gì, từng đầu vào và đầu ra, và cách node xử lý ở những chi tiết quan trọng.
|
||||
Phần cài đặt và hướng dẫn nhanh nằm ở [trang dự án](../../README.md#installation).
|
||||
@@ -41,7 +41,7 @@ Mỗi phần node bắt đầu bằng một thẻ được vẽ trực tiếp t
|
||||
| Ảnh — chỉnh sửa theo mask | [image-retouch](image-retouch.md) | NH Mask-Aware Resize · NH Agnostic Image Generator · NH Simple Face Paste |
|
||||
| Độ phân giải | [resolution](resolution.md) | NH Smart Resolution Picker · NH Smart Ratio Image Resize · NH Ratio Preset Image Resize · NH Remove Resize Padding · NH Restore Image Geometry |
|
||||
| Mask | [mask](mask.md) | Mask Morphology · Mask Properties · NH Create Box Mask · NH Mask Aspect Ratio Match |
|
||||
| Vision | [vision](vision.md) | Garment Segment · Portrait Segment · SAM3 Segment · Segs Select · Segs Compose · Segs To Masks · Segs To Impact SEGS · Segs Preview |
|
||||
| Vision | [vision](vision.md) | Garment Segment · Portrait Segment · Body Segment · SAM3 Segment · Segs Select · Segs Compose · Segs To Masks · Segs To Impact SEGS · Segs Preview |
|
||||
| VTON | [vton](vton.md) | VTON Ultimate Processor |
|
||||
| Logic | [logic](logic.md) | Compare · Logic Gate · If/Else · Switch N · Any Switch · Any Branch Switch · Gate Switch · Value Match Index · Math Eval · Random Choice |
|
||||
| Văn bản | [text](text.md) | Prompt Template · Prompt Scheduler · String Operations · Prompt Join · Text Split · Regex Extract · Text Index · Text Concatenate · Text Split Lines · Text Random Line |
|
||||
@@ -64,16 +64,20 @@ Mỗi phần node bắt đầu bằng một thẻ được vẽ trực tiếp t
|
||||
- **Resize data.** Các node độ phân giải trả về `resize_data` ghi lại ảnh đã được cắt hay thêm viền thế nào, để đưa kết quả
|
||||
về đúng khung ảnh gốc bằng [NH Restore Image Geometry](resolution.md#nh-restore-image-geometry).
|
||||
- **Instance.** SAM3 Segment trả về mỗi thứ nó tìm thấy như một instance riêng, có nhãn, điểm và mask, trong một giá trị
|
||||
`NH_SEGS`. Các node Segs chọn instance, ghép chúng bằng công thức và đổi chúng thành mask; xem
|
||||
[Các instance: kiểu NH_SEGS](vision.md#các-instance-kiểu-nh_segs).
|
||||
`NH_SEGS`. Garment Segment, Portrait Segment và Body Segment trả về các bộ phận chúng tìm được (tóc, áo, môi…) cũng trong
|
||||
kiểu này. Các node Segs chọn instance, ghép chúng bằng công thức và đổi chúng thành mask, và Segs Compose trộn được
|
||||
instance của nhiều node trong số đó; xem [Các instance: kiểu NH_SEGS](vision.md#các-instance-kiểu-nh_segs).
|
||||
- **Giấy phép.** Mọi node theo giấy phép MIT, trừ các phần ghi trong [THIRD_PARTY_NOTICES](../../THIRD_PARTY_NOTICES.md):
|
||||
VTON Ultimate Processor và Portrait Segment chỉ dùng cho mục đích **phi thương mại**, còn model của Garment Segment
|
||||
không công bố giấy phép. SAM3 Segment dùng SAM 3 của Meta theo SAM License, giấy phép không có điều khoản cấm thương mại
|
||||
nhưng cấm các mục đích quân sự, hạt nhân, gián điệp, súng và vũ khí bất hợp pháp; bạn tự cài gói và tự tải checkpoint.
|
||||
VTON Ultimate Processor và Portrait Segment chỉ dùng cho mục đích **phi thương mại**, Garment Segment cũng vậy, và ở đó
|
||||
giấy phép của NVIDIA định nghĩa "phi thương mại" là chỉ để nghiên cứu hoặc đánh giá. Body Segment dùng Sapiens2 của Meta
|
||||
theo Sapiens2 License, giấy phép không có điều khoản cấm thương mại nhưng cấm theo dõi (surveillance), xử lý sinh trắc học,
|
||||
nhận dạng người, deepfake và một số mục đích khác. SAM3 Segment dùng SAM 3 của Meta theo SAM License, giấy phép không có
|
||||
điều khoản cấm thương mại nhưng cấm các mục đích quân sự, hạt nhân, gián điệp, súng và vũ khí bất hợp pháp; bạn tự cài gói
|
||||
và tự tải checkpoint.
|
||||
|
||||
## Xem thêm
|
||||
|
||||
- [Changelog](../CHANGELOG.md) — thay đổi của từng phiên bản, gồm cả các thay đổi về hành vi.
|
||||
- [Sơ đồ cổng logic](../logic_gate_diagram.html) — bảng chân trị trực quan cho node Logic Gate (tải về và mở bằng trình
|
||||
duyệt).
|
||||
- [Third-party notices](../../THIRD_PARTY_NOTICES.md) — giấy phép của mã nguồn đi kèm, các model được tải về và SAM 3.
|
||||
- [Third-party notices](../../THIRD_PARTY_NOTICES.md) — giấy phép của mã nguồn đi kèm, các model được tải về, Sapiens2 và SAM 3.
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -12,7 +12,7 @@ Ba node làm việc với mask. Một node đổi kích thước ảnh về đú
|
||||
| [NH Agnostic Image Generator](#nh-agnostic-image-generator) | Xóa vùng mask bằng màu xám, nhiễu hoặc làm mờ |
|
||||
| [NH Simple Face Paste](#nh-simple-face-paste) | Dán khuôn mặt trong mask của ảnh này lên vùng mask của ảnh khác |
|
||||
|
||||
Một `MASK` có màu trắng (`1`) ở nơi node cần làm việc và màu đen (`0`) ở những chỗ còn lại. Bạn lấy mask từ một node phân đoạn như [SAM3 Segment (NH)](./vision.md#sam3-segment-nh) (mọi vật thể gọi được tên, hoặc một công thức như `person - bag` hay `background`), [Garment Segment (NH)](./vision.md#garment-segment-nh) hoặc [Portrait Segment (NH)](./vision.md#portrait-segment-nh), hoặc từ bất kỳ công cụ mask nào trong ComfyUI.
|
||||
Một `MASK` có màu trắng (`1`) ở nơi node cần làm việc và màu đen (`0`) ở những chỗ còn lại. Bạn lấy mask từ một node phân đoạn như [SAM3 Segment (NH)](./vision.md#sam3-segment-nh) (mọi vật thể gọi được tên, hoặc một công thức như `person - bag` hay `background`), [Garment Segment (NH)](./vision.md#garment-segment-nh), [Portrait Segment (NH)](./vision.md#portrait-segment-nh) hoặc [Body Segment (NH)](./vision.md#body-segment-nh) (da, tóc hay quần áo của người trong ảnh, chỉ cần gõ tên), hoặc từ bất kỳ công cụ mask nào trong ComfyUI.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,25 +2,26 @@
|
||||
|
||||
# Vision (phân đoạn ảnh)
|
||||
|
||||
Tám node cắt từng phần trên ảnh chụp. Hai node dùng các model SegFormer hiểu về con người: trang phục với Garment Segment, và khuôn mặt, tóc, cổ với Portrait Segment. Mỗi node trả về một mask có cùng kích thước với ảnh đầu vào, một ảnh cắt nền có độ trong suốt và một ảnh xem trước mask. Sáu node còn lại dùng SAM 3, model phân đoạn của Meta: bạn gõ thứ mình muốn vào SAM3 Segment (người, ly rượu, cửa sổ, bầu trời, hoặc một công thức như `person - bag` hay `background`) và nó trả về mask, đồng thời hiển thị ngay trên node. Nó cũng giữ lại mọi instance (từng đối tượng riêng lẻ) tìm được, và năm node Segs giúp chọn, ghép, xem trước và chuyển đổi chúng. Bạn tìm thấy chúng trong **NH-Nodes → Vision**.
|
||||
Chín node cắt từng phần trên ảnh chụp. Ba node trong số đó phân tích người bằng các model hiểu về con người: trang phục với Garment Segment, khuôn mặt, tóc, cổ với Portrait Segment, còn Body Segment cho bạn gõ tên các bộ phận muốn lấy, như da, tóc, môi, răng, tay, chân và quần áo của mọi người trong ảnh. Mỗi node trả về một mask có cùng kích thước với ảnh đầu vào, một ảnh cắt nền có độ trong suốt, một ảnh xem trước mask và các bộ phận nó tìm được dưới dạng instance. Sáu node còn lại dùng SAM 3, model phân đoạn của Meta: bạn gõ thứ mình muốn vào SAM3 Segment (người, ly rượu, cửa sổ, bầu trời, hoặc một công thức như `person - bag` hay `background`) và nó trả về mask, đồng thời hiển thị ngay trên node. Nó cũng giữ lại mọi instance (từng đối tượng riêng lẻ) tìm được, và năm node Segs giúp chọn, ghép, xem trước và chuyển đổi chúng, kể cả cùng với các bộ phận mà ba node phân tích người tìm được. Bạn tìm thấy chúng trong **NH-Nodes → Vision**.
|
||||
|
||||
**Trên trang này**
|
||||
|
||||
- [Nên dùng node nào?](#nên-dùng-node-nào) — các node SegFormer hay SAM 3
|
||||
- **Các node SegFormer: trang phục và chân dung**
|
||||
- [Nên dùng node nào?](#nên-dùng-node-nào) — các node phân tích người hay SAM 3
|
||||
- **Các node phân tích người: trang phục, chân dung và bộ phận cơ thể**
|
||||
- [Trước khi bắt đầu](#trước-khi-bắt-đầu) — model, bộ nhận diện khuôn mặt, lần chạy đầu tiên, giấy phép, bộ nhớ
|
||||
- [Cách các node SegFormer nhìn một bức ảnh](#cách-các-node-segformer-nhìn-một-bức-ảnh) — vì sao khuôn mặt và người nhỏ được xem cận cảnh
|
||||
- [Tinh chỉnh mask](#tinh-chỉnh-mask) — các tùy chọn dùng chung của hai node
|
||||
- [Garment Segment (NH)](#garment-segment-nh) — mask trang phục thân trên, thân dưới, giày dép hoặc mũ nón
|
||||
- [Cách các node SegFormer nhìn một bức ảnh](#cách-các-node-segformer-nhìn-một-bức-ảnh) — vì sao khuôn mặt và người nhỏ được xem cận cảnh, kể cả với Body Segment
|
||||
- [Tinh chỉnh mask](#tinh-chỉnh-mask) — các tùy chọn dùng chung của cả ba node
|
||||
- [Garment Segment (NH)](#garment-segment-nh) — mask trang phục thân trên, thân dưới, giày dép, mũ nón, hoặc riêng một chiếc đầm, túi, khăn…; hai model để chọn
|
||||
- [Portrait Segment (NH)](#portrait-segment-nh) — mask khuôn mặt, tóc, cổ và các bộ phận trên mặt
|
||||
- [Body Segment (NH)](#body-segment-nh) — gõ tên bộ phận muốn lấy: da, tóc, môi, răng, tay, chân, quần áo
|
||||
- **Các node SAM 3: bất cứ thứ gì bạn gọi tên được**
|
||||
- [Trước khi bắt đầu với SAM 3](#trước-khi-bắt-đầu-với-sam-3) — GPU, gói cài đặt, checkpoint, bộ nhớ, giấy phép
|
||||
- [Cách hai lượt phân tích hoạt động](#cách-hai-lượt-phân-tích-hoạt-động) — `balanced` làm được gì trên ảnh 4K đến 8K, và giới hạn ở đâu
|
||||
- [Các instance: kiểu NH_SEGS](#các-instance-kiểu-nh_segs) — thứ được chuyển qua lại giữa các node
|
||||
- [Viết công thức](#viết-công-thức) — những gì `concepts` và `expression` nhận: danh sách, `-`, `&`, `~`, `background`, từ tiếng Việt
|
||||
- [Các instance: kiểu NH_SEGS](#các-instance-kiểu-nh_segs) — thứ được chuyển qua lại giữa các node, và node nào tạo ra nó
|
||||
- [Viết công thức](#viết-công-thức) — những gì `concepts`, `expression` và `regions` nhận: danh sách, `-`, `&`, `~`, `background`, từ tiếng Việt
|
||||
- [SAM3 Segment (NH)](#sam3-segment-nh) — gõ thứ bạn muốn, nhận mask của nó và xem ngay trên node
|
||||
- [Segs Select (NH)](#segs-select-nh) — giữ instance theo nhãn, điểm, kích thước và vị trí
|
||||
- [Segs Compose (NH)](#segs-compose-nh) — dựng mask từ nhãn bằng `|`, `-`, `&` và `~`
|
||||
- [Segs Compose (NH)](#segs-compose-nh) — dựng mask từ nhãn bằng `|`, `-`, `&` và `~`, kể cả từ nhiều node cùng lúc
|
||||
- [Segs To Masks (NH)](#segs-to-masks-nh) — mỗi instance một mask, cùng mask hợp và danh sách nhãn
|
||||
- [Segs To Impact SEGS (NH)](#segs-to-impact-segs-nh) — đưa các instance cho detailer của Impact Pack
|
||||
- [Segs Preview (NH)](#segs-preview-nh) — vẽ các instance lên ảnh của chúng
|
||||
@@ -31,57 +32,62 @@ Tám node cắt từng phần trên ảnh chụp. Hai node dùng các model SegF
|
||||
|
||||
| Bạn muốn | Dùng | Cần gì |
|
||||
|---|---|---|
|
||||
| Trang phục của một người hoặc cả nhóm: áo, quần, giày, mũ | [Garment Segment](#garment-segment-nh) | Một model SegFormer và một bộ nhận diện khuôn mặt nhỏ, đều tự tải ở lần chạy đầu. Chạy được trên CPU hoặc GPU. |
|
||||
| Khuôn mặt, tóc, cổ và các bộ phận trên mặt của một ảnh chân dung, ảnh nửa người hoặc mọi khuôn mặt trong ảnh nhóm | [Portrait Segment](#portrait-segment-nh) | Giống như trên. |
|
||||
| Trang phục của một người hoặc cả nhóm: áo, đầm, quần, giày, mũ, túi, khăn | [Garment Segment](#garment-segment-nh) | Một model SegFormer (256 MB) và một bộ nhận diện khuôn mặt nhỏ, đều tự tải ở lần chạy đầu. Chạy được trên CPU hoặc GPU. |
|
||||
| Khuôn mặt, tóc, cổ và các bộ phận trên mặt của một ảnh chân dung, ảnh nửa người hoặc mọi khuôn mặt trong ảnh nhóm | [Portrait Segment](#portrait-segment-nh) | Giống như trên, với model 340 MB. |
|
||||
| Da, tóc, môi, răng, tay, chân hoặc quần áo của những người trong ảnh, gọi bằng tên bộ phận (`skin`, `hair, face`, `clothes - shoes`, hay `da`, `tóc, môi`) | [Body Segment](#body-segment-nh) | Một model Sapiens2 (1.6 GB) và cùng bộ nhận diện khuôn mặt, đều tự tải ở lần chạy đầu, cùng PyTorch 2.4 trở lên. Nên chạy trên GPU: CPU rất chậm. |
|
||||
| Bất cứ thứ gì bạn gọi tên được (người, đồ vật, bầu trời, tường, cửa sổ) dưới dạng mask từ một công thức ngắn (`person - bag`, `background`), hoặc từng instance riêng, kể cả trên ảnh 4K đến 8K | [SAM3 Segment](#sam3-segment-nh) và các node Segs | GPU NVIDIA, gói `sam3` của Meta và một checkpoint mà bạn tự cài và tự tải. Xem [Trước khi bắt đầu với SAM 3](#trước-khi-bắt-đầu-với-sam-3). |
|
||||
|
||||
Bạn cũng có thể yêu cầu SAM3 Segment tìm `dress` hay `hair`. Các node SegFormer vẫn là lựa chọn dễ hơn cho các bộ phận trang phục và khuôn mặt cố định của chúng: chúng tự thiết lập và cũng chạy được không cần GPU.
|
||||
Bạn cũng có thể yêu cầu SAM3 Segment tìm `dress` hay `hair`. Các node phân tích người vẫn là lựa chọn dễ hơn cho những bộ phận cố định của chúng: chúng tự thiết lập, xử lý mọi người trong ảnh cùng một lúc, còn Garment Segment và Portrait Segment cũng chạy được không cần GPU.
|
||||
|
||||
**Chọn giữa các node phân tích người.** Garment Segment phân biệt được từng loại trang phục (áo với đầm, chân váy với quần) và còn tìm được túi, mũ, khăn, kính và trang sức. Body Segment gọi tên cơ thể: da, tóc, môi, răng, tay, chân và quần áo nói chung. Portrait Segment là node biết đến mắt, lông mày, mũi và tai. [Segs Compose](#segs-compose-nh) trộn những gì chúng tìm được, ví dụ `face - eyes - eyebrows` (khuôn mặt của cả Body Segment lẫn Portrait Segment, bỏ mắt và lông mày của Portrait Segment).
|
||||
|
||||
## Trước khi bắt đầu
|
||||
|
||||
Phần này nói về Garment Segment và Portrait Segment. SAM3 Segment và các node Segs có phần riêng: [Trước khi bắt đầu với SAM 3](#trước-khi-bắt-đầu-với-sam-3).
|
||||
Phần này nói về ba node phân tích người: Garment Segment, Portrait Segment và Body Segment. SAM3 Segment và các node Segs có phần riêng: [Trước khi bắt đầu với SAM 3](#trước-khi-bắt-đầu-với-sam-3).
|
||||
|
||||
| | Garment Segment (NH) | Portrait Segment (NH) |
|
||||
|---|---|---|
|
||||
| Model | SegFormer huấn luyện cho các lớp trang phục của Fashionpedia | SegFormer tinh chỉnh (fine-tune) trên CelebAMask-HQ cho các bộ phận khuôn mặt |
|
||||
| Kho Hugging Face | `1038lab/segformer_fashion` | `jonathandinu/face-parsing` |
|
||||
| Dung lượng tải | khoảng 190 MB | khoảng 340 MB |
|
||||
| Giấy phép | Người đăng tải không khai báo | Chỉ dành cho nghiên cứu và giáo dục phi thương mại |
|
||||
| Node và model | Kho Hugging Face | Dung lượng tải | Giấy phép |
|
||||
|---|---|---|---|
|
||||
| Garment Segment (NH), `model` `fashn` (mặc định): FASHN Human Parser, một SegFormer-B4 tinh chỉnh từ `nvidia/mit-b4`, với 18 lớp cho thời trang | `fashn-ai/fashn-human-parser` | khoảng 256 MB | NVIDIA Source Code License for SegFormer: chỉ để nghiên cứu hoặc đánh giá |
|
||||
| Garment Segment (NH), `model` `fashionpedia`: SegFormer huấn luyện cho các lớp trang phục của Fashionpedia, model của 1.8.0 trở về trước | `1038lab/segformer_fashion` | khoảng 190 MB | Người đăng tải không khai báo, nhưng model được tinh chỉnh từ `nvidia/mit-b3`: hãy coi như `fashn` |
|
||||
| Portrait Segment (NH): SegFormer tinh chỉnh (fine-tune) trên CelebAMask-HQ cho các bộ phận khuôn mặt | `jonathandinu/face-parsing` | khoảng 340 MB | Chỉ dành cho nghiên cứu và giáo dục phi thương mại |
|
||||
| Body Segment (NH): Sapiens2 (Meta, 2026), với 28 lớp bộ phận cơ thể và trang phục, cộng với nền | `facebook/sapiens2-seg-0.4b`, `-0.8b` hoặc `-1b` | 1.6 GB (`0.4b`), 3.3 GB (`0.8b`) hoặc 5.9 GB (`1b`) | Sapiens2 License (Meta): miễn phí bản quyền, không có điều khoản cấm thương mại, nhưng có hạn chế về mục đích sử dụng |
|
||||
|
||||
**Lần chạy đầu tiên.** Khi chạy lần đầu, mỗi node tải model của nó từ Hugging Face, cùng với bộ nhận diện khuôn mặt mô tả bên dưới. Mỗi lần tải được cố định (pinned) vào một phiên bản xác định của kho, nên thay đổi về sau ở nguồn không thể làm đổi thứ bạn nhận được. Các tệp được lưu vào `ComfyUI/models/NH-Nodes/`, mỗi model một thư mục con. Nếu đã có sẵn một bản đầy đủ thì node dùng luôn bản đó: trong `models/RMBG/segformer_fashion` với model trang phục, trong `models/face_parsing` hoặc `models/RMBG/segformer_face` với model khuôn mặt, hoặc trong bất kỳ thư mục nào bạn thêm bằng khóa `nh_nodes` trong `extra_model_paths.yaml` (xem ví dụ ở mục [Installation](../../README.md#installation)). Nếu quá trình tải bị gián đoạn, hãy chạy lại node: các tệp đã tải xong được giữ lại.
|
||||
**Lần chạy đầu tiên.** Khi chạy lần đầu, mỗi node tải model của nó từ Hugging Face, cùng với bộ nhận diện khuôn mặt mô tả bên dưới: với Garment Segment là model chọn trong `model`, với Body Segment là cỡ chọn trong `model_size`. Mỗi lần tải được cố định (pinned) vào một phiên bản xác định của kho, nên thay đổi về sau ở nguồn không thể làm đổi thứ bạn nhận được. Các tệp được lưu vào `ComfyUI/models/NH-Nodes/`, mỗi model một thư mục con (`fashn_human_parser`, `segformer_fashion`, `face_parsing`, `sapiens2-seg-<size>`). Nếu đã có sẵn một bản đầy đủ thì node dùng luôn bản đó: trong `models/RMBG/segformer_fashion` với model Fashionpedia, trong `models/face_parsing` hoặc `models/RMBG/segformer_face` với model khuôn mặt, hoặc trong bất kỳ thư mục nào bạn thêm bằng khóa `nh_nodes` trong `extra_model_paths.yaml` (xem ví dụ ở mục [Installation](../../README.md#installation)). Nếu quá trình tải bị gián đoạn, hãy chạy lại node: các tệp đã tải xong được giữ lại. Máy đang offline sẽ báo lỗi ở lần chạy đầu của một model cho đến khi model đó được tải về.
|
||||
|
||||
**Bộ nhận diện khuôn mặt.** Cả hai node còn dùng YuNet, một bộ nhận diện khuôn mặt nhỏ (khoảng 230 KB, giấy phép MIT, từ OpenCV Zoo), để tìm xem người đang ở đâu trước khi phân tích một bức ảnh. Nó được tải một lần, cố định vào một phiên bản xác định của `opencv/face_detection_yunet`, vào `ComfyUI/models/NH-Nodes/yunet/`. Nó chạy trên CPU bằng OpenCV mà NH-Nodes vốn đã cần, nên không tốn VRAM. Nếu đã có sẵn một bản trong thư mục con `yunet` của bất kỳ thư mục `nh_nodes` nào (`models/NH-Nodes/`, hoặc thư mục bạn thêm trong `extra_model_paths.yaml`) thì node dùng luôn bản đó; muốn chạy offline, hãy tự đặt `face_detection_yunet_2023mar.onnx` vào đó. Nếu không nạp được bộ nhận diện (offline ở lần chạy đầu, hoặc OpenCV quá cũ không chạy được nó), node in một cảnh báo trong console và phân tích cả bức ảnh, không có các ảnh cận cảnh nói ở [Cách các node SegFormer nhìn một bức ảnh](#cách-các-node-segformer-nhìn-một-bức-ảnh). Hãy khởi động lại ComfyUI để thử lại.
|
||||
**Bộ nhận diện khuôn mặt.** Cả ba node còn dùng YuNet, một bộ nhận diện khuôn mặt nhỏ (khoảng 230 KB, giấy phép MIT, từ OpenCV Zoo), để tìm xem người đang ở đâu trước khi phân tích một bức ảnh. Nó được tải một lần, cố định vào một phiên bản xác định của `opencv/face_detection_yunet`, vào `ComfyUI/models/NH-Nodes/yunet/`. Nó chạy trên CPU bằng OpenCV mà NH-Nodes vốn đã cần, nên không tốn VRAM. Nếu đã có sẵn một bản trong thư mục con `yunet` của bất kỳ thư mục `nh_nodes` nào (`models/NH-Nodes/`, hoặc thư mục bạn thêm trong `extra_model_paths.yaml`) thì node dùng luôn bản đó; muốn chạy offline, hãy tự đặt `face_detection_yunet_2023mar.onnx` vào đó. Nếu không nạp được bộ nhận diện (offline ở lần chạy đầu, hoặc OpenCV quá cũ không chạy được nó), node in một cảnh báo trong console và chạy tiếp mà không có nó. Khi đó Portrait Segment phân tích cả bức ảnh, không có các ảnh cận cảnh nói ở [Cách các node SegFormer nhìn một bức ảnh](#cách-các-node-segformer-nhìn-một-bức-ảnh). Garment Segment và Body Segment vẫn xem cận cảnh những người mà lượt nhìn đầu tiên trên cả bức ảnh tìm thấy, nhưng có thể bỏ sót người mà lượt nhìn đầu không thấy. Hãy khởi động lại ComfyUI để thử lại.
|
||||
|
||||
**Bộ nhớ.** Model được đọc từ đĩa một lần và giữ trong bộ nhớ hệ thống. Nó chỉ chuyển sang GPU trong lúc node chạy, rồi trở về bộ nhớ hệ thống, nên không chiếm VRAM giữa các lần chạy. Trước khi chuyển sang GPU, node nhờ ComfyUI gỡ bớt các model của chính nó nếu VRAM đang eo hẹp.
|
||||
**Bộ nhớ.** Model được đọc từ đĩa một lần và giữ trong bộ nhớ hệ thống. Nó chỉ chuyển sang GPU trong lúc node chạy, rồi trở về bộ nhớ hệ thống, nên không chiếm VRAM giữa các lần chạy. Trước khi chuyển sang GPU, node nhờ ComfyUI gỡ bớt các model của chính nó nếu VRAM đang eo hẹp. Body Segment chỉ giữ một cỡ Sapiens2 trong bộ nhớ mỗi lần: khi bạn chọn cỡ khác, cỡ cũ bị bỏ đi.
|
||||
|
||||
> **Lưu ý:** Nếu node báo nhãn không như mong đợi (unexpected labels), nghĩa là có một checkpoint khác nằm trong một thư mục mà node tìm. Hãy xóa bản đó, model đúng sẽ được tải về.
|
||||
> **Lưu ý:** Nếu node báo nhãn không như mong đợi (unexpected labels), nghĩa là có một checkpoint khác nằm trong một thư mục mà node tìm. Hãy xóa bản đó, model đúng sẽ được tải về. Body Segment cũng báo tương tự khi một tệp không phải checkpoint Sapiens2 của cỡ bạn chọn.
|
||||
|
||||
> **Giấy phép:** Model đứng sau Portrait Segment chỉ được dùng cho nghiên cứu và giáo dục phi thương mại. Model đứng sau Garment Segment không khai báo giấy phép, nên điều khoản của nó chưa rõ. Mã nguồn của NH-Nodes thuộc giấy phép MIT. Bộ nhận diện khuôn mặt YuNet theo giấy phép MIT và không thay đổi điều nào ở trên. Hãy đọc [THIRD_PARTY_NOTICES.md](../../THIRD_PARTY_NOTICES.md) trước khi dùng một trong hai node cho mục đích thương mại.
|
||||
> **Giấy phép:** Các model này đi kèm điều khoản giới hạn cách bạn được dùng chúng. Model đứng sau Portrait Segment chỉ được dùng cho nghiên cứu và giáo dục phi thương mại. Cả hai model của Garment Segment đều xây trên SegFormer của NVIDIA. FASHN Human Parser theo NVIDIA Source Code License for SegFormer; mục 3.3 của giấy phép này nói rằng "the Work" (phần mềm và các sản phẩm phái sinh của nó) "only may be used or intended for use non-commercially" (chỉ được dùng, hoặc chỉ được dự định dùng, một cách phi thương mại) và định nghĩa điều đó là "for research or evaluation purposes only" (chỉ cho mục đích nghiên cứu hoặc đánh giá): hẹp hơn nhiều so với "mọi việc dùng mà không thu tiền", đó là nghiên cứu hoặc đánh giá và không gì khác. Model Fashionpedia không khai báo giấy phép, nhưng được tinh chỉnh từ `nvidia/mit-b3`, nên nhiều khả năng cùng giấy phép đó cũng áp dụng. Sapiens2, đứng sau Body Segment, theo Sapiens2 License của Meta: miễn phí bản quyền, không có điều khoản cấm thương mại, nhưng cấm theo dõi (surveillance), xử lý sinh trắc học, nhận dạng người, deepfake và một số mục đích khác, đồng thời yêu cầu bạn tuân thủ luật về quyền riêng tư, dữ liệu sinh trắc học và kiểm soát thương mại. Mã nguồn của NH-Nodes thuộc giấy phép MIT. Bộ nhận diện khuôn mặt YuNet theo giấy phép MIT và không thay đổi điều nào ở trên. Hãy đọc [THIRD_PARTY_NOTICES.md](../../THIRD_PARTY_NOTICES.md) trước khi dùng bất kỳ node nào trong số này cho công việc có thu phí.
|
||||
|
||||
## Cách các node SegFormer nhìn một bức ảnh
|
||||
|
||||
Cả hai model đều nhìn một bức ảnh 512 × 512. Trên ảnh 6000 px, một khuôn mặt rộng 600 px chỉ còn rộng khoảng 50 px trong mắt model, nên các node không đưa cho model cả bức ảnh nguyên khối.
|
||||
Các model đứng sau những node này nhìn một bức ảnh nhỏ có kích thước cố định (rộng × cao): 512 × 512 với model khuôn mặt và model Fashionpedia, 384 × 576 với FASHN, 768 × 1024 với Sapiens2. Trên ảnh 6000 px, một khuôn mặt rộng 600 px chỉ còn rộng khoảng 50 px trong mắt model 512 px, nên các node không đưa cho model cả bức ảnh nguyên khối.
|
||||
|
||||
1. **Cả bức ảnh trước.** Ảnh có một cạnh dài hơn gấp đôi cỡ 512 px của model được thu nhỏ bằng cách lấy trung bình theo diện tích (area averaging), nhanh hơn phép resize riêng của model và giữ chi tiết nhỏ sạch sẽ, rồi mới được phân tích. Kết quả đầu tiên này được giữ lại ở mọi chỗ chưa có gì tốt hơn, nên những gì các bước sau bỏ sót vẫn giữ ít nhất kết quả này.
|
||||
2. **Tìm người.** Bộ nhận diện khuôn mặt tìm khuôn mặt trên hai kích cỡ của bức ảnh. Garment Segment còn lấy thêm những người mà lượt phân tích đầu đã tìm thấy.
|
||||
3. **Ảnh cận cảnh tinh chỉnh kết quả.** Mỗi khuôn mặt (Portrait Segment) hoặc mỗi nhóm người (Garment Segment) được phân tích lại trên một vùng cắt mà model thấy ở đủ 512 px. Nhãn của vùng cắt được đưa vào kết quả đầu, nhưng không đưa dọc các cạnh cắt của vùng, nơi model thấy vật bị cắt cụt, nên không có cạnh thẳng nào lộ ra trong tóc hay quần áo. Mức độ tin vào một vùng cắt khác nhau tùy node, như các mục Garment Segment và Portrait Segment mô tả.
|
||||
1. **Cả bức ảnh trước.** Ảnh lớn được thu nhỏ bằng cách lấy trung bình theo diện tích (area averaging), nhanh hơn phép resize riêng của model và giữ chi tiết nhỏ sạch sẽ, rồi mới được phân tích. Kết quả đầu tiên này được giữ lại ở mọi chỗ chưa có gì tốt hơn, nên những gì các bước sau bỏ sót vẫn giữ ít nhất kết quả này.
|
||||
2. **Tìm người.** Bộ nhận diện khuôn mặt tìm khuôn mặt trên hai kích cỡ của bức ảnh. Garment Segment và Body Segment còn lấy thêm những người mà lượt phân tích đầu đã tìm thấy.
|
||||
3. **Ảnh cận cảnh tinh chỉnh kết quả.** Mỗi khuôn mặt (Portrait Segment) hoặc mỗi nhóm người (Garment Segment, Body Segment) được phân tích lại trên một vùng cắt mà model thấy ở đủ độ phân giải của nó. Nhãn của vùng cắt được đưa vào kết quả đầu, nhưng không đưa dọc các cạnh cắt của vùng, nơi model thấy vật bị cắt cụt, nên không có cạnh thẳng nào lộ ra trong tóc hay quần áo. Mức độ tin vào một vùng cắt khác nhau tùy node, như các mục của từng node mô tả. Vùng cắt thấp hơn 128 px thì không được phân tích lại: nó sẽ bị phóng lên 4 đến 8 lần để vừa với đầu vào của model, nên phần lớn những gì model trả về là bịa ra, và nó hiện ra thành các đốm nhãn li ti rải rác (ví dụ một vật nhỏ cầm trên tay). Nhãn của lượt nhìn đầu được giữ nguyên ở đó.
|
||||
|
||||
Với ảnh dài hơn 4096 px, toàn bộ việc trên được làm trên một bản sao dài 4096 px rồi bản đồ nhãn được phóng lên đúng kích thước ảnh ở cuối: model chỉ nhìn 512 px mỗi lần nên nhãn của nó không chứa chi tiết mịn hơn, còn ghép các vùng cắt ở đủ kích thước 8K sẽ mất nhiều giây.
|
||||
Với ảnh dài hơn 4096 px, toàn bộ việc trên được làm trên một bản sao dài 4096 px rồi bản đồ nhãn được phóng lên đúng kích thước ảnh ở cuối: các model chỉ nhìn 384 đến 1024 px mỗi lần nên nhãn của chúng không chứa chi tiết mịn hơn, còn ghép các vùng cắt ở đủ kích thước 8K sẽ mất nhiều giây.
|
||||
|
||||
Mỗi node dùng điều này thế nào được nêu trong mục riêng của nó: [Garment Segment](#garment-segment-nh) và [Portrait Segment](#portrait-segment-nh).
|
||||
Body Segment chạy Sapiens2 chứ không phải SegFormer, và làm đúng ba bước trên với các vùng cắt có hình dạng 3:4 riêng của nó. Nó thấy mỗi vùng cắt ở 768 × 1024, nên mỗi người có khoảng 1024 px dành cho model dù ảnh lớn cỡ nào. Nó thêm một bước kiểm tra ngay từ đầu: khi không tìm thấy khuôn mặt nào, node coi như bức ảnh không có ai nếu Sapiens2 không chắc về phần lớn những gì nó gán là người, hoặc nếu một lượt nhìn vào bức ảnh lật gương không tìm thấy cùng những người đó (xem [Body Segment](#body-segment-nh)).
|
||||
|
||||
Mỗi node dùng điều này thế nào được nêu trong mục riêng của nó: [Garment Segment](#garment-segment-nh), [Portrait Segment](#portrait-segment-nh) và [Body Segment](#body-segment-nh).
|
||||
|
||||
## Tinh chỉnh mask
|
||||
|
||||
Garment Segment và Portrait Segment biến bản đồ nhãn của model thành mask, rồi tinh chỉnh theo thứ tự: `fill_holes`, `mask_expand`, `mask_blur`. Mask luôn có kích thước bằng ảnh đầu vào.
|
||||
Garment Segment, Portrait Segment và Body Segment biến bản đồ nhãn của model thành mask, rồi tinh chỉnh theo thứ tự: `fill_holes`, `mask_expand`, `mask_blur`. Mask luôn có kích thước bằng ảnh đầu vào.
|
||||
|
||||
| Tùy chọn | Khoảng | Garment Segment | Portrait Segment | Tác dụng |
|
||||
|---|---|---|---|---|
|
||||
| `fill_holes` | bật / tắt | tắt | bật | Lấp các lỗ nằm trọn trong mask. Với trang phục, mặc định là tắt vì nếu bật, cánh tay vắt ngang thân (nằm lọt giữa vùng trang phục) sẽ bị lấp vào mask. |
|
||||
| `mask_expand` | -64 đến 64 | `0` | `0` | Mở rộng (số dương) hoặc thu hẹp (số âm) mask đúng bấy nhiêu pixel. |
|
||||
| `mask_blur` | 0 đến 64 | `0` | `0` | Làm mềm (feather) cạnh khoảng bấy nhiêu pixel. |
|
||||
| `device` | `cuda`, `cpu` | `cuda` | `cuda` | Nơi model chạy. Không có CUDA thì node chuyển sang CPU và in một ghi chú một lần trong console. |
|
||||
| Tùy chọn | Khoảng | Garment Segment | Portrait Segment | Body Segment | Tác dụng |
|
||||
|---|---|---|---|---|---|
|
||||
| `fill_holes` | bật / tắt | tắt | bật | tắt | Lấp các lỗ nằm trọn trong mask. Với trang phục, mặc định là tắt vì nếu bật, cánh tay vắt ngang thân (nằm lọt giữa vùng trang phục) sẽ bị lấp vào mask. Với chân dung thì bật, vì khuôn mặt vẫn đặc ở nơi kính, hoa tai hay tóc cắt vào nó. |
|
||||
| `mask_expand` | -64 đến 64 | `0` | `0` | `0` | Mở rộng (số dương) hoặc thu hẹp (số âm) mask đúng bấy nhiêu pixel. |
|
||||
| `mask_blur` | 0 đến 64 | `0` | `0` | `0` | Làm mềm (feather) cạnh khoảng bấy nhiêu pixel. |
|
||||
| `device` | `cuda`, `cpu` | `cuda` | `cuda` | `cuda` | Nơi model chạy. Không có CUDA thì node chuyển sang CPU và in một ghi chú một lần trong console. |
|
||||
|
||||
Ba đầu ra dưới đây dùng chung cho cả hai node:
|
||||
Ba đầu ra dưới đây dùng chung cho cả ba node. Mỗi node còn trả về `segs` (các bộ phận nó tìm được dưới dạng instance, mô tả trong mục riêng của nó), và Body Segment thêm một `report`.
|
||||
|
||||
| Đầu ra | Kiểu | Mô tả |
|
||||
|---|---|---|
|
||||
@@ -97,45 +103,61 @@ Batch (lô ảnh) đều dùng được: từng ảnh được phân đoạn l
|
||||
|
||||
<p align="center"><img src="../assets/nodes/NH_GarmentSegment.svg" alt="Garment Segment (NH) node" width="360"></p>
|
||||
|
||||
Tạo mask cho một nhóm trang phục, hoặc tất cả các nhóm cùng lúc, trên những người trong ảnh.
|
||||
Tạo mask cho một nhóm trang phục, một món riêng (đầm, túi, khăn…), hoặc tất cả các nhóm cùng lúc, trên những người trong ảnh. Hai model làm việc này: `fashn` (mặc định) và `fashionpedia`.
|
||||
|
||||
**Dùng khi** bạn muốn có mask sạch của áo, quần, giày hoặc mũ để inpaint, đổi màu hoặc dùng trong workflow thử đồ.
|
||||
**Dùng khi** bạn muốn có mask sạch của áo, đầm, quần, giày, mũ hoặc túi để inpaint, đổi màu hoặc dùng trong workflow thử đồ.
|
||||
|
||||
| Đầu vào | Kiểu | Mặc định | Mô tả |
|
||||
|---|---|---|---|
|
||||
| `image` | IMAGE | — | Ảnh chụp một người hoặc một nhóm người. |
|
||||
| `part` | COMBO | `upper_garment` | `upper_garment`, `lower_garment`, `footwear`, `headwear` hoặc `all_garments`. Xem bảng bên dưới. |
|
||||
| `attach_parts` | BOOLEAN | `true` | Gộp túi, khóa kéo, nơ, bèo nhún và các chi tiết tương tự chạm vào trang phục đã chọn. |
|
||||
| `part` | COMBO | `upper_garment` | Một nhóm: `upper_garment`, `lower_garment`, `footwear`, `headwear` hoặc `all_garments`; hoặc một món riêng: `dress`, `bag`, `belt`, `scarf`, `glasses` hoặc `jewelry`. Xem bảng bên dưới. |
|
||||
| `attach_parts` | BOOLEAN | `true` | Chỉ với `fashionpedia`: gộp túi, khóa kéo, nơ, bèo nhún và các chi tiết tương tự chạm vào trang phục đã chọn. Không có tác dụng với `fashn`, vì model này tính chúng là một phần của món đồ. |
|
||||
| `mask_expand` | INT | `0` | Khoảng -64 đến 64. Mở rộng (+) hoặc thu hẹp (-) mask N pixel. |
|
||||
| `mask_blur` | INT | `0` | Khoảng 0 đến 64. Làm mềm cạnh mask N pixel. |
|
||||
| `fill_holes` | BOOLEAN | `false` | Lấp các lỗ khép kín. |
|
||||
| `fill_holes` | BOOLEAN | `false` | Lấp các lỗ khép kín. Mặc định tắt: nếu bật, cánh tay vắt ngang thân sẽ bị lấp vào mask. |
|
||||
| `device` | COMBO | `cuda` | `cuda` hoặc `cpu`. |
|
||||
| `model` | COMBO | `fashn` | Tùy chọn (optional), để các workflow lưu từ trước khi có nó vẫn nạp được: chúng chạy `fashn`. `fashn` hoặc `fashionpedia`. Xem bảng bên dưới. |
|
||||
|
||||
| Đầu ra | Kiểu | Mô tả |
|
||||
|---|---|---|
|
||||
| `mask` | MASK | Mask của phần đã chọn. |
|
||||
| `cutout_rgba` | IMAGE | Ảnh đầu vào với mask làm kênh alpha. |
|
||||
| `mask_image` | IMAGE | Mask dưới dạng ảnh xem trước RGB màu xám. |
|
||||
| `segs` | NH_SEGS | Mỗi bộ phận model tìm được là một instance, bất kể `part` là gì, dùng cho [Segs Compose](#segs-compose-nh) và các node Segs khác. Với `fashn`: face, hair, top, dress, skirt, pants, belt, bag, hat, scarf, glasses, arms, hands, legs, feet, torso, jewelry. Với `fashionpedia`: tên các lớp của nó (shirt, top, sweater, …, sleeve, pocket, …). Một instance phủ bộ phận của nó trên mọi người trong ảnh. |
|
||||
|
||||
**Chọn `model` nào**
|
||||
|
||||
| `model` | Là gì | Giỏi ở | Yếu ở |
|
||||
|---|---|---|---|
|
||||
| `fashn` (mặc định) | FASHN Human Parser, một SegFormer-B4 cho thời trang với 18 lớp, 256 MB | Áo, đầm, chân váy, quần, túi, mũ, khăn, kính và trang sức, với đường viền sạch. | Không có lớp giày, tất hay băng đô. Món đồ nhạt màu, gần với màu da. |
|
||||
| `fashionpedia` | `1038lab/segformer_fashion`, model của 1.8.0 trở về trước, 190 MB | Giày, tất, băng đô và các chi tiết nhỏ (tay áo, cổ áo, túi áo). | Nguyên cả món đồ: bỏ sót một số chiếc đầm và áo. Không có lớp trang sức. |
|
||||
|
||||
**Mỗi `part` gồm những gì**
|
||||
|
||||
| `part` | Bao gồm |
|
||||
|---|---|
|
||||
| `upper_garment` | Áo sơ mi và áo kiểu, áo thun và áo nỉ, áo len, cardigan, áo khoác, áo gile, áo khoác dài (coat), đầm, jumpsuit, áo choàng (cape), cà vạt, khăn quàng, mũ trùm áo, cổ áo, ve áo, cầu vai, tay áo và đường viền cổ. |
|
||||
| `lower_garment` | Quần dài, quần short, váy, thắt lưng, khóa thắt lưng, bó chân (leg warmer), quần tất và tất dài. Không gồm giày. |
|
||||
| `footwear` | Giày và tất. |
|
||||
| `headwear` | Mũ, băng đô, khăn đội đầu và phụ kiện tóc. |
|
||||
| `all_garments` | Cả bốn nhóm trong cùng một mask. |
|
||||
| `part` | Với `fashn` | Với `fashionpedia` |
|
||||
|---|---|---|
|
||||
| `upper_garment` | Áo, đầm và khăn quàng. | Áo sơ mi và áo kiểu, áo thun và áo nỉ, áo len, cardigan, áo khoác, áo gile, áo khoác dài (coat), đầm, jumpsuit, áo choàng (cape), cà vạt, khăn quàng, mũ trùm áo, cổ áo, ve áo, cầu vai, tay áo và đường viền cổ. |
|
||||
| `lower_garment` | Chân váy, quần dài và thắt lưng. | Quần dài, quần short, váy, thắt lưng, khóa thắt lưng, bó chân (leg warmer), quần tất và tất dài. Không gồm giày. |
|
||||
| `footwear` | Lớp "feet" của FASHN: bàn chân cùng thứ nó đang mang, nên chân trần cũng được lấy. | Giày và tất. |
|
||||
| `headwear` | Mũ. Không có lớp băng đô. | Mũ, băng đô, khăn đội đầu và phụ kiện tóc. |
|
||||
| `all_garments` | Bốn nhóm trên trong cùng một mask, gồm cả chân trần. | Bốn nhóm trên trong cùng một mask. |
|
||||
| `dress` | Đầm. | Đầm và jumpsuit. |
|
||||
| `bag` | Túi. | Túi xách và ví. |
|
||||
| `belt` | Thắt lưng. | Thắt lưng và khóa thắt lưng. |
|
||||
| `scarf` | Khăn quàng. | Khăn quàng. |
|
||||
| `glasses` | Kính. | Kính. |
|
||||
| `jewelry` | Trang sức. | Mask rỗng: model không có lớp trang sức, và console cho biết điều đó. Hãy chọn `fashn`. |
|
||||
|
||||
**Cách hoạt động**
|
||||
|
||||
- **Đầm và jumpsuit** thuộc `upper_garment`. Không có tùy chọn đầm riêng. Muốn mask áo và quần cùng lúc, hoặc đầm và giày, hãy chọn `all_garments`.
|
||||
- **Chi tiết đi kèm (attached parts)** gồm túi (pocket), khóa kéo, họa tiết đính (appliqué), hạt cườm, nơ, hoa, tua rua, ruy băng, đinh tán, bèo nhún, kim sa (sequin) và tua (tassel). Khi bật `attach_parts`, một chi tiết chỉ được thêm vào khi nó chạm vào trang phục đã chọn (trong vòng vài pixel). Túi trên áo khoác sẽ nhập vào mask `upper_garment`, còn chiếc nơ trên một chiếc túi xách thì không.
|
||||
- **Không bao giờ được chọn:** kính, găng tay, đồng hồ, túi xách, ví và ô không thuộc `part` nào.
|
||||
- **Ảnh cận cảnh của người.** Node phân tích cả bức ảnh trước và giữ kết quả đó. Sau đó nó tìm những người (trong lượt phân tích ấy và bằng bộ nhận diện khuôn mặt) và cắt các vùng vuông, đúng hình dạng đầu vào của model, quanh từng nhóm người: một hàng các vùng cắt chồng lên nhau cho nhóm rộng, một vùng cho một người hoặc một nhóm gọn. Mỗi vùng được phân tích ở đủ 512 px của model, và vùng được nới rộng khi người bị tràn qua một cạnh của nó. Việc này giúp những người nhỏ trong một bức ảnh lớn. Xem [Cách các node SegFormer nhìn một bức ảnh](#cách-các-node-segformer-nhìn-một-bức-ảnh).
|
||||
- **Đầm và jumpsuit** thuộc `upper_garment`, cùng với các loại áo. `dress` chỉ lấy riêng các chiếc đầm. Muốn mask áo và quần cùng lúc, hoặc đầm và giày, hãy chọn `all_garments`.
|
||||
- **Giày dép với `fashn`** là lớp "feet" của FASHN, không phải giày: bàn chân cùng thứ nó đang mang. Chân trần cũng nằm trong `footwear` và cả trong `all_garments`.
|
||||
- **Chi tiết đi kèm (attached parts)** (chỉ `fashionpedia`) gồm túi (pocket), khóa kéo, họa tiết đính (appliqué), hạt cườm, nơ, hoa, tua rua, ruy băng, đinh tán, bèo nhún, kim sa (sequin) và tua (tassel). Khi bật `attach_parts`, một chi tiết chỉ được thêm vào khi nó chạm vào trang phục đã chọn (trong vòng vài pixel). Túi trên áo khoác sẽ nhập vào mask `upper_garment`, còn chiếc nơ trên một chiếc túi xách thì không. `fashn` không có các lớp này: nó tính túi áo, khóa kéo và miếng vá là một phần của món đồ.
|
||||
- **Không lấy được qua `part`:** với `fashn` là khuôn mặt, tóc, cánh tay, bàn tay, chân và thân; với `fashionpedia` là găng tay, đồng hồ và ô. Chúng nằm trong `segs`.
|
||||
- **Ảnh cận cảnh của người.** Node phân tích cả bức ảnh trước và giữ kết quả đó. Sau đó nó tìm những người (trong lượt phân tích ấy và bằng bộ nhận diện khuôn mặt) và cắt các vùng có đúng hình dạng đầu vào của model (vuông với `fashionpedia`, 2:3 với `fashn`) quanh từng nhóm người: một hàng các vùng cắt chồng lên nhau cho nhóm rộng, một vùng cho một người hoặc một nhóm gọn. Mỗi vùng được phân tích ở đủ độ phân giải của model, và vùng được nới rộng khi người bị tràn qua một cạnh của nó. Việc này giúp những người nhỏ trong một bức ảnh lớn. Xem [Cách các node SegFormer nhìn một bức ảnh](#cách-các-node-segformer-nhìn-một-bức-ảnh).
|
||||
- **Vùng cắt chỉ tinh chỉnh.** Nhãn của nó chỉ thay kết quả đầu ở những chỗ model chắc hơn, cách xa cạnh cắt của vùng, và chỉ với các phần nằm trên những người đã tìm thấy. Không có gì bị bịa ra trên một phông trống, và không có cạnh thẳng nào của vùng cắt xẻ qua một món trang phục.
|
||||
|
||||
<p align="center"><img src="../assets/examples/garment-segment.jpg" alt="Mask của Garment Segment phủ lên một ảnh mẫu" width="720"><br><em>Garment Segment trên một ảnh mẫu: các vùng tô màu là mask của những phần đã chọn.</em></p>
|
||||
<p align="center"><img src="../assets/examples/garment-segment.jpg" alt="Mask của Garment Segment phủ lên một ảnh mẫu" width="720"><br><em>Garment Segment trên một ảnh mẫu: các vùng tô màu là mask của phần đã chọn, từ model fashn (mặc định) và từ model fashionpedia (màu hổ phách: các chi tiết mà attach_parts đã gộp vào), cùng ảnh cắt nền làm từ mask của fashn.</em></p>
|
||||
|
||||
**Ví dụ**
|
||||
|
||||
@@ -145,9 +167,9 @@ Load Image → Garment Segment (NH, upper_garment) → mask → Mask Morphology
|
||||
|
||||
Hãy mở rộng mask thêm vài pixel bằng [Mask Morphology (NH)](./mask.md#mask-morphology-nh) để trang phục được inpaint có thể phủ lên đường viền cũ.
|
||||
|
||||
> **Lưu ý:** Model Fashionpedia bỏ sót một số áo, ví dụ chiếc áo cánh nằm dưới một chiếc mũ lớn. Một model trang phục tốt hơn đang được dự kiến.
|
||||
> **Lưu ý:** `fashn` có thể bỏ sót món đồ nhạt màu gần với màu da: một chiếc áo sát nách màu hồng nhạt chỉ được tìm thấy một phần. Lỗi nằm ở chính model, không phải ở các vùng cắt, và `top` hay `clothes` của Body Segment lấy được món đó tốt hơn. Nó cũng có thể gắn nhãn áo cho những thứ cầm sát người, ví dụ ly champagne. `fashionpedia` cũng bỏ sót một số áo, ví dụ chiếc áo cánh nằm dưới một chiếc mũ lớn.
|
||||
|
||||
> **Giấy phép:** model trang phục không khai báo giấy phép. Xem [Trước khi bắt đầu](#trước-khi-bắt-đầu).
|
||||
> **Giấy phép:** cả hai model trang phục chỉ dùng cho nghiên cứu hoặc đánh giá. FASHN Human Parser theo NVIDIA Source Code License for SegFormer, còn model Fashionpedia, không khai báo giấy phép, được tinh chỉnh từ `mit-b3` của NVIDIA. Xem [Trước khi bắt đầu](#trước-khi-bắt-đầu).
|
||||
|
||||
---
|
||||
|
||||
@@ -181,12 +203,13 @@ Tạo mask cho khuôn mặt, tóc, cổ và các bộ phận trên mặt của m
|
||||
| `mask` | MASK | Mask gộp của mọi vùng đã bật, trên mọi khuôn mặt tìm thấy. |
|
||||
| `cutout_rgba` | IMAGE | Ảnh đầu vào với mask làm kênh alpha. |
|
||||
| `mask_image` | IMAGE | Mask dưới dạng ảnh xem trước RGB màu xám. |
|
||||
| `segs` | NH_SEGS | Mỗi bộ phận model tìm được là một instance, bất kể bạn đã bật vùng nào: skin, nose, glasses, eyes, eyebrows, ears, mouth (bên trong miệng), lips, hair, hat, earrings, necklace, neck, clothes. Một instance phủ bộ phận của nó trên mọi người trong ảnh. Dùng cho [Segs Compose](#segs-compose-nh) và các node Segs khác. |
|
||||
|
||||
**Cách hoạt động**
|
||||
|
||||
- Các vùng đã bật được gộp thành một mask. Không bật vùng nào thì mask rỗng.
|
||||
- `face` đã bao gồm mắt, lông mày, mũi, môi và bên trong miệng. Các tùy chọn đó chỉ làm kết quả thay đổi khi `face` tắt.
|
||||
- Mũ, hoa tai, dây chuyền và quần áo có trong model nhưng node không cung cấp.
|
||||
- Mũ, hoa tai, dây chuyền và quần áo có trong model nhưng node không có công tắc cho chúng. Chúng nằm trong `segs`, nơi [Segs Compose](#segs-compose-nh) dùng được.
|
||||
- **Cả bức ảnh trước.** Bức ảnh được phân tích dưới dạng một hình vuông có thêm viền (giữ nguyên tỉ lệ, phần còn lại được lấp bằng màu xám) thay vì bị nén vào hình vuông của model. Kết quả này được giữ lại ở mọi chỗ mà các vùng cắt bên dưới không chạm tới, nên một bức ảnh không tìm thấy khuôn mặt nào, hoặc không có bộ nhận diện, giữ nó làm kết quả. Khi đã tìm thấy khuôn mặt, các phần của mặt (da, mắt, lông mày, mũi, môi, tai) mà kết quả này cho thấy ở xa mọi khuôn mặt được bỏ đi vì là nhầm lẫn, chẳng hạn một vệt phản chiếu hay một hoa văn; tóc, cổ và quần áo của nó vẫn được giữ.
|
||||
- **Khuôn mặt.** Bộ nhận diện khuôn mặt tìm các khuôn mặt (tối đa 32, lấy những khuôn mặt lớn nhất). Khi không tìm thấy khuôn mặt nào, bức ảnh được thêm một lề xám và tìm thêm một lần nữa, cách này tìm được khuôn mặt bị khung hình cắt. Sau đó model phân tích (parser) kiểm tra từng khuôn mặt trên một vùng cắt sát: bàn tay hay một họa tiết mà bộ nhận diện tưởng là khuôn mặt bị loại và không làm thay đổi gì.
|
||||
- **Hai vùng cắt cho mỗi khuôn mặt.** Một vùng cắt rộng cho thấy trọn tóc và cổ. Một vùng cắt sát, đóng khung giống những bức ảnh model đã học, cho mắt, lông mày, mũi và môi. Vùng rộng tinh chỉnh phần nằm trong nó, cách xa các cạnh cắt của nó, còn vùng sát chỉ quyết định bên trong một hình elip quanh khuôn mặt, nên không có cạnh cắt thẳng nào lộ ra trong mái tóc dài. Nơi các vùng cắt của hai khuôn mặt chồng nhau, mỗi điểm ảnh thuộc về khuôn mặt gần hơn. Vì vậy trong ảnh nhóm, mask chứa các vùng đã bật của mọi người.
|
||||
@@ -208,6 +231,171 @@ Tắt mọi vùng trừ `hair`. Với các giá trị mặc định, bạn có k
|
||||
|
||||
---
|
||||
|
||||
## Body Segment (NH)
|
||||
|
||||
<p align="center"><img src="../assets/nodes/NH_BodySegment.svg" alt="Body Segment (NH) node" width="360"></p>
|
||||
|
||||
Bạn gõ tên các bộ phận cơ thể muốn lấy (`skin`, `hair, face`, `clothes - shoes`, hoặc tiếng Việt `da`, `tóc, môi`) và nhận mask của chúng. Model là Sapiens2 (Meta, 2026). Nó gán cho từng điểm ảnh của mỗi người một trong 28 bộ phận, hoặc là nền: khuôn mặt và cổ, tóc, thân, cánh tay trên và dưới, bàn tay, đùi và cẳng chân, bàn chân, môi, răng, lưỡi, kính, áo và quần (trang phục thân trên và thân dưới), giày, tất và "apparel" (phụ kiện). Node đổi những từ bạn gõ thành một mask và hiển thị nó ngay trên node.
|
||||
|
||||
**Dùng khi** bạn muốn có mask da, tóc, môi, răng, bàn tay hay quần áo của những người trong ảnh chỉ bằng một node, không phải tô vẽ gì: để retouch da, đổi màu tóc hay môi, bảo vệ bàn tay, hoặc thay trang phục. Garment Segment hợp hơn khi cần riêng một món đồ (đầm, túi, khăn), Portrait Segment hợp hơn khi cần mắt, lông mày, mũi và tai, còn SAM3 Segment dùng cho mọi thứ không phải người.
|
||||
|
||||
| Đầu vào | Kiểu | Mặc định | Mô tả |
|
||||
|---|---|---|---|
|
||||
| `image` | IMAGE | — | Ảnh có người. Mọi ảnh trong batch được xử lý lần lượt. |
|
||||
| `regions` | STRING | `skin` | Các bộ phận bạn muốn, gọi bằng tên, viết dưới dạng công thức (xem [Viết công thức](#viết-công-thức)): `skin`, `hair, face`, `clothes - shoes`, `~person`, `background`. Dấu phẩy hoặc xuống dòng để liệt kê nhiều thứ. Các từ nằm trong các bảng bên dưới, bằng tiếng Anh và tiếng Việt. Hãy viết ít nhất một từ. |
|
||||
| `model_size` | COMBO | `0.4b` | `0.4b`, `0.8b` hoặc `1b`. Các cỡ lớn hơn chậm hơn và cần nhiều VRAM hơn. Xem "Cỡ model, bộ nhớ và tốc độ" bên dưới. |
|
||||
| `mask_expand` | INT | `0` | Khoảng -64 đến 64. Mở rộng (+) hoặc thu hẹp (-) mask N pixel. |
|
||||
| `mask_blur` | INT | `0` | Khoảng 0 đến 64. Làm mềm cạnh mask N pixel. |
|
||||
| `fill_holes` | BOOLEAN | `false` | Lấp các lỗ khép kín. |
|
||||
| `device` | COMBO | `cuda` | `cuda` hoặc `cpu`. CPU vẫn chạy được nhưng rất chậm. |
|
||||
| `show_preview` | BOOLEAN | `true` | Tùy chọn (optional). Hiển thị kết quả ngay trên node: mask sáng và phần còn lại bị làm tối, các bộ phận mà mask giữ lại có viền và gắn nhãn, và các dòng báo cáo bên dưới. Tối đa 1536 px ở cạnh dài, và chỉ 16 ảnh đầu của một batch. Tắt: không vẽ gì, và node vẫn chạy (xem "Cách hoạt động" bên dưới). |
|
||||
|
||||
| Đầu ra | Kiểu | Mô tả |
|
||||
|---|---|---|
|
||||
| `mask` | MASK | Mask của công thức, một mask cho mỗi ảnh, có kích thước bằng ảnh. |
|
||||
| `cutout_rgba` | IMAGE | Ảnh đầu vào với mask làm kênh alpha. |
|
||||
| `mask_image` | IMAGE | Mask dưới dạng ảnh xem trước RGB màu xám. |
|
||||
| `segs` | NH_SEGS | Mỗi bộ phận tìm được là một instance, bất kể `regions` viết gì: hair, face, torso, arms, hands, legs, feet, lips, teeth, tongue, glasses, top, bottom, shoes, socks, accessories. Một instance phủ bộ phận của nó trên mọi người trong ảnh. Dùng cho [Segs Compose](#segs-compose-nh) và các node Segs khác. |
|
||||
| `report` | STRING | Mỗi từ trong `regions` một dòng, kèm tỉ lệ của nó trong bức ảnh ("under 0.1%" khi tỉ lệ rất nhỏ, "not in the picture" khi không có, "of the pictures" với một batch). Tiếp theo là các ghi chú khi một từ mang nghĩa khác trong Sapiens2 so với cách nói thường ngày. |
|
||||
|
||||
**Các từ bạn có thể gõ**
|
||||
|
||||
Từ bạn gõ là tiếng Anh, hoặc những từ tiếng Việt thông dụng trong danh sách dùng chung. Không phân biệt chữ hoa/thường, số ít và số nhiều là cùng một từ (`lower leg` hay `lower legs`, `accessory` hay `accessories`), và nhiều từ liền nhau là một nhãn (`upper arm` không cần ngoặc kép). Các bảng dưới đây theo `REGIONS` và `WHOLE_LIMBS` trong `nodes/vision/body_segment_nodes.py`, nơi có danh sách đầy đủ.
|
||||
|
||||
| Viết | Bạn nhận được |
|
||||
|---|---|
|
||||
| `skin`, `body skin` | Da của khuôn mặt và cổ, thân, cánh tay, bàn tay, chân và bàn chân. Không gồm môi, răng, lưỡi hay tóc. |
|
||||
| `face`, `neck`, `face skin` | Khuôn mặt và cổ cùng nhau: một lớp duy nhất, kèm cả mắt, lông mày và râu. |
|
||||
| `head` | Khuôn mặt và tóc. |
|
||||
| `hair` | Tóc. |
|
||||
| `arm`, `arms`, `upper arm`, `upper arms`, `lower arm`, `lower arms`, `forearm`, `forearms` | Cánh tay, không gồm bàn tay. `upper arm` là phần phía trên khuỷu tay (bắp tay), `lower arm` và `forearm` là phần phía dưới (cẳng tay). |
|
||||
| `hand`, `hands` | Bàn tay. |
|
||||
| `leg`, `legs`, `thigh`, `thighs`, `upper legs`, `calf`, `calves`, `lower legs` | Chân, không gồm bàn chân. `thigh` và `upper legs` là phần phía trên đầu gối (đùi), `calf`, `calves` và `lower legs` là phần phía dưới (cẳng chân). |
|
||||
| `foot`, `feet` | Bàn chân. |
|
||||
| `torso`, `chest`, `belly` | Thân người: chỉ là một lớp, nên ba từ này cho cùng kết quả. |
|
||||
| `lips`, `lip`, `upper lip`, `lower lip` | Môi (cả hai, hoặc một bên). |
|
||||
| `teeth`, `tooth`, `upper teeth`, `lower teeth` | Răng (tất cả, hoặc một hàm). |
|
||||
| `tongue` | Lưỡi. |
|
||||
| `mouth` | Môi, răng và lưỡi cùng nhau. |
|
||||
| `glasses`, `eyeglasses`, `sunglasses` | Kính. |
|
||||
| `top`, `upper clothes`, `shirt`, `t-shirt`, `blouse`, `jacket`, `suit jacket`, `sweater`, `hoodie`, `coat`, `vest`, `bra`, `dress`, `wedding dress`, `ao dai` | Trang phục thân trên. Tất cả các từ này là cùng một lớp. |
|
||||
| `bottom`, `lower clothes`, `pants`, `trousers`, `jeans`, `shorts`, `skirt` | Trang phục thân dưới. Tất cả các từ này là cùng một lớp. |
|
||||
| `shoe`, `shoes`, `high heels`, `sandal`, `sandals`, `sneakers`, `boots` | Giày. |
|
||||
| `sock`, `socks` | Tất. |
|
||||
| `accessories`, `apparel` | Lớp phụ kiện duy nhất của model: mũ, túi và những thứ tương tự. |
|
||||
| `clothes`, `clothing`, `outfit` | Trang phục thân trên và thân dưới, giày, tất và phụ kiện. |
|
||||
| `person`, `body` | Mọi thứ model gán là người. |
|
||||
| `left hand`, `right hand`, `left arm`, `right arm`, `left leg`, `right leg`, `left foot`, `right foot`, `left shoe`, `right shoe` | Chỉ một bên. Xem bên dưới về bên nào. |
|
||||
|
||||
| Tiếng Việt | Bạn nhận được |
|
||||
|---|---|
|
||||
| `da` | Da (`skin`). |
|
||||
| `tóc` | Tóc. |
|
||||
| `mặt`, `da mặt`, `cổ` | Khuôn mặt và cổ, một lớp duy nhất. |
|
||||
| `đầu` | Đầu: khuôn mặt và tóc. |
|
||||
| `môi`, `răng`, `lưỡi`, `miệng` | Môi, răng, lưỡi, và miệng (cả ba cùng nhau). |
|
||||
| `tay`, `đôi tay`, `hai tay` | Cả cánh tay kèm bàn tay, đúng như cách người Việt hiểu chữ "tay". |
|
||||
| `tay trái`, `tay phải` | Cả cánh tay trái hoặc phải kèm bàn tay của nó. |
|
||||
| `cánh tay` | Cánh tay, không kèm bàn tay. |
|
||||
| `bắp tay`, `cẳng tay` | Bắp tay (phần trên), cẳng tay (phần dưới). |
|
||||
| `bàn tay` | Bàn tay. |
|
||||
| `chân`, `đôi chân`, `hai chân` | Cả cái chân kèm bàn chân. |
|
||||
| `chân trái`, `chân phải` | Cả cái chân trái hoặc phải kèm bàn chân của nó. |
|
||||
| `đùi`, `bắp đùi` | Đùi. |
|
||||
| `bắp chân`, `cẳng chân` | Cẳng chân, phần phía dưới đầu gối. |
|
||||
| `bàn chân` | Bàn chân. |
|
||||
| `ngực`, `bụng` | Thân người. |
|
||||
| `áo`, `áo sơ mi`, `áo thun`, `áo khoác`, `áo len` | Trang phục thân trên. |
|
||||
| `váy`, `đầm`, `váy cưới`, `áo dài` | Một chiếc đầm, mà model gán là trang phục thân trên. |
|
||||
| `quần`, `quần jean`, `quần short` | Trang phục thân dưới. |
|
||||
| `chân váy` | Chân váy: trang phục thân dưới. |
|
||||
| `giày`, `giày cao gót`, `dép` | Giày. |
|
||||
| `tất`, `vớ` | Tất. |
|
||||
| `kính`, `kính mát` | Kính. |
|
||||
| `quần áo`, `trang phục` | Quần áo nói chung. |
|
||||
| `người`, `cơ thể` | Cả người. |
|
||||
| `nền` | Từ khóa `background`: mọi thứ nằm ngoài người. |
|
||||
|
||||
Muốn chỉ lấy một bên, hãy viết `left hand`, `right foot`… bằng tiếng Anh, hoặc `tay trái`, `tay phải`, `chân trái` và `chân phải` bằng tiếng Việt. Màu sắc hay kích cỡ đứng sau một từ tiếng Việt (`áo đỏ`) không được hiểu ở đây: Body Segment gọi tên bộ phận, không tìm theo màu.
|
||||
|
||||
**Các từ mang nghĩa gì**
|
||||
|
||||
Sapiens2 có ít lớp hơn số từ người ta hay dùng, nên một số từ dùng chung một lớp. Báo cáo thêm ghi chú khi bạn dùng một trong những từ này. Các ghi chú về lớp face, về đầm và về chân váy chỉ xuất hiện khi bức ảnh có phần nào của vùng đó.
|
||||
|
||||
- **Trái và phải** là bên của chính người trong ảnh: `left hand` nằm ở bên phải bức ảnh khi người đó nhìn thẳng vào ống kính.
|
||||
- **`face` là một lớp duy nhất**, gồm cả cổ, mắt, lông mày và râu, và `skin` bao gồm nó. Vì vậy `skin - face` cũng bỏ luôn cổ, `face - neck` rỗng, và mask da không có lỗ ở chỗ mắt. Muốn mask khuôn mặt không có mắt và lông mày, hãy dùng Portrait Segment, hoặc lấy `eyes` và `eyebrows` của nó trừ đi bằng Segs Compose (xem bên dưới).
|
||||
- **Đầm được tính là áo.** Sapiens2 chỉ có một lớp cho mọi thứ mặc ở phần thân trên: `dress - top` rỗng, và phần chân váy của một chiếc đầm được tính là `top`, không phải `bottom`. `skirt` chỉ tìm chiếc chân váy rời.
|
||||
- **`skin` không có môi, răng và lưỡi.** Chúng là các lớp riêng, nên `skin - lips` không đổi gì, còn `skin + lips` là da kèm cả môi.
|
||||
- **`clothes` gồm cả phụ kiện** (túi, mũ), giày và tất.
|
||||
- **`tay` và `chân` là cả chi**, gồm cả bàn tay và bàn chân. `arm` và `leg` là chi không kèm chúng, nên `tay - cánh tay` chỉ còn lại bàn tay.
|
||||
|
||||
**Những từ bị từ chối**
|
||||
|
||||
Nếu một từ không ứng với vùng nào mà Body Segment có, node dừng lại và báo lỗi, trước khi nạp model; lỗi cho biết nên viết gì thay thế:
|
||||
|
||||
| Bạn viết | Nên viết |
|
||||
|---|---|
|
||||
| `eyes`, `eyebrows`, `nose`, `ears` (cũng như `mắt`, `mũi`, `tai`) | Chúng là một phần của `face` ở đây. Portrait Segment mask từng bộ phận một. |
|
||||
| `beard`, `mustache` (cũng như `râu`, `ria mép`) | Râu là một phần của `face` ở đây, và không parser nào của NH-Nodes có lớp râu. SAM3 Segment có thể thử `beard`. |
|
||||
| `hat`, `bag`, `scarf`, `belt`, `jewelry`, `necklace`, `watch`, `gloves`, `tie` (cũng như `mũ`, `túi`, `khăn`) | Sapiens2 chỉ có một lớp cho phụ kiện: `accessories`. Muốn lấy riêng một món, hãy dùng Garment Segment hoặc SAM3 Segment. |
|
||||
| `shoulders` | Chúng là một phần của `torso` và `upper arms`. |
|
||||
| `fingers` | Chúng là một phần của `hands`. |
|
||||
| `skin and hair`, `skin or hair`, `not skin` | `and`, `or` và `not` không phải toán tử ở đây: hãy viết `skin + hair` (hoặc `skin, hair`), `skin - hair` và `~skin`. |
|
||||
| Ký tự đại diện (`*`, `?`) | Không được phép: hãy viết tên vùng. |
|
||||
| Một từ tiếng Việt không có trong danh sách | Hãy viết bằng tiếng Anh, hoặc dùng `da`, `tóc`, `mặt`, `môi`, `răng`, `tay`, `chân`, `áo`, `quần`, `giày`. |
|
||||
| Ô trống | Hãy viết ít nhất một vùng, ví dụ `skin`. |
|
||||
|
||||
**Cách hoạt động**
|
||||
|
||||
- **Người, xem cận cảnh.** Node phân tích cả bức ảnh trước, tìm người (trong lượt phân tích đó và bằng bộ nhận diện khuôn mặt), rồi phân tích các vùng cắt có hình dạng 3:4 của model quanh từng người hoặc từng nhóm ở 1024 × 768, nên mỗi người có khoảng 1024 px dành cho model dù ảnh lớn cỡ nào. Như ở Garment Segment, vùng cắt chỉ tinh chỉnh kết quả đầu, và với ảnh dài hơn 4096 px, việc này được làm trên một bản sao 4096 px rồi phóng lên ở cuối. Xem [Cách các node SegFormer nhìn một bức ảnh](#cách-các-node-segformer-nhìn-một-bức-ảnh).
|
||||
- **Công thức.** Mỗi từ chọn một số lớp của model, và công thức kết hợp chúng như trong [Viết công thức](#viết-công-thức). `background` là mọi thứ nằm ngoài người, ở đây giống `~person`; `all` là mọi bộ phận tìm được, tức là người; `image` là cả bức ảnh và `none` là không có gì. Sau đó `fill_holes`, `mask_expand` và `mask_blur` tinh chỉnh kết quả, theo thứ tự này.
|
||||
- **Ảnh không có ai.** Khi không tìm thấy khuôn mặt nào, node kiểm tra xem có thật là có người hay không, bằng hai phép thử. Thứ nhất, lượt nhìn đầu của Sapiens2 phải chắc chắn về ít nhất một nửa những gì nó gán là người (một điểm ảnh được tính là chắc chắn từ độ tin cậy 0.9). Thứ hai, một lượt nhìn vào bức ảnh lật gương phải tìm thấy cùng những người đó: phần người của hai lượt nhìn phải chồng lên nhau ít nhất 0.6 (IoU). Người thật vẫn hiện ra trong ảnh lật gương; thứ model tự bịa ra trên một bức tường thì không. Nếu một phép thử không đạt, node coi như bức ảnh không có ai: `skin` rỗng, `background` là cả bức ảnh, và báo cáo cùng bản xem trước ghi "no person found (none Sapiens2 was sure of, and no face)", kèm số thứ tự các ảnh nếu là một batch. Phép thử thứ hai tốn thêm một lượt nhìn, và chỉ khi không tìm thấy khuôn mặt. Nếu không có các phép thử này, một mặt tiền tòa nhà hay một căn phòng có nhiều kệ sẽ hiện ra những "người" mờ nhạt trong tường và trên kệ, và những chỉnh sửa thường ngày trên ảnh mặt tiền (tăng chút độ phơi sáng, độ tương phản, độ bão hòa hay làm nét) có thể đẩy tỉ lệ ở phép thử đầu lên trên một nửa, đó là lý do phép thử thứ hai tồn tại. Người mà khuôn mặt được tìm thấy thì không bao giờ bị bỏ. Một món quần áo đứng một mình (ảnh sản phẩm bày phẳng, không có người mặc) bị tính là không có ai, nên hãy dùng Garment Segment cho trường hợp đó, và một hình người được cách điệu mạnh hoặc bị nhòe, không có khuôn mặt, cũng có thể bị bỏ.
|
||||
- **Các phép thử "không có ai" được đo thế nào.** Trên 49 ảnh không thấy mặt, 32 ảnh có người (chụp từ phía sau, thân người không có đầu, một ma-nơ-canh) được giữ và 17 ảnh không có ai (một mặt tiền, một quán cà phê, một con voi, ảnh sản phẩm bày phẳng, mặt nạ) bị làm rỗng. Người thật chồng lên ảnh lật gương của họ 0.83 đến 0.99 (bản đã chỉnh sửa và người nhỏ trên nền trơn xuống tới 0.64, vẫn trên ngưỡng 0.6), còn thứ model tự bịa ra trên một mặt tiền chỉ 0.02 đến 0.03. Trên 21 bản chỉnh sửa thường ngày của mỗi ảnh mặt tiền, quán cà phê và con voi, bản đồ luôn rỗng. Trên 21 bản chỉnh sửa của mỗi ảnh trong bốn ảnh có người nhưng không thấy mặt (một ma-nơ-canh, một ảnh sản phẩm không thấy đầu, một vũ công, một sản phẩm đang mặc trên người), bản đồ luôn được giữ.
|
||||
- **Báo cáo.** Mỗi từ một dòng, rồi các ghi chú nêu ở trên. Với một batch, tỉ lệ tính cho tất cả các ảnh gộp lại, còn bản xem trước dưới mỗi ảnh cho thấy tỉ lệ của riêng ảnh đó.
|
||||
- **Xem kết quả.** Khi `show_preview` bật, node tự vẽ bản xem trước: không cần node nào khác. Nó vẽ viền các bộ phận mà mask giữ lại: với `skin - hands`, nó vẽ viền các phần da chứ không vẽ bàn tay. Muốn xem các bộ phận ở kích thước đầy đủ của bức ảnh, hãy nối `segs` và đúng bức ảnh đó vào [Segs Preview](#segs-preview-nh).
|
||||
- **Tự chạy.** Body Segment là một node đầu ra (output node; chấm trên thẻ của nó màu cam): ComfyUI chạy nó kể cả khi không có gì nối vào các đầu ra của nó. Vì vậy một node bạn không còn cần vẫn tiếp tục chạy Sapiens2 mỗi khi đầu vào của nó thay đổi, và tắt `show_preview` không làm nó ngừng chạy. Hãy vô hiệu hóa nó (Mute) hoặc xóa nó.
|
||||
- **Kết hợp với các node khác.** `segs` chứa cùng các bộ phận dù bạn gõ gì trong `regions`. Hãy nối nó vào [Segs Compose](#segs-compose-nh), cùng với `segs` của SAM3 Segment, Garment Segment hay Portrait Segment chạy trên cùng bức ảnh: ví dụ Body Segment vào `segs`, Portrait Segment vào `segs_2` và `face - eyes - eyebrows` làm biểu thức.
|
||||
|
||||
**Cỡ model, bộ nhớ và tốc độ**
|
||||
|
||||
| `model_size` | Dung lượng tải | Ghi chú |
|
||||
|---|---|---|
|
||||
| `0.4b` (mặc định) | 1.6 GB | Nhanh. Khoảng 1.5 đến 2 GB VRAM ở mức cao nhất. |
|
||||
| `0.8b` | 3.3 GB | Model lớn hơn: chậm hơn và cần nhiều VRAM hơn. |
|
||||
| `1b` | 5.9 GB | Lớn nhất: còn chậm hơn nữa và cần nhiều VRAM hơn. |
|
||||
|
||||
Trọng số được tải một lần từ Hugging Face (`facebook/sapiens2-seg-<size>`, cố định vào một phiên bản xác định), ngay lần đầu bạn chọn một cỡ, và lưu vào `ComfyUI/models/NH-Nodes/sapiens2-seg-<size>/`, tức thư mục model `nh_nodes`. Chúng ở lại bộ nhớ hệ thống, mỗi lần một cỡ, và chỉ chuyển sang GPU trong lúc node chạy. Node cần PyTorch 2.4 trở lên và dừng lại với một thông báo khi bản của bạn cũ hơn.
|
||||
|
||||
**Đo trên một máy** (NVIDIA RTX 5090) với `0.4b`: khoảng 1 giây trên ảnh 1 đến 2K px và 4 đến 7 giây trên ảnh 8K. Chỉ `0.4b` được đo. Trên CPU node vẫn chạy được nhưng chậm: khoảng 8 giây cho mỗi vùng cắt với 8 luồng. Máy khác sẽ cho số khác.
|
||||
|
||||
<p align="center"><img src="../assets/examples/body-segment.jpg" alt="Mask của Body Segment trên một ảnh mẫu: da, tóc và môi, quần áo, và phần nền" width="720"><br><em>Body Segment trên một ảnh mẫu, đúng như node hiển thị. Hàng trên: "skin", báo cáo nằm bên dưới bức ảnh. Hàng dưới: "tóc, môi" (từ tiếng Việt), "clothes" và "background"; phần sáng là đầu ra mask của mỗi công thức.</em></p>
|
||||
|
||||
**Các công thức để bắt đầu**
|
||||
|
||||
| `regions` | Bạn nhận được |
|
||||
|---|---|
|
||||
| `skin` | Da của khuôn mặt và cổ, thân, cánh tay, bàn tay, chân và bàn chân. |
|
||||
| `hair, face` | Tóc, và khuôn mặt cùng cổ. |
|
||||
| `tóc, môi` | Tóc và môi (từ tiếng Việt). |
|
||||
| `clothes - shoes` | Thứ người trong ảnh đang mặc, không gồm giày. |
|
||||
| `skin - hands` | Da nhưng bỏ bàn tay. |
|
||||
| `skin + lips` | Da kèm cả môi. |
|
||||
| `background` | Mọi thứ nằm ngoài người. |
|
||||
|
||||
**Ví dụ**
|
||||
|
||||
```text
|
||||
Load Image → Body Segment (NH, regions: skin) → mask → Mask Morphology (NH, thu hẹp) → retouch
|
||||
```
|
||||
|
||||
Hãy thu hẹp mask vài pixel bằng [Mask Morphology (NH)](./mask.md#mask-morphology-nh) để phần chỉnh sửa nằm gọn trong da, tránh xa mép tóc và quần áo.
|
||||
|
||||
> **Lưu ý:** Ảnh scan cũ, đã phai màu hoặc ngả nâu (sepia) là một giới hạn đã biết. Sapiens2 có thể coi cả phông nền là quần áo: một tấm ảnh nhóm đã scan cho ra 96% diện tích là "người". Hãy dùng Garment Segment (`fashn`) hoặc SAM3 Segment với `person` cho những ảnh như vậy.
|
||||
|
||||
> **Giấy phép:** Sapiens2 theo Sapiens2 License của Meta. Giấy phép này miễn phí bản quyền và không có điều khoản cấm thương mại, nhưng cấm theo dõi (surveillance), xử lý sinh trắc học, nhận dạng hay nhận dạng lại người, deepfake, mạo danh và nội dung gây hiểu lầm, suy đoán dữ liệu nhạy cảm về con người khi không có quyền và sự đồng ý mà luật pháp yêu cầu, cùng một số mục đích khác. Nó yêu cầu bạn tuân thủ luật về quyền riêng tư, dữ liệu sinh trắc học và kiểm soát thương mại, và cho phép Meta kiểm tra việc bạn dùng, chấm dứt giấy phép và thay đổi điều khoản. Mã nguồn của NH-Nodes thuộc giấy phép MIT, có kèm mã model của Meta cùng văn bản giấy phép nhưng không có trọng số, vốn được tải từ các kho Hugging Face của Meta. Hãy đọc [THIRD_PARTY_NOTICES.md](../../THIRD_PARTY_NOTICES.md) trước khi dùng node.
|
||||
|
||||
---
|
||||
|
||||
## Trước khi bắt đầu với SAM 3
|
||||
|
||||
SAM 3 là model của Meta để tìm vật theo tên: bạn viết thứ mình muốn (`person`, `window`, `wine glass`) và nó trả về mọi instance của thứ đó, mỗi instance có điểm tin cậy (score) và mask riêng. NH-Nodes không kèm model và không bao giờ tự tải nó, nên SAM3 Segment cần bạn chuẩn bị ba thứ, chỉ một lần. Nếu một pack SAM 3 khác đã chạy tốt trong ComfyUI của bạn, bước 2 và 3 thường đã xong: pack đó đã cài gói và đặt `sam3.pt` đúng nơi node này tìm.
|
||||
@@ -260,21 +448,24 @@ SAM 3 nhìn một bức ảnh cỡ 1000 px. Trên ảnh 6000 px, đó chỉ bằ
|
||||
|
||||
## Các instance: kiểu NH_SEGS
|
||||
|
||||
`NH_SEGS` là dữ liệu mà SAM3 Segment chuyển cho các node Segs. Với mỗi ảnh trong batch (lô ảnh), nó chứa một danh sách instance. Mỗi instance giữ **nhãn** (các từ tiếng Anh mà SAM 3 được yêu cầu tìm: `áo đỏ` được lưu thành `red top`), **điểm tin cậy**, một **khung** ôm sát và mask **chỉ trong phạm vi khung đó**. Mask kích thước đầy đủ chỉ được dựng khi một node xuất `MASK`, nên hàng chục instance trên ảnh 8K vẫn nhẹ.
|
||||
`NH_SEGS` là dữ liệu mà SAM3 Segment, Garment Segment, Portrait Segment và Body Segment chuyển cho các node Segs. Với mỗi ảnh trong batch (lô ảnh), nó chứa một danh sách instance. Mỗi instance giữ **nhãn** (ở SAM3 Segment là các từ tiếng Anh mà SAM 3 được yêu cầu tìm: `áo đỏ` được lưu thành `red top`; ở ba node kia là tên bộ phận), **điểm tin cậy** (ở ba node kia luôn là 1.0), một **khung** ôm sát và mask **chỉ trong phạm vi khung đó**. Mask kích thước đầy đủ chỉ được dựng khi một node xuất `MASK`, nên hàng chục instance trên ảnh 8K vẫn nhẹ.
|
||||
|
||||
- Các instance xếp theo thứ tự các nhãn xuất hiện trong concepts của bạn, và theo điểm trong cùng một nhãn.
|
||||
- **Đối tượng và bộ phận.** SAM3 Segment trả về mỗi vật nó tìm thấy một instance: ba người là ba instance. Garment Segment, Portrait Segment và Body Segment trả về mỗi bộ phận một instance cho cả bức ảnh: có ba người thì `hair` là tóc của cả ba người gộp lại, trong một instance. Muốn chọn riêng một người trong nhiều người, hãy dùng SAM3 Segment.
|
||||
- **Nhãn của ba node phân tích người** được liệt kê trong bảng đầu ra của từng node: [Garment Segment](#garment-segment-nh), [Portrait Segment](#portrait-segment-nh) và [Body Segment](#body-segment-nh). Chúng không dùng hết cùng một từ: `mouth` là bên trong miệng ở Portrait Segment, còn Body Segment có `lips`, `teeth` và `tongue`. Các node Segs cũng đọc những từ riêng của node đã tạo ra segs (`skin` trên segs của Body Segment): xem [Segs Compose](#segs-compose-nh). Khi một nhãn không tìm thấy gì, hãy xem các nhãn trong bản xem trước hoặc trong [Segs To Masks](#segs-to-masks-nh).
|
||||
- Các instance của SAM3 Segment xếp theo thứ tự các nhãn xuất hiện trong concepts của bạn, và theo điểm trong cùng một nhãn. Các instance của ba node kia xếp theo thứ tự danh sách bộ phận của chúng.
|
||||
- Các node Segs xử lý từng ảnh trong batch riêng biệt.
|
||||
- Các instance của một ảnh còn giữ một bản in nhỏ màu xám của bức ảnh mà chúng được tìm ra từ đó. [Segs Compose](#segs-compose-nh) dùng nó để từ chối các instance của một bức ảnh khác.
|
||||
- Các node gốc của ComfyUI không đọc được `NH_SEGS`. Để lấy mask ra, hãy dùng các đầu ra `mask`, [Segs Compose](#segs-compose-nh) hoặc [Segs To Masks](#segs-to-masks-nh). Để dùng detailer của Impact Pack, hãy chuyển đổi bằng [Segs To Impact SEGS](#segs-to-impact-segs-nh).
|
||||
|
||||
---
|
||||
|
||||
## Viết công thức
|
||||
|
||||
`concepts` của SAM3 Segment và `expression` của Segs Compose đọc cùng một ngôn ngữ nhỏ. Bạn gọi tên những thứ mình muốn, ghép chúng bằng các toán tử, và kết quả là một mask cho mỗi ảnh. Trong SAM3 Segment, các từ bạn viết cũng là thứ SAM 3 đi tìm. Trong Segs Compose, chúng chọn các instance đã được tìm sẵn.
|
||||
`concepts` của SAM3 Segment, `expression` của Segs Compose và `regions` của Body Segment đọc cùng một ngôn ngữ nhỏ. Bạn gọi tên những thứ mình muốn, ghép chúng bằng các toán tử, và kết quả là một mask cho mỗi ảnh. Trong SAM3 Segment, các từ bạn viết cũng là thứ SAM 3 đi tìm. Trong Segs Compose, chúng chọn các instance đã được tìm sẵn. Trong Body Segment, chúng chọn các bộ phận cơ thể mà model của nó gán nhãn.
|
||||
|
||||
| Viết | Nghĩa |
|
||||
|---|---|
|
||||
| `person` | Một nhãn: các instance mang tên đó. Không phân biệt chữ hoa/thường. |
|
||||
| `person` | Một nhãn: các instance mang tên đó. Không phân biệt chữ hoa/thường, và khi so khớp instance thì số ít và số nhiều là một (`arm` tìm `arms`; `glasses` và `glass` vẫn là hai nhãn). |
|
||||
| `red car`, `women's dress` | Nhiều từ liền nhau là một nhãn, không cần ngoặc kép. Dấu gạch nối nằm giữa hai chữ cái hoặc chữ số thuộc về nhãn (`t-shirt`, `x-ray`), dấu nháy đơn nằm giữa một từ cũng vậy. |
|
||||
| `person, hair` | Một danh sách: hợp của các mục trong đó. Xuống dòng có tác dụng như dấu phẩy. Danh sách được đọc sau mọi toán tử khác, nên `person - hair, dress` là `person - hair` cộng với `dress`. Mục trống bị bỏ qua. |
|
||||
| `a + b`, `a \| b` | Hợp: mọi thứ thuộc a hoặc thuộc b. |
|
||||
@@ -317,6 +508,8 @@ SAM 3 nhìn một bức ảnh cỡ 1000 px. Trên ảnh 6000 px, đó chỉ bằ
|
||||
| Nhãn làm gì | Là thứ SAM 3 được yêu cầu tìm. | Chọn các instance có nhãn đó. Nhãn mà không instance nào có cho một phần rỗng. |
|
||||
| Ô trống | Node dừng lại với thông báo yêu cầu viết ít nhất một concept. Ô chỉ có ghi chú, hoặc chỉ có `all`, `image` hay `none`, cũng được tính là trống. | Node dừng lại với thông báo dùng `all` để lấy mọi instance. |
|
||||
|
||||
**`regions` của Body Segment** theo các quy tắc của SAM3 Segment, với ba điểm khác. `or`, `and` và `not` không phải toán tử ở đó (chúng bị từ chối kèm lời khuyên: hãy viết `+`, `-` hoặc `~`). Mỗi từ phải gọi tên một vùng cơ thể mà model của nó có, như `skin`, `hair` hay `clothes`: từ khác, và ký tự đại diện, bị từ chối trước khi nạp model. Và các từ tiếng Việt đi qua cùng danh sách đó, nên ở đó `tay` và `chân` nghĩa là cả chi. Xem [Body Segment](#body-segment-nh).
|
||||
|
||||
---
|
||||
|
||||
## SAM3 Segment (NH)
|
||||
@@ -366,7 +559,7 @@ Các đầu vào COMBO không nêu mặc định sẽ bắt đầu ở lựa ch
|
||||
- **Nhãn.** Các từ tiếng Anh mà SAM 3 được yêu cầu tìm là nhãn của các instance: `áo đỏ` được tìm là `red top`, và các instance của nó mang nhãn `red top`. Các node Segs bỏ qua chữ hoa/thường khi so khớp nhãn, nên cách viết hoa không thành vấn đề.
|
||||
- **Mask.** `mask` là kết quả của công thức. Với `person - bag`, SAM 3 tìm `person` và `bag`, `mask` là người trừ đi các túi, và `segs` vẫn chứa cả hai loại instance.
|
||||
- **`background`.** Là mọi thứ nằm ngoài mọi instance đã tìm được, nên node cần biết chủ thể nằm ở đâu. Khi công thức dùng nó mà không nêu `person`, SAM 3 cũng tìm `person`; các instance đó thuộc `segs` và `count` và có viền trong bản xem trước. Muốn loại cả những thứ khác ra khỏi phần nền, hãy nêu tên chúng: `~(person + wine glass + chair)`. Danh sách là phép hợp, nên `person, background` là cả bức ảnh.
|
||||
- **Thứ tự và bản trùng.** Các instance xếp theo thứ tự các nhãn xuất hiện trong công thức (`person` do `background` thêm vào đứng cuối), điểm cao nhất trước trong mỗi nhãn. Hai instance của cùng một nhãn mà mask gần như trùng hoàn toàn (từ 90% trở lên) được tính là một, giữ lại cái điểm cao hơn. Sau đó `max_per_concept` giữ những cái tốt nhất trong phần còn lại. Các instance có điểm đúng bằng nhau giữ thứ tự mà SAM 3 đưa ra.
|
||||
- **Thứ tự và bản trùng.** Các instance xếp theo thứ tự các nhãn xuất hiện trong công thức (`person` do `background` thêm vào đứng cuối), điểm cao nhất trước trong mỗi nhãn. Hai instance của cùng một nhãn mà mask gần như trùng hoàn toàn (từ 90% trở lên) được tính là một, giữ lại cái điểm cao hơn. Sau đó `max_per_concept` giữ những cái tốt nhất trong phần còn lại. Các instance có điểm đúng bằng nhau giữ thứ tự mà SAM 3 đưa ra. Một concept viết cả số ít lẫn số nhiều (`arm, arms`) chỉ được tìm một lần, và báo cáo cũng chỉ liệt kê nó một lần; `glass` và `glasses` vẫn là hai concept.
|
||||
- **Xem kết quả.** Khi `show_preview` bật, node tự vẽ bản xem trước: không cần node nào khác. Muốn xem ảnh ở kích thước đầy đủ (bản xem trước tối đa 1536 px), hoặc xem Segs Select đang làm việc với gì, hãy nối `segs` và đúng bức ảnh đó vào [Segs Preview](#segs-preview-nh).
|
||||
- **Tự chạy.** SAM3 Segment là một node đầu ra (output node; chấm trên thẻ của nó màu cam): ComfyUI chạy nó kể cả khi không có gì nối vào các đầu ra của nó. Vì vậy một node bạn không còn cần vẫn tiếp tục chạy SAM 3 mỗi khi đầu vào của nó thay đổi, và tắt `show_preview` không làm nó ngừng chạy. Hãy vô hiệu hóa nó (Mute) hoặc xóa nó. Chừng nào đầu vào giữ nguyên, ComfyUI dùng lại kết quả lần trước.
|
||||
- **Không tìm thấy gì** không phải lỗi: bạn nhận được không instance nào và `count` bằng 0. Mask rỗng với danh sách từ đơn thuần, và là cả bức ảnh với một công thức như `background` hay `~person`, vì không có gì được tìm thấy để loại ra.
|
||||
@@ -393,8 +586,8 @@ Giữ lại những instance bạn muốn, theo nhãn, điểm, kích thước v
|
||||
|
||||
| Đầu vào | Kiểu | Mặc định | Mô tả |
|
||||
|---|---|---|---|
|
||||
| `segs` | NH_SEGS | — | Các instance, từ SAM3 Segment hoặc một node Segs khác. |
|
||||
| `labels` | STRING | rỗng | Các nhãn cần giữ, cách nhau bằng dấu phẩy. `*` thay cho một chuỗi ký tự bất kỳ và `?` thay cho một ký tự (`*shirt`). Không phân biệt chữ hoa/thường, và `_` được coi là dấu cách (`red_car` tìm `red car`). Nhãn có dấu cách không cần ngoặc kép ở đây: `wine glass, red car`. Các từ tiếng Việt thông dụng được dịch (`tóc` tìm `hair`); các từ công thức `nền` và `tất cả` không phải nhãn ở đây. Để trống thì giữ mọi nhãn. |
|
||||
| `segs` | NH_SEGS | — | Các instance, từ SAM3 Segment, Garment Segment, Portrait Segment, Body Segment hoặc một node Segs khác. |
|
||||
| `labels` | STRING | rỗng | Các nhãn cần giữ, cách nhau bằng dấu phẩy. `*` thay cho một chuỗi ký tự bất kỳ và `?` thay cho một ký tự (`*shirt`). Không phân biệt chữ hoa/thường, số ít và số nhiều là một (`arm` tìm `arms`), và `_` được coi là dấu cách (`red_car` tìm `red car`). Nhãn có dấu cách không cần ngoặc kép ở đây: `wine glass, red car`. Các từ tiếng Việt thông dụng được dịch (`tóc` tìm `hair`); các từ công thức `nền` và `tất cả` không phải nhãn ở đây. Các từ riêng của node đã tạo ra segs cũng dùng được: `skin` trên segs của Body Segment giữ các bộ phận face, torso, arms, hands, legs và feet của nó (xem [Segs Compose](#segs-compose-nh)). Để trống thì giữ mọi nhãn. |
|
||||
| `min_score` | FLOAT | `0.0` | Khoảng 0 đến 1. Loại các instance có điểm thấp hơn. |
|
||||
| `min_area` | FLOAT | `0.0` | Khoảng 0 đến 1. Loại các instance nhỏ hơn tỉ lệ này của bức ảnh (`0.01` là 1%). |
|
||||
| `max_area` | FLOAT | `1.0` | Khoảng 0 đến 1. Loại các instance lớn hơn tỉ lệ này của bức ảnh. |
|
||||
@@ -438,14 +631,17 @@ Giữ lại những instance bạn muốn, theo nhãn, điểm, kích thước v
|
||||
|
||||
<p align="center"><img src="../assets/nodes/NH_SegsCompose.svg" alt="Segs Compose (NH) node" width="360"></p>
|
||||
|
||||
Tạo một mask từ nhãn của các instance, bằng chính công thức nhỏ của `concepts` trong SAM3 Segment: hợp, trừ, giao và "mọi thứ trừ...".
|
||||
Tạo một mask từ nhãn của các instance, bằng chính công thức nhỏ của `concepts` trong SAM3 Segment: hợp, trừ, giao và "mọi thứ trừ...". Các instance có thể đến từ tối đa ba node cùng lúc.
|
||||
|
||||
**Dùng khi** bạn đã có sẵn các instance và muốn có "phần nền", "người nhưng không có tóc", "bức tường nhưng không có cửa sổ", hoặc một tổ hợp khác của những gì SAM3 Segment tìm được: sau [Segs Select](#segs-select-nh), hoặc để tạo nhiều mask từ một lần chạy SAM 3. Với một mask duy nhất, bạn cũng có thể viết đúng công thức đó thẳng vào `concepts` của SAM3 Segment.
|
||||
**Dùng khi** bạn đã có sẵn các instance và muốn có "phần nền", "người nhưng không có tóc", "bức tường nhưng không có cửa sổ", hoặc một tổ hợp khác của những gì SAM3 Segment tìm được: sau [Segs Select](#segs-select-nh), hoặc để tạo nhiều mask từ một lần chạy SAM 3. Cũng dùng nó để trộn những gì nhiều node tìm được trên cùng một bức ảnh: cái túi từ SAM 3, chiếc đầm từ Garment Segment, đôi môi từ Body Segment. Với một mask duy nhất chỉ từ SAM 3, bạn cũng có thể viết đúng công thức đó thẳng vào `concepts` của SAM3 Segment.
|
||||
|
||||
| Đầu vào | Kiểu | Mặc định | Mô tả |
|
||||
|---|---|---|---|
|
||||
| `segs` | NH_SEGS | — | Các instance, từ SAM3 Segment hoặc Segs Select. |
|
||||
| `segs` | NH_SEGS | — | Các instance, từ SAM3 Segment, Garment Segment, Portrait Segment, Body Segment hoặc Segs Select. |
|
||||
| `expression` | STRING | `all` | Công thức. Xem [Viết công thức](#viết-công-thức). Ô này có nhiều dòng: xuống dòng có tác dụng liệt kê, như dấu phẩy, và nối liền khi nằm cạnh một toán tử. |
|
||||
| `segs_2` | NH_SEGS | — | Tùy chọn (optional). Thêm các instance của cùng bức ảnh, từ một trong các node trên. Chúng được trộn vào công thức cùng với `segs`. |
|
||||
| `segs_3` | NH_SEGS | — | Tùy chọn (optional). Và thêm nữa. |
|
||||
| `check_same_photo` | BOOLEAN | `true` | Tùy chọn (optional). Từ chối các segs trông như từ những bức ảnh khác nhau có cùng kích thước. Hãy tắt nó khi cần trộn segs của các bản chỉnh sửa từ một bức ảnh mà khác nhau nhiều, như đổi phông nền hay đổi ánh sáng. Xem "Chỉ cùng một bức ảnh" bên dưới. |
|
||||
|
||||
| Đầu ra | Kiểu | Mô tả |
|
||||
|---|---|---|
|
||||
@@ -454,7 +650,7 @@ Tạo một mask từ nhãn của các instance, bằng chính công thức nh
|
||||
**Ở đây có gì khác.** Ngôn ngữ chính là ngôn ngữ trong [Viết công thức](#viết-công-thức), với những khác biệt sau so với SAM3 Segment:
|
||||
|
||||
- `or`, `and` và `not` là toán tử: `a or b` là `a | b`, `a and b` là `a & b`, `not a` là `~a`.
|
||||
- Một nhãn chọn các instance mang nhãn đó. Không phân biệt chữ hoa/thường, `_` được coi là dấu cách (`red_car` tìm `red car`), và `*`, `?` là ký tự đại diện (wildcard) (`*shirt`).
|
||||
- Một nhãn chọn các instance mang nhãn đó. Không phân biệt chữ hoa/thường, số ít và số nhiều là một (`arm` tìm `arms`, `foot` tìm `feet`, `shoe` tìm `shoes`, `scarf` tìm `scarves`; `glasses` và `glass` thì được giữ riêng), `_` được coi là dấu cách (`red_car` tìm `red car`), và `*`, `?` là ký tự đại diện (wildcard), được so khớp đúng như đã viết (`*shirt`).
|
||||
- Đặt nhãn có chứa ký tự toán tử trong ngoặc kép, `"` hoặc `'` (`"salt & pepper"`), và dùng ngoặc kép đôi khi nhãn còn có dấu nháy đơn. Nhiều từ liền nhau không cần ngoặc kép.
|
||||
- Nhãn tiếng Việt được dịch (`tóc` tìm `hair`), cả khi nằm trong ngoặc kép. `nền` là từ khóa `background`.
|
||||
|
||||
@@ -466,12 +662,31 @@ Tạo một mask từ nhãn của các instance, bằng chính công thức nh
|
||||
| `(wall \| ceiling) - window` | Tường và trần, khoét bỏ các ô cửa sổ. |
|
||||
| `wine glass, person` | Các ly rượu và người cùng lúc (một danh sách, giống `wine glass \| person`). |
|
||||
| `all - person` | Mọi thứ đã tìm được trừ người. Khác với `~person`, nó không gồm phần nền mà không instance nào phủ tới. |
|
||||
| `dress - bag - lips` | Với ba node trên cùng một bức ảnh: SAM3 Segment (`bag`) vào `segs`, Garment Segment vào `segs_2` và Body Segment vào `segs_3`. Chiếc đầm nhưng bỏ mọi cái túi và đôi môi. |
|
||||
| `face - eyes - eyebrows` | Body Segment vào `segs` và Portrait Segment vào `segs_2`. `face` lấy khuôn mặt của cả hai node (xem bảng kế tiếp), còn mắt và lông mày của Portrait Segment bị bỏ ra. |
|
||||
| `skin - bag` | SAM3 Segment (`bag`) vào `segs` và Body Segment vào `segs_2`. Da nhưng bỏ cái túi: `skin` là một từ riêng của Body Segment (xem bảng kế tiếp). |
|
||||
| `skin - hands` | Chỉ cần segs của Body Segment, với cùng các từ đó. Da nhưng bỏ bàn tay. |
|
||||
|
||||
**Từ riêng của một node.** Một nhãn chọn các instance mang nhãn đó. Mỗi node được đọc riêng: ở node nào không có instance mang từ đó làm nhãn, Segs Compose đọc từ đó như một từ riêng của node ấy và lấy các bộ phận mà từ đó đại diện. Instance của một node mang từ đó không che mất từ của node khác: khi segs của Portrait Segment và của Body Segment cùng có mặt, `skin` là instance skin của Portrait Segment cộng với các bộ phận da của Body Segment. [Segs Select](#segs-select-nh) đọc cùng những từ này trong `labels`. Chỉ các từ nhóm mới đại diện cho nhiều bộ phận:
|
||||
|
||||
| Với segs của | Từ nhóm | Chúng lấy gì |
|
||||
|---|---|---|
|
||||
| Body Segment | `skin` (`body skin`), `head`, `mouth`, `clothes` (`clothing`, `outfit`), `person` (`body`), `upper clothes` (top), `lower clothes` (bottom), `apparel` (accessories), các từ tiếng Việt tương ứng (`da`, `đầu`, `miệng`, `quần áo`, `người`), và các chi nguyên vẹn `tay`, `đôi tay`, `hai tay`, `chân`, `đôi chân`, `hai chân` | `skin`: các bộ phận face, torso, arms, hands, legs và feet. `head`: face và hair. `mouth`: lips, teeth và tongue. `clothes`: top, bottom, shoes, socks và accessories. `person`: mọi bộ phận. `tay`, `đôi tay`, `hai tay`: arms và hands. `chân`, `đôi chân`, `hai chân`: legs và feet. |
|
||||
| Garment Segment | Các nhóm của `part`: `upper_garment`, `lower_garment`, `footwear`, `headwear`, `all_garments` | Với `fashn`: `upper_garment` là top, dress và scarf, `lower_garment` là skirt, pants và belt, `footwear` là feet, `headwear` là hat, `all_garments` là tất cả những thứ đó. Với `fashionpedia`: các nhóm riêng của nó (`footwear` là shoe và sock, `headwear` là hat và headband…). |
|
||||
| Portrait Segment | `face`, `face skin`, `mouth interior` | `face`: skin, nose, glasses, eyes, eyebrows, mouth và lips. `face skin`: bộ phận skin. `mouth interior`: bộ phận mouth, tức bên trong miệng. |
|
||||
|
||||
- Một từ trùng tên với một bộ phận của chính node đó thì không bao giờ đại diện cho các bộ phận khác: `belt` của Fashionpedia chỉ là chiếc thắt lưng.
|
||||
- Các từ gần nghĩa trong `regions` của Body Segment (`dress`, `pants`, `skirt`, `jeans`, `shirt`, `neck`…) không được đọc ở đây: chỉ với segs của Body Segment, `dress` hay `váy` và `pants` hay `quần` không tìm thấy gì. Hãy viết tên bộ phận (`top`, `bottom`), hoặc trộn thêm segs của Garment Segment, nơi dress, skirt và pants được gắn nhãn thật.
|
||||
- Một từ chỉ phủ một phần của một bộ phận (`left hand`, `upper arm`, `thigh`) thì không tìm thấy gì ở đây, các dạng một bên `tay trái`, `tay phải`, `chân trái` và `chân phải` cũng vậy: segs của Body Segment không có instance riêng cho một bên. `regions` của Body Segment có những từ đó. `cánh tay` vẫn là chỉ cánh tay, không kèm bàn tay.
|
||||
- Segs Select đọc từ tiếng Việt `tay` và `chân` là `arm` và `leg`, tức cánh tay và cái chân không kèm bàn tay và bàn chân.
|
||||
|
||||
**Cách hoạt động**
|
||||
|
||||
- **Nhãn mà không instance nào có** cho một phần rỗng, và console báo điều đó (`no instance is labelled 'x'; those parts are empty`). Vì thế gõ sai `~persn` sẽ cho cả bức ảnh: hãy xem console khi mask trông lạ.
|
||||
- **Nhãn mà không instance nào có** cho một phần rỗng, và console báo điều đó kèm danh sách các nhãn mà segs đang có (`no instance is labelled 'x'; those parts are empty. The segs hold: hair, face, …`). Vì thế gõ sai `~persn` sẽ cho cả bức ảnh: hãy xem console khi mask trông lạ.
|
||||
- **Các từ liền nhau là một nhãn.** Khi không instance nào có nhãn đó nhưng mỗi từ lại là nhãn của một instance (`person hair`), node dừng với một lỗi gợi ý `person + hair`. Với dấu gạch nối cũng vậy: khi cả `person` và `hair` đều có, `person-hair` làm node dừng với lỗi cho thấy `person - hair`.
|
||||
- **Mỗi ảnh** trong batch có mask riêng, dựng từ chính các instance của nó.
|
||||
- **Trộn nhiều node.** `segs`, `segs_2` và `segs_3` được gộp lại trước khi công thức được đọc: mỗi ảnh nhận các instance của tất cả chúng, và một nhãn chọn các instance của mọi node có nhãn đó. Khi hai node dùng cùng một nhãn (`hair` ở Portrait Segment và ở Body Segment, `glasses` ở ba node), nhãn đó là hợp mask của chúng. Mỗi node cắt bức ảnh bằng model riêng của nó, nên mask từ các node khác nhau không khớp nhau đến từng cạnh.
|
||||
- **Chỉ cùng một bức ảnh.** Node dừng lại khi các segs đến từ các bức ảnh khác nhau: số ảnh khác nhau, hoặc kích thước khác nhau. Khi `check_same_photo` bật, node còn dừng lại nếu chúng trông như những bức ảnh khác nhau có cùng kích thước. Mỗi khung (frame) của segs giữ một bản in nhỏ màu xám của bức ảnh của nó, và các bản in phải khớp nhau. Ảnh đã chỉnh sửa từ cùng một bức (độ sáng, độ tương phản, màu sắc, làm mờ) vẫn được chấp nhận. Đây là chốt chặn cho việc nối nhầm dây chứ không phải bằng chứng: nó bắt được phần lớn các bức ảnh khác có cùng kích thước, một bản bị dịch hoặc phóng nhẹ thường vẫn lọt qua, và một phông nền mới sau cùng một người có thể bị từ chối. Với những bản chỉnh sửa như vậy, hãy tắt `check_same_photo`. Segs do phiên bản cũ tạo ra không có bản in và được trộn chỉ dựa vào kích thước.
|
||||
- **Công thức không đọc được** làm node dừng lại với thông báo nêu vị trí của chỗ sai, ví dụ `unexpected '(' at position 8` với `person (adult)`.
|
||||
|
||||
<p align="center"><img src="../assets/examples/sam3-segment.jpg" alt="Các instance của SAM3 Segment do Segs Preview vẽ, và ba mask tạo từ chúng bằng Segs Compose và Segs Select trên một ảnh mẫu" width="720"><br><em>Các instance của "person, helmet, face" trên một ảnh mẫu, do Segs Preview vẽ, rồi ba mask tạo từ chúng bằng Segs Compose và Segs Select.</em></p>
|
||||
@@ -488,7 +703,7 @@ Tạo một mask từ nhãn của các instance, bằng chính công thức nh
|
||||
|
||||
| Đầu vào | Kiểu | Mặc định | Mô tả |
|
||||
|---|---|---|---|
|
||||
| `segs` | NH_SEGS | — | Các instance, từ SAM3 Segment hoặc Segs Select. |
|
||||
| `segs` | NH_SEGS | — | Các instance, từ SAM3 Segment, Garment Segment, Portrait Segment, Body Segment hoặc Segs Select. |
|
||||
|
||||
| Đầu ra | Kiểu | Mô tả |
|
||||
|---|---|---|
|
||||
@@ -511,7 +726,7 @@ Chuyển các instance của một ảnh sang `SEGS` của Impact Pack, để c
|
||||
|
||||
| Đầu vào | Kiểu | Mặc định | Mô tả |
|
||||
|---|---|---|---|
|
||||
| `segs` | NH_SEGS | — | Các instance, từ SAM3 Segment hoặc Segs Select. |
|
||||
| `segs` | NH_SEGS | — | Các instance, từ SAM3 Segment, Garment Segment, Portrait Segment, Body Segment hoặc Segs Select. |
|
||||
| `crop_factor` | FLOAT | `3.0` | Khoảng 1 đến 10. Kích thước vùng mà detailer xử lý quanh mỗi instance, tính bằng bội số của khung của nó (cũng là `crop_factor` của Impact Pack). Vùng được dịch để nằm trong ảnh và luôn chứa trọn khung. |
|
||||
| `batch_index` | INT | `0` | Khoảng 0 đến 4095. SEGS của Impact Pack chỉ chứa một ảnh, nên đầu vào này chọn ảnh nào trong batch được chuyển. Số vượt quá ảnh cuối làm node dừng lại với một thông báo. |
|
||||
|
||||
@@ -604,7 +819,9 @@ Load Image → SAM3 Segment (concepts: building - (window + door))
|
||||
| Lỗi từ Segs Preview về kích thước hoặc số ảnh | Hãy nối đúng bức ảnh mà segs được tìm ra từ đó. |
|
||||
| Lỗi từ Segs Compose có nêu một vị trí | Kiểm tra công thức tại vị trí đó. Xem [Viết công thức](#viết-công-thức). |
|
||||
| Lỗi từ Segs Compose báo không instance nào có nhãn `person hair`, nhưng `person` và `hair` thì có | Các từ liền nhau là một nhãn. Hãy nối chúng lại: `person + hair`. |
|
||||
| Mask của Segs Compose rỗng, và console báo không instance nào có một nhãn | Kiểm tra chính tả của nhãn. Đặt dấu cách quanh dấu trừ: `person - hair`, không phải `person-hair`. |
|
||||
| Mask của Segs Compose rỗng, và console báo không instance nào có một nhãn | Kiểm tra chính tả của nhãn, và đọc các nhãn mà console liệt kê sau "The segs hold". Đặt dấu cách quanh dấu trừ: `person - hair`, không phải `person-hair`. Nhãn khác nhau giữa các node: xem bảng đầu ra của node đã tạo ra segs, và các từ riêng của nó ở mục "Từ riêng của một node" trong [Segs Compose](#segs-compose-nh). |
|
||||
| Lỗi từ Segs Compose báo các segs cần trộn đến từ các bức ảnh khác nhau (different pictures) | `segs`, `segs_2` và `segs_3` phải đến từ cùng một batch ảnh: cùng số ảnh, cùng kích thước. Hãy chạy mọi node trên cùng một Load Image. |
|
||||
| Lỗi từ Segs Compose báo các segs cần trộn trông như những bức ảnh khác nhau có cùng kích thước (look like different photos of the same size) | Một trong các node đã chạy trên một bức ảnh khác có cùng kích thước. Hãy chạy mọi node trên cùng một bức ảnh. Nếu chúng là các bản chỉnh sửa của một bức ảnh mà khác nhau nhiều (đổi phông nền, đổi ánh sáng), hãy tắt `check_same_photo`. Bản chỉ chỉnh độ sáng, độ tương phản, màu sắc hoặc làm mờ thì được chấp nhận. |
|
||||
| Lỗi từ Segs To Masks báo các mask instance kích thước đầy đủ cần nhiều bộ nhớ hơn mức còn trống | Có quá nhiều instance so với kích thước bức ảnh. Dùng Segs Select để chỉ giữ những instance bạn cần, hoặc Segs Compose để lấy một mask duy nhất. |
|
||||
| Console báo SAM 3 nhìn bức ảnh bị nén vào hình vuông và có thể bỏ sót vật nhỏ | Bức ảnh rộng hoặc cao hơn 4:1. Hãy chia nó thành các ô bằng Image Tile (NH) rồi chạy từng ô. |
|
||||
| ComfyUI hết VRAM sau khi node chạy | Để `keep_model_loaded` tắt, để trọng số rời GPU sau mỗi lần chạy. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
"""Body Segment (NH): skin, hair, arms, lips, teeth, clothes... of the people in a picture, from a few words.
|
||||
|
||||
Sapiens2 (core/sapiens2_backend.py) labels every pixel of each person with one of 28 body parts and clothes, on crops
|
||||
of its 3:4 shape around the people (core/parse_crops.people_labels), so a person gets about 1024 px of the model
|
||||
whatever the size of the photo. The words you type pick the regions, with the formula of SAM3 Segment (core/segs.py):
|
||||
``clothes - shoes``, ``hair + face``, ``~person``. Sapiens2 has fewer classes than the words people use, so some words
|
||||
share one (the face and the neck, a top and a dress); the report says so when it matters.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from ...core import sapiens2_backend as sapiens2
|
||||
from ...core.parse_crops import people_labels, person_box
|
||||
from ...core.segs import (
|
||||
SEGS_TYPE,
|
||||
EmptyExpressionError,
|
||||
ExpressionError,
|
||||
add_aliases,
|
||||
evaluate,
|
||||
expression_terms,
|
||||
fold_label,
|
||||
normalize_label,
|
||||
parse_concepts,
|
||||
preview_picture,
|
||||
stamped,
|
||||
)
|
||||
from ...core.ui_preview import MAX_PREVIEWS, save_temp_previews
|
||||
from ...core.vi_words import normalize
|
||||
from .vision_segment_nodes import (
|
||||
DEVICES,
|
||||
_image_batch_to_numpy,
|
||||
find_faces,
|
||||
labels_at_work_size,
|
||||
pack_outputs,
|
||||
parts_frame,
|
||||
refine_mask,
|
||||
)
|
||||
|
||||
try:
|
||||
from comfy.model_management import throw_exception_if_processing_interrupted
|
||||
except Exception: # outside ComfyUI (tests)
|
||||
def throw_exception_if_processing_interrupted():
|
||||
return None
|
||||
|
||||
_C = {name: index for index, name in enumerate(sapiens2.CLASSES)}
|
||||
|
||||
|
||||
def _ids(*names):
|
||||
return frozenset(_C[name] for name in names)
|
||||
|
||||
|
||||
ARMS = _ids("left_lower_arm", "left_upper_arm", "right_lower_arm", "right_upper_arm")
|
||||
LEGS = _ids("left_lower_leg", "left_upper_leg", "right_lower_leg", "right_upper_leg")
|
||||
HANDS = _ids("left_hand", "right_hand")
|
||||
FEET = _ids("left_foot", "right_foot")
|
||||
LIPS = _ids("lower_lip", "upper_lip")
|
||||
TEETH = _ids("lower_teeth", "upper_teeth")
|
||||
FACE = _ids("face_neck")
|
||||
HAIR = _ids("hair")
|
||||
TOP = _ids("upper_clothing")
|
||||
BOTTOM = _ids("lower_clothing")
|
||||
SHOES = _ids("left_shoe", "right_shoe")
|
||||
GLASSES = _ids("eyeglass")
|
||||
SKIN = FACE | _ids("torso") | ARMS | LEGS | HANDS | FEET
|
||||
CLOTHES = _ids("apparel", "upper_clothing", "lower_clothing", "left_shoe", "right_shoe", "left_sock", "right_sock")
|
||||
PERSON = frozenset(range(1, len(sapiens2.CLASSES)))
|
||||
|
||||
# The words Body Segment understands, and the classes each one covers. Plurals and the common synonyms are listed so
|
||||
# that what people naturally type works; Vietnamese words reach here already translated by vi_words.
|
||||
REGIONS = {
|
||||
"skin": SKIN, "body skin": SKIN,
|
||||
"face": FACE, "neck": FACE, "face neck": FACE, "face skin": FACE, "head": FACE | HAIR,
|
||||
"hair": HAIR,
|
||||
"arm": ARMS, "arms": ARMS, "upper arm": _ids("left_upper_arm", "right_upper_arm"),
|
||||
"upper arms": _ids("left_upper_arm", "right_upper_arm"),
|
||||
"lower arm": _ids("left_lower_arm", "right_lower_arm"), "lower arms": _ids("left_lower_arm", "right_lower_arm"),
|
||||
"forearm": _ids("left_lower_arm", "right_lower_arm"), "forearms": _ids("left_lower_arm", "right_lower_arm"),
|
||||
"hand": HANDS, "hands": HANDS,
|
||||
"leg": LEGS, "legs": LEGS, "thigh": _ids("left_upper_leg", "right_upper_leg"),
|
||||
"thighs": _ids("left_upper_leg", "right_upper_leg"), "upper legs": _ids("left_upper_leg", "right_upper_leg"),
|
||||
"calf": _ids("left_lower_leg", "right_lower_leg"), "calves": _ids("left_lower_leg", "right_lower_leg"),
|
||||
"lower legs": _ids("left_lower_leg", "right_lower_leg"),
|
||||
"foot": FEET, "feet": FEET,
|
||||
"torso": _ids("torso"), "chest": _ids("torso"), "belly": _ids("torso"),
|
||||
"lips": LIPS, "lip": LIPS, "upper lip": _ids("upper_lip"), "lower lip": _ids("lower_lip"),
|
||||
"teeth": TEETH, "tooth": TEETH, "upper teeth": _ids("upper_teeth"), "lower teeth": _ids("lower_teeth"),
|
||||
"tongue": _ids("tongue"), "mouth": LIPS | TEETH | _ids("tongue"),
|
||||
"glasses": GLASSES, "eyeglasses": GLASSES, "sunglasses": GLASSES,
|
||||
"clothes": CLOTHES, "clothing": CLOTHES, "outfit": CLOTHES,
|
||||
# Sapiens2 has one class for everything worn on the upper body, dresses included, and one for the lower body
|
||||
"top": TOP, "upper clothes": TOP, "shirt": TOP, "t-shirt": TOP, "blouse": TOP, "jacket": TOP, "suit jacket": TOP,
|
||||
"sweater": TOP, "hoodie": TOP, "coat": TOP, "vest": TOP, "bra": TOP, "dress": TOP, "wedding dress": TOP,
|
||||
"ao dai": TOP,
|
||||
"bottom": BOTTOM, "lower clothes": BOTTOM, "pants": BOTTOM, "trousers": BOTTOM, "jeans": BOTTOM,
|
||||
"shorts": BOTTOM, "skirt": BOTTOM,
|
||||
"shoe": SHOES, "shoes": SHOES, "high heels": SHOES, "sandal": SHOES, "sandals": SHOES, "sneakers": SHOES,
|
||||
"boots": SHOES,
|
||||
"sock": _ids("left_sock", "right_sock"), "socks": _ids("left_sock", "right_sock"),
|
||||
"accessories": _ids("apparel"), "apparel": _ids("apparel"),
|
||||
"person": PERSON, "body": PERSON,
|
||||
"left hand": _ids("left_hand"), "right hand": _ids("right_hand"),
|
||||
"left arm": _ids("left_lower_arm", "left_upper_arm"), "right arm": _ids("right_lower_arm", "right_upper_arm"),
|
||||
"left leg": _ids("left_lower_leg", "left_upper_leg"), "right leg": _ids("right_lower_leg", "right_upper_leg"),
|
||||
"left foot": _ids("left_foot"), "right foot": _ids("right_foot"),
|
||||
"left shoe": _ids("left_shoe"), "right shoe": _ids("right_shoe"),
|
||||
}
|
||||
# Vietnamese "tay" and "chân" name the whole limb, hand or foot included ("cánh tay" is the arm alone, "bàn tay" the
|
||||
# hand); vi_words turns them into "arm" and "leg", the limb alone in English, so the request keeps them apart under
|
||||
# their own name (see _whole_limbs).
|
||||
WHOLE_LIMBS = {
|
||||
"tay": ARMS | HANDS, "đôi tay": ARMS | HANDS, "hai tay": ARMS | HANDS,
|
||||
"chân": LEGS | FEET, "đôi chân": LEGS | FEET, "hai chân": LEGS | FEET,
|
||||
"tay trái": _ids("left_lower_arm", "left_upper_arm", "left_hand"),
|
||||
"tay phải": _ids("right_lower_arm", "right_upper_arm", "right_hand"),
|
||||
"chân trái": _ids("left_lower_leg", "left_upper_leg", "left_foot"),
|
||||
"chân phải": _ids("right_lower_leg", "right_upper_leg", "right_foot"),
|
||||
}
|
||||
# Words people try that Sapiens2 has no class for, and what to do instead.
|
||||
_IN_FACE = "eyes, eyebrows, nose and ears are part of 'face' here; Portrait Segment masks them one by one"
|
||||
_BEARD = "a beard is part of 'face' here, and no NH-Nodes parser has a beard class (SAM3 Segment can try 'beard')"
|
||||
_ACCESSORY = ("Sapiens2 puts hats, bags and other accessories in one class, 'accessories'; for one of them use "
|
||||
"Garment Segment or SAM3 Segment")
|
||||
NOT_REGIONS = {
|
||||
**dict.fromkeys(("eye", "eyes", "eyebrow", "eyebrows", "eyelash", "nose", "ear", "ears"), _IN_FACE),
|
||||
**dict.fromkeys(("beard", "mustache", "moustache"), _BEARD),
|
||||
**dict.fromkeys(("hat", "cap", "bag", "handbag", "backpack", "scarf", "belt", "necklace", "jewelry", "watch",
|
||||
"glove", "gloves", "tie"), _ACCESSORY),
|
||||
**dict.fromkeys(("shoulder", "shoulders"), "shoulders are part of 'torso' and 'upper arms'"),
|
||||
**dict.fromkeys(("finger", "fingers", "fingernail"), "fingers are part of 'hands'"),
|
||||
}
|
||||
# One instance per part in the segs output, named as people say them.
|
||||
PARTS = {
|
||||
"hair": HAIR, "face": FACE, "torso": _ids("torso"), "arms": ARMS, "hands": HANDS,
|
||||
"legs": LEGS, "feet": FEET, "lips": LIPS, "teeth": TEETH, "tongue": _ids("tongue"), "glasses": GLASSES,
|
||||
"top": TOP, "bottom": BOTTOM, "shoes": SHOES, "socks": _ids("left_sock", "right_sock"), "accessories": _ids("apparel"),
|
||||
}
|
||||
MODEL_SIZES = tuple(sapiens2.MODELS)
|
||||
_EXAMPLES = "skin, hair, face, arms, hands, legs, feet, torso, lips, teeth, glasses, clothes, top, bottom, shoes"
|
||||
_VIETNAMESE = "da, tóc, mặt, môi, răng, tay, chân, áo, quần, giày"
|
||||
# With no face found, Sapiens2's first look must be sure (0.9 or more) of this share of what it labels as people: it
|
||||
# was 0.61 to 0.98 on 32 faceless photos of people (backs, bodies without heads, a mannequin) and 0.00 to 0.41 on 17
|
||||
# pictures of no one (a facade, a cafe, an elephant, products, masks), with PIL's and with ComfyUI's JPEG decoder.
|
||||
FIRST_LOOK_FLOOR = 0.5
|
||||
|
||||
_SIDES = ("left and right are the person's own sides: on the right of the picture when they face the camera")
|
||||
_FACE_CLASS = ("face is one Sapiens2 class with the neck, eyes, brows and beard (skin includes it); Portrait Segment "
|
||||
"masks the parts of a face")
|
||||
_NOBODY = "no person found (none Sapiens2 was sure of, and no face)"
|
||||
# the words above in singular (fold_label), so "lower leg", "accessory" or "dresses" are read like the listed form
|
||||
_FOLDED_REGIONS = {fold_label(word): ids for word, ids in REGIONS.items()}
|
||||
_FOLDED_NOT_REGIONS = {fold_label(word): advice for word, advice in NOT_REGIONS.items()}
|
||||
# Segs Compose and Segs Select read Body Segment's group words on its segs ("skin" is the face, torso, arms... parts),
|
||||
# the other names of its parts that mean exactly them, and the Vietnamese whole limbs; not the near synonyms ("dress"
|
||||
# is its top, "neck" its face), which other nodes label for real.
|
||||
_COMPOSE_WORDS = ("skin", "body skin", "head", "mouth", "clothes", "clothing", "outfit", "person", "body",
|
||||
"upper clothes", "lower clothes", "apparel")
|
||||
add_aliases("sapiens2", {**{word: REGIONS[word] for word in _COMPOSE_WORDS}, **WHOLE_LIMBS}, PARTS)
|
||||
|
||||
|
||||
def region_ids(written, english, status):
|
||||
"""Class ids a word of the request covers, or None when Body Segment has no such region."""
|
||||
if status == "translated" and normalize(written) in WHOLE_LIMBS:
|
||||
return WHOLE_LIMBS[normalize(written)]
|
||||
word = normalize_label(english)
|
||||
return REGIONS.get(word, _FOLDED_REGIONS.get(fold_label(word)))
|
||||
|
||||
|
||||
def _whole_limbs(tree):
|
||||
"""The parsed request with "tay", "chân"... named as written, so "tay - cánh tay" (whole arms minus arms) leaves the
|
||||
hands instead of nothing: both would be "arm" in English."""
|
||||
if tree[0] == "label":
|
||||
word = normalize(tree[2])
|
||||
return ("label", word, tree[2], tree[3]) if tree[3] == "translated" and word in WHOLE_LIMBS else tree
|
||||
if tree[0] == "keyword":
|
||||
return tree
|
||||
return (tree[0], *(_whole_limbs(child) for child in tree[1:]))
|
||||
|
||||
|
||||
def check_regions(tree):
|
||||
"""{english label: class ids} of the regions a parsed request names; a word Body Segment does not know raises a
|
||||
ValueError saying what to write instead, before any model is loaded."""
|
||||
named, problems = {}, []
|
||||
for written, english, status in expression_terms(tree):
|
||||
if status == "keyword":
|
||||
continue
|
||||
word = normalize_label(english)
|
||||
shown = f"'{written}' ({english})" if status == "translated" else f"'{written}'"
|
||||
if any(ch in word for ch in "*?["):
|
||||
problems.append(f"{shown} has a wildcard; Body Segment takes region names ({_EXAMPLES})")
|
||||
continue
|
||||
ids = region_ids(written, english, status)
|
||||
if ids is not None:
|
||||
named[word] = named.get(word, frozenset()) | ids
|
||||
elif status == "unknown":
|
||||
problems.append(f"{shown} is not in the Vietnamese word list; write it in English or use {_VIETNAMESE}")
|
||||
elif fold_label(word) in _FOLDED_NOT_REGIONS:
|
||||
problems.append(f"{shown} is not a body region: {_FOLDED_NOT_REGIONS[fold_label(word)]}")
|
||||
elif {"and", "or", "not"} & set(word.split()):
|
||||
problems.append(f"{shown} is not a body region; combine regions with + or a comma (skin + hair), remove "
|
||||
"with - and invert with ~")
|
||||
else:
|
||||
problems.append(f"{shown} is not a body region; use {_EXAMPLES} (Vietnamese too: {_VIETNAMESE})")
|
||||
if problems:
|
||||
raise ValueError("[NH-Nodes] Body Segment: " + "; ".join(problems) + ".")
|
||||
return named
|
||||
|
||||
|
||||
def _share(covered, pixels, where):
|
||||
if not covered:
|
||||
return f"not in the {where}"
|
||||
share = 100.0 * covered / max(1, pixels)
|
||||
return f"{share:.1f}% of the {where}" if share >= 0.1 else f"under 0.1% of the {where}"
|
||||
|
||||
|
||||
def describe(tree, counts, pixels, where="picture"):
|
||||
"""Report lines for a request: the share of the picture each word covers, from the per-class pixel ``counts``
|
||||
of a label map of ``pixels`` pixels; then what the words mean where Sapiens2 differs from everyday use."""
|
||||
lines, notes, seen = [], [], set()
|
||||
for written, english, status in expression_terms(tree):
|
||||
word = normalize_label(english)
|
||||
if (status == "keyword", word) in seen:
|
||||
continue
|
||||
seen.add((status == "keyword", word))
|
||||
if status == "keyword":
|
||||
if english == "background":
|
||||
lines.append("background = everything outside the people")
|
||||
continue
|
||||
ids = region_ids(written, english, status)
|
||||
name = f"{written} -> {english}" if status == "translated" and word != normalize(written) else written
|
||||
covered = int(sum(counts[i] for i in ids))
|
||||
lines.append(f"{name}: {_share(covered, pixels, where)}")
|
||||
folded = fold_label(word)
|
||||
if word.startswith(("left ", "right ")):
|
||||
notes.append(_SIDES)
|
||||
# what a class holds matters only when the picture has some of it
|
||||
if covered and folded in ("skin", "body skin", "face", "neck", "face neck", "face skin", "head"):
|
||||
notes.append(_FACE_CLASS)
|
||||
if covered and folded in ("dress", "wedding dress", "ao dai"):
|
||||
notes.append(f"{name}: Sapiens2 labels a dress as a top (upper clothes)")
|
||||
if covered and folded == "skirt":
|
||||
notes.append("skirt: the skirt of a dress counts as a top in Sapiens2")
|
||||
if status == "translated" and normalize(written) in WHOLE_LIMBS:
|
||||
notes.append(f"{written}: the whole limb, {'hands' if ids & HANDS else 'feet'} included")
|
||||
return lines + list(dict.fromkeys(notes))
|
||||
|
||||
|
||||
def body_labels(bundle, picture):
|
||||
"""Sapiens2 label map of the people in the uint8 RGB ``picture``, parsed close up (see parse_crops.people_labels),
|
||||
with the people its first look misses found from their faces; nobody when no face is found and it is unsure of
|
||||
everyone it sees (on a picture without people it would otherwise see faint people in walls and shelves)."""
|
||||
|
||||
def predict(crop):
|
||||
throw_exception_if_processing_interrupted()
|
||||
return sapiens2.predict_labels(bundle, crop, confidence=True)
|
||||
|
||||
people = [person_box(face, picture.shape[:2]) for face in find_faces(picture, second_look=False)]
|
||||
return people_labels(predict, picture, sapiens2.INPUT_SIZE[1] / sapiens2.INPUT_SIZE[0], people=people,
|
||||
floor=FIRST_LOOK_FLOOR)
|
||||
|
||||
|
||||
def _mostly_inside(seg, held):
|
||||
"""True when at least half of the instance ``seg`` lies in the bool mask ``held``."""
|
||||
x1, y1, x2, y2 = seg.box
|
||||
pixels = seg.mask.numpy()
|
||||
return 2 * int((held[y1:y2, x1:x2] & pixels).sum()) >= int(pixels.sum())
|
||||
|
||||
|
||||
class NH_BodySegment:
|
||||
"""Mask body parts, skin and clothes of the people in a picture by name, with Sapiens2 (Meta)."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"regions": ("STRING", {
|
||||
"default": "skin", "multiline": True,
|
||||
"tooltip": f"Which parts, by name: {_EXAMPLES}, upper arms, thighs, left hand, mouth, person... "
|
||||
f"Vietnamese works too ({_VIETNAMESE}). Combine like SAM3 Segment: clothes - shoes, "
|
||||
"hair + face, ~person, background. Commas list several. Left and right are the "
|
||||
"person's own sides; face includes the neck.",
|
||||
}),
|
||||
"model_size": (list(MODEL_SIZES), {
|
||||
"default": "0.4b",
|
||||
"tooltip": "Sapiens2 size: 0.4b (1.6 GB download, about 1.5 GB of VRAM) is fast and good; 0.8b "
|
||||
"(3.3 GB) and 1b (5.9 GB) are a little more precise, slower and need more VRAM.",
|
||||
}),
|
||||
"mask_expand": ("INT", {"default": 0, "min": -64, "max": 64, "step": 1,
|
||||
"tooltip": "Grow (+) or shrink (-) the mask by N pixels."}),
|
||||
"mask_blur": ("INT", {"default": 0, "min": 0, "max": 64, "step": 1,
|
||||
"tooltip": "Feather the mask edge by N pixels."}),
|
||||
"fill_holes": ("BOOLEAN", {"default": False, "tooltip": "Fill enclosed holes."}),
|
||||
"device": (DEVICES, {"default": "cuda"}),
|
||||
},
|
||||
# optional, like SAM3 Segment's, so API prompts without it still validate
|
||||
"optional": {
|
||||
"show_preview": ("BOOLEAN", {
|
||||
"default": True,
|
||||
"tooltip": "Show the regions and the report on the node itself.",
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MASK", "IMAGE", "IMAGE", SEGS_TYPE, "STRING")
|
||||
RETURN_NAMES = ("mask", "cutout_rgba", "mask_image", "segs", "report")
|
||||
OUTPUT_NODE = True # it shows its own preview, so it runs even with nothing connected
|
||||
FUNCTION = "segment"
|
||||
CATEGORY = "NH-Nodes/Vision"
|
||||
DESCRIPTION = (
|
||||
"Type the body parts you want ('skin', 'hair, face', 'clothes - shoes', 'teeth') and get their mask: Sapiens2 "
|
||||
"(Meta) labels skin, hair, face, arms, hands, legs, feet, torso, lips, teeth, glasses and clothes of every "
|
||||
"person at about 1024 px per person. Shows what it found on the node. segs feed Segs Compose."
|
||||
)
|
||||
SEARCH_ALIASES = ["body segment", "skin mask", "body parts", "sapiens", "teeth mask", "lips mask", "arms mask"]
|
||||
|
||||
def segment(self, image, regions, model_size, mask_expand, mask_blur, fill_holes, device, show_preview=True):
|
||||
try:
|
||||
tree = _whole_limbs(parse_concepts(regions))
|
||||
except EmptyExpressionError:
|
||||
raise ValueError("[NH-Nodes] Body Segment: write at least one region, e.g. 'skin' or 'hair'.") from None
|
||||
except ExpressionError as exc:
|
||||
raise ValueError(f"[NH-Nodes] Body Segment: {exc}") from None
|
||||
named = check_regions(tree)
|
||||
bundle = sapiens2.load(model_size, sapiens2.torch_device(device))
|
||||
masks, cutouts, mask_images, frames, counts, pixels = [], [], [], [], [], []
|
||||
try:
|
||||
for picture in _image_batch_to_numpy(image):
|
||||
throw_exception_if_processing_interrupted()
|
||||
size = picture.shape[:2]
|
||||
_, work = labels_at_work_size(body_labels, bundle, picture, full=False)
|
||||
counts.append(np.bincount(work.ravel(), minlength=len(sapiens2.CLASSES)))
|
||||
pixels.append(work.size)
|
||||
# the formula sees one instance per word it names, plus the person (for ~person and background)
|
||||
formula_frame = parts_frame(work, {**named, "person": PERSON}, size)
|
||||
mask = refine_mask(evaluate(tree, formula_frame).numpy().astype(np.uint8), mask_expand, mask_blur,
|
||||
fill_holes)
|
||||
mask_t, rgba_t, mask_img_t = pack_outputs(picture, mask)
|
||||
masks.append(mask_t)
|
||||
cutouts.append(rgba_t)
|
||||
mask_images.append(mask_img_t)
|
||||
frames.append(parts_frame(work, PARTS, size, "sapiens2"))
|
||||
finally:
|
||||
sapiens2.offload(bundle)
|
||||
frames = stamped(frames, image)
|
||||
where = "picture" if len(frames) == 1 else "pictures"
|
||||
lines = describe(tree, np.sum(counts, axis=0), sum(pixels), where)
|
||||
empty = [str(index + 1) for index, found in enumerate(counts) if not found[1:].any()]
|
||||
if empty:
|
||||
lines.append(_NOBODY if len(frames) == 1 else f"{_NOBODY} in image(s) {', '.join(empty)}")
|
||||
report = "\n".join([f"Body Segment (Sapiens2 {model_size}): {len(frames)} image(s)", *lines])
|
||||
mask_batch = torch.cat(masks, 0)
|
||||
result = (mask_batch, torch.cat(cutouts, 0), torch.cat(mask_images, 0), frames, report)
|
||||
if not show_preview:
|
||||
return result
|
||||
wanted = frozenset().union(*named.values())
|
||||
try:
|
||||
pictures = []
|
||||
for index, (picture, frame, held) in enumerate(zip(image[:MAX_PREVIEWS], frames, mask_batch)):
|
||||
inside = held.numpy() >= 0.5
|
||||
# the parts the request names and keeps: "skin - lips" shows the skin parts, not the lips
|
||||
shown = frame.with_segments(seg for seg in frame.segments
|
||||
if PARTS[seg.label] & wanted and _mostly_inside(seg, inside))
|
||||
notes = lines if len(frames) == 1 else describe(tree, counts[index], pixels[index]) + (
|
||||
[] if counts[index][1:].any() else [_NOBODY])
|
||||
pictures.append(preview_picture(picture, shown, notes, mask=held))
|
||||
images = save_temp_previews(pictures, "NH_body")
|
||||
except Exception as exc: # noqa: BLE001 - a preview that cannot be drawn or saved must not lose the run
|
||||
print(f"[NH-Nodes] Body Segment: could not show the preview ({exc}); the outputs are fine")
|
||||
return result
|
||||
return {"ui": {"images": images}, "result": result}
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"NH_BodySegment": NH_BodySegment,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"NH_BodySegment": "Body Segment (NH)",
|
||||
}
|
||||
@@ -13,9 +13,10 @@ from ...core.segs import (
|
||||
describe_request,
|
||||
expression_labels,
|
||||
expression_masks,
|
||||
normalize_label,
|
||||
fold_label,
|
||||
parse_concepts,
|
||||
preview_picture,
|
||||
stamped,
|
||||
uses_keyword,
|
||||
)
|
||||
from ...core.ui_preview import MAX_PREVIEWS, save_temp_previews
|
||||
@@ -42,8 +43,8 @@ def concepts_to_find(tree):
|
||||
if any(ch in label for ch in "*?["):
|
||||
raise ValueError(f"[NH-Nodes] SAM3 Segment: '{label}' has a wildcard; SAM 3 needs plain words (wildcards "
|
||||
"work in Segs Select and Segs Compose).")
|
||||
if normalize_label(label) not in seen:
|
||||
seen.add(normalize_label(label))
|
||||
if fold_label(label) not in seen: # "arm, arms" is one search
|
||||
seen.add(fold_label(label))
|
||||
names.append(label)
|
||||
added = []
|
||||
if uses_keyword(tree, "background") and "person" not in seen:
|
||||
@@ -139,6 +140,7 @@ class NH_SAM3Segment:
|
||||
progress=(lambda done, total: bar.update_absolute(done, total)) if bar is not None else None,
|
||||
misses=misses,
|
||||
)
|
||||
segs = stamped(segs, image)
|
||||
count = count_segments(segs)
|
||||
lines = describe_request(tree, segs, threshold, misses, added)
|
||||
report = "\n".join([f"SAM3 Segment: {count} instance(s) in {len(segs)} image(s)", *lines])
|
||||
|
||||
@@ -6,11 +6,13 @@ All of them work on the instances SAM3 Segment (NH) finds; the logic lives in co
|
||||
from ...core.segs import (
|
||||
SEGS_TYPE,
|
||||
SORT_ORDERS,
|
||||
merge_segs,
|
||||
count_segments,
|
||||
expression_labels,
|
||||
expression_masks,
|
||||
expression_terms,
|
||||
instance_masks,
|
||||
label_matches,
|
||||
matching,
|
||||
parse_expression,
|
||||
parse_label_list,
|
||||
preview_batch,
|
||||
@@ -83,6 +85,17 @@ class NH_SegsCompose:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"optional": {
|
||||
"segs_2": (SEGS_TYPE, {"tooltip": "More instances of the same pictures (from another SAM3 Segment, "
|
||||
"Garment, Portrait or Body Segment), mixed into the formula."}),
|
||||
"segs_3": (SEGS_TYPE, {"tooltip": "And more."}),
|
||||
"check_same_photo": ("BOOLEAN", {
|
||||
"default": True,
|
||||
"tooltip": "Refuse segs of different photos of the same size (each segs keeps a small print of "
|
||||
"its picture). Turn off to mix segs of edits of one photo, such as a new background "
|
||||
"or a relight.",
|
||||
}),
|
||||
},
|
||||
"required": {
|
||||
"segs": (SEGS_TYPE,),
|
||||
"expression": ("STRING", {
|
||||
@@ -108,30 +121,36 @@ class NH_SegsCompose:
|
||||
)
|
||||
SEARCH_ALIASES = ["segs compose", "mask formula", "combine masks", "subtract mask", "background mask"]
|
||||
|
||||
def compose(self, segs, expression):
|
||||
def compose(self, segs, expression, segs_2=None, segs_3=None, check_same_photo=True):
|
||||
segs = merge_segs(segs, segs_2, segs_3, check_prints=check_same_photo)
|
||||
tree = parse_expression(expression)
|
||||
labels = [seg.label for frame in segs for seg in frame.segments]
|
||||
|
||||
def found(pattern):
|
||||
return any(label_matches(label, pattern) for label in labels)
|
||||
def found(pattern, written=None):
|
||||
return any(matching(frame.segments, pattern, written) for frame in segs)
|
||||
|
||||
unmatched = [pattern for pattern in expression_labels(tree) if not found(pattern)]
|
||||
def carried(pattern): # a label an instance carries, not a node's word for some of its parts
|
||||
return any(label_matches(seg.label, pattern) for frame in segs for seg in frame.segments)
|
||||
|
||||
terms = [(english, written if status == "translated" else None)
|
||||
for written, english, status in expression_terms(tree) if status != "keyword"]
|
||||
unmatched = list(dict.fromkeys(english for english, written in terms if not found(english, written)))
|
||||
for pattern in unmatched:
|
||||
words = pattern.split()
|
||||
if len(words) > 1 and all(found(word) for word in words):
|
||||
if len(words) > 1 and all(carried(word) for word in words):
|
||||
# words in a row make one label; with each of them found, an operator was left out
|
||||
raise ValueError(f"[NH-Nodes] Segs Compose: no instance is labelled '{pattern}', but "
|
||||
f"{' and '.join(repr(word) for word in words)} are. Join them with + or a comma: "
|
||||
f"{' + '.join(words)}")
|
||||
parts = pattern.split("-")
|
||||
if len(parts) > 1 and all(part and found(part) for part in parts):
|
||||
if len(parts) > 1 and all(part and carried(part) for part in parts):
|
||||
# "person-hair" is one label; with person and hair both found, a subtraction was meant
|
||||
raise ValueError(f"[NH-Nodes] Segs Compose: no instance is labelled '{pattern}', but "
|
||||
f"{' and '.join(repr(part) for part in parts)} are. To subtract, put spaces around "
|
||||
f"the minus: {' - '.join(parts)}")
|
||||
if unmatched:
|
||||
held = sorted({seg.label for frame in segs for seg in frame.segments})
|
||||
print(f"[NH-Nodes] Segs Compose: no instance is labelled {', '.join(repr(p) for p in unmatched)}; "
|
||||
"those parts are empty")
|
||||
f"those parts are empty. The segs hold: {', '.join(held) or 'nothing'}")
|
||||
return (expression_masks(tree, segs),)
|
||||
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ from ...core.parse_crops import (
|
||||
visible,
|
||||
)
|
||||
from ...core.segformer_backend import load_segformer, offload, predict_labels
|
||||
from ...core.segs import SEGS_TYPE, SegsFrame, Segment, add_aliases, stamped
|
||||
|
||||
try:
|
||||
from comfy.model_management import throw_exception_if_processing_interrupted
|
||||
@@ -49,7 +50,36 @@ GARMENT_GROUPS = {
|
||||
# selected group only when their connected component touches that group.
|
||||
GARMENT_ATTACHED_PARTS = {33, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46}
|
||||
# Deliberately unassigned: 14 glasses, 18 glove, 19 watch, 25 bag/wallet, 27 umbrella.
|
||||
GARMENT_PART_CHOICES = list(GARMENT_GROUPS.keys()) + ["all_garments"]
|
||||
# Parts that only some outfits have, chosen on their own.
|
||||
FASHIONPEDIA_EXTRA = {"dress": {11, 12}, "bag": {25}, "belt": {20, 35}, "scarf": {26}, "glasses": {14}, "jewelry": set()}
|
||||
GARMENT_PART_CHOICES = list(GARMENT_GROUPS.keys()) + ["all_garments", *FASHIONPEDIA_EXTRA]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FASHN Human Parser (18 classes) ids used by fashn-ai/fashn-human-parser
|
||||
# ---------------------------------------------------------------------------
|
||||
FASHN_PARTS = {
|
||||
"upper_garment": {3, 4, 10}, # top, dress, scarf (a dress is an upper garment, as with Fashionpedia)
|
||||
"lower_garment": {5, 6, 7}, # skirt, pants, belt
|
||||
"footwear": {15}, # feet: FASHN has no shoe class, its "feet" are the feet with whatever they wear
|
||||
"headwear": {9}, # hat
|
||||
"all_garments": {3, 4, 5, 6, 7, 9, 10, 15}, # the groups above, as with Fashionpedia (bare feet too)
|
||||
"dress": {4}, "bag": {8}, "belt": {7}, "scarf": {10}, "glasses": {11}, "jewelry": {17},
|
||||
}
|
||||
GARMENT_MODELS = {"fashn": "fashn_human_parser", "fashionpedia": "segformer_fashion"}
|
||||
|
||||
# One instance per part in the segs outputs, named as people say them.
|
||||
FASHN_SEGS = {"face": {1}, "hair": {2}, "top": {3}, "dress": {4}, "skirt": {5}, "pants": {6}, "belt": {7},
|
||||
"bag": {8}, "hat": {9}, "scarf": {10}, "glasses": {11}, "arms": {12}, "hands": {13}, "legs": {14},
|
||||
"feet": {15}, "torso": {16}, "jewelry": {17}}
|
||||
FASHIONPEDIA_SEGS = {
|
||||
"shirt": {1}, "top": {2}, "sweater": {3}, "cardigan": {4}, "jacket": {5}, "vest": {6}, "pants": {7},
|
||||
"shorts": {8}, "skirt": {9}, "coat": {10}, "dress": {11}, "jumpsuit": {12}, "cape": {13}, "glasses": {14},
|
||||
"hat": {15}, "headband": {16}, "tie": {17}, "glove": {18}, "watch": {19}, "belt": {20}, "leg warmer": {21},
|
||||
"tights": {22}, "sock": {23}, "shoe": {24}, "bag": {25}, "scarf": {26}, "umbrella": {27}, "hood": {28},
|
||||
"collar": {29}, "lapel": {30}, "epaulette": {31}, "sleeve": {32}, "pocket": {33}, "neckline": {34},
|
||||
"buckle": {35}, "zipper": {36}, "applique": {37}, "bead": {38}, "bow": {39}, "flower": {40}, "fringe": {41},
|
||||
"ribbon": {42}, "rivet": {43}, "ruffle": {44}, "sequin": {45}, "tassel": {46},
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CelebAMask-HQ (19 classes) ids used by jonathandinu/face-parsing
|
||||
@@ -67,7 +97,16 @@ PORTRAIT_REGIONS = {
|
||||
"mouth_interior": {10},
|
||||
}
|
||||
PORTRAIT_DEFAULT_ON = {"face", "neck", "hair", "eyes", "lips", "nose", "eyebrows", "ears"}
|
||||
PORTRAIT_SEGS = {"skin": {1}, "nose": {2}, "glasses": {3}, "eyes": {4, 5}, "eyebrows": {6, 7}, "ears": {8, 9},
|
||||
"mouth": {10}, "lips": {11, 12}, "hair": {13}, "hat": {14}, "earrings": {15}, "necklace": {16},
|
||||
"neck": {17}, "clothes": {18}}
|
||||
|
||||
# Segs Compose and Segs Select read each node's own words on its segs: "upper garment" is the top, dress and scarf of
|
||||
# FASHN's segs, "face" the skin, eyes, brows, nose and lips of Portrait Segment's (see segs.add_aliases).
|
||||
add_aliases("fashn_human_parser", FASHN_PARTS, FASHN_SEGS)
|
||||
add_aliases("segformer_fashion", {**GARMENT_GROUPS, "all_garments": set().union(*GARMENT_GROUPS.values()),
|
||||
**FASHIONPEDIA_EXTRA}, FASHIONPEDIA_SEGS)
|
||||
add_aliases("face_parsing", {**PORTRAIT_REGIONS, "face skin": {1}}, PORTRAIT_SEGS)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mask helpers (pure numpy/cv2, unit-testable without a model)
|
||||
@@ -128,7 +167,7 @@ def pack_outputs(image_rgb, mask):
|
||||
|
||||
def _image_batch_to_numpy(image):
|
||||
"""ComfyUI IMAGE (B,H,W,3|4) -> list of HxWx3 float32 arrays in [0,1]."""
|
||||
array = image.detach().cpu().numpy().astype(np.float32)
|
||||
array = image.detach().cpu().float().numpy() # float() first: numpy has no bfloat16
|
||||
if array.ndim == 3:
|
||||
array = array[None]
|
||||
channels = array.shape[-1]
|
||||
@@ -188,8 +227,8 @@ def find_faces(image, second_look=True):
|
||||
return found
|
||||
except Exception as exc: # noqa: BLE001 - any failure of the optional detector falls back to whole frames
|
||||
_detection_failed = True
|
||||
print(f"[NH-Nodes] Portrait Segment: the face detector is not available ({exc}); whole frames are parsed "
|
||||
"instead. Restart ComfyUI to try again.")
|
||||
print(f"[NH-Nodes] The face detector is not available ({exc}); Portrait, Garment and Body Segment parse "
|
||||
"whole frames instead. Restart ComfyUI to try again.")
|
||||
return []
|
||||
|
||||
|
||||
@@ -289,48 +328,95 @@ def garment_labels(bundle, image):
|
||||
WORK_SIDE = 4096 # label maps are worked out on a copy at most this long, then enlarged to the picture
|
||||
|
||||
|
||||
def labels_at_work_size(labeler, bundle, frame):
|
||||
"""Label map of the float HxWx3 ``frame`` at its size, worked out by ``labeler(bundle, uint8 RGB)`` on a copy at
|
||||
most WORK_SIDE pixels long. The parsers look at 512 to 1024 px at a time, so their labels hold no finer detail
|
||||
than that on an 8K photo, while merging crops at full size would take seconds."""
|
||||
def labels_at_work_size(labeler, bundle, frame, full=True):
|
||||
"""(label map of the float HxWx3 ``frame`` at its size, the map it was enlarged from), worked out by
|
||||
``labeler(bundle, uint8 RGB)`` on a copy at most WORK_SIDE pixels long. The parsers look at 512 to 1024 px at a
|
||||
time, so their labels hold no finer detail than that on an 8K photo, while merging crops at full size would take
|
||||
seconds. Both maps are the same when the frame is small enough; otherwise, with ``full`` off, the first one is
|
||||
None (a caller that builds everything with parts_frame does not need it)."""
|
||||
height, width = frame.shape[:2]
|
||||
pixels = cv2.convertScaleAbs(frame, alpha=255.0) # rounds and saturates in one pass
|
||||
scale = min(1.0, WORK_SIDE / max(height, width))
|
||||
if scale >= 1.0:
|
||||
return labeler(bundle, pixels)
|
||||
labels = labeler(bundle, pixels)
|
||||
return labels, labels
|
||||
small = cv2.resize(pixels, (max(1, round(width * scale)), max(1, round(height * scale))),
|
||||
interpolation=cv2.INTER_AREA)
|
||||
labels = labeler(bundle, small)
|
||||
return cv2.resize(labels.astype(np.int32), (width, height), interpolation=cv2.INTER_NEAREST).astype(np.int64)
|
||||
work = labeler(bundle, small)
|
||||
if not full:
|
||||
return None, work
|
||||
rows, cols = _nearest_index(height, work.shape[0]), _nearest_index(width, work.shape[1])
|
||||
return work[np.ix_(rows, cols)], work
|
||||
|
||||
|
||||
def _run_batch(image, model_name, device, build_mask, expand, blur, holes, labeler):
|
||||
"""Shared per-image loop: label map via ``labeler(bundle, uint8 RGB)``, mask via ``build_mask``, refine, pack."""
|
||||
def _nearest_index(size, work_size, start=0, stop=None):
|
||||
"""Index into a ``work_size`` map of each of the ``size`` pixels in [start, stop) it is enlarged to."""
|
||||
stop = size if stop is None else stop
|
||||
return np.minimum((np.arange(start, stop) * work_size) // size, work_size - 1)
|
||||
|
||||
|
||||
def parts_frame(work, parts, size, source="parser"):
|
||||
"""SegsFrame of a picture of ``size`` (height, width) with one instance per part of ``parts`` (name -> class
|
||||
ids) present in the label map ``work``, enlarged to the picture the same way as the mask: each instance is cut
|
||||
from the small map and only its own box is enlarged, so this costs little even on an 8K photo. ``source`` names
|
||||
the model, whose own words Segs Compose then understands (see segs.add_aliases)."""
|
||||
height, width = size
|
||||
small_h, small_w = work.shape
|
||||
# one table lookup per part instead of a comparison per class: the label maps have well under 256 classes
|
||||
codes = work.astype(np.uint8) if work.size and 0 <= work.min() and work.max() < 256 else None
|
||||
lookup = np.zeros(256, bool)
|
||||
segments = []
|
||||
for name, ids in parts.items():
|
||||
if not ids:
|
||||
continue
|
||||
if codes is None:
|
||||
found = np.isin(work, list(ids))
|
||||
else:
|
||||
lookup[:] = False
|
||||
lookup[[i for i in ids if 0 <= i < 256]] = True
|
||||
found = lookup[codes]
|
||||
rows, cols = np.nonzero(found.any(axis=1))[0], np.nonzero(found.any(axis=0))[0]
|
||||
if rows.size == 0:
|
||||
continue
|
||||
# the picture's pixels whose nearest map pixel lies in the part's box, and the map pixels they take
|
||||
y1, y2 = -(-int(rows[0]) * height // small_h), -(-(int(rows[-1]) + 1) * height // small_h)
|
||||
x1, x2 = -(-int(cols[0]) * width // small_w), -(-(int(cols[-1]) + 1) * width // small_w)
|
||||
crop = found[np.ix_(_nearest_index(height, small_h, y1, min(height, y2)),
|
||||
_nearest_index(width, small_w, x1, min(width, x2)))]
|
||||
if crop.any():
|
||||
segments.append(Segment(name, 1.0, (x1, y1, x1 + crop.shape[1], y1 + crop.shape[0]),
|
||||
torch.from_numpy(np.ascontiguousarray(crop)), source=source))
|
||||
return SegsFrame(height, width, tuple(segments))
|
||||
|
||||
|
||||
def _run_batch(image, model_name, device, build_mask, expand, blur, holes, labeler, parts):
|
||||
"""Shared per-image loop: label map via ``labeler(bundle, uint8 RGB)``, mask via ``build_mask``, refine, pack;
|
||||
plus NH_SEGS with one instance per part of ``parts`` found."""
|
||||
bundle = load_segformer(model_name, device)
|
||||
masks, cutouts, mask_images = [], [], []
|
||||
masks, cutouts, mask_images, frames_out = [], [], [], []
|
||||
frames = _image_batch_to_numpy(image)
|
||||
try:
|
||||
for frame in frames:
|
||||
throw_exception_if_processing_interrupted()
|
||||
labels = labels_at_work_size(labeler, bundle, frame)
|
||||
labels, work = labels_at_work_size(labeler, bundle, frame)
|
||||
mask = refine_mask(build_mask(labels), expand, blur, holes)
|
||||
mask_t, rgba_t, mask_img_t = pack_outputs(frame, mask)
|
||||
masks.append(mask_t)
|
||||
cutouts.append(rgba_t)
|
||||
mask_images.append(mask_img_t)
|
||||
frames_out.append(parts_frame(work, parts, labels.shape, model_name))
|
||||
finally:
|
||||
offload(bundle)
|
||||
return torch.cat(masks, 0), torch.cat(cutouts, 0), torch.cat(mask_images, 0)
|
||||
return torch.cat(masks, 0), torch.cat(cutouts, 0), torch.cat(mask_images, 0), stamped(frames_out, image)
|
||||
|
||||
|
||||
def _refine_inputs(fill_holes_default):
|
||||
def _refine_inputs(fill_holes_default, fill_holes_tip):
|
||||
return {
|
||||
"mask_expand": ("INT", {"default": 0, "min": -64, "max": 64, "step": 1,
|
||||
"tooltip": "Grow (+) or shrink (-) the mask by N pixels."}),
|
||||
"mask_blur": ("INT", {"default": 0, "min": 0, "max": 64, "step": 1,
|
||||
"tooltip": "Feather the mask edge by N pixels."}),
|
||||
"fill_holes": ("BOOLEAN", {"default": fill_holes_default,
|
||||
"tooltip": "Fill enclosed holes. Off for garments by default: an arm across the torso would be filled in."}),
|
||||
"fill_holes": ("BOOLEAN", {"default": fill_holes_default, "tooltip": fill_holes_tip}),
|
||||
"device": (DEVICES, {"default": "cuda"}),
|
||||
}
|
||||
|
||||
@@ -345,35 +431,70 @@ class NH_GarmentSegment:
|
||||
def INPUT_TYPES(cls):
|
||||
required = {
|
||||
"image": ("IMAGE",),
|
||||
"part": (GARMENT_PART_CHOICES, {"default": "upper_garment",
|
||||
"tooltip": "all_garments combines upper/lower garments, footwear and headwear."}),
|
||||
"attach_parts": ("BOOLEAN", {"default": True,
|
||||
"tooltip": "Merge pockets, zippers, bows, ruffles... that touch the selected garment."}),
|
||||
"part": (GARMENT_PART_CHOICES, {
|
||||
"default": "upper_garment",
|
||||
"tooltip": "all_garments combines upper/lower garments, footwear and headwear. With fashn, footwear "
|
||||
"is the feet with whatever they wear (bare feet too), and jewelry is found; fashionpedia "
|
||||
"has shoes and socks but no jewelry.",
|
||||
}),
|
||||
"attach_parts": ("BOOLEAN", {
|
||||
"default": True,
|
||||
"tooltip": "fashionpedia only: merge pockets, zippers, bows, ruffles... that touch the selected "
|
||||
"garment (fashn counts them as part of the garment).",
|
||||
}),
|
||||
}
|
||||
required.update(_refine_inputs(False, "Fill enclosed holes. Off by default: an arm across the torso would "
|
||||
"be filled in."))
|
||||
return {
|
||||
"required": required,
|
||||
# optional, so prompts saved before it existed still validate (and get the better model)
|
||||
"optional": {
|
||||
"model": (list(GARMENT_MODELS), {
|
||||
"default": "fashn",
|
||||
"tooltip": "fashn: FASHN Human Parser, clean tops, dresses, skirts, pants, bags, hats, "
|
||||
"scarves, glasses and jewelry, 256 MB. fashionpedia: the model of 1.8.0 and before, "
|
||||
"with shoes, socks, headbands and small parts (sleeves, collars, pockets) in segs. Both "
|
||||
"are built on NVIDIA's SegFormer, whose licence allows non-commercial use only "
|
||||
"(research or evaluation).",
|
||||
}),
|
||||
},
|
||||
}
|
||||
required.update(_refine_inputs(fill_holes_default=False))
|
||||
return {"required": required}
|
||||
|
||||
RETURN_TYPES = ("MASK", "IMAGE", "IMAGE")
|
||||
RETURN_NAMES = ("mask", "cutout_rgba", "mask_image")
|
||||
RETURN_TYPES = ("MASK", "IMAGE", "IMAGE", SEGS_TYPE)
|
||||
RETURN_NAMES = ("mask", "cutout_rgba", "mask_image", "segs")
|
||||
FUNCTION = "segment"
|
||||
CATEGORY = "NH-Nodes/Vision"
|
||||
SEARCH_ALIASES = ["garment segment", "clothes mask", "upper garment", "lower garment", "footwear", "headwear", "all garments"]
|
||||
DESCRIPTION = (
|
||||
"Mask a garment group (upper, lower, footwear, headwear, all) or a single part (dress, bag, belt, scarf, "
|
||||
"glasses, jewelry) of the people in a photo. Each person is parsed close up at the model's full resolution. "
|
||||
"segs holds every part found, for Segs Compose."
|
||||
)
|
||||
SEARCH_ALIASES = ["garment segment", "clothes mask", "upper garment", "lower garment", "footwear", "headwear",
|
||||
"all garments", "dress mask", "bag mask", "fashn"]
|
||||
|
||||
@staticmethod
|
||||
def build_mask(labels, part, attach_parts=True):
|
||||
def build_mask(labels, part, attach_parts=True, model="fashn"):
|
||||
if model == "fashn":
|
||||
return labels_to_mask(labels, FASHN_PARTS.get(part, set()))
|
||||
if part in FASHIONPEDIA_EXTRA:
|
||||
return labels_to_mask(labels, FASHIONPEDIA_EXTRA[part])
|
||||
class_ids = set().union(*GARMENT_GROUPS.values()) if part == "all_garments" else GARMENT_GROUPS[part]
|
||||
mask = labels_to_mask(labels, class_ids)
|
||||
if attach_parts:
|
||||
mask = absorb_touching_parts(mask, labels_to_mask(labels, GARMENT_ATTACHED_PARTS))
|
||||
return mask
|
||||
|
||||
def segment(self, image, part, attach_parts, mask_expand, mask_blur, fill_holes, device):
|
||||
def segment(self, image, part, attach_parts, mask_expand, mask_blur, fill_holes, device, model="fashn"):
|
||||
if part not in GARMENT_PART_CHOICES:
|
||||
part = GARMENT_PART_CHOICES[0]
|
||||
model = model if model in GARMENT_MODELS else "fashn"
|
||||
if model == "fashionpedia" and part == "jewelry":
|
||||
print("[NH-Nodes] Garment Segment: fashionpedia has no jewelry class; choose model fashn for jewelry")
|
||||
return _run_batch(
|
||||
image, "segformer_fashion", device,
|
||||
lambda labels: self.build_mask(labels, part, attach_parts),
|
||||
image, GARMENT_MODELS[model], device,
|
||||
lambda labels: self.build_mask(labels, part, attach_parts, model),
|
||||
mask_expand, mask_blur, fill_holes, garment_labels,
|
||||
FASHN_SEGS if model == "fashn" else FASHIONPEDIA_SEGS,
|
||||
)
|
||||
|
||||
|
||||
@@ -385,13 +506,19 @@ class NH_PortraitSegment:
|
||||
required = {"image": ("IMAGE",)}
|
||||
for region in PORTRAIT_REGIONS:
|
||||
required[region] = ("BOOLEAN", {"default": region in PORTRAIT_DEFAULT_ON})
|
||||
required.update(_refine_inputs(fill_holes_default=True))
|
||||
required.update(_refine_inputs(True, "Fill enclosed holes. On by default, so a face stays solid where "
|
||||
"glasses, earrings or a strand of hair cut into it; off keeps only the "
|
||||
"ticked parts (lips without the open mouth)."))
|
||||
return {"required": required}
|
||||
|
||||
RETURN_TYPES = ("MASK", "IMAGE", "IMAGE")
|
||||
RETURN_NAMES = ("mask", "cutout_rgba", "mask_image")
|
||||
RETURN_TYPES = ("MASK", "IMAGE", "IMAGE", SEGS_TYPE)
|
||||
RETURN_NAMES = ("mask", "cutout_rgba", "mask_image", "segs")
|
||||
FUNCTION = "segment"
|
||||
CATEGORY = "NH-Nodes/Vision"
|
||||
DESCRIPTION = (
|
||||
"Mask face, hair, neck and facial features (eyes, eyebrows, nose, lips, ears) of every face in a photo, each "
|
||||
"face parsed close up. segs holds every part found (skin, eyes, lips, hair, earrings...), for Segs Compose."
|
||||
)
|
||||
SEARCH_ALIASES = ["portrait segment", "face parsing", "face mask", "hair mask", "skin mask"]
|
||||
|
||||
@staticmethod
|
||||
@@ -406,7 +533,7 @@ class NH_PortraitSegment:
|
||||
return _run_batch(
|
||||
image, "face_parsing", device,
|
||||
lambda labels: self.build_mask(labels, selected),
|
||||
mask_expand, mask_blur, fill_holes, portrait_labels,
|
||||
mask_expand, mask_blur, fill_holes, portrait_labels, PORTRAIT_SEGS,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,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, and VTON preprocessing."
|
||||
version = "1.8.0"
|
||||
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.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"]
|
||||
@@ -0,0 +1,430 @@
|
||||
"""Body Segment (Sapiens2): region words, the formula, the node with a stub parser, and the backend without weights.
|
||||
|
||||
The last test runs the real Sapiens2 0.4B and is skipped when its weights are not on disk.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
bs = importlib.import_module("NH-Nodes.nodes.vision.body_segment_nodes")
|
||||
s2 = importlib.import_module("NH-Nodes.core.sapiens2_backend")
|
||||
C = {name: index for index, name in enumerate(s2.CLASSES)}
|
||||
|
||||
|
||||
# --- region words --------------------------------------------------------------
|
||||
def test_region_words_cover_the_right_classes():
|
||||
assert all(0 < i < len(s2.CLASSES) for ids in bs.REGIONS.values() for i in ids)
|
||||
skin = bs.REGIONS["skin"]
|
||||
assert C["face_neck"] in skin and C["left_hand"] in skin and C["torso"] in skin
|
||||
assert not skin & (bs.LIPS | bs.TEETH | bs.CLOTHES | {C["hair"], C["eyeglass"]})
|
||||
assert bs.REGIONS["person"] == frozenset(range(1, len(s2.CLASSES)))
|
||||
assert bs.REGIONS["mouth"] == bs.LIPS | bs.TEETH | {C["tongue"]}
|
||||
assert all(word == bs.normalize_label(word) for word in bs.REGIONS) # looked up after normalising
|
||||
assert set().union(*bs.PARTS.values()) == set(range(1, len(s2.CLASSES))) # every class has a segs part
|
||||
assert bs.region_ids("Lips", "Lips", "english") == bs.LIPS and bs.region_ids("wings", "wings", "english") is None
|
||||
assert bs.region_ids("tay", "arm", "translated") == bs.ARMS | bs.HANDS # the whole limb in Vietnamese
|
||||
assert bs.region_ids("cánh tay", "arm", "translated") == bs.ARMS
|
||||
|
||||
|
||||
# --- the node, with a stub parser -------------------------------------------------
|
||||
H, W = 60, 40
|
||||
|
||||
|
||||
def _picture():
|
||||
"""A person-shaped label map (the class id is written into the red channel, so any crop parses the same)."""
|
||||
labels = np.zeros((H, W), np.int64)
|
||||
labels[5:15, 15:25] = C["hair"]
|
||||
labels[15:25, 15:25] = C["face_neck"]
|
||||
labels[22:24, 18:22] = C["upper_lip"]
|
||||
labels[25:45, 10:30] = C["upper_clothing"]
|
||||
labels[25:45, 5:10] = C["left_upper_arm"]
|
||||
labels[45:58, 12:28] = C["lower_clothing"]
|
||||
image = np.zeros((H, W, 3), np.float32)
|
||||
image[..., 0] = labels * 8 / 255.0
|
||||
return labels, torch.from_numpy(image)[None]
|
||||
|
||||
|
||||
def _run(monkeypatch, regions, show_preview=False):
|
||||
saved, shown = [], []
|
||||
|
||||
def stub_predict(bundle, picture, confidence=False):
|
||||
shown.append(picture.shape[:2])
|
||||
labels = np.rint(picture[..., 0] / 8.0).astype(np.int64)
|
||||
return (labels, np.full(labels.shape, 0.9, np.float16)) if confidence else labels
|
||||
|
||||
monkeypatch.setattr(bs, "find_faces", lambda picture, second_look=True: [])
|
||||
monkeypatch.setattr(bs.sapiens2, "load", lambda size, device: {"size": size})
|
||||
monkeypatch.setattr(bs.sapiens2, "predict_labels", stub_predict)
|
||||
monkeypatch.setattr(bs.sapiens2, "offload", lambda bundle: None)
|
||||
monkeypatch.setattr(bs, "save_temp_previews", lambda pictures, prefix: saved.extend(pictures) or [{"f": 1}])
|
||||
labels, image = _picture()
|
||||
out = bs.NH_BodySegment().segment(image, regions, "0.4b", 0, 0, False, "cpu", show_preview)
|
||||
return out, labels, saved, shown
|
||||
|
||||
|
||||
def test_formula_over_body_regions(monkeypatch):
|
||||
(mask, rgba, mask_image, segs, report), labels, _, shown = _run(monkeypatch, "skin - lips")
|
||||
skin = np.isin(labels, list(bs.REGIONS["skin"])) & ~np.isin(labels, list(bs.LIPS))
|
||||
assert mask.shape == (1, H, W) and (mask[0].numpy() > 0.5).tolist() == skin.tolist()
|
||||
assert rgba.shape == (1, H, W, 4) and mask_image.shape == (1, H, W, 3)
|
||||
assert shown[0] == (H, W) and len(shown) >= 1 # a first look at the whole picture
|
||||
assert {seg.label for seg in segs[0].segments} == {"hair", "face", "lips", "arms", "top", "bottom"}
|
||||
assert "skin: " in report and "% of the picture" in report and "lips: " in report
|
||||
|
||||
|
||||
def test_background_vietnamese_and_unknown_words(monkeypatch):
|
||||
(mask, _, _, _, report), labels, _, _ = _run(monkeypatch, "background")
|
||||
assert (mask[0].numpy() > 0.5).tolist() == (labels == 0).tolist()
|
||||
assert "background = everything outside the people" in report
|
||||
(mask, _, _, _, report), labels, _, _ = _run(monkeypatch, "da, tóc, răng")
|
||||
wanted = np.isin(labels, list(bs.REGIONS["skin"] | bs.REGIONS["hair"]))
|
||||
assert (mask[0].numpy() > 0.5).tolist() == wanted.tolist()
|
||||
assert "da -> skin:" in report and "răng -> teeth: not in the picture" in report
|
||||
# "tay" and "chân" are the whole limb in Vietnamese, hand and foot included
|
||||
(mask, _, _, _, report), labels, _, _ = _run(monkeypatch, "tay")
|
||||
assert (mask[0].numpy() > 0.5).tolist() == np.isin(labels, list(bs.ARMS | bs.HANDS)).tolist()
|
||||
assert "tay: the whole limb, hands included" in report
|
||||
|
||||
|
||||
def test_errors_and_preview(monkeypatch):
|
||||
with pytest.raises(ValueError, match="at least one region"):
|
||||
_run(monkeypatch, " , # nothing")
|
||||
with pytest.raises(ValueError, match="not closed"):
|
||||
_run(monkeypatch, "skin - (hair")
|
||||
out, _, saved, _ = _run(monkeypatch, "hair", show_preview=True)
|
||||
assert set(out) == {"ui", "result"} and len(out["result"]) == 5 and len(saved) == 1
|
||||
assert saved[0].shape[1] == W and saved[0].shape[0] > H # the picture plus the report below it
|
||||
spec = bs.NH_BodySegment.INPUT_TYPES()
|
||||
assert list(spec["optional"]) == ["show_preview"] and "show_preview" not in spec["required"]
|
||||
assert spec["required"]["model_size"][0] == ["0.4b", "0.8b", "1b"]
|
||||
|
||||
|
||||
# --- backend without weights ---------------------------------------------------------
|
||||
def test_seg_head_turns_patches_into_full_resolution_scores():
|
||||
head = s2.SegHead(32, deconv_channels=(16, 8, 8, 8), conv_channels=(8, 8), classes=len(s2.CLASSES))
|
||||
scores = head(torch.zeros(1, 32, 4, 3))
|
||||
assert scores.shape == (1, 29, 64, 48) # 16 x the patch grid, like 64 x 48 patches -> 1024 x 768
|
||||
|
||||
|
||||
def test_a_wrong_checkpoint_is_refused_with_advice(monkeypatch, tmp_path):
|
||||
from safetensors.torch import save_file
|
||||
|
||||
path = tmp_path / "sapiens2_0.4b_seg.safetensors"
|
||||
save_file({"backbone.cls_token": torch.zeros(1, 1, 1024)}, str(path))
|
||||
monkeypatch.setattr(s2, "model_path", lambda size, download=True: str(path))
|
||||
monkeypatch.setattr(s2, "_LOADED", {})
|
||||
with pytest.raises(RuntimeError, match="is not the Sapiens2 0.4b segmentation checkpoint"):
|
||||
s2.load("0.4b", torch.device("cpu"))
|
||||
|
||||
|
||||
def test_model_path_downloads_the_pinned_file_once(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(s2, "register_model_folder", lambda *a: [str(tmp_path)])
|
||||
monkeypatch.setattr(s2, "default_folder", lambda: str(tmp_path))
|
||||
calls = []
|
||||
|
||||
def fake_download(repo, revision, target, patterns):
|
||||
calls.append((repo, revision, tuple(patterns)))
|
||||
(tmp_path / "sapiens2-seg-1b").mkdir()
|
||||
(tmp_path / "sapiens2-seg-1b" / s2.MODELS["1b"]["file"]).write_bytes(b"x")
|
||||
|
||||
monkeypatch.setattr(s2, "download_pinned", fake_download)
|
||||
assert s2.model_path("1b", download=False) is None
|
||||
assert s2.model_path("1b").endswith(s2.MODELS["1b"]["file"]) and s2.model_path("1b")
|
||||
assert calls == [("facebook/sapiens2-seg-1b", s2.MODELS["1b"]["revision"], (s2.MODELS["1b"]["file"],))]
|
||||
|
||||
|
||||
def test_predict_labels_shrinks_to_the_model_size_and_restores_the_picture_size(monkeypatch):
|
||||
seen = []
|
||||
|
||||
class Fake(torch.nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.weight = torch.nn.Parameter(torch.zeros(1))
|
||||
|
||||
def forward(self, pixels):
|
||||
seen.append(tuple(pixels.shape))
|
||||
scores = torch.zeros(1, len(s2.CLASSES), 64, 48)
|
||||
scores[:, C["hair"], :32] = 1.0
|
||||
return scores
|
||||
|
||||
bundle = {"model": Fake(), "device": torch.device("cpu")}
|
||||
labels = s2.predict_labels(bundle, np.zeros((3000, 2000, 3), np.uint8))
|
||||
assert seen == [(1, 3, 1024, 768)] and labels.shape == (3000, 2000) and labels.dtype == np.int64
|
||||
assert (labels[:1400] == C["hair"]).all() and (labels[1600:] == 0).all()
|
||||
|
||||
|
||||
# --- real model (skipped without weights) ---------------------------------------------
|
||||
def test_real_sapiens2_smoke():
|
||||
if s2.model_path("0.4b", download=False) is None:
|
||||
pytest.skip("Sapiens2 0.4b weights not on disk")
|
||||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
bundle = s2.load("0.4b", device)
|
||||
labels = s2.predict_labels(bundle, np.full((200, 150, 3), 128, np.uint8))
|
||||
s2.offload(bundle)
|
||||
assert labels.shape == (200, 150) and labels.min() >= 0 and labels.max() < len(s2.CLASSES)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("regions, message", [
|
||||
("cánh", "'cánh' is not in the Vietnamese word list"),
|
||||
("wings", "'wings' is not a body region; use skin, hair"),
|
||||
("skin, mắt", r"'mắt' \(eye\) is not a body region: eyes, eyebrows, nose and ears are part of 'face'"),
|
||||
("hat", "one class, 'accessories'"),
|
||||
("skin and hair", r"combine regions with \+ or a comma"),
|
||||
("hair*", "has a wildcard"),
|
||||
])
|
||||
def test_unknown_words_are_refused_before_the_model_loads(monkeypatch, regions, message):
|
||||
monkeypatch.setattr(bs.sapiens2, "load", lambda size, device: pytest.fail("the model must not load"))
|
||||
with pytest.raises(ValueError, match=message):
|
||||
bs.NH_BodySegment().segment(torch.zeros(1, 8, 8, 3), regions, "0.4b", 0, 0, False, "cpu", False)
|
||||
|
||||
|
||||
def test_new_region_words():
|
||||
for word, region in (("t-shirt", "top"), ("jacket", "top"), ("sunglasses", "glasses"), ("face skin", "face"),
|
||||
("sneakers", "shoes"), ("ao dai", "dress")):
|
||||
assert bs.REGIONS[word] == bs.REGIONS[region]
|
||||
assert bs.REGIONS["head"] == bs.FACE | bs.HAIR
|
||||
assert not set(bs.REGIONS) & set(bs.NOT_REGIONS)
|
||||
|
||||
|
||||
def test_report_explains_sides_shared_classes_and_tiny_shares():
|
||||
counts = np.zeros(len(s2.CLASSES), np.int64)
|
||||
counts[C["left_hand"]], counts[C["face_neck"]], counts[C["upper_clothing"]] = 5, 3000, 40000
|
||||
lines = bs.describe(bs.parse_concepts("left hand, neck, dress"), counts, 100000)
|
||||
assert "left hand: under 0.1% of the picture" in lines and "neck: 3.0% of the picture" in lines
|
||||
assert "dress: 40.0% of the picture" in lines
|
||||
assert bs._SIDES in lines and bs._FACE_CLASS in lines
|
||||
assert "dress: Sapiens2 labels a dress as a top (upper clothes)" in lines
|
||||
batch = bs.describe(bs.parse_concepts("hair"), counts, 100000, "pictures")
|
||||
assert batch == ["hair: not in the pictures"]
|
||||
|
||||
|
||||
def test_preview_shows_only_the_parts_the_mask_keeps(monkeypatch):
|
||||
drawn = []
|
||||
monkeypatch.setattr(bs, "preview_picture",
|
||||
lambda picture, frame, notes, mask=None: drawn.append(frame) or np.zeros((4, 4, 3), np.uint8))
|
||||
out, _, saved, _ = _run(monkeypatch, "skin - lips", show_preview=True)
|
||||
assert {seg.label for seg in drawn[0].segments} == {"face", "arms"} and len(saved) == 1
|
||||
drawn.clear()
|
||||
_run(monkeypatch, "background", show_preview=True)
|
||||
assert drawn[0].segments == () # nothing named: only the lit background
|
||||
|
||||
|
||||
def test_a_failed_preview_keeps_the_outputs(monkeypatch, capsys):
|
||||
def broken(*args, **kwargs):
|
||||
raise OSError("disk full")
|
||||
|
||||
monkeypatch.setattr(bs, "preview_picture", broken)
|
||||
out, _, _, _ = _run(monkeypatch, "hair", show_preview=True)
|
||||
assert len(out) == 5 and "could not show the preview" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_a_batch_gets_one_mask_and_frame_per_picture(monkeypatch):
|
||||
monkeypatch.setattr(bs, "find_faces", lambda picture, second_look=True: [])
|
||||
monkeypatch.setattr(bs.sapiens2, "load", lambda size, device: {"size": size})
|
||||
monkeypatch.setattr(bs.sapiens2, "offload", lambda bundle: None)
|
||||
monkeypatch.setattr(bs.sapiens2, "predict_labels", lambda bundle, picture, confidence=False: (
|
||||
np.rint(picture[..., 0] / 8.0).astype(np.int64), np.full(picture.shape[:2], 0.9, np.float16)))
|
||||
labels, image = _picture()
|
||||
batch = torch.cat([image, torch.flip(image, dims=[2])])
|
||||
mask, _, _, segs, report = bs.NH_BodySegment().segment(batch, "hair", "0.4b", 0, 0, False, "cpu", False)
|
||||
assert mask.shape == (2, H, W) and len(segs) == 2 and "% of the pictures" in report
|
||||
assert (mask[1].numpy() > 0.5).tolist() == np.fliplr(labels == C["hair"]).tolist()
|
||||
assert all(frame.picture is not None for frame in segs)
|
||||
|
||||
|
||||
def test_a_damaged_checkpoint_is_refused_with_advice(monkeypatch, tmp_path):
|
||||
path = tmp_path / "sapiens2_0.4b_seg.safetensors"
|
||||
path.write_bytes(b"\x10\x00\x00\x00\x00\x00\x00\x00{\"a\":") # cut short
|
||||
monkeypatch.setattr(s2, "model_path", lambda size, download=True: str(path))
|
||||
monkeypatch.setattr(s2, "_LOADED", {"0.8b": {"model": None}})
|
||||
with pytest.raises(RuntimeError, match="cannot be read .* delete it so it is downloaded again"):
|
||||
s2.load("0.4b", torch.device("cpu"))
|
||||
assert "0.8b" not in s2._LOADED # one size at a time
|
||||
|
||||
|
||||
def test_cpu_fallback_is_said_once_and_cpu_runs_in_float32(monkeypatch, capsys):
|
||||
monkeypatch.setattr(s2.torch.cuda, "is_available", lambda: False)
|
||||
monkeypatch.setattr(s2, "_said_cpu", False)
|
||||
assert s2.torch_device("cuda").type == "cpu" and s2.torch_device("cuda").type == "cpu"
|
||||
assert capsys.readouterr().out.count("CUDA not available") == 1
|
||||
bundle = {"model": torch.nn.Linear(2, 2).to(torch.bfloat16), "device": torch.device("cpu")}
|
||||
s2._ensure_on_device(bundle)
|
||||
assert next(bundle["model"].parameters()).dtype == torch.float32
|
||||
|
||||
|
||||
def test_real_sapiens2_on_a_photo():
|
||||
if s2.model_path("0.4b", download=False) is None:
|
||||
pytest.skip("Sapiens2 0.4b weights not on disk")
|
||||
data = pytest.importorskip("skimage.data")
|
||||
picture = data.astronaut() # a woman facing the camera, head and shoulders
|
||||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
bundle = s2.load("0.4b", device)
|
||||
labels = bs.body_labels(bundle, picture)
|
||||
s2.offload(bundle)
|
||||
face, hair = labels == C["face_neck"], labels == C["hair"]
|
||||
assert face.mean() > 0.03 and hair.mean() > 0.03
|
||||
assert np.nonzero(face)[0].mean() < picture.shape[0] * 0.6 # the face is in the upper part
|
||||
|
||||
|
||||
def test_vietnamese_limbs_are_whole_and_kept_apart_from_the_english_word(monkeypatch):
|
||||
(mask, _, _, _, report), labels, _, _ = _run(monkeypatch, "tay - cánh tay")
|
||||
assert (mask[0].numpy() > 0.5).tolist() == np.isin(labels, list(bs.HANDS)).tolist() # the hands: none here
|
||||
(mask, _, _, _, report), labels, _, _ = _run(monkeypatch, "chân")
|
||||
assert (mask[0].numpy() > 0.5).tolist() == np.isin(labels, list(bs.LEGS | bs.FEET)).tolist()
|
||||
assert "chân: the whole limb, feet included" in report and "chân ->" not in report
|
||||
assert bs.WHOLE_LIMBS["tay trái"] == {C["left_lower_arm"], C["left_upper_arm"], C["left_hand"]}
|
||||
(mask, _, _, _, _), labels, _, _ = _run(monkeypatch, "tay trái")
|
||||
assert (mask[0].numpy() > 0.5).tolist() == np.isin(labels, list(bs.WHOLE_LIMBS["tay trái"])).tolist()
|
||||
|
||||
|
||||
def test_nobody_found_is_said(monkeypatch):
|
||||
monkeypatch.setattr(bs, "find_faces", lambda picture, second_look=True: [])
|
||||
monkeypatch.setattr(bs.sapiens2, "load", lambda size, device: {"size": size})
|
||||
monkeypatch.setattr(bs.sapiens2, "offload", lambda bundle: None)
|
||||
monkeypatch.setattr(bs.sapiens2, "predict_labels", lambda bundle, picture, confidence=False: (
|
||||
np.zeros(picture.shape[:2], np.int64), np.full(picture.shape[:2], 0.9, np.float16)))
|
||||
drawn = []
|
||||
monkeypatch.setattr(bs, "preview_picture", lambda picture, frame, notes, mask=None: drawn.append(notes) or picture)
|
||||
monkeypatch.setattr(bs, "save_temp_previews", lambda pictures, prefix: [])
|
||||
labels, image = _picture()
|
||||
batch = torch.cat([image, torch.zeros_like(image)])
|
||||
out = bs.NH_BodySegment().segment(batch, "skin", "0.4b", 0, 0, False, "cpu", True)
|
||||
report = out["result"][4]
|
||||
assert f"{bs._NOBODY} in image(s) 1, 2" in report
|
||||
assert drawn[0][-1] == bs._NOBODY and drawn[1][-1] == bs._NOBODY # each preview has its own notes
|
||||
|
||||
|
||||
def test_batch_previews_get_their_own_picture_s_numbers(monkeypatch):
|
||||
drawn = []
|
||||
monkeypatch.setattr(bs, "preview_picture", lambda picture, frame, notes, mask=None: drawn.append(notes) or picture)
|
||||
monkeypatch.setattr(bs, "find_faces", lambda picture, second_look=True: [])
|
||||
monkeypatch.setattr(bs.sapiens2, "load", lambda size, device: {"size": size})
|
||||
monkeypatch.setattr(bs.sapiens2, "offload", lambda bundle: None)
|
||||
monkeypatch.setattr(bs.sapiens2, "predict_labels", lambda bundle, picture, confidence=False: (
|
||||
np.rint(picture[..., 0] / 8.0).astype(np.int64), np.full(picture.shape[:2], 0.9, np.float16)))
|
||||
monkeypatch.setattr(bs, "save_temp_previews", lambda pictures, prefix: [])
|
||||
labels, image = _picture()
|
||||
half = image.clone()
|
||||
half[:, :, : W // 2] = 0 # the left half of the picture is empty
|
||||
bs.NH_BodySegment().segment(torch.cat([image, half]), "hair", "0.4b", 0, 0, False, "cpu", True)
|
||||
whole = 100.0 * (labels == C["hair"]).sum() / labels.size
|
||||
cut = 100.0 * (labels[:, W // 2:] == C["hair"]).sum() / labels.size
|
||||
assert drawn[0][0] == f"hair: {whole:.1f}% of the picture" and drawn[1][0] == f"hair: {cut:.1f}% of the picture"
|
||||
|
||||
|
||||
def _limb_labels():
|
||||
"""A person with both arms, hands, legs and feet, as a Sapiens2 label map."""
|
||||
labels = np.zeros((H, W), np.int64)
|
||||
labels[5:15, 15:25] = C["face_neck"]
|
||||
labels[3:5, 15:25] = C["hair"]
|
||||
labels[15:30, 12:28] = C["torso"]
|
||||
for side, x in (("right", 4), ("left", 30)):
|
||||
labels[15:28, x:x + 6] = C[f"{side}_upper_arm"]
|
||||
labels[28:38, x:x + 6] = C[f"{side}_lower_arm"]
|
||||
labels[38:42, x:x + 6] = C[f"{side}_hand"]
|
||||
for side, x in (("left", 13), ("right", 21)):
|
||||
labels[30:42, x:x + 6] = C[f"{side}_upper_leg"]
|
||||
labels[42:52, x:x + 6] = C[f"{side}_lower_leg"]
|
||||
labels[52:56, x:x + 6] = C[f"{side}_foot"]
|
||||
return labels
|
||||
|
||||
|
||||
def _run_on(monkeypatch, labels, regions):
|
||||
"""Body Segment on a picture that parses to ``labels`` (the class id is written into the red channel)."""
|
||||
image = np.zeros((*labels.shape, 3), np.float32)
|
||||
image[..., 0] = labels * 8 / 255.0
|
||||
monkeypatch.setattr(bs, "find_faces", lambda picture, second_look=True: [])
|
||||
monkeypatch.setattr(bs.sapiens2, "load", lambda size, device: {"size": size})
|
||||
monkeypatch.setattr(bs.sapiens2, "offload", lambda bundle: None)
|
||||
monkeypatch.setattr(bs.sapiens2, "predict_labels", lambda bundle, picture, confidence=False: (
|
||||
np.rint(picture[..., 0] / 8.0).astype(np.int64), np.full(picture.shape[:2], 0.95, np.float16)))
|
||||
return bs.NH_BodySegment().segment(torch.from_numpy(image)[None], regions, "0.4b", 0, 0, False, "cpu", False)
|
||||
|
||||
|
||||
def test_whole_limbs_on_a_picture_with_hands_and_feet(monkeypatch):
|
||||
labels = _limb_labels()
|
||||
for regions, ids in (("tay - cánh tay", bs.HANDS), ("chân", bs.LEGS | bs.FEET), ("tay", bs.ARMS | bs.HANDS),
|
||||
("tay trái", bs.WHOLE_LIMBS["tay trái"]), ("cánh tay", bs.ARMS), ("bàn chân", bs.FEET),
|
||||
("chân phải - bàn chân", bs.WHOLE_LIMBS["chân phải"] - bs.FEET)):
|
||||
wanted = np.isin(labels, list(ids))
|
||||
mask = _run_on(monkeypatch, labels, regions)[0]
|
||||
assert wanted.any() and (mask[0].numpy() > 0.5).tolist() == wanted.tolist(), regions
|
||||
|
||||
|
||||
def test_compose_reads_body_words_on_body_segs(monkeypatch):
|
||||
segs_nodes = importlib.import_module("NH-Nodes.nodes.vision.segs_nodes")
|
||||
labels = _limb_labels()
|
||||
segs = _run_on(monkeypatch, labels, "skin")[3]
|
||||
compose = segs_nodes.NH_SegsCompose().compose
|
||||
for formula, ids in (("skin", bs.SKIN), ("da", bs.SKIN), ("arm", bs.ARMS), ("tay", bs.ARMS | bs.HANDS),
|
||||
("chân", bs.LEGS | bs.FEET), ("skin - face", bs.SKIN - bs.FACE), ("head", bs.FACE | bs.HAIR),
|
||||
("person", bs.PERSON), ("quần", frozenset()), ("váy", frozenset())):
|
||||
(mask,) = compose(segs, formula)
|
||||
assert (mask[0].numpy() > 0.5).tolist() == np.isin(labels, list(ids)).tolist(), formula
|
||||
|
||||
|
||||
def test_compose_does_not_stand_body_parts_in_for_another_node_s_garments(monkeypatch):
|
||||
segs_nodes = importlib.import_module("NH-Nodes.nodes.vision.segs_nodes")
|
||||
vs = importlib.import_module("NH-Nodes.nodes.vision.vision_segment_nodes")
|
||||
(_, _, _, body, _), labels, _, _ = _run(monkeypatch, "skin")
|
||||
fashn = np.zeros((H, W), np.int64)
|
||||
fashn[25:45, 10:30], fashn[45:58, 12:28] = 3, 6 # FASHN: a top and pants, no dress or skirt
|
||||
garment = (vs.parts_frame(fashn, vs.FASHN_SEGS, (H, W), "fashn_human_parser"),)
|
||||
for formula in ("váy", "dress", "chân váy", "skirt"):
|
||||
(mask,) = segs_nodes.NH_SegsCompose().compose(body, formula, segs_2=garment)
|
||||
assert not mask.any(), formula
|
||||
(mask,) = segs_nodes.NH_SegsCompose().compose(body, "pants", segs_2=garment)
|
||||
assert (mask[0].numpy() > 0.5).tolist() == (fashn == 6).tolist()
|
||||
|
||||
|
||||
def test_region_words_are_read_in_singular_or_plural():
|
||||
for word, region in (("lower leg", "lower legs"), ("upper leg", "upper legs"), ("accessory", "accessories"),
|
||||
("dresses", "dress"), ("Sunglass", "sunglasses"), ("calf", "calves")):
|
||||
assert bs.region_ids(word, word, "english") == bs.REGIONS[region], word
|
||||
assert bs.region_ids("cẳng chân", "lower leg", "translated") == bs.REGIONS["lower legs"]
|
||||
tree = bs.parse_concepts("cẳng chân, bắp tay, đùi")
|
||||
assert set(bs.check_regions(tree)) == {"lower leg", "upper arm", "thigh"}
|
||||
with pytest.raises(ValueError, match="part of 'face'"):
|
||||
bs.check_regions(bs.parse_concepts("eyebrow"))
|
||||
|
||||
|
||||
def test_class_notes_only_for_words_the_picture_has_and_the_beard_advice():
|
||||
counts = np.zeros(len(s2.CLASSES), np.int64)
|
||||
assert bs.describe(bs.parse_concepts("skin, dress"), counts, 1000) == [
|
||||
"skin: not in the picture", "dress: not in the picture"]
|
||||
counts[C["face_neck"]] = 10
|
||||
assert bs._FACE_CLASS in bs.describe(bs.parse_concepts("skin"), counts, 1000)
|
||||
with pytest.raises(ValueError, match="no NH-Nodes parser has a beard class"):
|
||||
bs.check_regions(bs.parse_concepts("râu"))
|
||||
|
||||
|
||||
def test_compose_on_body_segs_gives_no_wrong_advice_and_reads_any_accent_form(monkeypatch, capsys):
|
||||
import unicodedata
|
||||
|
||||
segs_nodes = importlib.import_module("NH-Nodes.nodes.vision.segs_nodes")
|
||||
labels = _limb_labels()
|
||||
segs = _run_on(monkeypatch, labels, "skin")[3]
|
||||
compose = segs_nodes.NH_SegsCompose().compose
|
||||
for formula in ("da mặt", "face skin"): # not a word of Body's segs: empty, with the labels it holds
|
||||
(mask,) = compose(segs, formula)
|
||||
assert not mask.any() and "The segs hold: arms, face, feet, hair, hands, legs, torso" in capsys.readouterr().out
|
||||
(mask,) = compose(segs, unicodedata.normalize("NFD", "chân")) # some keyboards send the accents apart
|
||||
assert (mask[0].numpy() > 0.5).tolist() == np.isin(labels, list(bs.LEGS | bs.FEET)).tolist()
|
||||
(mask,) = compose(segs, "upper clothes, apparel")
|
||||
assert not mask.any() # Body's exact words, found nowhere in this picture, and no error
|
||||
|
||||
|
||||
def test_real_sapiens2_finds_nobody_on_pictures_without_people():
|
||||
if s2.model_path("0.4b", download=False) is None:
|
||||
pytest.skip("Sapiens2 0.4b weights not on disk")
|
||||
data = pytest.importorskip("skimage.data")
|
||||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
bundle = s2.load("0.4b", device)
|
||||
try:
|
||||
for name in ("rocket", "coffee", "chelsea"): # a launch pad, a cup of coffee, a cat
|
||||
assert not bs.body_labels(bundle, np.ascontiguousarray(getattr(data, name)()[..., :3])).any(), name
|
||||
finally:
|
||||
s2.offload(bundle)
|
||||
@@ -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()
|
||||
|
||||
@@ -125,20 +125,39 @@ def test_people_labels_adds_people_found_by_their_faces():
|
||||
|
||||
|
||||
def test_a_crop_grows_when_its_people_run_past_a_cut_edge():
|
||||
image = np.full((400, 400, 3), 200, np.uint8)
|
||||
image[150:250, 100:300, 0] = 40
|
||||
image = np.full((800, 800, 3), 200, np.uint8)
|
||||
image[300:500, 200:600, 0] = 40
|
||||
shown = []
|
||||
|
||||
def predict(picture):
|
||||
shown.append(picture.shape[:2])
|
||||
person = picture[..., 0] == 40
|
||||
if len(shown) == 1:
|
||||
person[:, 160:] = False # the first look sees only the left part
|
||||
person[:, 320:] = False # the first look sees only the left part
|
||||
return np.where(person, 5, 0).astype(np.int64), np.full(picture.shape[:2], 0.9, np.float16)
|
||||
|
||||
labels = crops.people_labels(predict, image, 1.0)
|
||||
assert len(shown) >= 3 and shown[2][1] > shown[1][1] # parsed again, wider
|
||||
assert (labels[150:250, 100:300] == 5).mean() > 0.9
|
||||
assert (labels[300:500, 200:600] == 5).mean() > 0.9
|
||||
|
||||
|
||||
def test_people_labels_leaves_regions_too_small_to_parse():
|
||||
image = np.full((600, 900, 3), 200, np.uint8)
|
||||
image[100:500, 100:300, 0] = 40 # a person
|
||||
image[50:70, 700:730, 0] = 40 # a speck far from them: a crop of it would be blown up 20 times
|
||||
shown = []
|
||||
|
||||
def predict(picture):
|
||||
shown.append(picture.shape[:2])
|
||||
labels = np.where(picture[..., 0] == 40, 5, 0).astype(np.int64)
|
||||
if len(shown) == 1:
|
||||
labels[50:70, 700:730] = 7 # what the first look made of the speck
|
||||
return labels, np.full(picture.shape[:2], 0.9, np.float16)
|
||||
|
||||
labels = crops.people_labels(predict, image, 2 / 3)
|
||||
assert len(shown) >= 2 and all(height >= crops.MIN_CROP for height, _ in shown[1:])
|
||||
assert (labels[50:70, 700:730] == 7).all() # the speck keeps its first-look labels
|
||||
assert (labels[100:500, 100:300] == 5).all()
|
||||
|
||||
|
||||
def test_foreground_clusters_groups_people_standing_together():
|
||||
@@ -222,3 +241,52 @@ def test_model_path_finds_existing_weights_before_downloading(monkeypatch, tmp_p
|
||||
assert path.endswith(faces.YUNET["file"]) and downloads[0][1] == faces.YUNET["revision"]
|
||||
assert "LICENSE" in downloads[0][2]
|
||||
assert faces.model_path() == path and len(downloads) == 1 # found on disk the second time
|
||||
|
||||
|
||||
def test_people_labels_with_a_floor_finds_nobody_where_the_parser_is_unsure():
|
||||
image = np.zeros((60, 80, 3), np.uint8)
|
||||
image[10:50, 20:40, 0] = 3 * 8 # the class id is written into the red channel, so any crop parses the same
|
||||
confidence = [0.3]
|
||||
|
||||
def predict(picture):
|
||||
labels = np.rint(picture[..., 0] / 8.0).astype(np.int64)
|
||||
labels[labels != 3] = 0 # the grey padding of a crop is background
|
||||
return labels, np.full(picture.shape[:2], confidence[0], np.float16)
|
||||
|
||||
assert crops.people_labels(predict, image, 0.75)[10:50, 20:40].all() # without a floor: kept as it was
|
||||
assert not crops.people_labels(predict, image, 0.75, floor=0.5).any()
|
||||
with_face = crops.people_labels(predict, image, 0.75, people=[(22, 12, 30, 20)], floor=0.5)
|
||||
assert with_face[10:50, 20:40].all() # a face found: the people are there
|
||||
confidence[0] = 0.9
|
||||
assert crops.people_labels(predict, image, 0.75, floor=0.5)[10:50, 20:40].all() # sure without a face: a back
|
||||
assert not crops.people_labels(predict, np.zeros_like(image), 0.75, floor=0.5).any() # nothing labelled
|
||||
|
||||
|
||||
def test_the_floor_goes_by_the_share_the_parser_is_sure_of():
|
||||
image = np.zeros((60, 80, 3), np.uint8)
|
||||
image[10:50, 20:40, 0] = 3 * 8
|
||||
sure_rows = [16]
|
||||
|
||||
def predict(picture):
|
||||
labels = np.rint(picture[..., 0] / 8.0).astype(np.int64)
|
||||
labels[labels != 3] = 0
|
||||
sure = np.full(picture.shape[:2], 0.7, np.float16)
|
||||
sure[10:10 + sure_rows[0], 20:40] = 0.95
|
||||
return labels, sure
|
||||
|
||||
# 40% of the labelled pixels are sure: nobody, although the median confidence (0.7) is well over the floor
|
||||
assert not crops.people_labels(predict, image, 0.75, floor=0.5).any()
|
||||
sure_rows[0] = 24 # 60% sure: the people stay
|
||||
assert crops.people_labels(predict, image, 0.75, floor=0.5)[10:50, 20:40].all()
|
||||
|
||||
|
||||
def test_the_floor_needs_the_mirror_to_agree():
|
||||
image = np.zeros((60, 80, 3), np.uint8)
|
||||
|
||||
def predict(picture): # a sure "person" at the same place in any picture: invented, so the mirror misses it
|
||||
labels = np.zeros(picture.shape[:2], np.int64)
|
||||
labels[10:50, 5:25] = 3
|
||||
return labels, np.full(picture.shape[:2], 0.95, np.float16)
|
||||
|
||||
assert not crops.people_labels(predict, image, 0.75, floor=0.5).any()
|
||||
assert crops.people_labels(predict, image, 0.75)[10:50, 5:25].all() # without the floor: kept as it was
|
||||
|
||||
@@ -13,7 +13,7 @@ for path in (COMFY_ROOT, os.path.dirname(PACK_DIR)):
|
||||
|
||||
pack = importlib.import_module("NH-Nodes")
|
||||
|
||||
EXPECTED_NODE_COUNT = 75
|
||||
EXPECTED_NODE_COUNT = 76
|
||||
# Heavy libraries only some nodes need (VTON: onnxruntime, skimage; SAM 3: sam3, whose import also switches TF32
|
||||
# on process-wide); importing the pack must not load them.
|
||||
LAZY_ONLY = ("onnxruntime", "skimage", "sam3")
|
||||
|
||||
@@ -420,6 +420,8 @@ def _run_node(monkeypatch, concepts, misses=None, show_preview=False, frame=None
|
||||
def test_segment_node_lists_formulas_and_background(monkeypatch):
|
||||
(result, mask, count, report), calls, _ = _run_node(monkeypatch, "cat, dog\ncat")
|
||||
assert calls == [["cat", "dog"]] and count == 1 and int(mask.sum()) == 4 # a plain list: the union, as before
|
||||
assert result[0].picture.shape == (32, 32) # the print Segs Compose checks segs_2 and segs_3 against
|
||||
assert sam3_nodes.concepts_to_find(segs.parse_concepts("arm, arms, Arm"))[0] == ["arm"] # one search, not three
|
||||
assert sam3_nodes.NH_SAM3Segment.RETURN_NAMES == ("segs", "mask", "count", "report")
|
||||
(_, mask, _, _), calls, _ = _run_node(monkeypatch, "person - bag")
|
||||
assert calls == [["person", "bag"]] and int(mask.sum()) == 40 - 4
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import importlib
|
||||
import re
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
@@ -64,7 +65,7 @@ def test_label_matching_ignores_case_spacing_and_supports_wildcards():
|
||||
assert segs.label_matches("Red Car", "red_car")
|
||||
assert segs.label_matches("t-shirt", "*shirt")
|
||||
assert segs.label_matches("person", "pers?n")
|
||||
assert not segs.label_matches("person", "persons")
|
||||
assert segs.label_matches("person", "persons") and not segs.label_matches("person", "personal")
|
||||
|
||||
|
||||
# --- select ------------------------------------------------------------------
|
||||
@@ -371,3 +372,153 @@ def test_segs_select_reads_vietnamese_labels_but_not_formula_words():
|
||||
assert count == 1 and picked[0].segments[0].label == "hair"
|
||||
picked, _, count = segs_nodes.NH_SegsSelect().select((frame,), "tất cả", 0.0, 0.0, 1.0, "score", 0, 0)
|
||||
assert count == 0 # "tất cả" is the formula word all, not a label named "all"
|
||||
|
||||
|
||||
def test_merge_segs_mixes_instances_of_the_same_pictures():
|
||||
a = (_frame(_seg("person", 0.9, (0, 0, 10, 10))),)
|
||||
b = (_frame(_seg("dress", 1.0, (2, 2, 8, 8))),)
|
||||
merged = segs.merge_segs(a, None, b)
|
||||
assert [seg.label for seg in merged[0].segments] == ["person", "dress"] and segs.merge_segs(a) is a
|
||||
with pytest.raises(ValueError, match="different pictures"):
|
||||
segs.merge_segs(a, (_frame(height=20, width=20),))
|
||||
(mask,) = segs_nodes.NH_SegsCompose().compose(a, "person - dress", segs_2=b)
|
||||
assert int(mask.sum()) == 100 - 36
|
||||
|
||||
|
||||
def test_picture_prints_tell_photos_apart_but_not_edits_of_one():
|
||||
rng = np.random.default_rng(3)
|
||||
base = torch.from_numpy(cv2.GaussianBlur(rng.random((90, 120, 3)).astype(np.float32), (0, 0), 6))
|
||||
other = torch.from_numpy(cv2.GaussianBlur(rng.random((90, 120, 3)).astype(np.float32), (0, 0), 6))
|
||||
first = segs.picture_print(base)
|
||||
assert first.shape == (segs.PRINT_SIDE, segs.PRINT_SIDE) and first.dtype == np.float32
|
||||
for edited in (base * 1.3 + 0.1, base ** 2.0, base.mean(dim=2, keepdim=True).expand(-1, -1, 3),
|
||||
torch.cat([base, torch.ones(90, 120, 1)], dim=2)): # brighter, darker, grey, with alpha
|
||||
assert segs.same_picture(first, segs.picture_print(edited))
|
||||
assert not segs.same_picture(first, segs.picture_print(other))
|
||||
assert not segs.same_picture(first, segs.picture_print(base.flip(1))) # a mirrored copy would not line up
|
||||
flat = segs.picture_print(torch.full((90, 120, 3), 0.5))
|
||||
assert segs.same_picture(flat, first) and segs.same_picture(first, flat) # a blank picture tells nothing
|
||||
# numpy pictures (uint8 or float) give the same print up to scale
|
||||
assert segs.same_picture(first, segs.picture_print((base.numpy() * 255).astype(np.uint8)))
|
||||
|
||||
|
||||
def test_merge_segs_refuses_segs_of_different_photos_of_the_same_size():
|
||||
rng = np.random.default_rng(4)
|
||||
one, two = (torch.from_numpy(cv2.GaussianBlur(rng.random((40, 60, 3)).astype(np.float32), (0, 0), 4))[None]
|
||||
for _ in range(2))
|
||||
a = segs.stamped((_frame(_seg("person", 0.9, (0, 0, 10, 10))),), one)
|
||||
b = segs.stamped((_frame(_seg("dress", 1.0, (2, 2, 8, 8))),), one * 0.8) # the same photo, darker
|
||||
c = segs.stamped((_frame(_seg("bag", 1.0, (4, 4, 6, 6))),), two)
|
||||
assert a[0].picture is not None and a[0].with_segments([]).picture is a[0].picture
|
||||
assert segs.scale_frame(a[0], 0.5).picture is a[0].picture
|
||||
merged = segs.merge_segs(a, b, None)
|
||||
assert [seg.label for seg in merged[0].segments] == ["person", "dress"]
|
||||
with pytest.raises(ValueError, match="different photos of the same size"):
|
||||
segs.merge_segs(a, b, c)
|
||||
with pytest.raises(ValueError, match="different photos"):
|
||||
segs_nodes.NH_SegsCompose().compose(a, "person - bag", segs_2=c)
|
||||
# frames without a print (made by older nodes) are mixed on their size alone
|
||||
assert len(segs.merge_segs((_frame(_seg("cat", 1.0, (0, 0, 2, 2))),), c)[0].segments) == 2
|
||||
# batches: every image is checked, and the message says which one differs
|
||||
batch_a = segs.stamped(a + a, torch.cat([one, two]))
|
||||
batch_b = segs.stamped(b + b, torch.cat([one, one]))
|
||||
with pytest.raises(ValueError, match="image 2 of the batch"):
|
||||
segs.merge_segs(batch_a, batch_b)
|
||||
batch_c = segs.stamped(c + c, torch.cat([one, two]))
|
||||
merged = segs.merge_segs(batch_a, None, batch_c)
|
||||
assert [len(frame.segments) for frame in merged] == [2, 2]
|
||||
|
||||
|
||||
def test_picture_prints_take_any_image_dtype_and_keep_every_frame():
|
||||
picture = torch.rand(20, 30, 3)
|
||||
first = segs.picture_print(picture)
|
||||
for other in (picture.half(), picture.bfloat16(), picture.double().numpy(), picture.numpy().astype(np.float16)):
|
||||
assert segs.same_picture(first, segs.picture_print(other))
|
||||
frames = (_frame(height=20, width=30), _frame(height=20, width=30))
|
||||
assert segs.stamped(frames, picture[None]) == frames # one picture for two frames: left as they were
|
||||
|
||||
|
||||
def test_labels_match_singular_and_plural_but_wildcards_as_written():
|
||||
for label, pattern in (("arms", "arm"), ("arm", "Arms"), ("feet", "foot"), ("teeth", "tooth"),
|
||||
("accessories", "accessory"), ("shoes", "shoe"), ("dresses", "dress"), ("red cars", "red car")):
|
||||
assert segs.label_matches(label, pattern) and segs.label_matches(pattern, label)
|
||||
assert not segs.label_matches("dress", "dres") and not segs.label_matches("bus", "bu")
|
||||
assert segs.label_matches("cars", "car*") and not segs.label_matches("car", "cars*") # a wildcard is as written
|
||||
|
||||
|
||||
def test_a_node_s_own_words_name_the_parts_of_its_segs(monkeypatch):
|
||||
monkeypatch.setattr(segs, "_ALIASES", dict(segs._ALIASES))
|
||||
segs.add_aliases("test-parser", {"skin": {1, 2, 3}, "body": {1, 2, 3, 4}, "nothing": {9}},
|
||||
{"face": {1}, "arms": {2, 3}, "top": {4}})
|
||||
face = segs.make_segment("face", 1.0, torch.ones(2, 2, dtype=torch.bool), (0, 0), "test-parser")
|
||||
arms = segs.make_segment("arms", 1.0, torch.ones(2, 2, dtype=torch.bool), (4, 0), "test-parser")
|
||||
top = segs.make_segment("top", 1.0, torch.ones(2, 2, dtype=torch.bool), (8, 0), "test-parser")
|
||||
other = segs.make_segment("arms", 1.0, torch.ones(2, 2, dtype=torch.bool), (0, 4), "sam3")
|
||||
frame = segs.SegsFrame(8, 12, (face, arms, top, other))
|
||||
assert segs.matching(frame.segments, "skin") == [face, arms] # only that node's parts
|
||||
assert segs.matching(frame.segments, "arm") == [arms, other] # a label is found on every node's instances
|
||||
assert segs.matching(frame.segments, "bodies") == [face, arms, top] and segs.matching(frame.segments, "nothing") == []
|
||||
labelled_skin = segs.make_segment("skin", 0.9, torch.ones(2, 2, dtype=torch.bool), (0, 6), "sam3")
|
||||
# each node is read its own way: SAM 3's "skin" instance, and the test parser's skin parts
|
||||
assert segs.matching(frame.segments + (labelled_skin,), "skin") == [face, arms, labelled_skin]
|
||||
assert int(segs.evaluate(segs.parse_expression("skin - face"), frame).sum()) == 4
|
||||
(picked,) = segs.select_segments(frame, labels=("skin",)).segments[1:]
|
||||
assert picked is arms
|
||||
|
||||
|
||||
def test_the_photo_check_can_be_turned_off_and_a_nan_does_not_trip_it():
|
||||
rng = np.random.default_rng(5)
|
||||
one, two = (torch.from_numpy(cv2.GaussianBlur(rng.random((40, 60, 3)).astype(np.float32), (0, 0), 4))[None]
|
||||
for _ in range(2))
|
||||
a = segs.stamped((_frame(_seg("person", 0.9, (0, 0, 10, 10))),), one)
|
||||
c = segs.stamped((_frame(_seg("bag", 1.0, (4, 4, 6, 6))),), two)
|
||||
with pytest.raises(ValueError, match="turn check_same_photo off"):
|
||||
segs_nodes.NH_SegsCompose().compose(a, "person - bag", segs_2=c)
|
||||
(mask,) = segs_nodes.NH_SegsCompose().compose(a, "person - bag", segs_2=c, check_same_photo=False)
|
||||
assert int(mask.sum()) == 100 - 4
|
||||
spoiled = one.clone()
|
||||
spoiled[0, 5, 5, 1] = float("nan")
|
||||
assert segs.same_picture(a[0].picture, segs.picture_print(spoiled[0]))
|
||||
optional = segs_nodes.NH_SegsCompose.INPUT_TYPES()["optional"]
|
||||
assert optional["check_same_photo"][1]["default"] is True
|
||||
|
||||
|
||||
def test_compose_decides_node_by_node_and_a_part_keeps_its_name(monkeypatch):
|
||||
monkeypatch.setattr(segs, "_ALIASES", dict(segs._ALIASES))
|
||||
segs.add_aliases("body-like", {"skin": {1, 2}, "tay": {2, 5}}, {"face": {1}, "arms": {2}, "hands": {5}, "top": {3}})
|
||||
segs.add_aliases("garment-like", {"dress": {7}, "upper garment": {6, 7}}, {"top": {6}, "dress": {7}})
|
||||
assert ("garment-like", "dress") not in segs._ALIASES # the name of one of its parts
|
||||
|
||||
def seg(label, x, source):
|
||||
return segs.make_segment(label, 1.0, torch.ones(1, 1, dtype=torch.bool), (x, 0), source)
|
||||
|
||||
face, arms, hands, top = (seg(label, x, "body-like") for x, label in enumerate(("face", "arms", "hands", "top")))
|
||||
skin, garment_top, sam_arm = seg("skin", 4, "portrait-like"), seg("top", 5, "garment-like"), seg("arm", 6, "sam3")
|
||||
frame = segs.SegsFrame(1, 8, (face, arms, hands, top, skin, garment_top, sam_arm))
|
||||
assert segs.matching(frame.segments, "skin") == [face, arms, skin] # one node's label hides no other node's parts
|
||||
assert segs.matching(frame.segments, "dress") == [] # no dress anywhere: nothing stands in for it
|
||||
assert segs.matching(frame.segments, "upper garment") == [garment_top]
|
||||
assert segs.matching(frame.segments, "arm") == [arms, sam_arm]
|
||||
assert segs.matching(frame.segments, "arm", "tay") == [arms, hands, sam_arm] # a node's own reading of "tay"
|
||||
assert segs.evaluate(segs.parse_expression("tay"), frame).numpy().tolist() == [
|
||||
[False, True, True, False, False, False, True, False]]
|
||||
|
||||
|
||||
def test_plural_exceptions():
|
||||
assert not segs.label_matches("glass", "glasses") and segs.label_matches("Glasses", "glasses")
|
||||
for plural, single in (("scarves", "scarf"), ("calves", "calf"), ("hoodies", "hoodie"), ("leaves", "leaf"),
|
||||
("movies", "movie"), ("ties", "tie")):
|
||||
assert segs.label_matches(plural, single) and segs.label_matches(single, plural)
|
||||
|
||||
|
||||
def test_a_concept_in_singular_and_plural_is_one_report_line():
|
||||
frame = _frame(_seg("arm", 0.8, (0, 0, 4, 4)))
|
||||
lines = segs.describe_request(segs.parse_concepts("arm, arms"), (frame,), 0.5, {}, ())
|
||||
assert lines == ["arm: 1 found (best 0.80)"]
|
||||
|
||||
|
||||
def test_labels_with_accents_typed_apart_are_the_same_label():
|
||||
import unicodedata
|
||||
|
||||
assert segs.label_matches("chân", unicodedata.normalize("NFD", "chân"))
|
||||
assert segs.normalize_label(unicodedata.normalize("NFD", "Tóc_Dài")) == "tóc dài"
|
||||
|
||||
@@ -64,16 +64,16 @@ def test_garment_build_mask_respects_attach_parts_flag():
|
||||
labels[2:10, 2:18] = 1 # shirt
|
||||
labels[10:12, 4:8] = 33 # pocket touching shirt
|
||||
labels[16:19, 16:19] = 36 # zipper elsewhere
|
||||
with_parts = vs.NH_GarmentSegment.build_mask(labels, "upper_garment", True)
|
||||
without_parts = vs.NH_GarmentSegment.build_mask(labels, "upper_garment", False)
|
||||
with_parts = vs.NH_GarmentSegment.build_mask(labels, "upper_garment", True, model="fashionpedia")
|
||||
without_parts = vs.NH_GarmentSegment.build_mask(labels, "upper_garment", False, model="fashionpedia")
|
||||
assert with_parts[10:12, 4:8].all() and with_parts[16:19, 16:19].sum() == 0
|
||||
assert without_parts[10:12, 4:8].sum() == 0
|
||||
assert vs.NH_GarmentSegment.build_mask(labels, "footwear").sum() == 0
|
||||
assert vs.NH_GarmentSegment.build_mask(labels, "footwear", model="fashionpedia").sum() == 0
|
||||
|
||||
|
||||
def test_all_garments_includes_clothing_groups_and_excludes_unassigned_accessories():
|
||||
labels = np.arange(47).reshape(1, 47)
|
||||
mask = vs.NH_GarmentSegment.build_mask(labels, "all_garments", False)
|
||||
mask = vs.NH_GarmentSegment.build_mask(labels, "all_garments", False, model="fashionpedia")
|
||||
expected_ids = set(range(1, 14)) | {15, 16, 17, 20, 21, 22, 23, 24, 26, 28, 29, 30, 31, 32, 34, 35}
|
||||
assert set(np.flatnonzero(mask)) == expected_ids
|
||||
|
||||
@@ -82,7 +82,7 @@ def test_all_garments_combines_shirt_and_pants_in_mask_and_cutout():
|
||||
labels = np.zeros((8, 8), np.int64)
|
||||
labels[1:3, 2:6] = 1 # shirt
|
||||
labels[4:7, 2:6] = 7 # pants
|
||||
mask = vs.NH_GarmentSegment.build_mask(labels, "all_garments", False)
|
||||
mask = vs.NH_GarmentSegment.build_mask(labels, "all_garments", False, model="fashionpedia")
|
||||
assert np.array_equal(mask, (labels != 0).astype(np.uint8))
|
||||
image = np.full((8, 8, 3), 0.5, np.float32)
|
||||
mask_t, rgba, mask_image = vs.pack_outputs(image, mask)
|
||||
@@ -99,7 +99,7 @@ def test_all_garments_attaches_parts_to_upper_and_lower_only_when_enabled(attach
|
||||
labels[6:8, 2:4] = 33 # pocket touching shirt
|
||||
labels[16:18, 2:4] = 36 # zipper touching pants
|
||||
labels[10:12, 24:26] = 38 # detached decoration
|
||||
mask = vs.NH_GarmentSegment.build_mask(labels, "all_garments", attach_parts)
|
||||
mask = vs.NH_GarmentSegment.build_mask(labels, "all_garments", attach_parts, model="fashionpedia")
|
||||
assert mask[2:6, 2:6].all() and mask[12:16, 2:6].all()
|
||||
assert bool(mask[6:8, 2:4].all()) == attach_parts
|
||||
assert bool(mask[16:18, 2:4].all()) == attach_parts
|
||||
@@ -190,14 +190,26 @@ def test_nodes_end_to_end_with_stub_backend(monkeypatch):
|
||||
monkeypatch.setattr(vs, "detect_faces", lambda img: []) # no face: Portrait Segment parses the whole frame
|
||||
image = torch.rand(2, 8, 8, 3)
|
||||
|
||||
mask, rgba, mask_img = vs.NH_GarmentSegment().segment(image, "lower_garment", True, 0, 0, True, "cpu")
|
||||
mask, rgba, mask_img, segs = vs.NH_GarmentSegment().segment(image, "lower_garment", True, 0, 0, True, "cpu",
|
||||
"fashionpedia")
|
||||
assert mask.shape == (2, 8, 8) and rgba.shape == (2, 8, 8, 4) and mask_img.shape == (2, 8, 8, 3)
|
||||
assert mask[0, 4:, :].all() and mask[0, :4, :].sum() == 0
|
||||
assert [seg.label for seg in segs[0].segments] == ["top", "pants"] and len(segs) == 2 # Fashionpedia names
|
||||
# each frame keeps a print of its own picture, so Segs Compose can refuse segs of another photo
|
||||
assert all(frame.picture.shape == (32, 32) for frame in segs)
|
||||
assert not importlib.import_module("NH-Nodes.core.segs").same_picture(segs[0].picture, segs[1].picture)
|
||||
|
||||
mask, rgba, mask_img = vs.NH_GarmentSegment().segment(image, "all_garments", True, 0, 0, False, "cpu")
|
||||
mask, rgba, mask_img, _ = vs.NH_GarmentSegment().segment(image, "all_garments", True, 0, 0, False, "cpu",
|
||||
"fashionpedia")
|
||||
assert mask.all() and rgba[:, :, :, 3].all() and mask_img.all()
|
||||
|
||||
mask, _, _ = vs.NH_PortraitSegment().segment(image, 0, 0, False, "cpu", nose=True, face=False)
|
||||
# FASHN (the default): 2 is hair and 7 a belt there, so only the belt is a garment
|
||||
mask, _, _, segs = vs.NH_GarmentSegment().segment(image, "all_garments", True, 0, 0, False, "cpu")
|
||||
assert mask[0, 4:, :].all() and mask[0, :4, :].sum() == 0
|
||||
assert [seg.label for seg in segs[0].segments] == ["hair", "belt"]
|
||||
|
||||
mask, _, _, segs = vs.NH_PortraitSegment().segment(image, 0, 0, False, "cpu", nose=True, face=False)
|
||||
assert [seg.label for seg in segs[0].segments] == ["nose", "eyebrows"] # CelebAMask-HQ: 2 nose, 7 right brow
|
||||
assert mask.shape == (2, 8, 8)
|
||||
assert mask[0, :4, :].all() and mask[0, 4:, :].sum() == 0 # label 2 is nose in the face model
|
||||
|
||||
@@ -299,7 +311,7 @@ def test_garment_labels_refine_a_crop_of_the_model_shape_around_the_clothes(monk
|
||||
shown.append(img.shape[:2])
|
||||
labels = np.zeros(img.shape[:2], np.int64)
|
||||
if len(shown) == 1: # the first look at the whole picture: the clothes, under another label, unsure
|
||||
labels[50:150, 120:180] = 3
|
||||
labels[100:300, 240:360] = 3
|
||||
return labels, np.full(img.shape[:2], 0.5, np.float16)
|
||||
labels[np.asarray(img)[..., 0] == 9] = 5 # the close look
|
||||
return labels, np.full(img.shape[:2], 0.9, np.float16)
|
||||
@@ -307,12 +319,12 @@ def test_garment_labels_refine_a_crop_of_the_model_shape_around_the_clothes(monk
|
||||
monkeypatch.setattr(vs, "predict_labels", parser)
|
||||
monkeypatch.setattr(vs, "detect_faces", lambda img: [])
|
||||
bundle = {"processor": types.SimpleNamespace(size={"height": 576, "width": 384})}
|
||||
image = np.zeros((200, 300, 3), np.uint8)
|
||||
image[50:150, 120:180, 0] = 9
|
||||
image = np.zeros((400, 600, 3), np.uint8)
|
||||
image[100:300, 240:360, 0] = 9
|
||||
canvas = vs.garment_labels(bundle, image)
|
||||
crop_h, crop_w = shown[1]
|
||||
assert shown[0] == (200, 300) and abs(crop_w / crop_h - 384 / 576) < 0.02 # the model's 2:3, not squeezed
|
||||
assert (canvas[50:150, 120:180] == 5).all() and set(np.unique(canvas)) == {0, 5}
|
||||
assert shown[0] == (400, 600) and abs(crop_w / crop_h - 384 / 576) < 0.02 # the model's 2:3, not squeezed
|
||||
assert (canvas[100:300, 240:360] == 5).all() and set(np.unique(canvas)) == {0, 5}
|
||||
|
||||
shown.clear()
|
||||
monkeypatch.setattr(vs, "predict_labels", lambda bundle, img, confidence=False: shown.append(1) or (
|
||||
@@ -321,9 +333,14 @@ def test_garment_labels_refine_a_crop_of_the_model_shape_around_the_clothes(monk
|
||||
|
||||
|
||||
def test_input_types_expose_expected_widgets():
|
||||
garment = vs.NH_GarmentSegment.INPUT_TYPES()["required"]
|
||||
assert garment["part"][0] == ["upper_garment", "lower_garment", "footwear", "headwear", "all_garments"]
|
||||
spec = vs.NH_GarmentSegment.INPUT_TYPES()
|
||||
garment = spec["required"]
|
||||
assert garment["part"][0] == ["upper_garment", "lower_garment", "footwear", "headwear", "all_garments", "dress",
|
||||
"bag", "belt", "scarf", "glasses", "jewelry"]
|
||||
assert garment["part"][1]["default"] == "upper_garment"
|
||||
# the model choice is optional, so prompts saved before it existed still validate
|
||||
assert spec["optional"]["model"][0] == ["fashn", "fashionpedia"] and spec["optional"]["model"][1]["default"] == "fashn"
|
||||
assert vs.NH_GarmentSegment.RETURN_NAMES[-1] == "segs" and vs.NH_PortraitSegment.RETURN_NAMES[-1] == "segs"
|
||||
portrait = vs.NH_PortraitSegment.INPUT_TYPES()["required"]
|
||||
for region in ("face", "neck", "hair", "eyes", "lips", "nose", "eyebrows", "ears"):
|
||||
assert portrait[region][1]["default"] is True
|
||||
@@ -331,7 +348,7 @@ def test_input_types_expose_expected_widgets():
|
||||
|
||||
|
||||
# --- real model smoke test (skipped without weights) -------------------------
|
||||
@pytest.mark.parametrize("model_name", ["segformer_fashion", "face_parsing"])
|
||||
@pytest.mark.parametrize("model_name", ["segformer_fashion", "face_parsing", "fashn_human_parser"])
|
||||
def test_real_model_smoke(model_name):
|
||||
if backend.resolve_model_dir(model_name, download=False) is None:
|
||||
pytest.skip(f"{model_name} weights not on disk")
|
||||
@@ -361,3 +378,136 @@ def test_predict_labels_caps_logit_size_and_restores_input_size(monkeypatch):
|
||||
monkeypatch.setattr(backend, "_MAX_LOGIT_SIDE", 16)
|
||||
labels = backend.predict_labels(bundle, np.zeros((40, 30, 3), np.uint8))
|
||||
assert labels.shape == (40, 30) and labels.dtype == np.int64
|
||||
|
||||
|
||||
def test_fashn_parts_map_to_its_classes():
|
||||
labels = np.array([[3, 4, 5, 6], [7, 8, 9, 10], [11, 15, 17, 2]])
|
||||
build = vs.NH_GarmentSegment.build_mask
|
||||
assert build(labels, "upper_garment", model="fashn").tolist() == [[1, 1, 0, 0], [0, 0, 0, 1], [0, 0, 0, 0]]
|
||||
assert build(labels, "lower_garment", model="fashn").tolist() == [[0, 0, 1, 1], [1, 0, 0, 0], [0, 0, 0, 0]]
|
||||
# footwear is FASHN's feet, and all_garments has it, as with Fashionpedia's shoes
|
||||
assert build(labels, "footwear", model="fashn")[2, 1] == 1 and build(labels, "all_garments", model="fashn")[2, 1] == 1
|
||||
assert build(labels, "all_garments").tolist() == build(labels, "all_garments", model="fashn").tolist() # default
|
||||
for part, value in (("dress", 4), ("bag", 8), ("headwear", 9), ("glasses", 11), ("jewelry", 17)):
|
||||
assert build(labels, part, model="fashn").sum() == 1 and build(labels, part, model="fashn")[labels == value].all()
|
||||
# the same parts on Fashionpedia ids; it has no jewelry class
|
||||
fashionpedia = np.array([[11, 25, 20, 26], [14, 12, 35, 1]])
|
||||
assert build(fashionpedia, "dress", model="fashionpedia").tolist() == [[1, 0, 0, 0], [0, 1, 0, 0]]
|
||||
assert build(fashionpedia, "belt", model="fashionpedia").tolist() == [[0, 0, 1, 0], [0, 0, 1, 0]]
|
||||
assert not build(fashionpedia, "jewelry", model="fashionpedia").any()
|
||||
|
||||
|
||||
def test_parts_frame_matches_the_enlarged_label_map_pixel_for_pixel():
|
||||
rng = np.random.default_rng(0)
|
||||
work = rng.integers(0, 4, (13, 17))
|
||||
rows, cols = vs._nearest_index(50, 13), vs._nearest_index(61, 17)
|
||||
full = work[np.ix_(rows, cols)]
|
||||
frame = vs.parts_frame(work, {"a": {1}, "bc": {2, 3}, "none": {9}}, (50, 61))
|
||||
assert [seg.label for seg in frame.segments] == ["a", "bc"] and (frame.height, frame.width) == (50, 61)
|
||||
for seg, ids in zip(frame.segments, ({1}, {2, 3})):
|
||||
canvas = np.zeros((50, 61), bool)
|
||||
x1, y1, x2, y2 = seg.box
|
||||
canvas[y1:y2, x1:x2] = seg.mask.numpy()
|
||||
assert (canvas == np.isin(full, list(ids))).all()
|
||||
|
||||
|
||||
def test_labels_at_work_size_shrinks_big_frames_only(monkeypatch):
|
||||
shown = []
|
||||
monkeypatch.setattr(vs, "WORK_SIDE", 64)
|
||||
|
||||
def labeler(bundle, picture):
|
||||
shown.append(picture.shape[:2])
|
||||
return np.ones(picture.shape[:2], np.int64)
|
||||
|
||||
full, work = vs.labels_at_work_size(labeler, {}, np.zeros((100, 200, 3), np.float32))
|
||||
assert shown == [(32, 64)] and full.shape == (100, 200) and work.shape == (32, 64)
|
||||
full, work = vs.labels_at_work_size(labeler, {}, np.zeros((30, 40, 3), np.float32))
|
||||
assert full is work and full.shape == (30, 40)
|
||||
|
||||
|
||||
def test_garment_jewelry_with_fashionpedia_says_why_it_is_empty(monkeypatch, capsys):
|
||||
monkeypatch.setattr(vs, "load_segformer", lambda name, device: {"name": name})
|
||||
monkeypatch.setattr(vs, "predict_labels", lambda bundle, img, confidence=False: (
|
||||
np.zeros(img.shape[:2], np.int64), np.ones(img.shape[:2], np.float16)) if confidence else np.zeros(
|
||||
img.shape[:2], np.int64))
|
||||
monkeypatch.setattr(vs, "offload", lambda bundle: None)
|
||||
monkeypatch.setattr(vs, "detect_faces", lambda img: [])
|
||||
mask, _, _, _ = vs.NH_GarmentSegment().segment(torch.rand(1, 8, 8, 3), "jewelry", True, 0, 0, False, "cpu",
|
||||
"fashionpedia")
|
||||
assert not mask.any() and "no jewelry class" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_parts_frame_is_exact_for_small_and_border_parts():
|
||||
rng = np.random.default_rng(1)
|
||||
parts = {"a": {1}, "b": {2, 3}, "corner": {5}, "other corner": {6}, "missing": {9}}
|
||||
for small_h, small_w, height, width in ((13, 17, 50, 61), (9, 7, 9, 7), (20, 30, 41, 33), (5, 8, 160, 97)):
|
||||
work = np.zeros((small_h, small_w), np.int64)
|
||||
work[rng.integers(0, small_h, 4), rng.integers(0, small_w, 4)] = rng.integers(1, 4, 4)
|
||||
work[0, -1], work[-1, 0] = 5, 6 # one pixel each, on the border
|
||||
full = work[np.ix_(vs._nearest_index(height, small_h), vs._nearest_index(width, small_w))]
|
||||
frame = vs.parts_frame(work, parts, (height, width))
|
||||
assert [seg.label for seg in frame.segments] == [
|
||||
name for name, ids in parts.items() if np.isin(work, list(ids)).any()]
|
||||
for seg in frame.segments:
|
||||
wanted = np.isin(full, list(parts[seg.label]))
|
||||
canvas = np.zeros((height, width), bool)
|
||||
x1, y1, x2, y2 = seg.box
|
||||
canvas[y1:y2, x1:x2] = seg.mask.numpy()
|
||||
ys, xs = np.nonzero(wanted)
|
||||
assert (canvas == wanted).all() and seg.box == (xs.min(), ys.min(), xs.max() + 1, ys.max() + 1)
|
||||
# class ids past a byte take the slower comparison, with the same result
|
||||
work = np.array([[0, 300], [7, 300]])
|
||||
(seg,) = vs.parts_frame(work, {"big": {300}}, (4, 4)).segments
|
||||
assert seg.box == (2, 0, 4, 4) and seg.mask.all()
|
||||
|
||||
|
||||
def test_fashn_gets_the_area_resize_it_was_trained_with(monkeypatch):
|
||||
shown = []
|
||||
|
||||
class _Logits:
|
||||
def __init__(self, t):
|
||||
self.logits = t
|
||||
|
||||
class _Model:
|
||||
device = torch.device("cpu")
|
||||
|
||||
def __call__(self, **kw):
|
||||
return _Logits(torch.zeros(1, 3, 8, 8))
|
||||
|
||||
class _Processor:
|
||||
def __init__(self, height, width):
|
||||
self.size = {"height": height, "width": width}
|
||||
|
||||
def __call__(self, images, return_tensors):
|
||||
shown.append(images.shape[:2])
|
||||
return {"pixel_values": torch.zeros(1)}
|
||||
|
||||
monkeypatch.setattr(backend, "_ensure_on_device", lambda b: None)
|
||||
fashn = {"processor": _Processor(576, 384), "model": _Model(), "name": "fashn_human_parser"}
|
||||
backend.predict_labels(fashn, np.zeros((800, 500, 3), np.uint8)) # between 1x and 2x its size
|
||||
backend.predict_labels(fashn, np.zeros((300, 200, 3), np.uint8)) # smaller than its size
|
||||
face = {"processor": _Processor(512, 512), "model": _Model(), "name": "face_parsing"}
|
||||
backend.predict_labels(face, np.zeros((700, 700, 3), np.uint8)) # under 2x: the processor resizes it
|
||||
assert shown == [(576, 384), (576, 384), (700, 700)]
|
||||
|
||||
|
||||
def test_parser_segs_carry_their_model_and_answer_to_its_words(monkeypatch):
|
||||
segs_core = importlib.import_module("NH-Nodes.core.segs")
|
||||
work = np.array([[3, 4, 10, 5], [6, 7, 12, 1]]) # FASHN: top, dress, scarf, skirt / pants, belt, arms, face
|
||||
frame = vs.parts_frame(work, vs.FASHN_SEGS, (2, 4), "fashn_human_parser")
|
||||
assert {seg.source for seg in frame.segments} == {"fashn_human_parser"}
|
||||
upper = segs_core.evaluate(segs_core.parse_expression("upper garment"), frame).numpy()
|
||||
assert upper.tolist() == [[True, True, True, False], [False, False, False, False]]
|
||||
lower = segs_core.evaluate(segs_core.parse_expression("lower_garment"), frame).numpy()
|
||||
assert lower.tolist() == [[False, False, False, True], [True, True, False, False]]
|
||||
face = vs.parts_frame(np.array([[1, 4, 13, 17]]), vs.PORTRAIT_SEGS, (1, 4), "face_parsing")
|
||||
assert segs_core.evaluate(segs_core.parse_expression("face"), face).numpy().tolist() == [[True, True, False, False]]
|
||||
for formula in ("face skin", "da mặt"): # Portrait's skin part
|
||||
assert segs_core.evaluate(segs_core.parse_expression(formula), face).numpy().tolist() == [
|
||||
[True, False, False, False]]
|
||||
|
||||
|
||||
def test_image_batches_of_any_float_type_are_read():
|
||||
for dtype in (torch.float32, torch.float16, torch.bfloat16):
|
||||
(picture,) = vs._image_batch_to_numpy(torch.full((1, 3, 4, 3), 0.5, dtype=dtype))
|
||||
assert picture.dtype == np.float32 and picture.shape == (3, 4, 3) and np.allclose(picture, 0.5)
|
||||
|
||||